commit 03b982e7c27bbe085f452a0b13399be48fb24edd Author: Christian Krakau-Louis Date: Tue May 13 08:59:02 2025 +0000 Initial clean commit diff --git a/.env.demo b/.env.demo new file mode 100644 index 0000000..e5d4518 --- /dev/null +++ b/.env.demo @@ -0,0 +1,63 @@ +# Quizzical Beats - Environment Variables Demo File +# Copy this file to .env and replace with your actual values + +# Debug settings +DEBUG=True +DEBUG2=False +SECRET_KEY=your-secret-key-here-make-it-long-and-random + +# API Keys +OPENAI_API_KEY=your-openai-api-key +OPENAI_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini +OPENAI_SEARCH_MODEL=gpt-4o-mini-search-preview + +# Translation and language services +DEEPL_API_KEY=your-deepl-api-key +MEANINGCLOUD_API_KEY=your-meaningcloud-api-key + +# Audio services +ELEVENLABS_API_KEY=your-elevenlabs-api-key +ACRCLOUD_TOKEN=your-acrcloud-token + +# Music APIs +LASTFM_API_KEY=your-lastfm-api-key + +# Database configuration +SQLALCHEMY_DATABASE_URI=sqlite:///data/song_data.db +SQLALCHEMY_TRACK_MODIFICATIONS=False + +# Spotify API configuration +SPOTIFY_CLIENT_ID=your-spotify-client-id +SPOTIFY_CLIENT_SECRET=your-spotify-client-secret +SPOTIFY_REDIRECT_URI=http://localhost:5000/auth/spotify/callback + +# Deezer API configuration +DEEZER_APP_ID=your-deezer-app-id +DEEZER_APP_SECRET=your-deezer-app-secret +DEEZER_REDIRECT_URI=http://localhost:5000/deezer-callback + +# OAuth providers +# Google OAuth configuration +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret + +# Authentik OAuth configuration +AUTHENTIK_CLIENT_ID=your-authentik-client-id +AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret +AUTHENTIK_METADATA_URL=https://authentik.example.com/.well-known/openid-configuration + +# Dropbox OAuth configuration +DROPBOX_APP_KEY=your-dropbox-app-key +DROPBOX_APP_SECRET=your-dropbox-app-secret +DROPBOX_REDIRECT_URI=http://localhost:5000/users/dropbox/callback + +# Email configuration +MAIL_HOST=smtp.example.com +MAIL_PORT=587 +MAIL_USE_TLS=True +MAIL_USE_SSL=False +MAIL_USERNAME=your-email-username +MAIL_PASSWORD=your-email-password +MAIL_SENDER=quizzical-beats@example.com +MAIL_RECIPIENT=admin@example.com \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..048bb9d --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,98 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '30 21 * * *' + push: + branches: [ "main" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15201ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d86b87b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim + +# Set the working directory to /app +WORKDIR /app + +# Install system dependencies and Node.js +RUN apt-get update && apt-get install -y \ + libavcodec-extra \ + ffmpeg \ + curl \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Make the entrypoint script executable +RUN chmod +x docker-entrypoint.sh + +# Create necessary directories and ensure proper permissions +RUN mkdir -p /data && chmod 777 /data + +# Set environment variables +ENV PYTHONPATH=/app +ENV FLASK_APP=run.py + +# Use the entrypoint script +ENTRYPOINT ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e5e7d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Christian Krakau-Louis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aaa07d --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# MusicRound + +**MusicRound** is a Flask-based web application for building engaging music rounds for pub quizzes. Leveraging the Spotify API, it allows you to generate rounds based on the least-used genres, decades, or completely random criteria, making your quizzes dynamic and entertaining. + +--- + +## Features + +- **Spotify Integration**: Import songs and playlists directly from Spotify using their API. +- **Dynamic Round Creation**: + - Randomly generated songs. + - Based on least-used genres or decades. + - Unique and diverse song selections. +- **Preview and Export**: + - Include Spotify preview links in rounds. + - Export rounds as printable **PDFs** and playable **MP3s**. +- **Last.fm Integration**: Automatically enrich tracks with genre metadata. +- **Email Delivery**: Email generated quiz rounds to the designated recipient. + +--- + +## Getting Started + +### Prerequisites + +- **Python**: Version 3.6 or higher. +- **Spotify Developer Account**: [Create a Spotify Developer App](https://developer.spotify.com/dashboard/applications) to retrieve your client ID and secret. +- **Last.fm API Key**: Sign up at [Last.fm](https://www.last.fm/api) to obtain an API key. + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/christianlouis/musicround.git + cd musicround + ``` + +2. Create a virtual environment: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Set up environment variables in a `.env` file: + ```env + SPOTIFY_CLIENT_ID=your_spotify_client_id + SPOTIFY_CLIENT_SECRET=your_spotify_client_secret + SPOTIFY_REDIRECT_URI=http://localhost:5000/callback + LASTFM_API_KEY=your_lastfm_api_key + ``` + +5. Initialize the SQLite database: + ```bash + python + >>> from app import db + >>> db.create_all() + >>> exit() + ``` + +6. Start the application: + ```bash + python app.py + ``` + +7. Open your browser and navigate to `http://localhost:5000`. + +--- + +## APIs Used + +- **Spotify API**: + - Used to import songs, playlists, and retrieve song metadata. + - [API Documentation](https://developer.spotify.com/documentation/web-api/) + +- **Last.fm API**: + - Enriches tracks with genre information. + - [API Documentation](https://www.last.fm/api) + +### Provided APIs + +**MusicRound** also provides APIs to fetch data from the application. For example: + +- `GET /rounds`: Fetch all rounds created. +- `POST /rounds`: Create a new round using specified criteria. +- `GET /songs`: Retrieve all songs in the database. + +For detailed API usage, refer to the in-app documentation or inspect the routes in `app.py`. + +--- + +## Changelog + +### Version 1.0 +- Initial release. +- Features: + - Spotify and Last.fm integration. + - Random, genre-based, and decade-based round generation. + - PDF and MP3 export functionality. + - Email delivery of rounds. + +--- + +## Project Structure + +``` +musicround/ +├── app.py # Main application logic +├── config.py # Configuration settings +├── templates/ # HTML templates for rendering views +├── static/ # Static files (CSS, JS) +├── requirements.txt # Python dependencies +├── instance/ # SQLite database folder +├── mp3/ # Audio files for MP3 generation +├── pdf_reports/ # Generated PDF reports +├── README.md # Project documentation +└── rounds/ # MP3 cache for quiz rounds +``` + +--- + +## License + +This project is licensed under the **MIT License**. See `LICENSE` for details. + +--- + +## Contributing + +We welcome contributions! To contribute: + +1. Fork the repository. +2. Create a feature branch: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Commit your changes: + ```bash + git commit -m "Add your feature description" + ``` +4. Push the branch: + ```bash + git push origin feature/your-feature-name + ``` +5. Open a pull request. + +--- + +## Contact + +- **Developer**: Christian Krakau-Louis +- **Email**: [christian@kaufdeinquiz.com](mailto:christian@kaufdeinquiz.com) +- **GitHub**: [christianlouis](https://github.com/christianlouis) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3b767b5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,166 @@ +# Quizzical Beats TODO List + +## ✅ Completed Milestones + +### Milestone 1: Spotify Integration Fix (Immediate Priority) + +* [x] Fix Spotify playlist import functionality + + * [x] Implement proper pagination for playlist retrieval + * [x] Add better error handling for API rate limits + * [x] Refactor Spotify client code for maintainability + * [x] Add logging to track API requests and responses for debugging + +### Milestone 2: Authentication Foundation + +* [x] Design database schema for users and roles +* [x] Implement basic authentication system with local username/password +* [x] Create user management interfaces (register, login, profile) +* [x] Set up admin role functionality +* [x] Implement secure password handling and session management + +### Milestone 3: Spotify Integration with User Accounts + +* [x] Migrate Spotify token storage to user-specific model +* [x] Add Spotify OAuth login option +* [x] Create fallback mechanism for service account +* [x] Link user playlists with their Spotify accounts + +### Milestone 4: Enhanced User Experience + +* [x] Enable user-specific intro/outro/replay MP3s +* [x] Update email system to use logged-in user's email +* [x] Implement user preferences and settings + +### Milestone 5: Additional OAuth Providers + +* [x] Add Google OAuth integration +* [x] Add Authentik OAuth integration +* [x] Ensure consistent user experience across auth methods + +### Milestone 6: Advanced Features & Optimizations + +* [x] Create comprehensive logging and monitoring + +### Milestone 7: **"Bulletproof Backups" Release** – System Backup & Restore + +* [x] Implement full system-wide backup and restore + + * [x] Backup all critical data: DB, rounds, MP3s, user settings + * [x] Support both manual and scheduled backups + * [x] Admin UI to download, manage, and restore backups + * [x] Store backups to local filesystem or optional cloud locations + * [x] Include versioning to support future schema migration compatibility + * [x] Add internal backup verification/checksum logic + * [x] Integrate with Ofelia scheduler for automatic backups + * [x] Add command-line backup functionality for scripting + * [x] Implement retention policy for automatic cleanup of old backups +* [x] Add system health check and status dashboard + +### Milestone 8: **"Dropbox Dispatch" Release** – Round Export via Dropbox + +* [x] Add Dropbox OAuth login per user +* [x] Let users link/unlink and view Dropbox account info +* [x] Export full rounds (metadata + MP3s) as ZIP or PDF +* [x] Push selected rounds to user's Dropbox via UI +* [x] Add fallback handling and Dropbox access token refresh +* [x] Log export actions and errors for transparency + +--- + +## 🆕 Upcoming Milestones + +### 🎯 Milestone 9: **"Storage Sanctuary" Release** – Multi-Provider Storage + +* [ ] Implement cloud storage backend abstraction +* [ ] Add support for AWS S3 storage + * [ ] Configure S3 credentials and bucket management + * [ ] Support for optional encryption and lifecycle policies +* [ ] Add support for S3-compatible storage (MinIO, Wasabi, etc.) +* [ ] Integrate Dropbox as a storage backend +* [ ] Create unified storage management UI +* [ ] Export backups to cloud storage +* [ ] Store and retrieve music rounds from cloud storage +* [ ] Add background synchronization and status tracking +* [ ] Implement bandwidth-efficient differential uploads + +### 🎯 Milestone 10: **"Rhythm Roundsmith" Release** – AI-Generated Quiz Rounds + +* [ ] Develop AI module to generate full quiz rounds + + * [ ] Use existing song metadata (genre, year, tempo, artist) + * [ ] Support different quiz formats: multiple-choice, guess-the-clip, open-ended + * [ ] Create quiz rounds from a playlist + * [ ] Let users prompt AI with a theme or vibe (e.g., "Chill 80s", "Dancefloor Divas") + * [ ] Include fallback logic when metadata is sparse +* [ ] Let users review/edit AI-generated questions before saving +* [ ] Log prompt/response pairs for model improvement +* [ ] Add backend abstraction to swap AI providers (OpenAI, Mistral, etc.) +* [ ] Build tuning pipeline for prompt quality testing + +### 🎯 Milestone 11: **"Collaboration Core" Release** – Multi-User Round Sharing + +* [ ] Allow shared editing of rounds +* [ ] Add collaboration roles (view, comment, edit) +* [ ] Invite others to rounds via username/email +* [ ] Show who is currently editing (presence indicator) +* [ ] Track revision history and changes +* [ ] Allow public view-only sharing links with optional expiration +* [ ] Display access audit log (who opened/edited and when) + +### 🎯 Milestone 12: **"Profile Personalizer" Release** – User Preferences & Tagging + +* [x] User-specific intro/outro/replay MP3 fallback system +* [x] Persistent custom user settings +* [ ] Let users define default round format +* [ ] Implement personal tags for rounds (e.g., "pub night", "2020s", "pop") +* [ ] Add filtering and sorting by tag +* [ ] Dark mode toggle + +### 🎯 Milestone 13: **"Scraper Symphony" Release** – External Music Data + +* [ ] Identify 2–3 public music chart sources (Billboard, Official Charts, etc.) +* [ ] Build scraper with user-agent rotation and proxy support +* [ ] Normalize results into song data model +* [ ] Link scraped data to existing Spotify records +* [ ] Review interface for admins to validate scraped data +* [ ] Store scraper runs and log errors transparently +* [ ] Add cron-based scheduler for scraper refresh + +### 🎯 Milestone 14: **"Alert Amplifier" Release** – Notifications & Emails + +* [ ] Email verification for new accounts +* [ ] Notify users when round generation completes +* [ ] Notify users of expiring OAuth tokens (Spotify, Dropbox) +* [ ] Optional weekly usage summary for admins +* [ ] Push notification support via browser or Telegram + +### 🎯 Milestone 15: **"Performance Pulse" Release** – Scaling & Speed + +* [ ] Index high-traffic database fields (tags, dates, users) +* [ ] Paginate round/song lists +* [ ] Lazy load MP3 previews and large content +* [ ] Add Redis/memory cache layer for read-heavy endpoints +* [ ] Load test with simulated users and large playlists + +### 🎯 Milestone 16: **"Deployment Dynamo" Release** – CI/CD and Maintenance + +* [ ] GitHub Actions or GitLab CI/CD pipeline for builds and tests +* [ ] Nightly backup job with status alert +* [ ] Add Sentry or similar for exception tracking +* [ ] Updater script for pulling latest Git commits safely +* [ ] Add status endpoint (`/healthz`) for uptime monitors + +--- + +## Other Features / Ideas in Progress + +* [ ] "Blind Test" toggle per round (hide title/artist metadata) +* [ ] Team scoreboard (live projection mode) +* [ ] Public round library with clone/save features +* [ ] REST API for third-party integration (e.g., with trivia bots) +* [ ] Audio fingerprint validation for user-uploaded MP3s +* [ ] Round analytics: usage frequency, popularity, ratings + +*Last updated: May 8, 2025* + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1d2753 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + web: + build: . + ports: + - "5000:5000" + volumes: + - ./musicround:/app/musicround + - ./templates:/app/templates + - ./static:/app/static + - ./migrations:/app/migrations + - ./.env:/app/.env + - musicround_data:/data + labels: + ofelia.enabled: "true" + ofelia.job-exec.backup.schedule: "@hourly" + ofelia.job-exec.backup.command: "python /app/run.py backup create --auto" + ofelia.job-exec.backup.no-overlap: "true" + ofelia.job-exec.retention.schedule: "@weekly" + ofelia.job-exec.retention.command: "python /app/run.py backup retention --days 13" + ofelia.job-exec.retention.no-overlap: "true" + env_file: + - .env + environment: + - FLASK_APP=run.py + - FLASK_ENV=development # Using the older style for compatibility + - FLASK_DEBUG=1 + - SQLALCHEMY_DATABASE_URI=sqlite:///data/song_data.db + - PYTHONUNBUFFERED=1 + # Hot reloading specific settings + - FLASK_RUN_EXTRA_FILES=./templates:/app/templates,./static:/app/static + - FLASK_RUN_HOST=0.0.0.0 + - FLASK_RUN_PORT=5000 + - PYTHONDONTWRITEBYTECODE=1 + # Email configuration variables + - MAIL_HOST=${MAIL_HOST} + - MAIL_PORT=${MAIL_PORT} + - MAIL_USERNAME=${MAIL_USERNAME} + - MAIL_PASSWORD=${MAIL_PASSWORD} + - MAIL_SENDER=${MAIL_SENDER} + - MAIL_RECIPIENT=${MAIL_RECIPIENT} + restart: unless-stopped + # Updated command with explicit extra-files and reload arguments + command: python -m flask run --host=0.0.0.0 --port=5000 --reload --extra-files ./templates:/app/templates,./static:/app/static + + scheduler: + image: mcuadros/ofelia:latest + depends_on: + - web + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + command: daemon --docker + labels: + ofelia.job-local.my-test-job.schedule: "@hourly" + ofelia.job-local.my-test-job.command: "date" + +volumes: + musicround_data: + driver: local \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..3d7b430 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +# Run the OAuth migration script first +echo "Running database migration for OAuth providers..." +python run_migration.py + +# Start the Flask application with better error reporting +echo "Starting Flask application..." +export PYTHONUNBUFFERED=1 +export FLASK_DEBUG=1 +echo "Flask environment: $FLASK_ENV" +echo "Database URI: $SQLALCHEMY_DATABASE_URI" +echo "Database path: $DATABASE_PATH" +echo "Available environment variables:" +env | grep -v PASSWORD | grep -v SECRET + +# Use flask run with explicit reload for better hot reloading +export PYTHONFAULTHANDLER=1 +export PYTHONDONTWRITEBYTECODE=1 + +echo "Starting Flask development server with hot reload..." +exec python -m flask run --host=0.0.0.0 --port=5000 --reload --debug || { + echo "Flask application failed to start. Error details:" + python -c "import traceback; traceback.print_exc()" + exit 1 +} \ No newline at end of file diff --git a/docs/brand_identity.md b/docs/brand_identity.md new file mode 100644 index 0000000..067dc2f --- /dev/null +++ b/docs/brand_identity.md @@ -0,0 +1,72 @@ +# 🎧 Quizzical Beats – Brand Identity + +## 🧠 Tagline + +**Primary:** +> *"Where trivia meets the rhythm."* + +**Alternate options:** +- "The soundtrack to your smartest guesses." +- "Play the quiz. Feel the beat." + +--- + +## 🎯 Mission Statement + +*Quizzical Beats empowers quiz hosts to create unforgettable music rounds that blend knowledge and rhythm. We make music-based trivia engaging, easy to set up, and endlessly fun for players of all ages.* + +--- + +## 🌍 Purpose Statement + +*We aim to make pub quizzes more interactive and exciting by elevating the music round. Whether you're a professional quizmaster or a casual host, Quizzical Beats provides the tools to bring sound, style, and smarts together.* + +--- + +## 🎨 Color Scheme + +| Color | Usage | Hex Code | Description | +|------------------|----------------------------|-----------|-----------------------------| +| Deep Navy Blue | Primary text/logo | `#1A237E` | Bold, trustworthy, smart | +| Vibrant Teal | Accents/highlights | `#00ACC1` | Energetic, fresh, modern | +| Bright Orange | Call-to-actions/buttons | `#FF7043` | Playful, attention-grabbing | +| Light Gray | Background/secondary UI | `#F5F5F5` | Clean, soft, minimalist | +| Dark Gray | Text/subtle UI elements | `#212121` | Contrast, structure | + +--- + +## 🖋️ Typography + +**Primary Font:** Montserrat +> Modern, geometric sans-serif font with excellent readability. + +**Secondary Font:** Open Sans +> Humanist sans-serif for body copy and UI elements. + +--- + +## 🖼️ Visual Assets + +- ✅ **Logo:** Stylized note and magnifying glass, symbolizing *music + discovery* +- ✅ **Textlogo:** “Quizzical Beats” in bold *Montserrat* +- ✅ **Monogram:** Clean *"QB"* + musical-symbol monogram + +--- + +## 🔧 Optional Brand Assets + +Would you like any of the following? + +- [ ] Favicon pack (.ico, .png, .svg) +- [ ] Social media banners (e.g., Twitter/X, YouTube, Instagram) +- [ ] Brand style guide (PDF) +- [ ] Pitch deck slide template or Keynote theme + +--- + +## 🔊 Voice & Tone + +- Friendly and playful +- Confident but not arrogant +- Fun, musical, and clever +- Inclusive for all trivia lovers diff --git a/docs/oauth_callback_urls.md b/docs/oauth_callback_urls.md new file mode 100644 index 0000000..4cb5440 --- /dev/null +++ b/docs/oauth_callback_urls.md @@ -0,0 +1,106 @@ +# OAuth Integration Callback URLs + +This document provides information about the OAuth callback URLs used in Quizzical Beats for various authentication providers. + +## Overview + +When configuring OAuth providers (Google, Authentik, Spotify, Dropbox), you need to set up redirect/callback URLs in each provider's developer console. These URLs tell the provider where to send users after they authenticate. + +## Callback URLs by Provider + +### Google OAuth + +**Callback URL:** `https://your-domain.com/users/login/google/callback` +**Local Development:** `http://localhost:5000/users/login/google/callback` + +When configuring Google OAuth in the Google Cloud Console: +1. Go to "APIs & Services" > "Credentials" +2. Create or edit an OAuth 2.0 Client ID +3. Add the above URLs to the "Authorized redirect URIs" section + +### Authentik OAuth + +**Callback URL:** `https://your-domain.com/users/login/authentik/callback` +**Local Development:** `http://localhost:5000/users/login/authentik/callback` + +When configuring Authentik: +1. Create an OAuth2/OIDC Provider +2. Add the above URLs to the "Redirect URIs" field +3. Ensure the scopes include "openid", "profile", and "email" + +### Spotify API + +**Callback URL:** `https://your-domain.com/users/spotify-callback` +**Local Development:** `http://localhost:5000/users/spotify-callback` + +When configuring Spotify in the Spotify Developer Dashboard: +1. Go to your app's settings +2. Add the above URLs to the "Redirect URIs" section +3. Save the changes + +### Dropbox API + +**Callback URL:** `https://your-domain.com/users/dropbox-callback` +**Local Development:** `http://localhost:5000/users/dropbox-callback` + +When configuring Dropbox in the Dropbox Developer Console: +1. Go to your app's settings in the [Dropbox App Console](https://www.dropbox.com/developers/apps) +2. Under "OAuth 2", add the above URLs to the "Redirect URIs" section +3. Make sure you've selected the correct permission scopes: + - `files.content.read` + - `files.content.write` + - `sharing.write` + - `offline_access` (for refresh tokens) +4. Set the app status to "Production" if it's still in development mode +5. In the "Permissions" tab, ensure all required scopes are selected + +## Environment Variables + +Make sure to update the following environment variables in your `.env` file to match your configured callback URLs: + +``` +# For Google OAuth +GOOGLE_REDIRECT_URI=http://localhost:5000/users/login/google/callback + +# For Authentik OAuth +AUTHENTIK_REDIRECT_URI=http://localhost:5000/users/login/authentik/callback + +# For Spotify API +SPOTIFY_REDIRECT_URI=http://localhost:5000/users/spotify-callback + +# For Dropbox API +# DROPBOX_REDIRECT_URI=http://localhost:5000/users/dropbox-callback +# Note: The Dropbox URL is automatically generated using Flask's url_for function +``` + +In production, update these URLs to use your actual domain. + +## Additional Notes + +1. Make sure your application is properly configured to handle these callback routes. +2. For security, always use HTTPS URLs in production environments. +3. When testing locally with HTTP, some providers may require you to explicitly allow HTTP redirects for development. +4. If you're using Docker or other containerization, ensure your application is accessible at the configured URLs. +5. Dropbox requires the app to be in "Production" mode for non-developers to use it. + +## Troubleshooting + +If you encounter OAuth errors such as "invalid_redirect_uri" or "redirect_uri_mismatch": + +1. Verify that the callback URL is exactly the same in both your OAuth provider configuration and your application code. +2. Check that the protocol (http vs https) matches what's configured. +3. Ensure there are no trailing slashes unless specifically required. +4. For development behind NAT/firewalls, you may need to use a service like ngrok to create a public URL. +5. Check that the response data format matches what your code expects. For Google OAuth, ensure your code is accessing fields correctly (Google uses 'sub' for user IDs rather than 'id'). +6. Enable debug logging to inspect the full OAuth response payload to identify missing or incorrectly named fields. +7. Verify that your OAuth scopes in the provider configuration match the scopes requested in your application code. +8. Test with a minimal set of scopes first, then add more as needed once the basic flow works. + +### Dropbox-Specific Troubleshooting + +If you see "This app is not valid" error from Dropbox: +1. Make sure your app is configured in the [Dropbox App Console](https://www.dropbox.com/developers/apps) +2. Verify that your app key and app secret in your config match what's in the Dropbox console +3. Ensure your redirect URI is correctly registered in the Dropbox console +4. Check if your app needs to be in "Production" mode (it may be in development mode) +5. Verify that you've selected all required permission scopes in the Dropbox console \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..460e978 Binary files /dev/null and b/favicon.ico differ diff --git a/migrations/add_dropbox_export_path.py b/migrations/add_dropbox_export_path.py new file mode 100644 index 0000000..0b0b8cd --- /dev/null +++ b/migrations/add_dropbox_export_path.py @@ -0,0 +1,78 @@ +"""Add dropbox_export_path column to User model + +Revision ID: add_dropbox_export_path +Revises: f83a512b9c47 +Create Date: 2025-05-08 +""" +import logging +from sqlalchemy import text, inspect + +def run_migration(): + """ + Add dropbox_export_path column to the User table if it doesn't exist. + """ + try: + from musicround import db + changes_made = False + try: + inspector = inspect(db.engine) + existing_columns = [column['name'] for column in inspector.get_columns('user')] + with db.engine.connect() as conn: + if 'dropbox_export_path' not in existing_columns: + conn.execute(text('ALTER TABLE user ADD COLUMN dropbox_export_path TEXT')) + conn.commit() + changes_made = True + except Exception as e: + logging.error(f"Error in migration: {str(e)}") + return False + if changes_made: + logging.info("Migration completed successfully") + return True + else: + logging.info("No changes were needed") + return None + except ImportError: + import sqlite3 + import os + logging.info("Falling back to direct SQLite connection") + try: + db_path = os.environ.get('DATABASE_PATH', '/data/song_data.db') + if not os.path.exists(db_path): + possible_paths = [ + './instance/musicround.db', + '/app/instance/musicround.db', + '/data/song_data.db', + './song_data.db', + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'instance', 'musicround.db') + ] + for path in possible_paths: + if os.path.exists(path): + db_path = path + break + else: + logging.warning(f"Database not found at any of the possible paths") + return False + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(user)") + columns = [column[1] for column in cursor.fetchall()] + if 'dropbox_export_path' not in columns: + cursor.execute("ALTER TABLE user ADD COLUMN dropbox_export_path TEXT") + conn.commit() + conn.close() + logging.info("Migration completed successfully") + return True + else: + conn.close() + logging.info("Column already exists: dropbox_export_path") + return None + except Exception as e: + logging.error(f"Migration add_dropbox_export_path failed: {str(e)}") + import traceback + logging.error(traceback.format_exc()) + return False + +if __name__ == "__main__": + import logging + logging.basicConfig(level=logging.INFO) + run_migration() diff --git a/migrations/add_dropbox_oauth.py b/migrations/add_dropbox_oauth.py new file mode 100644 index 0000000..1a104a5 --- /dev/null +++ b/migrations/add_dropbox_oauth.py @@ -0,0 +1,185 @@ +"""Add Dropbox OAuth fields to User model + +Revision ID: f83a512b9c47 +Revises: e7c912b4d835 +Create Date: 2025-05-08 14:30:45.891234 + +""" +from alembic import op +import sqlalchemy as sa +import logging +from sqlalchemy import text, inspect +from datetime import datetime + +# revision identifiers, used by Alembic. +revision = 'f83a512b9c47' +down_revision = 'e7c912b4d835' # previous migration was add_tag_system +branch_labels = None +depends_on = None + +logger = logging.getLogger(__name__) + +def upgrade(): + # Add Dropbox OAuth columns to user table + op.add_column('user', sa.Column('dropbox_id', sa.String(100), nullable=True)) + op.add_column('user', sa.Column('dropbox_token', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('dropbox_refresh_token', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('dropbox_token_expiry', sa.DateTime(), nullable=True)) + +def downgrade(): + # Remove Dropbox OAuth columns + op.drop_column('user', 'dropbox_id') + op.drop_column('user', 'dropbox_token') + op.drop_column('user', 'dropbox_refresh_token') + op.drop_column('user', 'dropbox_token_expiry') + +def run_migration(): + """ + Add Dropbox OAuth fields to the User table if they don't exist. + """ + try: + from musicround import db + + # Track changes made + changes_made = False + + try: + # Connect to the database + inspector = inspect(db.engine) + existing_columns = [column['name'] for column in inspector.get_columns('user')] + + # Use connection for executing SQL statements + with db.engine.connect() as conn: + # Add Dropbox OAuth columns + if 'dropbox_id' not in existing_columns: + logger.info("Adding dropbox_id column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN dropbox_id VARCHAR(100)')) + conn.commit() + changes_made = True + logger.info("Added dropbox_id column") + except Exception as e: + logger.error(f"Error adding dropbox_id column: {str(e)}") + + if 'dropbox_token' not in existing_columns: + logger.info("Adding dropbox_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN dropbox_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added dropbox_token column") + except Exception as e: + logger.error(f"Error adding dropbox_token column: {str(e)}") + + if 'dropbox_refresh_token' not in existing_columns: + logger.info("Adding dropbox_refresh_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN dropbox_refresh_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added dropbox_refresh_token column") + except Exception as e: + logger.error(f"Error adding dropbox_refresh_token column: {str(e)}") + + if 'dropbox_token_expiry' not in existing_columns: + logger.info("Adding dropbox_token_expiry column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN dropbox_token_expiry DATETIME')) + conn.commit() + changes_made = True + logger.info("Added dropbox_token_expiry column") + except Exception as e: + logger.error(f"Error adding dropbox_token_expiry column: {str(e)}") + + except Exception as e: + logger.error(f"Error in migration: {str(e)}") + return False # Return False for errors + + # Report results + if changes_made: + logger.info("Migration completed successfully") + return True # Changes were made successfully + else: + logger.info("No changes were needed") + return None # No changes were needed (database is already up to date) + + except ImportError: + # If we can't import the db object, fall back to SQLite direct connection + import sqlite3 + import os + + logger.info("Falling back to direct SQLite connection") + + # Try to get the database path from environment or standard locations + try: + # Try environment variable + db_path = os.environ.get('DATABASE_PATH', '/data/song_data.db') + + # If that doesn't exist, try other possible paths + if not os.path.exists(db_path): + possible_paths = [ + './instance/musicround.db', + '/app/instance/musicround.db', + '/data/song_data.db', + './song_data.db', + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'instance', 'musicround.db') + ] + + for path in possible_paths: + if os.path.exists(path): + db_path = path + logger.info(f"Found database at: {db_path}") + break + else: + logger.warning(f"Database not found at any of the possible paths") + return False + + # Connect to the database + logger.info(f"Connecting to database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get existing columns + cursor.execute("PRAGMA table_info(user)") + columns = [column[1] for column in cursor.fetchall()] + + # Define the new columns to add + new_columns = [ + ("dropbox_id", "VARCHAR(100)"), + ("dropbox_token", "TEXT"), + ("dropbox_refresh_token", "TEXT"), + ("dropbox_token_expiry", "DATETIME") + ] + + # Add each column if it doesn't exist + changes_made = False + for column_name, column_type in new_columns: + if column_name not in columns: + sql = f"ALTER TABLE user ADD COLUMN {column_name} {column_type}" + cursor.execute(sql) + logger.info(f"Added column: {column_name} {column_type}") + changes_made = True + else: + logger.info(f"Column already exists: {column_name}") + + # Commit changes and close connection + conn.commit() + conn.close() + + if changes_made: + logger.info("Migration completed successfully") + return True + else: + logger.info("No changes were needed") + return None + + except Exception as e: + logger.error(f"Migration add_dropbox_oauth failed: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False + +if __name__ == "__main__": + # Set up logging when run directly + logging.basicConfig(level=logging.INFO) + run_migration() \ No newline at end of file diff --git a/migrations/add_oauth_providers.py b/migrations/add_oauth_providers.py new file mode 100644 index 0000000..03156dd --- /dev/null +++ b/migrations/add_oauth_providers.py @@ -0,0 +1,158 @@ +""" +Migration script to add Google and Authentik OAuth fields to the User table +""" +from datetime import datetime +import logging +from sqlalchemy import text, inspect + +logger = logging.getLogger(__name__) + +def run_migration(): + """ + Add new columns to the User table for Google and Authentik OAuth integration + Returns: + - True: if changes were made successfully + - None: if no changes were needed (already up to date) + - False: if errors occurred + """ + from musicround import db + + # Track changes made + changes_made = False + + try: + # Connect to the database + inspector = inspect(db.engine) + existing_columns = [column['name'] for column in inspector.get_columns('user')] + + # Use connection for executing SQL statements + with db.engine.connect() as conn: + # Add auth_provider column if it doesn't exist + if 'auth_provider' not in existing_columns: + logger.info("Adding auth_provider column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN auth_provider VARCHAR(20)')) + # Set default value for existing rows + conn.execute(text("UPDATE user SET auth_provider = 'local' WHERE auth_provider IS NULL")) + conn.commit() + changes_made = True + logger.info("Added auth_provider column") + except Exception as e: + logger.error(f"Error adding auth_provider column: {str(e)}") + + # Add Google OAuth columns + if 'google_id' not in existing_columns: + logger.info("Adding google_id column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN google_id VARCHAR(100)')) + conn.commit() + changes_made = True + logger.info("Added google_id column") + except Exception as e: + logger.error(f"Error adding google_id column: {str(e)}") + + if 'google_token' not in existing_columns: + logger.info("Adding google_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN google_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added google_token column") + except Exception as e: + logger.error(f"Error adding google_token column: {str(e)}") + + if 'google_refresh_token' not in existing_columns: + logger.info("Adding google_refresh_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN google_refresh_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added google_refresh_token column") + except Exception as e: + logger.error(f"Error adding google_refresh_token column: {str(e)}") + + # Add Authentik OAuth columns + if 'authentik_id' not in existing_columns: + logger.info("Adding authentik_id column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN authentik_id VARCHAR(100)')) + conn.commit() + changes_made = True + logger.info("Added authentik_id column") + except Exception as e: + logger.error(f"Error adding authentik_id column: {str(e)}") + + if 'authentik_token' not in existing_columns: + logger.info("Adding authentik_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN authentik_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added authentik_token column") + except Exception as e: + logger.error(f"Error adding authentik_token column: {str(e)}") + + if 'authentik_refresh_token' not in existing_columns: + logger.info("Adding authentik_refresh_token column") + try: + conn.execute(text('ALTER TABLE user ADD COLUMN authentik_refresh_token TEXT')) + conn.commit() + changes_made = True + logger.info("Added authentik_refresh_token column") + except Exception as e: + logger.error(f"Error adding authentik_refresh_token column: {str(e)}") + + # Make password_hash nullable for OAuth-only users + try: + # Due to SQLite limitations, we need to recreate the table to change column nullability + # Check if it's already nullable + is_nullable = False + result = conn.execute(text("PRAGMA table_info('user')")) + columns_info = result.fetchall() + + for col in columns_info: + if col[1] == 'password_hash' and col[3] == 0: # 0 means nullable + is_nullable = True + break + + if not is_nullable: + logger.info("Modifying password_hash to be nullable") + # Get all column definitions + columns = [] + for col_info in columns_info: + name = col_info[1] + type_name = col_info[2] + not_null = "NOT NULL" if col_info[3] == 1 and name != "password_hash" else "" + pk = "PRIMARY KEY" if col_info[5] == 1 else "" + columns.append(f"{name} {type_name} {pk} {not_null}".strip()) + + # Create a temporary table with the new schema + column_defs = ", ".join(columns) + conn.execute(text(f'CREATE TABLE user_temp ({column_defs})')) + + # Copy data from the old table + conn.execute(text('INSERT INTO user_temp SELECT * FROM user')) + + # Replace the old table + conn.execute(text('DROP TABLE user')) + conn.execute(text('ALTER TABLE user_temp RENAME TO user')) + conn.commit() + + changes_made = True + logger.info("Made password_hash column nullable for OAuth-only users") + else: + logger.info("password_hash is already nullable") + except Exception as e: + logger.error(f"Error modifying password_hash column: {str(e)}") + + except Exception as e: + logger.error(f"Error in migration: {str(e)}") + return False # Return False for errors + + # Report results + if changes_made: + logger.info("Migration completed successfully") + return True # Changes were made successfully + else: + logger.info("No changes were needed") + return None # No changes were needed (database is already up to date) \ No newline at end of file diff --git a/migrations/add_preview_urls.py b/migrations/add_preview_urls.py new file mode 100644 index 0000000..380b17f --- /dev/null +++ b/migrations/add_preview_urls.py @@ -0,0 +1,126 @@ +"""Add multiple preview URLs and cover URLs to Song model + +Revision ID: d82c9a4f1e56 +Revises: a7cb4e9f8d21 +Create Date: 2023-06-11 14:23:45.678901 + +""" +from alembic import op +import sqlalchemy as sa +import sqlite3 +import os +import logging + +# revision identifiers, used by Alembic. +revision = 'd82c9a4f1e56' +down_revision = 'a7cb4e9f8d21' # replace with your previous migration id +branch_labels = None +depends_on = None + +def upgrade(): + # Add new columns for preview URLs from different sources + op.add_column('song', sa.Column('spotify_preview_url', sa.String(255), nullable=True)) + op.add_column('song', sa.Column('deezer_preview_url', sa.String(255), nullable=True)) + op.add_column('song', sa.Column('apple_preview_url', sa.String(255), nullable=True)) + op.add_column('song', sa.Column('youtube_preview_url', sa.String(255), nullable=True)) + + # Add new columns for cover URLs from different services + op.add_column('song', sa.Column('spotify_cover_url', sa.String(255), nullable=True)) + op.add_column('song', sa.Column('deezer_cover_url', sa.String(255), nullable=True)) + op.add_column('song', sa.Column('apple_cover_url', sa.String(255), nullable=True)) + + # Add a column for additional data as JSON + op.add_column('song', sa.Column('additional_data', sa.Text(), nullable=True)) + +def downgrade(): + # Remove the new columns + op.drop_column('song', 'spotify_preview_url') + op.drop_column('song', 'deezer_preview_url') + op.drop_column('song', 'apple_preview_url') + op.drop_column('song', 'youtube_preview_url') + op.drop_column('song', 'spotify_cover_url') + op.drop_column('song', 'deezer_cover_url') + op.drop_column('song', 'apple_cover_url') + op.drop_column('song', 'additional_data') + +def run_migration(): + """ + Add platform-specific preview URL columns to the song table if they don't exist. + """ + logger = logging.getLogger(__name__) + logger.info("Running migration: add_preview_urls") + + # Try to get the database path from Flask config first + try: + from flask import current_app + if current_app and current_app.config.get('SQLALCHEMY_DATABASE_URI'): + # Extract path from URI + db_uri = current_app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri[10:] # Remove 'sqlite:///' + logger.info(f"Got database path from Flask config: {db_path}") + else: + # Use DATABASE_PATH if available + db_path = current_app.config.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH: {db_path}") + else: + # If Flask isn't running, try Docker standard path + db_path = '/data/song_data.db' + logger.info(f"Flask not available, using default path: {db_path}") + except Exception as e: + # Fallbacks in case Flask isn't available + logger.warning(f"Could not get path from Flask: {str(e)}") + + # Try environment variable + db_path = os.environ.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH from environment: {db_path}") + + if not os.path.exists(db_path): + logger.warning(f"Database not found at {db_path}") + return False + + try: + # Connect to the database + logger.info(f"Connecting to database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get existing columns + cursor.execute("PRAGMA table_info(song)") + columns = [column[1] for column in cursor.fetchall()] + + # Define the new columns to add + new_columns = [ + ("spotify_preview_url", "VARCHAR(500)"), + ("deezer_preview_url", "VARCHAR(500)"), + ("apple_preview_url", "VARCHAR(500)"), + ("youtube_preview_url", "VARCHAR(500)"), + ("spotify_cover_url", "VARCHAR(500)"), + ("deezer_cover_url", "VARCHAR(500)"), + ("apple_cover_url", "VARCHAR(500)"), + ("additional_data", "TEXT") + ] + + # Add each column if it doesn't exist + for column_name, column_type in new_columns: + if column_name not in columns: + sql = f"ALTER TABLE song ADD COLUMN {column_name} {column_type}" + cursor.execute(sql) + logger.info(f"Added column: {column_name} {column_type}") + else: + logger.info(f"Column already exists: {column_name}") + + # Commit changes and close connection + conn.commit() + conn.close() + logger.info("Migration add_preview_urls completed successfully") + return True + + except Exception as e: + logger.error(f"Migration add_preview_urls failed: {str(e)}") + return False + +if __name__ == "__main__": + # Set up logging when run directly + logging.basicConfig(level=logging.INFO) + run_migration() diff --git a/migrations/add_song_fields.py b/migrations/add_song_fields.py new file mode 100644 index 0000000..5cc68e6 --- /dev/null +++ b/migrations/add_song_fields.py @@ -0,0 +1,124 @@ +"""Add new fields to Song model + +Revision ID: a7cb4e9f8d21 +Revises: previous_revision_id +Create Date: 2023-06-10 12:34:56.789012 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'a7cb4e9f8d21' +down_revision = 'previous_revision_id' # replace with your previous migration id +branch_labels = None +depends_on = None + +def upgrade(): + # Add new columns to the song table + op.add_column('song', sa.Column('isrc', sa.String(50), nullable=True)) + op.add_column('song', sa.Column('album_name', sa.String(100), nullable=True)) + op.add_column('song', sa.Column('metadata_sources', sa.String(100), nullable=True)) + op.add_column('song', sa.Column('import_date', sa.DateTime, nullable=True)) + + # Create index for ISRC + op.create_index(op.f('ix_song_isrc'), 'song', ['isrc'], unique=False) + + # Increase length of existing URL columns + op.alter_column('song', 'preview_url', type_=sa.String(255)) + op.alter_column('song', 'cover_url', type_=sa.String(255)) + +def downgrade(): + # Remove the new columns + op.drop_index(op.f('ix_song_isrc'), table_name='song') + op.drop_column('song', 'isrc') + op.drop_column('song', 'album_name') + op.drop_column('song', 'metadata_sources') + op.drop_column('song', 'import_date') + + # Restore original column lengths + op.alter_column('song', 'preview_url', type_=sa.String(200)) + op.alter_column('song', 'cover_url', type_=sa.String(200)) + +def run_migration(): + """ + Add additional song fields to the song table if they don't exist. + """ + import sqlite3 + import os + import logging + + logger = logging.getLogger(__name__) + logger.info("Running migration: add_song_fields") + + # Try to get the database path from Flask config first + try: + from flask import current_app + if current_app and current_app.config.get('SQLALCHEMY_DATABASE_URI'): + # Extract path from URI + db_uri = current_app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri[10:] # Remove 'sqlite:///' + logger.info(f"Got database path from Flask config: {db_path}") + else: + # Use DATABASE_PATH if available + db_path = current_app.config.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH: {db_path}") + else: + # If Flask isn't running, try Docker standard path + db_path = '/data/song_data.db' + logger.info(f"Flask not available, using default path: {db_path}") + except Exception as e: + # Fallbacks in case Flask isn't available + logger.warning(f"Could not get path from Flask: {str(e)}") + + # Try environment variable + db_path = os.environ.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH from environment: {db_path}") + + if not os.path.exists(db_path): + logger.warning(f"Database not found at {db_path}") + return False + + try: + # Connect to the database + logger.info(f"Connecting to database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get existing columns + cursor.execute("PRAGMA table_info(song)") + columns = [column[1] for column in cursor.fetchall()] + + # Define the new columns to add + new_columns = [ + ("album_name", "VARCHAR(200)"), + ("metadata_sources", "VARCHAR(500)"), + ("import_date", "DATETIME"), + ("source", "VARCHAR(20)") + ] + + # Add each column if it doesn't exist + for column_name, column_type in new_columns: + if column_name not in columns: + sql = f"ALTER TABLE song ADD COLUMN {column_name} {column_type}" + cursor.execute(sql) + logger.info(f"Added column: {column_name} {column_type}") + else: + logger.info(f"Column already exists: {column_name}") + + # Commit changes and close connection + conn.commit() + conn.close() + logger.info("Migration add_song_fields completed successfully") + return True + + except Exception as e: + logger.error(f"Migration add_song_fields failed: {str(e)}") + return False + +if __name__ == "__main__": + # Set up logging when run directly + import logging + logging.basicConfig(level=logging.INFO) + run_migration() diff --git a/migrations/add_spotify_audio_features.py b/migrations/add_spotify_audio_features.py new file mode 100644 index 0000000..080ed9c --- /dev/null +++ b/migrations/add_spotify_audio_features.py @@ -0,0 +1,116 @@ +""" +Migration script to add Spotify audio features to the Song model +""" +import sqlite3 +import os +import logging +from flask import current_app + +def run_migration(): + """ + Add Spotify audio features columns to the song table if they don't exist. + This is safe to run multiple times as it checks for column existence. + """ + logger = logging.getLogger(__name__) + logger.info("Running migration: add_spotify_audio_features") + + # Try to get the database path from Flask app config + db_path = None + + # First check if we can get the path from Flask current_app + if current_app: + # Get the database URI from Flask's config + db_uri = current_app.config.get('SQLALCHEMY_DATABASE_URI') + if db_uri and db_uri.startswith('sqlite:///'): + # Extract the path from the URI + db_path = db_uri.replace('sqlite:///', '') + logger.info(f"Got database path from Flask config: {db_path}") + + # If we couldn't get the path from Flask, try the known locations + if not db_path or not os.path.exists(db_path): + # Docker container path based on app configuration in __init__.py + data_dir = '/data' + db_path = os.path.join(data_dir, 'song_data.db') # Path used in Flask app config + + # If that doesn't exist, try other possible paths + if not os.path.exists(db_path): + possible_paths = [ + './instance/musicround.db', + '/app/instance/musicround.db', + '/data/song_data.db', + './song_data.db', + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'instance', 'musicround.db') + ] + + for path in possible_paths: + if os.path.exists(path): + db_path = path + logger.info(f"Found database at: {db_path}") + break + else: + logger.warning(f"Database not found at any of the possible paths") + return False + + try: + # Connect to the database + logger.info(f"Connecting to database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get existing columns + cursor.execute("PRAGMA table_info(song)") + columns = [column[1] for column in cursor.fetchall()] + + logger.info(f"Current columns in the song table: {columns}") + + # Define the new columns to add + new_columns = [ + ("acousticness", "FLOAT"), + ("danceability", "FLOAT"), + ("energy", "FLOAT"), + ("instrumentalness", "FLOAT"), + ("key", "INTEGER"), + ("liveness", "FLOAT"), + ("loudness", "FLOAT"), + ("mode", "INTEGER"), + ("speechiness", "FLOAT"), + ("tempo", "FLOAT"), + ("time_signature", "INTEGER"), + ("valence", "FLOAT"), + ("duration_ms", "INTEGER"), + ("analysis_url", "VARCHAR(500)") + ] + + # Add each column if it doesn't exist + columns_added = 0 + for column_name, column_type in new_columns: + if column_name not in columns: + sql = f"ALTER TABLE song ADD COLUMN {column_name} {column_type}" + cursor.execute(sql) + logger.info(f"Added column: {column_name} {column_type}") + columns_added += 1 + else: + logger.info(f"Column already exists: {column_name}") + + # Commit changes and close connection + conn.commit() + conn.close() + + if columns_added > 0: + logger.info(f"Successfully added {columns_added} new columns to the database.") + else: + logger.info("No new columns needed to be added.") + + logger.info("Migration add_spotify_audio_features completed successfully") + return True + + except Exception as e: + logger.error(f"Migration add_spotify_audio_features failed: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False + +if __name__ == "__main__": + # Set up logging when run directly + logging.basicConfig(level=logging.INFO) + run_migration() \ No newline at end of file diff --git a/migrations/add_tag_system.py b/migrations/add_tag_system.py new file mode 100644 index 0000000..223772a --- /dev/null +++ b/migrations/add_tag_system.py @@ -0,0 +1,146 @@ +"""Add tag system for songs + +Revision ID: e7c912b4d835 +Revises: d82c9a4f1e56 +Create Date: 2025-04-22 10:34:56.789012 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'e7c912b4d835' +down_revision = 'd82c9a4f1e56' # previous migration was add_preview_urls +branch_labels = None +depends_on = None + +def upgrade(): + # Create tag table + op.create_table('tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + + # Create song_tag mapping table + op.create_table('song_tag', + sa.Column('song_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['song_id'], ['song.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('song_id', 'tag_id') + ) + + # Create indexes for faster lookups + op.create_index(op.f('ix_song_tag_song_id'), 'song_tag', ['song_id'], unique=False) + op.create_index(op.f('ix_song_tag_tag_id'), 'song_tag', ['tag_id'], unique=False) + +def downgrade(): + # Drop the indexes first + op.drop_index(op.f('ix_song_tag_tag_id'), table_name='song_tag') + op.drop_index(op.f('ix_song_tag_song_id'), table_name='song_tag') + + # Drop the tables + op.drop_table('song_tag') + op.drop_table('tag') + +def run_migration(): + """ + Add tag and song_tag tables for the tag system if they don't exist. + """ + import sqlite3 + import os + import logging + + logger = logging.getLogger(__name__) + logger.info("Running migration: add_tag_system") + + # Try to get the database path from Flask config first + try: + from flask import current_app + if current_app and current_app.config.get('SQLALCHEMY_DATABASE_URI'): + # Extract path from URI + db_uri = current_app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri[10:] # Remove 'sqlite:///' + logger.info(f"Got database path from Flask config: {db_path}") + else: + # Use DATABASE_PATH if available + db_path = current_app.config.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH: {db_path}") + else: + # If Flask isn't running, try Docker standard path + db_path = '/data/song_data.db' + logger.info(f"Flask not available, using default path: {db_path}") + except Exception as e: + # Fallbacks in case Flask isn't available + logger.warning(f"Could not get path from Flask: {str(e)}") + + # Try environment variable + db_path = os.environ.get('DATABASE_PATH', '/data/song_data.db') + logger.info(f"Using DATABASE_PATH from environment: {db_path}") + + if not os.path.exists(db_path): + logger.warning(f"Database not found at {db_path}") + return False + + try: + # Connect to the database + logger.info(f"Connecting to database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if tables exist + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tag'") + tag_table_exists = cursor.fetchone() is not None + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='song_tag'") + song_tag_table_exists = cursor.fetchone() is not None + + # Create tag table if it doesn't exist + if not tag_table_exists: + cursor.execute(''' + CREATE TABLE tag ( + id INTEGER PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + logger.info("Created table: tag") + else: + logger.info("Table already exists: tag") + + # Create song_tag table if it doesn't exist + if not song_tag_table_exists: + cursor.execute(''' + CREATE TABLE song_tag ( + song_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (song_id, tag_id), + FOREIGN KEY (song_id) REFERENCES song (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE + ) + ''') + logger.info("Created table: song_tag") + else: + logger.info("Table already exists: song_tag") + + # Commit changes and close connection + conn.commit() + conn.close() + logger.info("Migration add_tag_system completed successfully") + return True + + except Exception as e: + logger.error(f"Migration add_tag_system failed: {str(e)}") + return False + +if __name__ == "__main__": + # Set up logging when run directly + import logging + logging.basicConfig(level=logging.INFO) + run_migration() \ No newline at end of file diff --git a/musicround/__init__.py b/musicround/__init__.py new file mode 100644 index 0000000..c35843c --- /dev/null +++ b/musicround/__init__.py @@ -0,0 +1,367 @@ +import os +import logging +import importlib.util +from flask import Flask, session, redirect, url_for, request +from flask_login import LoginManager, current_user +from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFProtect +from dotenv import load_dotenv +from werkzeug.middleware.proxy_fix import ProxyFix +from importlib import import_module +import spotipy +from spotipy.oauth2 import SpotifyOAuth +from musicround.config import Config +from musicround.version import VERSION_INFO, get_version_str + +# Initialize SQLAlchemy +db = SQLAlchemy() +login_manager = LoginManager() +csrf = CSRFProtect() + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def run_migrations(): + """ + Run all migration scripts in the migrations directory + """ + logger.info("Running database migrations...") + + # Path to migrations directory + migrations_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migrations') + if not os.path.isdir(migrations_dir): + logger.warning(f"Migrations directory not found at {migrations_dir}") + return + + # Get all Python files in the migrations directory + migration_files = [f for f in os.listdir(migrations_dir) + if f.endswith('.py') and not f.startswith('__')] + + if not migration_files: + logger.info("No migration scripts found") + return + + # Run each migration script + migration_errors = False + + for migration_file in sorted(migration_files): + try: + logger.info(f"Loading migration: {migration_file}") + file_path = os.path.join(migrations_dir, migration_file) + + # Load the module dynamically + spec = importlib.util.spec_from_file_location( + f"migrations.{migration_file[:-3]}", file_path) + if spec and spec.loader: + migration_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration_module) + + # Check if the module has a run_migration function + if hasattr(migration_module, "run_migration"): + logger.info(f"Executing migration: {migration_file}") + result = migration_module.run_migration() + + # Handle the three possible return values: + # True: changes were made successfully + # None: no changes needed (already up to date) + # False: errors occurred + if result is True: + logger.info(f"Migration {migration_file} completed successfully") + elif result is None: + logger.info(f"Migration {migration_file} reported no changes needed") + else: + logger.warning(f"Migration {migration_file} reported errors") + migration_errors = True + else: + logger.warning(f"Migration {migration_file} doesn't have run_migration() function") + else: + logger.warning(f"Could not load migration module: {migration_file}") + except Exception as e: + logger.error(f"Error running migration {migration_file}: {str(e)}") + migration_errors = True + + if migration_errors: + logger.warning("Some migrations encountered errors, but the application will continue to start") + else: + logger.info("All migrations completed") + +def create_app(config=None): + """ + Factory pattern for creating the Flask app + """ + # Load environment variables + load_dotenv() + + # Create Flask app + app = Flask(__name__, instance_relative_config=True) + + # Configure ProxyFix for reverse proxy (e.g., Nginx) + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + + # Create data directory if it doesn't exist + data_dir = '/data' + if not os.path.exists(data_dir): + os.makedirs(data_dir, exist_ok=True) + + # Set the database file path in the data directory + db_path = os.path.join(data_dir, 'song_data.db') + + # Configure the app + app.config.from_object(Config) + + # Explicitly set the database URI to ensure correct path + app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' + + # Initialize extensions with app + db.init_app(app) + csrf.init_app(app) + + # Register custom Jinja filters + @app.template_filter('timestamp_to_datetime') + def timestamp_to_datetime(timestamp): + """Convert a Unix timestamp or ISO datetime string to a datetime object""" + from datetime import datetime + + if isinstance(timestamp, str): + try: + # Try to parse as ISO format string + return datetime.fromisoformat(timestamp) + except (ValueError, TypeError): + try: + # Try to convert to float first then use as timestamp + return datetime.fromtimestamp(float(timestamp)) + except (ValueError, TypeError): + return None + elif timestamp is None: + return None + else: + # Assume it's a numeric timestamp + try: + return datetime.fromtimestamp(timestamp) + except (ValueError, TypeError): + return None + + @app.template_filter('format_datetime') + def format_datetime(dt, format='%Y-%m-%d %H:%M:%S'): + """Format a datetime object to a string""" + if not dt: + return "Unknown" + return dt.strftime(format) + + # Set up the Flask-Login extension + login_manager.login_view = 'users.login' + login_manager.login_message_category = 'info' + login_manager.login_message = 'Please log in to access this page.' + login_manager.init_app(app) + + # Add version info to template context + @app.context_processor + def inject_version(): + from musicround.version import get_version_str, VERSION_INFO + return { + 'get_version_str': get_version_str, + 'version_info': VERSION_INFO + } + + # Import User model here to avoid circular imports + from musicround.models import User + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Ensure instance folder exists + try: + os.makedirs(app.instance_path, exist_ok=True) + except OSError: + pass + + # Initialize OAuth providers (Google, Authentik) + from musicround.helpers.auth_helpers import init_oauth + init_oauth(app) + + # Initialize Spotify client for common API access + # This will be available for any authenticated route + if app.config['SPOTIFY_CLIENT_ID'] and app.config['SPOTIFY_CLIENT_SECRET']: + app.config['sp_oauth'] = SpotifyOAuth( + client_id=app.config['SPOTIFY_CLIENT_ID'], + client_secret=app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=app.config['SPOTIFY_REDIRECT_URI'], + scope=app.config['SPOTIFY_SCOPE'] + ) + # Create a Spotify client that will be used throughout the app + app.config['sp'] = spotipy.Spotify(auth_manager=app.config['sp_oauth']) + +# Initialize Deezer client - import inside the function to avoid circular dependency + from musicround.deezer_client import DeezerClient + app.config['deezer'] = DeezerClient() + + # Add before_request handler to ensure Spotify token is available + @app.before_request + def ensure_spotify_token(): + """ + Ensure a valid Spotify token is available in the session. + Priority: + 1. Use existing manual bearer token if present in session + 2. Try to refresh user's token if they have a refresh token + 3. Use client credentials flow as fallback (no user login required) + """ + # Skip for static files and certain paths + if request.path.startswith('/static') or request.path.startswith('/favicon.ico'): + return + + # If we already have a manual token in session, don't do anything + # Manual tokens take priority over everything else + if 'access_token' in session and session.get('token_source') != 'user' and session.get('token_source') != 'client_credentials': + app.logger.debug("Using existing manual bearer token") + return + + from datetime import datetime + from spotipy.oauth2 import SpotifyOAuth + from .models import SystemSetting + import base64 + import requests + + try: + # Only check user token if user is logged in + if current_user.is_authenticated: + # Step 1: Try to use user's refresh token + if current_user.spotify_refresh_token: + app.logger.debug(f"Attempting to refresh token for user {current_user.username}") + + # Create OAuth manager for token refresh + sp_oauth = SpotifyOAuth( + client_id=app.config['SPOTIFY_CLIENT_ID'], + client_secret=app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=url_for('users.spotify_callback', _external=True), + scope=app.config['SPOTIFY_SCOPE'] + ) + + try: + # Refresh user's token + token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token) + + if token_info and 'access_token' in token_info: + # Update user's tokens in database + current_user.spotify_token = token_info['access_token'] + current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) + + # If we got a new refresh token (rare but possible), update it + if 'refresh_token' in token_info: + current_user.spotify_refresh_token = token_info['refresh_token'] + + # Save to database + db.session.commit() + + # Store token in session + session['access_token'] = token_info['access_token'] + session['token_source'] = 'user' + + app.logger.debug(f"Generated new token for user {current_user.username}") + return + except Exception as e: + app.logger.warning(f"Failed to refresh user token: {str(e)}") + + # Step 2: If no user token or user not logged in, use client credentials flow + # Check if we already have a valid client credentials token + client_token_expiry = session.get('client_token_expiry', 0) + + if 'access_token' in session and session.get('token_source') == 'client_credentials' and client_token_expiry > datetime.now().timestamp(): + app.logger.debug("Using existing client credentials token") + return + + # Get client credentials from config + client_id = app.config['SPOTIFY_CLIENT_ID'] + client_secret = app.config['SPOTIFY_CLIENT_SECRET'] + + if client_id and client_secret: + app.logger.debug("Getting new token via client credentials flow") + + # Encode client credentials + auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + + # Prepare headers and payload + headers = { + 'Authorization': f'Basic {auth_header}', + 'Content-Type': 'application/x-www-form-urlencoded' + } + data = { + 'grant_type': 'client_credentials' + } + + try: + # Make the POST request + response = requests.post('https://accounts.spotify.com/api/token', headers=headers, data=data) + response.raise_for_status() + + token_data = response.json() + + if 'access_token' in token_data: + # Store the token in session + session['access_token'] = token_data['access_token'] + session['token_source'] = 'client_credentials' + + # Calculate and store expiry time (typically 1 hour from now) + expires_in = token_data.get('expires_in', 3600) # Default to 1 hour + expiry_timestamp = datetime.now().timestamp() + expires_in + session['client_token_expiry'] = expiry_timestamp + + app.logger.debug("Successfully obtained client credentials token") + return + else: + app.logger.warning("No access token in client credentials response") + + except Exception as e: + app.logger.error(f"Error getting client credentials token: {str(e)}") + + except Exception as e: + app.logger.error(f"Error in ensure_spotify_token: {str(e)}") + pass # Continue without a token if all methods fail + + # Register blueprints + from musicround.routes.core import core_bp + from musicround.routes.users import users_bp + from musicround.routes.import_songs import import_songs_bp + from musicround.routes.rounds import rounds_bp + from musicround.routes.generate import generate_bp + from musicround.routes.api import api_bp + from musicround.routes.import_routes import import_bp + from musicround.routes.process import process_bp + from musicround.routes.deezer_routes import deezer_bp + from musicround.routes.db_admin import db_admin_bp, init_admin + from musicround.routes.auth import auth_bp + + app.register_blueprint(core_bp) + app.register_blueprint(users_bp) + app.register_blueprint(import_songs_bp) + app.register_blueprint(rounds_bp) + app.register_blueprint(generate_bp) + app.register_blueprint(api_bp) + app.register_blueprint(import_bp) + app.register_blueprint(process_bp) + app.register_blueprint(deezer_bp) + app.register_blueprint(db_admin_bp) + app.register_blueprint(auth_bp) + + # Initialize the admin interface + init_admin(app) + + # Register error handlers + from musicround.errors import register_error_handlers + register_error_handlers(app) + + # Try to create database tables if they don't exist + with app.app_context(): + try: + db.create_all() + logger.info("Database tables created successfully during app initialization") + + # Run migrations after tables are created + run_migrations() + except Exception as e: + logger.error(f"Error creating database tables during app initialization: {e}") + + # Return the app + return app + diff --git a/musicround/config.py b/musicround/config.py new file mode 100644 index 0000000..58ce92f --- /dev/null +++ b/musicround/config.py @@ -0,0 +1,75 @@ +import os +import openai +from dotenv import load_dotenv +import tempfile +from datetime import timedelta + +# Load environment variables from .env file +load_dotenv() + +# Set up OpenAI API key +openai.api_key = os.getenv("OPENAI_API_KEY") + +# Get the base directory of the application +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + # Debug settings + DEBUG = os.getenv("DEBUG", "True") == "True" + DEBUG2 = os.getenv("DEBUG2", "False") == "True" + SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key-please-change') + + # API Keys + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + DEEPL_API_KEY = os.getenv("DEEPL_API_KEY") + MEANINGCLOUD_API_KEY = os.getenv("MEANINGCLOUD_API_KEY") + LASTFM_API_KEY = os.getenv("LASTFM_API_KEY") + ACRCLOUD_TOKEN = os.getenv("ACRCLOUD_TOKEN") + ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY") + OPENAI_URL = os.getenv("OPENAI_URL", "https://api.openai.com/v1") + OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + OPENAI_SEARCH_MODEL = os.getenv("OPENAI_SEARCH_MODEL", "gpt-4o-mini-search") + + SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + + # Spotify API credentials + SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") + SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") + SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI") + SPOTIFY_SCOPE = "playlist-read-private playlist-read-collaborative user-library-read user-top-read" + + # Deezer API credentials + DEEZER_APP_ID = os.getenv("DEEZER_APP_ID", "") + DEEZER_APP_SECRET = os.getenv("DEEZER_APP_SECRET", "") + DEEZER_REDIRECT_URI = os.getenv("DEEZER_REDIRECT_URI", "http://localhost:5000/deezer-callback") + + # Google OAuth credentials + GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") + GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") + + # Authentik OAuth credentials + AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "") + AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "") + AUTHENTIK_METADATA_URL = os.getenv("AUTHENTIK_METADATA_URL", "") + + # Dropbox OAuth credentials + DROPBOX_APP_KEY = os.getenv("DROPBOX_APP_KEY", "") + DROPBOX_APP_SECRET = os.getenv("DROPBOX_APP_SECRET", "") + DROPBOX_REDIRECT_URI = os.getenv("DROPBOX_REDIRECT_URI", "http://localhost:5000/users/dropbox/callback") + + MAIL_HOST = os.getenv("MAIL_HOST", "localhost") + MAIL_PORT = os.getenv("MAIL_PORT", 25) + MAIL_USE_TLS = os.getenv("MAIL_USE_TLS", "False") == "True" + MAIL_USE_SSL = os.getenv("MAIL_USE_SSL", "False") == "True" + MAIL_USERNAME = os.getenv("MAIL_USERNAME", "") + MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "") + MAIL_SENDER = os.getenv("MAIL_SENDER", "quizzical-beats@example.com") + MAIL_RECIPIENT = os.getenv("MAIL_RECIPIENT", "admin@example.com") + + # Automation settings + AUTOMATION_TOKEN = os.getenv("AUTOMATION_TOKEN", "change-this-token-in-production") + + + diff --git a/musicround/deezer_client.py b/musicround/deezer_client.py new file mode 100644 index 0000000..45748d8 --- /dev/null +++ b/musicround/deezer_client.py @@ -0,0 +1,252 @@ +import os +import requests +import logging +import random +import time +from flask import current_app +from musicround.models import Song, db + +logger = logging.getLogger(__name__) + +class DeezerClient: + """ + Client for interacting with the Deezer API + Handles searching and importing songs, albums, and playlists + """ + + def __init__(self): + self.base_url = "https://api.deezer.com" + self.logger = logging.getLogger(__name__) + + def _make_request(self, endpoint, params=None): + """Make a GET request to the Deezer API""" + url = f"{self.base_url}/{endpoint}" + try: + response = requests.get(url, params=params) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + self.logger.error(f"Deezer API request error: {e}") + return None + + def search_tracks(self, query, limit=50): + """Search for tracks on Deezer""" + params = { + 'q': query, + 'limit': limit + } + result = self._make_request('search/track', params=params) + if result and 'data' in result: + return result['data'] + return [] + + def search_albums(self, query, limit=25): + """Search for albums on Deezer""" + params = { + 'q': query, + 'limit': limit + } + result = self._make_request('search/album', params=params) + if result and 'data' in result: + return result['data'] + return [] + + def search_playlists(self, query, limit=25): + """Search for playlists on Deezer""" + params = { + 'q': query, + 'limit': limit + } + result = self._make_request('search/playlist', params=params) + if result and 'data' in result: + return result['data'] + return [] + + def get_track(self, track_id): + """Get details for a specific track""" + return self._make_request(f'track/{track_id}') + + def get_album(self, album_id): + """Get details for a specific album""" + return self._make_request(f'album/{album_id}') + + def get_album_tracks(self, album_id): + """Get tracks from a specific album""" + result = self._make_request(f'album/{album_id}/tracks') + if result and 'data' in result: + return result['data'] + return [] + + def get_playlist(self, playlist_id): + """Get details for a specific playlist""" + return self._make_request(f'playlist/{playlist_id}') + + def get_playlist_tracks(self, playlist_id): + """Get tracks from a specific playlist""" + result = self._make_request(f'playlist/{playlist_id}/tracks') + if result and 'data' in result: + return result['data'] + return [] + + def get_popular_playlists(self, limit=30): + """ + Get popular playlists from Deezer + This uses a set of predetermined searches to find popular playlists + """ + playlists = [] + search_terms = ['hits', 'top', 'chart', 'popular', 'best', 'essential'] + + # Search for each term and combine results + for term in search_terms: + results = self.search_playlists(term, limit=10) + playlists.extend(results) + + # Avoid rate limiting + time.sleep(0.1) + + # Filter for playlists with reasonable track counts (avoid tiny playlists) + playlists = [p for p in playlists if p.get('nb_tracks', 0) >= 10] + + # Sort by popularity or track count + playlists.sort(key=lambda x: x.get('nb_tracks', 0), reverse=True) + + # Shuffle and limit results + random.shuffle(playlists) + return playlists[:limit] + + def get_genre_from_lastfm(self, artist_name, track_name, lastfm_api_key): + """ + Fetch genre from Last.fm API + Returns the genre string, or empty string if not found + """ + if not lastfm_api_key: + return "" + + url = 'http://ws.audioscrobbler.com/2.0/' + params = { + 'method': 'track.getInfo', + 'api_key': lastfm_api_key, + 'artist': artist_name, + 'track': track_name, + 'format': 'json' + } + + try: + response = requests.get(url=url, params=params).json() + + # If present, use the first top-level tag as "genre" + if ('track' in response and + 'toptags' in response['track'] and + 'tag' in response['track']['toptags'] and + response['track']['toptags']['tag']): + return response['track']['toptags']['tag'][0]['name'] + return "" + except Exception as e: + self.logger.error(f"Last.fm API error: {e}") + return "" + + def import_track(self, track_id, lastfm_api_key=None): + """ + Import a track from Deezer into the database + Returns the Song object if successful, None otherwise + """ + track_info = self.get_track(track_id) + + if not track_info: + self.logger.error(f"Could not fetch track with ID {track_id}") + return None + + # Check if the track has a preview URL (required for our application) + preview_url = track_info.get('preview') + if not preview_url: + self.logger.warning(f"Track {track_info.get('title')} has no preview URL") + return None + + # Check if this track is already in our database + existing_song = Song.query.filter_by(deezer_id=str(track_info['id'])).first() + if existing_song: + self.logger.info(f"Track {track_info.get('title')} already exists in database") + return existing_song + + # Get additional artist details if needed + artist_name = track_info.get('artist', {}).get('name', '') + + # Get album release year + album_id = track_info.get('album', {}).get('id') + release_year = '' + cover_url = '' + + if album_id: + album_info = self.get_album(album_id) + if album_info: + release_date = album_info.get('release_date', '') + release_year = release_date[:4] if release_date else '' + # Get highest quality cover image + cover_url = album_info.get('cover_xl') or album_info.get('cover_big') or album_info.get('cover_medium', '') + + # Get genre from Last.fm + genre = self.get_genre_from_lastfm(artist_name, track_info.get('title', ''), lastfm_api_key) + + # Create new Song object + new_song = Song( + deezer_id=str(track_info['id']), + spotify_id=None, # We don't have Spotify ID for Deezer tracks + title=track_info.get('title', ''), + artist=artist_name, + genre=genre, + year=release_year, + preview_url=preview_url, + cover_url=cover_url, + popularity=track_info.get('rank', 0), + used_count=0 + ) + + try: + db.session.add(new_song) + db.session.commit() + self.logger.info(f"Imported track '{new_song.title}' by {new_song.artist}") + return new_song + except Exception as e: + db.session.rollback() + self.logger.error(f"Error saving track to database: {e}") + return None + + def import_album(self, album_id, lastfm_api_key=None): + """ + Import all tracks from an album + Returns a list of successfully imported Song objects + """ + tracks = self.get_album_tracks(album_id) + imported_songs = [] + + for track in tracks: + track_id = track.get('id') + if track_id: + song = self.import_track(track_id, lastfm_api_key) + if song: + imported_songs.append(song) + + # Add a small delay to avoid overwhelming the API + time.sleep(0.2) + + return imported_songs + + def import_playlist(self, playlist_id, lastfm_api_key=None): + """ + Import all tracks from a playlist + Returns a list of successfully imported Song objects + """ + tracks = self.get_playlist_tracks(playlist_id) + imported_songs = [] + + for track in tracks: + track_id = track.get('id') + if track_id: + song = self.import_track(track_id, lastfm_api_key) + if song: + imported_songs.append(song) + + # Add a small delay to avoid overwhelming the API + time.sleep(0.2) + + return imported_songs \ No newline at end of file diff --git a/musicround/errors.py b/musicround/errors.py new file mode 100644 index 0000000..2c2770b --- /dev/null +++ b/musicround/errors.py @@ -0,0 +1,222 @@ +import traceback +from flask import render_template, session, request, current_app, jsonify +from flask_wtf.csrf import CSRFError +from flask_login import current_user +import json +import openai + +# Import the csrf instance from the package +from musicround import csrf + +def generate_friendly_error_message(error_info, app=None): + """ + Generate a user-friendly error message using OpenAI + + Args: + error_info (dict): Information about the error + app: Flask application instance + + Returns: + str: User-friendly error message or None if generation fails + """ + if not app: + app = current_app + + try: + # Get OpenAI API details + openai_api_key = app.config.get('OPENAI_API_KEY') + openai_url = app.config.get('OPENAI_URL') + openai_model = app.config.get('OPENAI_MODEL') + + if not openai_api_key or not openai_model: + app.logger.warning("OpenAI credentials not configured for error messages") + return None + + # Configure OpenAI API + openai.api_key = openai_api_key + if openai_url: + if hasattr(openai, 'base_url'): # New OpenAI API client (>= 1.0.0) + openai.base_url = openai_url + else: # Old OpenAI API client (< 1.0.0) + openai.api_base = openai_url + + # Create a meaningful prompt with the error information + prompt = f""" + Generate a user-friendly, helpful explanation for this technical error: + Error Type: {error_info.get('error_type', 'Unknown Error')} + Error Message: {error_info.get('error_message', 'No specific message available')} + Error Code: {error_info.get('code', 'Unknown')} + + The explanation should: + 1. Be written in simple, non-technical language + 2. Explain what might have happened + 3. Suggest possible solutions or next steps + 4. Be concise (max 2-3 sentences) + 5. Be friendly and reassuring + + Return just the friendly explanation without any additional text. + """ + + app.logger.info(f"Requesting friendly error message from OpenAI") + content = None + + # Call OpenAI based on library version + if hasattr(openai, 'chat') and hasattr(openai.chat, 'completions'): + # New OpenAI API client (>= 1.0.0) + try: + response = openai.chat.completions.create( + model=openai_model, + messages=[{"role": "user", "content": prompt}], + max_tokens=150, + temperature=0.7 + ) + + if response and hasattr(response, 'choices') and response.choices: + content = response.choices[0].message.content + app.logger.info(f"Received friendly error message from OpenAI") + except Exception as e: + app.logger.error(f"OpenAI chat completions error: {e}") + else: + # Old OpenAI API client (< 1.0.0) + try: + response = openai.Completion.create( + engine=openai_model, + prompt=prompt, + max_tokens=150, + temperature=0.7 + ) + + if response and hasattr(response, 'choices') and len(response.choices) > 0: + content = response.choices[0].text.strip() + app.logger.info(f"Received friendly error message from OpenAI") + except Exception as e: + app.logger.error(f"OpenAI completion error: {e}") + + # Return the friendly message if available + if content: + return content.strip() + + except Exception as e: + app.logger.error(f"Error generating friendly error message: {e}") + + return None + +def register_error_handlers(app): + """Register error handlers with the Flask application.""" + + # Add endpoint to get friendly error message asynchronously + @app.route('/api/friendly-error', methods=['POST']) + @csrf.exempt # Exempt this endpoint from CSRF protection + def get_friendly_error_message(): + try: + data = request.get_json() + error_info = { + 'error_type': data.get('error_type', 'Unknown Error'), + 'error_message': data.get('error_message', 'No specific message available'), + 'code': data.get('code', 'Unknown') + } + + friendly_message = generate_friendly_error_message(error_info, app) + + if friendly_message: + return jsonify({'success': True, 'message': friendly_message}) + else: + return jsonify({'success': False, 'message': 'Could not generate a friendly message'}), 500 + except Exception as e: + app.logger.error(f"Error in friendly error API: {e}") + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.errorhandler(400) + def bad_request_error(error): + return handle_error(error, 400, "Bad Request") + + @app.errorhandler(401) + def unauthorized_error(error): + return handle_error(error, 401, "Unauthorized") + + @app.errorhandler(403) + def forbidden_error(error): + return handle_error(error, 403, "Forbidden") + + @app.errorhandler(404) + def not_found_error(error): + return handle_error(error, 404, "Page Not Found") + + @app.errorhandler(405) + def method_not_allowed_error(error): + return handle_error(error, 405, "Method Not Allowed") + + @app.errorhandler(429) + def too_many_requests_error(error): + return handle_error(error, 429, "Too Many Requests") + + @app.errorhandler(500) + def internal_server_error(error): + return handle_error(error, 500, "Internal Server Error") + + @app.errorhandler(CSRFError) + def handle_csrf_error(error): + return handle_error(error, 400, "CSRF Error") + + # Add this to ensure other errors are also captured + @app.errorhandler(Exception) + def unhandled_exception(error): + app.logger.error(f"Unhandled Exception: {error}") + return handle_error(error, 500, "Internal Server Error") + + +def handle_error(error, code, default_message): + """ + Common error handler that renders the error.html template with appropriate context + """ + # Get the error message + message = getattr(error, 'description', str(error)) or default_message + + # Prepare debug info for logged-in users + debug_info = None + tb = None + + # Use Flask-Login instead of checking for access_token in session + if current_user.is_authenticated: + # Include request details in debug info + debug_info = { + 'error_type': error.__class__.__name__, + 'request_path': request.path, + 'request_method': request.method, + 'request_headers': {k: v for k, v in request.headers.items() if k.lower() not in ('cookie', 'authorization')}, + 'request_args': request.args.to_dict(), + } + + # Include POST data if it's form data (not for file uploads) + if request.form and 'multipart/form-data' not in request.content_type: + debug_info['request_form'] = request.form.to_dict() + + # Include JSON data if applicable + if request.is_json: + try: + debug_info['request_json'] = request.get_json() + except: + debug_info['request_json'] = 'Invalid JSON' + + # Get traceback for more detailed debugging + tb = traceback.format_exc() if code == 500 else None + + # Convert debug_info to formatted string for template + debug_info_str = json.dumps(debug_info, indent=2) + + # We'll pass the error info to the template so JavaScript can request a friendly message + error_info_for_js = json.dumps({ + 'error_type': error.__class__.__name__, + 'error_message': str(error), + 'code': code + }) + + # Render the template with all necessary information + return render_template( + 'error.html', + message=message, # Original technical message + code=code, + debug_info=debug_info_str if 'debug_info_str' in locals() else None, + traceback=tb, + error_info_for_js=error_info_for_js # Error info for JavaScript to use + ), code \ No newline at end of file diff --git a/musicround/helpers/__init__.py b/musicround/helpers/__init__.py new file mode 100644 index 0000000..b8ca501 --- /dev/null +++ b/musicround/helpers/__init__.py @@ -0,0 +1 @@ +# This file makes the helpers directory a proper Python package diff --git a/musicround/helpers/auth_helpers.py b/musicround/helpers/auth_helpers.py new file mode 100644 index 0000000..8d59c44 --- /dev/null +++ b/musicround/helpers/auth_helpers.py @@ -0,0 +1,280 @@ +""" +Authentication helper functions for OAuth providers +""" +import os +from flask import current_app, url_for, session, flash, redirect, request +from authlib.integrations.flask_client import OAuth +from flask_login import login_user, current_user +from functools import wraps +from datetime import datetime, timedelta +import requests + +from musicround.models import db, User + +# Initialize OAuth object +oauth = OAuth() + +def init_oauth(app): + """ + Initialize OAuth with the Flask app and register providers + """ + oauth.init_app(app) + + # Register Google OAuth client + if app.config.get('GOOGLE_CLIENT_ID') and app.config.get('GOOGLE_CLIENT_SECRET'): + oauth.register( + name='google', + client_id=app.config.get('GOOGLE_CLIENT_ID'), + client_secret=app.config.get('GOOGLE_CLIENT_SECRET'), + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } + ) + app.logger.info("Google OAuth client registered") + else: + app.logger.warning("Google OAuth client not registered - missing client ID or secret") + + # Register Authentik OAuth client + if app.config.get('AUTHENTIK_CLIENT_ID') and app.config.get('AUTHENTIK_CLIENT_SECRET'): + oauth.register( + name='authentik', + client_id=app.config.get('AUTHENTIK_CLIENT_ID'), + client_secret=app.config.get('AUTHENTIK_CLIENT_SECRET'), + server_metadata_url=app.config.get('AUTHENTIK_METADATA_URL'), + client_kwargs={ + 'scope': 'openid email profile' + } + ) + app.logger.info("Authentik OAuth client registered") + else: + app.logger.warning("Authentik OAuth client not registered - missing client ID or secret") + + # Register Dropbox OAuth client + if app.config.get('DROPBOX_APP_KEY') and app.config.get('DROPBOX_APP_SECRET'): + oauth.register( + name='dropbox', + client_id=app.config.get('DROPBOX_APP_KEY'), + client_secret=app.config.get('DROPBOX_APP_SECRET'), + authorize_url='https://www.dropbox.com/oauth2/authorize', + authorize_params=None, + access_token_url='https://api.dropboxapi.com/oauth2/token', + access_token_params=None, + refresh_token_url='https://api.dropboxapi.com/oauth2/token', + client_kwargs={ + 'scope': 'files.content.write account_info.read' + } + ) + app.logger.info("Dropbox OAuth client registered") + else: + app.logger.warning("Dropbox OAuth client not registered - missing app key or secret") + + return oauth + +def get_google_user_info(token): + """ + Get Google user info from the token + """ + try: + resp = oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo') + profile = resp.json() + + # Create a standardized user info dictionary + user_info = { + 'id': profile.get('sub'), # Google uses 'sub' as the unique identifier + 'email': profile.get('email'), + 'name': profile.get('name'), + 'given_name': profile.get('given_name'), + 'family_name': profile.get('family_name'), + 'picture': profile.get('picture') + } + + # Add 'sub' field explicitly for backwards compatibility + if profile.get('sub'): + user_info['sub'] = profile.get('sub') + + return user_info + except Exception as e: + current_app.logger.error(f"Error getting Google user info: {str(e)}") + return None + +def get_authentik_user_info(token): + """ + Get Authentik user info from the token + """ + try: + resp = oauth.authentik.get('userinfo') + profile = resp.json() + return { + 'id': profile.get('sub'), + 'email': profile.get('email'), + 'name': profile.get('name'), + 'given_name': profile.get('given_name', ''), + 'family_name': profile.get('family_name', ''), + 'picture': profile.get('picture', '') + } + except Exception as e: + current_app.logger.error(f"Error getting Authentik user info: {str(e)}") + return None + +def get_dropbox_user_info(token): + """ + Get Dropbox user info from the token + """ + try: + # Add debug logging for token + current_app.logger.debug(f"Retrieving Dropbox user info with token: {token}") + + # Make sure we have an access token + access_token = token.get("access_token") + if not access_token: + # Try direct token string if token is not a dict + if isinstance(token, str): + access_token = token + else: + current_app.logger.error("No access token found in token object") + return None + + # Set proper headers for Dropbox API - no Content-Type for null body + headers = { + 'Authorization': f'Bearer {access_token}' + } + + # The Dropbox API for get_current_account actually expects a null body with no Content-Type header + response = requests.post( + 'https://api.dropboxapi.com/2/users/get_current_account', + headers=headers, + data=None # Send null body + ) + + # Check for successful response + if response.status_code != 200: + current_app.logger.error(f"Dropbox API error: {response.status_code} - {response.text}") + return None + + # Parse response + profile = response.json() + current_app.logger.debug(f"Dropbox user info response: {profile}") + + # Create a standardized user info dictionary + user_info = { + 'id': profile.get('account_id', ''), + 'email': profile.get('email', ''), + 'name': profile.get('name', {}).get('display_name', ''), + 'given_name': profile.get('name', {}).get('given_name', ''), + 'family_name': profile.get('name', {}).get('surname', ''), + 'picture': profile.get('profile_photo_url', '') + } + + return user_info + except Exception as e: + current_app.logger.error(f"Error getting Dropbox user info: {str(e)}") + return None + +def find_or_create_user(user_info, auth_provider): + """ + Find existing user or create a new one based on OAuth user info + """ + if not user_info: + return None + + # First try to find user by provider-specific ID + if auth_provider == 'google': + user = User.query.filter_by(google_id=user_info['id']).first() + elif auth_provider == 'authentik': + user = User.query.filter_by(authentik_id=user_info['id']).first() + elif auth_provider == 'dropbox': + user = User.query.filter_by(dropbox_id=user_info['id']).first() + else: + return None + + # If not found by provider ID, try email + if user is None and user_info.get('email'): + user = User.query.filter_by(email=user_info['email']).first() + + # If user exists but doesn't have provider ID, update it + if user: + if auth_provider == 'google': + user.google_id = user_info['id'] + elif auth_provider == 'authentik': + user.authentik_id = user_info['id'] + elif auth_provider == 'dropbox': + user.dropbox_id = user_info['id'] + + db.session.commit() + current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID") + + # If user still not found, check if new signups are allowed before creating + if user is None: + # Check system setting if new signups are allowed + from musicround.models import SystemSetting + allow_signups = SystemSetting.get('allow_signups', 'true') == 'true' + + if not allow_signups: + current_app.logger.warning(f"OAuth signup attempted for {auth_provider} but new signups are disabled") + return None + + # Generate a username from email + email = user_info.get('email', '') + base_username = email.split('@')[0] if email else f"{auth_provider}_{user_info['id']}" + + # Ensure username is unique + username = base_username + counter = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + # Create new user + user = User( + username=username, + email=user_info.get('email', ''), + first_name=user_info.get('given_name', ''), + last_name=user_info.get('family_name', ''), + auth_provider=auth_provider, + created_at=datetime.now(), + last_login=datetime.now() + ) + + # Set provider-specific fields + if auth_provider == 'google': + user.google_id = user_info['id'] + elif auth_provider == 'authentik': + user.authentik_id = user_info['id'] + elif auth_provider == 'dropbox': + user.dropbox_id = user_info['id'] + + db.session.add(user) + try: + db.session.commit() + current_app.logger.info(f"Created new user {username} with {auth_provider} auth") + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating user: {str(e)}") + return None + + return user + +def update_oauth_tokens(user, tokens, auth_provider): + """ + Update user's OAuth tokens + """ + if auth_provider == 'google': + user.google_token = tokens.get('access_token') + user.google_refresh_token = tokens.get('refresh_token') + elif auth_provider == 'authentik': + user.authentik_token = tokens.get('access_token') + user.authentik_refresh_token = tokens.get('refresh_token') + elif auth_provider == 'dropbox': + user.dropbox_token = tokens.get('access_token') + user.dropbox_refresh_token = tokens.get('refresh_token') + if tokens.get('expires_in'): + user.dropbox_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in'))) + user.last_login = datetime.now() + try: + db.session.commit() + return True + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating {auth_provider} tokens: {str(e)}") + return False \ No newline at end of file diff --git a/musicround/helpers/backup_helper.py b/musicround/helpers/backup_helper.py new file mode 100644 index 0000000..b8b5b09 --- /dev/null +++ b/musicround/helpers/backup_helper.py @@ -0,0 +1,724 @@ +""" +Backup helper functions for creating, managing, and restoring backups. +""" +import os +import shutil +import json +import logging +import sqlite3 +import zipfile +from datetime import datetime +import tempfile +from flask import current_app + +# Set up logging +logger = logging.getLogger(__name__) + +def create_backup(backup_name=None, include_mp3s=True, include_config=True): + """ + Create a full system backup including database, MP3s, and configuration. + + Args: + backup_name: Optional name for the backup (defaults to timestamp) + include_mp3s: Whether to include MP3 files in the backup + include_config: Whether to include configuration files + + Returns: + dict: Backup information including path and status + """ + try: + # Generate backup name if not provided + if not backup_name: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_name = f"backup_{timestamp}" + + # Ensure backup directory exists + backup_dir = os.path.join('/data', 'backups') + os.makedirs(backup_dir, exist_ok=True) + + # Create backup zip file path + backup_path = os.path.join(backup_dir, f"{backup_name}.zip") + + # Create a temporary directory for collecting files + with tempfile.TemporaryDirectory() as temp_dir: + # Step 1: Backup the database + db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + + if os.path.exists(db_path): + # Create a copy of the database (to avoid locking issues) + temp_db = os.path.join(temp_dir, 'song_data.db') + + # Connect to source database and back it up + conn = sqlite3.connect(db_path) + backup_conn = sqlite3.connect(temp_db) + conn.backup(backup_conn) + conn.close() + backup_conn.close() + + logger.info(f"Database backed up to {temp_db}") + else: + logger.error(f"Database not found at {db_path}") + return { + "status": "error", + "message": f"Database not found at {db_path}", + "path": None + } + + # Step 2: Copy MP3 files if requested + if include_mp3s: + mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3') + if os.path.exists(mp3_dir): + mp3_backup_dir = os.path.join(temp_dir, 'mp3') + os.makedirs(mp3_backup_dir, exist_ok=True) + + # Copy all MP3 files + for mp3_file in os.listdir(mp3_dir): + if mp3_file.endswith('.mp3'): + source_path = os.path.join(mp3_dir, mp3_file) + dest_path = os.path.join(mp3_backup_dir, mp3_file) + shutil.copy2(source_path, dest_path) + + logger.info(f"MP3 files backed up to {mp3_backup_dir}") + else: + logger.warning(f"MP3 directory not found at {mp3_dir}") + + # Step 3: Add configuration files if requested + if include_config: + config_dir = os.path.join(temp_dir, 'config') + os.makedirs(config_dir, exist_ok=True) + + # Copy .env file if it exists + env_path = os.path.join(os.path.dirname(current_app.root_path), '.env') + if os.path.exists(env_path): + shutil.copy2(env_path, os.path.join(config_dir, '.env')) + logger.info(f".env file backed up") + + # Extract system settings from database and save as JSON + try: + from musicround.models import SystemSetting + settings = SystemSetting.all_settings() + + # Save settings to JSON file + settings_path = os.path.join(config_dir, 'system_settings.json') + with open(settings_path, 'w') as f: + json.dump(settings, f, indent=2) + + logger.info(f"System settings backed up to {settings_path}") + except Exception as e: + logger.error(f"Error backing up system settings: {str(e)}") + + # Step 4: Add backup metadata file with version info and timestamp + from musicround.version import VERSION_INFO + + metadata = { + "backup_name": backup_name, + "timestamp": datetime.now().isoformat(), + "version": VERSION_INFO['version'], + "release_name": VERSION_INFO['release_name'], + "includes_mp3s": include_mp3s, + "includes_config": include_config + } + + metadata_path = os.path.join(temp_dir, 'backup_metadata.json') + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + # Step 5: Create a ZIP archive of all backed up content + with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Add all files from temp directory to ZIP + for root, _, files in os.walk(temp_dir): + for file in files: + file_path = os.path.join(root, file) + # Add file to ZIP with a relative path + arcname = os.path.relpath(file_path, temp_dir) + zipf.write(file_path, arcname) + + # Get backup file size + backup_size = os.path.getsize(backup_path) + + return { + "status": "success", + "message": "Backup created successfully", + "path": backup_path, + "name": backup_name, + "size": backup_size, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Error during backup creation: {str(e)}") + return { + "status": "error", + "message": f"Backup failed: {str(e)}", + "path": None + } + +def list_backups(): + """ + List all available backups with their metadata. + + Returns: + list: List of backup information dictionaries + """ + backup_dir = os.path.join('/data', 'backups') + + if not os.path.exists(backup_dir): + os.makedirs(backup_dir, exist_ok=True) + return [] + + backups = [] + + for filename in os.listdir(backup_dir): + if filename.endswith('.zip'): + backup_path = os.path.join(backup_dir, filename) + try: + # Extract metadata from ZIP file + with zipfile.ZipFile(backup_path, 'r') as zipf: + if 'backup_metadata.json' in zipf.namelist(): + with zipf.open('backup_metadata.json') as f: + metadata = json.load(f) + + # Add file information to metadata + file_info = os.stat(backup_path) + metadata['file_size'] = file_info.st_size + metadata['file_name'] = filename + metadata['file_path'] = backup_path + metadata['file_date'] = datetime.fromtimestamp(file_info.st_mtime).isoformat() + + backups.append(metadata) + else: + # No metadata file, create basic info + file_info = os.stat(backup_path) + backups.append({ + 'backup_name': os.path.splitext(filename)[0], + 'file_name': filename, + 'file_path': backup_path, + 'file_size': file_info.st_size, + 'file_date': datetime.fromtimestamp(file_info.st_mtime).isoformat(), + 'timestamp': datetime.fromtimestamp(file_info.st_mtime).isoformat(), + 'version': 'Unknown', + 'release_name': 'Unknown' + }) + except Exception as e: + logger.error(f"Error reading backup metadata from {filename}: {str(e)}") + + # Sort backups by timestamp (newest first) + backups.sort(key=lambda x: x.get('timestamp', ''), reverse=True) + + return backups + +def delete_backup(backup_filename): + """ + Delete a backup file. + + Args: + backup_filename: Name of the backup file to delete + + Returns: + dict: Operation status information + """ + backup_dir = os.path.join('/data', 'backups') + backup_path = os.path.join(backup_dir, backup_filename) + + if not os.path.exists(backup_path): + return { + "status": "error", + "message": f"Backup file {backup_filename} not found" + } + + try: + os.remove(backup_path) + return { + "status": "success", + "message": f"Backup {backup_filename} deleted successfully" + } + except Exception as e: + logger.error(f"Error deleting backup {backup_filename}: {str(e)}") + return { + "status": "error", + "message": f"Error deleting backup: {str(e)}" + } + +def restore_backup(backup_filename): + """ + Restore system from a backup file. + + Args: + backup_filename: Name of the backup file to restore + + Returns: + dict: Operation status information + """ + backup_dir = os.path.join('/data', 'backups') + backup_path = os.path.join(backup_dir, backup_filename) + + if not os.path.exists(backup_path): + return { + "status": "error", + "message": f"Backup file {backup_filename} not found" + } + + try: + # Create a temporary directory for extracting backup + with tempfile.TemporaryDirectory() as temp_dir: + # Extract the backup ZIP + with zipfile.ZipFile(backup_path, 'r') as zipf: + zipf.extractall(temp_dir) + + # Get backup metadata + metadata_path = os.path.join(temp_dir, 'backup_metadata.json') + if os.path.exists(metadata_path): + with open(metadata_path, 'r') as f: + metadata = json.load(f) + else: + metadata = { + "includes_mp3s": True, + "includes_config": True + } + + # Restore database + db_backup_path = os.path.join(temp_dir, 'song_data.db') + db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + + if os.path.exists(db_backup_path): + # Create a backup of the current database before overwriting + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + db_current_backup = f"{db_path}.{timestamp}.bak" + + if os.path.exists(db_path): + shutil.copy2(db_path, db_current_backup) + logger.info(f"Created backup of current database at {db_current_backup}") + + # Restore the database from backup + shutil.copy2(db_backup_path, db_path) + logger.info(f"Restored database from backup") + else: + logger.error("Database file not found in backup") + return { + "status": "error", + "message": "Database file not found in backup" + } + + # Restore MP3 files if included in backup + if metadata.get("includes_mp3s", True): + mp3_backup_dir = os.path.join(temp_dir, 'mp3') + if os.path.exists(mp3_backup_dir): + mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3') + + # Create backup of current MP3 files + if os.path.exists(mp3_dir): + mp3_backup = f"{mp3_dir}.{timestamp}.bak" + shutil.copytree(mp3_dir, mp3_backup) + logger.info(f"Created backup of current MP3 files at {mp3_backup}") + + # Remove current MP3 directory and replace with backup + if os.path.exists(mp3_dir): + shutil.rmtree(mp3_dir) + + # Create MP3 directory if it doesn't exist + os.makedirs(mp3_dir, exist_ok=True) + + # Copy MP3 files from backup + for mp3_file in os.listdir(mp3_backup_dir): + if mp3_file.endswith('.mp3'): + source_path = os.path.join(mp3_backup_dir, mp3_file) + dest_path = os.path.join(mp3_dir, mp3_file) + shutil.copy2(source_path, dest_path) + + logger.info(f"Restored MP3 files from backup") + + # Restore config files if included in backup + if metadata.get("includes_config", True): + config_backup_dir = os.path.join(temp_dir, 'config') + if os.path.exists(config_backup_dir): + # Restore .env file if present in backup + env_backup_path = os.path.join(config_backup_dir, '.env') + if os.path.exists(env_backup_path): + env_path = os.path.join(os.path.dirname(current_app.root_path), '.env') + + # Backup current .env + if os.path.exists(env_path): + env_backup = f"{env_path}.{timestamp}.bak" + shutil.copy2(env_path, env_backup) + logger.info(f"Created backup of current .env file at {env_backup}") + + # Restore .env from backup + shutil.copy2(env_backup_path, env_path) + logger.info(f"Restored .env file from backup") + + # Restore system settings from JSON if present + settings_backup_path = os.path.join(config_backup_dir, 'system_settings.json') + if os.path.exists(settings_backup_path): + try: + with open(settings_backup_path, 'r') as f: + settings = json.load(f) + + # Import within function to avoid circular imports + from musicround.models import SystemSetting, db + + # Restore each setting + for key, value in settings.items(): + SystemSetting.set(key, value) + + logger.info("Restored system settings from backup") + except Exception as e: + logger.error(f"Error restoring system settings: {str(e)}") + + return { + "status": "success", + "message": "Backup restored successfully", + "backup_name": backup_filename + } + + except Exception as e: + logger.error(f"Error restoring backup {backup_filename}: {str(e)}") + return { + "status": "error", + "message": f"Error restoring backup: {str(e)}" + } + +def verify_backup(backup_filename): + """ + Verify the integrity of a backup file. + + Args: + backup_filename: Name of the backup file to verify + + Returns: + dict: Verification result + """ + backup_dir = os.path.join('/data', 'backups') + backup_path = os.path.join(backup_dir, backup_filename) + + if not os.path.exists(backup_path): + return { + "status": "error", + "message": f"Backup file {backup_filename} not found", + "is_valid": False + } + + try: + # Check if the file is a valid ZIP + if not zipfile.is_zipfile(backup_path): + return { + "status": "error", + "message": f"Backup file is not a valid ZIP archive", + "is_valid": False + } + + # Try to open the ZIP and extract metadata + with zipfile.ZipFile(backup_path, 'r') as zipf: + # Test the integrity of all files in the ZIP + test_result = zipf.testzip() + if test_result is not None: + return { + "status": "error", + "message": f"Backup file contains corrupted files, first bad file: {test_result}", + "is_valid": False + } + + # Check for essential files + if 'song_data.db' not in zipf.namelist(): + return { + "status": "error", + "message": "Backup file does not contain a database", + "is_valid": False + } + + # Extract metadata if available + if 'backup_metadata.json' in zipf.namelist(): + with zipf.open('backup_metadata.json') as f: + metadata = json.load(f) + else: + metadata = {"version": "Unknown"} + + # If we got here, the backup is valid + return { + "status": "success", + "message": "Backup file is valid", + "is_valid": True, + "version": metadata.get("version", "Unknown"), + "timestamp": metadata.get("timestamp", "Unknown") + } + + except Exception as e: + logger.error(f"Error verifying backup {backup_filename}: {str(e)}") + return { + "status": "error", + "message": f"Error verifying backup: {str(e)}", + "is_valid": False + } + +def schedule_backup(schedule_time=None, frequency='daily', retention_days=30): + """ + Schedule automatic backups. + + Args: + schedule_time: Time to run the backup (HH:MM format) + frequency: Frequency of backups ('hourly', 'daily', 'weekly') + retention_days: Number of days of backups to keep (0 = keep all) + + Returns: + dict: Operation status information + """ + # This would typically integrate with a scheduler like cron + # For now, we'll just store the settings in SystemSetting + try: + from musicround.models import SystemSetting + + # Get current time if not provided + if schedule_time is None: + schedule_time = datetime.now().strftime('%H:%M') + + # Store backup schedule settings + SystemSetting.set('backup_schedule_time', schedule_time) + SystemSetting.set('backup_schedule_frequency', frequency) + SystemSetting.set('backup_schedule_enabled', 'true') + SystemSetting.set('backup_retention_days', str(retention_days)) + + # If retention policy is set, apply it immediately + if retention_days > 0: + apply_retention_policy(retention_days) + + return { + "status": "success", + "message": f"Backup scheduled for {schedule_time} ({frequency}), keeping {retention_days} days of backups", + "schedule_time": schedule_time, + "frequency": frequency, + "retention_days": retention_days + } + except Exception as e: + logger.error(f"Error scheduling backup: {str(e)}") + return { + "status": "error", + "message": f"Error scheduling backup: {str(e)}" + } + +def get_backup_summary(): + """ + Get a summary of backup system status. + + Returns: + dict: Summary information including counts, schedule info, etc. + """ + from musicround.models import SystemSetting + + # Get all backups + backups = list_backups() + + # Extract info from settings + schedule_enabled = SystemSetting.get('backup_schedule_enabled', 'false') == 'true' + schedule_time = SystemSetting.get('backup_schedule_time', '03:00') + schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily') + retention_days = int(SystemSetting.get('backup_retention_days', '30')) + + # Calculate next backup time based on schedule + from datetime import datetime, time, timedelta + now = datetime.now() + + next_backup = None + if schedule_enabled: + try: + # Parse schedule time + hour, minute = map(int, schedule_time.split(':')) + schedule_time_obj = time(hour, minute) + + # Calculate next occurrence + next_backup_date = now.date() + next_backup_datetime = datetime.combine(next_backup_date, schedule_time_obj) + + # If today's scheduled time has passed, move to next occurrence based on frequency + if next_backup_datetime < now: + if schedule_frequency == 'hourly': + next_backup_datetime = now + timedelta(hours=1) + elif schedule_frequency == 'daily': + next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=1), schedule_time_obj) + elif schedule_frequency == 'weekly': + next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=7), schedule_time_obj) + + next_backup = next_backup_datetime.strftime('%Y-%m-%d %H:%M') + except: + next_backup = "Error calculating next backup time" + + # Get latest backup info + latest_backup = backups[0] if backups else None + + return { + "backup_count": len(backups), + "latest_backup": latest_backup, + "schedule_enabled": schedule_enabled, + "schedule_time": schedule_time, + "schedule_frequency": schedule_frequency, + "next_backup": next_backup, + "backup_location": "/data/backups", + "retention_days": retention_days + } + +def generate_backup_config_suggestion(retention_days=30): + """ + Generate a configuration suggestion for setting up automated backups. + This does NOT modify any files, it only returns a suggestion. + + Args: + retention_days: Number of days to keep backups + + Returns: + dict: Configuration suggestion and instructions + """ + # Generate the backup schedule configuration suggestion + from musicround.models import SystemSetting + + # Get backup schedule information + schedule_time = SystemSetting.get('backup_schedule_time', '03:00') + schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily') + + # Map schedule frequency to cron expressions for documentation + frequency_map = { + 'hourly': '@hourly', + 'daily': '@daily', + 'weekly': '@weekly' + } + + schedule_cron = frequency_map.get(schedule_frequency, '@daily') + + # Generate docker-compose config example + docker_compose_suggestion = f"""labels: + ofelia.enabled: "true" + ofelia.job-exec.backup.schedule: "{schedule_cron}" + ofelia.job-exec.backup.command: "python /app/run.py backup create --auto" + ofelia.job-exec.backup.no-overlap: "true" + # Retention policy - automatically delete backups older than {retention_days} days + ofelia.job-exec.retention.schedule: "@weekly" + ofelia.job-exec.retention.command: "python /app/run.py backup retention --days {retention_days}" + ofelia.job-exec.retention.no-overlap: "true" +""" + + # Generate ofelia.ini config example (for standalone setups) + ofelia_ini_suggestion = f"""[global] +save-folder = /var/log/ofelia + +[job-exec "backup"] +schedule = {schedule_cron} +command = python /app/run.py backup create --auto +user = root +no-overlap = true + +[job-exec "retention"] +schedule = @weekly +command = python /app/run.py backup retention --days {retention_days} +user = root +no-overlap = true +""" + + # Generate instructions for manual setup + instructions = f"""To set up automated backups, add the configuration to your Docker Compose file OR use the ofelia.ini file. + +Option 1: Add these labels to your main service in docker-compose.yml: +{docker_compose_suggestion} + +Option 2: Add these sections to ofelia.ini: +{ofelia_ini_suggestion} + +After making changes, restart your containers to apply the configuration: +docker-compose down +docker-compose up -d +""" + + return { + "status": "success", + "schedule": { + "frequency": schedule_frequency, + "time": schedule_time, + "retention_days": retention_days + }, + "docker_compose_suggestion": docker_compose_suggestion, + "ofelia_ini_suggestion": ofelia_ini_suggestion, + "instructions": instructions, + "message": "Generated backup configuration suggestion" + } + +def apply_retention_policy(retention_days=30): + """ + Apply the backup retention policy by deleting old backups. + + Args: + retention_days: Number of days of backups to keep (0 = keep all) + + Returns: + dict: Operation status information + """ + if retention_days <= 0: + return { + "status": "success", + "message": "Retention policy disabled, all backups kept", + "deleted_count": 0, + "deleted_backups": [] + } + + try: + from datetime import datetime, timedelta + import os + + # Get the cutoff date + cutoff_date = datetime.now() - timedelta(days=retention_days) + + # Get list of all backups + backups = list_backups() + + # Filter to find backups older than the cutoff date + deleted_backups = [] + + for backup in backups: + # Get backup timestamp + backup_time = None + + if 'timestamp' in backup: + try: + backup_time = datetime.fromisoformat(backup['timestamp']) + except (ValueError, TypeError): + # Try the file date as fallback + if 'file_date' in backup: + try: + backup_time = datetime.fromisoformat(backup['file_date']) + except (ValueError, TypeError): + # Can't determine date, skip this backup + continue + elif 'file_date' in backup: + try: + backup_time = datetime.fromisoformat(backup['file_date']) + except (ValueError, TypeError): + # Can't determine date, skip this backup + continue + + # If we couldn't determine when this backup was created, skip it + if not backup_time: + continue + + # Check if this backup is older than the cutoff date + if backup_time < cutoff_date: + backup_path = backup.get('file_path') + if backup_path and os.path.exists(backup_path): + try: + os.remove(backup_path) + deleted_backups.append({ + 'name': backup.get('backup_name') or os.path.basename(backup_path), + 'date': backup_time.isoformat() + }) + except Exception as e: + logger.error(f"Error deleting old backup {backup_path}: {str(e)}") + + return { + "status": "success", + "message": f"Retention policy applied: deleted {len(deleted_backups)} backups older than {retention_days} days", + "deleted_count": len(deleted_backups), + "deleted_backups": deleted_backups + } + + except Exception as e: + logger.error(f"Error applying retention policy: {str(e)}") + return { + "status": "error", + "message": f"Error applying retention policy: {str(e)}", + "deleted_count": 0, + "deleted_backups": [] + } \ No newline at end of file diff --git a/musicround/helpers/dropbox_helper.py b/musicround/helpers/dropbox_helper.py new file mode 100644 index 0000000..1bf7a5c --- /dev/null +++ b/musicround/helpers/dropbox_helper.py @@ -0,0 +1,495 @@ +""" +Helpers for Dropbox API integration +""" +from flask import current_app, url_for, redirect, session +import requests +import json +import os +from datetime import datetime, timedelta +from flask_login import current_user + +def get_dropbox_auth_url(): + """Get the authorization URL for Dropbox OAuth flow""" + app_key = current_app.config.get('DROPBOX_APP_KEY') + redirect_uri = url_for('users.dropbox_callback', _external=True) + + # Add the required scopes for our application + scopes = ["files.content.read", "files.content.write", "sharing.write","account_info.read"] + + auth_url = f'https://www.dropbox.com/oauth2/authorize?client_id={app_key}&response_type=code&redirect_uri={redirect_uri}&scope={" ".join(scopes)}&token_access_type=offline' + return auth_url + +def exchange_code_for_token(code): + """Exchange the authorization code for an access token""" + app_key = current_app.config.get('DROPBOX_APP_KEY') + app_secret = current_app.config.get('DROPBOX_APP_SECRET') + redirect_uri = url_for('users.dropbox_callback', _external=True) + + data = { + 'code': code, + 'grant_type': 'authorization_code', + 'client_id': app_key, + 'client_secret': app_secret, + 'redirect_uri': redirect_uri + } + + response = requests.post('https://api.dropboxapi.com/oauth2/token', data=data) + + if response.status_code == 200: + return response.json() + else: + current_app.logger.error(f"Error exchanging code for token: {response.text}") + return None + +def refresh_dropbox_token(refresh_token): + """Refresh an expired Dropbox access token""" + app_key = current_app.config.get('DROPBOX_APP_KEY') + app_secret = current_app.config.get('DROPBOX_APP_SECRET') + + data = { + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + 'client_id': app_key, + 'client_secret': app_secret + } + + response = requests.post('https://api.dropboxapi.com/oauth2/token', data=data) + + if response.status_code == 200: + return response.json() + else: + current_app.logger.error(f"Error refreshing token: {response.text}") + return None + +def get_dropbox_user_info(access_token): + """Get user info from Dropbox API""" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + response = requests.post('https://api.dropboxapi.com/2/users/get_current_account', headers=headers) + + if response.status_code == 200: + return response.json() + else: + current_app.logger.error(f"Error getting user info: {response.text}") + return None + +def get_current_user_dropbox_token(): + """Get a valid Dropbox access token for the current user, refreshing if needed""" + if not current_user or not current_user.is_authenticated: + current_app.logger.error("No authenticated user") + return None + + # Check if token exists and is valid + if (current_user.dropbox_token and + current_user.dropbox_token_expiry and + current_user.dropbox_token_expiry > datetime.now() + timedelta(minutes=5)): + # Token is valid and not about to expire + return current_user.dropbox_token + + # Token is missing or about to expire - try to refresh + if current_user.dropbox_refresh_token: + from musicround.models import db + + # Try to refresh the token + token_info = refresh_dropbox_token(current_user.dropbox_refresh_token) + + if token_info and 'access_token' in token_info: + # Update token in database + current_user.dropbox_token = token_info['access_token'] + expires_in = token_info.get('expires_in', 14400) # Default to 4 hours if not specified + current_user.dropbox_token_expiry = datetime.now() + timedelta(seconds=expires_in) + + db.session.commit() + + return current_user.dropbox_token + + # If we get here, we couldn't refresh the token + current_app.logger.error("Failed to get valid Dropbox token") + return None + +def upload_and_share(file_path, dropbox_path): + """ + Upload a file to Dropbox and create a shared link + + Args: + file_path: Local path to the file to upload + dropbox_path: Destination path in Dropbox (including filename) + + Returns: + Shared link URL or None if upload failed + """ + token = get_current_user_dropbox_token() + if not token: + current_app.logger.error("No valid Dropbox token available") + return None + + # Make sure dropbox_path starts with / + if not dropbox_path.startswith('/'): + dropbox_path = '/' + dropbox_path + + # First, upload the file + try: + # Check if file exists + if not os.path.exists(file_path): + current_app.logger.error(f"File not found: {file_path}") + return None + + # Get file size + file_size = os.path.getsize(file_path) + + # For small files (< 150 MB), use simple upload + if file_size < 150 * 1024 * 1024: + with open(file_path, 'rb') as f: + file_data = f.read() + + headers = { + 'Authorization': f'Bearer {token}', + 'Dropbox-API-Arg': json.dumps({ + 'path': dropbox_path, + 'mode': 'overwrite', + 'autorename': True, + 'mute': False + }), + 'Content-Type': 'application/octet-stream' + } + + response = requests.post( + 'https://content.dropboxapi.com/2/files/upload', + headers=headers, + data=file_data + ) + + if response.status_code != 200: + current_app.logger.error(f"Error uploading file: {response.text}") + return None + + file_metadata = response.json() + current_app.logger.info(f"File uploaded successfully: {file_metadata.get('path_display')}") + else: + # For larger files, we'd implement chunked upload here + current_app.logger.error(f"File too large for simple upload: {file_size} bytes") + return None + + # Now create a shared link + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + data = { + 'path': file_metadata.get('path_lower', dropbox_path), + 'settings': { + 'requested_visibility': 'public' # Make link publicly accessible + } + } + + response = requests.post( + 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings', + headers=headers, + json=data + ) + + # If the link already exists, we'll get a 409 error with "shared_link_already_exists" + if response.status_code == 409 and "shared_link_already_exists" in response.text: + # Get existing links + list_data = { + 'path': file_metadata.get('path_lower', dropbox_path) + } + + list_response = requests.post( + 'https://api.dropboxapi.com/2/sharing/list_shared_links', + headers=headers, + json=list_data + ) + + if list_response.status_code == 200: + links_data = list_response.json() + if links_data.get('links') and len(links_data['links']) > 0: + # Return the first link's URL + return links_data['links'][0].get('url') + + elif response.status_code == 200: + share_data = response.json() + current_app.logger.info(f"Created shared link: {share_data.get('url')}") + return share_data.get('url') + + current_app.logger.error(f"Error creating shared link: {response.text}") + return None + + except Exception as e: + current_app.logger.error(f"Exception in upload_and_share: {str(e)}") + return None + +def refresh_dropbox_token_if_needed(user): + """ + Check if user's Dropbox token needs refreshing and refresh it if needed + + Args: + user: The User object with Dropbox token information + + Returns: + dict: {'success': True/False, 'message': 'success or error message'} + """ + if not user.dropbox_token or not user.dropbox_refresh_token: + return {'success': False, 'message': 'No Dropbox token available'} + + # If token is still valid, return success + if user.dropbox_token_expiry and user.dropbox_token_expiry > datetime.now() + timedelta(minutes=5): + return {'success': True, 'message': 'Token is still valid'} + + # Token needs refreshing + from musicround.models import db + + try: + token_info = refresh_dropbox_token(user.dropbox_refresh_token) + + if token_info and 'access_token' in token_info: + # Update token in database + user.dropbox_token = token_info['access_token'] + expires_in = token_info.get('expires_in', 14400) # Default to 4 hours if not specified + user.dropbox_token_expiry = datetime.now() + timedelta(seconds=expires_in) + + # If we got a new refresh token, update that too + if token_info.get('refresh_token'): + user.dropbox_refresh_token = token_info['refresh_token'] + + db.session.commit() + + return {'success': True, 'message': 'Token refreshed successfully'} + else: + return {'success': False, 'message': 'Failed to refresh token'} + + except Exception as e: + current_app.logger.error(f"Error refreshing Dropbox token: {str(e)}") + return {'success': False, 'message': f'Error refreshing token: {str(e)}'} + +def upload_to_dropbox(access_token, dropbox_path, data, mode='binary'): + """ + Upload data to Dropbox + + Args: + access_token: Dropbox access token + dropbox_path: Destination path in Dropbox (including filename) + data: The data to upload (bytes for binary mode, string for text mode) + mode: 'binary' or 'text' + + Returns: + dict: {'success': True/False, 'message': 'success or error message', 'metadata': file metadata if successful} + """ + # Make sure dropbox_path starts with / + if not dropbox_path.startswith('/'): + dropbox_path = '/' + dropbox_path + + try: + # Convert string data to bytes if text mode + if mode == 'text' and isinstance(data, str): + data = data.encode('utf-8') + + # Debug token information + token_preview = access_token[:10] + '...' if access_token else 'None' + current_app.logger.debug(f"Upload to Dropbox - Path: {dropbox_path}, Token preview: {token_preview}, Data size: {len(data) if data else 0} bytes") + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Dropbox-API-Arg': json.dumps({ + 'path': dropbox_path, + 'mode': 'overwrite', + 'autorename': True, + 'mute': False + }), + 'Content-Type': 'application/octet-stream' + } + + current_app.logger.debug(f"Dropbox API headers: {headers}") + + response = requests.post( + 'https://content.dropboxapi.com/2/files/upload', + headers=headers, + data=data + ) + + current_app.logger.debug(f"Dropbox upload response code: {response.status_code}") + + if response.status_code != 200: + current_app.logger.error(f"Error uploading to Dropbox: Status code {response.status_code}") + current_app.logger.error(f"Response headers: {response.headers}") + + # Try to parse response body + try: + error_json = response.json() + current_app.logger.error(f"Error details: {json.dumps(error_json, indent=2)}") + error_message = error_json.get('error_summary', 'Unknown error') + except: + error_message = response.text[:500] # Limit to first 500 chars + current_app.logger.error(f"Raw error response: {error_message}") + + return { + 'success': False, + 'message': f"Error uploading file: {response.status_code} - {error_message}", + 'status_code': response.status_code + } + + file_metadata = response.json() + current_app.logger.info(f"File uploaded successfully: {file_metadata.get('path_display')}") + current_app.logger.debug(f"Upload metadata: {json.dumps(file_metadata, indent=2)}") + + return { + 'success': True, + 'message': 'File uploaded successfully', + 'metadata': file_metadata + } + + except Exception as e: + import traceback + current_app.logger.error(f"Exception in upload_to_dropbox: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return { + 'success': False, + 'message': f"Error uploading file: {str(e)}" + } + +def create_shared_link(access_token, dropbox_path): + """ + Create a shared link for a file in Dropbox + + Args: + access_token: Dropbox access token + dropbox_path: Path to the file in Dropbox + + Returns: + dict: {'success': True/False, 'message': 'success or error message', 'url': shared link URL if successful} + """ + # Make sure dropbox_path starts with / + if not dropbox_path.startswith('/'): + dropbox_path = '/' + dropbox_path + + try: + # Debug token information + token_preview = access_token[:10] + '...' if access_token else 'None' + current_app.logger.debug(f"Creating shared link - Path: {dropbox_path}, Token preview: {token_preview}") + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + data = { + 'path': dropbox_path, + 'settings': { + 'requested_visibility': 'public' # Make link publicly accessible + } + } + + current_app.logger.debug(f"Sharing API request data: {json.dumps(data, indent=2)}") + + response = requests.post( + 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings', + headers=headers, + json=data + ) + + current_app.logger.debug(f"Sharing API response code: {response.status_code}") + + # If the link already exists, we'll get a 409 error with "shared_link_already_exists" + if response.status_code == 409 and "shared_link_already_exists" in response.text: + current_app.logger.debug("Shared link already exists, retrieving existing link") + + # Get existing links + list_data = { + 'path': dropbox_path + } + + list_response = requests.post( + 'https://api.dropboxapi.com/2/sharing/list_shared_links', + headers=headers, + json=list_data + ) + + current_app.logger.debug(f"List shared links response code: {list_response.status_code}") + + if list_response.status_code == 200: + links_data = list_response.json() + current_app.logger.debug(f"Existing links data: {json.dumps(links_data, indent=2)}") + + if links_data.get('links') and len(links_data['links']) > 0: + # Return the first link's URL + url = links_data['links'][0].get('url') + current_app.logger.info(f"Retrieved existing shared link: {url}") + return { + 'success': True, + 'message': 'Existing shared link retrieved', + 'url': url + } + else: + current_app.logger.error("No links found despite 'shared_link_already_exists' error") + else: + current_app.logger.error(f"Error listing shared links: {list_response.text}") + + elif response.status_code == 200: + share_data = response.json() + current_app.logger.info(f"Created shared link: {share_data.get('url')}") + current_app.logger.debug(f"Shared link data: {json.dumps(share_data, indent=2)}") + return { + 'success': True, + 'message': 'Shared link created successfully', + 'url': share_data.get('url') + } + + # Try to parse error response + try: + error_json = response.json() + current_app.logger.error(f"Sharing API error details: {json.dumps(error_json, indent=2)}") + error_message = error_json.get('error_summary', 'Unknown error') + except: + error_message = response.text[:500] # Limit to first 500 chars + current_app.logger.error(f"Raw sharing API error response: {error_message}") + + current_app.logger.error(f"Error creating shared link: {response.status_code} - {error_message}") + return { + 'success': False, + 'message': f"Error creating shared link: {response.status_code} - {error_message}", + 'status_code': response.status_code + } + + except Exception as e: + import traceback + current_app.logger.error(f"Exception in create_shared_link: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return { + 'success': False, + 'message': f"Error creating shared link: {str(e)}" + } + +def get_dropbox_account_info(access_token): + """ + Get account information for a Dropbox user + + Args: + access_token: Dropbox access token + + Returns: + dict: Account information or None if failed + """ + headers = { + 'Authorization': f'Bearer {access_token}' + } + + try: + # According to the API documentation, this endpoint requires no request body + response = requests.post( + 'https://api.dropboxapi.com/2/users/get_current_account', + headers=headers + ) + + if response.status_code == 200: + return response.json() + else: + current_app.logger.error(f"Error getting Dropbox account info: {response.text}") + return None + + except Exception as e: + current_app.logger.error(f"Exception in get_dropbox_account_info: {str(e)}") + return None \ No newline at end of file diff --git a/musicround/helpers/email_helper.py b/musicround/helpers/email_helper.py new file mode 100644 index 0000000..e739f70 --- /dev/null +++ b/musicround/helpers/email_helper.py @@ -0,0 +1,103 @@ +""" +Email helper functions for Quizzical Beats +""" +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from flask import current_app + +def send_email(recipient, subject, body_text, attachments=None): + """ + Sends an email with optional attachments. + + Args: + recipient (str): Email address of the recipient + subject (str): Email subject + body_text (str): Plain text email body + attachments (list): Optional list of attachment dictionaries with keys: + - 'data': The binary data of the attachment + - 'filename': Filename for the attachment + - 'mimetype': Mimetype string like 'application/pdf' + + Returns: + tuple: (success, message) where success is a boolean and message contains + details about the result + """ + # Get mail configuration from environment variables + mail_host = current_app.config.get('MAIL_HOST') + mail_port = current_app.config.get('MAIL_PORT') + mail_username = current_app.config.get('MAIL_USERNAME') + mail_password = current_app.config.get('MAIL_PASSWORD') + mail_sender = current_app.config.get('MAIL_SENDER') + + # Check if all email configuration parameters are available + missing_config = [] + if not mail_host: + missing_config.append("MAIL_HOST") + if not mail_port: + missing_config.append("MAIL_PORT") + if not mail_username: + missing_config.append("MAIL_USERNAME") + if not mail_password: + missing_config.append("MAIL_PASSWORD") + if not mail_sender: + missing_config.append("MAIL_SENDER") + + if missing_config: + missing_params = ", ".join(missing_config) + error_msg = f"Email server configuration is incomplete. Missing parameters: {missing_params}." + current_app.logger.error(f"Email configuration error: {error_msg}") + current_app.logger.error(f"Current config values - MAIL_HOST: {'set' if mail_host else 'missing'}, " + f"MAIL_PORT: {'set' if mail_port else 'missing'}, " + f"MAIL_USERNAME: {'set' if mail_username else 'missing'}, " + f"MAIL_PASSWORD: {'set' if mail_password else 'missing'}, " + f"MAIL_SENDER: {'set' if mail_sender else 'missing'}") + return False, error_msg + + # Create message object + msg = MIMEMultipart() + msg['From'] = mail_sender + msg['To'] = recipient + msg['Subject'] = subject + + # Attach text body + msg.attach(MIMEText(body_text, 'plain')) + + # Attach files if provided + if attachments: + for attachment in attachments: + part = MIMEBase( + attachment.get('mimetype', 'application/octet-stream').split('/')[0], + attachment.get('mimetype', 'application/octet-stream').split('/')[1] + ) + part.set_payload(attachment['data']) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename={attachment["filename"]}' + ) + msg.attach(part) + + try: + current_app.logger.info(f"Attempting to send email to {recipient} via {mail_host}:{mail_port}") + with smtplib.SMTP(mail_host, mail_port) as server: + server.starttls() + current_app.logger.debug("STARTTLS established") + server.login(mail_username, mail_password) + current_app.logger.debug(f"Login successful for {mail_username}") + server.sendmail(mail_sender, recipient, msg.as_string()) + current_app.logger.info(f"Email sent successfully from {mail_sender} to {recipient}") + + return True, f'Email sent successfully to {recipient}!' + + except smtplib.SMTPException as e: + error_msg = str(e) + current_app.logger.error(f"SMTP Error: {error_msg}") + current_app.logger.error(f"Failed to send email from {mail_sender} to {recipient} via {mail_host}:{mail_port}") + return False, error_msg + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + current_app.logger.error(error_msg) + return False, error_msg \ No newline at end of file diff --git a/musicround/helpers/import_helper.py b/musicround/helpers/import_helper.py new file mode 100644 index 0000000..17a0f16 --- /dev/null +++ b/musicround/helpers/import_helper.py @@ -0,0 +1,611 @@ +""" +Unified import helper for importing music content across different services. +This module provides consistent import functionality for tracks, albums, and playlists +from various music streaming services like Spotify and Deezer. +""" +import json +import logging +import secrets +import string +from flask import current_app, flash +from musicround.models import Song, Tag, db +from musicround.helpers.metadata import get_song_metadata_by_isrc + +def generate_token(length=32): + """ + Generate a secure random token for authentication or validation purposes. + + Args: + length (int): The length of the token to generate (default: 32) + + Returns: + str: A secure random token string + """ + # Use secrets module for cryptographically strong random numbers + alphabet = string.ascii_letters + string.digits + token = ''.join(secrets.choice(alphabet) for _ in range(length)) + return token + +class ImportHelper: + """Unified helper for importing music content from different services.""" + + # Helper method to create tags from genres + @staticmethod + def create_tags_from_genre(song, genre_data): + """ + Create tags from genre data and associate them with a song + + Args: + song (Song): Song object to associate tags with + genre_data (str or list): Genre data that could be string, list, or comma-separated values + """ + if not genre_data: + return + + genres = [] + + # Handle different types of genre data + if isinstance(genre_data, str): + # Handle comma-separated genre string + genres = [g.strip() for g in genre_data.split(',')] + elif isinstance(genre_data, list): + # Handle genre list + genres = [g.strip() if isinstance(g, str) else str(g).strip() for g in genre_data] + + # Add each genre as a tag + for genre_name in genres: + if not genre_name: + continue + + # Convert to lowercase for consistency + genre_name = genre_name.lower() + + # Find existing tag or create new one + tag = Tag.query.filter(Tag.name.ilike(genre_name)).first() + if not tag: + tag = Tag(name=genre_name) + db.session.add(tag) + try: + db.session.flush() # Flush to get ID but don't commit yet + except Exception as e: + current_app.logger.error(f"Error creating tag '{genre_name}': {e}") + continue + + # Add tag to song if not already present + if tag not in song.tags: + song.tags.append(tag) + current_app.logger.info(f"Added tag '{tag.name}' to song '{song.title}'") + + @staticmethod + def import_item(service_name, item_type, item_id): + """ + Import a track, album, or playlist from a specific service. + + Args: + service_name (str): Name of the service (e.g., 'spotify', 'deezer') + item_type (str): Type of item ('track', 'album', 'playlist') + item_id (str): ID of the item to import + + Returns: + dict: Summary of import operation with counts of imported items + """ + current_app.logger.info(f"Importing {item_type} {item_id} from {service_name}") + + result = { + 'success': False, + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [], + 'service': service_name, + 'item_type': item_type, + 'item_id': item_id + } + + try: + if service_name.lower() == 'spotify': + # Get Spotify client + sp = current_app.config.get('sp') + if not sp: + result['errors'].append("Spotify client not configured") + return result + + # Handle based on item type + if item_type.lower() == 'track': + track_result = ImportHelper.import_spotify_track(sp, item_id) + result.update(track_result) + elif item_type.lower() == 'album': + album_result = ImportHelper.import_spotify_album(sp, item_id) + result.update(album_result) + elif item_type.lower() == 'playlist': + playlist_result = ImportHelper.import_spotify_playlist(sp, item_id) + result.update(playlist_result) + else: + result['errors'].append(f"Unknown item type: {item_type}") + return result + + elif service_name.lower() == 'deezer': + # Get Deezer client + deezer_client = current_app.config.get('deezer') + if not deezer_client: + result['errors'].append("Deezer client not configured") + return result + + # Handle based on item type + if item_type.lower() == 'track': + track_result = ImportHelper.import_deezer_track(deezer_client, item_id) + result.update(track_result) + elif item_type.lower() == 'album': + album_result = ImportHelper.import_deezer_album(deezer_client, item_id) + result.update(album_result) + elif item_type.lower() == 'playlist': + playlist_result = ImportHelper.import_deezer_playlist(deezer_client, item_id) + result.update(playlist_result) + else: + result['errors'].append(f"Unknown item type: {item_type}") + return result + else: + result['errors'].append(f"Unsupported service: {service_name}") + return result + + result['success'] = len(result['errors']) == 0 + return result + + except Exception as e: + current_app.logger.error(f"Error importing {item_type} {item_id} from {service_name}: {str(e)}") + result['errors'].append(str(e)) + result['success'] = False + return result + + # ------------- SPOTIFY IMPORT METHODS ------------- + + @staticmethod + def import_spotify_track(sp, track_id): + """Import a single track from Spotify""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # First check if this Spotify track is already in our database + existing_song = Song.query.filter_by(spotify_id=track_id).first() + if existing_song: + current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') + result['skipped_count'] += 1 + return result + + # Get track info from Spotify + track_info = sp.track(track_id) + if not track_info: + result['errors'].append(f"Track with ID {track_id} not found on Spotify") + result['error_count'] += 1 + return result + + # Try to get ISRC if available + isrc = track_info.get('external_ids', {}).get('isrc') + song = None + + # If we have an ISRC, check if a song with this ISRC already exists + if isrc: + existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() + if existing_by_isrc: + current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') + result['skipped_count'] += 1 + return result + + # Get comprehensive metadata using ISRC + current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") + metadata = get_song_metadata_by_isrc(isrc, current_app) + + if metadata and metadata.get("title"): + # Create song with enriched metadata + song = Song( + spotify_id=track_id, + deezer_id=metadata.get("deezer_id"), + title=metadata.get("title", track_info['name']), + artist=metadata.get("artist_name", ", ".join([artist['name'] for artist in track_info['artists']])), + genre=metadata.get("genre"), + year=metadata.get("year"), + preview_url=metadata.get("preview_url", track_info.get('preview_url')), + cover_url=metadata.get("cover_url") or (track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None), + popularity=metadata.get("popularity", track_info.get('popularity')), + isrc=isrc, + album_name=track_info.get('album', {}).get('name'), + metadata_sources=','.join(metadata.get("sources", [])), + source='spotify', + spotify_preview_url=metadata.get("spotify_preview_url"), + deezer_preview_url=metadata.get("deezer_preview_url"), + apple_preview_url=metadata.get("apple_preview_url"), + youtube_preview_url=metadata.get("youtube_preview_url"), + spotify_cover_url=metadata.get("spotify_cover_url"), + deezer_cover_url=metadata.get("deezer_cover_url"), + apple_cover_url=metadata.get("apple_cover_url"), + additional_data=json.dumps({ + k: v for k, v in metadata.items() + if k not in ['artist_name', 'title', 'year', 'genre', 'popularity', + 'preview_url', 'sources', 'isrc', 'spotify_id', + 'deezer_id', 'cover_url', 'spotify_preview_url', + 'deezer_preview_url', 'apple_preview_url', + 'youtube_preview_url', 'spotify_cover_url', + 'deezer_cover_url', 'apple_cover_url'] + }) if metadata else None + ) + current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") + else: + # Fallback to just Spotify data with the ISRC + song = ImportHelper._create_song_from_spotify(track_info, isrc) + else: + # No ISRC available, just use Spotify data + song = ImportHelper._create_song_from_spotify(track_info) + + # We already checked for duplicates above, so we can add the song directly + if song: + db.session.add(song) + + # Create tags from genre information + if song.genre: + ImportHelper.create_tags_from_genre(song, song.genre) + + # Also check additional data for genres + if song.additional_data: + try: + additional_data = json.loads(song.additional_data) + if 'genres' in additional_data: + ImportHelper.create_tags_from_genre(song, additional_data['genres']) + except Exception as e: + current_app.logger.error(f"Error parsing additional data for genres: {e}") + + # Get audio features if this is a Spotify track - NEW ADDITION + ImportHelper._fetch_audio_features_for_song(sp, song) + + db.session.commit() + current_app.logger.info(f'Imported Spotify track {song.title} by {song.artist}') + result['imported_count'] += 1 + else: + current_app.logger.warning(f'Could not create song from Spotify track {track_id}') + result['skipped_count'] += 1 + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Spotify track {track_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result + + @staticmethod + def import_spotify_album(sp, album_id): + """Import all tracks from a Spotify album""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # Get album tracks from Spotify + album_tracks = sp.album_tracks(album_id) + if not album_tracks or 'items' not in album_tracks: + result['errors'].append(f"Album with ID {album_id} not found on Spotify") + result['error_count'] += 1 + return result + + # Import each track in the album + for track in album_tracks['items']: + if track and 'id' in track: + track_result = ImportHelper.import_spotify_track(sp, track['id']) + result['imported_count'] += track_result['imported_count'] + result['skipped_count'] += track_result['skipped_count'] + result['error_count'] += track_result['error_count'] + result['errors'].extend(track_result['errors']) + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Spotify album {album_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result + + @staticmethod + def import_spotify_playlist(sp, playlist_id): + """Import all tracks from a Spotify playlist""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # Get playlist tracks from Spotify + tracks_info = sp.playlist_tracks(playlist_id) + if not tracks_info or 'items' not in tracks_info: + result['errors'].append(f"Playlist with ID {playlist_id} not found on Spotify") + result['error_count'] += 1 + return result + + # Import each track in the playlist + items = tracks_info.get('items', []) + for item in items: + # 'track' can be None if it's a local or unavailable track + track_obj = item.get('track') + if track_obj and 'id' in track_obj: + track_result = ImportHelper.import_spotify_track(sp, track_obj['id']) + result['imported_count'] += track_result['imported_count'] + result['skipped_count'] += track_result['skipped_count'] + result['error_count'] += track_result['error_count'] + result['errors'].extend(track_result['errors']) + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Spotify playlist {playlist_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result + + @staticmethod + def _create_song_from_spotify(track_info, isrc=None): + """Create a song object from Spotify track data""" + # Basic song with just Spotify data + preview_url = track_info.get('preview_url') + cover_url = track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None + + # Extract year from album if available + year = None + if track_info.get('album') and track_info['album'].get('release_date'): + year = track_info['album']['release_date'][:4] + + # Try to get genre from album + genre = None + + # Create song object + return Song( + spotify_id=track_info['id'], + title=track_info['name'], + artist=", ".join([artist['name'] for artist in track_info['artists']]), + genre=genre, + year=year, + preview_url=preview_url, + cover_url=cover_url, + popularity=track_info.get('popularity'), + isrc=isrc, + album_name=track_info.get('album', {}).get('name'), + metadata_sources='spotify', + source='spotify' + ) + + @staticmethod + def _fetch_audio_features_for_song(sp, song): + """Fetch audio features for a Spotify song and update the song object""" + try: + audio_features = sp.audio_features(song.spotify_id) + if audio_features and len(audio_features) > 0: + features = audio_features[0] + song.danceability = features.get('danceability') + song.energy = features.get('energy') + song.key = features.get('key') + song.loudness = features.get('loudness') + song.mode = features.get('mode') + song.speechiness = features.get('speechiness') + song.acousticness = features.get('acousticness') + song.instrumentalness = features.get('instrumentalness') + song.liveness = features.get('liveness') + song.valence = features.get('valence') + song.tempo = features.get('tempo') + current_app.logger.info(f"Audio features fetched for song '{song.title}'") + except Exception as e: + current_app.logger.error(f"Error fetching audio features for song '{song.title}': {str(e)}") + + # ------------- DEEZER IMPORT METHODS ------------- + + @staticmethod + def import_deezer_track(deezer_client, track_id): + """Import a single track from Deezer""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # First check if this Deezer track is already in our database + existing_song = Song.query.filter_by(deezer_id=track_id).first() + if existing_song: + current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') + result['skipped_count'] += 1 + return result + + # Get track info from Deezer + track = deezer_client.get_track(track_id) + if not track: + result['errors'].append(f"Track with ID {track_id} not found on Deezer") + result['error_count'] += 1 + return result + + # Check if ISRC is available + isrc = track.get('isrc') + song = None + + # If we have an ISRC, check if a song with this ISRC already exists + if isrc: + existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() + if existing_by_isrc: + current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') + result['skipped_count'] += 1 + return result + + # Use metadata helper function to get comprehensive metadata + current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") + metadata = get_song_metadata_by_isrc(isrc, current_app) + + # Use metadata if found, otherwise fall back to Deezer data only + if metadata and metadata.get("title"): + song = Song( + deezer_id=track.get('id'), + spotify_id=metadata.get("spotify_id"), + title=metadata.get("title", track.get('title')), + artist=metadata.get("artist_name", track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist'), + preview_url=metadata.get("preview_url", track.get('preview')), + cover_url=metadata.get("cover_url") or track.get('album', {}).get('cover'), + genre=metadata.get("genre"), + year=metadata.get("year"), + popularity=metadata.get("popularity"), + isrc=isrc, + album_name=track.get('album', {}).get('title'), + metadata_sources=','.join(metadata.get("sources", [])), + source='deezer', + spotify_preview_url=metadata.get("spotify_preview_url"), + deezer_preview_url=metadata.get("deezer_preview_url"), + apple_preview_url=metadata.get("apple_preview_url"), + youtube_preview_url=metadata.get("youtube_preview_url"), + spotify_cover_url=metadata.get("spotify_cover_url"), + deezer_cover_url=metadata.get("deezer_cover_url"), + apple_cover_url=metadata.get("apple_cover_url"), + additional_data=json.dumps( + {k: v for k, v in metadata.items() if k not in ['artist_name', 'title', 'year', 'genre', + 'popularity', 'preview_url', 'sources', + 'isrc', 'spotify_id', 'deezer_id', 'cover_url', + 'spotify_preview_url', 'deezer_preview_url', + 'apple_preview_url', 'youtube_preview_url', + 'spotify_cover_url', 'deezer_cover_url', + 'apple_cover_url']} + ) if metadata else None + ) + current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") + else: + # Fallback to just Deezer data + song = Song( + deezer_id=track.get('id'), + title=track.get('title'), + artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', + preview_url=track.get('preview'), + cover_url=track.get('album', {}).get('cover'), + album_name=track.get('album', {}).get('title'), + metadata_sources='deezer', + source='deezer' + ) + else: + # No ISRC available, just use Deezer data + song = Song( + deezer_id=track.get('id'), + title=track.get('title'), + artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', + preview_url=track.get('preview'), + cover_url=track.get('album', {}).get('cover'), + album_name=track.get('album', {}).get('title'), + metadata_sources='deezer', + source='deezer' + ) + + # We already checked for duplicates above, so we can add the song directly + if song: + db.session.add(song) + + # Create tags from genre information + if song.genre: + ImportHelper.create_tags_from_genre(song, song.genre) + + # Also check additional data for genres + if song.additional_data: + try: + additional_data = json.loads(song.additional_data) + if 'genres' in additional_data: + ImportHelper.create_tags_from_genre(song, additional_data['genres']) + except Exception as e: + current_app.logger.error(f"Error parsing additional data for genres: {e}") + + db.session.commit() + current_app.logger.info(f'Imported Deezer track {song.title} by {song.artist}') + result['imported_count'] += 1 + else: + current_app.logger.warning(f'Could not create song from Deezer track {track_id}') + result['skipped_count'] += 1 + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Deezer track {track_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result + + @staticmethod + def import_deezer_album(deezer_client, album_id): + """Import all tracks from a Deezer album""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # Get album info from Deezer + album = deezer_client.get_album(album_id) + if not album or not album.get('tracks') or not album['tracks'].get('data'): + result['errors'].append(f"Album with ID {album_id} not found on Deezer") + result['error_count'] += 1 + return result + + # Import each track in the album + tracks = album['tracks']['data'] + for track in tracks: + track_id = track.get('id') + if track_id: + track_result = ImportHelper.import_deezer_track(deezer_client, track_id) + result['imported_count'] += track_result['imported_count'] + result['skipped_count'] += track_result['skipped_count'] + result['error_count'] += track_result['error_count'] + result['errors'].extend(track_result['errors']) + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Deezer album {album_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result + + @staticmethod + def import_deezer_playlist(deezer_client, playlist_id): + """Import all tracks from a Deezer playlist""" + result = { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + + try: + # Get playlist info from Deezer + playlist = deezer_client.get_playlist(playlist_id) + if not playlist or not playlist.get('tracks') or not playlist['tracks'].get('data'): + result['errors'].append(f"Playlist with ID {playlist_id} not found on Deezer") + result['error_count'] += 1 + return result + + # Import each track in the playlist + tracks = playlist['tracks']['data'] + for track in tracks: + track_id = track.get('id') + if track_id: + track_result = ImportHelper.import_deezer_track(deezer_client, track_id) + result['imported_count'] += track_result['imported_count'] + result['skipped_count'] += track_result['skipped_count'] + result['error_count'] += track_result['error_count'] + result['errors'].extend(track_result['errors']) + + return result + + except Exception as e: + current_app.logger.error(f"Error importing Deezer playlist {playlist_id}: {str(e)}") + result['errors'].append(str(e)) + result['error_count'] += 1 + return result \ No newline at end of file diff --git a/musicround/helpers/metadata.py b/musicround/helpers/metadata.py new file mode 100644 index 0000000..b29a1c5 --- /dev/null +++ b/musicround/helpers/metadata.py @@ -0,0 +1,839 @@ +import requests +import json +import statistics +import musicbrainzngs +import openai +from flask import current_app +from collections import Counter +import traceback # Added for detailed error tracking + +def get_song_metadata_by_isrc(isrc, app=None): + """ + Get comprehensive song metadata by ISRC code from multiple sources. + + Args: + isrc (str): The ISRC code to look up + app: Flask application context (optional) + + Returns: + dict: Standardized metadata with the following keys: + - artist_name: Artist name(s) + - title: Title of the song + - year: Year the song was first released + - genre: Primary genre + - genres: All genres as an array + - popularity: Popularity rating (0-100) + - preview_url: Main preview URL (prioritized from sources) + - sources: List of sources that provided data + - spotify_id: Spotify track ID if available + - deezer_id: Deezer track ID if available + - And more provider-specific data + """ + # Initialize result dictionary + metadata = { + "artist_name": None, + "title": None, + "year": None, + "genre": None, + "genres": [], # New array to store all genres + "popularity": None, + "preview_url": None, + "sources": [], + "isrc": isrc, + "spotify_id": None, + "deezer_id": None, + # Cover URLs from different sources + "cover_url": None, + "spotify_cover_url": None, + "deezer_cover_url": None, + "apple_cover_url": None, + # Preview URLs from different sources + "spotify_preview_url": None, + "deezer_preview_url": None, + "apple_preview_url": None, + "youtube_preview_url": None + } + + # Store results from different sources to compare + artist_names = [] + titles = [] + years = [] + genres = [] # This will collect all genres for final processing + preview_urls = [] + + # Initialize logger if app context provided + logger = app.logger if app else None + if logger: + logger.info(f"=== DEBUG: Starting metadata refresh for ISRC: {isrc} ===") + + try: + # 0. Query ACRCloud first (provides info from multiple platforms) + if logger: + logger.info(f"DEBUG: Querying ACRCloud for ISRC: {isrc}") + acrcloud_data = get_acrcloud_data(isrc, app) + if acrcloud_data: + metadata["sources"].append("acrcloud") + if logger: + logger.info(f"DEBUG: ACRCloud data received: {json.dumps(acrcloud_data, default=str)}") + + if acrcloud_data.get("artist_name"): + artist_names.append(acrcloud_data["artist_name"]) + if acrcloud_data.get("title"): + titles.append(acrcloud_data["title"]) + if acrcloud_data.get("year"): + years.append(acrcloud_data["year"]) + if acrcloud_data.get("genre"): + # Debug the genre value + if logger: + logger.info(f"DEBUG: ACRCloud genre type: {type(acrcloud_data['genre']).__name__}") + logger.info(f"DEBUG: ACRCloud genre value: {acrcloud_data['genre']}") + + # Handle genre properly whether it's a string, list, or dict + if isinstance(acrcloud_data["genre"], list): + if logger: + logger.info(f"DEBUG: Processing genre as list: {acrcloud_data['genre']}") + genres.extend(acrcloud_data["genre"]) # ACRCloud might return multiple genres + elif isinstance(acrcloud_data["genre"], str): + if logger: + logger.info(f"DEBUG: Processing genre as string: {acrcloud_data['genre']}") + genres.append(acrcloud_data["genre"]) + elif isinstance(acrcloud_data["genre"], dict): + # Debug the dict structure + if logger: + logger.info(f"DEBUG: Processing genre as dict: {acrcloud_data['genre']}") + + # Extract genre name from dict if available + for genre_key, genre_value in acrcloud_data["genre"].items(): + if logger: + logger.info(f"DEBUG: Genre key: {genre_key}, value type: {type(genre_value).__name__}") + + if isinstance(genre_value, str): + if logger: + logger.info(f"DEBUG: Adding genre string: {genre_value}") + genres.append(genre_value) + elif isinstance(genre_value, list) and genre_value: + if logger: + logger.info(f"DEBUG: Adding genres from list: {genre_value}") + genres.extend([g for g in genre_value if isinstance(g, str)]) + else: + if logger: + logger.info(f"DEBUG: Skipping genre value of type: {type(genre_value).__name__}") + else: + if logger: + logger.info(f"DEBUG: Unknown genre type: {type(acrcloud_data['genre']).__name__}") + + # Store platform IDs + if acrcloud_data.get("spotify_id"): + metadata["spotify_id"] = acrcloud_data["spotify_id"] + if acrcloud_data.get("deezer_id"): + metadata["deezer_id"] = acrcloud_data["deezer_id"] + + # Store preview URLs from different sources + if acrcloud_data.get("spotify_preview_url"): + metadata["spotify_preview_url"] = acrcloud_data["spotify_preview_url"] + if acrcloud_data.get("deezer_preview_url"): + metadata["deezer_preview_url"] = acrcloud_data["deezer_preview_url"] + if acrcloud_data.get("apple_preview_url"): + metadata["apple_preview_url"] = acrcloud_data["apple_preview_url"] + if acrcloud_data.get("youtube_preview_url"): + metadata["youtube_preview_url"] = acrcloud_data["youtube_preview_url"] + + # Store cover URLs from different sources + if acrcloud_data.get("spotify_cover_url"): + metadata["spotify_cover_url"] = acrcloud_data["spotify_cover_url"] + if acrcloud_data.get("deezer_cover_url"): + metadata["deezer_cover_url"] = acrcloud_data["deezer_cover_url"] + if acrcloud_data.get("apple_cover_url"): + metadata["apple_cover_url"] = acrcloud_data["apple_cover_url"] + + # Store album cover as main cover if available + if acrcloud_data.get("album_cover"): + metadata["cover_url"] = acrcloud_data["album_cover"] + except Exception as e: + if logger: + logger.error(f"ACRCloud error for ISRC {isrc}: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + + try: + # 1. Query MusicBrainz (direct ISRC support) + mb_data = get_musicbrainz_data(isrc, logger) + if mb_data: + metadata["sources"].append("musicbrainz") + if mb_data.get("artist_name"): + artist_names.append(mb_data["artist_name"]) + if mb_data.get("title"): + titles.append(mb_data["title"]) + if mb_data.get("year"): + years.append(mb_data["year"]) + if mb_data.get("genre"): + genres.append(mb_data["genre"]) + except Exception as e: + if logger: + logger.error(f"MusicBrainz error for ISRC {isrc}: {e}") + + try: + # 2. Query Spotify (direct ISRC support) + spotify_data = get_spotify_data(isrc, app) + if spotify_data: + metadata["sources"].append("spotify") + if spotify_data.get("artist_name"): + artist_names.append(spotify_data["artist_name"]) + if spotify_data.get("title"): + titles.append(spotify_data["title"]) + if spotify_data.get("year"): + years.append(spotify_data["year"]) + if spotify_data.get("genre"): + genres.append(spotify_data["genre"]) + if spotify_data.get("popularity") is not None: + metadata["popularity"] = spotify_data["popularity"] + if spotify_data.get("spotify_preview_url"): + metadata["spotify_preview_url"] = spotify_data["spotify_preview_url"] + if spotify_data.get("id"): + metadata["spotify_id"] = spotify_data["id"] + if spotify_data.get("spotify_cover_url"): + metadata["spotify_cover_url"] = spotify_data["spotify_cover_url"] + except Exception as e: + if logger: + logger.error(f"Spotify error for ISRC {isrc}: {e}") + + try: + # 3. Query Deezer (direct ISRC support in newer API) + deezer_data = get_deezer_data(isrc, app) + if deezer_data: + metadata["sources"].append("deezer") + if deezer_data.get("artist_name"): + artist_names.append(deezer_data["artist_name"]) + if deezer_data.get("title"): + titles.append(deezer_data["title"]) + if deezer_data.get("year"): + years.append(deezer_data["year"]) + if deezer_data.get("genre"): + genres.append(deezer_data["genre"]) + if deezer_data.get("deezer_preview_url"): + metadata["deezer_preview_url"] = deezer_data["deezer_preview_url"] + if deezer_data.get("id"): + metadata["deezer_id"] = deezer_data["id"] + if deezer_data.get("deezer_cover_url"): + metadata["deezer_cover_url"] = deezer_data["deezer_cover_url"] + except Exception as e: + if logger: + logger.error(f"Deezer error for ISRC {isrc}: {e}") + + # Debug the collected data before processing + if logger: + logger.info(f"DEBUG: All collected artist names: {artist_names}") + logger.info(f"DEBUG: All collected titles: {titles}") + logger.info(f"DEBUG: All collected years: {years}") + logger.info(f"DEBUG: All collected genres: {genres}") + + # Determine most common values so far + if artist_names and titles: + try: + # Use the most frequent values from collected data + if logger: + logger.info(f"DEBUG: Computing most common artist from: {artist_names}") + metadata["artist_name"] = Counter(artist_names).most_common(1)[0][0] + + if logger: + logger.info(f"DEBUG: Computing most common title from: {titles}") + metadata["title"] = Counter(titles).most_common(1)[0][0] + + # With artist and title, we can query services that don't support ISRC + try: + # 4. Query Last.fm + lastfm_data = get_lastfm_data(metadata["artist_name"], metadata["title"], app) + if lastfm_data: + metadata["sources"].append("lastfm") + if lastfm_data.get("genre"): + genres.append(lastfm_data["genre"]) + except Exception as e: + if logger: + logger.error(f"Last.fm error for {metadata['artist_name']} - {metadata['title']}: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + + try: + # 5. Query OpenAI for additional verification + openai_data = get_openai_data(metadata["artist_name"], metadata["title"], app) + if openai_data: + metadata["sources"].append("openai") + if openai_data.get("year"): + years.append(openai_data["year"]) + if openai_data.get("genre"): + genres.append(openai_data["genre"]) + except Exception as e: + if logger: + logger.error(f"OpenAI error for {metadata['artist_name']} - {metadata['title']}: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + except Exception as e: + if logger: + logger.error(f"Error determining most common values: {e}") + logger.error(f"Artist names: {artist_names}") + logger.error(f"Titles: {titles}") + logger.error(f"Traceback: {traceback.format_exc()}") + + # Process collected data + if years: + try: + # For year, take the earliest one as "first released" + if logger: + logger.info(f"DEBUG: Processing years: {years}") + + numeric_years = [int(y) for y in years if y and y.isdigit()] + if numeric_years: + metadata["year"] = str(min(numeric_years)) + if logger: + logger.info(f"DEBUG: Selected earliest year: {metadata['year']}") + except Exception as e: + # Fallback to most common if conversion fails + if logger: + logger.error(f"Year processing error: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + + try: + metadata["year"] = Counter(years).most_common(1)[0][0] + if logger: + logger.info(f"DEBUG: Fallback to most common year: {metadata['year']}") + except Exception as e2: + if logger: + logger.error(f"Year fallback error: {e2}") + + # Process all genres and create a clean list for tagging + if genres: + try: + # First, clean up genres for storage + clean_genres = [] + for g in genres: + if isinstance(g, str) and g.strip(): + # Translate specific genre names to English + if g.strip().lower() == "vaihtoehtoinen": + clean_genres.append("Alternative") + if logger: + logger.info(f"DEBUG: Translated genre 'Vaihtoehtoinen' to 'Alternative'") + else: + clean_genres.append(g.strip()) + elif isinstance(g, list): + # Flatten any nested lists and translate if needed + for item in g: + if isinstance(item, str) and item.strip(): + if item.strip().lower() == "vaihtoehtoinen": + clean_genres.append("Alternative") + if logger: + logger.info(f"DEBUG: Translated genre 'Vaihtoehtoinen' to 'Alternative'") + else: + clean_genres.append(item.strip()) + + # Store all unique genres in the metadata + unique_genres = [] + for g in clean_genres: + if g.lower() not in [existing.lower() for existing in unique_genres]: + unique_genres.append(g) + + metadata["genres"] = unique_genres + + if logger: + logger.info(f"DEBUG: All cleaned genres: {unique_genres}") + + # For the main genre field, take the most common one + if clean_genres: + # Get a case-insensitive count by converting all to lowercase + lowercase_genres = [g.lower() for g in clean_genres] + genre_counter = Counter(lowercase_genres) + most_common_genre_lower = genre_counter.most_common(1)[0][0] + + # Find the original case version from our clean genres + for g in clean_genres: + if g.lower() == most_common_genre_lower: + metadata["genre"] = g + break + + if logger: + logger.info(f"DEBUG: Selected most common genre as main: {metadata['genre']}") + except Exception as e: + if logger: + logger.error(f"Genre processing error: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + + # Process preview URLs with priority for Spotify, then Apple Music, then Deezer + preview_sources = [ + metadata.get("spotify_preview_url"), + metadata.get("apple_preview_url"), + metadata.get("deezer_preview_url"), + metadata.get("youtube_preview_url") + ] + + # Select the first available preview URL as the main one + for url in preview_sources: + if url: + metadata["preview_url"] = url + if logger: + logger.info(f"DEBUG: Selected preview URL: {url}") + break + + # Process cover URLs with priority + cover_sources = [ + metadata.get("spotify_cover_url"), + metadata.get("apple_cover_url"), + metadata.get("deezer_cover_url") + ] + + # Select the first available cover URL as the main one if not already set + if not metadata["cover_url"]: + for url in cover_sources: + if url: + metadata["cover_url"] = url + if logger: + logger.info(f"DEBUG: Selected cover URL: {url}") + break + + if logger: + logger.info(f"=== DEBUG: Completed metadata refresh for ISRC: {isrc} ===") + logger.info(f"=== Final metadata: {json.dumps(metadata, default=str)} ===") + + return metadata + +def get_musicbrainz_data(isrc, logger=None): + """Query MusicBrainz API using ISRC""" + result = {} + + # Set user agent for MusicBrainz API + musicbrainzngs.set_useragent("MusicRound", "0.1", "fret@fret.de") + + try: + # Search MusicBrainz by ISRC + mb_results = musicbrainzngs.search_recordings(isrc=isrc, limit=1) + if mb_results and mb_results.get('recording-list') and len(mb_results['recording-list']) > 0: + recording = mb_results['recording-list'][0] + + # Extract title + result["title"] = recording.get('title') + + # Extract artist name + if recording.get('artist-credit'): + artist_names = [] + for artist_credit in recording['artist-credit']: + if isinstance(artist_credit, dict) and 'artist' in artist_credit: + artist_names.append(artist_credit['artist']['name']) + if artist_names: + result["artist_name"] = ", ".join(artist_names) + + # Extract genre tags + if 'tag-list' in recording: + tags = [tag['name'] for tag in recording['tag-list']] + if tags: + result["genre"] = tags[0] + + # Get release year + if 'release-list' in recording and recording['release-list']: + release = recording['release-list'][0] + if 'date' in release: + result["year"] = release['date'][:4] # Extract year from date + except Exception as e: + if logger: + logger.error(f"MusicBrainz API error: {e}") + + return result + +def get_spotify_data(isrc, app=None): + """Query Spotify API using ISRC""" + result = {} + + try: + # Get Spotify client from app context + sp = app.config.get('sp') if app else None + if not sp: + return result + + # Search Spotify by ISRC + query = f"isrc:{isrc}" + spotify_result = sp.search(q=query, type='track') + + if spotify_result and spotify_result.get('tracks') and spotify_result['tracks'].get('items'): + track = spotify_result['tracks']['items'][0] + + # Extract track title + result["title"] = track.get('name') + + # Extract artist names + if track.get('artists'): + result["artist_name"] = ", ".join([artist['name'] for artist in track['artists']]) + + # Extract popularity + result["popularity"] = track.get('popularity') + + # Extract preview URL + result["spotify_preview_url"] = track.get('preview_url') + + # Store main track ID + result["id"] = track.get('id') + + # Get album details to extract more info + if track.get('album') and track['album'].get('id'): + album = sp.album(track['album']['id']) + + # Extract genre + if album.get('genres') and len(album['genres']) > 0: + result["genre"] = album['genres'][0] + + # Extract release year + if album.get('release_date'): + result["year"] = album['release_date'][:4] + + # Extract cover images + if track['album'].get('images') and len(track['album']['images']) > 0: + for img in track['album']['images']: + if img.get('height') and img.get('width') and img.get('url'): + if img['height'] > 600: # Consider this a large image + result["spotify_cover_url"] = img['url'] + break + # If we didn't find a large image, use the first one + if not result.get("spotify_cover_url") and track['album']['images'][0].get('url'): + result["spotify_cover_url"] = track['album']['images'][0]['url'] + except Exception as e: + if app: + app.logger.error(f"Spotify API error: {e}") + + return result + +def get_deezer_data(isrc, app=None): + """Query Deezer API using ISRC""" + result = {} + + try: + # Get Deezer client from app context or create a basic one + deezer_client = app.config.get('deezer') if app else None + + if not deezer_client: + # If no client in app context, make direct API call + response = requests.get(f"https://api.deezer.com/track/isrc:{isrc}") + if response.status_code == 200: + track = response.json() + else: + return result + else: + # Try to use the ISRC search if available, or search by track if not + try: + track = deezer_client._make_request(f"track/isrc:{isrc}") + except: + # Deezer client might not have direct ISRC support, so try a workaround + # (This would require having title and artist from another source) + track = None + + if track and not track.get('error'): + # Extract title + result["title"] = track.get('title') + + # Extract artist name + if track.get('artist'): + result["artist_name"] = track['artist'].get('name') + + # Extract preview URL + result["deezer_preview_url"] = track.get('preview') + + # Extract Deezer ID + result["id"] = track.get('id') + + # Get album details to extract more info + if track.get('album') and track['album'].get('id'): + album_id = track['album']['id'] + + if deezer_client: + album = deezer_client.get_album(album_id) + else: + album_response = requests.get(f"https://api.deezer.com/album/{album_id}") + album = album_response.json() if album_response.status_code == 200 else None + + if album and not album.get('error'): + # Extract genre + if album.get('genres') and album['genres'].get('data') and len(album['genres']['data']) > 0: + result["genre"] = album['genres']['data'][0].get('name') + + # Extract release year + if album.get('release_date'): + result["year"] = album['release_date'][:4] + + # Extract cover image + if track['album'].get('cover'): + result["deezer_cover_url"] = track['album']['cover'] + # Try the bigger version + if track['album'].get('cover_xl'): + result["deezer_cover_url"] = track['album']['cover_xl'] + elif track['album'].get('cover_big'): + result["deezer_cover_url"] = track['album']['cover_big'] + except Exception as e: + if app: + app.logger.error(f"Deezer API error: {e}") + + return result + +def get_lastfm_data(artist_name, track_title, app=None): + """Query Last.fm API using artist name and track title""" + result = {} + + if not artist_name or not track_title: + return result + + try: + # Get Last.fm API key from app context or environment + lastfm_api_key = None + if app: + lastfm_api_key = app.config.get('LASTFM_API_KEY') + + if not lastfm_api_key: + return result + + # Query Last.fm API + url = 'http://ws.audioscrobbler.com/2.0/' + params = { + 'method': 'track.getInfo', + 'api_key': lastfm_api_key, + 'artist': artist_name, + 'track': track_title, + 'format': 'json' + } + + response = requests.get(url=url, params=params) + if response.status_code == 200: + data = response.json() + + # Extract genre from top tags + if (data.get('track') and + data['track'].get('toptags') and + data['track']['toptags'].get('tag')): + tags = data['track']['toptags']['tag'] + if tags and len(tags) > 0: + result["genre"] = tags[0]['name'] + except Exception as e: + if app: + app.logger.error(f"Last.fm API error: {e}") + + return result + +def get_openai_data(artist_name, track_title, app=None): + """Query OpenAI API for additional metadata verification""" + result = {} + + if not artist_name or not track_title: + return result + + try: + # Get OpenAI API details from app context + if not app: + return result + + openai_api_key = app.config.get('OPENAI_API_KEY') + openai_url = app.config.get('OPENAI_URL') + openai_model = app.config.get('OPENAI_MODEL') + + if not openai_api_key or not openai_model: + return result + + # Configure OpenAI API key + openai.api_key = openai_api_key + + # Create prompt + prompt = f"Provide the genre and release year for the song '{track_title}' by {artist_name}. Return the data as a JSON object with keys 'genre' and 'year'. If the information is not available, return null for the corresponding key." + + # Log the query + app.logger.info(f"ChatGPT Query: {prompt}") + + content = None + + # Check which version of the OpenAI library is being used + if hasattr(openai, 'chat') and hasattr(openai.chat, 'completions'): + # New OpenAI API client (>= 1.0.0) + if openai_url: + openai.base_url = openai_url + + # Call OpenAI API with new client + try: + response = openai.chat.completions.create( + model=openai_model, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"} + ) + + if response and hasattr(response, 'choices') and response.choices: + content = response.choices[0].message.content + app.logger.info(f"ChatGPT Response: {content}") + except Exception as e: + app.logger.error(f"OpenAI chat completions error: {e}") + # Try falling back to completion API if available + try: + if hasattr(openai, 'Completion'): + response = openai.Completion.create( + engine=openai_model, + prompt=prompt, + max_tokens=200, + temperature=0.2, + top_p=1.0 + ) + if response and hasattr(response, 'choices') and len(response.choices) > 0: + content = response.choices[0].text.strip() + app.logger.info(f"OpenAI Completion Response: {content}") + except Exception as inner_e: + app.logger.error(f"OpenAI completion fallback error: {inner_e}") + else: + # Old OpenAI API client (< 1.0.0) + if openai_url: + openai.api_base = openai_url # Different attribute in old client + + # Call OpenAI API with old client + try: + response = openai.Completion.create( + engine=openai_model, # In old API, it's 'engine' instead of 'model' + prompt=prompt, + max_tokens=200, + temperature=0.2, + top_p=1.0 + ) + + if response and hasattr(response, 'choices') and len(response.choices) > 0: + content = response.choices[0].text.strip() + app.logger.info(f"ChatGPT Response: {content}") + except Exception as e: + app.logger.error(f"OpenAI completion error: {e}") + + # Process the response content + if content: + try: + # Try to extract JSON from the content (handle cases where there might be extra text) + import re + json_match = re.search(r'(\{.*\})', content, re.DOTALL) + if json_match: + json_str = json_match.group(1) + data = json.loads(json_str) + else: + data = json.loads(content) + + if data.get("genre"): + result["genre"] = data["genre"] + if data.get("year"): + # Always convert year to string + result["year"] = str(data["year"]) + + # If we got valid data, return it + if "genre" in result or "year" in result: + return result + + except Exception as e: + app.logger.error(f"Error parsing OpenAI response: {e}") + app.logger.error(f"Raw response content: {content}") + except AttributeError as e: + app.logger.error(f"OpenAI module error: {e}") + except Exception as e: + if app: + app.logger.error(f"OpenAI API error: {e}") + + return result + +def get_acrcloud_data(isrc, app=None): + """Query ACRCloud API using ISRC""" + result = {} + + if not app: + return result + + try: + # Get ACRCloud API key from app config + acrcloud_token = app.config.get('ACRCLOUD_TOKEN') + if not acrcloud_token: + logger = app.logger if app else None + if logger: + logger.warning("ACRCloud token not found in app config.") + return result + + # Query ACRCloud API for track metadata + url = "https://eu-api-v2.acrcloud.com/api/external-metadata/tracks" + headers = { + 'Authorization': f'Bearer {acrcloud_token}' + } + params = { + 'isrc': isrc, + 'platforms': 'spotify,deezer,youtube,applemusic', + 'include_works': 1 # Include additional work metadata + } + + response = requests.get(url, headers=headers, params=params) + if response.status_code != 200: + app.logger.warning(f"ACRCloud API error: {response.status_code} - {response.text}") + return result + + data = response.json() + if not data or not data.get('data') or not len(data['data']) > 0: + return result + + track_data = data['data'][0] + logger = app.logger if app else None + if logger: + logger.info(f"ACRCloud API response: {json.dumps(data, default=str)}") + # Extract basic metadata + if track_data.get('name'): + result['title'] = track_data['name'] + + if track_data.get('artists') and len(track_data['artists']) > 0: + artist_names = [artist['name'] for artist in track_data['artists'] if 'name' in artist] + result['artist_name'] = ', '.join(artist_names) + + if track_data.get('release_date'): + result['year'] = track_data['release_date'][:4] # Extract year + + if track_data.get('genres'): + result['genre'] = track_data['genres'] + + # Extract album cover if available + if track_data.get('album') and track_data['album'].get('cover'): + result['album_cover'] = track_data['album']['cover'] + + # Also get covers from specific sizes if available + if track_data['album'].get('covers'): + covers = track_data['album']['covers'] + if covers.get('large'): + result['album_cover_large'] = covers['large'] + if covers.get('medium'): + result['album_cover_medium'] = covers['medium'] + + # Get external metadata from platforms + ext_meta = track_data.get('external_metadata', {}) + + # Get Spotify metadata + if 'spotify' in ext_meta and ext_meta['spotify'] and len(ext_meta['spotify']) > 0: + spotify_data = ext_meta['spotify'][0] + if spotify_data.get('id'): + result['spotify_id'] = spotify_data['id'] + if spotify_data.get('preview'): + result['spotify_preview_url'] = spotify_data['preview'] + if spotify_data.get('album') and spotify_data['album'].get('cover'): + result['spotify_cover_url'] = spotify_data['album']['cover'] + + # Get Deezer metadata + if 'deezer' in ext_meta and ext_meta['deezer'] and len(ext_meta['deezer']) > 0: + deezer_data = ext_meta['deezer'][0] + if deezer_data.get('id'): + result['deezer_id'] = deezer_data['id'] + # Deezer preview URL might come from additional API call + if deezer_data.get('album') and deezer_data['album'].get('cover'): + result['deezer_cover_url'] = deezer_data['album']['cover'] + + # Get Apple Music metadata + if 'applemusic' in ext_meta and ext_meta['applemusic'] and len(ext_meta['applemusic']) > 0: + apple_data = ext_meta['applemusic'][0] + if apple_data.get('preview'): + result['apple_preview_url'] = apple_data['preview'] + if apple_data.get('album') and apple_data['album'].get('cover'): + result['apple_cover_url'] = apple_data['album']['cover'] + + # Get YouTube metadata + if 'youtube' in ext_meta and ext_meta['youtube'] and len(ext_meta['youtube']) > 0: + youtube_data = ext_meta['youtube'][0] + if youtube_data.get('id'): + youtube_id = youtube_data['id'] + result['youtube_id'] = youtube_id + # Construct a YouTube Music playback URL + result['youtube_preview_url'] = f"https://music.youtube.com/watch?v={youtube_id}" + + return result + except Exception as e: + if app: + app.logger.error(f"ACRCloud API error: {e}") + + return result diff --git a/musicround/helpers/spotify_direct.py b/musicround/helpers/spotify_direct.py new file mode 100644 index 0000000..2c12771 --- /dev/null +++ b/musicround/helpers/spotify_direct.py @@ -0,0 +1,304 @@ +""" +Direct Spotify Web API client implementation. +This is an alternative to spotipy for testing and comparison purposes. +""" +import json +import os +import time +import requests +import logging +from flask import current_app + +class SpotifyDirectClient: + """ + A client for the Spotify Web API that directly uses the HTTP endpoints + rather than using the spotipy library. + """ + def __init__(self, client_id=None, client_secret=None, cache_path=None, bearer_token=None): + self.client_id = client_id or current_app.config['SPOTIFY_CLIENT_ID'] + self.client_secret = client_secret or current_app.config['SPOTIFY_CLIENT_SECRET'] + self.cache_path = cache_path or os.path.join('/data', '.spotifycache') + self.base_url = 'https://api.spotify.com/v1' + self.token_url = 'https://accounts.spotify.com/api/token' + self.access_token = bearer_token # Use provided bearer token if available + self.token_expiry = 0 + self.refresh_token = None + self.logger = logging.getLogger(__name__) + + # Configure session with retry logic + self.session = requests.Session() + from requests.adapters import HTTPAdapter + from urllib3.util import Retry + retries = Retry( + total=5, + backoff_factor=0.5, + status_forcelist=[429, 500, 502, 503, 504] + ) + self.session.mount('https://', HTTPAdapter(max_retries=retries)) + + # Only load tokens from cache if bearer token was not provided + if not bearer_token: + self._load_token_from_cache() + + def _load_token_from_cache(self): + """Load access and refresh tokens from cache file""" + if not os.path.exists(self.cache_path): + self.logger.warning(f"Cache file not found at {self.cache_path}") + return + + try: + with open(self.cache_path, 'r') as f: + token_info = json.load(f) + self.access_token = token_info.get('access_token') + self.refresh_token = token_info.get('refresh_token') + self.token_expiry = token_info.get('expires_at', 0) + self.logger.info("Loaded tokens from cache file") + except Exception as e: + self.logger.error(f"Error loading tokens from cache: {e}") + + def _save_token_to_cache(self, token_info): + """Save token information to cache file""" + try: + with open(self.cache_path, 'w') as f: + json.dump(token_info, f) + self.logger.info("Saved tokens to cache file") + except Exception as e: + self.logger.error(f"Error saving tokens to cache: {e}") + + def _refresh_access_token(self): + """Refresh the access token using the refresh token""" + if not self.refresh_token: + self.logger.error("No refresh token available. User must log in again.") + return False + + self.logger.info("Refreshing access token...") + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret + } + + try: + response = self.session.post(self.token_url, data=data) + response.raise_for_status() + + token_info = response.json() + self.access_token = token_info['access_token'] + self.token_expiry = int(time.time()) + token_info['expires_in'] + + # If new refresh token provided, update it + if 'refresh_token' in token_info: + self.refresh_token = token_info['refresh_token'] + + # Update cache + token_info['expires_at'] = self.token_expiry + if 'refresh_token' not in token_info and self.refresh_token: + token_info['refresh_token'] = self.refresh_token + + self._save_token_to_cache(token_info) + return True + + except Exception as e: + self.logger.error(f"Error refreshing access token: {e}") + return False + + def _ensure_token_valid(self): + """Check if token is valid and refresh if needed""" + # If we're using a manually provided bearer token, assume it's valid + if self.access_token and not self.refresh_token: + return True + + # Otherwise use the normal refresh logic + if not self.access_token or time.time() > self.token_expiry - 60: + return self._refresh_access_token() + return True + + def _make_api_request(self, endpoint, method='GET', params=None, data=None, retry_on_auth_error=True): + """Make a request to the Spotify API with automatic token refresh""" + if not self._ensure_token_valid(): + return None + + url = f"{self.base_url}/{endpoint}" + headers = { + 'Authorization': f'Bearer {self.access_token}', + 'Content-Type': 'application/json' + } + + try: + if method == 'GET': + response = self.session.get(url, headers=headers, params=params) + elif method == 'POST': + response = self.session.post(url, headers=headers, json=data, params=params) + elif method == 'PUT': + response = self.session.put(url, headers=headers, json=data, params=params) + elif method == 'DELETE': + response = self.session.delete(url, headers=headers, params=params) + else: + self.logger.error(f"Unsupported HTTP method: {method}") + return None + + # Handle 401 by refreshing token and retrying once + if response.status_code == 401 and retry_on_auth_error: + self.logger.info("Got 401, refreshing token and retrying...") + if self._refresh_access_token(): + return self._make_api_request(endpoint, method, params, data, retry_on_auth_error=False) + + response.raise_for_status() + return response.json() + + except requests.exceptions.HTTPError as e: + self.logger.error(f"HTTP error: {e}") + # Log the response content for debugging + if hasattr(e, 'response') and e.response: + self.logger.error(f"Response status: {e.response.status_code}") + self.logger.error(f"Response content: {e.response.text}") + return None + + except Exception as e: + self.logger.error(f"Error making API request: {e}") + return None + + def user_playlists(self, user_id, limit=50, offset=0): + """ + Get a user's playlists. Mirrors the spotipy interface. + + Args: + user_id: The Spotify user ID + limit: Maximum number of playlists to return (max 50) + offset: The index of the first playlist to return + + Returns: + A dictionary containing the user's playlists or None if an error occurred + """ + endpoint = f"users/{user_id}/playlists" + params = { + 'limit': min(limit, 50), # Spotify API has a max limit of 50 + 'offset': offset + } + + self.logger.info(f"Fetching {limit} playlists at offset {offset} for user {user_id}") + result = self._make_api_request(endpoint, params=params) + + if result: + self.logger.info(f"Got {len(result.get('items', []))} playlists, total {result.get('total', 0)}") + else: + self.logger.error("Failed to fetch playlists") + + return result + + def fetch_all_user_playlists(self, user_id, limit=50): + """ + Fetch all playlists for a user with proper pagination. + + Args: + user_id: The Spotify user ID + limit: Maximum number of playlists per request (max 50) + + Returns: + List of all playlists from the user + """ + all_playlists = [] + offset = 0 + total = None + max_loops = 50 # Safety limit + loop_count = 0 + + self.logger.info(f"Starting to fetch all playlists for user {user_id}") + start_time = time.time() + + while loop_count < max_loops: + loop_count += 1 + + result = self.user_playlists(user_id, limit=limit, offset=offset) + if not result: + self.logger.error(f"Failed to fetch playlists for user {user_id} at offset {offset}") + break + + # Get total on first request + if total is None: + total = result.get('total', 0) + self.logger.info(f"User has {total} total playlists according to API") + + # Process items from this batch + items = result.get('items', []) + item_count = len(items) + all_playlists.extend(items) + + self.logger.info(f"Fetched {item_count} playlists for user {user_id}, progress: {len(all_playlists)}/{total}") + + # Break if we got fewer items than requested (last page) + if item_count < limit: + self.logger.info(f"Received fewer items than requested, assuming end of list") + break + + # Update offset for next batch + offset += item_count + + # Break if we've fetched all playlists + if offset >= total: + self.logger.info(f"Reached total {total} playlists") + break + + # Break if next URL is None (no more pages) + if not result.get('next'): + self.logger.info(f"No 'next' URL in response, end of pagination") + # Check for inconsistency + if offset < total: + self.logger.warning(f"API inconsistency: no more pages but only fetched {len(all_playlists)}/{total}") + break + + duration_ms = int((time.time() - start_time) * 1000) + self.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists in {duration_ms}ms") + + return all_playlists + + def get_track_audio_features(self, track_id): + """ + Get audio features for a specific track + + Args: + track_id: Spotify ID of the track + + Returns: + dict: Audio features for the track or None if not found + """ + self.logger.info(f"Getting audio features for track {track_id}") + if not self._ensure_token_valid(): + return None + + endpoint = f"audio-features/{track_id}" + return self._make_api_request(endpoint) + + def get_tracks_audio_features(self, track_ids): + """ + Get audio features for multiple tracks in a single request + + Args: + track_ids: List of Spotify track IDs (max 100) + + Returns: + list: List of audio features for tracks + """ + if not track_ids: + return [] + + self.logger.info(f"Getting audio features for {len(track_ids)} tracks") + if not self._ensure_token_valid(): + return None + + # Spotify API only accepts up to 100 IDs per request + if len(track_ids) > 100: + self.logger.warning("More than 100 track IDs provided, only fetching the first 100") + track_ids = track_ids[:100] + + # Convert list to comma-separated string + ids_param = ",".join(track_ids) + + endpoint = "audio-features" + result = self._make_api_request(endpoint, params={"ids": ids_param}) + + if result and "audio_features" in result: + return result["audio_features"] + return [] \ No newline at end of file diff --git a/musicround/helpers/utils.py b/musicround/helpers/utils.py new file mode 100644 index 0000000..eba7131 --- /dev/null +++ b/musicround/helpers/utils.py @@ -0,0 +1,303 @@ +""" +General utility functions used throughout the application. +""" +import secrets +import string +import os +import shutil +from flask import current_app +from werkzeug.utils import secure_filename +import uuid +import requests +import json + +def generate_token(length=32): + """ + Generate a secure random token for authentication or validation purposes. + + Args: + length (int): The length of the token to generate (default: 32) + + Returns: + str: A secure random token string + """ + # Use secrets module for cryptographically strong random numbers + alphabet = string.ascii_letters + string.digits + token = ''.join(secrets.choice(alphabet) for _ in range(length)) + return token + +def get_user_mp3_directory(username): + """ + Get the directory path for a user's custom MP3 files + Creates the directory if it doesn't exist + + Args: + username (str): Username + + Returns: + str: Path to the user's MP3 directory + """ + base_dir = os.path.join('/data', 'custommp3') + user_dir = os.path.join(base_dir, secure_filename(username)) + + # Create directories if they don't exist + if not os.path.exists(base_dir): + os.makedirs(base_dir) + if not os.path.exists(user_dir): + os.makedirs(user_dir) + + return user_dir + +def save_user_mp3(file, username, mp3_type): + """ + Save a user's uploaded MP3 file + + Args: + file: FileStorage object from Flask request.files + username (str): Username + mp3_type (str): Type of MP3 (intro, outro, or replay) + + Returns: + str: Path to the saved file relative to data directory + """ + if not file or not allowed_file(file.filename): + return None + + # Get user directory and create if needed + user_dir = get_user_mp3_directory(username) + + # Generate a unique filename to avoid overwriting + original_filename = secure_filename(file.filename) + file_extension = os.path.splitext(original_filename)[1] # Should be .mp3 + unique_filename = f"{mp3_type}{file_extension}" + + # Full path to save the file + filepath = os.path.join(user_dir, unique_filename) + + # Save the file + file.save(filepath) + + # Return the relative path for database storage + return os.path.join('custommp3', secure_filename(username), unique_filename) + +def allowed_file(filename): + """ + Check if the file has an allowed extension + + Args: + filename (str): Name of the file + + Returns: + bool: True if extension is allowed, False otherwise + """ + allowed_extensions = {'mp3'} + return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions + +def get_mp3_path(user, mp3_type): + """ + Get the path to a user's custom MP3 file or the default if not set + + Args: + user: User object from database + mp3_type (str): Type of MP3 (intro, outro, or replay) + + Returns: + str: Path to the MP3 file + """ + user_setting_attr = f"{mp3_type}_mp3" + user_mp3_path = getattr(user, user_setting_attr) + + if user_mp3_path and os.path.exists(os.path.join('/data', user_mp3_path)): + return os.path.join('/data', user_mp3_path) + + # Fall back to the default MP3 + return os.path.join(current_app.root_path, 'static', 'audio', f'{mp3_type}.mp3') + +def get_available_voices(service='polly'): + """ + Get a list of available voices for the specified TTS service + + Args: + service (str): TTS service ('polly', 'openai', or 'elevenlabs') + + Returns: + list: List of available voice options + """ + if service == 'polly': + # Standard AWS Polly voices - subset of most natural ones + return [ + {'id': 'Joanna', 'name': 'Joanna (Female, US)', 'gender': 'Female', 'language': 'en-US'}, + {'id': 'Matthew', 'name': 'Matthew (Male, US)', 'gender': 'Male', 'language': 'en-US'}, + {'id': 'Amy', 'name': 'Amy (Female, UK)', 'gender': 'Female', 'language': 'en-GB'}, + {'id': 'Brian', 'name': 'Brian (Male, UK)', 'gender': 'Male', 'language': 'en-GB'}, + {'id': 'Kendra', 'name': 'Kendra (Female, US)', 'gender': 'Female', 'language': 'en-US'}, + {'id': 'Kimberly', 'name': 'Kimberly (Female, US)', 'gender': 'Female', 'language': 'en-US'}, + {'id': 'Salli', 'name': 'Salli (Female, US)', 'gender': 'Female', 'language': 'en-US'}, + {'id': 'Joey', 'name': 'Joey (Male, US)', 'gender': 'Male', 'language': 'en-US'}, + ] + elif service == 'openai': + return [ + {'id': 'alloy', 'name': 'Alloy (Neutral)', 'gender': 'Neutral', 'language': 'en'}, + {'id': 'echo', 'name': 'Echo (Male)', 'gender': 'Male', 'language': 'en'}, + {'id': 'fable', 'name': 'Fable (Male)', 'gender': 'Male', 'language': 'en'}, + {'id': 'onyx', 'name': 'Onyx (Male)', 'gender': 'Male', 'language': 'en'}, + {'id': 'nova', 'name': 'Nova (Female)', 'gender': 'Female', 'language': 'en'}, + {'id': 'shimmer', 'name': 'Shimmer (Female)', 'gender': 'Female', 'language': 'en'}, + ] + elif service == 'elevenlabs': + # Check if we have a valid API key + api_key = current_app.config.get('ELEVENLABS_API_KEY') + if not api_key: + return [] + + try: + # Call the ElevenLabs API to get available voices + headers = { + "xi-api-key": api_key, + "Content-Type": "application/json" + } + response = requests.get("https://api.elevenlabs.io/v1/voices", headers=headers) + + if response.status_code == 200: + voices_data = response.json() + voices = [] + for voice in voices_data.get('voices', []): + voice_info = { + 'id': voice.get('voice_id'), + 'name': voice.get('name', 'Unknown'), + 'gender': voice.get('labels', {}).get('gender', 'Unknown'), + 'language': 'en' # Default to English + } + voices.append(voice_info) + return voices + else: + current_app.logger.error(f"Error fetching ElevenLabs voices: {response.status_code} - {response.text}") + return [] + except Exception as e: + current_app.logger.error(f"Exception fetching ElevenLabs voices: {str(e)}") + return [] + + # Default fallback + return [] + +def generate_tts_mp3(text, username, mp3_type, service='polly', voice=None, model=None, stability=None, similarity=None): + """ + Generate a text-to-speech MP3 file + + Args: + text (str): The text to convert to speech + username (str): Username + mp3_type (str): Type of MP3 (intro, outro, or replay) + service (str): TTS service to use ('polly', 'openai', or 'elevenlabs') + voice (str): Voice ID to use (defaults to service-specific default if None) + model (str): Model to use for OpenAI or ElevenLabs (defaults to service-specific default if None) + stability (float): Voice stability parameter for ElevenLabs (0.0-1.0) + similarity (float): Voice similarity parameter for ElevenLabs (0.0-1.0) + + Returns: + str: Path to the generated file relative to data directory + """ + try: + user_dir = get_user_mp3_directory(username) + output_filename = f"{mp3_type}.mp3" + output_path = os.path.join(user_dir, output_filename) + + if service == 'polly': + # AWS Polly implementation + import boto3 + + polly_client = boto3.client('polly', + region_name=current_app.config.get('AWS_REGION', 'us-east-1'), + aws_access_key_id=current_app.config.get('AWS_ACCESS_KEY_ID'), + aws_secret_access_key=current_app.config.get('AWS_SECRET_ACCESS_KEY') + ) + + # Use provided voice or default + voice_id = voice or current_app.config.get('AWS_POLLY_VOICE', 'Joanna') + + # Use neural engine if available + engine = 'neural' if voice_id in ['Joanna', 'Matthew', 'Amy', 'Emma', 'Brian', 'Kendra'] else 'standard' + + response = polly_client.synthesize_speech( + Text=text, + OutputFormat='mp3', + VoiceId=voice_id, + Engine=engine + ) + + # Write the audio stream to a file + if "AudioStream" in response: + with open(output_path, 'wb') as file: + file.write(response["AudioStream"].read()) + + current_app.logger.info(f"Generated AWS Polly TTS with voice {voice_id} for {username}/{mp3_type}") + + elif service == 'openai': + # OpenAI TTS implementation + import openai + + openai.api_key = current_app.config.get('OPENAI_API_KEY') + openai.base_url = current_app.config.get('OPENAI_URL', 'https://api.openai.com/v1') + + # Use provided voice or default + voice_id = voice or 'alloy' + # Use provided model or default + tts_model = model or 'tts-1' # Options: tts-1, tts-1-hd + + response = openai.audio.speech.create( + model=tts_model, + voice=voice_id, + input=text + ) + + response.stream_to_file(output_path) + current_app.logger.info(f"Generated OpenAI TTS with voice {voice_id} for {username}/{mp3_type}") + + elif service == 'elevenlabs': + # ElevenLabs implementation + api_key = current_app.config.get('ELEVENLABS_API_KEY') + if not api_key: + current_app.logger.error("ElevenLabs API key not configured") + return None + + # Use provided voice or default + voice_id = voice or "21m00Tcm4TlvDq8ikWAM" # Default to "Rachel" voice + + # Set default values for stability and similarity if not provided + stability_value = stability if stability is not None else 0.5 + similarity_value = similarity if similarity is not None else 0.75 + + headers = { + "xi-api-key": api_key, + "Content-Type": "application/json" + } + + payload = { + "text": text, + "model_id": model or "eleven_monolingual_v1", + "voice_settings": { + "stability": stability_value, + "similarity_boost": similarity_value + } + } + + response = requests.post( + f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", + headers=headers, + data=json.dumps(payload) + ) + + if response.status_code == 200: + with open(output_path, 'wb') as file: + file.write(response.content) + current_app.logger.info(f"Generated ElevenLabs TTS with voice {voice_id} for {username}/{mp3_type}") + else: + current_app.logger.error(f"ElevenLabs API error: {response.status_code} - {response.text}") + return None + + # Return the relative path for database storage + return os.path.join('custommp3', secure_filename(username), output_filename) + + except Exception as e: + current_app.logger.error(f"Error generating TTS MP3: {str(e)}") + return None diff --git a/musicround/models.py b/musicround/models.py new file mode 100644 index 0000000..fc94dbc --- /dev/null +++ b/musicround/models.py @@ -0,0 +1,331 @@ +from datetime import datetime +from musicround import db +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +import uuid + +# User-Role association table for many-to-many relationship +user_roles = db.Table('user_roles', + db.Column('user_id', db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), primary_key=True), + db.Column('role_id', db.Integer, db.ForeignKey('role.id', ondelete='CASCADE'), primary_key=True) +) + +class Role(db.Model): + """ + Role model for user permissions + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.String(255)) + + def __repr__(self): + return f"Role(id={self.id}, name='{self.name}')" + + +class UserPreferences(db.Model): + """ + User preferences model for storing user-specific settings + """ + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True) + default_tts_service = db.Column(db.String(32), default='polly') + enable_intro = db.Column(db.Boolean, default=True) + theme = db.Column(db.String(16), default='light') + user = db.relationship('User', back_populates='preferences') + + +class User(db.Model, UserMixin): + """ + User model for authentication and user management + """ + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), index=True, unique=True, nullable=False) + email = db.Column(db.String(120), index=True, unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=True) # Now nullable for OAuth-only users + first_name = db.Column(db.String(50)) + last_name = db.Column(db.String(50)) + active = db.Column(db.Boolean, default=True) + is_admin = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_login = db.Column(db.DateTime) + + # Token for password reset, email verification, etc. + reset_token = db.Column(db.String(100), index=True, unique=True) + reset_token_expiry = db.Column(db.DateTime) + + # Authentication provider info + auth_provider = db.Column(db.String(20), default='local') # 'local', 'google', 'authentik' + + # OAuth provider info - Spotify + oauth_id = db.Column(db.String(100)) # Spotify user ID + spotify_token = db.Column(db.Text) # Store Spotify access token + spotify_refresh_token = db.Column(db.Text) # Store Spotify refresh token + spotify_token_expiry = db.Column(db.DateTime) + + # OAuth provider info - Google + google_id = db.Column(db.String(100)) # Google user ID + google_token = db.Column(db.Text) # Store Google access token + google_refresh_token = db.Column(db.Text) # Store Google refresh token + + # OAuth provider info - Authentik + authentik_id = db.Column(db.String(100)) # Authentik user ID + authentik_token = db.Column(db.Text) # Store Authentik access token + authentik_refresh_token = db.Column(db.Text) # Store Authentik refresh token + + # OAuth provider info - Dropbox + dropbox_id = db.Column(db.String(100)) # Dropbox user ID + dropbox_token = db.Column(db.Text) # Store Dropbox access token + dropbox_refresh_token = db.Column(db.Text) # Store Dropbox refresh token + dropbox_token_expiry = db.Column(db.DateTime) # Dropbox token expiration + dropbox_export_path = db.Column(db.String(255), default='/QuizzicalBeats') # User's preferred Dropbox export folder + + # User preferences + intro_mp3 = db.Column(db.String(255)) # Custom intro MP3 path + outro_mp3 = db.Column(db.String(255)) # Custom outro MP3 path + replay_mp3 = db.Column(db.String(255)) # Custom replay MP3 path + + # Relationships + roles = db.relationship('Role', secondary=user_roles, backref=db.backref('users', lazy='dynamic')) + preferences = db.relationship('UserPreferences', uselist=False, back_populates='user') + + @property + def password(self): + """Password getter should raise an error""" + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + """Hash and store the password""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """Check if provided password matches the hash""" + return check_password_hash(self.password_hash, password) + + def set_token(self): + """Generate a unique token for the user""" + self.reset_token = str(uuid.uuid4()) + return self.reset_token + + def has_role(self, role_name): + """Check if user has a specific role""" + return any(role.name == role_name for role in self.roles) + + def is_admin(self): + """Check if user is an admin""" + return self.has_role('admin') + + def __repr__(self): + return f"User(id={self.id}, username='{self.username}', email='{self.email}')" + + +class Tag(db.Model): + """ + Tag model for storing song tags + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Define relationship to songs via SongTag + songs = db.relationship('Song', secondary='song_tag', back_populates='tags') + + def __repr__(self): + return f"Tag(id={self.id}, name='{self.name}')" + + +class SongTag(db.Model): + """ + Association table for Song-Tag many-to-many relationship + """ + __tablename__ = 'song_tag' + + song_id = db.Column(db.Integer, db.ForeignKey('song.id', ondelete='CASCADE'), primary_key=True) + tag_id = db.Column(db.Integer, db.ForeignKey('tag.id', ondelete='CASCADE'), primary_key=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f"SongTag(song_id={self.song_id}, tag_id={self.tag_id})" + + +class Song(db.Model): + """ + Song model for storing song details from Spotify and Deezer + """ + id = db.Column(db.Integer, primary_key=True) + spotify_id = db.Column(db.String(100), unique=True, nullable=True) + deezer_id = db.Column(db.Integer, unique=True, nullable=True) + isrc = db.Column(db.String(20), index=True, nullable=True) # Add ISRC code + title = db.Column(db.String(200), nullable=False) + artist = db.Column(db.String(200), nullable=False) + album_name = db.Column(db.String(200), nullable=True) # Album name + genre = db.Column(db.String(100)) + year = db.Column(db.Integer) + preview_url = db.Column(db.String(500)) # Increased length for longer URLs - primary preview URL + cover_url = db.Column(db.String(500)) # Increased length - primary cover URL + spotify_preview_url = db.Column(db.String(500), nullable=True) # Spotify-specific preview + deezer_preview_url = db.Column(db.String(500), nullable=True) # Deezer-specific preview + apple_preview_url = db.Column(db.String(500), nullable=True) # Apple Music preview + youtube_preview_url = db.Column(db.String(500), nullable=True) # YouTube preview + spotify_cover_url = db.Column(db.String(500), nullable=True) # Spotify cover + deezer_cover_url = db.Column(db.String(500), nullable=True) # Deezer cover + apple_cover_url = db.Column(db.String(500), nullable=True) # Apple Music cover + popularity = db.Column(db.Integer) + used_count = db.Column(db.Integer, default=0) + source = db.Column(db.String(20), default='spotify') # 'spotify', 'deezer', or 'acrcloud' + import_date = db.Column(db.DateTime, default=datetime.utcnow) # Add import date + added_at = db.Column(db.DateTime, default=datetime.utcnow) + last_used = db.Column(db.DateTime) + metadata_sources = db.Column(db.String(500), nullable=True) # Comma-separated list of metadata sources + + # Spotify Audio Features + acousticness = db.Column(db.Float, nullable=True) # Confidence of track being acoustic (0.0 to 1.0) + danceability = db.Column(db.Float, nullable=True) # How suitable for dancing (0.0 to 1.0) + energy = db.Column(db.Float, nullable=True) # Intensity and activity measure (0.0 to 1.0) + instrumentalness = db.Column(db.Float, nullable=True) # Predicts if track has no vocals (0.0 to 1.0) + key = db.Column(db.Integer, nullable=True) # Key of the track (standard Pitch Class notation, -1 = no key) + liveness = db.Column(db.Float, nullable=True) # Presence of audience in recording (0.0 to 1.0) + loudness = db.Column(db.Float, nullable=True) # Overall loudness in dB (-60 to 0 typically) + mode = db.Column(db.Integer, nullable=True) # Modality - major (1) or minor (0) + speechiness = db.Column(db.Float, nullable=True) # Presence of spoken words (0.0 to 1.0) + tempo = db.Column(db.Float, nullable=True) # Estimated tempo in BPM + time_signature = db.Column(db.Integer, nullable=True) # Estimated time signature (3 to 7 representing 3/4 to 7/4) + valence = db.Column(db.Float, nullable=True) # Musical positiveness (0.0 to 1.0) + duration_ms = db.Column(db.Integer, nullable=True) # Duration of track in milliseconds + analysis_url = db.Column(db.String(500), nullable=True) # URL to access full audio analysis + + # Additional properties as JSON strings + additional_data = db.Column(db.Text, nullable=True) # Store additional data as JSON + + # Relationship with tags + tags = db.relationship('Tag', secondary='song_tag', back_populates='songs') + + def __repr__(self): + return ( + f"Song('{self.id}', '{self.title}', '{self.artist}', " + f"'{self.genre}', '{self.year}', source='{self.source}', " + f"isrc='{self.isrc}', spotify_id='{self.spotify_id}', deezer_id='{self.deezer_id}')" + ) + + def to_dict(self): + return { + 'id': self.id, + 'title': self.title, + 'artist': self.artist, + 'album_name': self.album_name, + 'cover_url': self.cover_url, + 'preview_url': self.preview_url, + 'spotify_id': self.spotify_id, + 'deezer_id': self.deezer_id, + 'isrc': self.isrc, + 'year': self.year, + 'genre': self.genre, + 'popularity': self.popularity, + 'used_count': self.used_count, + 'last_used': self.last_used.strftime('%Y-%m-%d %H:%M:%S') if self.last_used else None, + 'metadata_sources': self.metadata_sources, + 'tags': [{'id': tag.id, 'name': tag.name} for tag in self.tags], + # Include audio features in the dictionary + 'acousticness': self.acousticness, + 'danceability': self.danceability, + 'energy': self.energy, + 'instrumentalness': self.instrumentalness, + 'key': self.key, + 'liveness': self.liveness, + 'loudness': self.loudness, + 'mode': self.mode, + 'speechiness': self.speechiness, + 'tempo': self.tempo, + 'time_signature': self.time_signature, + 'valence': self.valence, + 'duration_ms': self.duration_ms + } + + +class Round(db.Model): + """ + Round model for storing quiz rounds and their associated songs + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=True) # Optional name for the round + round_type = db.Column(db.String(50), nullable=False) + round_criteria_used = db.Column(db.String(500), nullable=False) + songs = db.Column(db.Text, nullable=False) # JSON string of song IDs in order + genre = db.Column(db.String(100)) + decade = db.Column(db.String(10)) + tag = db.Column(db.String(50)) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow) + mp3_generated = db.Column(db.Boolean, default=False) # Track if MP3 has been generated + pdf_generated = db.Column(db.Boolean, default=False) # Track if PDF has been generated + last_generated_at = db.Column(db.DateTime, nullable=True) # When files were last generated + + def __repr__(self): + return ( + f"Round('{self.name or self.id}', '{self.round_type}', '{self.round_criteria_used}', " + f"'{self.songs}', '{self.created_at}')" + ) + + @property + def song_list(self): + """ + Returns a list of Song objects associated with this round + """ + song_ids = self.songs.split(',') + return Song.query.filter(Song.id.in_(song_ids)).all() + + def reset_generated_status(self): + """ + Reset the MP3 and PDF generated flags when a round is modified + """ + self.mp3_generated = False + self.pdf_generated = False + + +class RoundExport(db.Model): + """ + Model to track round exports to various destinations (Dropbox, email, etc.) + """ + id = db.Column(db.Integer, primary_key=True) + round_id = db.Column(db.Integer, db.ForeignKey('round.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True) + export_type = db.Column(db.String(20), nullable=False) # 'dropbox', 'email', etc. + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + destination = db.Column(db.String(500), nullable=True) # Path, email address, etc. + include_mp3s = db.Column(db.Boolean, default=False) # Whether MP3s were included + status = db.Column(db.String(20), default='success') # 'success', 'failed' + error_message = db.Column(db.Text, nullable=True) # Error details if failed + + # Relationships + round = db.relationship('Round', backref=db.backref('exports', lazy='dynamic')) + user = db.relationship('User', backref=db.backref('exports', lazy='dynamic')) + + def __repr__(self): + return f"RoundExport(id={self.id}, round_id={self.round_id}, type='{self.export_type}', timestamp='{self.timestamp}')" + + +class SystemSetting(db.Model): + __tablename__ = 'system_settings' + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(64), unique=True, nullable=False) + value = db.Column(db.Text, nullable=True) + + @staticmethod + def get(key, default=None): + setting = SystemSetting.query.filter_by(key=key).first() + return setting.value if setting else default + + @staticmethod + def set(key, value): + setting = SystemSetting.query.filter_by(key=key).first() + if not setting: + setting = SystemSetting(key=key, value=value) + db.session.add(setting) + else: + setting.value = value + db.session.commit() + + @staticmethod + def all_settings(): + return {s.key: s.value for s in SystemSetting.query.all()} + diff --git a/musicround/mp3/1.mp3 b/musicround/mp3/1.mp3 new file mode 100644 index 0000000..db0b744 Binary files /dev/null and b/musicround/mp3/1.mp3 differ diff --git a/musicround/mp3/2.mp3 b/musicround/mp3/2.mp3 new file mode 100644 index 0000000..249e37d Binary files /dev/null and b/musicround/mp3/2.mp3 differ diff --git a/musicround/mp3/3.mp3 b/musicround/mp3/3.mp3 new file mode 100644 index 0000000..ae461ec Binary files /dev/null and b/musicround/mp3/3.mp3 differ diff --git a/musicround/mp3/4.mp3 b/musicround/mp3/4.mp3 new file mode 100644 index 0000000..2a1a255 Binary files /dev/null and b/musicround/mp3/4.mp3 differ diff --git a/musicround/mp3/5.mp3 b/musicround/mp3/5.mp3 new file mode 100644 index 0000000..75e8932 Binary files /dev/null and b/musicround/mp3/5.mp3 differ diff --git a/musicround/mp3/6.mp3 b/musicround/mp3/6.mp3 new file mode 100644 index 0000000..7fa82ca Binary files /dev/null and b/musicround/mp3/6.mp3 differ diff --git a/musicround/mp3/7.mp3 b/musicround/mp3/7.mp3 new file mode 100644 index 0000000..0ee88f0 Binary files /dev/null and b/musicround/mp3/7.mp3 differ diff --git a/musicround/mp3/8.mp3 b/musicround/mp3/8.mp3 new file mode 100644 index 0000000..c943a75 Binary files /dev/null and b/musicround/mp3/8.mp3 differ diff --git a/musicround/mp3/intro.mp3 b/musicround/mp3/intro.mp3 new file mode 100644 index 0000000..f5f0213 Binary files /dev/null and b/musicround/mp3/intro.mp3 differ diff --git a/musicround/mp3/outro.mp3 b/musicround/mp3/outro.mp3 new file mode 100644 index 0000000..600fbaf Binary files /dev/null and b/musicround/mp3/outro.mp3 differ diff --git a/musicround/mp3/replay.mp3 b/musicround/mp3/replay.mp3 new file mode 100644 index 0000000..2cc54b1 Binary files /dev/null and b/musicround/mp3/replay.mp3 differ diff --git a/musicround/routes/api.py b/musicround/routes/api.py new file mode 100644 index 0000000..ce07f1a --- /dev/null +++ b/musicround/routes/api.py @@ -0,0 +1,1073 @@ +""" +API routes for song operations in the Music Round application +""" +from flask import Blueprint, jsonify, request, current_app, session, redirect, url_for +from musicround.models import Song, Tag, SongTag, db, Round +from musicround.helpers.metadata import get_song_metadata_by_isrc +from flask_wtf.csrf import CSRFProtect +import traceback # Add import at the top +import spotipy as spotify # Changed import from spotify to spotipy as spotify +import logging +from sqlalchemy import or_ +from flask_login import login_required # Add import for login_required + +api_bp = Blueprint('api', __name__, url_prefix='/api') + +@api_bp.route('/songs/', methods=['GET', 'PUT', 'DELETE']) +def song_detail(song_id): + """API endpoint for getting, updating, and deleting song details""" + song = Song.query.get_or_404(song_id) + + if request.method == 'GET': + # Return song details as JSON + tag_list = [{'id': tag.id, 'name': tag.name} for tag in song.tags] + + return jsonify({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'genre': song.genre, + 'year': song.year, + 'isrc': song.isrc, + 'popularity': song.popularity, + 'used_count': song.used_count, + 'spotify_id': song.spotify_id, + 'deezer_id': song.deezer_id, + 'preview_url': song.preview_url, + 'cover_url': song.cover_url, + 'spotify_preview_url': song.spotify_preview_url, + 'deezer_preview_url': song.deezer_preview_url, + 'apple_preview_url': song.apple_preview_url, + 'youtube_preview_url': song.youtube_preview_url, + 'spotify_cover_url': song.spotify_cover_url, + 'deezer_cover_url': song.deezer_cover_url, + 'apple_cover_url': song.apple_cover_url, + 'metadata_sources': song.metadata_sources, + 'album_name': song.album_name, + 'source': song.source, + 'import_date': song.import_date.isoformat() if song.import_date else None, + 'tags': tag_list, + # Add all Spotify audio features + 'acousticness': song.acousticness, + 'danceability': song.danceability, + 'energy': song.energy, + 'instrumentalness': song.instrumentalness, + 'key': song.key, + 'liveness': song.liveness, + 'loudness': song.loudness, + 'mode': song.mode, + 'speechiness': song.speechiness, + 'tempo': song.tempo, + 'time_signature': song.time_signature, + 'valence': song.valence, + 'duration_ms': song.duration_ms + }) + + elif request.method == 'PUT': + # Update song details + data = request.get_json() + current_app.logger.info(f"Updating song {song_id} with data: {data}") + + # Update basic fields + if data.get('title'): + song.title = data['title'] + if data.get('artist'): + song.artist = data['artist'] + if data.get('genre'): + song.genre = data['genre'] + if data.get('year'): + song.year = data['year'] + if data.get('isrc'): + song.isrc = data['isrc'] + if 'popularity' in data and data['popularity'] is not None: + try: + song.popularity = int(data['popularity']) + except (ValueError, TypeError): + current_app.logger.warning(f"Invalid popularity value: {data['popularity']}") + + # Update IDs + if data.get('spotify_id'): + song.spotify_id = data['spotify_id'] + if data.get('deezer_id'): + song.deezer_id = data['deezer_id'] + + # Update URLs + if data.get('preview_url'): + song.preview_url = data['preview_url'] + if data.get('cover_url'): + song.cover_url = data['cover_url'] + + # Save changes + db.session.commit() + current_app.logger.info(f"Song {song_id} updated successfully") + + # Return updated song details including tags + tag_list = [{'id': tag.id, 'name': tag.name} for tag in song.tags] + + return jsonify({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'genre': song.genre, + 'year': song.year, + 'isrc': song.isrc, + 'popularity': song.popularity, + 'preview_url': song.preview_url, + 'cover_url': song.cover_url, + 'tags': tag_list + }) + + elif request.method == 'DELETE': + # Check if the song is used in any rounds before deleting + rounds_with_song = [] + + for round_obj in Round.query.all(): + song_ids = round_obj.songs.split(',') + if str(song_id) in song_ids: + rounds_with_song.append(round_obj.id) + + if rounds_with_song: + # The song is used in rounds, return error + return jsonify({ + 'error': 'Cannot delete song as it is used in rounds', + 'rounds': rounds_with_song + }), 400 + + # Delete the song + title = song.title + artist = song.artist + db.session.delete(song) + db.session.commit() + current_app.logger.info(f"Song {song_id} ({title} by {artist}) deleted successfully") + + return jsonify({ + 'message': f"Song '{title}' by {artist} deleted successfully", + 'id': song_id + }) + +@api_bp.route('/songs//refresh-metadata', methods=['POST']) +def refresh_song_metadata(song_id): + """API endpoint for refreshing song metadata""" + song = Song.query.get_or_404(song_id) + + # Check if we have an ISRC code to use for refreshing metadata + if not song.isrc: + current_app.logger.warning(f"Cannot refresh metadata for song {song_id} - no ISRC code") + return jsonify({'error': 'Song has no ISRC code to refresh metadata'}), 400 + + try: + # Get fresh metadata using the existing ISRC + current_app.logger.info(f"=== DEBUG: Starting metadata refresh for song {song_id} with ISRC {song.isrc} ===") + metadata = get_song_metadata_by_isrc(song.isrc, current_app) + + if not metadata: + current_app.logger.warning(f"No metadata found for ISRC {song.isrc}") + return jsonify({'error': 'No metadata found for this ISRC'}), 404 + + # Debug the metadata received + current_app.logger.info(f"DEBUG: Received metadata structure: {type(metadata).__name__}") + for key, value in metadata.items(): + value_type = type(value).__name__ + value_str = str(value) + if len(value_str) > 100: + value_str = value_str[:100] + "..." + current_app.logger.info(f"DEBUG: Metadata key '{key}' = {value_str} (type: {value_type})") + + # Update song with new metadata + if metadata.get('title'): + current_app.logger.info(f"DEBUG: Updating title to '{metadata['title']}'") + song.title = metadata['title'] + if metadata.get('artist_name'): + current_app.logger.info(f"DEBUG: Updating artist to '{metadata['artist_name']}'") + song.artist = metadata['artist_name'] + if metadata.get('genre'): + current_app.logger.info(f"DEBUG: Updating genre to '{metadata['genre']}' (type: {type(metadata['genre']).__name__})") + song.genre = metadata['genre'] + + # Import helper for creating tags from genre + from musicround.helpers.import_helper import ImportHelper + ImportHelper.create_tags_from_genre(song, metadata['genre']) + + if metadata.get('year'): + current_app.logger.info(f"DEBUG: Updating year to '{metadata['year']}'") + song.year = metadata['year'] + + # Check additional metadata for genres and add tags from there too + if 'genres' in metadata: + current_app.logger.info(f"DEBUG: Creating tags from additional genres") + from musicround.helpers.import_helper import ImportHelper + ImportHelper.create_tags_from_genre(song, metadata['genres']) + + # Update URLs if available + if metadata.get('preview_url'): + current_app.logger.info(f"DEBUG: Updating preview_url") + song.preview_url = metadata['preview_url'] + if metadata.get('cover_url'): + current_app.logger.info(f"DEBUG: Updating cover_url") + song.cover_url = metadata['cover_url'] + + # Update platform-specific IDs if available + if metadata.get('spotify_id'): + song.spotify_id = metadata['spotify_id'] + if metadata.get('deezer_id'): + song.deezer_id = metadata['deezer_id'] + + # Update platform-specific preview URLs + if metadata.get('spotify_preview_url'): + song.spotify_preview_url = metadata['spotify_preview_url'] + if metadata.get('deezer_preview_url'): + song.deezer_preview_url = metadata['deezer_preview_url'] + if metadata.get('apple_preview_url'): + song.apple_preview_url = metadata['apple_preview_url'] + if metadata.get('youtube_preview_url'): + song.youtube_preview_url = metadata['youtube_preview_url'] + + # Update platform-specific cover URLs + if metadata.get('spotify_cover_url'): + song.spotify_cover_url = metadata['spotify_cover_url'] + if metadata.get('deezer_cover_url'): + song.deezer_cover_url = metadata['deezer_cover_url'] + if metadata.get('apple_cover_url'): + song.apple_cover_url = metadata['apple_cover_url'] + + # Update popularity if available + if metadata.get('popularity') is not None: + song.popularity = metadata['popularity'] + + # Store metadata sources + if metadata.get('sources'): + try: + sources_str = ','.join(metadata['sources']) + current_app.logger.info(f"DEBUG: Setting metadata_sources to '{sources_str}'") + song.metadata_sources = sources_str + except Exception as source_error: + current_app.logger.error(f"DEBUG: Error joining sources: {source_error}") + current_app.logger.error(f"DEBUG: Sources value: {metadata['sources']} (type: {type(metadata['sources']).__name__})") + + # Save changes to the database + try: + current_app.logger.info("DEBUG: Committing changes to database") + db.session.commit() + current_app.logger.info(f"Metadata for song {song_id} updated successfully with sources: {metadata.get('sources')}") + except Exception as db_error: + db.session.rollback() + current_app.logger.error(f"DEBUG: Database commit error: {db_error}") + current_app.logger.error(f"DEBUG: Traceback: {traceback.format_exc()}") + return jsonify({'error': f"Database error: {str(db_error)}"}), 500 + + # Return updated song details including tags + tag_list = [{'id': tag.id, 'name': tag.name} for tag in song.tags] + + return jsonify({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'genre': song.genre, + 'year': song.year, + 'isrc': song.isrc, + 'popularity': song.popularity, + 'preview_url': song.preview_url, + 'cover_url': song.cover_url, + 'spotify_id': song.spotify_id, + 'deezer_id': song.deezer_id, + 'spotify_preview_url': song.spotify_preview_url, + 'deezer_preview_url': song.deezer_preview_url, + 'apple_preview_url': song.apple_preview_url, + 'youtube_preview_url': song.youtube_preview_url, + 'metadata_sources': song.metadata_sources, + 'tags': tag_list + }) + + except Exception as e: + current_app.logger.error(f"Error refreshing metadata for song {song_id}: {str(e)}") + current_app.logger.error(f"Full traceback: {traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +# New API routes for tag operations + +@api_bp.route('/tags', methods=['GET']) +def list_tags(): + """Get all available tags""" + tags = Tag.query.order_by(Tag.name).all() + return jsonify({ + 'tags': [{'id': tag.id, 'name': tag.name} for tag in tags] + }) + +@api_bp.route('/tags', methods=['POST']) +def create_tag(): + """Create a new tag""" + data = request.get_json() + + if not data or not data.get('name'): + return jsonify({'error': 'Tag name is required'}), 400 + + tag_name = data['name'].strip() + + # Check if tag already exists + existing_tag = Tag.query.filter(Tag.name.ilike(tag_name)).first() + if existing_tag: + return jsonify({ + 'message': 'Tag already exists', + 'tag': {'id': existing_tag.id, 'name': existing_tag.name} + }) + + # Create new tag + new_tag = Tag(name=tag_name) + db.session.add(new_tag) + db.session.commit() + + return jsonify({ + 'message': 'Tag created successfully', + 'tag': {'id': new_tag.id, 'name': new_tag.name} + }), 201 + +@api_bp.route('/songs//tags', methods=['GET']) +def get_song_tags(song_id): + """Get all tags for a specific song""" + song = Song.query.get_or_404(song_id) + + return jsonify({ + 'song_id': song_id, + 'tags': [{'id': tag.id, 'name': tag.name} for tag in song.tags] + }) + +@api_bp.route('/songs//tags', methods=['POST']) +def add_tag_to_song(song_id): + """Add a tag to a song""" + song = Song.query.get_or_404(song_id) + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Check if we're adding an existing tag or creating a new one + if data.get('tag_id'): + # Add existing tag + tag = Tag.query.get_or_404(data['tag_id']) + elif data.get('tag_name'): + # Find or create tag + tag_name = data['tag_name'].strip() + tag = Tag.query.filter(Tag.name.ilike(tag_name)).first() + + if not tag: + tag = Tag(name=tag_name) + db.session.add(tag) + db.session.commit() + else: + return jsonify({'error': 'Either tag_id or tag_name must be provided'}), 400 + + # Check if the song already has this tag + if tag in song.tags: + return jsonify({ + 'message': f"Song '{song.title}' already has tag '{tag.name}'", + 'song_id': song_id, + 'tag': {'id': tag.id, 'name': tag.name} + }) + + # Add tag to song + song.tags.append(tag) + db.session.commit() + + return jsonify({ + 'message': f"Tag '{tag.name}' added to song '{song.title}'", + 'song_id': song_id, + 'tag': {'id': tag.id, 'name': tag.name} + }) + +@api_bp.route('/songs//tags/', methods=['DELETE']) +def remove_tag_from_song(song_id, tag_id): + """Remove a tag from a song""" + song = Song.query.get_or_404(song_id) + tag = Tag.query.get_or_404(tag_id) + + # Check if the song has this tag + if tag not in song.tags: + return jsonify({ + 'message': f"Song '{song.title}' doesn't have tag '{tag.name}'", + 'song_id': song_id, + 'tag_id': tag_id + }) + + # Remove tag from song + song.tags.remove(tag) + db.session.commit() + + return jsonify({ + 'message': f"Tag '{tag.name}' removed from song '{song.title}'", + 'song_id': song_id, + 'tag_id': tag_id + }) + +@api_bp.route('/tags/', methods=['GET']) +def get_songs_by_tag(tag_id): + """Get all songs with a specific tag""" + tag = Tag.query.get_or_404(tag_id) + + songs = [] + for song in tag.songs: + songs.append({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist + }) + + return jsonify({ + 'tag': {'id': tag.id, 'name': tag.name}, + 'song_count': len(songs), + 'songs': songs + }) + +@api_bp.route('/spotify/album/', methods=['GET']) +def get_spotify_album(album_id): + try: + # Check for Spotify access token + if 'access_token' not in session: + return jsonify({'error': 'You must be logged in to access this feature'}), 401 + + # Initialize Spotify client with access token + sp = spotify.Spotify(auth=session.get('access_token')) + + # Get album details + album = sp.album(album_id) + album_tracks = sp.album_tracks(album_id, limit=50) + + # Format tracks + tracks = [] + for track in album_tracks['items']: + artist_names = [artist['name'] for artist in track['artists']] + tracks.append({ + 'name': track['name'], + 'artist': ', '.join(artist_names), + 'duration': track['duration_ms'], + 'track_number': track['track_number'] + }) + + # Format album response + album_data = { + 'id': album['id'], + 'name': album['name'], + 'artist': ', '.join([artist['name'] for artist in album['artists']]), + 'release_date': album['release_date'], + 'image_url': album['images'][0]['url'] if album['images'] else None, + 'total_tracks': album['total_tracks'], + 'tracks': tracks + } + + return jsonify(album_data) + except Exception as e: + current_app.logger.error(f"Error fetching Spotify album: {str(e)}") + return jsonify({'error': 'Unable to fetch album details'}), 500 + +@api_bp.route('/spotify/playlist/', methods=['GET']) +def get_spotify_playlist(playlist_id): + try: + # Check for Spotify access token + if 'access_token' not in session: + return jsonify({'error': 'You must be logged in to access this feature'}), 401 + + # Initialize Spotify client with access token + sp = spotify.Spotify(auth=session.get('access_token')) + + # Get playlist details + playlist = sp.playlist(playlist_id) + + # Format tracks + tracks = [] + for item in playlist['tracks']['items']: + if not item['track']: + continue + + track = item['track'] + artist_names = [artist['name'] for artist in track['artists']] + tracks.append({ + 'name': track['name'], + 'artist': ', '.join(artist_names), + 'duration': track['duration_ms'], + 'album': track['album']['name'] if track.get('album') else '' + }) + + # Format playlist response + playlist_data = { + 'id': playlist['id'], + 'name': playlist['name'], + 'description': playlist['description'], + 'owner': playlist['owner']['display_name'] or playlist['owner']['id'], + 'image_url': playlist['images'][0]['url'] if playlist['images'] else None, + 'followers': playlist['followers']['total'] if playlist.get('followers') else 0, + 'tracks': tracks + } + + return jsonify(playlist_data) + except Exception as e: + current_app.logger.error(f"Error fetching Spotify playlist: {str(e)}") + return jsonify({'error': 'Unable to fetch playlist details'}), 500 + +@api_bp.route('/deezer/album/', methods=['GET']) +def get_deezer_album(album_id): + try: + # Get album details from Deezer using the DeezerClient instance from Flask app config + deezer_client = current_app.config.get('deezer') # Using correct config key 'deezer' + album = deezer_client.get_album(album_id) + tracks = deezer_client.get_album_tracks(album_id) + + # Format tracks + formatted_tracks = [] + for track in tracks: + formatted_tracks.append({ + 'name': track['title'], + 'artist': track['artist']['name'], + 'duration': track['duration'] * 1000, # Convert to ms for consistency + 'track_number': track.get('track_position', 0) + }) + + # Format album response + album_data = { + 'id': album['id'], + 'name': album['title'], + 'artist': album['artist']['name'], + 'release_date': album.get('release_date', ''), + 'image_url': album.get('cover_xl') or album.get('cover_big') or album.get('cover'), + 'total_tracks': album.get('nb_tracks', len(formatted_tracks)), + 'tracks': formatted_tracks + } + + return jsonify(album_data) + except Exception as e: + current_app.logger.error(f"Error fetching Deezer album: {str(e)}") + return jsonify({'error': 'Unable to fetch album details'}), 500 + +@api_bp.route('/deezer/playlist/', methods=['GET']) +def get_deezer_playlist(playlist_id): + try: + # Get playlist details from Deezer using the DeezerClient instance from Flask app config + deezer_client = current_app.config.get('deezer') # Using correct config key 'deezer' + playlist = deezer_client.get_playlist(playlist_id) + tracks = deezer_client.get_playlist_tracks(playlist_id) + + # Format tracks + formatted_tracks = [] + for track in tracks: + formatted_tracks.append({ + 'name': track['title'], + 'artist': track['artist']['name'], + 'duration': track['duration'] * 1000, # Convert to ms for consistency + 'album': track['album']['title'] if 'album' in track else '' + }) + + # Format playlist response + playlist_data = { + 'id': playlist['id'], + 'name': playlist['title'], + 'description': playlist.get('description', ''), + 'owner': playlist.get('creator', {}).get('name', 'Unknown'), + 'image_url': playlist.get('picture_xl') or playlist.get('picture_big') or playlist.get('picture'), + 'followers': playlist.get('fans', 0), + 'tracks': formatted_tracks + } + + return jsonify(playlist_data) + except Exception as e: + current_app.logger.error(f"Error fetching Deezer playlist: {str(e)}") + return jsonify({'error': 'Unable to fetch playlist details'}), 500 + +@api_bp.route('/songs/search') +def search_songs(): + """Search for songs by title or artist""" + if 'access_token' not in session: + return jsonify({'error': 'Authentication required'}), 401 + + query = request.args.get('q', '') + if not query or len(query) < 2: + return jsonify([]) + + # Search for songs by title or artist + songs = Song.query.filter( + or_( + Song.title.ilike(f'%{query}%'), + Song.artist.ilike(f'%{query}%') + ) + ).limit(20).all() + + # Convert songs to JSON + results = [] + for song in songs: + results.append({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'year': song.year, + 'genre': song.genre, + 'cover_url': song.cover_url, + 'preview_url': song.preview_url + }) + + return jsonify(results) + +@api_bp.route('/songs/update-audio-features', methods=['POST']) +@login_required +def update_audio_features(): + """Update audio features for Spotify songs in the database""" + # Get parameters from the request + batch_size = request.json.get('batch_size', 50) # Process in batches to avoid timeouts + process_all = request.json.get('process_all', False) + process_specific = request.json.get('process_specific', False) + song_ids = request.json.get('song_ids', []) + + # If specific song IDs are provided, query those songs + if process_specific and song_ids: + query = Song.query.filter( + Song.id.in_(song_ids), + Song.spotify_id.isnot(None) # Only process songs with Spotify IDs + ) + else: + # Query songs that have Spotify IDs but no audio features + query = Song.query.filter( + Song.spotify_id.isnot(None) # Only process songs with Spotify IDs + ) + + # If not processing all, only select songs without audio features + if not process_all: + query = query.filter(Song.acousticness.is_(None)) + + # Count total songs to process + total_songs = query.count() + + if total_songs == 0: + return jsonify({ + 'success': True, + 'message': 'No songs found that need audio features.', + 'processed': 0, + 'total': 0 + }) + + # Get bearer token from session + bearer_token = session.get('direct_bearer_token') + + # If no direct bearer token found, try to use the standard access token as fallback + if not bearer_token: + bearer_token = session.get('access_token') + if not bearer_token: + return jsonify({ + 'success': False, + 'message': 'No Spotify authentication token found in session.', + 'error': 'SPOTIFY_TOKEN_NOT_FOUND' + }), 401 + + # Create an instance of the SpotifyDirectClient for this request + try: + # Initialize our custom Spotify client with the bearer token + from musicround.helpers.spotify_direct import SpotifyDirectClient + direct_sp = SpotifyDirectClient(bearer_token=bearer_token) + except Exception as e: + current_app.logger.error(f"Error initializing SpotifyDirectClient: {str(e)}") + return jsonify({ + 'success': False, + 'message': 'Failed to initialize Spotify client', + 'error': str(e) + }), 500 + + # Process songs in batches + processed_count = 0 + error_count = 0 + + # Get all matching songs (limit to reasonable number to prevent timeouts) + songs = query.limit(1000).all() + + # Process in batches of batch_size + for i in range(0, len(songs), batch_size): + batch = songs[i:i+batch_size] + track_ids = [song.spotify_id for song in batch] + + # Get audio features for this batch + try: + # Use our custom SpotifyDirectClient for batch audio features + features = direct_sp.get_tracks_audio_features(track_ids) + + if not features: + current_app.logger.warning(f"No audio features returned for batch {i//batch_size + 1}") + error_count += len(batch) + continue + + # Map features to songs by Spotify ID + features_dict = {feature['id']: feature for feature in features if feature} + + # Update each song with its audio features + for song in batch: + if song.spotify_id in features_dict: + feature = features_dict[song.spotify_id] + + # Update song with audio features + song.acousticness = feature.get('acousticness') + song.danceability = feature.get('danceability') + song.energy = feature.get('energy') + song.instrumentalness = feature.get('instrumentalness') + song.key = feature.get('key') + song.liveness = feature.get('liveness') + song.loudness = feature.get('loudness') + song.mode = feature.get('mode') + song.speechiness = feature.get('speechiness') + song.tempo = feature.get('tempo') + song.time_signature = feature.get('time_signature') + song.valence = feature.get('valence') + song.duration_ms = feature.get('duration_ms') + song.analysis_url = feature.get('analysis_url') + + # Add audio features to metadata sources if not already present + if song.metadata_sources: + sources = song.metadata_sources.split(',') + if 'audio_features' not in sources: + sources.append('audio_features') + song.metadata_sources = ','.join(sources) + else: + song.metadata_sources = 'audio_features' + + processed_count += 1 + else: + current_app.logger.warning(f"No audio features found for song {song.id} ({song.spotify_id})") + error_count += 1 + + # Save all changes to database + db.session.commit() + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error processing audio features batch: {str(e)}") + error_count += len(batch) + + return jsonify({ + 'success': True, + 'message': f'Successfully updated audio features for {processed_count} songs. {error_count} errors.', + 'processed': processed_count, + 'errors': error_count, + 'total': total_songs + }) + +@api_bp.route('/dropbox/folders', methods=['GET']) +@login_required +def list_dropbox_folders(): + """List folders from user's Dropbox account for the folder browser""" + from flask_login import current_user + from musicround.helpers.dropbox_helper import get_current_user_dropbox_token + import requests + import json + import traceback + + # Check if user has Dropbox connected + if not current_user.dropbox_token: + current_app.logger.error("Dropbox folder listing failed: User has no Dropbox token") + return jsonify({'error': 'Dropbox account not connected'}), 401 + + # Get Dropbox token + token = get_current_user_dropbox_token() + if not token: + current_app.logger.error("Dropbox folder listing failed: Failed to get valid token") + return jsonify({'error': 'Failed to get valid Dropbox token'}), 401 + + # Get the path from query params, default to empty string (root) + path = request.args.get('path', '') + + # Fix path format for Dropbox API + # Dropbox API requires empty string for root, not "/" + if path == '/' or not path: + path = "" + display_path = "/" + else: + display_path = path + + current_app.logger.info(f"Listing Dropbox folders for path: {display_path}") + + try: + # Call Dropbox API to list folders + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + # Use the list_folder API with recursive=False to get only immediate children + data = { + 'path': path, + 'recursive': False, + 'include_deleted': False, + 'include_has_explicit_shared_members': False, + 'include_mounted_folders': True, + 'include_non_downloadable_files': False + } + + current_app.logger.debug(f"Sending request to Dropbox API: {json.dumps(data)}") + current_app.logger.debug(f"Using token: {token[:5]}...{token[-5:] if len(token) > 10 else ''}") + + response = requests.post( + 'https://api.dropboxapi.com/2/files/list_folder', + headers=headers, + json=data + ) + + current_app.logger.debug(f"Dropbox API response status: {response.status_code}") + + if response.status_code != 200: + current_app.logger.error(f"Dropbox API error: {response.status_code}, Response: {response.text}") + + # Check for specific error types to provide better messages + try: + error_data = response.json() + error_message = f"Dropbox API error: {response.status_code}" + + # Handle "not_found" error by trying to create the folder if it's not root + if (response.status_code == 409 and + "not_found" in response.text and + path != ""): + current_app.logger.info(f"Folder {display_path} doesn't exist, showing root folder instead") + # Return root folder with a note about the folder not existing + return list_root_folders(token, display_path) + + if 'error_summary' in error_data: + error_message += f": {error_data['error_summary']}" + + # Handle common errors + if response.status_code == 401: + # Try to refresh the token and retry once + current_app.logger.info("Attempting to refresh Dropbox token and retry") + from musicround.helpers.dropbox_helper import refresh_dropbox_token + + if current_user.dropbox_refresh_token: + new_token_info = refresh_dropbox_token(current_user.dropbox_refresh_token) + if new_token_info and 'access_token' in new_token_info: + # Try again with the new token + headers['Authorization'] = f"Bearer {new_token_info['access_token']}" + response = requests.post( + 'https://api.dropboxapi.com/2/files/list_folder', + headers=headers, + json=data + ) + + if response.status_code == 200: + # Success! Continue with processing + current_app.logger.info("Successfully refreshed token and retrieved folders") + else: + current_app.logger.error(f"Still failed after token refresh: {response.status_code}, {response.text}") + return jsonify({'error': error_message, 'details': error_data}), response.status_code + else: + current_app.logger.error("Token refresh failed") + + if response.status_code != 200: # If we're still having an error + return jsonify({'error': error_message, 'details': error_data}), response.status_code + + except Exception as json_error: + current_app.logger.error(f"Error parsing Dropbox error response: {str(json_error)}") + return jsonify({'error': f'Dropbox API error: {response.status_code}', 'raw_response': response.text}), 500 + + # Process the successful response + result = response.json() + current_app.logger.debug(f"Received Dropbox response: {json.dumps(result)[:1000] if len(json.dumps(result)) > 1000 else json.dumps(result)}") + + # Filter to only include folders + folders = [] + for entry in result.get('entries', []): + if entry.get('.tag') == 'folder': + folders.append({ + 'name': entry.get('name', ''), + 'path_display': entry.get('path_display', ''), + 'id': entry.get('id', '') + }) + + current_app.logger.info(f"Successfully listed {len(folders)} folders in path '{display_path}'") + + return jsonify({ + 'path': display_path, + 'folders': folders + }) + + except Exception as e: + error_traceback = traceback.format_exc() + current_app.logger.error(f"Error listing Dropbox folders: {str(e)}") + current_app.logger.error(f"Traceback: {error_traceback}") + return jsonify({ + 'error': str(e), + 'traceback': error_traceback, + 'message': 'An unexpected error occurred while listing Dropbox folders' + }), 500 + +@api_bp.route('/dropbox/create-folder', methods=['POST']) +@login_required +def create_dropbox_folder(): + """Create a new folder in the user's Dropbox account""" + from flask_login import current_user + from musicround.helpers.dropbox_helper import get_current_user_dropbox_token + import requests + import json + import traceback + + # Check if user has Dropbox connected + if not current_user.dropbox_token: + current_app.logger.error("Dropbox folder creation failed: User has no Dropbox token") + return jsonify({'error': 'Dropbox account not connected'}), 401 + + # Get Dropbox token + token = get_current_user_dropbox_token() + if not token: + current_app.logger.error("Dropbox folder creation failed: Failed to get valid token") + return jsonify({'error': 'Failed to get valid Dropbox token'}), 401 + + # Get path and new folder name from request + data = request.get_json() + if not data or 'parent_path' not in data or 'folder_name' not in data: + return jsonify({'error': 'Missing required parameters: parent_path and folder_name'}), 400 + + parent_path = data['parent_path'] + folder_name = data['folder_name'].strip() + + # Validate folder name - basic validation + if not folder_name: + return jsonify({'error': 'Folder name cannot be empty'}), 400 + + if any(char in folder_name for char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|']): + return jsonify({'error': 'Folder name contains invalid characters'}), 400 + + # Construct full path + # If parent is root, we need special handling + if parent_path == '/' or parent_path == '': + full_path = '/' + folder_name + else: + full_path = parent_path + '/' + folder_name + + current_app.logger.info(f"Creating Dropbox folder: {full_path}") + + try: + # Call Dropbox API to create folder + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + data = { + 'path': full_path, + 'autorename': False + } + + current_app.logger.debug(f"Sending request to Dropbox API: {json.dumps(data)}") + + response = requests.post( + 'https://api.dropboxapi.com/2/files/create_folder_v2', + headers=headers, + json=data + ) + + current_app.logger.debug(f"Dropbox API response status: {response.status_code}") + + if response.status_code != 200: + current_app.logger.error(f"Dropbox API error: {response.status_code}, Response: {response.text}") + + try: + error_data = response.json() + error_message = f"Dropbox API error: {response.status_code}" + + if 'error_summary' in error_data: + error_message += f": {error_data['error_summary']}" + + # Special handling for conflict (folder already exists) + if response.status_code == 409 and 'conflict' in response.text: + return jsonify({ + 'error': 'A folder with this name already exists', + 'details': error_data + }), 409 + + return jsonify({'error': error_message, 'details': error_data}), response.status_code + + except Exception as json_error: + current_app.logger.error(f"Error parsing Dropbox error response: {str(json_error)}") + return jsonify({ + 'error': f'Dropbox API error: {response.status_code}', + 'raw_response': response.text + }), 500 + + # Process the successful response + result = response.json() + current_app.logger.debug(f"Received Dropbox response: {json.dumps(result)}") + + # Extract metadata from result + metadata = result.get('metadata', {}) + + current_app.logger.info(f"Successfully created folder: {full_path}") + + return jsonify({ + 'success': True, + 'path': metadata.get('path_display', full_path), + 'name': metadata.get('name', folder_name), + 'id': metadata.get('id', '') + }) + + except Exception as e: + error_traceback = traceback.format_exc() + current_app.logger.error(f"Error creating Dropbox folder: {str(e)}") + current_app.logger.error(f"Traceback: {error_traceback}") + return jsonify({ + 'error': str(e), + 'traceback': error_traceback, + 'message': 'An unexpected error occurred while creating Dropbox folder' + }), 500 + +def list_root_folders(token, attempted_path=None): + """Get root folders as a fallback when requested path doesn't exist""" + import requests + import json + + try: + # Call Dropbox API to list root folders + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + data = { + 'path': "", # Empty string for root + 'recursive': False, + 'include_deleted': False, + 'include_has_explicit_shared_members': False, + 'include_mounted_folders': True, + 'include_non_downloadable_files': False + } + + current_app.logger.debug("Listing root folders as fallback") + + response = requests.post( + 'https://api.dropboxapi.com/2/files/list_folder', + headers=headers, + json=data + ) + + if response.status_code != 200: + current_app.logger.error(f"Root folder listing failed: {response.status_code}, {response.text}") + return jsonify({ + 'error': f'Could not list root folders: {response.status_code}', + 'attempted_path': attempted_path + }), response.status_code + + # Process the successful response + result = response.json() + + # Filter to only include folders + folders = [] + for entry in result.get('entries', []): + if entry.get('.tag') == 'folder': + folders.append({ + 'name': entry.get('name', ''), + 'path_display': entry.get('path_display', ''), + 'id': entry.get('id', '') + }) + + message = None + if attempted_path: + message = f"Folder '{attempted_path}' doesn't exist. Showing root folder instead." + + return jsonify({ + 'path': '/', + 'folders': folders, + 'warning': message + }) + + except Exception as e: + current_app.logger.error(f"Error listing root folders: {str(e)}") + return jsonify({ + 'error': f'Error listing root folders: {str(e)}', + 'attempted_path': attempted_path + }), 500 \ No newline at end of file diff --git a/musicround/routes/auth.py b/musicround/routes/auth.py new file mode 100644 index 0000000..f1c550e --- /dev/null +++ b/musicround/routes/auth.py @@ -0,0 +1,179 @@ +""" +Authentication routes for the Music Round application +""" +import os +from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app, session +from flask_login import login_user, current_user, logout_user, login_required +from werkzeug.security import check_password_hash +from musicround.models import User, db +from datetime import datetime +import spotipy + +# Create blueprint +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/') +def index(): + """Landing page""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + return render_template('auth/index.html') + +@auth_bp.route('/login') +def login(): + """Redirect to user login page""" + return redirect(url_for('users.login')) + +@auth_bp.route('/login-with-spotify') +def login_with_spotify(): + """Start Spotify OAuth flow for login""" + # If user is already logged in, redirect to home + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Create a new OAuth object + sp_oauth = current_app.config['sp_oauth'] + + # Get the authorization URL + auth_url = sp_oauth.get_authorize_url() + + # Store state in session for validation + session['oauth_state'] = sp_oauth.state + + # Set flag that we're using OAuth for login, not just connection + session['spotify_login_flow'] = True + + return redirect(auth_url) + +@auth_bp.route('/callback') +def callback(): + """Handle Spotify OAuth callback for login""" + try: + # Verify the state parameter + if request.args.get('state') != session.get('oauth_state'): + flash("Authentication state mismatch. Please try logging in again.", "danger") + return redirect(url_for('auth.index')) + + # Get the authorization code + code = request.args.get('code') + if not code: + flash("No authorization code received from Spotify.", "danger") + return redirect(url_for('auth.index')) + + # Exchange the code for an access token + sp_oauth = current_app.config['sp_oauth'] + token_info = sp_oauth.get_access_token(code) + + if not token_info or 'access_token' not in token_info: + flash("Failed to obtain access token from Spotify.", "danger") + return redirect(url_for('auth.index')) + + # Store the token in the session + session['access_token'] = token_info['access_token'] + session['refresh_token'] = token_info.get('refresh_token') + session['token_expiration'] = token_info.get('expires_at') + session['token_source'] = 'user' + + # Get user info from Spotify to find or create the user account + sp = spotipy.Spotify(auth=token_info['access_token']) + spotify_user_info = sp.current_user() + + if not spotify_user_info or 'id' not in spotify_user_info: + flash("Could not fetch user information from Spotify.", "danger") + return redirect(url_for('auth.index')) + + spotify_id = spotify_user_info['id'] + email = spotify_user_info.get('email') + display_name = spotify_user_info.get('display_name', spotify_id) + + # Log the Spotify login attempt + current_app.logger.info(f"Spotify login attempt: ID={spotify_id}, Email={email}, Name={display_name}") + + # Look for an existing user with this Spotify ID + user = User.query.filter_by(oauth_id=spotify_id).first() + + # If no user found with this Spotify ID but we have an email, try to find by email + if not user and email: + user = User.query.filter_by(email=email).first() + if user: + # Update the user's Spotify ID if they have an account with the same email + user.oauth_id = spotify_id + current_app.logger.info(f"Linked Spotify ID {spotify_id} to existing account: {user.username}") + + # If we still don't have a user, create a new one + if not user: + if not email: + # If Spotify didn't provide an email, we can't create a new user automatically + flash("Your Spotify account does not have an email address. Please register manually.", "danger") + return redirect(url_for('users.register')) + + # Generate a unique username based on Spotify display name + base_username = ''.join(c for c in display_name if c.isalnum()).lower() + if not base_username: + base_username = "spotify_user" + + username = base_username + count = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}{count}" + count += 1 + + # Create a new user + from werkzeug.security import generate_password_hash + import secrets + + # Generate a random password - user can reset it later + random_password = secrets.token_urlsafe(12) + + user = User( + username=username, + email=email, + password_hash=generate_password_hash(random_password), + first_name=display_name.split()[0] if ' ' in display_name else display_name, + last_name=' '.join(display_name.split()[1:]) if ' ' in display_name else '', + oauth_id=spotify_id, + created_at=datetime.now(), + last_login=datetime.now() + ) + + try: + db.session.add(user) + db.session.commit() + current_app.logger.info(f"Created new user from Spotify: {username} (ID: {user.id})") + flash(f"Welcome! A new account has been created for you as '{username}'.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating user from Spotify: {e}") + flash("Error creating account. Please try again or register manually.", "danger") + return redirect(url_for('users.register')) + + # Store the Spotify tokens in the user's account + user.spotify_token = token_info['access_token'] + user.spotify_refresh_token = token_info.get('refresh_token') + if 'expires_at' in token_info: + user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) + + # Update last login time + user.last_login = datetime.now() + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating user with Spotify tokens: {e}") + flash("Error updating your account with Spotify information.", "danger") + return redirect(url_for('users.login')) + + # Log the user in + login_user(user) + + # Update the Spotify client with the new token + current_app.config['sp'].set_auth(token_info['access_token']) + + flash("Successfully logged in with Spotify!", "success") + return redirect(url_for('core.index')) + + except Exception as e: + current_app.logger.error(f"Error during Spotify callback: {e}") + flash("Error during Spotify authentication. Please try again.", "danger") + return redirect(url_for('auth.index')) \ No newline at end of file diff --git a/musicround/routes/core.py b/musicround/routes/core.py new file mode 100644 index 0000000..6a39af4 --- /dev/null +++ b/musicround/routes/core.py @@ -0,0 +1,323 @@ +""" +Core routes that form the basic navigation structure of the app. +""" +from flask import Blueprint, render_template, redirect, url_for, current_app, request, send_from_directory, abort, session +from flask_login import current_user, login_required +from musicround import db + +core_bp = Blueprint('core', __name__) + +@core_bp.route('/') +def index(): + """ + If user is not logged in, show login. + Otherwise, show homepage with user info. + """ + if not current_user.is_authenticated: + return redirect(url_for('users.login')) + + return render_template('homepage.html', user_info={'display_name': current_user.username}) + +@core_bp.route('/search', methods=['GET']) +@login_required +def search(): + """ + Show search page for Spotify + """ + return render_template('service_search.html', + service_name='Spotify', + search_results_url=url_for('core.search_results'), + track_import_url=url_for('import_songs.import_song'), + album_import_url=url_for('import_songs.import_album'), + playlist_import_url=url_for('import_songs.import_playlist'), + url_placeholder="https://open.spotify.com/track/...") + +@core_bp.route('/search-results', methods=['POST']) +@login_required +def search_results(): + """Process Spotify search and display results""" + if 'access_token' not in session: + # Redirect to Spotify login if not authenticated + return redirect(url_for('auth.spotify_login')) + + search_term = request.form.get('search_term', '') + if not search_term: + return redirect(url_for('core.search')) + + try: + # Initialize Spotify client with access token + import spotipy + from spotipy.exceptions import SpotifyException + + current_app.logger.info(f"Searching Spotify for: {search_term}") + + # Try to check if token is valid before using it + try: + sp = spotipy.Spotify(auth=session.get('access_token')) + # Make a simple API call to verify token + sp.current_user() + except SpotifyException as e: + # If token is expired, try refreshing it + if e.http_status == 401: + current_app.logger.info("Spotify token expired, attempting refresh") + # Check if we have a refresh token + if current_user.spotify_refresh_token: + try: + # Create OAuth object to refresh token + from spotipy.oauth2 import SpotifyOAuth + from musicround.config import Config + + sp_oauth = SpotifyOAuth( + client_id=Config.SPOTIFY_CLIENT_ID, + client_secret=Config.SPOTIFY_CLIENT_SECRET, + redirect_uri=Config.SPOTIFY_REDIRECT_URI, + scope=Config.SPOTIFY_SCOPE + ) + + # Get new token + token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token) + + # Update session and user + session['access_token'] = token_info['access_token'] + current_user.spotify_token = token_info['access_token'] + if 'refresh_token' in token_info: + current_user.spotify_refresh_token = token_info['refresh_token'] + + # Update token expiry + import datetime + current_user.spotify_token_expiry = datetime.datetime.now() + datetime.timedelta(seconds=token_info['expires_in']) + + # Save changes + db.session.commit() + + # Create new Spotify client with updated token + sp = spotipy.Spotify(auth=token_info['access_token']) + + except Exception as refresh_error: + current_app.logger.error(f"Error refreshing Spotify token: {str(refresh_error)}") + # Redirect to login if we can't refresh + return redirect(url_for('auth.spotify_login')) + else: + # No refresh token, redirect to login + return redirect(url_for('auth.spotify_login')) + else: + # Some other Spotify error + raise + + # Prepare more specific search parameters for better results + # Try different search strategies for artists vs tracks + search_strategies = [ + # Regular search for all types + {'q': search_term, 'type': 'track,album,playlist', 'limit': 10}, + + # Search specifically for artist + {'q': f'artist:{search_term}', 'type': 'track', 'limit': 10}, + + # Search specifically for track + {'q': f'track:{search_term}', 'type': 'track', 'limit': 10} + ] + + tracks = [] + albums = [] + playlists = [] + + # Try different search strategies until we get results + for strategy in search_strategies: + current_app.logger.info(f"Trying search strategy: {strategy}") + + # Perform search with current strategy + results = sp.search(**strategy) + + # Extract track results if available + if 'tracks' in results and results['tracks']['items']: + for item in results['tracks']['items']: + # Skip None items or items without required fields + if item is None or 'id' not in item or 'artists' not in item or 'album' not in item: + continue + + artist_names = [artist['name'] for artist in item['artists']] + + # Get album image safely + image_url = None + if 'album' in item and item['album'] and 'images' in item['album'] and item['album']['images']: + image_url = item['album']['images'][0]['url'] if item['album']['images'] else None + + # Get album name safely + album_name = item['album']['name'] if 'album' in item and item['album'] and 'name' in item['album'] else 'Unknown Album' + + tracks.append({ + 'id': item['id'], + 'name': item['name'], + 'artist': ', '.join(artist_names), + 'album': album_name, + 'image_url': image_url, + 'preview_url': item.get('preview_url'), + 'duration_ms': item.get('duration_ms', 0) + }) + + # Extract album results if available + if 'albums' in results and results['albums']['items']: + for item in results['albums']['items']: + # Skip None items or items without required fields + if item is None or 'id' not in item or 'artists' not in item: + continue + + artist_names = [artist['name'] for artist in item['artists']] + + # Get album image safely + image_url = None + if 'images' in item and item['images']: + image_url = item['images'][0]['url'] if item['images'] else None + + albums.append({ + 'id': item['id'], + 'name': item['name'], + 'artist': ', '.join(artist_names), + 'image_url': image_url, + 'total_tracks': item.get('total_tracks', 0) + }) + + # Extract playlist results if available + if 'playlists' in results and results['playlists']['items']: + for item in results['playlists']['items']: + # Skip None items or items without required fields + if item is None or 'id' not in item or 'owner' not in item: + continue + + # Get playlist image safely + image_url = None + if 'images' in item and item['images']: + image_url = item['images'][0]['url'] if item['images'] else None + + # Get track count safely + track_count = 0 + if 'tracks' in item and item['tracks'] is not None and 'total' in item['tracks']: + track_count = item['tracks']['total'] + + # Get owner name safely + owner_name = item['owner'].get('display_name') or item['owner'].get('id', 'Unknown') + + playlists.append({ + 'id': item['id'], + 'name': item['name'], + 'owner': owner_name, + 'image_url': image_url, + 'tracks': track_count + }) + + # If we got any results, break the loop + if tracks or albums or playlists: + break + + # If still no results after all strategies, try one more approach + if not tracks and not albums and not playlists: + current_app.logger.info("No results from standard searches, trying market-specific search") + # Try a more generic search with market specification + results = sp.search(q=search_term, type='track,album,playlist', limit=10, market='US') + + # Extract track results + if 'tracks' in results and results['tracks']['items']: + for item in results['tracks']['items']: + artist_names = [artist['name'] for artist in item['artists']] + tracks.append({ + 'id': item['id'], + 'name': item['name'], + 'artist': ', '.join(artist_names), + 'album': item['album']['name'], + 'image_url': item['album']['images'][0]['url'] if item['album']['images'] else None, + 'preview_url': item['preview_url'], + 'duration_ms': item['duration_ms'] + }) + + # Remove duplicates (in case our strategies found the same items) + unique_tracks = [] + track_ids_seen = set() + for track in tracks: + if track['id'] not in track_ids_seen: + track_ids_seen.add(track['id']) + unique_tracks.append(track) + + unique_albums = [] + album_ids_seen = set() + for album in albums: + if album['id'] not in album_ids_seen: + album_ids_seen.add(album['id']) + unique_albums.append(album) + + unique_playlists = [] + playlist_ids_seen = set() + for playlist in playlists: + if playlist['id'] not in playlist_ids_seen: + playlist_ids_seen.add(playlist['id']) + unique_playlists.append(playlist) + + # Log the number of results found + current_app.logger.info(f"Search results: {len(unique_tracks)} tracks, {len(unique_albums)} albums, {len(unique_playlists)} playlists") + + # Render search results template + return render_template('service_search_results.html', + service_name='Spotify', + search_term=search_term, + tracks=unique_tracks, + albums=unique_albums, + playlists=unique_playlists, + track_import_url=url_for('import_songs.import_song'), + album_import_url=url_for('import_songs.import_album'), + playlist_import_url=url_for('import_songs.import_playlist'), + track_id_field='song_id', + album_id_field='album_id', + playlist_id_field='playlist_id', + tracks_label='Tracks', + has_preview=True, + search_url=url_for('core.search')) + + except Exception as e: + # Log the detailed error + import traceback + current_app.logger.error(f"Spotify search error: {str(e)}") + current_app.logger.error(traceback.format_exc()) + + # Render error template + return render_template('error.html', + error_message="An error occurred while searching Spotify.", + error_details=str(e), + back_url=url_for('core.search')) + +@core_bp.route('/view-songs') +@login_required +def view_songs(): + """ + Show all songs in database + """ + from musicround.models import Song, Tag + + # Get all songs + songs = Song.query.all() + + # Get all tags + tags = Tag.query.all() + + return render_template('view_songs.html', songs=songs, tags=tags) + +@core_bp.route('/data/') +@login_required +def serve_user_audio(filepath): + """ + Serve user custom audio files from the data directory + """ + # For security, ensure the filepath doesn't try to access parent directories + if '..' in filepath: + abort(404) + + # Only allow access to the current user's custom MP3 files or to admins + if 'custommp3/' in filepath: + # Extract username from the filepath + parts = filepath.split('/') + if len(parts) >= 2 and parts[0] == 'custommp3': + username = parts[1] + + # Check if current user is the owner of the file or an admin + if username != current_user.username and not current_user.is_admin: + abort(403) # Unauthorized + + return send_from_directory('/data', filepath) \ No newline at end of file diff --git a/musicround/routes/db_admin.py b/musicround/routes/db_admin.py new file mode 100644 index 0000000..e6ebfbf --- /dev/null +++ b/musicround/routes/db_admin.py @@ -0,0 +1,190 @@ +from flask import Blueprint, redirect, url_for, flash, jsonify +from flask_admin import Admin, BaseView, expose +from flask_admin.contrib.sqla import ModelView +from flask_admin.contrib.fileadmin import FileAdmin +from flask_admin.menu import MenuLink +from flask_admin.actions import action +from flask_login import current_user, login_required +from musicround.models import Song, Tag, SongTag, Round, User, Role, UserPreferences, SystemSetting, db +from functools import wraps +import os +import json + +# Create a basic authentication wrapper +def admin_required(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + # Check if user is logged in and is an admin + if not current_user.is_authenticated: + return redirect(url_for('users.login')) + + if not current_user.is_admin(): + flash('Admin access required.', 'danger') + return redirect(url_for('core.index')) + + return view_func(*args, **kwargs) + return wrapper + +# Create the blueprint +db_admin_bp = Blueprint('db_admin', __name__, url_prefix='/admin') + +# Define routes on the blueprint before it gets registered +@db_admin_bp.route('/raw') +@admin_required +def raw_db_access(): + return redirect(url_for('admin.index')) + +# Base model view with authentication +class AuthModelView(ModelView): + def is_accessible(self): + return current_user.is_authenticated and current_user.is_admin() + + def inaccessible_callback(self, name, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('users.login')) + return redirect(url_for('core.index')) + + # Add basic search functionality to all models + column_searchable_list = [] + column_filters = [] + + # Enable export to CSV + can_export = True + export_types = ['csv', 'json'] + +# Enhanced Song ModelView +class SongModelView(AuthModelView): + column_searchable_list = ['title', 'artist', 'spotify_id'] + column_filters = ['title', 'artist', 'year', 'genre', 'used_count'] + column_default_sort = ('id', False) + + @action('reset_used_count', 'Reset Used Count', 'Are you sure you want to reset used count to 0?') + def action_reset_used_count(self, ids): + try: + query = Song.query.filter(Song.id.in_(ids)) + + # Update all songs + for song in query.all(): + song.used_count = 0 + + db.session.commit() + flash(f'Used count reset for {len(ids)} songs.', 'success') + except Exception as ex: + db.session.rollback() + flash(f'Error resetting used count: {str(ex)}', 'danger') + +# Enhanced Round ModelView +class RoundModelView(AuthModelView): + column_searchable_list = ['name', 'round_type', 'round_criteria_used'] + column_filters = ['name', 'round_type', 'round_criteria_used'] + column_list = ['id', 'name', 'round_type', 'round_criteria_used', 'created_at', 'mp3_generated', 'pdf_generated'] + column_default_sort = ('id', False) + +# Enhanced Tag ModelView +class TagModelView(AuthModelView): + column_searchable_list = ['name'] + column_filters = ['name'] + +# Enhanced SongTag ModelView +class SongTagModelView(AuthModelView): + column_filters = ['song_id', 'tag_id'] + +# Enhanced User ModelView +class UserModelView(AuthModelView): + column_searchable_list = ['username', 'email', 'first_name', 'last_name'] + column_filters = ['username', 'email', 'active', 'created_at', 'last_login'] + column_default_sort = ('id', False) + + # Protect password field in forms + form_excluded_columns = ['password_hash', 'reset_token', 'reset_token_expiry'] + + @action('activate_users', 'Activate Users', 'Are you sure you want to activate selected users?') + def action_activate_users(self, ids): + try: + query = User.query.filter(User.id.in_(ids)) + + # Update all selected users + for user in query.all(): + user.active = True + + db.session.commit() + flash(f'Successfully activated {len(ids)} users.', 'success') + except Exception as ex: + db.session.rollback() + flash(f'Error activating users: {str(ex)}', 'danger') + + @action('deactivate_users', 'Deactivate Users', 'Are you sure you want to deactivate selected users?') + def action_deactivate_users(self, ids): + try: + query = User.query.filter(User.id.in_(ids)) + + # Update all selected users + for user in query.all(): + user.active = False + + db.session.commit() + flash(f'Successfully deactivated {len(ids)} users.', 'success') + except Exception as ex: + db.session.rollback() + flash(f'Error deactivating users: {str(ex)}', 'danger') + +# Enhanced Role ModelView +class RoleModelView(AuthModelView): + column_searchable_list = ['name', 'description'] + column_filters = ['name'] + +# Enhanced UserPreferences ModelView +class UserPreferencesModelView(AuthModelView): + column_filters = ['user_id', 'theme', 'enable_intro'] + +# Enhanced SystemSetting ModelView +class SystemSettingModelView(AuthModelView): + column_searchable_list = ['key'] + column_filters = ['key'] + column_exclude_list = [] # Ensure any sensitive values are not excluded if needed + +# Initialize the admin interface +admin = None + +def init_admin(app): + """Initialize the admin interface with the Flask app.""" + global admin + + # Set Flask-Admin configuration + app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' # Use a Bootstrap swatch theme + + # Create admin interface + admin = Admin( + app, + name='MusicRound Admin', + template_mode='bootstrap3', + url='/admin' + ) + + # Add model views + # Data models + admin.add_view(SongModelView(Song, db.session, category="Music Data")) + admin.add_view(TagModelView(Tag, db.session, category="Music Data")) + admin.add_view(SongTagModelView(SongTag, db.session, category="Music Data")) + admin.add_view(RoundModelView(Round, db.session, category="Music Data")) + + # User management + admin.add_view(UserModelView(User, db.session, category="User Management")) + admin.add_view(RoleModelView(Role, db.session, category="User Management")) + admin.add_view(UserPreferencesModelView(UserPreferences, db.session, category="User Management")) + + # System + admin.add_view(SystemSettingModelView(SystemSetting, db.session, category="System")) + + # Add file admin for audio files + path = os.path.join(os.path.dirname(__file__), '../static/audio') + admin.add_view(FileAdmin(path, '/static/audio/', name='Audio Files', category="System", endpoint='static_audio_files')) + + # User MP3 files + user_mp3_path = os.path.join(os.path.dirname(__file__), '../mp3') + admin.add_view(FileAdmin(user_mp3_path, '/mp3/', name='User MP3 Files', category="System", endpoint='user_mp3_files')) + + # Add links + admin.add_link(MenuLink(name='Back to App', url='/')) + + return admin \ No newline at end of file diff --git a/musicround/routes/deezer_routes.py b/musicround/routes/deezer_routes.py new file mode 100644 index 0000000..6b88830 --- /dev/null +++ b/musicround/routes/deezer_routes.py @@ -0,0 +1,223 @@ +from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify +from musicround.models import Song, db +import deezer +import musicbrainzngs +import requests +import openai +import os +import json +from musicround.helpers.metadata import get_song_metadata_by_isrc +from musicround.helpers.import_helper import ImportHelper + +deezer_bp = Blueprint('deezer', __name__) + +@deezer_bp.route('/deezer-search', methods=['GET']) +def deezer_search(): + """Display Deezer search form""" + return render_template('service_search.html', + service_name='Deezer', + search_results_url=url_for('deezer.deezer_search_results'), + browse_playlists_url=url_for('deezer.browse_deezer_playlists'), + track_import_url=url_for('deezer.import_deezer_track_result'), + album_import_url=url_for('deezer.import_deezer_album_result'), + playlist_import_url=url_for('deezer.import_deezer_playlist_result'), + url_placeholder='https://www.deezer.com/...') + +@deezer_bp.route('/deezer-search-results', methods=['POST']) +def deezer_search_results(): + """Search for tracks, albums, and playlists on Deezer""" + search_term = request.form['search_term'] + deezer_client = current_app.config['deezer'] + + try: + tracks = deezer_client.search_tracks(search_term) + albums = deezer_client.search_albums(search_term) + playlists = deezer_client.search_playlists(search_term) + + # Format tracks for the template + formatted_tracks = [] + for track in tracks: + if track: + formatted_tracks.append({ + 'id': track.get('id'), + 'name': track.get('title'), + 'artist': track.get('artist', {}).get('name', 'Unknown Artist') if track.get('artist') else 'Unknown Artist', + 'album': track.get('album', {}).get('title', '') if track.get('album') else '', + 'image_url': track.get('album', {}).get('cover_medium') if track.get('album') else None, + 'preview_url': track.get('preview') + }) + + # Format albums for the template + formatted_albums = [] + for album in albums: + if album: + formatted_albums.append({ + 'id': album.get('id'), + 'name': album.get('title'), + 'artist': album.get('artist', {}).get('name', 'Unknown Artist') if album.get('artist') else 'Unknown Artist', + 'image_url': album.get('cover_medium'), + 'track_count': album.get('nb_tracks') + }) + + # Format playlists for the template + formatted_playlists = [] + for playlist in playlists: + if playlist: + formatted_playlists.append({ + 'id': playlist.get('id'), + 'name': playlist.get('title'), + 'owner': playlist.get('user', {}).get('name', 'Unknown') if playlist.get('user') else 'Unknown', + 'image_url': playlist.get('picture_medium'), + 'track_count': playlist.get('nb_tracks') + }) + + # Use the standardized template for search results + return render_template('service_search_results.html', + service_name='Deezer', + search_term=search_term, + tracks=formatted_tracks, + albums=formatted_albums, + playlists=formatted_playlists, + tracks_label='Tracks', + search_url=url_for('deezer.deezer_search'), + has_preview=True, + track_import_url=url_for('deezer.import_deezer_track_result'), + track_id_field='track_id', + album_import_url=url_for('deezer.import_deezer_album_result'), + album_id_field='album_id', + playlist_import_url=url_for('deezer.import_deezer_playlist_result'), + playlist_id_field='playlist_id') + + except Exception as e: + current_app.logger.error(f"Deezer search error: {e}") + flash("Error searching Deezer. Please try again.", "danger") + return redirect(url_for('deezer.deezer_search')) + +@deezer_bp.route('/import-deezer-track', methods=['GET']) +def import_deezer_track(): + """Display form to import a single track from Deezer""" + return render_template('service_import.html', + service_name='Deezer', + item_type='Track', + url_example_prefix='https://www.deezer.com/track/', + url_example_id='12345678', + id_field='track_id', + form_action=url_for('deezer.import_deezer_track_result'), + back_url=url_for('deezer.deezer_search')) + +@deezer_bp.route('/import-deezer-track-result', methods=['POST']) +def import_deezer_track_result(): + """Process the import of a single track from Deezer using the unified ImportHelper""" + track_id = request.form['track_id'] + + # Use the unified ImportHelper to handle track import + result = ImportHelper.import_item('deezer', 'track', track_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} song from Deezer!', 'success') + elif result['skipped_count'] > 0: + flash('Song was already in the database.', 'info') + else: + flash(f'Error importing song: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + +# Legacy function kept for backward compatibility +def import_deezer_track_result_helper(track_id): + """Helper function to import a single track from Deezer""" + result = ImportHelper.import_item('deezer', 'track', track_id) + return result['imported_count'] > 0 + +@deezer_bp.route('/import-deezer-playlist', methods=['GET', 'POST']) +def import_deezer_playlist(): + """Display form to import all tracks from a Deezer playlist""" + if request.method == 'POST': + playlist_id = request.form.get('playlist_id') + if playlist_id: + # Use the unified ImportHelper to handle playlist import + result = ImportHelper.import_item('deezer', 'playlist', playlist_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from Deezer playlist!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + else: + flash("Playlist ID is required.", 'error') + + return render_template('service_import.html', + service_name='Deezer', + item_type='Playlist', + url_example_prefix='https://www.deezer.com/playlist/', + url_example_id='9876543', + id_field='playlist_id', + form_action=url_for('deezer.import_deezer_playlist_result'), + back_url=url_for('deezer.deezer_search')) + +@deezer_bp.route('/import-deezer-playlist-result', methods=['POST']) +def import_deezer_playlist_result(): + """Process the import of all tracks from a Deezer playlist using the unified ImportHelper""" + playlist_id = request.form['playlist_id'] + + # Use the unified ImportHelper to handle playlist import + result = ImportHelper.import_item('deezer', 'playlist', playlist_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from Deezer playlist!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + +@deezer_bp.route('/import-deezer-album', methods=['GET']) +def import_deezer_album(): + """Display form to import all tracks from a Deezer album""" + return render_template('service_import.html', + service_name='Deezer', + item_type='Album', + url_example_prefix='https://www.deezer.com/album/', + url_example_id='1234567', + id_field='album_id', + form_action=url_for('deezer.import_deezer_album_result'), + back_url=url_for('deezer.deezer_search')) + +@deezer_bp.route('/import-deezer-album-result', methods=['POST']) +def import_deezer_album_result(): + """Process the import of all tracks from a Deezer album using the unified ImportHelper""" + album_id = request.form['album_id'] + + # Use the unified ImportHelper to handle album import + result = ImportHelper.import_item('deezer', 'album', album_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from Deezer album!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing album: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + +@deezer_bp.route('/browse-deezer-playlists') +def browse_deezer_playlists(): + """Browse popular playlists on Deezer""" + deezer_client = current_app.config['deezer'] + + try: + playlists = deezer_client.get_popular_playlists() + return render_template('browse_deezer_playlists.html', playlists=playlists) + except Exception as e: + current_app.logger.error(f"Error browsing Deezer playlists: {e}") + flash("Error loading Deezer playlists. Please try again.", "danger") + return render_template('browse_deezer_playlists.html', playlists=[]) \ No newline at end of file diff --git a/musicround/routes/generate.py b/musicround/routes/generate.py new file mode 100644 index 0000000..6180d2d --- /dev/null +++ b/musicround/routes/generate.py @@ -0,0 +1,579 @@ +import random +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app +from flask_login import current_user, login_required +from musicround.models import Song, Round, Tag, db + +generate_bp = Blueprint('generate', __name__) + +# Constants +songs_per_round = 8 + +# Helper functions +def get_all_decades(): + """ + Return a list of 'decade' strings (e.g. '1970', '1980') + based on the first 3 digits of the year + '0'. + """ + all_decades = [] + for song in Song.query.all(): + if song.year: + decade = str(song.year)[:3] + '0' + if decade not in all_decades: + all_decades.append(decade) + return all_decades + +def get_all_genres(): + """ + Return a list of all genres in the Song table. + """ + all_genres = [] + for song in Song.query.all(): + if song.genre and song.genre not in all_genres: + all_genres.append(song.genre) + return all_genres + +def get_all_tags(): + """ + Return a list of all tag names in the Tag table. + """ + return [tag.name for tag in Tag.query.all()] + +def get_songs_by_tag(tag_name, limit=8): + """ + Return songs that have the specified tag. + """ + tag = Tag.query.filter_by(name=tag_name).first() + if tag: + return tag.songs[:limit] + return [] + +def get_least_used_genres(): + """ + Returns a list of genre(s) whose usage count is minimal among all genres. + Usage is measured by how many Rounds of type 'genre' reference that genre. + """ + all_genres_list = get_all_genres() + # Start every genre with usage=0 + genre_usage = {g: 0 for g in all_genres_list} + + # Count how many times each genre appears in Rounds of type 'genre' + used_genre_rounds = Round.query.filter_by(round_type='genre').all() + for rnd in used_genre_rounds: + # Ensure we only increment if it exists in genre_usage + if rnd.round_criteria_used in genre_usage: + genre_usage[rnd.round_criteria_used] += 1 + + # If we have no genres at all, return an empty list + if not genre_usage: + return [] + + # Find the minimal usage count + min_usage = min(genre_usage.values()) + + # Return all genres that match min_usage + return [g for g, usage in genre_usage.items() if usage == min_usage] + + +def get_least_used_decades(): + """ + Returns a list of decade(s) whose usage count is minimal among all decades. + Usage is measured by how many Rounds of type 'decade' reference that decade. + """ + all_decades_list = get_all_decades() + # Start each decade with usage=0 + decade_usage = {d: 0 for d in all_decades_list} + + # Count how many times each decade appears in Rounds of type 'decade' + used_decade_rounds = Round.query.filter_by(round_type='decade').all() + for rnd in used_decade_rounds: + if rnd.round_criteria_used in decade_usage: + decade_usage[rnd.round_criteria_used] += 1 + + # If we have no decades at all, return empty + if not decade_usage: + return [] + + # Minimal usage + min_usage = min(decade_usage.values()) + + # Return all decades that match min_usage + return [d for d, usage in decade_usage.items() if usage == min_usage] + +def get_least_used_songs(genre=None, decade=None): + """ + Returns songs that have never been used in a round. + Can filter by genre or decade. + """ + least_used_songs = [] + all_songs = Song.query.all() + + # gather round_criteria_used for rounds of type 'song' + used_song_ids = [] + for rnd in Round.query.all(): + if rnd.round_type == 'song': + used_song_ids.append(rnd.round_criteria_used) + + # If a song's spotify_id never appears in used_song_ids => "least used" + for song in all_songs: + if song.spotify_id not in used_song_ids: + least_used_songs.append(song) + + # Filter by genre or decade if passed + if genre: + least_used_songs = [s for s in least_used_songs if s.genre == genre] + if decade: + least_used_songs = [s for s in least_used_songs if s.year and str(s.year)[:3] + '0' == decade] + return least_used_songs + +def get_non_overused_songs(genre=None, decade=None): + """ + Returns a list of songs whose used_count is <= the average usage among all songs. + Optional filtering by genre or decade. + """ + all_songs = Song.query.all() + total_times_used = sum(song.used_count for song in all_songs) or 1 + average_times_used = total_times_used / len(all_songs) if len(all_songs) else 1 + + # pick songs that are used <= average usage + non_overused_songs = [s for s in all_songs if s.used_count <= average_times_used] + + if genre: + non_overused_songs = [s for s in non_overused_songs if s.genre == genre] + + if decade: + non_overused_songs = [s for s in non_overused_songs if s.year and str(s.year)[:3] + '0' == decade] + + return non_overused_songs + +def get_random_songs_from_genre(genre, x=5): + """ + Returns x random songs from the given genre, + filling from non-overused songs in that genre. + If not enough, fallback to any non-overused songs. + """ + non_overused = get_non_overused_songs(genre=genre) + while len(non_overused) < x: + more = get_non_overused_songs() + if not more: # in case the DB is empty or something else + break + non_overused.extend(more) + + return random.sample(non_overused, x) if len(non_overused) >= x else non_overused + +def get_random_songs_from_decade(decade, x=5): + """ + Returns x random songs from the given decade, + filling from non-overused songs in that decade. + If not enough, fallback to any non-overused songs. + """ + non_overused = get_non_overused_songs(decade=decade) + while len(non_overused) < x: + more = get_non_overused_songs() + if not more: + break + non_overused.extend(more) + + return random.sample(non_overused, x) if len(non_overused) >= x else non_overused + +def get_random_songs(x): + """ + Returns x random songs from the pool of non-overused songs, + ensuring some naive diversity constraints: + - no artist used more than once + - no decade used more than x/3 times + - number of unique artists must match number of chosen songs + """ + non_overused_songs = get_non_overused_songs() + if len(non_overused_songs) < x: + return non_overused_songs + + random_songs = random.sample(non_overused_songs, x) + + artist_count = {} + decade_count = {} + + for song in random_songs: + artist_count[song.artist] = artist_count.get(song.artist, 0) + 1 + if song.year: + dec = str(song.year)[:3] + '0' + decade_count[dec] = decade_count.get(dec, 0) + 1 + + while ( + max(artist_count.values()) > 1 + or (decade_count and max(decade_count.values()) > len(random_songs) / 3) + or len(artist_count) != len(random_songs) + ): + # 1. If any artist is used more than once, replace that song + if max(artist_count.values()) > 1: + repeated_artist = None + for artist, count in artist_count.items(): + if count > 1: + repeated_artist = artist + break + if repeated_artist: + # remove one of that artist from random_songs + to_remove = next(s for s in random_songs if s.artist == repeated_artist) + random_songs.remove(to_remove) + artist_count[repeated_artist] -= 1 + + # pick a new random non-overused + refill = [s for s in non_overused_songs if s not in random_songs] + if refill: + new_song = random.choice(refill) + random_songs.append(new_song) + artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1 + + # update decade_count + if to_remove.year: + dec_to_remove = str(to_remove.year)[:3] + '0' + decade_count[dec_to_remove] = decade_count.get(dec_to_remove, 0) - 1 + if new_song.year: + dec_new = str(new_song.year)[:3] + '0' + decade_count[dec_new] = decade_count.get(dec_new, 0) + 1 + else: + # If no new songs available, just return what we have + return random_songs + + elif decade_count and max(decade_count.values()) > len(random_songs) / 3: + # 2. If any decade is used more than x/3, remove a song from that decade + repeated_decade = None + for dec, count in decade_count.items(): + if count > len(random_songs) / 3: + repeated_decade = dec + break + if repeated_decade: + to_remove = next((s for s in random_songs if s.year and str(s.year)[:3] + '0' == repeated_decade), None) + if to_remove: + random_songs.remove(to_remove) + decade_count[repeated_decade] -= 1 + + # pick a new random + refill = [s for s in non_overused_songs if s not in random_songs] + if refill: + new_song = random.choice(refill) + random_songs.append(new_song) + if new_song.year: + dec_new = str(new_song.year)[:3] + '0' + decade_count[dec_new] = decade_count.get(dec_new, 0) + 1 + + # update artist_count + artist_count[to_remove.artist] -= 1 + artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1 + else: + # If no new songs available, just return what we have + return random_songs + + elif len(artist_count) != len(random_songs): + # 3. if there's mismatch in how many unique artists vs. songs, fix that + # i.e. if we have a repeated artist but haven't caught it above + refill = [s for s in non_overused_songs if s not in random_songs] + repeated_song = None + # find a repeated artist + for s in random_songs: + if artist_count[s.artist] > 1: + repeated_song = s + break + if repeated_song is None or not refill: + break # fallback + + random_songs.remove(repeated_song) + artist_count[repeated_song.artist] -= 1 + + new_song = random.choice(refill) + random_songs.append(new_song) + artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1 + + if repeated_song.year: + dec_removed = str(repeated_song.year)[:3] + '0' + decade_count[dec_removed] = decade_count.get(dec_removed, 0) - 1 + if new_song.year: + dec_new = str(new_song.year)[:3] + '0' + decade_count[dec_new] = decade_count.get(dec_new, 0) + 1 + + return random_songs + +def get_random_songs_from_least_used_decade(x): + """ + Returns up to x songs from *one* of the least used decade(s), chosen at random. + Returns (songs, chosen_decade). + """ + candidates = get_least_used_decades() + if not candidates: + return [], None + + chosen_decade = random.choice(candidates) + random_songs = get_random_songs_from_decade(chosen_decade, x=x) + + return random_songs, chosen_decade + + +def get_random_songs_from_least_used_genre(x): + """ + Returns up to x songs from *one* of the least used genre(s), chosen at random. + Returns (songs, chosen_genre). + """ + candidates = get_least_used_genres() + if not candidates: + return [], None + + chosen_genre = random.choice(candidates) + random_songs = get_random_songs_from_genre(chosen_genre, x=x) + + return random_songs, chosen_genre + +def get_songs_from_deezer_playlist(playlist_id): + """ + Fetch songs from a Deezer playlist, properly import them with metadata, and return them + """ + try: + deezer_client = current_app.config['deezer'] + songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10) + + playlist = deezer_client.get_playlist(playlist_id) + if not playlist: + return [] + + # Use the ImportHelper static methods directly without creating an instance + from musicround.helpers.import_helper import ImportHelper + + songs = [] + tracks = playlist.get('tracks', {}).get('data', []) + + for track in tracks: + deezer_id = str(track.get('id')) + if not deezer_id: + continue + + # Check if song already exists in our database + existing_song = Song.query.filter_by(deezer_id=deezer_id).first() + if existing_song: + songs.append(existing_song) + continue + + # Use the proper ImportHelper static method for importing + track_result = ImportHelper.import_deezer_track(deezer_client, deezer_id) + + # If the track was successfully imported, retrieve it from the database + if track_result.get('imported_count', 0) > 0: + imported_song = Song.query.filter_by(deezer_id=deezer_id).first() + if imported_song: + songs.append(imported_song) + + return songs[:songs_per_round] # Limit to songs_per_round + except Exception as e: + current_app.logger.error(f"Error fetching Deezer playlist: {e}") + import traceback + current_app.logger.error(traceback.format_exc()) + return [] + +def get_songs_from_spotify_playlist(playlist_id): + """ + Fetch songs from a Spotify playlist, properly import them with metadata, and return them + """ + try: + sp = current_app.config['sp'] + songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10) + + playlist = sp.playlist_tracks(playlist_id) + if not playlist: + return [] + + # Use the ImportHelper static methods directly without creating an instance + from musicround.helpers.import_helper import ImportHelper + + songs = [] + for item in playlist.get('items', []): + track = item.get('track') + if not track or not track.get('id'): + continue + + spotify_id = track.get('id') + + # Check if the song already exists in our database + existing_song = Song.query.filter_by(spotify_id=spotify_id).first() + if existing_song: + songs.append(existing_song) + continue + + # Use the proper ImportHelper static methods for importing + track_result = ImportHelper.import_spotify_track(sp, spotify_id) + + # If the track was successfully imported, retrieve it from the database + if track_result.get('imported_count', 0) > 0: + imported_song = Song.query.filter_by(spotify_id=spotify_id).first() + if imported_song: + songs.append(imported_song) + + return songs[:songs_per_round] # Limit to songs_per_round + except Exception as e: + current_app.logger.error(f"Error fetching Spotify playlist: {e}") + import traceback + current_app.logger.error(traceback.format_exc()) + return [] + +@generate_bp.route('/build-music-round', methods=['GET', 'POST']) +@login_required +def build_music_round(): + """Build a music round based on the selected criteria""" + if request.method == 'POST': + round_type = request.form['round_type'] + if round_type == 'Random': + round_criteria = 'Random' + songs = get_random_songs(songs_per_round) + return render_template('round.html', songs=songs, round_criteria=round_criteria) + + elif round_type == 'Decade': + round_criteria = 'Least Used Decade' + songs, decade_used = get_random_songs_from_least_used_decade(songs_per_round) + return render_template( + 'round.html', + songs=songs, + round_criteria=round_criteria, + decade=decade_used + ) + + elif round_type == 'Genre': + round_criteria = 'Least Used Genre' + songs, genre_used = get_random_songs_from_least_used_genre(songs_per_round) + return render_template( + 'round.html', + songs=songs, + round_criteria=round_criteria, + genre=genre_used + ) + + elif round_type == 'Tag': + tag_name = request.form.get('tag_name') + if tag_name: + round_criteria = f'Tag: {tag_name}' + songs = get_songs_by_tag(tag_name, songs_per_round) + return render_template( + 'round.html', + songs=songs, + round_criteria=round_criteria, + tag=tag_name + ) + + # Pass tag choices to the template for selection + tags = get_all_tags() + return render_template('build_music_round.html', tags=tags) + +@generate_bp.route('/import-playlist', methods=['GET', 'POST']) +@login_required +def import_playlist(): + """Import a playlist from Deezer or Spotify""" + if request.method == 'POST': + playlist_url = request.form.get('playlist_url', '') + platform = request.form.get('platform', '').lower() + round_name = request.form.get('round_name', '') + + if not playlist_url: + flash('Please enter a playlist URL or ID', 'error') + return redirect(url_for('generate.import_playlist')) + + # Extract playlist ID from URL or use as is + playlist_id = playlist_url + + if platform == 'deezer': + # Extract Deezer playlist ID from URL if needed + if 'deezer.com' in playlist_url: + try: + playlist_id = playlist_url.split('playlist/')[1].split('?')[0] + except (IndexError, ValueError): + flash('Invalid Deezer playlist URL', 'error') + return redirect(url_for('generate.import_playlist')) + + songs = get_songs_from_deezer_playlist(playlist_id) + if not songs: + flash('No songs found or error fetching playlist from Deezer', 'error') + return redirect(url_for('generate.import_playlist')) + + round_criteria = f'Deezer Playlist: {playlist_id}' + + elif platform == 'spotify': + # Extract Spotify playlist ID from URL if needed + if 'spotify.com' in playlist_url: + try: + playlist_id = playlist_url.split('playlist/')[1].split('?')[0] + except (IndexError, ValueError): + flash('Invalid Spotify playlist URL', 'error') + return redirect(url_for('generate.import_playlist')) + + songs = get_songs_from_spotify_playlist(playlist_id) + if not songs: + flash('No songs found or error fetching playlist from Spotify', 'error') + return redirect(url_for('generate.import_playlist')) + + round_criteria = f'Spotify Playlist: {playlist_id}' + + else: + flash('Please select a valid platform', 'error') + return redirect(url_for('generate.import_playlist')) + + return render_template('round.html', + songs=songs, + round_criteria=round_criteria, + round_name=round_name, + playlist_import=True) + + return render_template('import_playlist.html') + +@generate_bp.route('/save_round', methods=['POST']) +@login_required +def save_round(): + """ + Persists a new Round to the DB (with chosen songs). + Increments used_count on all chosen songs. + """ + # get round criteria and name from form + round_criteria = request.form.get('round_criteria') + round_name = request.form.get('round_name') + + # get optional genre and decade from form + genre = request.form.get('genre') + decade = request.form.get('decade') + tag = request.form.get('tag') + + # get list of song IDs from form + song_ids = request.form.getlist('song_id') + + # get list of song objects from database + songs = Song.query.filter(Song.id.in_(song_ids)).all() + + # create string representation of song IDs + song_ids_str = ','.join(song_id for song_id in song_ids) + + # determine round type + if genre: + round_type = 'Genre' + round_criteria_used = genre + elif decade: + round_type = 'Decade' + round_criteria_used = decade + elif tag: + round_type = 'Tag' + round_criteria_used = tag + else: + round_type = 'Random' + round_criteria_used = 'Random Selection' + + # create new Round object and add to database + new_round = Round( + name=round_name, + round_type=round_type, + round_criteria_used=round_criteria_used, + songs=song_ids_str, + created_at=datetime.utcnow() + ) + db.session.add(new_round) + + # update usage count for each song + for song in songs: + song.used_count += 1 + db.session.add(song) + + db.session.commit() + + # redirect back to the rounds page + return redirect(url_for('rounds.rounds_list')) \ No newline at end of file diff --git a/musicround/routes/import.py b/musicround/routes/import.py new file mode 100644 index 0000000..504f7ff --- /dev/null +++ b/musicround/routes/import.py @@ -0,0 +1,241 @@ +""" +Import routes for the Music Round application +""" +import json +import time +import random +from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify +from musicround.models import Song, db +from musicround.routes.import_songs import import_pl, import_track + +import_bp = Blueprint('import', __name__, url_prefix='/import') + +def fetch_all_user_playlists(sp, user_id, limit=50): + """ + Fetch all playlists from a specific Spotify user account with pagination + + Args: + sp: Spotify API client + user_id: Spotify user ID to fetch playlists from + limit: Number of playlists to fetch per request (max 50) + + Returns: + List of all playlists from the specified user + """ + all_playlists = [] + offset = 0 + total = None + + start_time = time.time() + current_app.logger.info(f"Started fetching playlists for user '{user_id}'") + + while total is None or offset < total: + try: + # Use Spotify API to get playlists with pagination + results = sp.user_playlists(user_id, limit=limit, offset=offset) + + # If first request, get the total + if total is None: + total = results['total'] + current_app.logger.info(f"User '{user_id}' has {total} playlists in total") + + # Add the current batch of playlists to our collection + playlists_batch = results.get('items', []) + all_playlists.extend(playlists_batch) + + # Update offset for next batch + offset += limit + + # Log progress + current_app.logger.info(f"Fetched {len(playlists_batch)} playlists for '{user_id}', progress: {len(all_playlists)}/{total}") + + # Break if we've reached the end + if not results.get('next'): + break + + except Exception as e: + current_app.logger.error(f"Error fetching playlists for user '{user_id}' at offset {offset}: {e}") + break + + end_time = time.time() + duration = int((end_time - start_time) * 1000) + current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists for user '{user_id}' in {duration}ms") + + return all_playlists + +def filter_playlists_by_keywords(playlists, keywords, debug_info=None): + """ + Filter playlists by checking if any of the keywords are in the playlist name + + Args: + playlists: List of playlist objects + keywords: List of keywords to filter by + debug_info: Optional debug info dictionary to update with filtering stats + + Returns: + Filtered list of playlists + """ + filtered = [] + keywords_lower = [k.lower() for k in keywords] + + for playlist in playlists: + name = playlist.get('name', '').lower() + + # Check if any keyword is in the playlist name + if any(keyword in name for keyword in keywords_lower): + filtered.append(playlist) + + # Add to debug info if provided + if debug_info is not None and 'matched_keywords' in debug_info: + matched = [k for k in keywords_lower if k in name] + for keyword in matched: + if keyword not in debug_info['matched_keywords']: + debug_info['matched_keywords'][keyword] = 0 + debug_info['matched_keywords'][keyword] += 1 + + return filtered + +@import_bp.route('/official-playlists', methods=['GET', 'POST']) +def import_official_playlists(): + """Display and import official Spotify playlists from multiple regional accounts""" + if 'access_token' not in session: + return redirect(url_for('auth.login')) + + sp = current_app.config['sp'] + + # Handle POST request for importing a playlist + if request.method == 'POST': + playlist_id = request.form['playlist_id'] + import_pl(playlist_id) + flash('Spotify playlist imported successfully!', 'success') + return redirect(url_for('core.view_songs')) + + # Get filter keywords from the query string (default to empty list) + filter_keywords = request.args.get('filter', '').split(',') + filter_keywords = [k.strip() for k in filter_keywords if k.strip()] + + # List of official Spotify user accounts to fetch playlists from + spotify_accounts = [ + 'spotify', + 'spotifycharts', + 'spotifymaps', + 'spotifyuk', + 'spotifyusa', + 'spotify_germany' + ] + + # Get selected account from query string or default to all + selected_account = request.args.get('account', 'all') + + # Get debug mode parameter + debug_mode = request.args.get('debug', 'false').lower() == 'true' + + # Prepare debug info + debug_info = { + 'accounts': {}, + 'total_fetched': 0, + 'total_filtered': 0, + 'filtered_out': 0, + 'matched_keywords': {}, + 'query_time_ms': 0, + 'duplicates_removed': 0 + } + + # Initialize playlists list + all_playlists = [] + + try: + start_time = time.time() + + # Process each Spotify account or just the selected one + accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts + + for account in accounts_to_process: + if account not in spotify_accounts and account != 'all': + continue + + account_debug = { + 'total': 0, + 'fetched': 0, + 'filtered': 0, + 'time_ms': 0 + } + + account_start = time.time() + + # Fetch all playlists for this account + account_playlists = fetch_all_user_playlists(sp, account) + + account_end = time.time() + account_debug['time_ms'] = int((account_end - account_start) * 1000) + + account_debug['total'] = len(account_playlists) + account_debug['fetched'] = len(account_playlists) + debug_info['total_fetched'] += len(account_playlists) + + # Apply keyword filtering if keywords provided + if filter_keywords: + filtered_playlists = filter_playlists_by_keywords( + account_playlists, + filter_keywords, + debug_info + ) + account_debug['filtered'] = len(filtered_playlists) + debug_info['filtered_out'] += (len(account_playlists) - len(filtered_playlists)) + all_playlists.extend(filtered_playlists) + else: + # No filtering, use all playlists + all_playlists.extend(account_playlists) + account_debug['filtered'] = len(account_playlists) + + debug_info['accounts'][account] = account_debug + + # Remove duplicates based on playlist ID + unique_playlists = [] + seen_ids = set() + for playlist in all_playlists: + if playlist['id'] not in seen_ids: + seen_ids.add(playlist['id']) + unique_playlists.append(playlist) + else: + debug_info['duplicates_removed'] += 1 + + all_playlists = unique_playlists + debug_info['total_filtered'] = len(all_playlists) + + end_time = time.time() + debug_info['query_time_ms'] = int((end_time - start_time) * 1000) + + # Log summary + current_app.logger.info( + f"Search summary: Fetched {debug_info['total_fetched']} playlists, " + f"filtered to {debug_info['total_filtered']} playlists " + f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) " + f"in {debug_info['query_time_ms']}ms" + ) + + # Sort playlists by follower count or name if available + all_playlists.sort(key=lambda x: x.get('name', '').lower()) + + # Randomize order if no specific sorting + if not filter_keywords: + random.shuffle(all_playlists) + + except Exception as e: + current_app.logger.error(f"Error fetching official playlists: {e}") + flash('Error retrieving playlists from Spotify', 'danger') + all_playlists = [] + + # Handle empty result + if not all_playlists: + flash('No Spotify playlists found matching your criteria', 'warning') + + return render_template( + 'import_official_playlists.html', + playlists=all_playlists, + filter_keywords=filter_keywords, + selected_account=selected_account, + spotify_accounts=spotify_accounts, + debug_info=debug_info, + debug_mode=debug_mode + ) \ No newline at end of file diff --git a/musicround/routes/import_routes.py b/musicround/routes/import_routes.py new file mode 100644 index 0000000..b9937fa --- /dev/null +++ b/musicround/routes/import_routes.py @@ -0,0 +1,745 @@ +""" +Import routes for the Music Round application +""" +import json +import time +import random +from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify +from musicround.models import Song, db +from musicround.routes.import_songs import import_pl +from musicround.helpers.import_helper import ImportHelper + +import_bp = Blueprint('import', __name__, url_prefix='/import') + +def fetch_all_user_playlists(sp, user_id, limit=50): + """ + Fetch all playlists from a specific Spotify user account with pagination + + Args: + sp: Spotify API client + user_id: Spotify user ID to fetch playlists from + limit: Number of playlists to fetch per request (max 50) + + Returns: + List of all playlists from the specified user + """ + all_playlists = [] + offset = 0 + total = None + + start_time = time.time() + current_app.logger.info(f"Started fetching playlists for user '{user_id}'") + + # Hard limit to prevent infinite loops (should never be needed if API works correctly) + max_loops = 100 + loop_count = 0 + + while loop_count < max_loops: + loop_count += 1 + try: + # Use Spotify API to get playlists with pagination + current_app.logger.info(f"Fetching playlists for {user_id} with offset={offset}, limit={limit}, loop={loop_count}") + results = sp.user_playlists(user_id, limit=limit, offset=offset) + + # Log raw API response for debugging (only first few characters to avoid flooding logs) + response_sample = str(results)[:500] + '...' if len(str(results)) > 500 else str(results) + current_app.logger.debug(f"API response sample: {response_sample}") + + # If first request, get and validate the total + if total is None: + total = results.get('total', 0) + current_app.logger.info(f"User '{user_id}' has {total} playlists in total according to API") + if total == 0: + current_app.logger.warning(f"API reported 0 total playlists for {user_id} - possible API error") + + # Add the current batch of playlists to our collection + playlists_batch = results.get('items', []) + batch_count = len(playlists_batch) + all_playlists.extend(playlists_batch) + + current_app.logger.info(f"Batch for {user_id}: offset={offset}, received={batch_count} playlists") + + # If we didn't get any playlists in this batch, something is wrong + if batch_count == 0: + current_app.logger.warning(f"Received 0 playlists for {user_id} at offset {offset} - possible API error") + if 'items' not in results: + current_app.logger.warning(f"Missing 'items' key in API response for {user_id}") + break + + # Break if we received fewer items than requested (last page) + if batch_count < limit: + current_app.logger.info(f"Reached end of results for {user_id} (received {batch_count} < limit {limit})") + break + + # Update offset for next batch + offset += batch_count + + # Log progress + current_app.logger.info(f"Fetched {len(playlists_batch)} playlists for '{user_id}', progress: {len(all_playlists)}/{total}") + + # Break if we've reached or exceeded the total number of playlists + if offset >= total: + current_app.logger.info(f"Reached total {total} playlists for {user_id} at offset {offset}") + break + + # Break if we've exhausted all playlists (next URL is None) + if not results.get('next'): + current_app.logger.info(f"No more 'next' URL for {user_id} at offset {offset}") + # Check if we should have more results based on 'total' + if offset < total: + current_app.logger.warning( + f"API inconsistency: 'next' is None but we've only fetched {offset} out of {total} playlists" + ) + break + + except Exception as e: + current_app.logger.error(f"Error fetching playlists for user '{user_id}' at offset {offset}: {str(e)}") + # Try to get more specific error information + import traceback + current_app.logger.error(f"Traceback: {traceback.format_exc()}") + break + + # Check if we hit the max loops limit + if loop_count >= max_loops: + current_app.logger.warning(f"Reached maximum loop count ({max_loops}) for user {user_id}") + + end_time = time.time() + duration = int((end_time - start_time) * 1000) + current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists for user '{user_id}' in {duration}ms") + + return all_playlists + +def filter_playlists_by_keywords(playlists, keywords, debug_info=None): + """ + Filter playlists by checking if any of the keywords are in the playlist name + + Args: + playlists: List of playlist objects + keywords: List of keywords to filter by + debug_info: Optional debug info dictionary to update with filtering stats + + Returns: + Filtered list of playlists + """ + filtered = [] + keywords_lower = [k.lower() for k in keywords] + + for playlist in playlists: + name = playlist.get('name', '').lower() + + # Check if any keyword is in the playlist name + if any(keyword in name for keyword in keywords_lower): + filtered.append(playlist) + + # Add to debug info if provided + if debug_info is not None and 'matched_keywords' in debug_info: + matched = [k for k in keywords_lower if k in name] + for keyword in matched: + if keyword not in debug_info['matched_keywords']: + debug_info['matched_keywords'][keyword] = 0 + debug_info['matched_keywords'][keyword] += 1 + + return filtered + +@import_bp.route('/official-playlists', methods=['GET', 'POST']) +def import_official_playlists(): + """Display and import official Spotify playlists from multiple regional accounts""" + if 'access_token' not in session: + return redirect(url_for('users.login')) + + sp = current_app.config['sp'] + + # Handle POST request for importing a playlist + if request.method == 'POST': + playlist_id = request.form['playlist_id'] + # Use the new unified ImportHelper + result = ImportHelper.import_item('spotify', 'playlist', playlist_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + + # Get filter keywords from the query string (default to empty list) + filter_keywords = request.args.get('filter', '').split(',') + filter_keywords = [k.strip() for k in filter_keywords if k.strip()] + + # List of official Spotify user accounts to fetch playlists from + spotify_accounts = [ + 'spotify', + 'spotifycharts', + 'spotifymaps', + 'spotifyuk', + 'spotifyusa', + 'spotify_germany' + ] + + # Get selected account from query string or default to all + selected_account = request.args.get('account', 'all') + + # Get debug mode parameter + debug_mode = request.args.get('debug', 'false').lower() == 'true' + + # Prepare debug info + debug_info = { + 'accounts': {}, + 'total_fetched': 0, + 'total_filtered': 0, + 'filtered_out': 0, + 'matched_keywords': {}, + 'query_time_ms': 0, + 'duplicates_removed': 0 + } + + # Initialize playlists list + all_playlists = [] + + try: + start_time = time.time() + + # Process each Spotify account or just the selected one + accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts + + for account in accounts_to_process: + if account not in spotify_accounts and account != 'all': + continue + + account_debug = { + 'total': 0, + 'fetched': 0, + 'filtered': 0, + 'time_ms': 0 + } + + account_start = time.time() + + # Fetch all playlists for this account + account_playlists = fetch_all_user_playlists(sp, account) + + account_end = time.time() + account_debug['time_ms'] = int((account_end - account_start) * 1000) + + account_debug['total'] = len(account_playlists) + account_debug['fetched'] = len(account_playlists) + debug_info['total_fetched'] += len(account_playlists) + + # Apply keyword filtering if keywords provided + if filter_keywords: + filtered_playlists = filter_playlists_by_keywords( + account_playlists, + filter_keywords, + debug_info + ) + account_debug['filtered'] = len(filtered_playlists) + debug_info['filtered_out'] += (len(account_playlists) - len(filtered_playlists)) + all_playlists.extend(filtered_playlists) + else: + # No filtering, use all playlists + all_playlists.extend(account_playlists) + account_debug['filtered'] = len(account_playlists) + + debug_info['accounts'][account] = account_debug + + # Remove duplicates based on playlist ID + unique_playlists = [] + seen_ids = set() + for playlist in all_playlists: + if playlist['id'] not in seen_ids: + seen_ids.add(playlist['id']) + unique_playlists.append(playlist) + else: + debug_info['duplicates_removed'] += 1 + + all_playlists = unique_playlists + debug_info['total_filtered'] = len(all_playlists) + + end_time = time.time() + debug_info['query_time_ms'] = int((end_time - start_time) * 1000) + + # Log summary + current_app.logger.info( + f"Search summary: Fetched {debug_info['total_fetched']} playlists, " + f"filtered to {debug_info['total_filtered']} playlists " + f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) " + f"in {debug_info['query_time_ms']}ms" + ) + + # Sort playlists by follower count or name if available + all_playlists.sort(key=lambda x: x.get('name', '').lower()) + + # Randomize order if no specific sorting + if not filter_keywords: + random.shuffle(all_playlists) + + except Exception as e: + current_app.logger.error(f"Error fetching official playlists: {e}") + flash('Error retrieving playlists from Spotify', 'danger') + all_playlists = [] + + # Handle empty result + if not all_playlists: + flash('No Spotify playlists found matching your criteria', 'warning') + + # Get the bearer token from the session to display in the form + session_bearer_token = session.get('direct_bearer_token', '') + spotify_username = session.get('direct_spotify_username') + + return render_template( + 'import_official_playlists.html', + playlists=all_playlists, + filter_keywords=filter_keywords, + selected_account=selected_account, + spotify_accounts=spotify_accounts, + debug_info=debug_info, + debug_mode=debug_mode, + session_bearer_token=session_bearer_token, + spotify_username=spotify_username, + direct_mode=False + ) + +@import_bp.route('/direct-official-playlists', methods=['GET', 'POST']) +def direct_official_playlists(): + """Display and import official Spotify playlists using the direct client with bearer token""" + # Check if user has provided a bearer token + bearer_token = session.get('direct_bearer_token') + if not bearer_token: + flash('Please provide a bearer token first', 'warning') + return redirect(url_for('import.direct_spotify_auth')) + + # Initialize direct Spotify client with bearer token + from musicround.helpers.spotify_direct import SpotifyDirectClient + direct_client = SpotifyDirectClient(bearer_token=bearer_token) + + # Handle POST request for importing a playlist + if request.method == 'POST': + playlist_id = request.form['playlist_id'] + # Use the new unified ImportHelper + result = ImportHelper.import_item('spotify', 'playlist', playlist_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from official Spotify playlist!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + + # Get filter keywords from the query string (default to empty list) + filter_keywords = request.args.get('filter', '').split(',') + filter_keywords = [k.strip() for k in filter_keywords if k.strip()] + + # List of official Spotify user accounts to fetch playlists from + spotify_accounts = [ + 'spotify', + 'spotifycharts', + 'spotifymaps', + 'spotifyuk', + 'spotifyusa', + 'spotify_germany' + ] + + # Get selected account from query string or default to all + selected_account = request.args.get('account', 'all') + + # Get debug mode parameter + debug_mode = request.args.get('debug', 'false').lower() == 'true' + + # Prepare debug info + debug_info = { + 'accounts': {}, + 'total_fetched': 0, + 'total_filtered': 0, + 'filtered_out': 0, + 'matched_keywords': {}, + 'query_time_ms': 0, + 'duplicates_removed': 0 + } + + # Initialize playlists list + all_playlists = [] + + try: + start_time = time.time() + + # Process each Spotify account or just the selected one + accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts + + for account in accounts_to_process: + if account not in spotify_accounts and account != 'all': + continue + + account_debug = { + 'total': 0, + 'fetched': 0, + 'filtered': 0, + 'time_ms': 0 + } + + account_start = time.time() + + # Fetch all playlists for this account using direct client + account_playlists = direct_client.fetch_all_user_playlists(account) + + account_end = time.time() + account_debug['time_ms'] = int((account_end - account_start) * 1000) + + account_debug['total'] = len(account_playlists) + account_debug['fetched'] = len(account_playlists) + debug_info['total_fetched'] += len(account_playlists) + + # Apply keyword filtering if keywords provided + if filter_keywords: + filtered_playlists = filter_playlists_by_keywords( + account_playlists, + filter_keywords, + debug_info + ) + account_debug['filtered'] = len(filtered_playlists) + debug_info['filtered_out'] += (len(account_playlists) - len(filtered_playlists)) + all_playlists.extend(filtered_playlists) + else: + # No filtering, use all playlists + all_playlists.extend(account_playlists) + account_debug['filtered'] = len(account_playlists) + + debug_info['accounts'][account] = account_debug + + # Remove duplicates based on playlist ID + unique_playlists = [] + seen_ids = set() + for playlist in all_playlists: + if playlist['id'] not in seen_ids: + seen_ids.add(playlist['id']) + unique_playlists.append(playlist) + else: + debug_info['duplicates_removed'] += 1 + + all_playlists = unique_playlists + debug_info['total_filtered'] = len(all_playlists) + + end_time = time.time() + debug_info['query_time_ms'] = int((end_time - start_time) * 1000) + + # Log summary + current_app.logger.info( + f"Direct search summary: Fetched {debug_info['total_fetched']} playlists, " + f"filtered to {debug_info['total_filtered']} playlists " + f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) " + f"in {debug_info['query_time_ms']}ms" + ) + + # Sort playlists by follower count or name if available + all_playlists.sort(key=lambda x: x.get('name', '').lower()) + + # Randomize order if no specific sorting + if not filter_keywords: + random.shuffle(all_playlists) + + except Exception as e: + current_app.logger.error(f"Error fetching official playlists with direct client: {e}") + import traceback + current_app.logger.error(traceback.format_exc()) + flash(f'Error retrieving playlists from Spotify: {str(e)}', 'danger') + all_playlists = [] + + # Handle empty result + if not all_playlists: + flash('No Spotify playlists found matching your criteria', 'warning') + + return render_template( + 'import_official_playlists.html', + playlists=all_playlists, + filter_keywords=filter_keywords, + selected_account=selected_account, + spotify_accounts=spotify_accounts, + debug_info=debug_info, + debug_mode=debug_mode, + direct_mode=True, + spotify_username=session.get('direct_spotify_username') + ) + +@import_bp.route('/test-spotify-client', methods=['GET']) +def test_spotify_client(): + """Test route to compare different Spotify client implementations""" + if 'access_token' not in session: + return redirect(url_for('users.login')) + + # Get Spotify account to check from query parameters + account = request.args.get('account', 'spotify') + + # Results container + results = { + 'spotipy': { + 'playlists': [], + 'count': 0, + 'total': 0, + 'time_ms': 0, + 'error': None + }, + 'direct': { + 'playlists': [], + 'count': 0, + 'total': 0, + 'time_ms': 0, + 'error': None + } + } + + # Test spotipy implementation + try: + sp = current_app.config['sp'] + start_time = time.time() + + current_app.logger.info(f"Testing spotipy implementation for account {account}") + spotipy_playlists = fetch_all_user_playlists(sp, account) + + end_time = time.time() + duration_ms = int((end_time - start_time) * 1000) + + results['spotipy']['playlists'] = spotipy_playlists + results['spotipy']['count'] = len(spotipy_playlists) + results['spotipy']['time_ms'] = duration_ms + + # Get total from first API call if available + if spotipy_playlists: + first_result = sp.user_playlists(account, limit=1) + results['spotipy']['total'] = first_result.get('total', 'unknown') + + except Exception as e: + import traceback + current_app.logger.error(f"Error testing spotipy: {e}") + current_app.logger.error(traceback.format_exc()) + results['spotipy']['error'] = str(e) + + # Test direct implementation + try: + from musicround.helpers.spotify_direct import SpotifyDirectClient + + # Get bearer token from session if available + bearer_token = session.get('direct_bearer_token') + if not bearer_token: + current_app.logger.warning("No bearer token in session for direct client") + results['direct']['error'] = "No bearer token available. Please set a token in Direct Auth page first." + else: + direct_client = SpotifyDirectClient(bearer_token=bearer_token) + + start_time = time.time() + current_app.logger.info(f"Testing direct implementation for account {account}") + direct_playlists = direct_client.fetch_all_user_playlists(account) + + end_time = time.time() + duration_ms = int((end_time - start_time) * 1000) + + results['direct']['playlists'] = direct_playlists + results['direct']['count'] = len(direct_playlists) + results['direct']['time_ms'] = duration_ms + + # Get total if available from response + if direct_playlists and len(direct_playlists) > 0: + first_result = direct_client.user_playlists(account, limit=1) + results['direct']['total'] = first_result.get('total', 'unknown') + + except Exception as e: + import traceback + current_app.logger.error(f"Error testing direct client: {e}") + current_app.logger.error(traceback.format_exc()) + results['direct']['error'] = str(e) + + # Compare playlists between implementations + comparison = { + 'only_in_spotipy': [], + 'only_in_direct': [], + 'in_both': [] + } + + if results['spotipy']['playlists'] and results['direct']['playlists']: + spotipy_ids = {pl['id'] for pl in results['spotipy']['playlists']} + direct_ids = {pl['id'] for pl in results['direct']['playlists']} + + comparison['only_in_spotipy'] = list(spotipy_ids - direct_ids) + comparison['only_in_direct'] = list(direct_ids - spotipy_ids) + comparison['in_both'] = list(spotipy_ids.intersection(direct_ids)) + + # Add direct auth link to template data + direct_auth_url = url_for('import.direct_spotify_auth') + + # Render the comparison template + return render_template( + 'spotify_client_test.html', + account=account, + results=results, + comparison=comparison, + direct_auth_url=direct_auth_url, + has_bearer_token=bool(session.get('direct_bearer_token')) + ) + +@import_bp.route('/raw-playlists', methods=['GET']) +def get_raw_playlists(): + """ + Get raw playlists from Spotify without any pagination logic. + This helps diagnose issues with the playlist retrieval. + """ + if 'access_token' not in session: + return redirect(url_for('users.login')) + + # Get Spotify account to check + account = request.args.get('account', 'spotify') + # Get limit parameter (max 50) + limit = min(int(request.args.get('limit', '50')), 50) + # Get offset parameter + offset = int(request.args.get('offset', '0')) + + results = { + 'spotipy': { + 'raw_response': None, + 'error': None + }, + 'direct': { + 'raw_response': None, + 'error': None + } + } + + # Test spotipy raw response + try: + sp = current_app.config['sp'] + current_app.logger.info(f"Getting raw playlists with spotipy for {account}, limit={limit}, offset={offset}") + raw_result = sp.user_playlists(account, limit=limit, offset=offset) + results['spotipy']['raw_response'] = raw_result + except Exception as e: + import traceback + current_app.logger.error(f"Error getting raw spotipy playlists: {e}") + current_app.logger.error(traceback.format_exc()) + results['spotipy']['error'] = str(e) + + # Test direct API raw response + try: + from musicround.helpers.spotify_direct import SpotifyDirectClient + + # Get bearer token from session if available + bearer_token = session.get('direct_bearer_token') + if not bearer_token: + current_app.logger.warning("No bearer token in session for direct client") + results['direct']['error'] = "No bearer token available. Please set a token in Direct Auth page first." + else: + direct_client = SpotifyDirectClient(bearer_token=bearer_token) + current_app.logger.info(f"Getting raw playlists with direct API for {account}, limit={limit}, offset={offset}") + raw_result = direct_client.user_playlists(account, limit=limit, offset=offset) + results['direct']['raw_response'] = raw_result + except Exception as e: + import traceback + current_app.logger.error(f"Error getting raw direct playlists: {e}") + current_app.logger.error(traceback.format_exc()) + results['direct']['error'] = str(e) + + # Add direct auth link to template data + direct_auth_url = url_for('import.direct_spotify_auth') + + return render_template( + 'raw_playlists.html', + account=account, + limit=limit, + offset=offset, + results=results, + direct_auth_url=direct_auth_url, + has_bearer_token=bool(session.get('direct_bearer_token')) + ) + +@import_bp.route('/direct-auth', methods=['GET', 'POST']) +def direct_spotify_auth(): + """ + Allow users to manually enter a Spotify bearer token for direct API access. + This bypasses the OAuth flow and is useful when API limitations are in place. + """ + error = None + success = None + + if request.method == 'POST': + bearer_token = request.form.get('bearer_token') + if bearer_token: + try: + # Store the token in session + session['direct_bearer_token'] = bearer_token + + # Test the token with a simple request + from musicround.helpers.spotify_direct import SpotifyDirectClient + client = SpotifyDirectClient(bearer_token=bearer_token) + + # Try to get current user info as a test + result = client._make_api_request("me") + + if result and 'id' in result: + session['direct_spotify_user'] = result['id'] + session['direct_spotify_username'] = result.get('display_name', result['id']) + success = f"Successfully authenticated as {session['direct_spotify_username']}" + else: + error = "Token validation failed. Please check the token and try again." + except Exception as e: + current_app.logger.error(f"Error validating bearer token: {e}") + error = f"Error: {str(e)}" + else: + error = "No bearer token provided" + + # Get stored user info if available + spotify_user = session.get('direct_spotify_user') + spotify_username = session.get('direct_spotify_username') + + return render_template( + 'spotify_direct_auth.html', + error=error, + success=success, + spotify_user=spotify_user, + spotify_username=spotify_username + ) + +@import_bp.route('/update-direct-token', methods=['POST']) +def update_direct_token(): + """Update the direct bearer token and redirect back to the referring page""" + # Get return URL from form or default to playlist page + return_url = request.form.get('return_url') or url_for('import.direct_official_playlists') + + # Check if clearing token was requested + if request.form.get('clear_token'): + session.pop('direct_bearer_token', None) + session.pop('direct_spotify_user', None) + session.pop('direct_spotify_username', None) + flash('Bearer token cleared successfully', 'success') + return redirect(return_url) + + # Get bearer token from form + bearer_token = request.form.get('bearer_token') + if not bearer_token: + flash('No bearer token provided', 'warning') + return redirect(return_url) + + try: + # Store the token in session + session['direct_bearer_token'] = bearer_token + + # Test the token with a simple request + from musicround.helpers.spotify_direct import SpotifyDirectClient + client = SpotifyDirectClient(bearer_token=bearer_token) + + # Try to get current user info as a test + result = client._make_api_request("me") + + if result and 'id' in result: + session['direct_spotify_user'] = result['id'] + session['direct_spotify_username'] = result.get('display_name', result['id']) + flash(f'Successfully authenticated as {session["direct_spotify_username"]}', 'success') + else: + flash('Token validation failed. Please check the token and try again.', 'error') + except Exception as e: + current_app.logger.error(f"Error validating bearer token: {e}") + flash(f'Error validating token: {str(e)}', 'error') + + return redirect(return_url) \ No newline at end of file diff --git a/musicround/routes/import_songs.py b/musicround/routes/import_songs.py new file mode 100644 index 0000000..d774113 --- /dev/null +++ b/musicround/routes/import_songs.py @@ -0,0 +1,111 @@ +import random +import os +import requests +import json +from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, flash +from musicround.models import Song, db +from musicround.helpers.metadata import get_song_metadata_by_isrc +from musicround.helpers.import_helper import ImportHelper + +import_songs_bp = Blueprint('import_songs', __name__, url_prefix='/import') + +# Legacy function retained for backward compatibility +def import_track(track_id): + """Legacy helper function that now uses the new ImportHelper""" + result = ImportHelper.import_item('spotify', 'track', track_id) + return result['imported_count'] > 0 + +# Legacy function retained for backward compatibility +def import_pl(playlist_id): + """Legacy helper function that now uses the new ImportHelper""" + ImportHelper.import_item('spotify', 'playlist', playlist_id) + +# Legacy function retained for backward compatibility +def import_al(album_id): + """Legacy helper function that now uses the new ImportHelper""" + ImportHelper.import_item('spotify', 'album', album_id) + +@import_songs_bp.route('/song', methods=['GET', 'POST']) +def import_song(): + if 'access_token' not in session: + return redirect(url_for('users.login')) + + if request.method == 'POST': + track_id = request.form['song_id'] + result = ImportHelper.import_item('spotify', 'track', track_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} song!', 'success') + elif result['skipped_count'] > 0: + flash('Song was already in the database.', 'info') + else: + flash(f'Error importing song: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + + return render_template('service_import.html', + service_name='Spotify', + item_type='Track', + url_example_prefix='https://open.spotify.com/track/', + url_example_id='6rqhFgbbKwnb9MLmUQDhG6', + id_field='song_id', + form_action=url_for('import_songs.import_song'), + back_url=url_for('core.search')) + +@import_songs_bp.route('/playlist', methods=['GET', 'POST']) +def import_playlist(): + if 'access_token' not in session: + return redirect(url_for('users.login')) + + if request.method == 'POST': + playlist_id = request.form['playlist_id'] + result = ImportHelper.import_item('spotify', 'playlist', playlist_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from playlist!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + + return render_template('service_import.html', + service_name='Spotify', + item_type='Playlist', + url_example_prefix='https://open.spotify.com/playlist/', + url_example_id='37i9dQZF1DXcBWIGoYBM5M', + id_field='playlist_id', + form_action=url_for('import_songs.import_playlist'), + back_url=url_for('core.search')) + +@import_songs_bp.route('/album', methods=['GET', 'POST']) +def import_album(): + if 'access_token' not in session: + return redirect(url_for('users.login')) + + if request.method == 'POST': + album_id = request.form['album_id'] + result = ImportHelper.import_item('spotify', 'album', album_id) + + if result['imported_count'] > 0: + flash(f'Successfully imported {result["imported_count"]} songs from album!', 'success') + elif result['skipped_count'] > 0 and result['error_count'] == 0: + flash(f'All {result["skipped_count"]} songs were already in the database.', 'info') + elif result['error_count'] > 0: + flash(f'Encountered {result["error_count"]} errors during import.', 'warning') + else: + flash(f'Error importing album: {", ".join(result["errors"])}', 'danger') + + return redirect(url_for('core.view_songs')) + + return render_template('service_import.html', + service_name='Spotify', + item_type='Album', + url_example_prefix='https://open.spotify.com/album/', + url_example_id='4aawyAB9vmqN3uQ7FjRGTy', + id_field='album_id', + form_action=url_for('import_songs.import_album'), + back_url=url_for('core.search')) \ No newline at end of file diff --git a/musicround/routes/process.py b/musicround/routes/process.py new file mode 100644 index 0000000..76d5a0d --- /dev/null +++ b/musicround/routes/process.py @@ -0,0 +1,21 @@ +from flask import Blueprint, session, redirect, url_for, jsonify, request, current_app +import base64 + +process_bp = Blueprint('process', __name__, url_prefix='/process') + +@process_bp.route('/base64', methods=['POST']) +def base64_encode_data(): + """ + Return base64-encoded string from data provided in request body. + """ + if 'access_token' not in session: + return redirect(url_for('users.login')) # Assuming 'users.login' is the correct endpoint + + # Get binary data from request + data = request.get_data() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + return jsonify({ + 'encoded': base64.b64encode(data).decode('utf-8') + }) \ No newline at end of file diff --git a/musicround/routes/rounds.py b/musicround/routes/rounds.py new file mode 100644 index 0000000..c025dd8 --- /dev/null +++ b/musicround/routes/rounds.py @@ -0,0 +1,1112 @@ +import os +import smtplib +import shutil +import base64 +import tempfile +import requests +import logging +from datetime import datetime +from io import BytesIO +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +import re +import zipfile +import io +import json + +from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, send_file, jsonify, flash +from flask_login import current_user, login_required +from musicround.models import Round, Song, db +from pydub import AudioSegment +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas + +rounds_bp = Blueprint('rounds', __name__, url_prefix='/rounds') + +@rounds_bp.route('/') +@login_required +def rounds_list(): + """Display a list of all rounds""" + rounds = Round.query.all() + return render_template('rounds.html', rounds=rounds) + +@rounds_bp.route('/') +@login_required +def round_detail(round_id): + """Display details of a specific round""" + rnd = Round.query.get(round_id) + sp = current_app.config['sp'] + + if rnd: + song_ids = rnd.songs.split(',') + songs = Song.query.filter(Song.id.in_(song_ids)).all() + + # Ensure songs are in the same order as in the round's song list + song_id_to_obj = {str(song.id): song for song in songs} + ordered_songs = [song_id_to_obj.get(song_id) for song_id in song_ids if song_id in song_id_to_obj] + + email_error = session.pop('email_error', None) # Retrieve and remove the error message from the session + + # For sp.current_user(), we need to ensure we have a valid token if needed for this view + user_info = None + try: + if 'access_token' in session: # Only try to get user info if we have a token + user_info = sp.current_user() + except: + # If we can't get user info, continue without it + current_app.logger.warning("Could not get Spotify user info") + + return render_template('round_detail.html', round=rnd, songs=ordered_songs, user_info=user_info, email_error=email_error) + else: + return 'Round not found' + +@rounds_bp.route('//update-name', methods=['POST']) +@login_required +def update_round_name(round_id): + """Update the name of a round""" + rnd = Round.query.get_or_404(round_id) + round_name = request.form.get('round_name', '').strip() + + # Update the round name + rnd.name = round_name if round_name else None + db.session.commit() + + flash('Round name updated successfully', 'success') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + +@rounds_bp.route('//update-songs', methods=['POST']) +@login_required +def update_round_songs(round_id): + """Update the songs in a round (order, additions, removals)""" + rnd = Round.query.get_or_404(round_id) + song_order = request.form.get('song_order', '') + + if song_order: + # Only reset the flags if the song order has actually changed + if rnd.songs != song_order: + rnd.songs = song_order + # Reset the MP3 and PDF generated flags when the song order changes + rnd.reset_generated_status() + db.session.commit() + flash('Round songs updated successfully', 'success') + else: + flash('No changes to save', 'info') + else: + flash('No song order provided', 'error') + + return redirect(url_for('rounds.round_detail', round_id=round_id)) + +@rounds_bp.route('/round//mp3', methods=['POST']) +@login_required +def round_mp3(round_id): + """Generates an MP3 file for a given round with intro, outro, and number announcements.""" + from musicround.helpers.utils import get_mp3_path + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # For AJAX requests, keep the response consistent + if not current_user.is_authenticated: + return jsonify({'success': False, 'error': 'Authentication required'}), 401 + + round = Round.query.get_or_404(round_id) + song_ids = [int(song_id) for song_id in round.songs.split(',')] + + # Create a dict mapping song_id to Song object for all songs in the round + songs_dict = {song.id: song for song in Song.query.filter(Song.id.in_(song_ids)).all()} + + # Preserve the exact ordering from the round.songs field + songs = [songs_dict.get(song_id) for song_id in song_ids if songs_dict.get(song_id)] + + # Create a directory for rounds in /data if it doesn't exist + rounds_dir = '/data/rounds' + if not os.path.exists(rounds_dir): + os.makedirs(rounds_dir) + + # Define the path for the MP3 file + mp3_file_path = os.path.join(rounds_dir, f'round_{round_id}.mp3') + current_app.logger.info(f"Checking MP3 generation status for round {round_id}") + + # Check if the MP3 has already been generated and if the file exists + # We'll generate a new file if either the flag is False or the file doesn't exist + if round.mp3_generated and os.path.exists(mp3_file_path): + current_app.logger.info(f"MP3 file already exists and is up to date at: {mp3_file_path}") + download_url = url_for('rounds.download_mp3', round_id=round_id) + + # Check if this is an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': True, + 'message': 'MP3 file already exists', + 'download_url': download_url + }) + else: + # Traditional form submission, send file + return send_file(mp3_file_path, as_attachment=True) + + # Load intro, outro, and replay audio segments - using user's custom ones if available + try: + # Use get_mp3_path helper to get the appropriate path for each MP3 type + intro_path = get_mp3_path(current_user, 'intro') + outro_path = get_mp3_path(current_user, 'outro') + replay_path = get_mp3_path(current_user, 'replay') + + intro = AudioSegment.from_mp3(intro_path) + outro = AudioSegment.from_mp3(outro_path) + replay = AudioSegment.from_mp3(replay_path) + + current_app.logger.info(f"Using MP3 files - Intro: {intro_path}, Outro: {outro_path}, Replay: {replay_path}") + except Exception as e: + error_msg = f"Error loading intro/outro/replay audio: {e}" + current_app.logger.error(error_msg) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + # Create an empty audio segment + combined_audio = AudioSegment.empty() + combined_audio += intro + + # Create temporary directory for number announcements and song previews + with tempfile.TemporaryDirectory() as temp_dir: + # Store song audio segments for later replay + song_segments = [] + number_segments = [] + + # First pass - append each song's preview with number announcements + for i, song in enumerate(songs): + number_audio_path = os.path.join(current_app.root_path, 'static', 'audio', f'{i+1}.mp3') + try: + number_audio = AudioSegment.from_mp3(number_audio_path) + number_segments.append(number_audio) + except Exception as e: + error_msg = f"Error loading number audio {i+1}: {e}" + current_app.logger.error(error_msg) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + if song.deezer_id: + try: + # Fetch fresh preview URL from Deezer API + deezer_client = current_app.config['deezer'] + track = deezer_client.get_track(song.deezer_id) + current_app.logger.info(f"Deezer track info: {track}") # Log the track info + preview_url = track.get('preview') + if not preview_url: + current_app.logger.warning(f"No preview available for {song.title} (Deezer ID: {song.deezer_id})") + song_segments.append(None) + continue + + # Download the song preview to a temporary file + response = requests.get(preview_url, stream=True) + response.raise_for_status() # Raise an exception for bad status codes + + temp_song_path = os.path.join(temp_dir, f'song_{song.id}.mp3') + with open(temp_song_path, 'wb') as temp_song_file: + for chunk in response.iter_content(chunk_size=8192): + temp_song_file.write(chunk) + + song_audio = AudioSegment.from_mp3(temp_song_path) + song_segments.append(song_audio) + + # Add to the combined audio for first playthrough + combined_audio += number_audio + combined_audio += song_audio + except requests.exceptions.RequestException as e: + error_msg = f"Error downloading {song.title} (Deezer ID: {song.deezer_id}): {e}" + current_app.logger.error(error_msg) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + except Exception as e: + error_msg = f"Error processing {song.title} (Deezer ID: {song.deezer_id}): {e}" + current_app.logger.error(error_msg) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + else: + current_app.logger.warning(f"No Deezer ID available for {song.title}") + song_segments.append(None) + + # Add the replay announcement + combined_audio += replay + + # Second pass - replay all songs + for i, (song_audio, number_audio) in enumerate(zip(song_segments, number_segments)): + if song_audio is not None: + combined_audio += number_audio + combined_audio += song_audio + + combined_audio += outro + + # Export the combined audio to an MP3 file + try: + combined_audio.export(mp3_file_path, format="mp3") + current_app.logger.info(f"MP3 file successfully generated at: {mp3_file_path}") + + # Update the round object to indicate MP3 has been generated and update timestamp + round.mp3_generated = True + round.last_generated_at = datetime.utcnow() + db.session.commit() + + # Check if this is an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + download_url = url_for('rounds.download_mp3', round_id=round_id) + return jsonify({ + 'success': True, + 'message': 'MP3 file successfully generated', + 'download_url': download_url + }) + else: + # Traditional form submission, send file + flash('MP3 generated successfully', 'success') + return send_file(mp3_file_path, as_attachment=True) + + except Exception as e: + error_msg = f"Error generating MP3 file: {e}" + current_app.logger.error(error_msg) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + +@rounds_bp.route('/download/mp3/round_', methods=['GET']) +@login_required +def download_mp3(round_id): + """Download an MP3 file for a round""" + mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3') + + if not os.path.exists(mp3_file_path): + flash('MP3 file not found. Please generate the MP3 first.', 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + return send_file(mp3_file_path, as_attachment=True) + +@rounds_bp.route('/download/pdf/round_', methods=['GET']) +@login_required +def download_pdf(round_id): + """Download a PDF file for a round""" + pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf') + + if not os.path.exists(pdf_file_path): + flash('PDF file not found. Please generate the PDF first.', 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + return send_file(pdf_file_path, as_attachment=True) + +def generate_pdf(round_id): + """ + Creates a stylish PDF "round_{id}.pdf" that lists the songs in that round. + Returns raw PDF data as bytes, for sending or saving. + """ + rnd = Round.query.get(round_id) + if not rnd: + return 'Round not found' + + file_name = f'round_{round_id}.pdf' + dir_path = '/data/pdfs' # Use /data/pdfs for storing PDFs + if not os.path.exists(dir_path): + os.makedirs(dir_path) + file_path = os.path.join(dir_path, file_name) + + # If it exists and the flag indicates it's up-to-date, read from disk + if os.path.exists(file_path) and rnd.pdf_generated: + with open(file_path, 'rb') as file: + return file.read() + + # Get songs data in the correct order + song_ids = [int(sid) for sid in rnd.songs.split(',')] + + # Create a dict mapping song_id to Song object for all songs in the round + songs_dict = {song.id: song for song in Song.query.filter(Song.id.in_(song_ids)).all()} + + # Preserve the exact ordering from the round.songs field + songs = [songs_dict.get(song_id) for song_id in song_ids if songs_dict.get(song_id)] + + # Import reportlab components + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import inch, cm + + # Create a buffer + buffer = BytesIO() + + # Set up the PDF document + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=1.5*cm, + rightMargin=1.5*cm, + topMargin=1.5*cm, + bottomMargin=1.5*cm + ) + + # Styles + styles = getSampleStyleSheet() + + # Create custom styles + title_style = ParagraphStyle( + 'Title', + parent=styles['Heading1'], + fontSize=24, + textColor=colors.HexColor('#00ACC1'), # Teal color for titles + spaceAfter=24, + alignment=1 # Center alignment + ) + + subtitle_style = ParagraphStyle( + 'Subtitle', + parent=styles['Heading2'], + fontSize=16, + textColor=colors.HexColor('#333333'), + spaceBefore=12, + spaceAfter=12 + ) + + info_style = ParagraphStyle( + 'Info', + parent=styles['Normal'], + fontSize=12, + textColor=colors.HexColor('#666666'), + spaceBefore=6, + spaceAfter=12 + ) + + song_style = ParagraphStyle( + 'Song', + parent=styles['Normal'], + fontSize=14, + spaceBefore=4, + spaceAfter=4 + ) + + # Story (container for PDF elements) + story = [] + + # Add logo + logo_path = os.path.join(current_app.root_path, 'static', 'img', 'light', 'logotype.png') + if os.path.exists(logo_path): + img = Image(logo_path) + img.drawHeight = 1.2*cm + img._restrictSize(5*cm, 5*cm) + img.hAlign = 'CENTER' + story.append(img) + story.append(Spacer(1, 0.5*cm)) + + # Add title + title = "Music Quiz Round" + if rnd.name: + title = rnd.name + + story.append(Paragraph(title, title_style)) + + # Add subtitle with round type + round_type_text = f"Round Type: {rnd.round_type}" + if rnd.round_criteria_used: + round_type_text += f" - {rnd.round_criteria_used}" + + story.append(Paragraph(round_type_text, subtitle_style)) + + # Add date info + current_date = datetime.now().strftime("%B %d, %Y") + story.append(Paragraph(f"Generated on {current_date}", info_style)) + + # Add divider + story.append(Spacer(1, 0.75*cm)) + + # Create song table data + data = [["#", "Artist", "Title", "Year", "Genre"]] + + for i, song in enumerate(songs): + if song: + data.append([ + f"{i+1}", + f"{song.artist}", # Artist in separate column + f"{song.title}", # Title in separate column + f"{song.year or ''}", + f"{song.genre or ''}" + ]) + + # Create and style the table + table = Table(data, colWidths=[0.7*cm, 6*cm, 6*cm, 2*cm, 2.5*cm]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#00ACC1')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 12), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('TOPPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 1, colors.lightgrey), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (0, 0), (0, -1), 'CENTER'), # Center the song numbers + ('ALIGN', (3, 1), (3, -1), 'CENTER'), # Center the years + ('FONTNAME', (0, 1), (0, -1), 'Helvetica-Bold'), # Make numbers bold + ('FONTNAME', (1, 1), (1, -1), 'Helvetica-Bold'), # Make artist names bold + # Zebra striping for rows + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ])) + + # Add alternating row colors + for i in range(1, len(data)): + if i % 2 == 0: + table.setStyle(TableStyle([ + ('BACKGROUND', (0, i), (-1, i), colors.HexColor('#F9FAFB')) + ])) + + story.append(table) + + # Add spacer + story.append(Spacer(1, 0.75*cm)) + + # Add footer + footer_text = "Quizzical Beats - Music Quiz Generator" + story.append(Paragraph(footer_text, info_style)) + + # Build PDF + doc.build(story) + + # Get the value from the buffer + buffer.seek(0) + + # Save to file + with open(file_path, 'wb') as file: + file.write(buffer.getvalue()) + + return buffer.getvalue() + +@rounds_bp.route('//pdf', methods=['POST']) +@login_required +def round_pdf(round_id): + """Generate a PDF file for a round""" + rnd = db.session.get(Round, round_id) + if not rnd: + return jsonify({'success': False, 'error': 'Round not found'}) + + # Define path for the PDF file + pdfs_dir = '/data/pdfs' + if not os.path.exists(pdfs_dir): + os.makedirs(pdfs_dir) + pdf_file_path = os.path.join(pdfs_dir, f'round_{rnd.id}.pdf') + + # Check if the PDF has already been generated and if the file exists + # We'll generate a new file if either the flag is False or the file doesn't exist + if rnd.pdf_generated and os.path.exists(pdf_file_path): + current_app.logger.info(f"PDF file already exists and is up to date at: {pdf_file_path}") + download_url = url_for('rounds.download_pdf', round_id=round_id) + + # Check if this is an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': True, + 'message': 'PDF file already exists', + 'download_url': download_url + }) + else: + # Traditional form submission, send file + return send_file(pdf_file_path, as_attachment=True) + + try: + pdf_data = generate_pdf(round_id) + if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'): + return jsonify({'success': False, 'error': pdf_data}) + + with open(pdf_file_path, 'wb') as f: + f.write(pdf_data) + + # Update the round object to indicate PDF has been generated and update timestamp + rnd.pdf_generated = True + rnd.last_generated_at = datetime.utcnow() + db.session.commit() + + # Check if this is an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + download_url = url_for('rounds.download_pdf', round_id=round_id) + return jsonify({ + 'success': True, + 'message': 'PDF file successfully generated', + 'download_url': download_url + }) + else: + # Traditional form submission, send the file + return send_file(pdf_file_path, as_attachment=True) + + except Exception as e: + error_msg = f"Error generating PDF file: {e}" + current_app.logger.error(error_msg) + return jsonify({'success': False, 'error': error_msg}) + +@rounds_bp.route('//mail', methods=['POST']) +@login_required +def send_email(round_id): + """ + Generate PDF + MP3, attach them to an email, and send via Postmark. + """ + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # For AJAX requests, keep the response consistent + if not current_user.is_authenticated: + return jsonify({'error': 'Authentication required'}), 401 + + # Check if the round exists + rnd = Round.query.get_or_404(round_id) + + # Generate PDF + pdf_data = generate_pdf(round_id) + if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'): + error_msg = 'Round not found' + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.rounds_list')) + + # Define the path for the MP3 file + mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3') + + # Check if MP3 exists, if not generate it + if not os.path.exists(mp3_file_path): + # Call the round_mp3 function but don't return its result yet + # This will generate the MP3 file at mp3_file_path + response = round_mp3(round_id) + + if isinstance(response, str) and response.startswith('Error'): + error_msg = response + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': error_msg}) + else: + flash(error_msg, 'error') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + # Get mail configuration from environment variables + mail_host = current_app.config.get('MAIL_HOST') + mail_port = current_app.config.get('MAIL_PORT') + mail_username = current_app.config.get('MAIL_USERNAME') + mail_password = current_app.config.get('MAIL_PASSWORD') + mail_sender = current_app.config.get('MAIL_SENDER') + + # Use the current user's email address as the recipient + mail_recipient = current_user.email + + if not mail_recipient: + error_msg = "You don't have an email address in your profile. Please update your profile with an email address." + current_app.logger.error(error_msg) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': error_msg}) + else: + session['email_error'] = error_msg + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + # Check if all email configuration parameters are available + missing_config = [] + if not mail_host: + missing_config.append("MAIL_HOST") + if not mail_port: + missing_config.append("MAIL_PORT") + if not mail_username: + missing_config.append("MAIL_USERNAME") + if not mail_password: + missing_config.append("MAIL_PASSWORD") + if not mail_sender: + missing_config.append("MAIL_SENDER") + + if missing_config: + missing_params = ", ".join(missing_config) + error_msg = f"Email server configuration is incomplete. Missing parameters: {missing_params}. Please check your .env file." + current_app.logger.error(f"Email configuration error: {error_msg}") + current_app.logger.error(f"Current config values - MAIL_HOST: {'set' if mail_host else 'missing'}, " + f"MAIL_PORT: {'set' if mail_port else 'missing'}, " + f"MAIL_USERNAME: {'set' if mail_username else 'missing'}, " + f"MAIL_PASSWORD: {'set' if mail_password else 'missing'}, " + f"MAIL_SENDER: {'set' if mail_sender else 'missing'}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': error_msg}) + else: + session['email_error'] = error_msg # Store the error message in the session + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + msg = MIMEMultipart() + msg['From'] = mail_sender + msg['To'] = mail_recipient + + # Get the round name for the subject + round_title = f'Pub Quiz Round #{round_id}' + if rnd and rnd.name: + round_title = rnd.name + + msg['Subject'] = round_title + msg.attach(MIMEText('Attached please find the MP3 and PDF files for the quiz round.', 'plain')) + + # Attach PDF + pdf_attachment = MIMEBase('application', 'pdf') + pdf_attachment.set_payload(pdf_data) + encoders.encode_base64(pdf_attachment) + pdf_attachment.add_header('Content-Disposition', f'attachment; filename=round_{round_id}.pdf') + msg.attach(pdf_attachment) + + # Attach MP3 + with open(mp3_file_path, 'rb') as mp3_file: + mp3_data = mp3_file.read() + mp3_attachment = MIMEBase('audio', 'mpeg') + mp3_attachment.set_payload(mp3_data) + encoders.encode_base64(mp3_attachment) + mp3_attachment.add_header('Content-Disposition', f'attachment; filename=round_{round_id}.mp3') + msg.attach(mp3_attachment) + + try: + current_app.logger.info(f"Attempting to send email to {mail_recipient} via {mail_host}:{mail_port}") + with smtplib.SMTP(mail_host, mail_port) as server: + server.starttls() + current_app.logger.debug("STARTTLS established") + server.login(mail_username, mail_password) + current_app.logger.debug(f"Login successful for {mail_username}") + server.sendmail(mail_sender, mail_recipient, msg.as_string()) + current_app.logger.info(f"Email sent successfully from {mail_sender} to {mail_recipient}") + + success_msg = f'Email sent successfully to {mail_recipient}!' + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'message': success_msg}) + else: + flash(success_msg, 'success') + return redirect(url_for('rounds.round_detail', round_id=round_id)) + + except smtplib.SMTPException as e: + error_msg = str(e) + current_app.logger.error(f"SMTP Error: {error_msg}") + current_app.logger.error(f"Failed to send email from {mail_sender} to {mail_recipient} via {mail_host}:{mail_port}") + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': error_msg}) + else: + session['email_error'] = error_msg + return redirect(url_for('rounds.round_detail', round_id=round_id)) + +@rounds_bp.route('//delete', methods=['POST']) +@login_required +def delete_round(round_id): + """Delete a round and its associated files""" + rnd = Round.query.get_or_404(round_id) + + try: + # Delete associated MP3 file if it exists + mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3') + if os.path.exists(mp3_file_path): + os.remove(mp3_file_path) + + # Delete associated PDF file if it exists + pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf') + if os.path.exists(pdf_file_path): + os.remove(pdf_file_path) + + # Delete the round from the database + db.session.delete(rnd) + db.session.commit() + + flash('Round deleted successfully', 'success') + return jsonify({'success': True}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error deleting round: {e}") + flash(f"Error deleting round: {e}", 'error') + return jsonify({'success': False, 'error': str(e)}), 500 + +@rounds_bp.route('//export-to-dropbox', methods=['POST']) +@login_required +def export_to_dropbox(round_id): + """ + Export a round to the user's Dropbox account, including metadata and optionally MP3 files + """ + current_app.logger.info(f"Starting Dropbox export for round ID {round_id} by user {current_user.username}") + round_obj = Round.query.get_or_404(round_id) + + # Properly parse boolean parameters from form data + include_mp3s = request.form.get('include_mp3s', 'true').lower() == 'true' + include_pdf = request.form.get('include_pdf', 'true').lower() == 'true' + custom_folder = request.form.get('custom_folder', '') + + current_app.logger.debug(f"Export options - Include MP3: {include_mp3s}, Include PDF: {include_pdf}, Custom folder: '{custom_folder}'") + + # Default response format + response_data = { + 'success': False, + 'message': 'Unknown error occurred', + 'shared_links': { + 'mp3': None, + 'pdf': None, + 'text': None # For metadata JSON + } + } + + # Validate user has Dropbox connected + if not current_user.dropbox_token or not current_user.dropbox_refresh_token: + current_app.logger.error(f"User {current_user.username} attempted Dropbox export without connected account") + response_data['message'] = 'You need to connect your Dropbox account first' + flash('Please connect your Dropbox account in your profile settings first.', 'error') + return jsonify(response_data) + + current_app.logger.debug(f"Dropbox token exists: {bool(current_user.dropbox_token)}, token expiry: {current_user.dropbox_token_expiry}") + + # Refresh token if needed + from musicround.helpers.dropbox_helper import refresh_dropbox_token_if_needed + token_refresh = refresh_dropbox_token_if_needed(current_user) + current_app.logger.info(f"Token refresh result: {token_refresh}") + + if not token_refresh['success']: + current_app.logger.error(f"Failed to refresh Dropbox token: {token_refresh['message']}") + response_data['message'] = f"Error with Dropbox authentication: {token_refresh['message']}" + flash('There was an error with your Dropbox connection. Please reconnect in your profile.', 'error') + return jsonify(response_data) + + # Initialize the round export record + from musicround.models import RoundExport, db + round_export = RoundExport( + round_id=round_id, + user_id=current_user.id, + export_type='dropbox', + include_mp3s=include_mp3s, + status='in_progress', + destination=current_user.dropbox_export_path + ) + db.session.add(round_export) + db.session.commit() + current_app.logger.debug(f"Created export record with ID: {round_export.id}") + + try: + # Determine base export path + base_folder = current_user.dropbox_export_path or '/QuizzicalBeats' + if custom_folder: + base_folder = os.path.join(base_folder, custom_folder.strip('/')) + + current_app.logger.debug(f"Using base folder: {base_folder}") + + # Create a folder for this round + round_folder_name = f"Round_{round_id}" + if round_obj.name: + # Sanitize round name for folder name (remove invalid characters) + safe_name = re.sub(r'[<>:"/\\|?*]', '', round_obj.name) + round_folder_name = f"Round_{round_id}_{safe_name}" + + round_folder = os.path.join(base_folder, round_folder_name) + metadata_folder = os.path.join(round_folder, "Metadata") + + current_app.logger.debug(f"Round folder path: {round_folder}") + current_app.logger.debug(f"Metadata folder path: {metadata_folder}") + + # Access token for Dropbox API calls + access_token = current_user.dropbox_token + current_app.logger.debug(f"Access token (first 10 chars): {access_token[:10] if access_token else 'None'}") + + # Prepare song list - check if the helper method works + try: + songs = round_obj.song_list + current_app.logger.debug(f"Successfully retrieved {len(songs)} songs") + except Exception as song_err: + current_app.logger.error(f"Error getting song list: {str(song_err)}") + # Fallback method to get songs + song_ids = [int(sid) for sid in round_obj.songs.split(',')] + songs = Song.query.filter(Song.id.in_(song_ids)).all() + current_app.logger.debug(f"Fallback method retrieved {len(songs)} songs") + + # 1. Export song metadata as JSON + song_data = [] + for song in songs: + try: + song_dict = song.to_dict() + song_data.append(song_dict) + except Exception as e: + current_app.logger.error(f"Error converting song {song.id} to dict: {str(e)}") + # Manual conversion fallback + song_data.append({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'year': song.year, + 'deezer_id': song.deezer_id, + 'spotify_id': song.spotify_id, + }) + + metadata_json = json.dumps({ + 'round_id': round_obj.id, + 'round_name': round_obj.name, + 'round_type': round_obj.round_type, + 'round_criteria': round_obj.round_criteria_used, + 'created_at': round_obj.created_at.isoformat() if round_obj.created_at else None, + 'songs': song_data + }, indent=2) + + current_app.logger.debug(f"Created metadata JSON ({len(metadata_json)} bytes)") + + # Upload metadata JSON + from musicround.helpers.dropbox_helper import upload_to_dropbox, create_shared_link + + json_path = f"{metadata_folder}/round_{round_id}_metadata.json" + current_app.logger.info(f"Uploading JSON metadata to {json_path}") + + json_upload = upload_to_dropbox( + access_token, + json_path, + metadata_json, + mode='text' + ) + + current_app.logger.debug(f"JSON upload result: {json_upload}") + + if not json_upload['success']: + current_app.logger.error(f"Error uploading JSON metadata: {json_upload}") + raise Exception(f"Error uploading JSON metadata: {json_upload['message']}") + + # Create shared link for JSON + current_app.logger.info(f"Creating shared link for JSON at {json_path}") + json_link = create_shared_link(access_token, json_path) + current_app.logger.debug(f"JSON shared link result: {json_link}") + + if json_link['success']: + response_data['shared_links']['text'] = json_link['url'] + + # 2. Export PDF if requested + if include_pdf: + current_app.logger.info(f"PDF export requested for round {round_id}") + + # Generate PDF if not already generated + if not round_obj.pdf_generated: + current_app.logger.debug("PDF not generated yet, generating now") + pdf_data = generate_pdf(round_id) + if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'): + current_app.logger.error(f"Error generating PDF: {pdf_data}") + raise Exception(f"Error generating PDF: {pdf_data}") + else: + # PDF already exists, read it + pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf') + current_app.logger.debug(f"Reading existing PDF from {pdf_file_path}") + + if not os.path.exists(pdf_file_path): + current_app.logger.error(f"PDF file doesn't exist at {pdf_file_path} despite pdf_generated=True") + # Generate it anyway + pdf_data = generate_pdf(round_id) + else: + with open(pdf_file_path, 'rb') as f: + pdf_data = f.read() + current_app.logger.debug(f"Read {len(pdf_data)} bytes from PDF file") + + # Upload PDF + pdf_path = f"{round_folder}/round_{round_id}.pdf" + current_app.logger.info(f"Uploading PDF to {pdf_path}") + + pdf_upload = upload_to_dropbox( + access_token, + pdf_path, + pdf_data + ) + + current_app.logger.debug(f"PDF upload result: {pdf_upload}") + + if not pdf_upload['success']: + current_app.logger.error(f"Error uploading PDF: {pdf_upload}") + raise Exception(f"Error uploading PDF: {pdf_upload['message']}") + + # Create shared link for PDF + current_app.logger.info(f"Creating shared link for PDF at {pdf_path}") + pdf_link = create_shared_link(access_token, pdf_path) + current_app.logger.debug(f"PDF shared link result: {pdf_link}") + + if pdf_link['success']: + response_data['shared_links']['pdf'] = pdf_link['url'] + + # 3. Export MP3 if requested + if include_mp3s: + current_app.logger.info(f"MP3 export requested for round {round_id}") + + # Check if MP3 exists, generate if not + mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3') + current_app.logger.debug(f"Checking for MP3 at {mp3_file_path}") + current_app.logger.debug(f"MP3 generated flag: {round_obj.mp3_generated}") + + if not round_obj.mp3_generated or not os.path.exists(mp3_file_path): + current_app.logger.warning(f"MP3 needs to be generated first. Generated flag: {round_obj.mp3_generated}, File exists: {os.path.exists(mp3_file_path)}") + # Need to redirect to MP3 generation first + response_data['success'] = False + response_data['message'] = 'MP3 needs to be generated first' + response_data['redirect'] = url_for('rounds.round_mp3', round_id=round_id) + + # Update export record + round_export.status = 'pending_mp3' + round_export.error_message = 'MP3 needs to be generated first' + db.session.commit() + + return jsonify(response_data) + + # MP3 exists, upload it + current_app.logger.debug(f"Reading MP3 file from {mp3_file_path}") + with open(mp3_file_path, 'rb') as f: + mp3_data = f.read() + + current_app.logger.debug(f"Read {len(mp3_data)} bytes from MP3 file") + + mp3_path = f"{round_folder}/round_{round_id}.mp3" + current_app.logger.info(f"Uploading MP3 to {mp3_path}") + + mp3_upload = upload_to_dropbox( + access_token, + mp3_path, + mp3_data + ) + + current_app.logger.debug(f"MP3 upload result: {mp3_upload}") + + if not mp3_upload['success']: + current_app.logger.error(f"Error uploading MP3: {mp3_upload}") + raise Exception(f"Error uploading MP3: {mp3_upload['message']}") + + # Create shared link for MP3 + current_app.logger.info(f"Creating shared link for MP3 at {mp3_path}") + mp3_link = create_shared_link(access_token, mp3_path) + current_app.logger.debug(f"MP3 shared link result: {mp3_link}") + + if mp3_link['success']: + response_data['shared_links']['mp3'] = mp3_link['url'] + + # Update export record as success + round_export.status = 'success' + db.session.commit() + current_app.logger.info(f"Export to Dropbox completed successfully for round {round_id}") + + # Success response + response_data['success'] = True + response_data['message'] = 'Round exported to Dropbox successfully' + + return jsonify(response_data) + + except Exception as e: + current_app.logger.error(f"Error exporting round {round_id} to Dropbox: {str(e)}", exc_info=True) + + # Update export record with error + round_export.status = 'failed' + round_export.error_message = str(e) + db.session.commit() + + # Error response + response_data['success'] = False + response_data['message'] = f"Error exporting to Dropbox: {str(e)}" + + return jsonify(response_data) + +def generate_round_text(round_obj): + """Generate a text representation of a round""" + lines = [ + f"ROUND: {round_obj.title}", + f"Created: {round_obj.created_at.strftime('%Y-%m-%d')}", + f"Creator: {round_obj.user.username if round_obj.user else 'Unknown'}", + "", + f"Description: {round_obj.description or 'No description'}", + "", + "SONGS:", + "" + ] + + for idx, song in enumerate(round_obj.songs, 1): + lines.append(f"{idx}. {song.title} - {song.artist}") + if song.year: + lines.append(f" Year: {song.year}") + if song.album: + lines.append(f" Album: {song.album}") + lines.append("") + + return "\n".join(lines) + +def generate_round_pdf(round_obj): + """Generate a PDF representation of a round""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.lib import colors + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from io import BytesIO + + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4) + styles = getSampleStyleSheet() + + # Create custom styles + styles.add(ParagraphStyle( + name='Title', + parent=styles['Heading1'], + fontSize=16, + spaceAfter=12 + )) + + styles.add(ParagraphStyle( + name='SongTitle', + parent=styles['Normal'], + fontSize=12, + fontName='Helvetica-Bold' + )) + + # Build the document content + content = [] + + # Round title + content.append(Paragraph(f"Round: {round_obj.title}", styles['Title'])) + content.append(Spacer(1, 12)) + + # Round info + content.append(Paragraph(f"Created: {round_obj.created_at.strftime('%Y-%m-%d')}", styles['Normal'])) + content.append(Paragraph(f"Creator: {round_obj.user.username if round_obj.user else 'Unknown'}", styles['Normal'])) + content.append(Spacer(1, 12)) + + # Description + if round_obj.description: + content.append(Paragraph("Description:", styles['Heading3'])) + content.append(Paragraph(round_obj.description, styles['Normal'])) + content.append(Spacer(1, 12)) + + # Songs + content.append(Paragraph("Songs:", styles['Heading3'])) + content.append(Spacer(1, 6)) + + for idx, song in enumerate(round_obj.songs, 1): + content.append(Paragraph(f"{idx}. {song.title} - {song.artist}", styles['SongTitle'])) + + # Song details + details = [] + if song.year: + details.append(f"Year: {song.year}") + if song.album: + details.append(f"Album: {song.album}") + + if details: + content.append(Paragraph(", ".join(details), styles['Normal'])) + + content.append(Spacer(1, 6)) + + # Build and return the PDF + doc.build(content) + pdf_data = buffer.getvalue() + buffer.close() + return pdf_data + + except ImportError: + current_app.logger.warning("ReportLab not installed, skipping PDF export") + return None + except Exception as e: + current_app.logger.error(f"Error generating PDF: {str(e)}") + return None + +def safe_filename(filename): + """Convert a string to a safe filename""" + # Replace problematic characters + safe_name = re.sub(r'[^\w\s-]', '', filename).strip().replace(' ', '_') + return safe_name \ No newline at end of file diff --git a/musicround/routes/users.py b/musicround/routes/users.py new file mode 100644 index 0000000..3a48aa5 --- /dev/null +++ b/musicround/routes/users.py @@ -0,0 +1,1743 @@ +""" +User authentication and profile management routes +""" +import os +import time +import uuid +from datetime import datetime, timedelta +from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, session, jsonify +from flask_login import login_user, current_user, logout_user, login_required +from werkzeug.security import generate_password_hash, check_password_hash +from sqlalchemy.exc import IntegrityError +from spotipy.oauth2 import SpotifyOAuth +from spotipy.exceptions import SpotifyException + +from musicround.models import db, User, Role, SystemSetting +from musicround.helpers.utils import get_available_voices +from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_google_user_info, get_authentik_user_info + +users_bp = Blueprint('users', __name__, url_prefix='/users') + +def admin_required(f): + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin(): + flash('Admin access required.', 'danger') + return redirect(url_for('users.profile')) + return f(*args, **kwargs) + return decorated_function + +# OAuth login routes for Google +@users_bp.route('/login/google') +def google_login(): + """Initiate Google OAuth login flow""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Google login is disabled if client ID is not set + if not current_app.config.get('GOOGLE_CLIENT_ID'): + flash('Google login is not configured.', 'danger') + return redirect(url_for('users.login')) + + redirect_uri = url_for('users.google_callback', _external=True) + return oauth.google.authorize_redirect(redirect_uri) + +@users_bp.route('/login/google/callback') +def google_callback(): + """Handle Google OAuth callback""" + try: + token = oauth.google.authorize_access_token() + user_info = oauth.google.userinfo() + + # Debug the response + current_app.logger.debug(f"Google user info response: {user_info}") + + # Check for required fields and adapt to Google's response format + if 'sub' in user_info and 'id' not in user_info: + user_info['id'] = user_info['sub'] # Google uses 'sub' for the user ID + + # Find or create user + user = find_or_create_user(user_info, 'google') + if not user: + flash('Could not authenticate with Google. Please try again.', 'danger') + return redirect(url_for('users.login')) + + # Update tokens and log in the user + update_oauth_tokens(user, token, 'google') + login_user(user) + + # Set last_login as a datetime object for consistency + user.last_login = datetime.now() + db.session.commit() + + # Redirect to next page or home + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('core.index') + + flash('You have been logged in via Google!', 'success') + return redirect(next_page) + + except Exception as e: + current_app.logger.error(f"Error in Google callback: {str(e)}") + flash('An error occurred during Google authentication. Please try again.', 'danger') + return redirect(url_for('users.login')) + +# OAuth login routes for Authentik +@users_bp.route('/login/authentik') +def authentik_login(): + """Initiate Authentik OAuth login flow""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Authentik login is disabled if client ID is not set + if not current_app.config.get('AUTHENTIK_CLIENT_ID'): + flash('Authentik login is not configured.', 'danger') + return redirect(url_for('users.login')) + + redirect_uri = url_for('users.authentik_callback', _external=True) + return oauth.authentik.authorize_redirect(redirect_uri) + +@users_bp.route('/login/authentik/callback') +def authentik_callback(): + """Handle Authentik OAuth callback""" + try: + token = oauth.authentik.authorize_access_token() + user_info = token.get('userinfo') + + # Debug the response + current_app.logger.debug(f"Authentik user info response: {user_info}") + + if not user_info: + # If userinfo not in token, fetch it separately + user_info = get_authentik_user_info(token) + + # Check for required fields and adapt to Authentik's response format + if user_info and 'sub' in user_info and 'id' not in user_info: + user_info['id'] = user_info['sub'] # Authentik uses 'sub' for the user ID + + # Find or create user + user = find_or_create_user(user_info, 'authentik') + if not user: + flash('Could not authenticate with Authentik. Please try again.', 'danger') + return redirect(url_for('users.login')) + + # Update tokens and log in the user + update_oauth_tokens(user, token, 'authentik') + login_user(user) + + user.last_login = datetime.now() + db.session.commit() + + # Redirect to next page or home + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('core.index') + + flash('You have been logged in via Authentik!', 'success') + return redirect(next_page) + + except Exception as e: + current_app.logger.error(f"Error in Authentik callback: {str(e)}") + flash('An error occurred during Authentik authentication. Please try again.', 'danger') + return redirect(url_for('users.login')) + +# OAuth login routes for Dropbox +@users_bp.route('/dropbox/auth') +@login_required +def dropbox_auth(): + """Initiate Dropbox OAuth flow""" + from musicround.helpers.dropbox_helper import get_dropbox_auth_url + + # Generate the authorization URL + auth_url = get_dropbox_auth_url() + + if auth_url: + # Store state in session to prevent CSRF + return redirect(auth_url) + else: + flash('Error initiating Dropbox authorization', 'error') + return redirect(url_for('users.profile')) + +@users_bp.route('/dropbox/callback') +@login_required +def dropbox_callback(): + """Handle Dropbox OAuth callback""" + from musicround.helpers.dropbox_helper import exchange_code_for_token, get_dropbox_account_info + + error = request.args.get('error') + if error: + flash(f'Dropbox authorization failed: {error}', 'error') + return redirect(url_for('users.profile')) + + code = request.args.get('code') + if not code: + flash('No authorization code received from Dropbox', 'error') + return redirect(url_for('users.profile')) + + # Exchange the code for a token + result = exchange_code_for_token(code) + + if not result or not result.get('access_token'): + flash('Failed to obtain Dropbox access token', 'error') + return redirect(url_for('users.profile')) + + # Store the tokens in the user's account + current_user.dropbox_token = result.get('access_token') + current_user.dropbox_refresh_token = result.get('refresh_token') + + # Convert expires_in (seconds from now) to an actual datetime + expires_in = result.get('expires_in', 14400) # Default to 4 hours if not provided + current_user.dropbox_token_expiry = datetime.now() + timedelta(seconds=expires_in) + + # Get account information to store the account ID + account_info = get_dropbox_account_info(current_user.dropbox_token) + + if account_info and account_info.get('account_id'): + current_user.dropbox_id = account_info.get('account_id') + + # Store additional info in session for display + session['dropbox_user_info'] = { + 'name': account_info.get('name', {}).get('display_name', ''), + 'email': account_info.get('email', ''), + 'picture': account_info.get('profile_photo_url', '') + } + + # If it's the first time setting up Dropbox, set a default export path + if not current_user.dropbox_export_path: + current_user.dropbox_export_path = '/QuizzicalBeats' + + db.session.commit() + flash('Successfully connected to Dropbox!', 'success') + else: + flash('Connected to Dropbox, but failed to get account information', 'warning') + db.session.commit() + + return redirect(url_for('users.profile')) + +@users_bp.route('/dropbox/disconnect', methods=['POST']) +@login_required +def dropbox_disconnect(): + """Disconnect user's Dropbox account""" + # Revoke token if present (optional but good practice) + if current_user.dropbox_token: + try: + from musicround.helpers.dropbox_helper import revoke_token + revoke_token(current_user.dropbox_token) + except Exception as e: + current_app.logger.error(f"Error revoking Dropbox token: {str(e)}") + + # Clear Dropbox credentials + current_user.dropbox_token = None + current_user.dropbox_refresh_token = None + current_user.dropbox_token_expiry = None + current_user.dropbox_id = None + + # Keep the export path in case they reconnect + + # Remove session info + if 'dropbox_user_info' in session: + session.pop('dropbox_user_info') + + db.session.commit() + flash('Dropbox account disconnected successfully', 'success') + + return redirect(url_for('users.profile')) + +@users_bp.route('/dropbox/export-path', methods=['POST']) +@login_required +def update_dropbox_export_path(): + """Update the user's Dropbox export path""" + export_path = request.form.get('dropbox_export_path', '/QuizzicalBeats') + + # Simple validation + if not export_path.startswith('/'): + export_path = '/' + export_path + + # Remove any trailing slash + if export_path.endswith('/') and len(export_path) > 1: + export_path = export_path[:-1] + + # Save the path + current_user.dropbox_export_path = export_path + db.session.commit() + + flash('Dropbox export path updated successfully', 'success') + return redirect(url_for('users.profile')) + +@users_bp.route('/register', methods=['GET', 'POST']) +def register(): + """Register a new user""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Check if new signups are allowed + allow_signups = SystemSetting.get('allow_signups', 'true') == 'true' + if not allow_signups: + flash('New user registration is currently disabled.', 'danger') + return redirect(url_for('users.login')) + + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + first_name = request.form.get('first_name') + last_name = request.form.get('last_name') + + # Validate the form data + if not username or not email or not password or not confirm_password: + flash('All fields are required', 'danger') + return render_template('users/register.html') + + if password != confirm_password: + flash('Passwords do not match', 'danger') + return render_template('users/register.html') + + # Check if username or email already exists + if User.query.filter_by(username=username).first(): + flash('Username already exists', 'danger') + return render_template('users/register.html') + + if User.query.filter_by(email=email).first(): + flash('Email already registered', 'danger') + return render_template('users/register.html') + + # Create new user + try: + new_user = User( + username=username, + email=email, + password_hash=generate_password_hash(password), + first_name=first_name, + last_name=last_name, + created_at=datetime.now(), + last_login=datetime.now() + ) + db.session.add(new_user) + db.session.commit() + + flash('Registration successful! You can now log in.', 'success') + return redirect(url_for('users.login')) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error registering user: {e}") + flash('An error occurred during registration', 'danger') + return render_template('users/register.html') + + return render_template('users/register.html') + +@users_bp.route('/login', methods=['GET', 'POST']) +def login(): + """Log in a user""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Check which OAuth providers are configured + oauth_providers = { + 'google': bool(current_app.config.get('GOOGLE_CLIENT_ID')), + 'authentik': bool(current_app.config.get('AUTHENTIK_CLIENT_ID')) + } + + if request.method == 'POST': + username_or_email = request.form.get('username') + password = request.form.get('password') + remember = True if request.form.get('remember') else False + + # Find user by username or email + user = User.query.filter_by(username=username_or_email).first() + if not user: + user = User.query.filter_by(email=username_or_email).first() + + # Check if user exists and password is correct + if not user or not check_password_hash(user.password_hash, password): + flash('Invalid username/email or password', 'danger') + return render_template('users/login.html', oauth_providers=oauth_providers) + + # Log in the user + login_user(user, remember=remember) + user.last_login = datetime.now() + db.session.commit() + + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('core.index') + + flash('You have been logged in!', 'success') + return redirect(next_page) + + return render_template('users/login.html', oauth_providers=oauth_providers) + +@users_bp.route('/logout') +@login_required +def logout(): + """Log out a user""" + logout_user() + flash('You have been logged out', 'success') + return redirect(url_for('users.login')) + +@users_bp.route('/profile') +@login_required +def profile(): + """Display user profile""" + # Check which view functions exist to avoid using current_app in template + available_routes = { + 'rounds_list': 'rounds.rounds_list' in current_app.view_functions, + 'view_songs': 'core.view_songs' in current_app.view_functions, + 'create_round': 'rounds.create' in current_app.view_functions + } + + # Check if any admin users exist + admin_role = Role.query.filter_by(name='admin').first() + admin_exists = False + if admin_role: + admin_exists = admin_role.users.count() > 0 + + # Get current time for token expiry checks + now = datetime.now() + + # Get info about current tokens + system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '') + session_bearer = session.get('access_token', '') + token_source = session.get('token_source', '') + client_token_expiry = session.get('client_token_expiry', 0) + + # Fetch user info for the active token + spotify_user_info = None + active_username = None + active_user_id = None + active_user_image = None + active_token_expiry = None + + # Check for an active token in the session + if session_bearer: + try: + # Set up the Spotify client with the token + sp = current_app.config['sp'] + sp.set_auth(session_bearer) + + # Client credentials don't have user context + if token_source != 'client_credentials': + try: + # Use the token to get user info + spotify_user_info = sp.current_user() + + if spotify_user_info: + active_user_id = spotify_user_info.get('id') + active_username = spotify_user_info.get('display_name') or active_user_id + current_app.logger.debug(f"Found Spotify user: {active_username} (ID: {active_user_id})") + + # Get profile image if available + images = spotify_user_info.get('images', []) + if images and len(images) > 0: + active_user_image = images[0].get('url') + + except Exception as user_info_error: + current_app.logger.error(f"Error fetching Spotify user info: {str(user_info_error)}") + + # For manual bearer tokens, try to determine expiry time + if token_source == '' or token_source not in ['user', 'client_credentials', 'system']: + # This is likely a manual bearer token + # Most bearer tokens are valid for 1 hour from issue + # We don't know when it was issued, but we can notify the user + # that these tokens typically expire after 1 hour + from datetime import timedelta + # Manual tokens stored in session likely were just added + token_added_time = session.get('bearer_token_added', now.timestamp()) + typical_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1) + active_token_expiry = typical_expiry + + # Mark it as a manual token for clarity + token_source = 'manual' + session['token_source'] = 'manual' + + except Exception as e: + current_app.logger.error(f"Error setting up Spotify client: {e}") + + # Determine Spotify connection status with corrected priority order + spotify_status = 'none' # Default: no connection + + # Check for manually set bearer token (highest priority) + has_manual_bearer = 'access_token' in session and token_source == 'manual' + if has_manual_bearer: + spotify_status = 'bearer' + + # Check user's own Spotify connection (second priority) + elif token_source == 'user' or (current_user.spotify_token and current_user.spotify_refresh_token): + if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now: + # User has valid token + spotify_status = 'user' + elif check_spotify_token(current_user): + # Token was refreshed successfully + spotify_status = 'user' + + # Check for client credentials token (third priority) + elif token_source == 'client_credentials': + spotify_status = 'client_credentials' + + return render_template( + 'users/profile.html', + available_routes=available_routes, + admin_exists=admin_exists, + spotify_status=spotify_status, + system_refresh_token=system_refresh_token, + session_bearer=session_bearer, + token_source=token_source, + client_token_expiry=client_token_expiry, + now=now, + spotify_user_info=spotify_user_info, + active_username=active_username, + active_user_id=active_user_id, + active_user_image=active_user_image, + active_token_expiry=active_token_expiry + ) + +@users_bp.route('/edit-profile', methods=['GET', 'POST']) +@login_required +def edit_profile(): + """Edit user profile""" + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + first_name = request.form.get('first_name') + last_name = request.form.get('last_name') + dropbox_export_path = request.form.get('dropbox_export_path', '/QuizzicalBeats').strip() + + # Check if username or email already exists and belongs to another user + if username != current_user.username and User.query.filter_by(username=username).first(): + flash('That username is already taken', 'danger') + return render_template('users/edit_profile.html') + + if email != current_user.email and User.query.filter_by(email=email).first(): + flash('That email is already registered', 'danger') + return render_template('users/edit_profile.html') + + # Update user profile + current_user.username = username + current_user.email = email + current_user.first_name = first_name + current_user.last_name = last_name + current_user.dropbox_export_path = dropbox_export_path + + try: + db.session.commit() + flash('Your profile has been updated', 'success') + return redirect(url_for('users.profile')) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating profile: {e}") + flash('An error occurred while updating your profile', 'danger') + + return render_template('users/edit_profile.html') + +@users_bp.route('/change-password', methods=['GET', 'POST']) +@login_required +def change_password(): + """Change user password""" + if request.method == 'POST': + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Check if current password is correct + if not check_password_hash(current_user.password_hash, current_password): + flash('Current password is incorrect', 'danger') + return render_template('users/change_password.html') + + # Check if new passwords match + if new_password != confirm_password: + flash('New passwords do not match', 'danger') + return render_template('users/change_password.html') + + # Update password + current_user.password_hash = generate_password_hash(new_password) + + try: + db.session.commit() + flash('Your password has been changed', 'success') + return redirect(url_for('users.profile')) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error changing password: {e}") + flash('An error occurred while changing your password', 'danger') + + return render_template('users/change_password.html') + +@users_bp.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + """Handle forgot password requests""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + if request.method == 'POST': + email = request.form.get('email') + + # Find user by email + user = User.query.filter_by(email=email).first() + + if user: + # Generate reset token + token = str(uuid.uuid4()) + user.reset_token = token + user.reset_token_expiry = datetime.now() + timedelta(hours=24) + + try: + db.session.commit() + + # Build reset URL + reset_url = url_for('users.reset_password', token=token, _external=True) + + # Email content + subject = "Quizzical Beats Password Reset" + body_text = f"""Hello {user.username}, + +You recently requested to reset your password for your Quizzical Beats account. +Please click the link below to reset your password: + +{reset_url} + +This link will expire in 24 hours. + +If you did not request a password reset, please ignore this email or contact support if you have questions. + +Best regards, +The Quizzical Beats Team +""" + + # Import and use the email helper + from musicround.helpers.email_helper import send_email + success, message = send_email(recipient=email, subject=subject, body_text=body_text) + + if success: + flash('Password reset instructions have been sent to your email address.', 'success') + current_app.logger.info(f"Password reset email sent to {email}") + else: + # Log the failure but don't reveal to user that the email exists + current_app.logger.error(f"Failed to send password reset email: {message}") + flash('If your email is registered, you will receive a password reset link.', 'success') + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error generating reset token: {e}") + flash('An error occurred. Please try again.', 'danger') + else: + # Don't reveal that email doesn't exist + # Add a small delay to prevent email enumeration + time.sleep(1) + flash('If your email is registered, you will receive a password reset link.', 'success') + current_app.logger.info(f"Password reset attempted for non-existent email: {email}") + + return render_template('users/forgot_password.html') + +@users_bp.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token): + """Reset password using token""" + if current_user.is_authenticated: + return redirect(url_for('core.index')) + + # Find user by reset token + user = User.query.filter_by(reset_token=token).first() + + # Check if token is valid and not expired + if not user or not user.reset_token_expiry or user.reset_token_expiry < datetime.now(): + flash('The password reset link is invalid or has expired', 'danger') + return redirect(url_for('users.forgot_password')) + + if request.method == 'POST': + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + if password != confirm_password: + flash('Passwords do not match', 'danger') + return render_template('users/reset_password.html', token=token) + + # Update password and clear token + user.password_hash = generate_password_hash(password) + user.reset_token = None + user.reset_token_expiry = None + + try: + db.session.commit() + + # Send confirmation email + subject = "Your Quizzical Beats Password Has Been Reset" + body_text = f"""Hello {user.username}, + +Your password has been successfully reset. You can now log in with your new password. + +If you did not reset your password, please contact support immediately. + +Best regards, +The Quizzical Beats Team +""" + + # Import and use email helper + from musicround.helpers.email_helper import send_email + send_email(recipient=user.email, subject=subject, body_text=body_text) + + flash('Your password has been reset. You can now log in.', 'success') + current_app.logger.info(f"Password reset successful for user: {user.username}") + return redirect(url_for('users.login')) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error resetting password: {e}") + flash('An error occurred while resetting your password', 'danger') + + return render_template('users/reset_password.html', token=token) + +@users_bp.route('/spotify-link', methods=['GET', 'POST']) +@login_required +def spotify_link(): + """Manage Spotify account connection""" + now = datetime.now() + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'disconnect': + # Disconnect Spotify account + current_user.spotify_token = None + current_user.spotify_refresh_token = None + current_user.spotify_token_expiry = None + current_user.oauth_id = None + + try: + db.session.commit() + flash('Your Spotify account has been disconnected', 'success') + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error disconnecting Spotify: {e}") + flash('An error occurred while disconnecting your Spotify account', 'danger') + + return render_template('users/spotify_link.html', now=now) + +@users_bp.route('/spotify-auth') +@login_required +def spotify_auth(): + """Initiate Spotify OAuth flow""" + try: + sp_oauth = SpotifyOAuth( + client_id=current_app.config['SPOTIFY_CLIENT_ID'], + client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=url_for('users.spotify_callback', _external=True), + scope=current_app.config['SPOTIFY_SCOPE'] + ) + auth_url = sp_oauth.get_authorize_url() + + # Store state in session for validation + session['oauth_state'] = sp_oauth.state + + return redirect(auth_url) + except Exception as e: + current_app.logger.error(f"Error initiating Spotify auth: {e}") + flash('Error connecting to Spotify. Please try again.', 'danger') + return redirect(url_for('users.spotify_link')) + +@users_bp.route('/spotify-callback') +@login_required +def spotify_callback(): + """Handle Spotify OAuth callback""" + try: + # Verify state parameter + if request.args.get('state') != session.get('oauth_state'): + flash('Authentication state mismatch. Please try again.', 'danger') + return redirect(url_for('users.spotify_link')) + + # Get authorization code + code = request.args.get('code') + if not code: + flash('No authorization code received from Spotify.', 'danger') + return redirect(url_for('users.spotify_link')) + + # Exchange code for token + sp_oauth = SpotifyOAuth( + client_id=current_app.config['SPOTIFY_CLIENT_ID'], + client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=url_for('users.spotify_callback', _external=True), + scope=current_app.config['SPOTIFY_SCOPE'] + ) + + token_info = sp_oauth.get_access_token(code) + + if not token_info or 'access_token' not in token_info: + flash('Failed to obtain access token from Spotify.', 'danger') + return redirect(url_for('users.spotify_link')) + + # Save token to user + current_user.spotify_token = token_info['access_token'] + current_user.spotify_refresh_token = token_info.get('refresh_token') + expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None + current_user.spotify_token_expiry = expiry + + # Get Spotify user ID + try: + sp = current_app.config['sp'] + sp.set_auth(token_info['access_token']) + user_info = sp.current_user() + current_user.oauth_id = user_info['id'] + except: + # Continue even if we can't get the Spotify ID + current_app.logger.warning("Could not fetch Spotify user ID") + + # Save to database + try: + db.session.commit() + flash('Successfully connected to Spotify!', 'success') + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error saving Spotify token: {e}") + flash('Error saving Spotify connection.', 'danger') + + return redirect(url_for('users.spotify_link')) + + except Exception as e: + current_app.logger.error(f"Error during Spotify callback: {e}") + flash('Error during Spotify authentication. Please try again.', 'danger') + return redirect(url_for('users.spotify_link')) + +@users_bp.route('/update-bearer-token', methods=['POST']) +@login_required +def update_bearer_token(): + """Update the Spotify bearer token in the session""" + # Check if clearing token was requested + if request.form.get('clear_token'): + session.pop('access_token', None) + session.pop('token_source', None) + session.pop('bearer_token_added', None) + flash('Spotify bearer token has been cleared', 'success') + return redirect(url_for('users.profile')) + + # Get bearer token from form + bearer_token = request.form.get('bearer_token', '').strip() + if not bearer_token: + flash('No bearer token provided', 'warning') + return redirect(url_for('users.profile')) + + try: + # Store the token in session with timestamp and mark as manual + session['access_token'] = bearer_token + session['token_source'] = 'manual' + session['bearer_token_added'] = datetime.now().timestamp() + + # Test the token with a simple request to validate it + sp = current_app.config['sp'] + sp.set_auth(bearer_token) + + # Try to get current user info as a test + user_info = sp.current_user() + + if user_info and 'id' in user_info: + username = user_info.get('display_name') or user_info.get('id') + flash(f'Successfully authenticated with Spotify as {username}', 'success') + + # Log who this token belongs to + current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})") + else: + flash('Token saved but validation failed. The token may be invalid or expired.', 'warning') + except Exception as e: + current_app.logger.error(f"Error validating bearer token: {e}") + flash(f'Token saved but error during validation: {str(e)}', 'warning') + + return redirect(url_for('users.profile')) + +@users_bp.route('/use-refresh-token', methods=['POST']) +@login_required +def use_refresh_token(): + """Generate a new access token using the stored refresh token""" + # Check if user has a refresh token + if not current_user.spotify_refresh_token: + flash('No Spotify refresh token found. Please connect your Spotify account first.', 'warning') + return redirect(url_for('users.profile')) + + try: + # Create OAuth object + sp_oauth = SpotifyOAuth( + client_id=current_app.config['SPOTIFY_CLIENT_ID'], + client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=url_for('users.spotify_callback', _external=True), + scope=current_app.config['SPOTIFY_SCOPE'] + ) + + # Refresh the token + token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token) + + if not token_info or 'access_token' not in token_info: + flash('Failed to refresh access token from Spotify.', 'danger') + return redirect(url_for('users.profile')) + + # Update user model with new token information + current_user.spotify_token = token_info['access_token'] + current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None + + # If we got a new refresh token (unusual but possible), store it + if 'refresh_token' in token_info: + current_user.spotify_refresh_token = token_info['refresh_token'] + + # Save to database + db.session.commit() + + # Also set token in the session for direct API access + session['access_token'] = token_info['access_token'] + + # Validate the token by getting user info + sp = current_app.config['sp'] + sp.set_auth(token_info['access_token']) + user_info = sp.current_user() + + if user_info and 'id' in user_info: + flash(f'Successfully generated new token for {user_info.get("display_name", user_info["id"])}', 'success') + else: + flash('Token generated but validation failed.', 'warning') + + except Exception as e: + current_app.logger.error(f"Error refreshing token: {e}") + flash(f'Error refreshing token: {str(e)}', 'danger') + + return redirect(url_for('users.profile')) + +def check_spotify_token(user): + """ + Helper function to check if user's Spotify token needs to be refreshed + Returns True if token is valid, False if not + """ + if not user.spotify_token or not user.spotify_refresh_token or not user.spotify_token_expiry: + return False + + now = datetime.now() + + # If token expires in less than 5 minutes, refresh it + if user.spotify_token_expiry - now < timedelta(minutes=5): + sp_oauth = SpotifyOAuth( + client_id=current_app.config['SPOTIFY_CLIENT_ID'], + client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'], + redirect_uri=url_for('users.spotify_callback', _external=True), + scope=current_app.config['SPOTIFY_SCOPE'] + ) + + try: + token_info = sp_oauth.refresh_access_token(user.spotify_refresh_token) + user.spotify_token = token_info['access_token'] + user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) + db.session.commit() + return True + except Exception as e: + current_app.logger.error(f"Error refreshing Spotify token: {e}") + return False + + return True + +@users_bp.route('/setup') +@login_required +def setup(): + """One-time setup route to promote the current user to admin""" + if current_user.is_admin(): + flash('You are already an administrator.', 'info') + return redirect(url_for('users.profile')) + + try: + # Check if the admin role exists + admin_role = Role.query.filter_by(name='admin').first() + + # Check if any admin users already exist + admin_exists = False + if admin_role: + admin_exists = admin_role.users.count() > 0 + + if admin_exists: + flash('An administrator already exists in the system. Only the first user can be promoted to admin.', 'warning') + return redirect(url_for('users.profile')) + + # Create the admin role if it doesn't exist + if not admin_role: + admin_role = Role(name='admin', description='Administrator role with full system access') + db.session.add(admin_role) + db.session.commit() + current_app.logger.info(f"Created admin role with ID {admin_role.id}") + + # Assign the admin role to the current user + if admin_role not in current_user.roles: + current_user.roles.append(admin_role) + db.session.commit() + current_app.logger.info(f"User {current_user.username} promoted to admin") + flash('You have been promoted to administrator!', 'success') + else: + flash('You already have the admin role, but it may not be working correctly.', 'warning') + + return redirect(url_for('users.profile')) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error promoting user to admin: {str(e)}") + flash(f'Error setting up admin privileges: {str(e)}', 'danger') + return redirect(url_for('users.profile')) + +@users_bp.route('/audio-settings', methods=['GET', 'POST']) +@login_required +def audio_settings(): + """Manage custom audio settings including intro, outro, and replay MP3s""" + from musicround.helpers.utils import save_user_mp3, generate_tts_mp3, get_available_voices + + if request.method == 'POST': + action = request.form.get('action') + mp3_type = request.form.get('mp3_type') + + if not mp3_type or mp3_type not in ['intro', 'outro', 'replay']: + flash('Invalid audio type specified', 'danger') + return redirect(url_for('users.audio_settings')) + + if action == 'upload': + # Handle file upload + if 'audio_file' not in request.files: + flash('No file provided', 'danger') + return redirect(request.url) + + file = request.files['audio_file'] + if file.filename == '': + flash('No file selected', 'danger') + return redirect(request.url) + + # Save the user's uploaded MP3 + file_path = save_user_mp3(file, current_user.username, mp3_type) + if file_path: + # Update user's MP3 setting + setattr(current_user, f'{mp3_type}_mp3', file_path) + db.session.commit() + flash(f'Your {mp3_type} audio has been updated', 'success') + else: + flash('Invalid file format. Please upload an MP3 file.', 'danger') + + elif action == 'generate': + # Handle text-to-speech generation + text = request.form.get('tts_text') + service = request.form.get('tts_service', 'polly') + voice = request.form.get('tts_voice') + + # Process advanced options for each service + model = None + stability = None + similarity = None + + if service == 'openai': + model = request.form.get('openai_model', 'tts-1') + elif service == 'elevenlabs': + model = request.form.get('elevenlabs_model', 'eleven_monolingual_v1') + # Convert string values to float + try: + stability = float(request.form.get('stability', 0.5)) + similarity = float(request.form.get('similarity_boost', 0.75)) + except (ValueError, TypeError): + stability = 0.5 + similarity = 0.75 + + if not text: + flash('No text provided for speech generation', 'danger') + return redirect(request.url) + + # Generate MP3 with the selected service and options + file_path = generate_tts_mp3( + text=text, + username=current_user.username, + mp3_type=mp3_type, + service=service, + voice=voice, + model=model, + stability=stability, + similarity=similarity + ) + + if file_path: + # Update user's MP3 setting + setattr(current_user, f'{mp3_type}_mp3', file_path) + db.session.commit() + flash(f'Your {mp3_type} audio has been generated', 'success') + else: + flash('Error generating audio. Please check your settings and try again.', 'danger') + + elif action == 'reset': + # Reset to default MP3 + setattr(current_user, f'{mp3_type}_mp3', None) + db.session.commit() + flash(f'Your {mp3_type} audio has been reset to default', 'success') + + return redirect(url_for('users.audio_settings')) + + # Define default text templates for each type + default_texts = { + 'intro': 'Welcome to the music quiz! Get ready to test your knowledge of songs and artists.', + 'outro': 'That concludes our music round. How many songs did you recognize?', + 'replay': 'Now we will play all the songs again. Listen carefully!' + } + + # Check which TTS services are available + tts_services = [] + + # Check for AWS credentials + has_aws = all([ + current_app.config.get('AWS_ACCESS_KEY_ID'), + current_app.config.get('AWS_SECRET_ACCESS_KEY') + ]) + if has_aws: + tts_services.append({ + 'id': 'polly', + 'name': 'Amazon Polly', + 'description': 'High-quality natural-sounding voices', + 'voices': get_available_voices('polly') + }) + + # Check for OpenAI credentials + has_openai = bool(current_app.config.get('OPENAI_API_KEY')) + if has_openai: + tts_services.append({ + 'id': 'openai', + 'name': 'OpenAI TTS', + 'description': 'Realistic speech synthesis from OpenAI', + 'voices': get_available_voices('openai'), + 'models': [ + {'id': 'tts-1', 'name': 'TTS-1 (Standard)', 'description': 'Standard quality voice'}, + {'id': 'tts-1-hd', 'name': 'TTS-1-HD (High Definition)', 'description': 'Higher quality voice'} + ] + }) + + # Check for ElevenLabs credentials + has_elevenlabs = bool(current_app.config.get('ELEVENLABS_API_KEY')) + if has_elevenlabs: + tts_services.append({ + 'id': 'elevenlabs', + 'name': 'ElevenLabs', + 'description': 'Ultra-realistic AI voices with emotion', + 'voices': get_available_voices('elevenlabs'), + 'models': [ + {'id': 'eleven_monolingual_v1', 'name': 'Monolingual V1', 'description': 'English only, faster generation'}, + {'id': 'eleven_multilingual_v2', 'name': 'Multilingual V2', 'description': 'Supports multiple languages'} + ], + 'settings': True # Flag to indicate this service has additional settings + }) + + # Handle TTS service selection for the form + selected_service = request.args.get('tts_service') + mp3_type = request.args.get('mp3_type') or 'intro' + + # Find the selected service dict + selected_service_dict = None + for svc in tts_services: + if svc['id'] == selected_service: + selected_service_dict = svc + break + if not selected_service_dict and tts_services: + selected_service_dict = tts_services[0] + + return render_template( + 'users/audio_settings.html', + default_texts=default_texts, + tts_services=tts_services, + has_tts_services=bool(tts_services), + selected_service=selected_service_dict, + mp3_type=mp3_type + ) + +@users_bp.route('/system-settings', methods=['GET', 'POST']) +@login_required +@admin_required +def system_settings(): + """Admin view to edit global/system settings.""" + from musicround.helpers.utils import get_available_voices + + # Define editable system settings keys and their labels + editable_settings = [ + ('default_tts_service', 'Default TTS Service'), + ('default_tts_voice', 'Default TTS Voice'), + ('default_tts_model', 'Default TTS Model'), + ('fallback_spotify_refresh_token', 'Fallback Spotify Refresh Token'), + ('spotify_region', 'Default Spotify Region'), + ('max_songs_per_round', 'Maximum Songs Per Round'), + ('enable_public_rounds', 'Enable Public Rounds'), + ('allow_signups', 'Allow New User Registrations'), + # Add more keys/labels as needed + ] + + # Define which settings are checkboxes (boolean values) + checkbox_settings = ['enable_public_rounds', 'allow_signups'] + + if request.method == 'POST': + # Process all settings from the form + for key, _ in editable_settings: + # Handle checkboxes differently - they're only in the form if checked + if key in checkbox_settings: + # Set 'true' if checkbox is in form data, otherwise 'false' + value = 'true' if key in request.form else 'false' + current_app.logger.debug(f"Setting {key} to {value}") + SystemSetting.set(key, value) + else: + # Normal text/select fields + value = request.form.get(key, '') + current_app.logger.debug(f"Setting {key} to {value}") + SystemSetting.set(key, value) + + # Add a database commit to ensure changes are saved + db.session.commit() + + flash('System settings updated.', 'success') + return redirect(url_for('users.system_settings')) + + # Get all current settings + settings = SystemSetting.all_settings() + + # Check which TTS services are available + tts_services = [] + + # Check for AWS credentials + has_aws = all([ + current_app.config.get('AWS_ACCESS_KEY_ID'), + current_app.config.get('AWS_SECRET_ACCESS_KEY') + ]) + if has_aws: + tts_services.append({ + 'id': 'polly', + 'name': 'Amazon Polly', + 'description': 'High-quality natural-sounding voices', + 'voices': get_available_voices('polly') + }) + + # Check for OpenAI credentials + has_openai = bool(current_app.config.get('OPENAI_API_KEY')) + if has_openai: + tts_services.append({ + 'id': 'openai', + 'name': 'OpenAI TTS', + 'description': 'Realistic speech synthesis from OpenAI', + 'voices': get_available_voices('openai'), + 'models': [ + {'id': 'tts-1', 'name': 'TTS-1 (Standard)', 'description': 'Standard quality voice'}, + {'id': 'tts-1-hd', 'name': 'TTS-1-HD (High Definition)', 'description': 'Higher quality voice'} + ] + }) + + # Check for ElevenLabs credentials + has_elevenlabs = bool(current_app.config.get('ELEVENLABS_API_KEY')) + if has_elevenlabs: + tts_services.append({ + 'id': 'elevenlabs', + 'name': 'ElevenLabs', + 'description': 'Ultra-realistic AI voices with emotion', + 'voices': get_available_voices('elevenlabs'), + 'models': [ + {'id': 'eleven_monolingual_v1', 'name': 'Monolingual V1', 'description': 'English only, faster generation'}, + {'id': 'eleven_multilingual_v2', 'name': 'Multilingual V2', 'description': 'Supports multiple languages'} + ] + }) + + # List of available regions for Spotify + spotify_regions = [ + {'code': 'US', 'name': 'United States'}, + {'code': 'GB', 'name': 'United Kingdom'}, + {'code': 'DE', 'name': 'Germany'}, + {'code': 'FR', 'name': 'France'}, + {'code': 'ES', 'name': 'Spain'}, + {'code': 'IT', 'name': 'Italy'}, + {'code': 'JP', 'name': 'Japan'}, + {'code': 'AU', 'name': 'Australia'}, + {'code': 'BR', 'name': 'Brazil'}, + {'code': 'CA', 'name': 'Canada'}, + ] + + return render_template( + 'admin/system_settings.html', + settings=settings, + editable_settings=editable_settings, + tts_services=tts_services, + spotify_regions=spotify_regions + ) + +@users_bp.route('/backup-manager') +@login_required +@admin_required +def backup_manager(): + """Backup Manager interface for administrators""" + from musicround.helpers.backup_helper import list_backups, get_backup_summary, generate_backup_config_suggestion + + # Get list of existing backups + backups = list_backups() + + # Get backup system status and summary + backup_summary = get_backup_summary() + + # Extract key information from summary + backup_count = backup_summary.get('backup_count', 0) + latest_backup = backup_summary.get('latest_backup') + schedule_enabled = backup_summary.get('schedule_enabled', False) + schedule_time = backup_summary.get('schedule_time') + schedule_frequency = backup_summary.get('schedule_frequency', 'daily') + next_backup = backup_summary.get('next_backup') + backup_location = backup_summary.get('backup_location') + retention_days = backup_summary.get('retention_days', 30) + + # Generate configuration suggestion instead of Docker Compose labels + config_suggestion = generate_backup_config_suggestion(retention_days=retention_days) + + # Check if we should show the schedule or create forms + show_schedule_form = request.args.get('show_schedule') == 'true' + show_create_form = request.args.get('show_create') == 'true' + + # Check for notifications from other backup operations + notification = None + if 'backup_notification' in session: + notification = session.pop('backup_notification') + + return render_template( + 'admin/backup_manager.html', + backups=backups, + backup_count=backup_count, + latest_backup=latest_backup, + schedule_enabled=schedule_enabled, + schedule_time=schedule_time, + schedule_frequency=schedule_frequency, + next_backup=next_backup, + backup_location=backup_location, + retention_days=retention_days, + show_schedule_form=show_schedule_form, + show_create_form=show_create_form, + notification=notification, + config_suggestion=config_suggestion + ) + +@users_bp.route('/create-backup', methods=['POST']) +@login_required +@admin_required +def create_backup(): + """Create a new backup""" + from musicround.helpers.backup_helper import create_backup as create_backup_helper + + # Check for automation token for scheduled backups + automation_token = request.headers.get('X-Automation-Token') or request.args.get('token') + if automation_token == current_app.config.get('AUTOMATION_TOKEN'): + # Allow the request without authentication for automation + pass + elif not current_user.is_authenticated or not current_user.is_admin(): + return jsonify({"status": "error", "message": "Unauthorized"}), 401 + + # Get custom backup name if provided + backup_name = request.form.get('backup_name', '').strip() + + # Get options for what to include + include_mp3s = request.form.get('include_mp3s', 'true') == 'true' + include_config = request.form.get('include_config', 'true') == 'true' + + # Create the backup + result = create_backup_helper( + backup_name=backup_name if backup_name else None, + include_mp3s=include_mp3s, + include_config=include_config + ) + + # If this is an API request, return JSON + if request.headers.get('Accept') == 'application/json' or automation_token: + return jsonify(result) + + # Otherwise, store the result for display in the UI and redirect + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/schedule-backup', methods=['POST']) +@login_required +@admin_required +def schedule_backup(): + """Schedule automatic backups""" + from musicround.helpers.backup_helper import schedule_backup as schedule_backup_helper + + # Get schedule settings from form + schedule_time = request.form.get('schedule_time', '03:00') + frequency = request.form.get('frequency', 'daily') + enabled = request.form.get('enabled') == 'true' + + # Get retention days from form + retention_days = request.form.get('retention_days', '30') + try: + retention_days = int(retention_days) + except ValueError: + retention_days = 30 # Default to 30 days if invalid input + + # Save schedule settings + result = schedule_backup_helper(schedule_time=schedule_time, frequency=frequency, retention_days=retention_days) + + # Update enabled status + from musicround.models import SystemSetting + SystemSetting.set('backup_schedule_enabled', 'true' if enabled else 'false') + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/verify-backup/', methods=['GET', 'POST']) +@login_required +@admin_required +def verify_backup(filename): + """Verify the integrity of a backup file""" + from musicround.helpers.backup_helper import verify_backup as verify_backup_helper + + # Verify the backup + result = verify_backup_helper(filename) + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/restore-backup/', methods=['POST']) +@login_required +@admin_required +def restore_backup(filename): + """Restore from a backup file""" + from musicround.helpers.backup_helper import restore_backup as restore_backup_helper + + # Restore the backup + result = restore_backup_helper(filename) + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/delete-backup/', methods=['POST']) +@login_required +@admin_required +def delete_backup(filename): + """Delete a backup file""" + from musicround.helpers.backup_helper import delete_backup as delete_backup_helper + + # Delete the backup + result = delete_backup_helper(filename) + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/download-backup/', methods=['GET', 'POST']) +@login_required +@admin_required +def download_backup(filename): + """Download a backup file""" + import os + from flask import send_file, abort + + # Security check: only allow .zip files + if not filename.endswith('.zip'): + abort(400, "Invalid file type") + + # Only allow downloading from the backup directory + backup_dir = os.path.join('/data', 'backups') + backup_path = os.path.join(backup_dir, filename) + + # Check if the file exists + if not os.path.exists(backup_path): + abort(404, "Backup file not found") + + # Send the file + return send_file( + backup_path, + mimetype='application/zip', + as_attachment=True, + download_name=filename + ) + +@users_bp.route('/upload-backup', methods=['POST']) +@login_required +@admin_required +def upload_backup(): + """Upload a backup file to the system""" + from musicround.helpers.backup_helper import upload_backup as upload_backup_helper + + # Check if a file was uploaded + if 'backup_file' not in request.files: + flash('No file selected', 'danger') + return redirect(url_for('users.backup_manager')) + + file = request.files['backup_file'] + + # Check if the file has a name + if file.filename == '': + flash('No file selected', 'danger') + return redirect(url_for('users.backup_manager')) + + # Upload the file + result = upload_backup_helper(file) + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message') + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/apply-retention-policy', methods=['POST']) +@login_required +@admin_required +def apply_retention_policy(): + """Apply retention policy to backups""" + from musicround.helpers.backup_helper import apply_retention_policy as apply_retention_policy_helper + from musicround.models import SystemSetting + + # Get retention days from form + retention_days = request.form.get('retention_days', '30') + try: + retention_days = int(retention_days) + except ValueError: + retention_days = 30 # Default to 30 days if invalid input + + # Save the retention policy setting + SystemSetting.set('backup_retention_days', str(retention_days)) + + # Apply the retention policy + result = apply_retention_policy_helper(retention_days) + + # Store the result for display + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message'), + 'details': { + 'deleted_count': result.get('deleted_count', 0), + 'deleted_backups': result.get('deleted_backups', []) + } + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/update-scheduler', methods=['POST']) +@login_required +@admin_required +def update_scheduler(): + """Update the Ofelia scheduler configuration based on current system settings""" + from musicround.helpers.backup_helper import update_ofelia_config + + # Get the current retention days setting + from musicround.models import SystemSetting + retention_days = int(SystemSetting.get('backup_retention_days', '30')) + + # Update the scheduler configuration + result = update_ofelia_config(retention_days=retention_days) + + # Store result for display in the UI + session['backup_notification'] = { + 'status': result.get('status'), + 'message': result.get('message'), + 'details': { + 'schedule': result.get('schedule'), + 'config_content': result.get('config_content'), + 'instructions': result.get('instructions') + } + } + + return redirect(url_for('users.backup_manager')) + +@users_bp.route('/system-health') +@login_required +@admin_required +def system_health(): + """Display system health status""" + import os + import platform + import sys + import flask + import sqlite3 + from datetime import datetime + from musicround.models import Song, Round, User, db + from musicround.version import VERSION_INFO + + # Check database status + database_status = {"color": "green", "message": "Database is operational and accessible."} + database_stats = {} + + try: + # Count database records + database_stats["song_count"] = Song.query.count() + database_stats["round_count"] = Round.query.count() + database_stats["user_count"] = User.query.count() + + # Get database file size + db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + if os.path.exists(db_path): + size_bytes = os.path.getsize(db_path) + size_mb = size_bytes / (1024 * 1024) + database_stats["file_size"] = f"{size_mb:.2f} MB" + else: + database_stats["file_size"] = "Unknown" + database_status = {"color": "yellow", "message": "Database file not found at expected location."} + except Exception as e: + current_app.logger.error(f"Error checking database status: {str(e)}") + database_status = {"color": "red", "message": f"Database error: {str(e)}"} + + # Check storage status + storage_status = {"color": "green", "message": "All storage locations are accessible and writable."} + storage_stats = [] + + # Check important directories + dirs_to_check = [ + {"path": '/data', "name": "Data Directory"}, + {"path": '/data/backups', "name": "Backups Directory"}, + {"path": os.path.join(os.path.dirname(current_app.root_path), 'mp3'), "name": "MP3 Directory"}, + {"path": os.path.join(current_app.root_path, 'static'), "name": "Static Files"} + ] + + for dir_info in dirs_to_check: + dir_path = dir_info["path"] + dir_name = dir_info["name"] + dir_stat = {"name": dir_name, "path": dir_path} + + # Create directory if it doesn't exist (for backups) + if dir_name == "Backups Directory" and not os.path.exists(dir_path): + try: + os.makedirs(dir_path, exist_ok=True) + except Exception: + pass + + if os.path.exists(dir_path): + # Check if directory is writable + dir_stat["writable"] = os.access(dir_path, os.W_OK) + + # Count files and calculate size + try: + files = [f for f in os.listdir(dir_path) if os.path.isfile(os.path.join(dir_path, f))] + dir_stat["file_count"] = len(files) + + total_size = sum(os.path.getsize(os.path.join(dir_path, f)) for f in files) + size_mb = total_size / (1024 * 1024) + dir_stat["size"] = f"{size_mb:.2f} MB" + except Exception: + dir_stat["file_count"] = "Error" + dir_stat["size"] = "Error" + else: + dir_stat["writable"] = False + dir_stat["file_count"] = 0 + dir_stat["size"] = "0.00 MB" + + # Update overall status + if storage_status["color"] != "red": + storage_status = {"color": "yellow", "message": f"Directory not found: {dir_name}"} + + storage_stats.append(dir_stat) + + # Check API services status + api_status = {"color": "green", "message": "All API services are available."} + service_stats = [] + + # Check Spotify API + spotify_service = { + "name": "Spotify API", + "status": "ok", + "message": "Connected and operational" + } + + try: + spotify_credentials = all([ + current_app.config.get('SPOTIFY_CLIENT_ID'), + current_app.config.get('SPOTIFY_CLIENT_SECRET') + ]) + + if not spotify_credentials: + spotify_service["status"] = "error" + spotify_service["message"] = "Missing API credentials" + if api_status["color"] == "green": + api_status = {"color": "yellow", "message": "Some API services are unavailable."} + except Exception as e: + spotify_service["status"] = "error" + spotify_service["message"] = f"Error: {str(e)}" + api_status = {"color": "yellow", "message": "Some API services have errors."} + + service_stats.append(spotify_service) + + # Check Deezer API + deezer_service = { + "name": "Deezer API", + "status": "ok", + "message": "Connected and operational" + } + + try: + deezer_client = current_app.config.get('deezer') + if not deezer_client: + deezer_service["status"] = "warning" + deezer_service["message"] = "Deezer client not initialized" + if api_status["color"] == "green": + api_status = {"color": "yellow", "message": "Some API services have warnings."} + except Exception as e: + deezer_service["status"] = "error" + deezer_service["message"] = f"Error: {str(e)}" + api_status = {"color": "yellow", "message": "Some API services have errors."} + + service_stats.append(deezer_service) + + # Check memory usage + memory_status = {"color": "gray", "message": "Memory usage information not available."} + + try: + import psutil + process = psutil.Process(os.getpid()) + memory_info = process.memory_info() + memory_mb = memory_info.rss / (1024 * 1024) + + if memory_mb > 500: # More than 500MB + memory_status = {"color": "yellow", "message": f"High memory usage: {memory_mb:.2f} MB"} + else: + memory_status = {"color": "green", "message": f"Memory usage: {memory_mb:.2f} MB"} + except ImportError: + # psutil module not installed + memory_status = {"color": "gray", "message": "Memory usage information not available (psutil not installed)."} + except Exception as e: + # Other errors + memory_status = {"color": "gray", "message": f"Error checking memory: {str(e)}"} + current_app.logger.error(f"Error checking memory: {str(e)}") + + # System information + system_info = { + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "platform": platform.platform(), + "flask_version": flask.__version__ + } + + # Get last backup timestamp + from musicround.helpers.backup_helper import list_backups + backups = list_backups() + if backups: + latest_backup = backups[0] + timestamp = latest_backup.get('timestamp') + if timestamp: + try: + backup_time = datetime.fromisoformat(timestamp) + database_stats["last_backup"] = backup_time.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + database_stats["last_backup"] = "Unknown format" + else: + database_stats["last_backup"] = "Unknown" + else: + database_stats["last_backup"] = "Never" + + return render_template( + 'admin/system_health.html', + database_status=database_status, + storage_status=storage_status, + api_status=api_status, + memory_status=memory_status, + database_stats=database_stats, + storage_stats=storage_stats, + service_stats=service_stats, + system_info=system_info, + version_info=VERSION_INFO + ) \ No newline at end of file diff --git a/musicround/static/audio/1.mp3 b/musicround/static/audio/1.mp3 new file mode 100644 index 0000000..db0b744 Binary files /dev/null and b/musicround/static/audio/1.mp3 differ diff --git a/musicround/static/audio/2.mp3 b/musicround/static/audio/2.mp3 new file mode 100644 index 0000000..249e37d Binary files /dev/null and b/musicround/static/audio/2.mp3 differ diff --git a/musicround/static/audio/3.mp3 b/musicround/static/audio/3.mp3 new file mode 100644 index 0000000..ae461ec Binary files /dev/null and b/musicround/static/audio/3.mp3 differ diff --git a/musicround/static/audio/4.mp3 b/musicround/static/audio/4.mp3 new file mode 100644 index 0000000..2a1a255 Binary files /dev/null and b/musicround/static/audio/4.mp3 differ diff --git a/musicround/static/audio/5.mp3 b/musicround/static/audio/5.mp3 new file mode 100644 index 0000000..75e8932 Binary files /dev/null and b/musicround/static/audio/5.mp3 differ diff --git a/musicround/static/audio/6.mp3 b/musicround/static/audio/6.mp3 new file mode 100644 index 0000000..7fa82ca Binary files /dev/null and b/musicround/static/audio/6.mp3 differ diff --git a/musicround/static/audio/7.mp3 b/musicround/static/audio/7.mp3 new file mode 100644 index 0000000..0ee88f0 Binary files /dev/null and b/musicround/static/audio/7.mp3 differ diff --git a/musicround/static/audio/8.mp3 b/musicround/static/audio/8.mp3 new file mode 100644 index 0000000..c943a75 Binary files /dev/null and b/musicround/static/audio/8.mp3 differ diff --git a/musicround/static/audio/intro.mp3 b/musicround/static/audio/intro.mp3 new file mode 100644 index 0000000..f5f0213 Binary files /dev/null and b/musicround/static/audio/intro.mp3 differ diff --git a/musicround/static/audio/outro.mp3 b/musicround/static/audio/outro.mp3 new file mode 100644 index 0000000..600fbaf Binary files /dev/null and b/musicround/static/audio/outro.mp3 differ diff --git a/musicround/static/audio/replay.mp3 b/musicround/static/audio/replay.mp3 new file mode 100644 index 0000000..2cc54b1 Binary files /dev/null and b/musicround/static/audio/replay.mp3 differ diff --git a/musicround/static/css/style.css b/musicround/static/css/style.css new file mode 100644 index 0000000..ebc3aeb --- /dev/null +++ b/musicround/static/css/style.css @@ -0,0 +1,32 @@ +body { + background-color: #DEDEDE; + color: #232323; +} + +.card { + margin-top: 50px; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +.card-header { + background-color: #191414; + color: #fff; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.btn-primary { + background-color: #1DB954; + border-color: #1DB954; +} + +.btn-primary:hover { + background-color: #1ed760; + border-color: #1ed760; +} + +.alert { + margin-top: 50px; +} + diff --git a/musicround/static/favicon.ico b/musicround/static/favicon.ico new file mode 100644 index 0000000..d2246c3 Binary files /dev/null and b/musicround/static/favicon.ico differ diff --git a/musicround/static/img/dark/logo.png b/musicround/static/img/dark/logo.png new file mode 100644 index 0000000..5407fb7 Binary files /dev/null and b/musicround/static/img/dark/logo.png differ diff --git a/musicround/static/img/dark/logo_whitespace.png b/musicround/static/img/dark/logo_whitespace.png new file mode 100644 index 0000000..041ab4c Binary files /dev/null and b/musicround/static/img/dark/logo_whitespace.png differ diff --git a/musicround/static/img/dark/logotype.png b/musicround/static/img/dark/logotype.png new file mode 100644 index 0000000..92ab4e4 Binary files /dev/null and b/musicround/static/img/dark/logotype.png differ diff --git a/musicround/static/img/dark/monogram.png b/musicround/static/img/dark/monogram.png new file mode 100644 index 0000000..38ec5e2 Binary files /dev/null and b/musicround/static/img/dark/monogram.png differ diff --git a/musicround/static/img/light/logo.png b/musicround/static/img/light/logo.png new file mode 100644 index 0000000..1058e44 Binary files /dev/null and b/musicround/static/img/light/logo.png differ diff --git a/musicround/static/img/light/logo_whitespace.png b/musicround/static/img/light/logo_whitespace.png new file mode 100644 index 0000000..3bb8792 Binary files /dev/null and b/musicround/static/img/light/logo_whitespace.png differ diff --git a/musicround/static/img/light/logotype.png b/musicround/static/img/light/logotype.png new file mode 100644 index 0000000..22719a7 Binary files /dev/null and b/musicround/static/img/light/logotype.png differ diff --git a/musicround/static/img/light/monogram.png b/musicround/static/img/light/monogram.png new file mode 100644 index 0000000..692f1ce Binary files /dev/null and b/musicround/static/img/light/monogram.png differ diff --git a/musicround/templates/admin/backup_manager.html b/musicround/templates/admin/backup_manager.html new file mode 100644 index 0000000..85978a1 --- /dev/null +++ b/musicround/templates/admin/backup_manager.html @@ -0,0 +1,528 @@ +{% extends 'base.html' %} +{% block title %}Backup Manager{% endblock %} + +{% block content %} +
+
+

System Backup Manager

+
{{ version_info.version }} - {{ version_info.release_name }}
+
+ + +
+
+
+

Backup Status

+
+
+ Total backups: + {{ backup_count }} +
+
+ Latest backup: + {{ latest_backup|default('None yet') }} +
+
+ Scheduled backups: + {{ 'Enabled' if schedule_enabled else 'Disabled' }} +
+ {% if schedule_enabled %} +
+ Next backup: + {{ next_backup|default('Not scheduled') }} +
+ {% endif %} +
+ Storage location: + {{ backup_location }} +
+
+
+ +
+

Quick Actions

+
+
+ + +
+ + + Manage Backups + + + +
+
+
+
+ + +
+

Backup Schedule Configuration

+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +

Enter 0 to keep all backups indefinitely

+
+
+ +
+ + + +
+
+ + +
+ +

+ View suggested configuration for scheduled backups +

+
+
+ + +
+

Create Custom Backup

+ +
+ + +
+ + +

Leave blank for automatic timestamp-based name

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + + +
+
+

Existing Backups

+ + + +
+ + + + + +
+
+

Backup Retention Policy

+ + + +
+ + +
+
+
+

Retention Policy Status

+
+

+ {% if retention_days > 0 %} + Currently keeping backups for {{ retention_days }} days. Older backups will be automatically deleted. + {% else %} + No retention policy is currently applied. All backups will be kept indefinitely. + {% endif %} +

+
+ + + +
+ + {% if backups %} +
+ + + + + + + + + + + + {% for backup in backups %} + + + + + + + + {% endfor %} + +
Backup NameCreatedVersionSizeActions
+
{{ backup.backup_name }}
+
{{ backup.file_name }}
+
+ {% if backup.timestamp %} + {{ backup.timestamp|timestamp_to_datetime|format_datetime('%Y-%m-%d %H:%M') }} + {% else %} + Unknown + {% endif %} + + v{{ backup.version }} + {% if backup.release_name %} +
{{ backup.release_name }}
+ {% endif %} +
+ {{ (backup.file_size / 1024 / 1024)|round(2) }} MB + +
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ {% else %} +
+

No backups found.

+ +
+ {% endif %} +
+ + +
+

System Health

+ +
+
+
+
+

Database

+
+

Database is operational and accessible.

+
+ +
+
+
+

File Storage

+
+

File storage is available and writable.

+
+ +
+
+
+

Configuration

+
+

System configuration is valid.

+
+
+
+ + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/admin/spotify_token_wizard.html b/musicround/templates/admin/spotify_token_wizard.html new file mode 100644 index 0000000..059de18 --- /dev/null +++ b/musicround/templates/admin/spotify_token_wizard.html @@ -0,0 +1,95 @@ +{% extends 'base.html' %} +{% block title %}Spotify Token Wizard{% endblock %} +{% block content %} +
+
+

Spotify Refresh Token Wizard

+ Spotify Logo +
+ +
+

What is this for?

+

This wizard helps you generate a Spotify refresh token for the system account (fallback account). This token will be used when:

+
    +
  • A user doesn't have their own Spotify account connected
  • +
  • The system needs to perform Spotify API operations in the background
  • +
  • For shared/global Spotify functionality
  • +
+

The refresh token doesn't expire, making it ideal for long-term system use.

+
+ + {% if has_token %} +
+
+ + Fallback token is configured +
+

A Spotify refresh token is already configured for the system account. You can replace it if needed.

+
+ {% endif %} + + {% if has_credentials %} +
+

Option 1: Automated Setup (Recommended)

+

This will guide you through the Spotify OAuth flow to generate a refresh token automatically.

+ +
+ + + + +
+ +
+

You'll be redirected to Spotify to authorize access, then brought back to this page when complete.

+
+
+ +
+

Option 2: Manual Entry

+

If you already have a Spotify refresh token (obtained elsewhere), you can enter it directly:

+ +
+ + + +
+ + +
+ + +
+
+ {% else %} +
+
+ + Spotify API credentials not configured +
+

Your Spotify API credentials are missing or incomplete. The following values need to be set in your environment configuration:

+
    +
  • SPOTIFY_CLIENT_ID
  • +
  • SPOTIFY_CLIENT_SECRET
  • +
  • SPOTIFY_REDIRECT_URI
  • +
+
+ {% endif %} + + +
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/admin/system_health.html b/musicround/templates/admin/system_health.html new file mode 100644 index 0000000..37d65d6 --- /dev/null +++ b/musicround/templates/admin/system_health.html @@ -0,0 +1,219 @@ +{% extends 'base.html' %} +{% block title %}System Health{% endblock %} + +{% block content %} +
+
+

System Health Status

+
{{ version_info.version }} - {{ version_info.release_name }}
+
+ + +
+
+
+
+

Database

+
+

{{ database_status.message }}

+
+ +
+
+
+

Storage

+
+

{{ storage_status.message }}

+
+ +
+
+
+

API Services

+
+

{{ api_status.message }}

+
+ +
+
+
+

Memory

+
+

{{ memory_status.message }}

+
+
+ + +
+

Database Information

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
+ Total Songs + + {{ database_stats.song_count|default('0') }} +
+ Total Rounds + + {{ database_stats.round_count|default('0') }} +
+ Total Users + + {{ database_stats.user_count|default('0') }} +
+ Database File Size + + {{ database_stats.file_size|default('Unknown') }} +
+ Last Backup + + {{ database_stats.last_backup|default('Never') }} +
+
+
+ + +
+

Storage Information

+
+ + + + + + + + + + + {% for dir in storage_stats %} + + + + + + + {% endfor %} + +
DirectoryFilesSizeStatus
+ {{ dir.name }} + + {{ dir.file_count }} + + {{ dir.size }} + + {% if dir.writable %} + + Writable + + {% else %} + + Not Writable + + {% endif %} +
+
+
+ + +
+

External Services

+
+ + + + + + + + + + {% for service in service_stats %} + + + + + + {% endfor %} + +
ServiceStatusDetails
+ {{ service.name }} + + {% if service.status == 'ok' %} + + Available + + {% elif service.status == 'warning' %} + + Warning + + {% else %} + + Unavailable + + {% endif %} + + {{ service.message }} +
+
+
+ + +
+

Version Information

+
+
+
+

Application Version: {{ version_info.version }}

+

Release Name: {{ version_info.release_name }}

+

Release Date: {{ version_info.release_date }}

+
+
+

Python Version: {{ system_info.python_version }}

+

Platform: {{ system_info.platform }}

+

Flask Version: {{ system_info.flask_version }}

+
+
+
+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/admin/system_settings.html b/musicround/templates/admin/system_settings.html new file mode 100644 index 0000000..4954952 --- /dev/null +++ b/musicround/templates/admin/system_settings.html @@ -0,0 +1,257 @@ +{% extends 'base.html' %} +{% block title %}System Settings{% endblock %} +{% block content %} +
+

System Settings

+ +
+ + + +
+

Text-to-Speech Settings

+ +
+ + +

The default text-to-speech service to use for audio generation

+
+ +
+ + +

Default voice ID for the selected TTS service

+
+ +
+ + +

Default model for OpenAI or ElevenLabs TTS

+
+
+ + +
+

Spotify Integration

+ +
+ +
+ + +
+

Service account refresh token used when users don't have Spotify access

+ +
+ +
+ + +

Default region for Spotify API searches and charts

+
+
+ + +
+

Authentication Settings

+ +
+ + +
+

+ When disabled, new users cannot create accounts. Existing users can still log in, and OAuth login will still work for existing accounts. +

+
+ + +
+

Backup & System Health

+ + + +

+ Manage system backups, restore from previous backups, and monitor system health. +

+ +
+
+
+ +
+
+

+ Regular backups are recommended to prevent data loss. The backup system will save your database, MP3 files, and system configuration. +

+
+
+
+
+ + +
+

Additional Settings

+ + {% for key, label in editable_settings %} + {% if key not in ['default_tts_service', 'default_tts_voice', 'default_tts_model', 'fallback_spotify_refresh_token', 'spotify_region', 'allow_signups'] %} +
+ + {% if key == 'enable_public_rounds' %} + + {% else %} + + {% endif %} +

+ {% if key == 'max_songs_per_round' %} + Maximum number of songs that can be included in a music round + {% elif key == 'enable_public_rounds' %} + Allow users to make their music rounds publicly accessible + {% endif %} +

+
+ {% endif %} + {% endfor %} +
+ +
+ + Cancel +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/musicround/templates/base.html b/musicround/templates/base.html new file mode 100644 index 0000000..7863fb6 --- /dev/null +++ b/musicround/templates/base.html @@ -0,0 +1,229 @@ + + + + + + {% block title %}Quizzical Beats{% endblock %} + + + + + + + + + + + + + + + + + {% block head %}{% endblock %} + + +
+ +
+ +
+ {% block content %} + {% endblock %} +
+ +
+
+

© 2025 Quizzical Beats | {{ get_version_str() }}

+
+
+ + + + + {% block scripts %} + {% endblock %} + + + diff --git a/musicround/templates/browse_deezer_playlists.html b/musicround/templates/browse_deezer_playlists.html new file mode 100644 index 0000000..35d70c3 --- /dev/null +++ b/musicround/templates/browse_deezer_playlists.html @@ -0,0 +1,278 @@ +{% extends 'base.html' %} + +{% block title %}Official Deezer Playlists{% endblock %} + +{% block content %} +
+

Official Deezer Playlists

+

Browse and import popular playlists from Deezer.

+ + +
+
+
+ + +

Comma-separated keywords to search in playlist names

+
+ +
+ +
+
+
+ + +
+ {% for playlist in playlists %} +
+ {% if playlist.picture_xl %} + {{ playlist.title }} + {% else %} +
+ No image +
+ {% endif %} +
+
+
{{ playlist.title }}
+ Deezer +
+ {% if playlist.description %} +

{{ playlist.description|truncate(100) }}

+ {% endif %} +

Tracks: {{ playlist.nb_tracks }}

+
+
+ + + +
+ +
+
+
+ {% else %} +
+

No playlists found matching your criteria. Try changing your filters.

+
+ {% endfor %} +
+ + +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/build_music_round.html b/musicround/templates/build_music_round.html new file mode 100644 index 0000000..56d3b63 --- /dev/null +++ b/musicround/templates/build_music_round.html @@ -0,0 +1,112 @@ +{% extends 'base.html' %} + +{% block title %}Build Music Quiz - Quizzical Beats{% endblock %} + +{% block content %} +
+
+

Create Your Music Quiz

+

Choose your preferred method to generate the perfect music round for your trivia night.

+
+ +
+ + +
+

Round Details

+
+ + +
+
+ +
+ +
+
+ +
+
+
Random Selection
+

Create a music quiz with randomly selected songs from different artists and decades for a diverse challenge.

+ +
+
+ + +
+
+ +
+
+
By Decade
+

Create a themed music quiz with songs from a specific decade that has been used the least in your quizzes.

+ +
+
+ + +
+
+ +
+
+
By Genre
+

Create a themed music quiz with songs from a specific genre that has been used the least in your quizzes.

+ +
+
+ + +
+
+ +
+
+
By Tag
+

Create a music quiz with songs that share a specific tag from your collection.

+ + +
+
+
+
+ +
+
+

Need more songs?

+

Import more songs from Spotify or Deezer to create even better music quizzes.

+ +
+ +
+

Import a Playlist

+

Quickly create a music quiz from an existing Spotify or Deezer playlist.

+ + Import from Playlist + +
+
+
+{% endblock %} + diff --git a/musicround/templates/error.html b/musicround/templates/error.html new file mode 100644 index 0000000..170539f --- /dev/null +++ b/musicround/templates/error.html @@ -0,0 +1,226 @@ +{% extends 'base.html' %} + +{% block title %}Error {{ code }} - Quizzical Beats{% endblock %} + +{% block content %} +
+
+
+ +
+

Error {{ code }}

+ + +
+ +
+
+ +
+

Interpreting this error for you...

+
+ + + + + + +
+ + +
+
+ + Technical Details + + +
+
+

{{ message }}

+
+
+
+
+ + {% if 'access_token' in session and (debug_info or traceback) %} + +
+ +
+ +
+ +
+ + Debug Information + + +
+
+
{{ debug_info }}
+
+ + {% if traceback %} +
+

Traceback

+
+
{{ traceback }}
+
+
+ {% endif %} +
+
+
+ {% endif %} + + + Return to Homepage + +
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + diff --git a/musicround/templates/homepage.html b/musicround/templates/homepage.html new file mode 100644 index 0000000..4632492 --- /dev/null +++ b/musicround/templates/homepage.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Quizzical Beats - Where trivia meets the rhythm{% endblock %} + +{% block content %} +
+
+

Welcome to Quizzical Beats

+

Where trivia meets the rhythm. Create unforgettable music rounds for your pub quiz or trivia night.

+
+ +
+
+

Welcome, {{ user_info['display_name'] }}

+
+
+
    +
  • + Username: + {{ current_user.username }} +
  • +
  • + Email: + {{ current_user.email }} +
  • + {% if current_user.first_name or current_user.last_name %} +
  • + Name: + {{ current_user.first_name }} {{ current_user.last_name }} +
  • + {% endif %} +
+ + +
+
+ +
+

"The soundtrack to your smartest guesses."

+
+
+{% endblock %} + diff --git a/musicround/templates/import_official_playlists.html b/musicround/templates/import_official_playlists.html new file mode 100644 index 0000000..b604d81 --- /dev/null +++ b/musicround/templates/import_official_playlists.html @@ -0,0 +1,430 @@ +{% extends 'base.html' %} + +{% block title %}Official Spotify Playlists{% endblock %} + +{% block content %} +
+

Official Spotify Playlists

+

Browse and import playlists from official Spotify accounts worldwide.

+ + +
+
+
+

Spotify Authentication

+

Enter a bearer token to access the Spotify API directly, bypassing OAuth limitations.

+ + {% if spotify_username %} +
+

+ ✓ Authenticated as: {{ spotify_username }} +

+
+ {% endif %} + +
+ + + +
+ + +

Get a token from Spotify Developer Console

+
+ +
+ + {% if session_bearer_token %} + + {% endif %} +
+
+
+ +
+

Authentication Mode

+ {% if direct_mode %} +

Currently using: Direct Bearer Token

+ + Switch to OAuth Authentication + + {% else %} +

Currently using: OAuth Authentication

+ + Switch to Direct Bearer Token + + {% endif %} +
+
+
+ + +
+
+
+
+ + +

Comma-separated keywords to search in playlist names

+
+ +
+ + +
+
+ +
+
+ + +
+ + +
+ + + {% if session_bearer_token %} + + {% endif %} +
+
+ + {% if debug_mode %} +
+

Debug Information

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
Total Playlists Fetched{{ debug_info.total_fetched }}
Keyword Filtered Out{{ debug_info.filtered_out }}
Duplicates Removed{{ debug_info.duplicates_removed }}
Final Count Displayed{{ debug_info.total_filtered }}
Total Processing Time{{ debug_info.query_time_ms }}ms
+
+ + +

Account Statistics

+
+ {% for account, stats in debug_info.accounts.items() %} +
+

{{ account }}

+
    +
  • Total playlists: {{ stats.total }}
  • +
  • Filtered: {{ stats.filtered }}/{{ stats.fetched }}
  • +
  • Processing time: {{ stats.time_ms }}ms
  • +
+
+ {% endfor %} +
+ + + {% if debug_info.matched_keywords %} +

Keyword Matches

+
+
    + {% for keyword, count in debug_info.matched_keywords.items() %} +
  • "{{ keyword }}": {{ count }} matches
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + + +
+ {% for playlist in playlists %} +
+ {% if playlist.images and playlist.images|length > 0 %} + {{ playlist.name }} + {% else %} +
+ No image +
+ {% endif %} +
+
+
{{ playlist.name }}
+ {{ playlist.owner.id }} +
+ {% if playlist.description %} +

{{ playlist.description | replace('', ' - ') | replace('', '') }}

+ {% endif %} +

Tracks: {{ playlist.tracks.total }}

+
+
+ + + +
+
+ + View + + +
+
+
+
+ {% else %} +
+

No playlists found matching your criteria. Try changing your filters.

+
+ {% endfor %} +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/import_playlist.html b/musicround/templates/import_playlist.html new file mode 100644 index 0000000..53e81b5 --- /dev/null +++ b/musicround/templates/import_playlist.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} + +{% block title %}Import Playlist - Quizzical Beats{% endblock %} + +{% block content %} +
+
+

Import Playlist

+

Create a music quiz round from a Spotify or Deezer playlist.

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +

Example: https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd or https://www.deezer.com/en/playlist/1111111

+
+ +
+ + + Back to Quiz Builder + +
+
+
+ +
+

Import Tips

+
    +
  • Make sure your playlist is public or at least accessible via link
  • +
  • Only the first 8 songs from the playlist will be imported for the quiz
  • +
  • Preview URLs might not be available for all songs
  • +
  • Songs will be saved in our database for future use
  • +
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/raw_playlists.html b/musicround/templates/raw_playlists.html new file mode 100644 index 0000000..9ae0e0c --- /dev/null +++ b/musicround/templates/raw_playlists.html @@ -0,0 +1,260 @@ +{% extends 'base.html' %} + +{% block title %}Raw Spotify Playlists{% endblock %} + +{% block content %} +
+

Raw Spotify API Response

+

Examining the raw Spotify API response for playlist queries.

+ + +
+
+
+ + +
+ +
+ + +

Max 50 items per request

+
+ +
+ + +
+ +
+ +
+
+
+ + +
+ +
+

Spotipy Response

+ + {% if results.spotipy.error %} +
+

Error:

+
{{ results.spotipy.error }}
+
+ {% endif %} + + {% if results.spotipy.raw_response %} +
+ +
+

Response Metadata:

+ + + + + + + + + + + + + + + + + + + +
Total Playlists{{ results.spotipy.raw_response.total }}
Items Returned{{ results.spotipy.raw_response.items|length }}
Has Next Page{{ "Yes" if results.spotipy.raw_response.next else "No" }}
Has Previous Page{{ "Yes" if results.spotipy.raw_response.previous else "No" }}
+
+ + +
+

Playlists ({{ results.spotipy.raw_response.items|length }}):

+
+ + + + + + + + + + + {% for playlist in results.spotipy.raw_response.items %} + + + + + + + {% endfor %} + +
#IDNameTracks
{{ loop.index }}{{ playlist.id }}{{ playlist.name }}{{ playlist.tracks.total }}
+
+
+ + + {% if results.spotipy.raw_response.previous or results.spotipy.raw_response.next %} +
+ {% if results.spotipy.raw_response.previous %} + + « Previous + + {% else %} + + {% endif %} + + {% if results.spotipy.raw_response.next %} + + Next » + + {% else %} + + {% endif %} +
+ {% endif %} + + +
+
+ View Raw JSON +
+
{{ results.spotipy.raw_response | tojson(indent=2) }}
+
+
+
+
+ {% else %} +
+

No response data available

+
+ {% endif %} +
+ + +
+

Direct API Response

+ + {% if results.direct.error %} +
+

Error:

+
{{ results.direct.error }}
+
+ {% endif %} + + {% if results.direct.raw_response %} +
+ +
+

Response Metadata:

+ + + + + + + + + + + + + + + + + + + +
Total Playlists{{ results.direct.raw_response.total }}
Items Returned{{ results.direct.raw_response.items|length }}
Has Next Page{{ "Yes" if results.direct.raw_response.next else "No" }}
Has Previous Page{{ "Yes" if results.direct.raw_response.previous else "No" }}
+
+ + +
+

Playlists ({{ results.direct.raw_response.items|length }}):

+
+ + + + + + + + + + + {% for playlist in results.direct.raw_response.items %} + + + + + + + {% endfor %} + +
#IDNameTracks
{{ loop.index }}{{ playlist.id }}{{ playlist.name }}{{ playlist.tracks.total }}
+
+
+ + + {% if results.direct.raw_response.previous or results.direct.raw_response.next %} +
+ {% if results.direct.raw_response.previous %} + + « Previous + + {% else %} + + {% endif %} + + {% if results.direct.raw_response.next %} + + Next » + + {% else %} + + {% endif %} +
+ {% endif %} + + +
+
+ View Raw JSON +
+
{{ results.direct.raw_response | tojson(indent=2) }}
+
+
+
+
+ {% else %} +
+

No response data available

+
+ {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/round.html b/musicround/templates/round.html new file mode 100644 index 0000000..7590e0d --- /dev/null +++ b/musicround/templates/round.html @@ -0,0 +1,86 @@ +{% extends 'base.html' %} + +{% block title %}Review Music Quiz - Quizzical Beats{% endblock %} + +{% block content %} +
+

Review Your Music Quiz

+
+

Quiz Criteria: {{ round_criteria }} + {% if genre %} + Genre: {{ genre }} + {% endif %} + {% if decade %} + Decade: {{ decade }} + {% endif %} + {% if tag %} + Tag: {{ tag }} + {% endif %} +

+
+ +
+ + + + + + + + + + + + + + {% for song in songs %} + + + + + + + + + + {% endfor %} + +
#CoverTitleArtistYearGenrePreview
{{ loop.index }}{{ song.title }}{{ song.title }}{{ song.artist }}{{ song.year }}{{ song.genre }} + +
+
+ +
+
+ + + +
+ + +
+ + {% if genre %} + + {% endif %} + {% if decade %} + + {% endif %} + {% if tag %} + + {% endif %} + {% for song in songs %} + + {% endfor %} + +
+ + Generate Different Quiz + +
+
+{% endblock %} diff --git a/musicround/templates/round_detail.html b/musicround/templates/round_detail.html new file mode 100644 index 0000000..c970b66 --- /dev/null +++ b/musicround/templates/round_detail.html @@ -0,0 +1,1173 @@ +{% extends 'base.html' %} + +{% block title %}{{ round.name or 'Quiz #' + round.id|string }} - Quizzical Beats{% endblock %} + +{% block content %} +
+
+

{{ round.name or 'Music Quiz #' + round.id|string }}

+
+ + + Back to Quizzes + +
+
+ +
+
+
+ +
+

{{ round.name or 'Quiz #' + round.id|string }}

+ +
+

+ Type: + + {{ round.round_type }} + +

+

Criteria: {{ round.round_criteria_used }}

+

Created: {{ round.created_at.strftime('%Y-%m-%d %H:%M:%S') }}

+
+ +
+ Quiz ID: {{ round.id }} +
+
+ +
+
+ {{ songs|length }} Songs +
+
+
+ +

Quiz Content

+
+
+ + + + + + + + + + + + + + + + + + {% for song in songs %} + + + + + + + + + + + {% endfor %} + +
#CoverTitleArtistYearGenrePreviewActions
+ {{ loop.index }} + + {{ song.title }}{{ song.title }}{{ song.artist }}{{ song.year }}{{ song.genre }} + + + +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+

Generate MP3

+

Create an audio file with all songs to play during your quiz.

+ +
+ +
+
+ +
+

Generate PDF

+

Create a printable list of songs and answers for your reference.

+ +
+ +
+
+ +
+

Send via Email

+

Email this quiz to yourself for safekeeping or offline access.

+ +
+ +
+
+ +
+

Export Options

+

View formatted song list or export to your Dropbox account.

+ + +
+
+ + + + + +
+
+
+ +
+
+

+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+ +{% endblock %} + +{% block scripts %} +{{ super() }} + + +{% endblock %} + diff --git a/musicround/templates/rounds.html b/musicround/templates/rounds.html new file mode 100644 index 0000000..6885291 --- /dev/null +++ b/musicround/templates/rounds.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} + +{% block title %}Music Quizzes - Quizzical Beats{% endblock %} + +{% block content %} +
+
+

Your Music Quizzes

+

Browse and manage all your created music quizzes.

+
+ +
+
+
+

Saved Quizzes

+ + Create New Quiz + +
+
+ +
+ + + + + + + + + + + + + {% for round in rounds %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDNameTypeCriteriaCreatedActions
{{ round.id }}{{ round.name or 'Quiz #' + round.id|string }} + + {{ round.round_type }} + + {{ round.round_criteria_used }}{{ round.created_at.strftime('%Y-%m-%d %H:%M') }} + + View + +
+

You haven't created any music quizzes yet.

+ + Create Your First Quiz + +
+
+
+ +
+

Quiz Tips

+
    +
  • Export your quizzes to PDF for easy printing of answer sheets
  • +
  • Generate MP3s to play your music round with consistent timing
  • +
  • Mix different decades and genres for a balanced challenge
  • +
  • Keep track of which rounds you've used to avoid repeating songs
  • +
+
+ + +
+{% endblock %} diff --git a/musicround/templates/service_import.html b/musicround/templates/service_import.html new file mode 100644 index 0000000..a6e3682 --- /dev/null +++ b/musicround/templates/service_import.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} + +{% block title %}Import from {{ service_name }}{% endblock %} + +{% block content %} +
+
+

+ Import {{ item_type }} from {{ service_name }} + {% if service_name == 'Spotify' %}{% elif service_name == 'Deezer' %}{% endif %} +

+ +
+

+ + + + How to find the {{ service_name }} {{ item_type|lower }} ID +

+
    +
  1. 1. Go to the {{ service_name }} website and navigate to your desired {{ item_type|lower }}
  2. +
  3. 2. Look at the URL in your browser's address bar
  4. +
  5. 3. Find the ID in the URL format shown below:
  6. +
+
+ {{ url_example_prefix }}{{ url_example_id }} +
+
+ +
+ +
+ + +
+ +
+ + + + + Back to Search + + +
+
+
+ +
+

Importing from {{ service_name }} allows you to add songs directly to your music round collection.

+
+ {% if service_name == 'Spotify' %} + + Spotify Integration + + {% elif service_name == 'Deezer' %} + + Deezer Integration + + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/service_search.html b/musicround/templates/service_search.html new file mode 100644 index 0000000..b496d18 --- /dev/null +++ b/musicround/templates/service_search.html @@ -0,0 +1,169 @@ +{% extends 'base.html' %} + +{% block title %}{{ service_name }} Search - Quizzical Beats{% endblock %} + +{% block content %} +
+
+
+

+ {% if service_name == 'Spotify' %} + + {% elif service_name == 'Deezer' %} + + {% endif %} + Search {{ service_name }} +

+

Import songs directly to your music quiz library

+
+
+
+ +
+ +
+
+ Search for music on {{ service_name }} by artist, song title, album, or playlist name. +
+ +
+ +
+
+
+ +
Browse Official Playlists
+

Discover curated collections on {{ service_name }}

+ + Browse Playlists + +
+
+
+
+ +
Import by URL
+

Enter a {{ service_name }} track, album or playlist URL

+ +
+
+
+ +
+

Looking for something specific? Try our song library to see what's already in your collection.

+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/service_search_results.html b/musicround/templates/service_search_results.html new file mode 100644 index 0000000..8fdd9ef --- /dev/null +++ b/musicround/templates/service_search_results.html @@ -0,0 +1,516 @@ +{% extends 'base.html' %} + +{% block title %}{{ service_name }} Search Results: {{ search_term }}{% endblock %} + +{% block content %} +
+

Search Results for "{{ search_term }}"

+

+ Back to Search +

+ +
+
+ + + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ {% if tracks %} +
+ + + + + + {% if has_preview %} + + {% else %} + + {% endif %} + + + + + {% for track in tracks %} + + + + {% if has_preview %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
TitleArtistPreviewAlbumActions
+ {% if track.image_url %} + Album cover + {% endif %} + {{ track.name }} + {{ track.artist }} + {% if track.preview_url %} + + {% else %} + No preview available + {% endif %} + {{ track.album }} +
+ + + +
+
+
+ {% else %} + + {% endif %} +
+
+
+ + + + + + +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/spotify_client_test.html b/musicround/templates/spotify_client_test.html new file mode 100644 index 0000000..637e2ee --- /dev/null +++ b/musicround/templates/spotify_client_test.html @@ -0,0 +1,172 @@ +{% extends 'base.html' %} + +{% block title %}Spotify Client Test{% endblock %} + +{% block content %} +
+

Spotify Client Comparison

+

Comparing spotipy library vs direct API implementation.

+ + +
+
+
+ + +
+ +
+ +
+
+
+ + +
+

Results Summary

+ +
+ +
+

Spotipy Implementation

+
+
+ Playlists Retrieved: + {{ results.spotipy.count }} +
+
+ Expected Total: + {{ results.spotipy.total }} +
+
+ Execution Time: + {{ results.spotipy.time_ms }} ms +
+ {% if results.spotipy.error %} +
+ Error: {{ results.spotipy.error }} +
+ {% endif %} +
+
+ + +
+

Direct API Implementation

+
+
+ Playlists Retrieved: + {{ results.direct.count }} +
+
+ Expected Total: + {{ results.direct.total }} +
+
+ Execution Time: + {{ results.direct.time_ms }} ms +
+ {% if results.direct.error %} +
+ Error: {{ results.direct.error }} +
+ {% endif %} +
+
+
+
+ + +
+

Implementation Comparison

+ +
+
+
Playlists in both implementations:
+
{{ comparison.in_both|length }}
+
+ +
+
Only in spotipy:
+
{{ comparison.only_in_spotipy|length }}
+
+ +
+
Only in direct API:
+
{{ comparison.only_in_direct|length }}
+
+
+
+ + +
+ +
+
+

Spotipy Playlists ({{ results.spotipy.count }})

+ {% if results.spotipy.playlists %} +
+ + + + + + + + + {% for playlist in results.spotipy.playlists %} + + + + + {% endfor %} + +
#Name
{{ loop.index }}{{ playlist.name }}
+
+ {% else %} +

No playlists retrieved.

+ {% endif %} +
+
+ + +
+
+

Direct API Playlists ({{ results.direct.count }})

+ {% if results.direct.playlists %} +
+ + + + + + + + + {% for playlist in results.direct.playlists %} + + + + + {% endfor %} + +
#Name
{{ loop.index }}{{ playlist.name }}
+
+ {% else %} +

No playlists retrieved.

+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/spotify_direct_auth.html b/musicround/templates/spotify_direct_auth.html new file mode 100644 index 0000000..9d5ff64 --- /dev/null +++ b/musicround/templates/spotify_direct_auth.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} + +{% block title %}Direct Spotify Authentication{% endblock %} + +{% block content %} +
+

Direct Spotify Authentication

+

Use a manual bearer token to access the Spotify API directly.

+ + + {% if spotify_user %} +
+
+
+ + + +
+
+

Currently authenticated

+
+

You are currently authenticated as {{ spotify_username }} (ID: {{ spotify_user }}).

+
+
+
+ + + +
+
+
+
+
+ {% endif %} + + {% if error %} +
+
+
+ + + +
+
+

Error

+
+

{{ error }}

+
+
+
+
+ {% endif %} + + {% if success %} +
+
+
+ + + +
+
+

Success

+
+

{{ success }}

+
+
+
+
+ {% endif %} + + +
+

How to Get a Bearer Token

+
    +
  1. +

    Go to the Spotify Developer Console

    +

    You'll need to log in with your Spotify account

    +
  2. +
  3. +

    Select any API endpoint (e.g., "Get Current User's Profile")

    +

    The specific endpoint doesn't matter, we just need to generate a token

    +
  4. +
  5. +

    Click the "Get Token" button

    +

    Make sure to select the following scopes:

    +
      +
    • user-read-private
    • +
    • user-read-email
    • +
    • playlist-read-private
    • +
    • playlist-read-collaborative
    • +
    +
  6. +
  7. +

    Copy the generated OAuth token (it starts with "BQ...")

    +
  8. +
  9. +

    Paste the token in the form below and click "Authenticate"

    +
  10. +
+
+ + +
+

Enter Your Bearer Token

+
+ + +
+ + +

The token will expire after about 1 hour. You'll need to generate a new one after that.

+
+ +
+ +
+
+
+ + + {% if spotify_user %} +
+

Ready to go!

+

You're authenticated and can now use the direct Spotify client.

+ +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/audio_settings.html b/musicround/templates/users/audio_settings.html new file mode 100644 index 0000000..514a3b5 --- /dev/null +++ b/musicround/templates/users/audio_settings.html @@ -0,0 +1,461 @@ +{% extends 'base.html' %} + +{% block title %}Audio Settings - Quizzical Beats{% endblock %} + +{% block content %} +
+
+

Custom Audio Settings

+

Upload or generate your own intro, outro, and replay announcements for quizzes.

+
+ + {% for category, message in get_flashed_messages(with_categories=true) %} +
+ {{ message }} +
+ {% endfor %} + + +
+
+

Intro Audio

+

This audio plays at the beginning of your music quiz

+
+ +
+
+

Current Setting

+ {% if current_user.intro_mp3 %} +
+

Custom intro audio is active

+ + +
+ + + + +
+
+ {% else %} +
+

Using default intro audio

+ +
+ {% endif %} +
+ +
+

Customize

+ +
+

Option 1: Upload MP3

+
+ + + + +
+
+ +
+ +
+
+
+ +
+

Option 2: Generate with Text-to-Speech

+ {% if has_tts_services %} +
+ + + + +
+
+ + + + +

{{ selected_service.description if selected_service else '' }}

+
+ + +
+ {% if selected_service.id == 'openai' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.settings %} +
+
Voice Settings
+
+ + +
+ More variable + More stable +
+
+
+ + +
+ More unique + More similar +
+
+
+ {% endif %} +
+ + +
+
+ +
+
+ {% else %} +
+

Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.

+
+ {% endif %} +
+
+
+
+ + +
+
+

Outro Audio

+

This audio plays at the end of your music quiz

+
+ +
+
+

Current Setting

+ {% if current_user.outro_mp3 %} +
+

Custom outro audio is active

+ + +
+ + + + +
+
+ {% else %} +
+

Using default outro audio

+ +
+ {% endif %} +
+ +
+

Customize

+ +
+

Option 1: Upload MP3

+
+ + + + +
+
+ +
+ +
+
+
+ +
+

Option 2: Generate with Text-to-Speech

+ {% if has_tts_services %} +
+ + + + +
+
+ + + + +

{{ selected_service.description if selected_service else '' }}

+
+ + +
+ {% if selected_service.id == 'openai' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.settings %} +
+
Voice Settings
+
+ + +
+ More variable + More stable +
+
+
+ + +
+ More unique + More similar +
+
+
+ {% endif %} +
+ + +
+
+ +
+
+ {% else %} +
+

Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.

+
+ {% endif %} +
+
+
+
+ + +
+
+

Replay Audio

+

This audio plays before replaying all songs at the end of the quiz

+
+
+
+

Current Setting

+ {% if current_user.replay_mp3 %} +
+

Custom replay audio is active

+ +
+ + + + +
+
+ {% else %} +
+

Using default replay audio

+ +
+ {% endif %} +
+
+

Customize

+
+

Option 1: Upload MP3

+
+ + + +
+
+ +
+ +
+
+
+
+

Option 2: Generate with Text-to-Speech

+ {% if has_tts_services %} +
+ + + + +
+
+ + + + +

{{ selected_service.description if selected_service else '' }}

+
+ + +
+ {% if selected_service.id == 'openai' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.models %} +
+ + +
+ {% endif %} + {% if selected_service.id == 'elevenlabs' and selected_service.settings %} +
+
Voice Settings
+
+ + +
+ More variable + More stable +
+
+
+ + +
+ More unique + More similar +
+
+
+ {% endif %} +
+ + +
+
+ +
+
+ {% else %} +
+

Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.

+
+ {% endif %} +
+
+
+
+ + +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/change_password.html b/musicround/templates/users/change_password.html new file mode 100644 index 0000000..89fb500 --- /dev/null +++ b/musicround/templates/users/change_password.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Change Password{% endblock %} + +{% block content %} +
+

Change Password

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Cancel + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/edit_profile.html b/musicround/templates/users/edit_profile.html new file mode 100644 index 0000000..8a4015b --- /dev/null +++ b/musicround/templates/users/edit_profile.html @@ -0,0 +1,566 @@ +{% extends 'base.html' %} + +{% block title %}Edit Profile{% endblock %} + +{% block content %} +
+

Edit Profile

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+

Set the Dropbox folder where your exported rounds will be saved.

+ {% if not current_user.dropbox_token %} +

+ + Connect your Dropbox account to browse folders. +

+ {% endif %} + +
+ +
+ + + Cancel + +
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/forgot_password.html b/musicround/templates/users/forgot_password.html new file mode 100644 index 0000000..758aa57 --- /dev/null +++ b/musicround/templates/users/forgot_password.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Forgot Password - Quizzical Beats{% endblock %} + +{% block content %} +
+
+
+

+ Forgot Password +

+

+ Enter your email address and we'll send a reset link +

+
+ +
+ {% for category, message in get_flashed_messages(with_categories=true) %} +
+ {{ message }} +
+ {% endfor %} + +
+ + +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+

Need help? Contact support

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/login.html b/musicround/templates/users/login.html new file mode 100644 index 0000000..5e6191f --- /dev/null +++ b/musicround/templates/users/login.html @@ -0,0 +1,92 @@ +{% extends 'base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+

Login to Quizzical Beats

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Forgot Password? + +
+
+ + + {% if oauth_providers and (oauth_providers.google or oauth_providers.authentik) %} +
+
+ {% if oauth_providers.google %} + + + + + + + + Sign in with Google + + {% endif %} + + {% if oauth_providers.authentik %} + + Authentik logo + Sign in with Authentik + + {% endif %} +
+
+ {% endif %} + +
+

+ Don't have an account? + + Register + +

+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/profile.html b/musicround/templates/users/profile.html new file mode 100644 index 0000000..30b010b --- /dev/null +++ b/musicround/templates/users/profile.html @@ -0,0 +1,832 @@ +{% extends 'base.html' %} + +{% block title %}My Profile{% endblock %} + +{% block content %} +
+

My Profile

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+
+

Account Information

+
+
+

Username

+

{{ current_user.username }}

+
+
+

Email

+

{{ current_user.email }}

+
+
+

Name

+

+ {% if current_user.first_name or current_user.last_name %} + {{ current_user.first_name }} {{ current_user.last_name }} + {% else %} + Not provided + {% endif %} +

+
+
+

Member Since

+

{{ current_user.created_at.strftime('%B %d, %Y') }}

+
+ +
+

Admin Status

+

+ {% if current_user.is_admin() %} + Administrator + + Admin Dashboard + + {% else %} + Regular User + {% if not admin_exists %} + + Setup Admin + + {% endif %} + {% endif %} +

+
+
+

Dropbox Export Folder

+

+ {{ current_user.dropbox_export_path or '/QuizzicalBeats' }} +

+
+
+
+ +

Account Actions

+ + + + {% if current_user.is_admin() %} +
+
+

Spotify Connection Debug

+ +
+ +
+
+ + {% if spotify_status == 'user' %} + Using your Spotify account + {% elif spotify_status == 'system' %} + Using system Spotify account + {% elif spotify_status == 'bearer' %} + Using manual bearer token + {% else %} + No Spotify connection active + {% endif %} + +
+
+ + +
+
+

Dropbox Connection Debug

+ +
+ +
+
+ + {% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %} + Active Dropbox connection + {% elif current_user.dropbox_token %} + Dropbox token expired or expiring soon + {% else %} + No Dropbox connection active + {% endif %} + +
+
+ {% endif %} +
+ + +
+

Connected Services

+ + + {% if current_user.is_admin() %} +
+
+ +
+

Spotify

+

+ {% if current_user.spotify_token %} + Connected + {% if current_user.oauth_id %} + as {{ current_user.oauth_id }} + {% endif %} + {% else %} + Not connected + {% endif %} +

+
+
+ + + {% if current_user.spotify_token %} + Manage Connection + {% else %} + Connect Spotify + {% endif %} + +
+ {% endif %} + + +
+
+ +
+

Dropbox

+

+ {% if current_user.dropbox_id %} + Connected + {% if current_user.dropbox_id %} + as {{ current_user.dropbox_id[:10] }}... + {% endif %} + {% else %} + Not connected + {% endif %} +

+
+
+ + {% if current_user.dropbox_id %} +
+

+ {% if current_user.dropbox_token_expiry %} + Token expires: {{ current_user.dropbox_token_expiry.strftime('%Y-%m-%d %H:%M') }} + {% endif %} +

+
+ + +
+
+ {% else %} + + Connect Dropbox + + {% endif %} +
+ + +
+

Direct Spotify Access

+

+ Enter a Spotify bearer token for direct API access. + This bypasses OAuth and allows immediate access to Spotify features. +

+ +
+ +
+ +

Get a token from Spotify Developer Console

+
+ +
+ + {% if session.get('access_token') %} + + {% endif %} +
+
+
+ + +
+

Audio Settings

+

+ Customize your audio preferences for quizzes. +

+ + Manage Audio Settings + +
+ + +
+

Last Login

+

+ {% if current_user.last_login %} + {{ current_user.last_login.strftime('%d %b %Y, %H:%M') }} + {% else %} + No login history + {% endif %} +

+
+
+
+
+ + +{% if current_user.is_admin() %} + + + + +{% endif %} +{% endblock %} + +{% block scripts %} +{% if current_user.is_admin() %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/register.html b/musicround/templates/users/register.html new file mode 100644 index 0000000..18c9869 --- /dev/null +++ b/musicround/templates/users/register.html @@ -0,0 +1,71 @@ +{% extends 'base.html' %} + +{% block title %}Register{% endblock %} + +{% block content %} +
+

Create an Account

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +

Must be at least 8 characters long.

+
+ +
+ + +
+ +
+ +
+
+ +
+

+ Already have an account? + + Login + +

+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/reset_password.html b/musicround/templates/users/reset_password.html new file mode 100644 index 0000000..5d94ce5 --- /dev/null +++ b/musicround/templates/users/reset_password.html @@ -0,0 +1,67 @@ +{% extends 'base.html' %} + +{% block title %}Reset Password - Quizzical Beats{% endblock %} + +{% block content %} +
+
+
+

+ Reset Your Password +

+

+ Please enter your new password below +

+
+ +
+ {% for category, message in get_flashed_messages(with_categories=true) %} +
+ {{ message }} +
+ {% endfor %} + +
+ + +
+ +
+ +

Password must be at least 8 characters

+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+

Need a new reset link? Request password reset again

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/spotify_link.html b/musicround/templates/users/spotify_link.html new file mode 100644 index 0000000..2701ee1 --- /dev/null +++ b/musicround/templates/users/spotify_link.html @@ -0,0 +1,143 @@ +{% extends 'base.html' %} + +{% block title %}Connect Spotify - Quizzical Beats{% endblock %} + +{% block content %} +
+

Connect Spotify Account

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+

Spotify Connection Status

+ + {% if current_user.spotify_token %} +
+
+ +
+
+

Connected to Spotify

+

Your account is linked to Spotify

+ {% if current_user.oauth_id %} +

Spotify ID: {{ current_user.oauth_id }}

+ {% endif %} +
+
+ +
+

Token Information

+
+

+ Token Status: + + {% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %} + Valid + {% else %} + Expired + {% endif %} + +

+ {% if current_user.spotify_token_expiry %} +

+ Expires: + {{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }} +

+ {% endif %} +
+
+ +
+ + + +
+ + {% else %} +
+
+ +
+
+

Not Connected

+

Your account is not linked to Spotify

+
+
+ + + Connect with Spotify + + {% endif %} +
+ + +
+

Why Connect Spotify?

+ +
+

+ Connecting your Spotify account enhances your music quiz creation experience +

+
+ +
+
+
+ +
+
+

Access Your Playlists

+

Import songs from your personal Spotify playlists

+
+
+ +
+
+ +
+
+

Search the Spotify Catalog

+

Find and import any track from Spotify's huge library

+
+
+ +
+
+ +
+
+

Trending Playlists

+

Access Spotify's official and trending playlists

+
+
+
+ +
+

Privacy Note

+

+ We only access your Spotify data to help you create music quizzes. + We don't share your information or post to your account. +

+
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/musicround/templates/view_songs.html b/musicround/templates/view_songs.html new file mode 100644 index 0000000..c74c253 --- /dev/null +++ b/musicround/templates/view_songs.html @@ -0,0 +1,1478 @@ +{% extends 'base.html' %} + +{% block title %}Your Song Library - Quizzical Beats{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Your Song Library

+

Browse, search and manage all your imported songs to use in music quizzes.

+
+ + {% if songs %} +
+
+
+ +
+ + + + +
+
+ +
+ + +
+ +
+
+

Total songs in your collection

+

+ {{ songs|length }} / {{ songs|length }} +

+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + {% for song in songs %} + + + + + + + + + + + {% endfor %} + +
IDTitleArtist(s)GenreYearTimes UsedPreviewActions
{{ song.id }}{{ song.title }}{{ song.artist }} + {% if song.genre %} + {{ song.genre }} + {% else %} + Unknown + {% endif %} + {{ song.year }} + + {{ song.used_count }} + + + {% if song.preview_url %} + + {% else %} + No preview + {% endif %} + +
+ + +
+
+ +
+
+ + Page 1 of 1 + +
+
+ + +
+
+
+ +
+

Library Stats

+
+
+

Total Songs

+

{{ songs|length }}

+
+
+

Unique Artists

+

{{ unique_artists|default('N/A') }}

+
+
+

Genres

+

{{ unique_genres|default('N/A') }}

+
+
+

Most Used Songs

+

{{ most_used|default('0') }}

+
+
+ + +
+
+

Spotify Audio Features

+ +
+

+ Retrieve audio features (danceability, energy, tempo, etc.) for Spotify tracks in your library. +

+ +
+
+ {% else %} +
+
+ +
+

No songs found

+

Start by importing songs from Spotify or Deezer

+ +
+ {% endif %} +
+ + + + + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} + diff --git a/musicround/version.py b/musicround/version.py new file mode 100644 index 0000000..6dc893c --- /dev/null +++ b/musicround/version.py @@ -0,0 +1,27 @@ +""" +Version information for the Quizzical Beats application. +This module provides version details that can be displayed in both CLI and UI. +""" + +# Version information +VERSION_INFO = { + "version": "1.7.0", + "release_name": "Bulletproof Backups", + "release_date": "2025-05-07", + "build_number": "20250507001" +} + +def get_version_str(include_build=False): + """ + Return a formatted version string. + + Args: + include_build: Whether to include the build number + + Returns: + str: Formatted version string + """ + if include_build: + return f"v{VERSION_INFO['version']} ({VERSION_INFO['release_name']}) - Build {VERSION_INFO['build_number']}" + else: + return f"v{VERSION_INFO['version']} ({VERSION_INFO['release_name']})" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5de272 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +Flask +Flask-WTF +Flask-SQLAlchemy +flask_migrate +spotipy +requests +pydub +reportlab +PyJWT +python-dotenv +deezer-python +Flask-Assets +musicbrainzngs +openai +flask-admin +Flask-Login +Flask-Mail +Flask-Session +authlib +Flask-Caching +psutil \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..8a4a5e7 --- /dev/null +++ b/run.py @@ -0,0 +1,80 @@ +from dotenv import load_dotenv +from musicround import create_app +from musicround.version import get_version_str, VERSION_INFO +import os +import sys +import argparse + +# Load environment variables +load_dotenv() + +def main(): + # Create argument parser + parser = argparse.ArgumentParser(description='Quizzical Beats Management Script') + subparsers = parser.add_subparsers(dest='command', help='Command to run') + + # Create the backup subcommand + backup_parser = subparsers.add_parser('backup', help='Backup management') + backup_subparsers = backup_parser.add_subparsers(dest='backup_action', help='Backup action to perform') + + # Create backup action + create_parser = backup_subparsers.add_parser('create', help='Create a new backup') + create_parser.add_argument('--auto', action='store_true', help='Create backup with automatic name') + + # Retention policy action + retention_parser = backup_subparsers.add_parser('retention', help='Apply backup retention policy') + retention_parser.add_argument('--days', type=int, default=30, help='Number of days to keep backups') + + # Parse the arguments + args = parser.parse_args() + + # Run the command + if args.command == 'backup': + app = create_app() + with app.app_context(): + if args.backup_action == 'create': + from musicround.helpers.backup_helper import create_backup + result = create_backup(backup_name=None if args.auto else f"manual_{VERSION_INFO['version']}") + if result["status"] == "success": + print(f"Backup created successfully: {result['path']}") + return 0 + else: + print(f"Backup failed: {result['message']}") + return 1 + elif args.backup_action == 'retention': + from musicround.helpers.backup_helper import apply_retention_policy + result = apply_retention_policy(retention_days=args.days) + if result["status"] == "success": + print(f"Retention policy applied: {result['message']}") + return 0 + else: + print(f"Retention policy failed: {result['message']}") + return 1 + else: + # Default: Run the Flask app + app = create_app() + # When running in Docker, we need to listen on 0.0.0.0 + host = os.environ.get('FLASK_HOST', '0.0.0.0') + port = int(os.environ.get('FLASK_PORT', 5000)) + + # Enable debug mode in development + debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' + + # Display app version and release info + version = get_version_str() + print(f"Quizzical Beats {version}") + print(f"Release Date: {VERSION_INFO['release_date']}") + print("-" * 40) + print(f"Starting Flask app on {host}:{port} (debug={debug})") + app.run(host=host, port=port, debug=debug) + + return 0 + +if __name__ == '__main__': + # Display app version and release info + version = get_version_str() + print(f"Quizzical Beats {version}") + + # Run the main function + exit_code = main() + sys.exit(exit_code) diff --git a/run_migration.py b/run_migration.py new file mode 100644 index 0000000..f73b7b7 --- /dev/null +++ b/run_migration.py @@ -0,0 +1,44 @@ +""" +Manually run the OAuth providers migration script +""" +import os +import sys +from flask import Flask +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Add the parent directory to the path so we can import modules +sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) + +def run_oauth_migration(): + """Run the migration to add OAuth provider columns""" + try: + # Create a minimal Flask app with database connection + from musicround import db, create_app + app = create_app() + + with app.app_context(): + logger.info("Starting OAuth providers migration...") + + # Import and run the migration script + from migrations.add_oauth_providers import run_migration + success = run_migration() + + if success: + logger.info("Migration completed successfully!") + return True + else: + # This is the key change: don't treat "no changes" as a failure + logger.info("Migration reported no changes needed - continuing anyway") + return True # Return True even when no changes were made + + except Exception as e: + logger.error(f"Error running migration: {str(e)}") + return False + +if __name__ == "__main__": + success = run_oauth_migration() + sys.exit(0 if success else 1) # Exit with 0 (success) or 1 (error) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3848b1c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# This file makes the tests directory a proper Python package diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..7850d2d --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,355 @@ +import unittest +import sys +import os +import logging +import dotenv +from unittest.mock import patch, MagicMock + +# Add the project root to Python path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Create the helpers directory if it doesn't exist +helpers_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'musicround', 'helpers') +if not os.path.exists(helpers_dir): + os.makedirs(helpers_dir) + +# Create __init__.py if it doesn't exist +helpers_init = os.path.join(helpers_dir, '__init__.py') +if not os.path.exists(helpers_init): + with open(helpers_init, 'w') as f: + f.write('# This file makes the helpers directory a proper Python package\n') + +from musicround.helpers.metadata import ( + get_song_metadata_by_isrc, + get_musicbrainz_data, + get_spotify_data, + get_deezer_data, + get_lastfm_data, + get_openai_data, + get_acrcloud_data +) + +class TestMetadataHelper(unittest.TestCase): + """Tests for the metadata helper functions.""" + + def setUp(self): + """Set up common test resources.""" + # Create a mock Flask app with logger + self.mock_app = MagicMock() + self.mock_app.logger = logging.getLogger("test_logger") + self.mock_app.config = {} + + # Test ISRC for AC/DC's Highway to Hell + self.test_isrc = "AUAP07900028" + + @patch('musicround.helpers.metadata.get_musicbrainz_data') + @patch('musicround.helpers.metadata.get_spotify_data') + @patch('musicround.helpers.metadata.get_deezer_data') + @patch('musicround.helpers.metadata.get_lastfm_data') + @patch('musicround.helpers.metadata.get_openai_data') + @patch('musicround.helpers.metadata.get_acrcloud_data') # Add the new mock for ACRCloud + def test_acdc_highway_to_hell(self, mock_acrcloud, mock_openai, mock_lastfm, mock_deezer, + mock_spotify, mock_musicbrainz): + """Test that AC/DC's Highway to Hell returns correct metadata.""" + # Set up mock return values + mock_musicbrainz.return_value = { + "title": "Highway to Hell", + "artist_name": "AC/DC", + "year": "1979", + "genre": "Hard Rock" + } + + mock_spotify.return_value = { + "title": "Highway to Hell", + "artist_name": "AC/DC", + "year": "1979", + "genre": "Hard Rock", + "popularity": 82 + } + + mock_deezer.return_value = { + "title": "Highway to Hell", + "artist_name": "AC/DC", + "year": "1979", + "preview_url": "https://cdns-preview-8.dzcdn.net/stream/c-8f5a9d479bc54be17e2754cb9653e60b-6.mp3" + } + + mock_lastfm.return_value = { + "genre": "Classic Rock" + } + + mock_openai.return_value = { + "genre": "Rock", + "year": "1979" + } + + # Add ACRCloud mock data + mock_acrcloud.return_value = { + "title": "Highway to Hell", + "artist_name": "AC/DC", + "year": "1979", + "genre": ["Rock", "Hard Rock"], + "spotify_id": "2zYzyRzz6pRmhPzyfMEC8s", + "deezer_id": "89077521", + "preview_url": "https://audio-ssl.spotify.com/preview/track1234.mp3" + } + + # Call function with test ISRC + metadata = get_song_metadata_by_isrc(self.test_isrc, self.mock_app) + + # Verify mock functions were called correctly + mock_acrcloud.assert_called_once_with(self.test_isrc, self.mock_app) + mock_musicbrainz.assert_called_once_with(self.test_isrc, self.mock_app.logger) + mock_spotify.assert_called_once_with(self.test_isrc, self.mock_app) + mock_deezer.assert_called_once_with(self.test_isrc, self.mock_app) + + # LastFM and OpenAI should be called with title and artist name + mock_lastfm.assert_called_once_with("AC/DC", "Highway to Hell", self.mock_app) + mock_openai.assert_called_once_with("AC/DC", "Highway to Hell", self.mock_app) + + # Assert the expected metadata values + self.assertEqual(metadata["title"], "Highway to Hell") + self.assertEqual(metadata["artist_name"], "AC/DC") + self.assertEqual(metadata["year"], "1979") + + # Genre should be the most common one from all sources + self.assertEqual(metadata["genre"], "Hard Rock") # Hard Rock appears most frequently in our mocks + + self.assertEqual(metadata["popularity"], 82) + # Preview URL should be from Spotify since we prioritize it + self.assertEqual(metadata["preview_url"], + "https://audio-ssl.spotify.com/preview/track1234.mp3") + + # Check that ACRCloud platform IDs are present + self.assertEqual(metadata["spotify_id"], "2zYzyRzz6pRmhPzyfMEC8s") + self.assertEqual(metadata["deezer_id"], "89077521") + + # Check that all sources are included + expected_sources = ["acrcloud", "musicbrainz", "spotify", "deezer", "lastfm", "openai"] + self.assertCountEqual(metadata["sources"], expected_sources) + + @patch('musicround.helpers.metadata.requests.get') + @patch('musicround.helpers.metadata.musicbrainzngs.search_recordings') + def test_integration_with_real_data(self, mock_mb_search, mock_requests): + """ + Test with more realistic data structures that might be returned by APIs. + This is still a mock test but with more complex structures. + """ + # Set up MockResponse class for requests + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + # Mock MusicBrainz data + mock_mb_search.return_value = { + 'recording-list': [{ + 'title': 'Highway to Hell', + 'artist-credit': [ + {'artist': {'name': 'AC/DC'}} + ], + 'tag-list': [ + {'name': 'hard rock'}, + {'name': 'rock'}, + {'name': 'classic rock'} + ], + 'release-list': [ + {'date': '1979-07-27'} + ] + }] + } + + # Mock Last.fm response + mock_lastfm_response = { + 'track': { + 'name': 'Highway to Hell', + 'artist': {'name': 'AC/DC'}, + 'toptags': { + 'tag': [ + {'name': 'rock'}, + {'name': 'classic rock'}, + {'name': 'hard rock'} + ] + } + } + } + + # Set up the mock for requests.get to return the mock response + def mock_request_get(url, **kwargs): + if 'audioscrobbler.com' in url: + return MockResponse(mock_lastfm_response, 200) + return MockResponse({}, 404) + + mock_requests.side_effect = mock_request_get + + # Since we can't easily mock everything, we'll patch the other functions + with patch('musicround.helpers.metadata.get_spotify_data') as mock_spotify, \ + patch('musicround.helpers.metadata.get_deezer_data') as mock_deezer, \ + patch('musicround.helpers.metadata.get_openai_data') as mock_openai: + + mock_spotify.return_value = {} + mock_deezer.return_value = {} + mock_openai.return_value = {} + + # Only test MusicBrainz data in this test + metadata = get_song_metadata_by_isrc(self.test_isrc, self.mock_app) + + # Verify the results + self.assertEqual(metadata["title"], "Highway to Hell") + self.assertEqual(metadata["artist_name"], "AC/DC") + self.assertEqual(metadata["year"], "1979") + self.assertEqual(metadata["genre"], "hard rock") # First tag from tag-list + + def test_real_api_calls(self): + """ + Test actual API calls with AC/DC's Highway to Hell. + This test makes real API calls so it should be skipped by default. + Run this test manually when needed by removing the skip decorator. + """ + # Load environment variables from .env file + dotenv.load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')) + + # Initialize test app with valid API keys + real_app = MagicMock() + real_app.logger = logging.getLogger("real_api_test") + real_app.logger.setLevel(logging.INFO) + + # Add a console handler to see the logs + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + real_app.logger.addHandler(console_handler) + + # Set up config with actual API keys from .env file + real_app.config = { + 'LASTFM_API_KEY': os.environ.get('LASTFM_API_KEY'), + 'OPENAI_API_KEY': os.environ.get('OPENAI_API_KEY'), + 'OPENAI_MODEL': os.environ.get('OPENAI_MODEL', 'gpt-4o-mini'), + 'OPENAI_URL': os.environ.get('OPENAI_URL'), + 'ACRCLOUD_TOKEN': os.environ.get('ACRCLOUD_TOKEN'), # Add ACRCloud token + } + + # Setup Spotify client if credentials are available + if all([os.environ.get(key) for key in ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET']]): + import spotipy + from spotipy.oauth2 import SpotifyClientCredentials + + real_app.logger.info("Setting up Spotify client...") + spotify_client_credentials = SpotifyClientCredentials( + client_id=os.environ.get('SPOTIFY_CLIENT_ID'), + client_secret=os.environ.get('SPOTIFY_CLIENT_SECRET') + ) + real_app.config['sp'] = spotipy.Spotify(client_credentials_manager=spotify_client_credentials) + + # Setup Deezer client + from musicround.deezer_client import DeezerClient + real_app.config['deezer'] = DeezerClient() + + # Test MusicBrainz directly (doesn't need API keys) + real_app.logger.info("Testing MusicBrainz API...") + mb_data = get_musicbrainz_data(self.test_isrc, real_app.logger) + self.assertIsNotNone(mb_data) + if mb_data: + real_app.logger.info(f"MusicBrainz data: {mb_data}") + self.assertEqual(mb_data.get("title"), "Highway to Hell") + self.assertIn("AC/DC", mb_data.get("artist_name", "")) + + # Test Spotify if client is available + if real_app.config.get('sp'): + real_app.logger.info("Testing Spotify API...") + spotify_data = get_spotify_data(self.test_isrc, real_app) + real_app.logger.info(f"Spotify data: {spotify_data}") + self.assertIsNotNone(spotify_data) + if spotify_data: + self.assertEqual(spotify_data.get("title"), "Highway to Hell") + self.assertIn("AC/DC", spotify_data.get("artist_name", "")) + else: + real_app.logger.warning("Skipping Spotify test: credentials not available") + + # Test Deezer API + real_app.logger.info("Testing Deezer API...") + deezer_data = get_deezer_data(self.test_isrc, real_app) + real_app.logger.info(f"Deezer data: {deezer_data}") + if deezer_data: + self.assertIsNotNone(deezer_data) + if "title" in deezer_data: + self.assertEqual(deezer_data.get("title"), "Highway to Hell") + if "artist_name" in deezer_data: + self.assertIn("AC/DC", deezer_data.get("artist_name")) + + # Test Last.fm if API key is available + if real_app.config.get('LASTFM_API_KEY'): + real_app.logger.info("Testing Last.fm API...") + lastfm_data = get_lastfm_data("AC/DC", "Highway to Hell", real_app) + real_app.logger.info(f"Last.fm data: {lastfm_data}") + self.assertIsNotNone(lastfm_data) + if lastfm_data: + self.assertIn("genre", lastfm_data) + else: + real_app.logger.warning("Skipping Last.fm test: API key not available") + + # Test OpenAI if API key is available + if real_app.config.get('OPENAI_API_KEY'): + real_app.logger.info("Testing OpenAI API...") + openai_data = get_openai_data("AC/DC", "Highway to Hell", real_app) + real_app.logger.info(f"OpenAI data: {openai_data}") + self.assertIsNotNone(openai_data) + if openai_data: + self.assertIn("year", openai_data) + # Convert to string for comparison if it's an integer + expected_year = "1979" + actual_year = str(openai_data.get("year")) if openai_data.get("year") is not None else None + self.assertEqual(actual_year, expected_year) + else: + real_app.logger.warning("Skipping OpenAI test: API key not available") + + # Test ACRCloud if token is available + if real_app.config.get('ACRCLOUD_TOKEN'): + from musicround.helpers.metadata import get_acrcloud_data + real_app.logger.info("Testing ACRCloud API...") + acrcloud_data = get_acrcloud_data(self.test_isrc, real_app) + real_app.logger.info(f"ACRCloud data: {acrcloud_data}") + if acrcloud_data: + self.assertIsNotNone(acrcloud_data) + if "title" in acrcloud_data: + self.assertEqual(acrcloud_data.get("title"), "Highway to Hell") + if "artist_name" in acrcloud_data: + self.assertIn("AC/DC", acrcloud_data.get("artist_name")) + # Check if platform IDs were retrieved + if "spotify_id" in acrcloud_data: + self.assertIsNotNone(acrcloud_data.get("spotify_id")) + if "deezer_id" in acrcloud_data: + self.assertIsNotNone(acrcloud_data.get("deezer_id")) + else: + real_app.logger.warning("Skipping ACRCloud test: API token not available") + + # Full test with all APIs + real_app.logger.info("Testing full metadata aggregation...") + metadata = get_song_metadata_by_isrc(self.test_isrc, real_app) + real_app.logger.info(f"Full metadata: {metadata}") + + self.assertIsNotNone(metadata) + if metadata: + self.assertEqual(metadata.get("title"), "Highway to Hell") + self.assertIn("AC/DC", metadata.get("artist_name", "")) + self.assertEqual(metadata.get("year"), "1979") + self.assertIsNotNone(metadata.get("genre")) + self.assertIn("musicbrainz", metadata.get("sources", [])) + + # Check if we got data from other sources as expected + if real_app.config.get('ACRCLOUD_TOKEN'): + self.assertIn("acrcloud", metadata.get("sources", [])) + if real_app.config.get('sp'): + self.assertIn("spotify", metadata.get("sources", [])) + if real_app.config.get('LASTFM_API_KEY'): + self.assertIn("lastfm", metadata.get("sources", [])) + + # Only check for OpenAI if we have both key and we've verified it works + if real_app.config.get('OPENAI_API_KEY') and openai_data and ('genre' in openai_data or 'year' in openai_data): + self.assertIn("openai", metadata.get("sources", [])) + + +if __name__ == '__main__': + unittest.main()