Initial clean commit

This commit is contained in:
Christian Krakau-Louis
2025-05-13 08:59:02 +00:00
commit 03b982e7c2
113 changed files with 22973 additions and 0 deletions
+63
View File
@@ -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
+98
View File
@@ -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 <account>/<repo>
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}
+171
View File
@@ -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
+33
View File
@@ -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"]
+21
View File
@@ -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.
+157
View File
@@ -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)
+166
View File
@@ -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 23 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*
+59
View File
@@ -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
+27
View File
@@ -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
}
+72
View File
@@ -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
+106
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+78
View File
@@ -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()
+185
View File
@@ -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()
+158
View File
@@ -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)
+126
View File
@@ -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()
+124
View File
@@ -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()
+116
View File
@@ -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()
+146
View File
@@ -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()
+367
View File
@@ -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
+75
View File
@@ -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")
+252
View File
@@ -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
+222
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# This file makes the helpers directory a proper Python package
+280
View File
@@ -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
+724
View File
@@ -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": []
}
+495
View File
@@ -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
+103
View File
@@ -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
+611
View File
@@ -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
+839
View File
@@ -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
+304
View File
@@ -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 []
+303
View File
@@ -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
+331
View File
@@ -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()}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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'))
+323
View File
@@ -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/<path:filepath>')
@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)
+190
View File
@@ -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
+223
View File
@@ -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=[])
+579
View File
@@ -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'))
+241
View File
@@ -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
)
+745
View File
@@ -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)
+111
View File
@@ -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'))
+21
View File
@@ -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')
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

@@ -0,0 +1,528 @@
{% extends 'base.html' %}
{% block title %}Backup Manager{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-navy-800">System Backup Manager</h2>
<div class="text-sm text-navy-600">{{ version_info.version }} - {{ version_info.release_name }}</div>
</div>
<!-- Status Card -->
<div class="mb-8 p-4 bg-gray-50 rounded-lg">
<div class="flex flex-wrap md:flex-nowrap gap-4">
<div class="flex-1 border rounded-lg p-4 bg-white">
<h3 class="text-lg font-semibold mb-2 text-navy-700">Backup Status</h3>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-gray-600">Total backups:</span>
<span class="font-medium">{{ backup_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Latest backup:</span>
<span class="font-medium">{{ latest_backup|default('None yet') }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Scheduled backups:</span>
<span class="font-medium">{{ 'Enabled' if schedule_enabled else 'Disabled' }}</span>
</div>
{% if schedule_enabled %}
<div class="flex justify-between">
<span class="text-gray-600">Next backup:</span>
<span class="font-medium">{{ next_backup|default('Not scheduled') }}</span>
</div>
{% endif %}
<div class="flex justify-between">
<span class="text-gray-600">Storage location:</span>
<span class="font-medium text-xs md:text-sm font-mono">{{ backup_location }}</span>
</div>
</div>
</div>
<div class="flex-1 border rounded-lg p-4 bg-white">
<h3 class="text-lg font-semibold mb-2 text-navy-700">Quick Actions</h3>
<div class="flex flex-col space-y-2">
<form method="POST" action="{{ url_for('users.create_backup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded flex items-center justify-center">
<i class="fas fa-save mr-2"></i> Create New Backup
</button>
</form>
<a href="#backup-list" class="w-full bg-navy-600 hover:bg-navy-700 text-white py-2 px-4 rounded flex items-center justify-center">
<i class="fas fa-list mr-2"></i> Manage Backups
</a>
<button type="button" id="schedule-btn" onclick="toggleSchedulerForm()" class="w-full bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded flex items-center justify-center">
<i class="fas fa-clock mr-2"></i> Schedule Backups
</button>
</div>
</div>
</div>
</div>
<!-- Backup Schedule Configuration -->
<div class="mb-8 p-4 border border-navy-200 rounded-lg {% if not show_schedule_form %}hidden{% endif %}" id="scheduler-form">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Backup Schedule Configuration</h3>
<form action="{{ url_for('users.schedule_backup') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block font-medium mb-1" for="schedule-time">Time of day (24-hour format)</label>
<input type="time" class="border rounded px-3 py-2 w-full" id="schedule-time" name="schedule_time" value="{{ schedule_time or '03:00' }}">
</div>
<div>
<label class="block font-medium mb-1" for="frequency">Frequency</label>
<select class="border rounded px-3 py-2 w-full" id="frequency" name="frequency">
<option value="hourly" {% if schedule_frequency == 'hourly' %}selected{% endif %}>Hourly</option>
<option value="daily" {% if schedule_frequency == 'daily' or not schedule_frequency %}selected{% endif %}>Daily</option>
<option value="weekly" {% if schedule_frequency == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="monthly" {% if schedule_frequency == 'monthly' %}selected{% endif %}>Monthly</option>
</select>
</div>
<div>
<label class="block font-medium mb-1" for="retention-days">Retention Policy (days)</label>
<input type="number" class="border rounded px-3 py-2 w-full" id="retention-days" name="retention_days" value="{{ retention_days }}" min="0" max="365" onchange="syncRetentionDays(this.value)">
<p class="text-sm text-gray-500 mt-1">Enter 0 to keep all backups indefinitely</p>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-4">
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
<i class="fas fa-save mr-2"></i> Save Schedule
</button>
<button type="button" onclick="toggleSchedulerForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancel
</button>
</div>
</form>
<!-- Configuration Suggestion Button (replacing Docker Compose Labels button) -->
<div class="mt-4 pt-4 border-t border-gray-200">
<button type="button" onclick="openConfigModal()" class="mt-2 bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600">
<i class="fas fa-file-code mr-2"></i> View Configuration Suggestion
</button>
<p class="text-sm text-gray-500 mt-1">
View suggested configuration for scheduled backups
</p>
</div>
</div>
<!-- Create Backup Form (Hidden by default) -->
<div id="create-backup-form" class="mb-8 p-4 border border-navy-200 rounded-lg {% if not show_create_form %}hidden{% endif %}">
<h3 class="text-lg font-semibold mb-4 text-navy-700">Create Custom Backup</h3>
<form method="POST" action="{{ url_for('users.create_backup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="block font-medium mb-1" for="backup_name">Backup Name (Optional)</label>
<input type="text" id="backup_name" name="backup_name"
class="border rounded px-3 py-2 w-full"
placeholder="e.g., pre_upgrade_backup">
<p class="text-sm text-gray-500 mt-1">Leave blank for automatic timestamp-based name</p>
</div>
<div class="flex items-center mb-4">
<input type="checkbox" id="include_mp3s" name="include_mp3s" value="true"
checked
class="w-4 h-4 text-teal-600 mr-2">
<label for="include_mp3s" class="font-medium">Include MP3 Files</label>
</div>
<div class="flex items-center mb-4">
<input type="checkbox" id="include_config" name="include_config" value="true"
checked
class="w-4 h-4 text-teal-600 mr-2">
<label for="include_config" class="font-medium">Include Configuration Files</label>
</div>
<div class="flex flex-wrap gap-2">
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
<i class="fas fa-save mr-2"></i> Create Backup
</button>
<button type="button" onclick="toggleCreateForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancel
</button>
</div>
</form>
</div>
<!-- Configuration Suggestion Modal (replacing Docker Compose Labels Modal) -->
<div id="config-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden overflow-y-auto p-4">
<div class="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto p-6">
<div class="flex justify-between items-center mb-4 sticky top-0 bg-white pb-2">
<h3 class="text-xl font-semibold text-navy-700">Backup Configuration Suggestion</h3>
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-4">
<p class="text-gray-600 mb-2">
Here's a suggested configuration for scheduled backups:
</p>
<div class="bg-gray-50 p-4 rounded-lg">
{% if config_suggestion %}
<div class="mb-4">
<h4 class="font-medium text-navy-700 mb-2">Docker Compose Labels</h4>
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.docker_compose_suggestion }}</pre>
</div>
<div class="mb-4">
<h4 class="font-medium text-navy-700 mb-2">Ofelia Configuration (Alternative)</h4>
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.ofelia_ini_suggestion }}</pre>
</div>
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4">
<h4 class="font-medium text-navy-700 mb-2">Instructions</h4>
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.instructions }}</pre>
</div>
{% else %}
<p class="text-gray-500">Configuration suggestion not available.</p>
{% endif %}
</div>
</div>
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">
This is a configuration suggestion only. You'll need to implement it manually based on your system setup.
</p>
</div>
</div>
</div>
<div class="flex justify-end">
<button onclick="copyConfigToClipboard()" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600 mr-2">
<i class="fas fa-copy mr-2"></i> Copy to Clipboard
</button>
<button onclick="closeConfigModal()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Close
</button>
</div>
</div>
</div>
<!-- Notification Modal -->
<div id="notification-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden">
<div class="bg-white rounded-lg max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold" id="notification-title">Notification</h3>
<button onclick="closeNotificationModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-4">
<p id="notification-message" class="text-gray-600"></p>
</div>
<div class="flex justify-end">
<button onclick="closeNotificationModal()" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
OK
</button>
</div>
</div>
</div>
<!-- Backup List -->
<div id="backup-list" class="mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-navy-700">Existing Backups</h3>
<!-- Upload Backup Button -->
<button type="button" onclick="toggleUploadForm()" class="bg-navy-600 text-white px-4 py-2 rounded hover:bg-navy-700">
<i class="fas fa-upload mr-2"></i> Upload Backup
</button>
</div>
<!-- Upload Backup Form (Hidden by default) -->
<div id="upload-backup-form" class="mb-4 p-4 border border-navy-200 rounded-lg hidden">
<h4 class="text-lg font-semibold mb-3 text-navy-700">Upload Backup File</h4>
<form method="POST" action="{{ url_for('users.upload_backup') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="block font-medium mb-1" for="backup_file">Select Backup ZIP File</label>
<input type="file" id="backup_file" name="backup_file"
accept=".zip"
class="border rounded px-3 py-2 w-full">
<p class="text-sm text-gray-500 mt-1">Only .zip backup files are supported</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
<i class="fas fa-upload mr-2"></i> Upload Backup
</button>
<button type="button" onclick="toggleUploadForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancel
</button>
</div>
</form>
</div>
<!-- Retention Policy (add after Upload Backup Form) -->
<div id="retention-policy-section" class="mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-navy-700">Backup Retention Policy</h3>
<!-- Toggle Retention Policy Form Button -->
<button type="button" onclick="toggleRetentionForm()" class="bg-navy-600 text-white px-4 py-2 rounded hover:bg-navy-700">
<i class="fas fa-calendar-alt mr-2"></i> Configure Retention
</button>
</div>
<!-- Current Retention Policy Status -->
<div class="mb-4 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full {% if retention_days > 0 %}bg-green-500{% else %}bg-gray-400{% endif %} mr-2"></div>
<h4 class="font-medium">Retention Policy Status</h4>
</div>
<p class="text-sm text-gray-600">
{% 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 %}
</p>
</div>
<!-- Retention Policy Form (Hidden by default) -->
<div id="retention-policy-form" class="mb-4 p-4 border border-navy-200 rounded-lg hidden">
<h4 class="text-lg font-semibold mb-3 text-navy-700">Configure Backup Retention</h4>
<form method="POST" action="{{ url_for('users.apply_retention_policy') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="block font-medium mb-1" for="retention_days_policy">Keep Backups For</label>
<div class="flex items-center">
<input type="number" id="retention_days_policy" name="retention_days"
min="0" max="365" value="{{ retention_days }}"
class="border rounded px-3 py-2 w-24 mr-2" onchange="syncRetentionDays(this.value)">
<span>days</span>
</div>
<p class="text-sm text-gray-500 mt-1">Enter 0 to keep all backups indefinitely</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
<i class="fas fa-save mr-2"></i> Save Policy
</button>
<button type="submit" name="apply_now" value="true" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
onclick="return confirm('Are you sure you want to apply the retention policy now? This will delete backups older than the specified period.')">
<i class="fas fa-trash-alt mr-2"></i> Apply Now
</button>
<button type="button" onclick="toggleRetentionForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
Cancel
</button>
</div>
</form>
</div>
</div>
{% if backups %}
<div class="overflow-x-auto border rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Backup Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for backup in backups %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ backup.backup_name }}</div>
<div class="text-xs text-gray-500">{{ backup.file_name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if backup.timestamp %}
{{ backup.timestamp|timestamp_to_datetime|format_datetime('%Y-%m-%d %H:%M') }}
{% else %}
Unknown
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
v{{ backup.version }}
{% if backup.release_name %}
<div class="text-xs text-gray-500">{{ backup.release_name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{{ (backup.file_size / 1024 / 1024)|round(2) }} MB
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex gap-2">
<a href="{{ url_for('users.download_backup', filename=backup.file_name) }}" class="text-teal-600 hover:text-teal-900" title="Download">
<i class="fas fa-download"></i>
</a>
<form method="POST" action="{{ url_for('users.verify_backup', filename=backup.file_name) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-blue-600 hover:text-blue-900" title="Verify">
<i class="fas fa-check-circle"></i>
</button>
</form>
<form method="POST" action="{{ url_for('users.restore_backup', filename=backup.file_name) }}" class="inline"
onsubmit="return confirm('Are you sure you want to restore this backup? This will overwrite current data.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-orange-600 hover:text-orange-900" title="Restore">
<i class="fas fa-undo"></i>
</button>
</form>
<form method="POST" action="{{ url_for('users.delete_backup', filename=backup.file_name) }}" class="inline"
onsubmit="return confirm('Are you sure you want to delete this backup?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-900" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-gray-50 p-4 rounded-lg text-center">
<p class="text-gray-500">No backups found.</p>
<button onclick="toggleCreateForm()" class="mt-2 text-teal-600 hover:text-teal-800">
<i class="fas fa-plus-circle mr-1"></i> Create your first backup
</button>
</div>
{% endif %}
</div>
<!-- System Health Status -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">System Health</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="border rounded-lg p-4 bg-white">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
<h4 class="font-medium">Database</h4>
</div>
<p class="text-sm text-gray-600">Database is operational and accessible.</p>
</div>
<div class="border rounded-lg p-4 bg-white">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
<h4 class="font-medium">File Storage</h4>
</div>
<p class="text-sm text-gray-600">File storage is available and writable.</p>
</div>
<div class="border rounded-lg p-4 bg-white">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
<h4 class="font-medium">Configuration</h4>
</div>
<p class="text-sm text-gray-600">System configuration is valid.</p>
</div>
</div>
</div>
<!-- Back to Admin -->
<div class="flex justify-between">
<a href="{{ url_for('users.system_settings') }}" class="bg-navy-600 text-white px-6 py-2 rounded hover:bg-navy-700">
<i class="fas fa-cog mr-2"></i> Back to System Settings
</a>
<a href="{{ url_for('core.index') }}" class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400">
Back to Home
</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleCreateForm() {
const form = document.getElementById('create-backup-form');
form.classList.toggle('hidden');
}
function toggleUploadForm() {
const form = document.getElementById('upload-backup-form');
form.classList.toggle('hidden');
}
function toggleRetentionForm() {
const form = document.getElementById('retention-policy-form');
form.classList.toggle('hidden');
}
function toggleSchedulerForm() {
const form = document.getElementById('scheduler-form');
form.classList.toggle('hidden');
}
function openConfigModal() {
const modal = document.getElementById('config-modal');
modal.classList.remove('hidden');
}
function closeConfigModal() {
const modal = document.getElementById('config-modal');
modal.classList.add('hidden');
}
function copyConfigToClipboard() {
const content = document.querySelector('#config-modal pre').textContent;
navigator.clipboard.writeText(content).then(() => {
showNotificationModal('Success', 'Configuration suggestion copied to clipboard!');
}).catch(err => {
console.error('Failed to copy: ', err);
showNotificationModal('Error', 'Failed to copy to clipboard. Please select and copy manually.');
});
}
function closeNotificationModal() {
const modal = document.getElementById('notification-modal');
modal.classList.add('hidden');
}
function showNotificationModal(title, message) {
const modal = document.getElementById('notification-modal');
document.getElementById('notification-title').textContent = title;
document.getElementById('notification-message').textContent = message;
modal.classList.remove('hidden');
}
// Function to sync retention days between both forms
function syncRetentionDays(value) {
// Update both retention days inputs with the same value
document.getElementById('retention-days').value = value;
document.getElementById('retention_days_policy').value = value;
}
// Show notification if passed from backend
document.addEventListener('DOMContentLoaded', function() {
{% if notification %}
showNotificationModal('Notification', '{{ notification.message }}');
{% endif %}
});
</script>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block title %}Spotify Token Wizard{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<div class="flex items-center mb-6">
<h2 class="text-2xl font-bold text-navy-800 flex-grow">Spotify Refresh Token Wizard</h2>
<img src="https://storage.googleapis.com/pr-newsroom-wp/1/2018/11/Spotify_Logo_RGB_Green.png" alt="Spotify Logo" class="h-8">
</div>
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-medium mb-2">What is this for?</h3>
<p class="mb-2">This wizard helps you generate a Spotify refresh token for the system account (fallback account). This token will be used when:</p>
<ul class="list-disc pl-6 mb-2">
<li>A user doesn't have their own Spotify account connected</li>
<li>The system needs to perform Spotify API operations in the background</li>
<li>For shared/global Spotify functionality</li>
</ul>
<p class="text-sm text-gray-600">The refresh token doesn't expire, making it ideal for long-term system use.</p>
</div>
{% if has_token %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center text-green-700 mb-2">
<i class="fas fa-check-circle mr-2"></i>
<span class="font-medium">Fallback token is configured</span>
</div>
<p>A Spotify refresh token is already configured for the system account. You can replace it if needed.</p>
</div>
{% endif %}
{% if has_credentials %}
<div class="mb-8 border-b pb-4">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Option 1: Automated Setup (Recommended)</h3>
<p class="mb-4">This will guide you through the Spotify OAuth flow to generate a refresh token automatically.</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="start_auth">
<button type="submit" class="bg-green-600 hover:bg-green-700 text-white py-2 px-6 rounded-md flex items-center">
<i class="fab fa-spotify mr-2"></i> Start Spotify Authorization
</button>
</form>
<div class="mt-3 text-sm text-gray-600">
<p>You'll be redirected to Spotify to authorize access, then brought back to this page when complete.</p>
</div>
</div>
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Option 2: Manual Entry</h3>
<p class="mb-4">If you already have a Spotify refresh token (obtained elsewhere), you can enter it directly:</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="manual_save">
<div class="mb-4">
<label for="refresh_token" class="block font-medium mb-1">Refresh Token</label>
<input type="password" id="refresh_token" name="refresh_token"
class="border rounded px-3 py-2 w-full" placeholder="Enter your Spotify refresh token">
</div>
<button type="submit" class="bg-navy-600 hover:bg-navy-700 text-white py-2 px-6 rounded-md">
Save Token
</button>
</form>
</div>
{% else %}
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center text-yellow-700 mb-2">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span class="font-medium">Spotify API credentials not configured</span>
</div>
<p class="mb-3">Your Spotify API credentials are missing or incomplete. The following values need to be set in your environment configuration:</p>
<ul class="list-disc pl-6">
<li>SPOTIFY_CLIENT_ID</li>
<li>SPOTIFY_CLIENT_SECRET</li>
<li>SPOTIFY_REDIRECT_URI</li>
</ul>
</div>
{% endif %}
<div class="mt-8 flex justify-between">
<a href="{{ url_for('users.system_settings') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-6 rounded-md">
Back to Settings
</a>
<a href="https://developer.spotify.com/documentation/web-api/concepts/access-token" target="_blank" class="text-navy-600 hover:underline flex items-center">
<span>Learn about Spotify authentication</span>
<i class="fas fa-external-link-alt ml-1"></i>
</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,219 @@
{% extends 'base.html' %}
{% block title %}System Health{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-navy-800">System Health Status</h2>
<div class="text-sm text-navy-600">{{ version_info.version }} - {{ version_info.release_name }}</div>
</div>
<!-- Health Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-{{ database_status.color }}-50 border border-{{ database_status.color }}-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-{{ database_status.color }}-500 mr-2"></div>
<h3 class="font-semibold text-{{ database_status.color }}-700">Database</h3>
</div>
<p class="text-sm text-{{ database_status.color }}-600">{{ database_status.message }}</p>
</div>
<div class="bg-{{ storage_status.color }}-50 border border-{{ storage_status.color }}-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-{{ storage_status.color }}-500 mr-2"></div>
<h3 class="font-semibold text-{{ storage_status.color }}-700">Storage</h3>
</div>
<p class="text-sm text-{{ storage_status.color }}-600">{{ storage_status.message }}</p>
</div>
<div class="bg-{{ api_status.color }}-50 border border-{{ api_status.color }}-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-{{ api_status.color }}-500 mr-2"></div>
<h3 class="font-semibold text-{{ api_status.color }}-700">API Services</h3>
</div>
<p class="text-sm text-{{ api_status.color }}-600">{{ api_status.message }}</p>
</div>
<div class="bg-{{ memory_status.color }}-50 border border-{{ memory_status.color }}-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<div class="h-4 w-4 rounded-full bg-{{ memory_status.color }}-500 mr-2"></div>
<h3 class="font-semibold text-{{ memory_status.color }}-700">Memory</h3>
</div>
<p class="text-sm text-{{ memory_status.color }}-600">{{ memory_status.message }}</p>
</div>
</div>
<!-- Database Details -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Database Information</h3>
<div class="overflow-x-auto border rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Metric</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
Total Songs
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ database_stats.song_count|default('0') }}
</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
Total Rounds
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ database_stats.round_count|default('0') }}
</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
Total Users
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ database_stats.user_count|default('0') }}
</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
Database File Size
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ database_stats.file_size|default('Unknown') }}
</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
Last Backup
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ database_stats.last_backup|default('Never') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Storage Details -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Storage Information</h3>
<div class="overflow-x-auto border rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Directory</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Files</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for dir in storage_stats %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ dir.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ dir.file_count }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ dir.size }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if dir.writable %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Writable
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Not Writable
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Service Status -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">External Services</h3>
<div class="overflow-x-auto border rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Service</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for service in service_stats %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ service.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if service.status == 'ok' %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Available
</span>
{% elif service.status == 'warning' %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
Warning
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Unavailable
</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ service.message }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Version Information -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Version Information</h3>
<div class="bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm"><strong>Application Version:</strong> {{ version_info.version }}</p>
<p class="text-sm"><strong>Release Name:</strong> {{ version_info.release_name }}</p>
<p class="text-sm"><strong>Release Date:</strong> {{ version_info.release_date }}</p>
</div>
<div>
<p class="text-sm"><strong>Python Version:</strong> {{ system_info.python_version }}</p>
<p class="text-sm"><strong>Platform:</strong> {{ system_info.platform }}</p>
<p class="text-sm"><strong>Flask Version:</strong> {{ system_info.flask_version }}</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between">
<a href="{{ url_for('users.backup_manager') }}" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">
<i class="fas fa-database mr-2"></i> Go to Backup Manager
</a>
<a href="{{ url_for('users.system_settings') }}" class="bg-navy-600 text-white px-6 py-2 rounded hover:bg-navy-700">
<i class="fas fa-cog mr-2"></i> Back to System Settings
</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,257 @@
{% extends 'base.html' %}
{% block title %}System Settings{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">System Settings</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- TTS Settings Section -->
<div class="mb-8 border-b pb-6">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Text-to-Speech Settings</h3>
<div class="mb-4">
<label class="block font-medium mb-1" for="default_tts_service">Default TTS Service</label>
<select name="default_tts_service" id="default_tts_service" class="border rounded px-3 py-2 w-full">
<option value="">(None)</option>
{% for service in tts_services %}
<option value="{{ service.id }}" {% if settings.get('default_tts_service') == service.id %}selected{% endif %}>
{{ service.name }}
</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">The default text-to-speech service to use for audio generation</p>
</div>
<div class="mb-4">
<label class="block font-medium mb-1" for="default_tts_voice">Default TTS Voice</label>
<select name="default_tts_voice" id="default_tts_voice" class="border rounded px-3 py-2 w-full">
<option value="">(None)</option>
{% for service in tts_services %}
<optgroup label="{{ service.name }}">
{% for voice in service.voices %}
<option value="{{ voice.id }}" {% if settings.get('default_tts_voice') == voice.id %}selected{% endif %}>
{{ voice.name }} {% if voice.gender or voice.language %}({{ voice.gender }}{% if voice.language and voice.gender %}, {{ voice.language }}{% elif voice.language %}{{ voice.language }}{% endif %}){% endif %}
</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Default voice ID for the selected TTS service</p>
</div>
<div class="mb-4">
<label class="block font-medium mb-1" for="default_tts_model">Default TTS Model</label>
<select name="default_tts_model" id="default_tts_model" class="border rounded px-3 py-2 w-full">
<option value="">(None)</option>
{% for service in tts_services %}
{% if service.models %}
<optgroup label="{{ service.name }} Models">
{% for model in service.models %}
<option value="{{ model.id }}" {% if settings.get('default_tts_model') == model.id %}selected{% endif %}>
{{ model.name }}
</option>
{% endfor %}
</optgroup>
{% endif %}
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Default model for OpenAI or ElevenLabs TTS</p>
</div>
</div>
<!-- Spotify Integration Settings -->
<div class="mb-8 border-b pb-6">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Spotify Integration</h3>
<div class="mb-4">
<label class="block font-medium mb-1" for="fallback_spotify_refresh_token">Fallback Spotify Refresh Token</label>
<div class="flex">
<input type="password" name="fallback_spotify_refresh_token" id="fallback_spotify_refresh_token"
class="border rounded-l px-3 py-2 flex-grow"
value="{{ settings.get('fallback_spotify_refresh_token', '') }}">
<button type="button" onclick="toggleTokenVisibility()"
class="bg-gray-200 px-3 py-2 rounded-r border-t border-r border-b">
<i class="fas fa-eye"></i>
</button>
</div>
<p class="text-sm text-gray-500 mt-1">Service account refresh token used when users don't have Spotify access</p>
<div class="mt-2">
<a href="{{ url_for('users.spotify_link') }}" class="bg-navy-600 text-white px-4 py-2 rounded inline-flex items-center">
<i class="fab fa-spotify mr-2"></i> Connect Spotify Account
</a>
</div>
</div>
<div class="mb-4">
<label class="block font-medium mb-1" for="spotify_region">Default Spotify Region</label>
<select name="spotify_region" id="spotify_region" class="border rounded px-3 py-2 w-full">
<option value="">(None)</option>
{% for region in spotify_regions %}
<option value="{{ region.code }}" {% if settings.get('spotify_region') == region.code %}selected{% endif %}>
{{ region.name }}
</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Default region for Spotify API searches and charts</p>
</div>
</div>
<!-- Authentication Settings Section -->
<div class="mb-8 border-b pb-6">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Authentication Settings</h3>
<div class="flex items-center mb-4">
<input type="checkbox" id="allow_signups" name="allow_signups" value="true"
{% if settings.get('allow_signups', 'true') == 'true' %}checked{% endif %}
class="w-4 h-4 text-blue-600 mr-2">
<label for="allow_signups" class="font-medium">Allow New User Registrations</label>
</div>
<p class="text-sm text-gray-600 mb-4">
When disabled, new users cannot create accounts. Existing users can still log in, and OAuth login will still work for existing accounts.
</p>
</div>
<!-- Backup & System Health Section -->
<div class="mb-8 border-b pb-6">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Backup & System Health</h3>
<div class="flex flex-col md:flex-row gap-4">
<a href="{{ url_for('users.backup_manager') }}" class="flex-1 bg-teal-500 hover:bg-teal-600 text-white py-3 px-4 rounded flex items-center justify-center">
<i class="fas fa-database mr-2"></i> Backup Manager
</a>
<a href="{{ url_for('users.system_health') }}" class="flex-1 bg-navy-600 hover:bg-navy-700 text-white py-3 px-4 rounded flex items-center justify-center">
<i class="fas fa-heartbeat mr-2"></i> System Health
</a>
</div>
<p class="text-sm text-gray-600 mt-2">
Manage system backups, restore from previous backups, and monitor system health.
</p>
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-md p-3">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">
Regular backups are recommended to prevent data loss. The backup system will save your database, MP3 files, and system configuration.
</p>
</div>
</div>
</div>
</div>
<!-- Additional Settings -->
<div class="mb-4">
<h3 class="text-xl font-semibold mb-4 text-navy-700">Additional Settings</h3>
{% 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'] %}
<div class="mb-4">
<label class="block font-medium mb-1" for="{{ key }}">{{ label }}</label>
{% if key == 'enable_public_rounds' %}
<select name="{{ key }}" id="{{ key }}" class="border rounded px-3 py-2 w-full">
<option value="true" {% if settings.get(key) == 'true' %}selected{% endif %}>Enabled</option>
<option value="false" {% if settings.get(key) != 'true' %}selected{% endif %}>Disabled</option>
</select>
{% else %}
<input type="text" name="{{ key }}" id="{{ key }}" class="border rounded px-3 py-2 w-full"
value="{{ settings.get(key, '') }}">
{% endif %}
<p class="text-sm text-gray-500 mt-1">
{% 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 %}
</p>
</div>
{% endif %}
{% endfor %}
</div>
<div class="flex justify-between">
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">
<i class="fas fa-save mr-2"></i> Save Settings
</button>
<a href="{{ url_for('core.index') }}" class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400">Cancel</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleTokenVisibility() {
const tokenField = document.getElementById('fallback_spotify_refresh_token');
if (tokenField.type === 'password') {
tokenField.type = 'text';
} else {
tokenField.type = 'password';
}
}
// Dynamic voice selection based on service
document.getElementById('default_tts_service').addEventListener('change', function() {
const selectedService = this.value;
const voiceSelect = document.getElementById('default_tts_voice');
const modelSelect = document.getElementById('default_tts_model');
// Hide all options first
Array.from(voiceSelect.options).forEach(option => {
option.style.display = 'none';
});
Array.from(modelSelect.options).forEach(option => {
option.style.display = 'none';
});
// Show only options for the selected service
Array.from(voiceSelect.options).forEach(option => {
if (option.value === '' || option.parentNode.label.includes(selectedService)) {
option.style.display = '';
}
});
Array.from(modelSelect.options).forEach(option => {
if (option.value === '' || option.parentNode.label.includes(selectedService)) {
option.style.display = '';
}
});
// Reset to empty if current selection is not valid for the service
let validVoice = false;
Array.from(voiceSelect.options).forEach(option => {
if (option.selected && option.style.display !== 'none') {
validVoice = true;
}
});
if (!validVoice) {
voiceSelect.value = '';
}
let validModel = false;
Array.from(modelSelect.options).forEach(option => {
if (option.selected && option.style.display !== 'none') {
validModel = true;
}
});
if (!validModel) {
modelSelect.value = '';
}
});
// Trigger the change event on page load to set up initial state
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('default_tts_service').dispatchEvent(new Event('change'));
});
</script>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Quizzical Beats{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/light/logo.png') }}">
<link rel="shortcut icon" type="image/png" href="{{ url_for('static', filename='img/light/logo.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/light/logo.png') }}">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom Tailwind Configuration -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'navy': {
50: '#e7e8f4',
100: '#c3c5e3',
200: '#9b9fd1',
300: '#7379bf',
400: '#555cb2',
500: '#3640a5',
600: '#2e379e',
700: '#242c8f',
800: '#1A237E', // Deep Navy Blue (primary)
900: '#0b0e59',
},
'teal': {
500: '#00ACC1', // Vibrant Teal (accent)
600: '#0097a7',
700: '#00838f',
},
'orange': {
500: '#FF7043', // Bright Orange (CTA)
600: '#f4511e',
700: '#e64a19',
},
'gray': {
100: '#F5F5F5', // Light Gray (background)
800: '#212121', // Dark Gray (text)
},
},
fontFamily: {
'montserrat': ['Montserrat', 'sans-serif'],
'opensans': ['Open Sans', 'sans-serif'],
},
}
}
}
</script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<!-- Custom styles -->
<style type="text/tailwindcss">
@layer base {
html {
font-family: 'Open Sans', sans-serif;
}
}
@layer utilities {
.content-auto {
content-visibility: auto;
}
/* Form styling to replicate Tailwind Forms plugin behavior */
[type='text'], [type='email'], [type='url'], [type='password'],
[type='number'], [type='date'], [type='datetime-local'],
[type='month'], [type='search'], [type='tel'],
[type='time'], [type='week'], [multiple], textarea, select {
@apply w-full rounded-md border-gray-300 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:ring-opacity-50;
}
[type='checkbox'], [type='radio'] {
@apply rounded border-gray-300 text-teal-600 focus:ring-teal-500;
}
}
</style>
{% block head %}{% endblock %}
</head>
<body class="flex flex-col min-h-screen bg-gray-100 font-opensans text-gray-800">
<header>
<nav class="bg-navy-800 text-white shadow-md">
<div class="container mx-auto px-4 py-3">
<div class="flex justify-between items-center">
<a class="flex items-center text-xl font-bold" href="{{ url_for('core.index') }}">
<img src="{{ url_for('static', filename='img/dark/logo.png') }}" alt="Quizzical Beats" class="h-8 mr-2">
<span class="hidden sm:inline">Quizzical Beats</span>
</a>
<button id="menu-toggle" class="md:hidden focus:outline-none">
<i class="fas fa-bars"></i>
</button>
<div id="navbar-menu" class="hidden md:flex flex-grow items-center">
<ul class="flex flex-col md:flex-row space-y-2 md:space-y-0 md:ml-8 md:space-x-6 mt-4 md:mt-0">
{% if current_user.is_authenticated %}
<li class="group relative">
<button class="peer flex items-center text-white hover:text-teal-500">
<i class="fab fa-spotify mr-2"></i>Import Spotify <i class="fas fa-chevron-down ml-1"></i>
</button>
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_song') }}">Song</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_playlist') }}">Playlist</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_album') }}">Album</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('core.search') }}">Search Spotify</a></li>
<li class="border-t border-gray-200 mt-1 pt-1">
<a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import.import_official_playlists') }}">
<span class="flex items-center text-teal-600">
<i class="fab fa-spotify mr-2"></i> Official Playlists
</span>
</a>
</li>
</ul>
</li>
<li class="group relative">
<button class="peer flex items-center text-white hover:text-teal-500">
<i class="fab fa-deezer mr-2"></i>Import Deezer <i class="fas fa-chevron-down ml-1"></i>
</button>
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_track') }}">Song</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_playlist') }}">Playlist</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_album') }}">Album</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.deezer_search') }}">Search Deezer</a></li>
<li class="border-t border-gray-200 mt-1 pt-1">
<a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.browse_deezer_playlists') }}">
<span class="flex items-center" style="color: #00ACC1;">
<i class="fab fa-deezer mr-2"></i> Official Playlists
</span>
</a>
</li>
</ul>
</li>
<li>
<a class="text-white hover:text-teal-500" href="{{ url_for('core.view_songs') }}">View Songs</a>
</li>
<li>
<a class="text-white hover:text-teal-500" href="{{ url_for('generate.build_music_round') }}">Build Round</a>
</li>
<li>
<a class="text-white hover:text-teal-500" href="{{ url_for('rounds.rounds_list') }}">View Rounds</a>
</li>
{% if current_user.is_admin() %}
<li class="group relative">
<button class="peer flex items-center text-white hover:text-teal-500">
<i class="fas fa-shield-alt mr-2"></i>Admin <i class="fas fa-chevron-down ml-1"></i>
</button>
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('admin.index') }}">
<span class="flex items-center">
<i class="fas fa-database mr-2"></i> Data Manager
</span>
</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_settings') }}">
<span class="flex items-center">
<i class="fas fa-cogs mr-2"></i> System Settings
</span>
</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.backup_manager') }}">
<span class="flex items-center">
<i class="fas fa-download mr-2"></i> Backup Manager
</span>
</a></li>
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
<span class="flex items-center">
<i class="fas fa-heartbeat mr-2"></i> System Health
</span>
</a></li>
</ul>
</li>
{% endif %}
{% endif %}
</ul>
<div class="ml-auto">
{% if current_user.is_authenticated %}
<div class="flex items-center space-x-4">
<a href="{{ url_for('users.profile') }}" class="text-white hover:text-teal-500 flex items-center">
<i class="fas fa-user-circle mr-1"></i> {{ current_user.username }}
</a>
<a href="{{ url_for('users.logout') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-1 px-3 rounded">
Logout
</a>
</div>
{% else %}
<div class="flex items-center space-x-4">
<a href="{{ url_for('users.login') }}" class="text-white hover:text-teal-500">
Login
</a>
<a href="{{ url_for('users.register') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-1 px-3 rounded">
Register
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</nav>
</header>
<main class="flex-grow container mx-auto px-4 py-6">
{% block content %}
{% endblock %}
</main>
<footer class="bg-navy-800 text-white py-4 mt-auto">
<div class="container mx-auto px-4 text-center">
<p>© 2025 Quizzical Beats | <span class="text-teal-500">{{ get_version_str() }}</span></p>
</div>
</footer>
<!-- Mobile menu script -->
<script>
document.getElementById('menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('navbar-menu');
menu.classList.toggle('hidden');
});
</script>
{% block scripts %}
{% endblock %}
</body>
</html>
@@ -0,0 +1,278 @@
{% extends 'base.html' %}
{% block title %}Official Deezer Playlists{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Official Deezer Playlists</h2>
<p class="text-gray-600 mb-6">Browse and import popular playlists from Deezer.</p>
<!-- Filtering -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<form method="GET" action="{{ url_for('deezer.browse_deezer_playlists') }}" class="space-y-4">
<div>
<label for="filter" class="block text-sm font-medium text-gray-700 mb-1">Filter by Keywords</label>
<input type="text" name="filter" id="filter" value="{{ filter_keywords|join(',') if filter_keywords else '' }}"
placeholder="top,hits,pop,rock,etc"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<p class="text-xs text-gray-500 mt-1">Comma-separated keywords to search in playlist names</p>
</div>
<div class="flex items-center justify-end">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
Apply Filter
</button>
</div>
</form>
</div>
<!-- Playlist grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for playlist in playlists %}
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200">
{% if playlist.picture_xl %}
<img src="{{ playlist.picture_xl }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.title }}">
{% else %}
<div class="w-full h-48 bg-gray-200 flex items-center justify-center">
<span class="text-gray-500">No image</span>
</div>
{% endif %}
<div class="p-6">
<div class="flex justify-between items-start">
<h5 class="text-xl font-semibold mb-2 text-navy-800 font-montserrat playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.title }}</h5>
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">Deezer</span>
</div>
{% if playlist.description %}
<p class="text-gray-700 mb-3 text-sm">{{ playlist.description|truncate(100) }}</p>
{% endif %}
<p class="text-gray-600 mb-4">Tracks: {{ playlist.nb_tracks }}</p>
<div class="flex justify-between items-center">
<form method="POST" action="{{ url_for('deezer.import_deezer_playlist') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="playlist_id" value="{{ playlist.id }}">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
<i class="fas fa-info-circle mr-1"></i> Details
</button>
</div>
</div>
</div>
{% else %}
<div class="col-span-3 p-8 text-center bg-gray-50 rounded-lg border border-gray-200">
<p class="text-gray-600">No playlists found matching your criteria. Try changing your filters.</p>
</div>
{% endfor %}
</div>
<div class="mt-8">
<a href="{{ url_for('deezer.deezer_search') }}" class="inline-flex items-center bg-gray-200 hover:bg-gray-300 text-gray-700 py-2 px-4 rounded transition-colors">
<i class="fas fa-arrow-left mr-2"></i> Back to Search
</a>
</div>
</div>
<!-- Playlist Details Modal -->
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Playlist Details</h3>
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times fa-lg"></i>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
<!-- Content will be loaded here -->
<div class="w-full md:w-1/3 flex flex-col items-center">
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
</div>
<div id="modal-metadata" class="w-full text-center mb-4">
<!-- Metadata will be loaded here -->
</div>
</div>
<div class="w-full md:w-2/3">
<p class="text-gray-600 mb-4" id="modal-description"></p>
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
<i class="fas fa-music mr-2"></i> Tracks
</h4>
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
</tr>
</thead>
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
<!-- Tracks will be loaded here -->
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 flex justify-end">
<form action="{{ url_for('deezer.import_deezer_playlist') }}" method="POST" id="modal-import-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="playlist_id" id="modal-item-id" value="">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('details-modal');
const closeModal = document.getElementById('close-modal');
const modalTitle = document.getElementById('modal-title');
const modalDescription = document.getElementById('modal-description');
const modalImage = document.getElementById('modal-image');
const modalMetadata = document.getElementById('modal-metadata');
const modalTracks = document.getElementById('modal-tracks');
const modalImportForm = document.getElementById('modal-import-form');
const modalItemId = document.getElementById('modal-item-id');
// Close modal
closeModal.addEventListener('click', () => {
modal.classList.add('hidden');
});
// Close modal when clicking outside
window.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
}
});
// Format duration from milliseconds to mm:ss
function formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Playlist detail functionality
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const playlistId = e.target.getAttribute('data-id') || e.target.closest('.playlist-detail-trigger').getAttribute('data-id');
// Reset modal content
modalTitle.textContent = "Loading playlist details...";
modalDescription.textContent = "";
modalImage.src = "";
modalMetadata.innerHTML = "";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
`;
// Set up import form
modalItemId.value = playlistId;
// Show modal
modal.classList.remove('hidden');
// Fetch playlist details
fetch(`/api/deezer/playlist/${playlistId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Update modal with playlist details
modalTitle.textContent = data.name;
if (data.description) {
modalDescription.textContent = data.description;
}
if (data.image_url) {
modalImage.src = data.image_url;
modalImage.alt = data.name;
}
// Add metadata
modalMetadata.innerHTML = `
<p class="font-semibold text-navy-800">By ${data.owner}</p>
<p class="text-gray-600">${data.tracks.length} tracks</p>
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
`;
// Add tracks
if (data.tracks && data.tracks.length > 0) {
modalTracks.innerHTML = '';
data.tracks.forEach((track, index) => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
row.innerHTML = `
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
<td class="px-4 py-2">${track.name}</td>
<td class="px-4 py-2 text-sm">${track.artist}</td>
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
`;
modalTracks.appendChild(row);
});
} else {
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
No tracks available
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error fetching playlist details:', error);
modalTitle.textContent = "Error Loading Details";
modalDescription.textContent = "There was a problem loading the playlist details.";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-red-500">
Failed to load tracks. Please try again later.
</td>
</tr>
`;
});
});
});
});
</script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends 'base.html' %}
{% block title %}Build Music Quiz - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-navy-800 font-montserrat mb-3">Create Your Music Quiz</h1>
<p class="text-lg text-gray-600 max-w-2xl mx-auto">Choose your preferred method to generate the perfect music round for your trivia night.</p>
</div>
<form method="POST" action="{{ url_for('generate.build_music_round') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6 bg-white shadow-md rounded-lg p-6">
<h3 class="text-xl font-semibold text-navy-800 font-montserrat mb-4">Round Details</h3>
<div class="mb-4">
<label for="round_name" class="block text-gray-700 text-sm font-bold mb-2">Round Name (Optional)</label>
<input type="text" name="round_name" id="round_name" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter a name for this round">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<!-- Random Selection Card -->
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
<div class="h-24 bg-gradient-to-r from-navy-700 to-navy-900 flex items-center justify-center text-white">
<i class="fas fa-random text-4xl"></i>
</div>
<div class="p-6">
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">Random Selection</h5>
<p class="text-gray-600 mb-5 h-20">Create a music quiz with randomly selected songs from different artists and decades for a diverse challenge.</p>
<button type="submit" name="round_type" value="Random" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
<i class="fas fa-dice mr-2"></i> Generate Random Round
</button>
</div>
</div>
<!-- Decade Card -->
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
<div class="h-24 bg-gradient-to-r from-teal-500 to-teal-700 flex items-center justify-center text-white">
<i class="fas fa-calendar-alt text-4xl"></i>
</div>
<div class="p-6">
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Decade</h5>
<p class="text-gray-600 mb-5 h-20">Create a themed music quiz with songs from a specific decade that has been used the least in your quizzes.</p>
<button type="submit" name="round_type" value="Decade" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
<i class="fas fa-hourglass-half mr-2"></i> Generate Decade Round
</button>
</div>
</div>
<!-- Genre Card -->
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
<div class="h-24 bg-gradient-to-r from-orange-500 to-orange-700 flex items-center justify-center text-white">
<i class="fas fa-guitar text-4xl"></i>
</div>
<div class="p-6">
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Genre</h5>
<p class="text-gray-600 mb-5 h-20">Create a themed music quiz with songs from a specific genre that has been used the least in your quizzes.</p>
<button type="submit" name="round_type" value="Genre" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
<i class="fas fa-music mr-2"></i> Generate Genre Round
</button>
</div>
</div>
<!-- Tag Card -->
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
<div class="h-24 bg-gradient-to-r from-purple-500 to-purple-700 flex items-center justify-center text-white">
<i class="fas fa-tags text-4xl"></i>
</div>
<div class="p-6">
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Tag</h5>
<p class="text-gray-600 mb-3 h-20">Create a music quiz with songs that share a specific tag from your collection.</p>
<select name="tag_name" class="shadow border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline">
<option value="">Select a Tag</option>
{% for tag in tags %}
<option value="{{ tag }}">{{ tag }}</option>
{% endfor %}
</select>
<button type="submit" name="round_type" value="Tag" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
<i class="fas fa-tag mr-2"></i> Generate Tag Round
</button>
</div>
</div>
</div>
</form>
<div class="mt-10 flex flex-col md:flex-row gap-6">
<div class="flex-1 bg-navy-50 rounded-lg p-6 border border-navy-100 text-center">
<h3 class="text-xl font-semibold text-navy-800 mb-2 font-montserrat">Need more songs?</h3>
<p class="text-gray-600 mb-4">Import more songs from Spotify or Deezer to create even better music quizzes.</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="{{ url_for('core.search') }}" class="bg-[#1DB954] hover:bg-[#1AA346] text-white py-2 px-4 rounded-md transition-colors inline-flex items-center">
<i class="fab fa-spotify mr-2"></i> Import from Spotify
</a>
<a href="{{ url_for('deezer.deezer_search') }}" class="bg-[#a238ff] hover:bg-[#8a30d8] text-white py-2 px-4 rounded-md transition-colors inline-flex items-center">
<i class="fab fa-deezer mr-2"></i> Import from Deezer
</a>
</div>
</div>
<div class="flex-1 bg-orange-50 rounded-lg p-6 border border-orange-100 text-center">
<h3 class="text-xl font-semibold text-navy-800 mb-2 font-montserrat">Import a Playlist</h3>
<p class="text-gray-600 mb-4">Quickly create a music quiz from an existing Spotify or Deezer playlist.</p>
<a href="{{ url_for('generate.import_playlist') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors inline-flex items-center">
<i class="fas fa-cloud-download-alt mr-2"></i> Import from Playlist
</a>
</div>
</div>
</div>
{% endblock %}
+226
View File
@@ -0,0 +1,226 @@
{% extends 'base.html' %}
{% block title %}Error {{ code }} - Quizzical Beats{% endblock %}
{% block content %}
<div class="flex justify-center items-center min-h-[70vh] px-4">
<div class="w-full max-w-lg text-center">
<div class="mb-6">
<i class="fas fa-exclamation-triangle text-orange-500 text-6xl"></i>
</div>
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-4">Error {{ code }}</h1>
<!-- Error message container - will be updated by JS -->
<div class="bg-red-50 border border-red-200 text-red-700 px-6 py-4 rounded-lg mb-6 relative group">
<!-- Loading indicator initially shown -->
<div id="loading-message" class="flex items-center justify-center py-2">
<div class="mr-3">
<i class="fas fa-circle-notch fa-spin text-orange-500"></i>
</div>
<p>Interpreting this error for you...</p>
</div>
<!-- Friendly error message initially hidden -->
<div id="friendly-error-container" class="hidden">
<div class="flex items-center justify-between">
<p id="friendly-error-message" class="text-lg"></p>
<button
class="copy-btn text-gray-500 hover:text-navy-600 ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
onclick="copyToClipboard(document.getElementById('friendly-error-message').innerText)"
title="Copy error message">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<!-- Technical error message initially hidden (fallback) -->
<div id="technical-error-container" class="hidden">
<div class="flex items-center justify-between">
<p id="technical-error-fallback" class="text-lg">{{ message }}</p>
<button
class="copy-btn text-gray-500 hover:text-navy-600 ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
onclick="copyToClipboard('{{ message }}')"
title="Copy error message">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- Technical error message (always available in details) -->
<div class="mt-4 bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<details class="technical-error-accordion">
<summary class="cursor-pointer text-md font-bold text-navy-800 py-2 flex items-center justify-between">
<span>Technical Details</span>
<i class="fas fa-chevron-down text-sm transition-transform"></i>
</summary>
<div class="mt-2">
<div class="bg-gray-100 p-3 rounded overflow-auto text-sm relative">
<p id="technical-error">{{ message }}</p>
</div>
</div>
</details>
</div>
{% if 'access_token' in session and (debug_info or traceback) %}
<!-- Debug info section -->
<div class="mt-6 bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<!-- Copy all button at the top -->
<div class="flex justify-end mb-3">
<button
class="flex items-center bg-navy-600 hover:bg-navy-700 text-white text-sm py-1 px-3 rounded transition-colors"
onclick="copyAllErrorInfo()"
title="Copy all error information">
<i class="fas fa-copy mr-2"></i> Copy All Error Info
</button>
</div>
<details class="debug-accordion">
<summary class="cursor-pointer text-lg font-bold text-navy-800 py-2 flex items-center justify-between">
<span>Debug Information</span>
<i class="fas fa-chevron-down text-sm transition-transform"></i>
</summary>
<div class="mt-4 debug-content">
<div class="bg-gray-100 p-4 rounded overflow-auto text-sm font-mono relative">
<pre id="debug-info-pre">{{ debug_info }}</pre>
</div>
{% if traceback %}
<div class="mt-4">
<h3 class="text-md font-bold text-navy-800 mb-2">Traceback</h3>
<div class="bg-gray-100 p-4 rounded overflow-auto text-sm font-mono relative">
<pre id="traceback-pre">{{ traceback }}</pre>
</div>
</div>
{% endif %}
</div>
</details>
</div>
{% endif %}
<a href="{{ url_for('core.index') }}" class="inline-flex items-center bg-navy-700 hover:bg-navy-800 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-home mr-2"></i> Return to Homepage
</a>
</div>
</div>
<!-- Hidden element to store all error info for copying -->
<div id="all-error-info" class="hidden">Error {{ code }}: <span id="copy-message"></span>
TECHNICAL DETAILS:
{{ message }}
{% if debug_info %}
DEBUG INFORMATION:
{{ debug_info }}{% endif %}{% if traceback %}
TRACEBACK:
{{ traceback }}{% endif %}
</div>
<!-- Store error info for JS -->
<div id="error-info-data" class="hidden" data-error-info='{{ error_info_for_js|safe }}'></div>
{% endblock %}
{% block scripts %}
<script>
// Function to fetch friendly error message
document.addEventListener('DOMContentLoaded', function() {
// Get error info
const errorInfoEl = document.getElementById('error-info-data');
if (!errorInfoEl) return;
try {
const errorInfo = JSON.parse(errorInfoEl.getAttribute('data-error-info'));
// Set a timeout in case the API call takes too long
const timeoutId = setTimeout(() => {
showTechnicalErrorFallback();
}, 5000); // 5 seconds timeout
// Make API call to get friendly error message
fetch('/api/friendly-error', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorInfo)
})
.then(response => response.json())
.then(data => {
clearTimeout(timeoutId);
if (data.success && data.message) {
showFriendlyErrorMessage(data.message);
document.getElementById('copy-message').innerText = data.message;
} else {
showTechnicalErrorFallback();
}
})
.catch(error => {
clearTimeout(timeoutId);
console.error('Error fetching friendly message:', error);
showTechnicalErrorFallback();
});
} catch (error) {
console.error('Error parsing error info:', error);
showTechnicalErrorFallback();
}
});
function showFriendlyErrorMessage(message) {
document.getElementById('loading-message').classList.add('hidden');
const friendlyContainer = document.getElementById('friendly-error-container');
friendlyContainer.classList.remove('hidden');
document.getElementById('friendly-error-message').innerText = message;
}
function showTechnicalErrorFallback() {
document.getElementById('loading-message').classList.add('hidden');
document.getElementById('technical-error-container').classList.remove('hidden');
document.getElementById('copy-message').innerText = document.getElementById('technical-error-fallback').innerText;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
showCopyNotification();
}, function(err) {
console.error('Could not copy text: ', err);
});
}
function copyAllErrorInfo() {
const allErrorInfo = document.getElementById('all-error-info').innerText;
copyToClipboard(allErrorInfo);
}
function showCopyNotification() {
const notification = document.createElement('div');
notification.className = 'fixed bottom-4 right-4 bg-navy-600 text-white px-4 py-2 rounded shadow-lg transform translate-y-0 opacity-100 transition-all duration-300';
notification.innerHTML = '<i class="fas fa-check mr-2"></i> Copied to clipboard';
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 2000);
}
// Add animation to details elements
document.addEventListener('DOMContentLoaded', function() {
const details = document.querySelectorAll('.debug-accordion, .technical-error-accordion');
details.forEach(detail => {
detail.addEventListener('toggle', function() {
const icon = this.querySelector('i.fa-chevron-down');
if (this.open) {
icon.classList.add('rotate-180');
} else {
icon.classList.remove('rotate-180');
}
});
});
});
</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}Quizzical Beats - Where trivia meets the rhythm{% endblock %}
{% block content %}
<div class="flex flex-col justify-center items-center min-h-[80vh] py-12 px-4">
<div class="text-center mb-10">
<h1 class="text-4xl md:text-5xl font-bold text-navy-800 font-montserrat mb-4">Welcome to Quizzical Beats</h1>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">Where trivia meets the rhythm. Create unforgettable music rounds for your pub quiz or trivia night.</p>
</div>
<div class="w-full max-w-md bg-white shadow-lg rounded-lg overflow-hidden mb-10">
<div class="bg-teal-500 text-white text-center py-4">
<h2 class="text-2xl font-montserrat font-semibold">Welcome, {{ user_info['display_name'] }}</h2>
</div>
<div class="p-6">
<ul class="space-y-3">
<li class="flex border-b border-gray-100 pb-2">
<span class="text-navy-800 font-semibold w-28">Username:</span>
<span class="text-gray-600">{{ current_user.username }}</span>
</li>
<li class="flex border-b border-gray-100 pb-2">
<span class="text-navy-800 font-semibold w-28">Email:</span>
<span class="text-gray-600">{{ current_user.email }}</span>
</li>
{% if current_user.first_name or current_user.last_name %}
<li class="flex border-b border-gray-100 pb-2">
<span class="text-navy-800 font-semibold w-28">Name:</span>
<span class="text-gray-600">{{ current_user.first_name }} {{ current_user.last_name }}</span>
</li>
{% endif %}
</ul>
<div class="mt-6 flex flex-col space-y-3">
<a href="{{ url_for('generate.build_music_round') }}"
class="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
<i class="fas fa-music mr-2"></i> Build a Music Round
</a>
<a href="{{ url_for('core.view_songs') }}"
class="bg-teal-500 hover:bg-teal-600 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
<i class="fas fa-list mr-2"></i> View Your Songs
</a>
<a href="{{ url_for('users.profile') }}"
class="bg-navy-600 hover:bg-navy-700 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
<i class="fas fa-user mr-2"></i> My Profile
</a>
</div>
</div>
</div>
<div class="text-center text-gray-600 max-w-2xl">
<p class="italic">"The soundtrack to your smartest guesses."</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,430 @@
{% extends 'base.html' %}
{% block title %}Official Spotify Playlists{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Official Spotify Playlists</h1>
<p class="text-gray-600 mb-6">Browse and import playlists from official Spotify accounts worldwide.</p>
<!-- Direct Token Entry/Display -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start gap-4">
<div class="w-full md:w-2/3">
<h3 class="text-lg font-semibold text-navy-800 mb-2">Spotify Authentication</h3>
<p class="text-gray-600 mb-3 text-sm">Enter a bearer token to access the Spotify API directly, bypassing OAuth limitations.</p>
{% if spotify_username %}
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
<p class="text-sm text-green-700">
<span class="font-medium">✓ Authenticated as:</span> {{ spotify_username }}
</p>
</div>
{% endif %}
<form action="{{ url_for('import.update_direct_token') }}" method="POST" class="flex flex-col md:flex-row gap-3 items-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="return_url" value="{{ request.path }}?{{ request.query_string.decode() }}"/>
<div class="w-full md:w-2/3">
<label for="bearer_token" class="block text-sm font-medium text-gray-700 mb-1">Bearer Token</label>
<textarea name="bearer_token" id="bearer_token" rows="1"
placeholder="BQBcQ1foQG0x14axu2kQVz..."
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">{{ session_bearer_token }}</textarea>
<p class="mt-1 text-xs text-gray-500">Get a token from <a href="https://developer.spotify.com/console/" target="_blank" class="text-teal-600 hover:underline">Spotify Developer Console</a></p>
</div>
<div class="flex gap-2">
<button type="submit" class="px-4 py-2 bg-teal-600 text-white text-sm font-medium rounded-md hover:bg-teal-700 transition-colors">
Apply Token
</button>
{% if session_bearer_token %}
<button type="submit" name="clear_token" value="1" class="px-4 py-2 bg-gray-500 text-white text-sm font-medium rounded-md hover:bg-gray-600 transition-colors">
Clear Token
</button>
{% endif %}
</div>
</form>
</div>
<div class="w-full md:w-1/3 md:text-right bg-blue-50 border border-blue-100 rounded-lg p-3">
<h4 class="font-medium text-sm text-blue-800 mb-1">Authentication Mode</h4>
{% if direct_mode %}
<p class="text-sm text-blue-700 mb-2">Currently using: <span class="font-semibold">Direct Bearer Token</span></p>
<a href="{{ url_for('import.import_official_playlists', **request.args) }}" class="text-sm text-blue-600 hover:underline">
Switch to OAuth Authentication
</a>
{% else %}
<p class="text-sm text-blue-700 mb-2">Currently using: <span class="font-semibold">OAuth Authentication</span></p>
<a href="{{ url_for('import.direct_official_playlists', **request.args) }}" class="text-sm text-blue-600 hover:underline">
Switch to Direct Bearer Token
</a>
{% endif %}
</div>
</div>
</div>
<!-- Filtering and Account Selection -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<form method="GET" action="{{ direct_mode and url_for('import.direct_official_playlists') or url_for('import.import_official_playlists') }}" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="filter" class="block text-sm font-medium text-gray-700 mb-1">Filter by Keywords</label>
<input type="text" name="filter" id="filter" value="{{ filter_keywords|join(',') }}"
placeholder="top,hits,pop,rock,etc"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<p class="text-xs text-gray-500 mt-1">Comma-separated keywords to search in playlist names</p>
</div>
<div>
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
<select name="account" id="account"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<option value="all" {% if selected_account == 'all' %}selected{% endif %}>All Accounts</option>
{% for account in spotify_accounts %}
<option value="{{ account }}" {% if selected_account == account %}selected{% endif %}>{{ account }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<input type="checkbox" name="debug" id="debug" value="true" {% if debug_mode %}checked{% endif %}>
<label for="debug" class="text-sm text-gray-700">Show Debug Info</label>
</div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
Apply Filters
</button>
</div>
<!-- Hidden field to persist the bearer token across requests -->
{% if session_bearer_token %}
<input type="hidden" name="bearer_token" value="{{ session_bearer_token }}">
{% endif %}
</form>
</div>
{% if debug_mode %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Debug Information</h2>
<!-- Summary stats -->
<div class="overflow-x-auto mb-6">
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th class="px-3 py-2 bg-gray-100 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">Metric</th>
<th class="px-3 py-2 bg-gray-100 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Total Playlists Fetched</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.total_fetched }}</td>
</tr>
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Keyword Filtered Out</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.filtered_out }}</td>
</tr>
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Duplicates Removed</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.duplicates_removed }}</td>
</tr>
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Final Count Displayed</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.total_filtered }}</td>
</tr>
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Total Processing Time</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.query_time_ms }}ms</td>
</tr>
</tbody>
</table>
</div>
<!-- Account stats -->
<h3 class="text-lg font-semibold mb-2">Account Statistics</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{% for account, stats in debug_info.accounts.items() %}
<div class="bg-white rounded-md border border-gray-200 p-4">
<h4 class="font-medium mb-2">{{ account }}</h4>
<ul class="list-disc pl-5 text-sm">
<li>Total playlists: {{ stats.total }}</li>
<li>Filtered: {{ stats.filtered }}/{{ stats.fetched }}</li>
<li>Processing time: {{ stats.time_ms }}ms</li>
</ul>
</div>
{% endfor %}
</div>
<!-- Keyword matches -->
{% if debug_info.matched_keywords %}
<h3 class="text-lg font-semibold mb-2">Keyword Matches</h3>
<div class="bg-white rounded-md border border-gray-200 p-4 mb-6">
<ul class="list-disc pl-5 text-sm">
{% for keyword, count in debug_info.matched_keywords.items() %}
<li><span class="font-medium">"{{ keyword }}"</span>: {{ count }} matches</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<!-- Playlist grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for playlist in playlists %}
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200">
{% if playlist.images and playlist.images|length > 0 %}
<img src="{{ playlist.images[0].url }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.name }}">
{% else %}
<div class="w-full h-48 bg-gray-200 flex items-center justify-center">
<span class="text-gray-500">No image</span>
</div>
{% endif %}
<div class="p-6">
<div class="flex justify-between items-start">
<h5 class="text-xl font-semibold mb-2 text-navy-800 font-montserrat playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.name }}</h5>
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">{{ playlist.owner.id }}</span>
</div>
{% if playlist.description %}
<p class="text-gray-700 mb-3 text-sm">{{ playlist.description | replace('<a href="', '') | replace('">', ' - ') | replace('</a>', '') }}</p>
{% endif %}
<p class="text-gray-600 mb-4">Tracks: {{ playlist.tracks.total }}</p>
<div class="flex justify-between items-center">
<form action="{{ url_for('import.import_official_playlists')}}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="playlist_id" value="{{ playlist.id }}">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
<div>
<a href="https://open.spotify.com/playlist/{{ playlist.id }}" target="_blank" class="text-teal-600 hover:underline text-sm mr-3">
<i class="fab fa-spotify mr-1"></i> View
</a>
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
<i class="fas fa-info-circle mr-1"></i> Details
</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-span-3 p-8 text-center bg-gray-50 rounded-lg border border-gray-200">
<p class="text-gray-600">No playlists found matching your criteria. Try changing your filters.</p>
</div>
{% endfor %}
</div>
</div>
<!-- Playlist Details Modal -->
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Playlist Details</h3>
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times fa-lg"></i>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
<!-- Content will be loaded here -->
<div class="w-full md:w-1/3 flex flex-col items-center">
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
</div>
<div id="modal-metadata" class="w-full text-center mb-4">
<!-- Metadata will be loaded here -->
</div>
</div>
<div class="w-full md:w-2/3">
<p class="text-gray-600 mb-4" id="modal-description"></p>
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
<i class="fas fa-music mr-2"></i> Tracks
</h4>
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
</tr>
</thead>
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
<!-- Tracks will be loaded here -->
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 flex justify-end">
<form action="{{ url_for('import.import_official_playlists') }}" method="POST" id="modal-import-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="playlist_id" id="modal-item-id" value="">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('details-modal');
const closeModal = document.getElementById('close-modal');
const modalTitle = document.getElementById('modal-title');
const modalDescription = document.getElementById('modal-description');
const modalImage = document.getElementById('modal-image');
const modalMetadata = document.getElementById('modal-metadata');
const modalTracks = document.getElementById('modal-tracks');
const modalImportForm = document.getElementById('modal-import-form');
const modalItemId = document.getElementById('modal-item-id');
// Close modal
closeModal.addEventListener('click', () => {
modal.classList.add('hidden');
});
// Close modal when clicking outside
window.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
}
});
// Format duration from milliseconds to mm:ss
function formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Playlist detail functionality
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const playlistId = e.target.getAttribute('data-id') || e.target.closest('.playlist-detail-trigger').getAttribute('data-id');
if (!playlistId) return;
// Reset modal content
modalTitle.textContent = "Loading playlist details...";
modalDescription.textContent = "";
modalImage.src = "";
modalMetadata.innerHTML = "";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
`;
// Set up import form
modalItemId.value = playlistId;
// Show modal
modal.classList.remove('hidden');
// Fetch playlist details
fetch(`/api/spotify/playlist/${playlistId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Update modal with playlist details
modalTitle.textContent = data.name;
if (data.description) {
// Remove HTML tags from description
const cleanDescription = data.description
.replace(/<a href="[^"]*">/g, '')
.replace(/<\/a>/g, '')
.replace(/&amp;/g, '&');
modalDescription.textContent = cleanDescription;
}
if (data.image_url) {
modalImage.src = data.image_url;
modalImage.alt = data.name;
}
// Add metadata
modalMetadata.innerHTML = `
<p class="font-semibold text-navy-800">By ${data.owner}</p>
<p class="text-gray-600">${data.tracks.length} tracks</p>
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
`;
// Add tracks
if (data.tracks && data.tracks.length > 0) {
modalTracks.innerHTML = '';
data.tracks.forEach((track, index) => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
row.innerHTML = `
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
<td class="px-4 py-2">${track.name}</td>
<td class="px-4 py-2 text-sm">${track.artist}</td>
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
`;
modalTracks.appendChild(row);
});
} else {
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
No tracks available
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error fetching playlist details:', error);
modalTitle.textContent = "Error Loading Details";
modalDescription.textContent = "There was a problem loading the playlist details.";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-red-500">
Failed to load tracks. Please try again later.
</td>
</tr>
`;
});
});
});
});
</script>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block title %}Import Playlist - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-2">Import Playlist</h1>
<p class="text-gray-600">Create a music quiz round from a Spotify or Deezer playlist.</p>
</div>
<div class="bg-white shadow-md rounded-lg p-6">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-4 border-l-4 {% if category == 'error' %}border-red-500 bg-red-50 text-red-700{% else %}border-green-500 bg-green-50 text-green-700{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('generate.import_playlist') }}" id="importPlaylistForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="round_name">
Round Name (optional)
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="round_name" name="round_name" type="text" placeholder="Enter a name for this round">
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="platform">
Platform
</label>
<div class="flex space-x-4">
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="platform" value="spotify" checked>
<span class="ml-2">Spotify</span>
</label>
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="platform" value="deezer">
<span class="ml-2">Deezer</span>
</label>
</div>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="playlist_url">
Playlist URL or ID
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="playlist_url" name="playlist_url" type="text" placeholder="Enter Spotify or Deezer playlist URL">
<p class="text-gray-600 text-xs italic mt-1">Example: https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd or https://www.deezer.com/en/playlist/1111111</p>
</div>
<div class="flex items-center justify-between">
<button class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition-colors"
type="submit" id="importButton">
<i class="fas fa-cloud-download-alt mr-2"></i>Import Playlist
</button>
<a class="inline-block align-baseline font-bold text-sm text-navy-700 hover:text-navy-800"
href="{{ url_for('generate.build_music_round') }}">
<i class="fas fa-arrow-left mr-1"></i>Back to Quiz Builder
</a>
</div>
</form>
</div>
<div class="mt-8 bg-navy-50 rounded-lg p-6 border border-navy-100">
<h3 class="text-xl font-semibold text-navy-800 mb-3 font-montserrat">Import Tips</h3>
<ul class="list-disc pl-5 space-y-2 text-gray-700">
<li>Make sure your playlist is public or at least accessible via link</li>
<li>Only the first 8 songs from the playlist will be imported for the quiz</li>
<li>Preview URLs might not be available for all songs</li>
<li>Songs will be saved in our database for future use</li>
</ul>
</div>
</div>
<!-- Loading Modal -->
<div id="loadingModal" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 hidden">
<div class="bg-white p-8 rounded-lg shadow-lg text-center max-w-md w-full">
<div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-orange-500 mx-auto mb-4"></div>
<h3 class="text-xl font-bold text-navy-800 mb-2">Importing Playlist...</h3>
<p class="text-gray-600 mb-4">This might take a moment as we fetch and process each song.</p>
<div class="text-sm text-gray-500">
<p>We're working on:</p>
<ul id="importSteps" class="mt-2 space-y-2 text-left px-4">
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 step-in-progress"></i> <span class="ml-2">Fetching playlist data</span></li>
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 hidden step-in-progress"></i> <span class="ml-2">Importing songs to database</span></li>
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 hidden step-in-progress"></i> <span class="ml-2">Creating quiz round</span></li>
</ul>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('importPlaylistForm');
const loadingModal = document.getElementById('loadingModal');
const importSteps = document.querySelectorAll('#importSteps li');
form.addEventListener('submit', function(e) {
// Show loading modal when form is submitted
loadingModal.classList.remove('hidden');
// Simulate progress updates (since we can't track actual backend progress)
setTimeout(() => {
// Mark first step as complete and start second step
importSteps[0].querySelector('.step-in-progress').classList.add('hidden');
importSteps[0].querySelector('.step-complete').classList.remove('hidden');
importSteps[1].querySelector('.step-in-progress').classList.remove('hidden');
}, 2000);
setTimeout(() => {
// Mark second step as complete and start third step
importSteps[1].querySelector('.step-in-progress').classList.add('hidden');
importSteps[1].querySelector('.step-complete').classList.remove('hidden');
importSteps[2].querySelector('.step-in-progress').classList.remove('hidden');
}, 4000);
// Let the form submission continue
return true;
});
});
</script>
{% endblock %}
+260
View File
@@ -0,0 +1,260 @@
{% extends 'base.html' %}
{% block title %}Raw Spotify Playlists{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Raw Spotify API Response</h1>
<p class="text-gray-600 mb-6">Examining the raw Spotify API response for playlist queries.</p>
<!-- Parameters Form -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<form method="GET" action="{{ url_for('import.get_raw_playlists') }}" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
<select name="account" id="account"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<option value="spotify" {% if account == 'spotify' %}selected{% endif %}>spotify</option>
<option value="spotifycharts" {% if account == 'spotifycharts' %}selected{% endif %}>spotifycharts</option>
<option value="spotifymaps" {% if account == 'spotifymaps' %}selected{% endif %}>spotifymaps</option>
<option value="spotifyuk" {% if account == 'spotifyuk' %}selected{% endif %}>spotifyuk</option>
<option value="spotifyusa" {% if account == 'spotifyusa' %}selected{% endif %}>spotifyusa</option>
<option value="spotify_germany" {% if account == 'spotify_germany' %}selected{% endif %}>spotify_germany</option>
</select>
</div>
<div>
<label for="limit" class="block text-sm font-medium text-gray-700 mb-1">Limit</label>
<input type="number" name="limit" id="limit" min="1" max="50" value="{{ limit }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<p class="text-xs text-gray-500 mt-1">Max 50 items per request</p>
</div>
<div>
<label for="offset" class="block text-sm font-medium text-gray-700 mb-1">Offset</label>
<input type="number" name="offset" id="offset" min="0" value="{{ offset }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
</div>
<div class="md:col-span-3">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
Get Raw Response
</button>
</div>
</form>
</div>
<!-- Results -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Spotipy Results -->
<div>
<h2 class="text-xl font-bold mb-4">Spotipy Response</h2>
{% if results.spotipy.error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<h3 class="font-medium text-red-800 mb-2">Error:</h3>
<pre class="text-red-700 text-sm whitespace-pre-wrap">{{ results.spotipy.error }}</pre>
</div>
{% endif %}
{% if results.spotipy.raw_response %}
<div class="bg-white shadow-md rounded-lg p-6">
<!-- Response metadata -->
<div class="mb-4">
<h3 class="font-medium text-gray-800 mb-2">Response Metadata:</h3>
<table class="min-w-full divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Total Playlists</td>
<td class="py-2 text-sm text-gray-900">{{ results.spotipy.raw_response.total }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Items Returned</td>
<td class="py-2 text-sm text-gray-900">{{ results.spotipy.raw_response.items|length }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Has Next Page</td>
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.spotipy.raw_response.next else "No" }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Has Previous Page</td>
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.spotipy.raw_response.previous else "No" }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Playlists -->
<div>
<h3 class="font-medium text-gray-800 mb-2">Playlists ({{ results.spotipy.raw_response.items|length }}):</h3>
<div class="overflow-y-auto max-h-96 border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracks</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for playlist in results.spotipy.raw_response.items %}
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ loop.index }}</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.id }}</td>
<td class="px-3 py-2 text-sm text-gray-900">{{ playlist.name }}</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.tracks.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination links -->
{% if results.spotipy.raw_response.previous or results.spotipy.raw_response.next %}
<div class="mt-4 flex justify-between">
{% if results.spotipy.raw_response.previous %}
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset-limit) }}"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
&laquo; Previous
</a>
{% else %}
<span></span>
{% endif %}
{% if results.spotipy.raw_response.next %}
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset+limit) }}"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
Next &raquo;
</a>
{% else %}
<span></span>
{% endif %}
</div>
{% endif %}
<!-- Raw JSON -->
<div class="mt-4">
<details>
<summary class="cursor-pointer text-teal-600 hover:text-teal-800">View Raw JSON</summary>
<div class="mt-2 p-4 bg-gray-50 rounded-lg overflow-x-auto">
<pre class="text-xs text-gray-800">{{ results.spotipy.raw_response | tojson(indent=2) }}</pre>
</div>
</details>
</div>
</div>
{% else %}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<p class="text-gray-500">No response data available</p>
</div>
{% endif %}
</div>
<!-- Direct API Results -->
<div>
<h2 class="text-xl font-bold mb-4">Direct API Response</h2>
{% if results.direct.error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<h3 class="font-medium text-red-800 mb-2">Error:</h3>
<pre class="text-red-700 text-sm whitespace-pre-wrap">{{ results.direct.error }}</pre>
</div>
{% endif %}
{% if results.direct.raw_response %}
<div class="bg-white shadow-md rounded-lg p-6">
<!-- Response metadata -->
<div class="mb-4">
<h3 class="font-medium text-gray-800 mb-2">Response Metadata:</h3>
<table class="min-w-full divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Total Playlists</td>
<td class="py-2 text-sm text-gray-900">{{ results.direct.raw_response.total }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Items Returned</td>
<td class="py-2 text-sm text-gray-900">{{ results.direct.raw_response.items|length }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Has Next Page</td>
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.direct.raw_response.next else "No" }}</td>
</tr>
<tr>
<td class="py-2 text-sm font-medium text-gray-700">Has Previous Page</td>
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.direct.raw_response.previous else "No" }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Playlists -->
<div>
<h3 class="font-medium text-gray-800 mb-2">Playlists ({{ results.direct.raw_response.items|length }}):</h3>
<div class="overflow-y-auto max-h-96 border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracks</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for playlist in results.direct.raw_response.items %}
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ loop.index }}</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.id }}</td>
<td class="px-3 py-2 text-sm text-gray-900">{{ playlist.name }}</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.tracks.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination links -->
{% if results.direct.raw_response.previous or results.direct.raw_response.next %}
<div class="mt-4 flex justify-between">
{% if results.direct.raw_response.previous %}
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset-limit) }}"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
&laquo; Previous
</a>
{% else %}
<span></span>
{% endif %}
{% if results.direct.raw_response.next %}
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset+limit) }}"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
Next &raquo;
</a>
{% else %}
<span></span>
{% endif %}
</div>
{% endif %}
<!-- Raw JSON -->
<div class="mt-4">
<details>
<summary class="cursor-pointer text-teal-600 hover:text-teal-800">View Raw JSON</summary>
<div class="mt-2 p-4 bg-gray-50 rounded-lg overflow-x-auto">
<pre class="text-xs text-gray-800">{{ results.direct.raw_response | tojson(indent=2) }}</pre>
</div>
</details>
</div>
</div>
{% else %}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<p class="text-gray-500">No response data available</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
{% extends 'base.html' %}
{% block title %}Review Music Quiz - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-4 text-navy-800 font-montserrat">Review Your Music Quiz</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
<p class="mb-4 font-medium text-navy-700">Quiz Criteria: {{ round_criteria }}
{% if genre %}
<span class="inline-block bg-orange-100 text-orange-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Genre: {{ genre }}</span>
{% endif %}
{% if decade %}
<span class="inline-block bg-teal-100 text-teal-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Decade: {{ decade }}</span>
{% endif %}
{% if tag %}
<span class="inline-block bg-purple-100 text-purple-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Tag: {{ tag }}</span>
{% endif %}
</p>
</div>
<div class="overflow-x-auto bg-white shadow-md rounded-lg mb-8">
<table class="w-full table-auto">
<thead class="bg-navy-50 text-navy-800">
<tr>
<th class="px-4 py-3 text-left font-semibold">#</th>
<th class="px-4 py-3 text-left font-semibold">Cover</th>
<th class="px-4 py-3 text-left font-semibold">Title</th>
<th class="px-4 py-3 text-left font-semibold">Artist</th>
<th class="px-4 py-3 text-left font-semibold">Year</th>
<th class="px-4 py-3 text-left font-semibold">Genre</th>
<th class="px-4 py-3 text-left font-semibold">Preview</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for song in songs %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium">{{ loop.index }}</td>
<td class="px-4 py-3"><img src="{{ song.cover_url }}" alt="{{ song.title }}" class="w-16 h-16 object-cover rounded-md shadow-sm"></td>
<td class="px-4 py-3 font-medium">{{ song.title }}</td>
<td class="px-4 py-3">{{ song.artist }}</td>
<td class="px-4 py-3">{{ song.year }}</td>
<td class="px-4 py-3">{{ song.genre }}</td>
<td class="px-4 py-3">
<audio controls class="w-full max-w-[200px]" src="{{ song.preview_url }}">Your browser does not support the audio element.</audio>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex flex-wrap gap-4 mt-6">
<form method="POST" action="{{ url_for('generate.save_round') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="round_criteria" value="{{ round_criteria }}">
<div class="mb-4">
<label for="round_name" class="block text-gray-700 text-sm font-bold mb-2">Round Name (Optional)</label>
<input type="text" id="round_name" name="round_name" value="{{ round_name or '' }}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="Enter a name for this round">
</div>
{% if genre %}
<input type="hidden" name="genre" value="{{ genre }}">
{% endif %}
{% if decade %}
<input type="hidden" name="decade" value="{{ decade }}">
{% endif %}
{% if tag %}
<input type="hidden" name="tag" value="{{ tag }}">
{% endif %}
{% for song in songs %}
<input type="hidden" name="song_id" value="{{ song.id }}">
{% endfor %}
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors font-semibold">
<i class="fas fa-save mr-2"></i> Save This Quiz
</button>
</form>
<a href="{{ url_for('generate.build_music_round') }}" class="inline-block bg-navy-600 hover:bg-navy-700 text-white py-2 px-4 rounded transition-colors font-semibold">
<i class="fas fa-refresh mr-2"></i> Generate Different Quiz
</a>
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}Music Quizzes - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-2">Your Music Quizzes</h1>
<p class="text-gray-600">Browse and manage all your created music quizzes.</p>
</div>
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="p-4 bg-navy-50 border-b">
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold text-navy-800">Saved Quizzes</h2>
<a href="{{ url_for('generate.build_music_round') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors text-sm font-semibold">
<i class="fas fa-plus mr-1"></i> Create New Quiz
</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead class="bg-gray-100 text-gray-700">
<tr>
<th class="px-4 py-3 text-left font-semibold">ID</th>
<th class="px-4 py-3 text-left font-semibold">Name</th>
<th class="px-4 py-3 text-left font-semibold">Type</th>
<th class="px-4 py-3 text-left font-semibold">Criteria</th>
<th class="px-4 py-3 text-left font-semibold">Created</th>
<th class="px-4 py-3 text-left font-semibold">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for round in rounds %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ round.id }}</td>
<td class="px-4 py-3 font-medium">{{ round.name or 'Quiz #' + round.id|string }}</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full
{% if round.round_type == 'Random' %}bg-navy-100 text-navy-800
{% elif round.round_type == 'Decade' %}bg-teal-100 text-teal-800
{% elif round.round_type == 'Genre' %}bg-orange-100 text-orange-800
{% elif round.round_type == 'Tag' %}bg-purple-100 text-purple-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ round.round_type }}
</span>
</td>
<td class="px-4 py-3">{{ round.round_criteria_used }}</td>
<td class="px-4 py-3 text-gray-600 text-sm">{{ round.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="px-4 py-3">
<a href="{{ url_for('rounds.round_detail', round_id=round.id) }}"
class="bg-teal-500 hover:bg-teal-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
<i class="fas fa-eye mr-1"></i> View
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
<p class="mb-3">You haven't created any music quizzes yet.</p>
<a href="{{ url_for('generate.build_music_round') }}" class="inline-block bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors text-sm">
<i class="fas fa-plus-circle mr-1"></i> Create Your First Quiz
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="mt-8 bg-navy-50 rounded-lg p-6 border border-navy-100">
<h3 class="text-xl font-semibold text-navy-800 mb-3 font-montserrat">Quiz Tips</h3>
<ul class="list-disc pl-5 space-y-2 text-gray-700">
<li>Export your quizzes to PDF for easy printing of answer sheets</li>
<li>Generate MP3s to play your music round with consistent timing</li>
<li>Mix different decades and genres for a balanced challenge</li>
<li>Keep track of which rounds you've used to avoid repeating songs</li>
</ul>
</div>
<div class="mt-4 flex gap-4 justify-center">
<a href="{{ url_for('generate.import_playlist') }}" class="inline-block bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors text-sm font-semibold">
<i class="fas fa-cloud-download-alt mr-1"></i> Import Playlist
</a>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% block title %}Import from {{ service_name }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto px-4 py-10">
<div class="bg-white rounded-lg shadow-md p-8">
<h2 class="text-3xl font-bold mb-4 text-center text-navy-800 font-montserrat">
Import {{ item_type }} from {{ service_name }}
{% if service_name == 'Spotify' %}<i class="fab fa-spotify ml-2 text-sm"></i>{% elif service_name == 'Deezer' %}<i class="fab fa-deezer ml-2 text-sm"></i>{% endif %}
</h2>
<div class="mb-8 bg-gray-50 rounded-lg p-5 border border-gray-200">
<h3 class="font-semibold text-lg mb-2 flex items-center text-navy-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-teal-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
How to find the {{ service_name }} {{ item_type|lower }} ID
</h3>
<ol class="text-gray-700 space-y-2">
<li>1. Go to the {{ service_name }} website and navigate to your desired {{ item_type|lower }}</li>
<li>2. Look at the URL in your browser's address bar</li>
<li>3. Find the ID in the URL format shown below:</li>
</ol>
<div class="mt-3 p-3 bg-gray-100 rounded border border-gray-300 font-mono text-sm break-all">
{{ url_example_prefix }}<span class="font-bold text-teal-600">{{ url_example_id }}</span>
</div>
</div>
<form method="POST" action="{{ form_action }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label for="{{ id_field }}" class="block text-sm font-medium text-gray-700 mb-2">{{ item_type }} ID:</label>
<input type="text" id="{{ id_field }}" name="{{ id_field }}" placeholder="Enter {{ item_type|lower }} ID..." required
class="w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" />
</div>
<div class="flex justify-between items-center pt-2">
<a href="{{ back_url }}" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md transition-colors border border-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Search
</a>
<button type="submit" class="inline-flex items-center px-6 py-2 rounded-md font-medium text-white transition-colors bg-orange-500 hover:bg-orange-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Import {{ item_type }}
</button>
</div>
</form>
</div>
<div class="mt-6 text-center text-sm text-gray-500">
<p>Importing from {{ service_name }} allows you to add songs directly to your music round collection.</p>
<div class="mt-2">
{% if service_name == 'Spotify' %}
<span class="inline-flex items-center text-navy-800">
<i class="fab fa-spotify mr-1"></i> Spotify Integration
</span>
{% elif service_name == 'Deezer' %}
<span class="inline-flex items-center text-navy-800">
<i class="fab fa-deezer mr-1"></i> Deezer Integration
</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+169
View File
@@ -0,0 +1,169 @@
{% extends 'base.html' %}
{% block title %}{{ service_name }} Search - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="bg-navy-800 text-white px-6 py-4">
<h2 class="text-xl font-semibold font-montserrat">
{% if service_name == 'Spotify' %}
<i class="fab fa-spotify mr-2"></i>
{% elif service_name == 'Deezer' %}
<i class="fab fa-deezer mr-2"></i>
{% endif %}
Search {{ service_name }}
</h2>
<p class="text-sm opacity-90">Import songs directly to your music quiz library</p>
</div>
<div class="p-6">
<form action="{{ search_results_url }}" method="POST" class="mb-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<input type="text" class="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
name="search_term"
placeholder="Search for tracks, albums, or playlists..."
aria-label="Search term" required>
</div>
<div class="text-sm text-gray-600 mb-4">
Search for music on {{ service_name }} by artist, song title, album, or playlist name.
</div>
<button class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-6 rounded-md transition-colors flex items-center" type="submit">
<i class="fas fa-search mr-2"></i> Search
</button>
</form>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div class="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
<div class="text-center">
<i class="{% if service_name == 'Spotify' %}fab fa-spotify{% elif service_name == 'Deezer' %}fab fa-deezer{% endif %} fa-3x text-navy-800 mb-4"></i>
<h5 class="text-lg font-semibold text-navy-800 font-montserrat">Browse Official Playlists</h5>
<p class="text-gray-600 mb-4">Discover curated collections on {{ service_name }}</p>
<a href="{{ browse_playlists_url }}" class="inline-block bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded-md transition-colors">
Browse Playlists
</a>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
<div class="text-center">
<i class="fas fa-link fa-3x text-navy-800 mb-4"></i>
<h5 class="text-lg font-semibold text-navy-800 font-montserrat">Import by URL</h5>
<p class="text-gray-600 mb-4">Enter a {{ service_name }} track, album or playlist URL</p>
<button class="inline-block bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded-md transition-colors" type="button" data-bs-toggle="modal" data-bs-target="#{{ service_name|lower }}UrlModal">
Import by URL
</button>
</div>
</div>
</div>
<div class="text-center mt-8 text-sm text-gray-500">
<p>Looking for something specific? Try our <a href="{{ url_for('core.view_songs') }}" class="text-teal-600 hover:underline">song library</a> to see what's already in your collection.</p>
</div>
</div>
</div>
</div>
<!-- URL Import Modal -->
<div class="modal fade" id="{{ service_name|lower }}UrlModal" tabindex="-1" aria-labelledby="{{ service_name|lower }}UrlModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{{ service_name|lower }}UrlModalLabel">Import from {{ service_name }} URL</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="{{ service_name|lower }}UrlForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label for="{{ service_name|lower }}Url" class="block text-sm font-medium text-gray-700 mb-1">{{ service_name }} URL</label>
<input type="url" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-teal-500 focus:border-teal-500" id="{{ service_name|lower }}Url" placeholder="{{ url_placeholder }}" required>
<div class="text-sm text-gray-500 mt-1">
Paste a {{ service_name }} track, album, or playlist URL
</div>
</div>
<div class="flex justify-end">
<button type="button" class="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded transition-colors mr-2" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">Import</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('{{ service_name|lower }}UrlForm').addEventListener('submit', function(e) {
e.preventDefault();
const url = document.getElementById('{{ service_name|lower }}Url').value;
if (!url) return;
let match;
let formAction;
let idField;
let idValue;
{% if service_name == 'Spotify' %}
if (match = url.match(/spotify\.com\/track\/([a-zA-Z0-9]+)/)) {
formAction = "{{ track_import_url }}";
idField = "song_id";
idValue = match[1];
} else if (match = url.match(/spotify\.com\/album\/([a-zA-Z0-9]+)/)) {
formAction = "{{ album_import_url }}";
idField = "album_id";
idValue = match[1];
} else if (match = url.match(/spotify\.com\/playlist\/([a-zA-Z0-9]+)/)) {
formAction = "{{ playlist_import_url }}";
idField = "playlist_id";
idValue = match[1];
} else {
alert("Invalid Spotify URL. Please enter a valid track, album, or playlist URL.");
return;
}
{% elif service_name == 'Deezer' %}
if (match = url.match(/deezer\.com\/(?:..\/)?track\/(\d+)/)) {
formAction = "{{ track_import_url }}";
idField = "track_id";
idValue = match[1];
} else if (match = url.match(/deezer\.com\/(?:..\/)?album\/(\d+)/)) {
formAction = "{{ album_import_url }}";
idField = "album_id";
idValue = match[1];
} else if (match = url.match(/deezer\.com\/(?:..\/)?playlist\/(\d+)/)) {
formAction = "{{ playlist_import_url }}";
idField = "playlist_id";
idValue = match[1];
} else {
alert("Invalid Deezer URL. Please enter a valid track, album, or playlist URL.");
return;
}
{% endif %}
// Create and submit the form
const form = document.createElement('form');
form.method = 'POST';
form.action = formAction;
form.style.display = 'none';
// Add CSRF token
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
// Add ID field
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = idField;
idInput.value = idValue;
form.appendChild(idInput);
document.body.appendChild(form);
form.submit();
});
</script>
{% endblock %}
@@ -0,0 +1,516 @@
{% extends 'base.html' %}
{% block title %}{{ service_name }} Search Results: {{ search_term }}{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-4 text-navy-800 font-montserrat">Search Results for "{{ search_term }}"</h2>
<p><a href="{{ search_url }}" class="inline-flex items-center bg-gray-200 hover:bg-gray-300 text-gray-700 py-2 px-4 rounded transition-colors mb-4">
<i class="fas fa-arrow-left mr-2"></i> Back to Search
</a></p>
<div class="mb-6">
<form method="POST" action="{{ url_for(request.endpoint) }}" class="flex flex-col sm:flex-row gap-2 mb-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" class="flex-grow py-2 px-4 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-500 focus:border-teal-500" placeholder="Search for tracks, albums, or playlists" name="search_term" value="{{ search_term }}">
<button class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-6 rounded-lg transition-colors" type="submit">Search</button>
</form>
</div>
<!-- Tabs -->
<div class="mb-6">
<div class="border-b border-gray-200">
<nav class="flex flex-wrap -mb-px" aria-label="Tabs">
<button class="tab-btn inline-block p-4 text-teal-500 border-teal-500 border-b-2 rounded-t-lg active" id="tracks-tab" data-tab="tracks" type="button" role="tab">
{{ tracks_label }} ({{ tracks|length }})
</button>
<button class="tab-btn inline-block p-4 text-gray-600 hover:text-gray-800 hover:border-gray-300 border-b-2 border-transparent rounded-t-lg" id="albums-tab" data-tab="albums" type="button" role="tab">
Albums ({{ albums|length }})
</button>
<button class="tab-btn inline-block p-4 text-gray-600 hover:text-gray-800 hover:border-gray-300 border-b-2 border-transparent rounded-t-lg" id="playlists-tab" data-tab="playlists" type="button" role="tab">
Playlists ({{ playlists|length }})
</button>
</nav>
</div>
</div>
<div class="tab-content">
<!-- Tracks Section -->
<div id="tracks" class="tab-pane block">
<div class="bg-white shadow-md rounded-lg mb-5">
<div class="p-6">
{% if tracks %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead class="bg-gray-100 text-gray-700">
<tr>
<th class="px-4 py-3 text-left">Title</th>
<th class="px-4 py-3 text-left">Artist</th>
{% if has_preview %}
<th class="px-4 py-3 text-left">Preview</th>
{% else %}
<th class="px-4 py-3 text-left">Album</th>
{% endif %}
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for track in tracks %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 flex items-center">
{% if track.image_url %}
<img src="{{ track.image_url }}" alt="Album cover" class="w-12 h-12 rounded mr-2">
{% endif %}
{{ track.name }}
</td>
<td class="px-4 py-3">{{ track.artist }}</td>
{% if has_preview %}
<td class="px-4 py-3">
{% if track.preview_url %}
<audio controls class="w-full max-w-[200px]">
<source src="{{ track.preview_url }}" type="audio/mpeg">
Your browser does not support the audio element.</audio>
{% else %}
<span class="text-gray-500">No preview available</span>
{% endif %}
</td>
{% else %}
<td class="px-4 py-3">{{ track.album }}</td>
{% endif %}
<td class="px-4 py-3">
<form action="{{ track_import_url }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="{{ track_id_field }}" value="{{ track.id }}">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No tracks found matching your search.</div>
{% endif %}
</div>
</div>
</div>
<!-- Albums Section -->
<div id="albums" class="tab-pane hidden">
<div class="bg-white shadow-md rounded-lg mb-5">
<div class="p-6">
{% if albums %}
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for album in albums %}
<div class="bg-white rounded-lg overflow-hidden shadow-md">
{% if album.image_url %}
<img src="{{ album.image_url }}" class="w-full h-48 object-cover album-detail-trigger cursor-pointer" data-id="{{ album.id }}" alt="{{ album.name }} cover">
{% endif %}
<div class="p-4">
<h5 class="text-lg font-semibold text-navy-800 album-detail-trigger cursor-pointer" data-id="{{ album.id }}">{{ album.name }}</h5>
<p class="text-gray-700">{{ album.artist }}</p>
{% if album.track_count %}
<p class="text-gray-600 text-sm">{{ album.track_count }} tracks</p>
{% endif %}
</div>
<div class="bg-gray-100 px-4 py-2 flex justify-between items-center">
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm album-detail-trigger" data-id="{{ album.id }}">
<i class="fas fa-info-circle mr-1"></i> Details
</button>
<form action="{{ album_import_url }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="{{ album_id_field }}" value="{{ album.id }}">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No albums found matching your search.</div>
{% endif %}
</div>
</div>
</div>
<!-- Playlists Section -->
<div id="playlists" class="tab-pane hidden">
<div class="bg-white shadow-md rounded-lg mb-5">
<div class="p-6">
{% if playlists %}
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for playlist in playlists %}
<div class="bg-white rounded-lg overflow-hidden shadow-md">
{% if playlist.image_url %}
<img src="{{ playlist.image_url }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.name }} cover">
{% endif %}
<div class="p-4">
<h5 class="text-lg font-semibold text-navy-800 playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.name }}</h5>
<p class="text-gray-700">By {{ playlist.owner }}</p>
{% if playlist.track_count %}
<p class="text-gray-600 text-sm">{{ playlist.track_count }} tracks</p>
{% endif %}
</div>
<div class="bg-gray-100 px-4 py-2 flex justify-between items-center">
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
<i class="fas fa-info-circle mr-1"></i> Details
</button>
<form action="{{ playlist_import_url }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="{{ playlist_id_field }}" value="{{ playlist.id }}">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No playlists found matching your search.</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Album/Playlist Details Modal -->
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Details</h3>
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times fa-lg"></i>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
<!-- Content will be loaded here -->
<div class="w-full md:w-1/3 flex flex-col items-center">
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
</div>
<div id="modal-metadata" class="w-full text-center mb-4">
<!-- Metadata will be loaded here -->
</div>
</div>
<div class="w-full md:w-2/3">
<p class="text-gray-600 mb-4" id="modal-description"></p>
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
<i class="fas fa-music mr-2"></i> Tracks
</h4>
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
</tr>
</thead>
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
<!-- Tracks will be loaded here -->
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 flex justify-end">
<form action="" method="POST" id="modal-import-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="item_id" id="modal-item-id" value="">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
<i class="fas fa-plus-circle mr-1"></i> Import
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
const modal = document.getElementById('details-modal');
const closeModal = document.getElementById('close-modal');
const modalTitle = document.getElementById('modal-title');
const modalDescription = document.getElementById('modal-description');
const modalImage = document.getElementById('modal-image');
const modalMetadata = document.getElementById('modal-metadata');
const modalTracks = document.getElementById('modal-tracks');
const modalImportForm = document.getElementById('modal-import-form');
const modalItemId = document.getElementById('modal-item-id');
// Tab functionality
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.getAttribute('data-tab');
// Hide all tab panes
tabPanes.forEach(pane => {
pane.classList.add('hidden');
pane.classList.remove('block');
});
// Show the selected tab pane
document.getElementById(tabId).classList.remove('hidden');
document.getElementById(tabId).classList.add('block');
// Update active state for tab buttons
tabButtons.forEach(btn => {
// Remove all active classes
btn.classList.remove('text-teal-500', 'border-teal-500');
btn.classList.add('text-gray-600', 'border-transparent');
});
// Add active classes to clicked button
button.classList.remove('text-gray-600', 'border-transparent');
button.classList.add('text-teal-500', 'border-teal-500');
});
});
// Close modal
closeModal.addEventListener('click', () => {
modal.classList.add('hidden');
});
// Close modal when clicking outside
window.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
}
});
// Format duration from milliseconds to mm:ss
function formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Album detail functionality
document.querySelectorAll('.album-detail-trigger').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const albumId = e.target.getAttribute('data-id');
// Reset modal content
modalTitle.textContent = "Loading album details...";
modalDescription.textContent = "";
modalImage.src = "";
modalMetadata.innerHTML = "";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
`;
// Set up import form
modalImportForm.action = "{{ album_import_url }}";
modalItemId.name = "{{ album_id_field }}";
modalItemId.value = albumId;
// Show modal
modal.classList.remove('hidden');
// Fetch album details
fetch(`/api/{{ service_name|lower }}/album/${albumId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Update modal with album details
modalTitle.textContent = data.name;
if (data.description) {
modalDescription.textContent = data.description;
} else {
modalDescription.textContent = `Album by ${data.artist}`;
}
if (data.image_url) {
modalImage.src = data.image_url;
modalImage.alt = data.name;
}
// Add metadata
modalMetadata.innerHTML = `
<p class="font-semibold text-navy-800">${data.artist}</p>
<p class="text-gray-600">${data.release_date || 'Release date unknown'}</p>
<p class="text-gray-600">${data.tracks.length} tracks</p>
`;
// Add tracks
if (data.tracks && data.tracks.length > 0) {
modalTracks.innerHTML = '';
data.tracks.forEach((track, index) => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
row.innerHTML = `
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
<td class="px-4 py-2">${track.name}</td>
<td class="px-4 py-2 text-sm">${track.artist}</td>
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
`;
modalTracks.appendChild(row);
});
} else {
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
No tracks available
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error fetching album details:', error);
modalTitle.textContent = "Error Loading Details";
modalDescription.textContent = "There was a problem loading the album details.";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-red-500">
Failed to load tracks. Please try again later.
</td>
</tr>
`;
});
});
});
// Playlist detail functionality
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const playlistId = e.target.getAttribute('data-id');
// Reset modal content
modalTitle.textContent = "Loading playlist details...";
modalDescription.textContent = "";
modalImage.src = "";
modalMetadata.innerHTML = "";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
<div class="flex justify-center items-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
<span class="ml-2">Loading tracks...</span>
</div>
</td>
</tr>
`;
// Set up import form
modalImportForm.action = "{{ playlist_import_url }}";
modalItemId.name = "{{ playlist_id_field }}";
modalItemId.value = playlistId;
// Show modal
modal.classList.remove('hidden');
// Fetch playlist details
fetch(`/api/{{ service_name|lower }}/playlist/${playlistId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Update modal with playlist details
modalTitle.textContent = data.name;
if (data.description) {
modalDescription.textContent = data.description;
}
if (data.image_url) {
modalImage.src = data.image_url;
modalImage.alt = data.name;
}
// Add metadata
modalMetadata.innerHTML = `
<p class="font-semibold text-navy-800">By ${data.owner}</p>
<p class="text-gray-600">${data.tracks.length} tracks</p>
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
`;
// Add tracks
if (data.tracks && data.tracks.length > 0) {
modalTracks.innerHTML = '';
data.tracks.forEach((track, index) => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
row.innerHTML = `
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
<td class="px-4 py-2">${track.name}</td>
<td class="px-4 py-2 text-sm">${track.artist}</td>
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
`;
modalTracks.appendChild(row);
});
} else {
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
No tracks available
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error fetching playlist details:', error);
modalTitle.textContent = "Error Loading Details";
modalDescription.textContent = "There was a problem loading the playlist details.";
modalTracks.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-4 text-center text-red-500">
Failed to load tracks. Please try again later.
</td>
</tr>
`;
});
});
});
});
</script>
{% endblock %}
@@ -0,0 +1,172 @@
{% extends 'base.html' %}
{% block title %}Spotify Client Test{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Spotify Client Comparison</h1>
<p class="text-gray-600 mb-6">Comparing spotipy library vs direct API implementation.</p>
<!-- Account Selection -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<form method="GET" action="{{ url_for('import.test_spotify_client') }}" class="space-y-4">
<div>
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
<select name="account" id="account"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<option value="spotify" {% if account == 'spotify' %}selected{% endif %}>spotify</option>
<option value="spotifycharts" {% if account == 'spotifycharts' %}selected{% endif %}>spotifycharts</option>
<option value="spotifymaps" {% if account == 'spotifymaps' %}selected{% endif %}>spotifymaps</option>
<option value="spotifyuk" {% if account == 'spotifyuk' %}selected{% endif %}>spotifyuk</option>
<option value="spotifyusa" {% if account == 'spotifyusa' %}selected{% endif %}>spotifyusa</option>
<option value="spotify_germany" {% if account == 'spotify_germany' %}selected{% endif %}>spotify_germany</option>
</select>
</div>
<div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
Test Account
</button>
</div>
</form>
</div>
<!-- Results Summary -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Results Summary</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Spotipy Results -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Spotipy Implementation</h3>
<div class="space-y-2">
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Playlists Retrieved:</span>
<span class="font-medium">{{ results.spotipy.count }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Expected Total:</span>
<span class="font-medium">{{ results.spotipy.total }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Execution Time:</span>
<span class="font-medium">{{ results.spotipy.time_ms }} ms</span>
</div>
{% if results.spotipy.error %}
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
<strong>Error:</strong> {{ results.spotipy.error }}
</div>
{% endif %}
</div>
</div>
<!-- Direct API Results -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Direct API Implementation</h3>
<div class="space-y-2">
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Playlists Retrieved:</span>
<span class="font-medium">{{ results.direct.count }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Expected Total:</span>
<span class="font-medium">{{ results.direct.total }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Execution Time:</span>
<span class="font-medium">{{ results.direct.time_ms }} ms</span>
</div>
{% if results.direct.error %}
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
<strong>Error:</strong> {{ results.direct.error }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Comparison -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Implementation Comparison</h2>
<div class="space-y-4">
<div class="flex items-center">
<div class="w-1/3 font-medium">Playlists in both implementations:</div>
<div class="w-2/3">{{ comparison.in_both|length }}</div>
</div>
<div class="flex items-center">
<div class="w-1/3 font-medium">Only in spotipy:</div>
<div class="w-2/3">{{ comparison.only_in_spotipy|length }}</div>
</div>
<div class="flex items-center">
<div class="w-1/3 font-medium">Only in direct API:</div>
<div class="w-2/3">{{ comparison.only_in_direct|length }}</div>
</div>
</div>
</div>
<!-- Playlist Details -->
<div class="flex flex-col md:flex-row gap-6">
<!-- Spotipy Playlists -->
<div class="w-full md:w-1/2">
<div class="bg-white shadow-md rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Spotipy Playlists ({{ results.spotipy.count }})</h3>
{% if results.spotipy.playlists %}
<div class="overflow-y-auto max-h-96">
<table class="min-w-full">
<thead>
<tr>
<th class="px-4 py-2 border-b text-left">#</th>
<th class="px-4 py-2 border-b text-left">Name</th>
</tr>
</thead>
<tbody>
{% for playlist in results.spotipy.playlists %}
<tr>
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500">No playlists retrieved.</p>
{% endif %}
</div>
</div>
<!-- Direct API Playlists -->
<div class="w-full md:w-1/2">
<div class="bg-white shadow-md rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Direct API Playlists ({{ results.direct.count }})</h3>
{% if results.direct.playlists %}
<div class="overflow-y-auto max-h-96">
<table class="min-w-full">
<thead>
<tr>
<th class="px-4 py-2 border-b text-left">#</th>
<th class="px-4 py-2 border-b text-left">Name</th>
</tr>
</thead>
<tbody>
{% for playlist in results.direct.playlists %}
<tr>
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500">No playlists retrieved.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,141 @@
{% extends 'base.html' %}
{% block title %}Direct Spotify Authentication{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Direct Spotify Authentication</h1>
<p class="text-gray-600 mb-6">Use a manual bearer token to access the Spotify API directly.</p>
<!-- Status Information -->
{% if spotify_user %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800">Currently authenticated</h3>
<div class="mt-2 text-sm text-green-700">
<p>You are currently authenticated as <strong>{{ spotify_username }}</strong> (ID: {{ spotify_user }}).</p>
</div>
<div class="mt-4">
<form action="{{ url_for('import.direct_spotify_auth') }}" method="POST">
<input type="hidden" name="bearer_token" value="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Sign Out
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% if error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Error</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
</div>
</div>
{% endif %}
{% if success %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800">Success</h3>
<div class="mt-2 text-sm text-green-700">
<p>{{ success }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Instructions -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold mb-4 text-navy-800">How to Get a Bearer Token</h2>
<ol class="list-decimal pl-5 space-y-4 text-gray-700">
<li>
<p class="mb-1">Go to the <a href="https://developer.spotify.com/console/" target="_blank" class="text-teal-600 hover:underline">Spotify Developer Console</a></p>
<p class="text-sm text-gray-600">You'll need to log in with your Spotify account</p>
</li>
<li>
<p class="mb-1">Select any API endpoint (e.g., "Get Current User's Profile")</p>
<p class="text-sm text-gray-600">The specific endpoint doesn't matter, we just need to generate a token</p>
</li>
<li>
<p class="mb-1">Click the "Get Token" button</p>
<p class="text-sm text-gray-600">Make sure to select the following scopes:</p>
<ul class="list-disc pl-5 mt-1 text-sm text-gray-600">
<li>user-read-private</li>
<li>user-read-email</li>
<li>playlist-read-private</li>
<li>playlist-read-collaborative</li>
</ul>
</li>
<li>
<p class="mb-1">Copy the generated OAuth token (it starts with "BQ...")</p>
</li>
<li>
<p class="mb-1">Paste the token in the form below and click "Authenticate"</p>
</li>
</ol>
</div>
<!-- Token Entry Form -->
<div class="bg-white shadow-md rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4 text-navy-800">Enter Your Bearer Token</h2>
<form action="{{ url_for('import.direct_spotify_auth') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label for="bearer_token" class="block text-sm font-medium text-gray-700 mb-1">Bearer Token</label>
<textarea name="bearer_token" id="bearer_token" rows="3"
placeholder="BQBcQ1foQG0x14axu2kQVz..."
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500"></textarea>
<p class="mt-1 text-xs text-gray-500">The token will expire after about 1 hour. You'll need to generate a new one after that.</p>
</div>
<div class="mt-4">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-orange-500 hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">
Authenticate
</button>
</div>
</form>
</div>
<!-- Next Steps -->
{% if spotify_user %}
<div class="mt-8 text-center">
<h3 class="text-lg font-semibold mb-2">Ready to go!</h3>
<p class="text-gray-600 mb-4">You're authenticated and can now use the direct Spotify client.</p>
<div class="flex justify-center space-x-4">
<a href="{{ url_for('import.direct_official_playlists') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-teal-600 hover:bg-teal-700">
Browse Official Playlists
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,461 @@
{% extends 'base.html' %}
{% block title %}Audio Settings - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-2">Custom Audio Settings</h1>
<p class="text-gray-600">Upload or generate your own intro, outro, and replay announcements for quizzes.</p>
</div>
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-6 p-4 rounded {% if category == 'danger' %}bg-red-50 text-red-700 border border-red-300{% elif category == 'success' %}bg-green-50 text-green-700 border border-green-300{% else %}bg-blue-50 text-blue-700 border border-blue-300{% endif %}">
{{ message }}
</div>
{% endfor %}
<!-- Intro Audio Section -->
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<div class="bg-navy-50 p-4 border-b">
<h2 class="text-xl font-semibold text-navy-800">Intro Audio</h2>
<p class="text-gray-600 text-sm mt-1">This audio plays at the beginning of your music quiz</p>
</div>
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
{% if current_user.intro_mp3 %}
<div class="p-4 bg-navy-50 rounded-lg">
<p class="font-medium text-navy-800 mb-2">Custom intro audio is active</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/intro.mp3') if not current_user.intro_mp3 else url_for('core.serve_user_audio', filepath=current_user.intro_mp3) }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<form method="POST" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="reset">
<input type="hidden" name="mp3_type" value="intro">
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
</form>
</div>
{% else %}
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-700 mb-2">Using default intro audio</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/intro.mp3') }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
{% endif %}
</div>
<div class="border-t pt-6">
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
<div class="mb-8">
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="upload">
<input type="hidden" name="mp3_type" value="intro">
<div class="flex items-center space-x-4">
<div class="flex-grow">
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
</div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
Upload
</button>
</div>
</form>
</div>
<div>
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
{% if has_tts_services %}
<form method="GET" class="mb-4 flex items-end space-x-2">
<input type="hidden" name="mp3_type" value="intro">
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for svc in tts_services %}
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
{% endfor %}
</select>
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
</form>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="generate">
<input type="hidden" name="mp3_type" value="intro">
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for voice in selected_service.voices %}
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
{% endfor %}
</select>
</div>
{% if selected_service.id == 'openai' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
<div class="mb-4 bg-gray-50 p-4 rounded-md">
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More variable</span>
<span>More stable</span>
</div>
</div>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More unique</span>
<span>More similar</span>
</div>
</div>
</div>
{% endif %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
placeholder="Enter text for intro announcement...">{{ default_texts.intro }}</textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
Generate Audio
</button>
</div>
</form>
{% else %}
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Outro Audio Section -->
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<div class="bg-navy-50 p-4 border-b">
<h2 class="text-xl font-semibold text-navy-800">Outro Audio</h2>
<p class="text-gray-600 text-sm mt-1">This audio plays at the end of your music quiz</p>
</div>
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
{% if current_user.outro_mp3 %}
<div class="p-4 bg-navy-50 rounded-lg">
<p class="font-medium text-navy-800 mb-2">Custom outro audio is active</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/outro.mp3') if not current_user.outro_mp3 else url_for('core.serve_user_audio', filepath=current_user.outro_mp3) }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<form method="POST" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="reset">
<input type="hidden" name="mp3_type" value="outro">
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
</form>
</div>
{% else %}
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-700 mb-2">Using default outro audio</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/outro.mp3') }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
{% endif %}
</div>
<div class="border-t pt-6">
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
<div class="mb-8">
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="upload">
<input type="hidden" name="mp3_type" value="outro">
<div class="flex items-center space-x-4">
<div class="flex-grow">
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
</div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
Upload
</button>
</div>
</form>
</div>
<div>
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
{% if has_tts_services %}
<form method="GET" class="mb-4 flex items-end space-x-2">
<input type="hidden" name="mp3_type" value="outro">
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for svc in tts_services %}
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
{% endfor %}
</select>
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
</form>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="generate">
<input type="hidden" name="mp3_type" value="outro">
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for voice in selected_service.voices %}
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
{% endfor %}
</select>
</div>
{% if selected_service.id == 'openai' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
<div class="mb-4 bg-gray-50 p-4 rounded-md">
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More variable</span>
<span>More stable</span>
</div>
</div>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More unique</span>
<span>More similar</span>
</div>
</div>
</div>
{% endif %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
placeholder="Enter text for outro announcement...">{{ default_texts.outro }}</textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
Generate Audio
</button>
</div>
</form>
{% else %}
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Replay Audio Section -->
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<div class="bg-navy-50 p-4 border-b">
<h2 class="text-xl font-semibold text-navy-800">Replay Audio</h2>
<p class="text-gray-600 text-sm mt-1">This audio plays before replaying all songs at the end of the quiz</p>
</div>
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
{% if current_user.replay_mp3 %}
<div class="p-4 bg-navy-50 rounded-lg">
<p class="font-medium text-navy-800 mb-2">Custom replay audio is active</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/replay.mp3') if not current_user.replay_mp3 else url_for('core.serve_user_audio', filepath=current_user.replay_mp3) }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<form method="POST" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="reset">
<input type="hidden" name="mp3_type" value="replay">
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
</form>
</div>
{% else %}
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-700 mb-2">Using default replay audio</p>
<audio controls class="w-full">
<source src="{{ url_for('static', filename='audio/replay.mp3') }}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
{% endif %}
</div>
<div class="border-t pt-6">
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
<div class="mb-8">
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="upload">
<input type="hidden" name="mp3_type" value="replay">
<div class="flex items-center space-x-4">
<div class="flex-grow">
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
</div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
Upload
</button>
</div>
</form>
</div>
<div>
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
{% if has_tts_services %}
<form method="GET" class="mb-4 flex items-end space-x-2">
<input type="hidden" name="mp3_type" value="replay">
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for svc in tts_services %}
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
{% endfor %}
</select>
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
</form>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="generate">
<input type="hidden" name="mp3_type" value="replay">
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for voice in selected_service.voices %}
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
{% endfor %}
</select>
</div>
{% if selected_service.id == 'openai' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
{% for model in selected_service.models %}
<option value="{{ model.id }}">{{ model.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
<div class="mb-4 bg-gray-50 p-4 rounded-md">
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More variable</span>
<span>More stable</span>
</div>
</div>
<div class="mb-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>More unique</span>
<span>More similar</span>
</div>
</div>
</div>
{% endif %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
placeholder="Enter text for replay announcement...">{{ default_texts.replay }}</textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
Generate Audio
</button>
</div>
</form>
{% else %}
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="mt-8 text-center">
<a href="{{ url_for('users.profile') }}" class="inline-block bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded transition-colors">
Back to Profile
</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- No Alpine.js needed -->
{% endblock %}
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}Change Password{% endblock %}
{% block content %}
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Change Password</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.change_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="current_password">
Current Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="current_password" name="current_password" type="password" required>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="new_password">
New Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="new_password" name="new_password" type="password" required>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="confirm_password">
Confirm New Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="confirm_password" name="confirm_password" type="password" required>
</div>
<div class="flex items-center justify-between">
<button class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
Change Password
</button>
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="{{ url_for('users.profile') }}">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,566 @@
{% extends 'base.html' %}
{% block title %}Edit Profile{% endblock %}
{% block content %}
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Edit Profile</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.edit_profile') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
Username
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username" name="username" type="text" placeholder="Username" value="{{ current_user.username }}">
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="email">
Email
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email" name="email" type="email" placeholder="Email address" value="{{ current_user.email }}">
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="first_name">
First Name
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="first_name" name="first_name" type="text" placeholder="First name" value="{{ current_user.first_name or '' }}">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="last_name">
Last Name
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="last_name" name="last_name" type="text" placeholder="Last name" value="{{ current_user.last_name or '' }}">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="dropbox_export_path">
Dropbox Export Folder
</label>
<div class="flex space-x-2">
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="dropbox_export_path" name="dropbox_export_path" type="text" placeholder="/QuizzicalBeats"
value="{{ current_user.dropbox_export_path or '/QuizzicalBeats' }}">
<button type="button" id="browse-dropbox-btn"
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded flex items-center"
{% if not current_user.dropbox_token %}disabled{% endif %}>
<i class="fab fa-dropbox mr-1"></i> Browse
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Set the Dropbox folder where your exported rounds will be saved.</p>
{% if not current_user.dropbox_token %}
<p class="text-xs text-red-500 mt-1">
<i class="fas fa-exclamation-circle mr-1"></i>
<a href="{{ url_for('users.dropbox_auth') }}" class="underline">Connect your Dropbox account</a> to browse folders.
</p>
{% endif %}
<div id="dropbox-error-container" class="hidden mt-3 p-3 bg-red-100 text-sm rounded">
<div class="flex justify-between">
<h4 class="font-semibold mb-1">Dropbox API Error</h4>
<button id="close-error-btn" class="text-red-700">&times;</button>
</div>
<div id="dropbox-error-details"></div>
<div class="mt-2">
<button id="dropbox-debug-btn" type="button" class="text-xs bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded">
Show Technical Details
</button>
<div id="dropbox-debug-info" class="hidden mt-2 bg-gray-100 p-2 rounded font-mono text-xs overflow-x-auto"></div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<button class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
Save Changes
</button>
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="{{ url_for('users.profile') }}">
Cancel
</a>
</div>
</form>
</div>
<!-- Dropbox Folder Browser Modal -->
<div id="dropbox-folder-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-bold text-navy-800">Select Dropbox Folder</h3>
<button id="close-dropbox-modal" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-4 border-b border-gray-200 flex items-center bg-gray-50">
<div class="flex-1 flex items-center">
<button id="parent-folder-btn" class="bg-gray-200 hover:bg-gray-300 rounded p-1 mr-2">
<i class="fas fa-arrow-up"></i>
</button>
<span id="current-path" class="text-gray-600 font-mono text-sm">/</span>
</div>
<div class="flex items-center space-x-2">
<button id="create-folder-btn" class="bg-teal-500 hover:bg-teal-600 text-white rounded p-1 px-2 text-sm flex items-center">
<i class="fas fa-folder-plus mr-1"></i> Create Folder
</button>
<button id="refresh-folders-btn" class="bg-gray-200 hover:bg-gray-300 rounded p-1">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div class="overflow-y-auto flex-1 p-2" style="max-height: 60vh;">
<div id="folder-list" class="space-y-1">
<div class="text-center p-8">
<div class="animate-spin inline-block w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mb-2"></div>
<p>Loading folders...</p>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-2">
<button id="select-folder-btn" class="bg-teal-500 hover:bg-teal-600 text-white px-4 py-2 rounded">
Select This Folder
</button>
<button id="cancel-selection-btn" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded">
Cancel
</button>
</div>
</div>
</div>
<!-- Create Folder Modal -->
<div id="create-folder-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-lg w-full max-w-md">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-bold text-navy-800">Create New Folder</h3>
</div>
<div class="p-6">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="new-folder-name">
Folder Name
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="new-folder-name" type="text" placeholder="My New Folder">
<p id="folder-name-error" class="hidden mt-1 text-xs text-red-500"></p>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">
Parent Folder
</label>
<p class="py-2 px-3 bg-gray-100 rounded text-gray-700 font-mono text-sm" id="parent-folder-path"></p>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button id="create-folder-submit" class="bg-teal-500 hover:bg-teal-600 text-white px-4 py-2 rounded">
Create
</button>
<button id="create-folder-cancel" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const browseDropboxBtn = document.getElementById('browse-dropbox-btn');
const dropboxFolderModal = document.getElementById('dropbox-folder-modal');
const closeDropboxModal = document.getElementById('close-dropbox-modal');
const cancelSelectionBtn = document.getElementById('cancel-selection-btn');
const selectFolderBtn = document.getElementById('select-folder-btn');
const folderList = document.getElementById('folder-list');
const currentPathDisplay = document.getElementById('current-path');
const parentFolderBtn = document.getElementById('parent-folder-btn');
const refreshFoldersBtn = document.getElementById('refresh-folders-btn');
const dropboxExportPath = document.getElementById('dropbox_export_path');
const createFolderBtn = document.getElementById('create-folder-btn');
const createFolderModal = document.getElementById('create-folder-modal');
const createFolderSubmit = document.getElementById('create-folder-submit');
const createFolderCancel = document.getElementById('create-folder-cancel');
const newFolderNameInput = document.getElementById('new-folder-name');
const folderNameError = document.getElementById('folder-name-error');
const parentFolderPathDisplay = document.getElementById('parent-folder-path');
// Error display elements
const errorContainer = document.getElementById('dropbox-error-container');
const errorDetails = document.getElementById('dropbox-error-details');
const closeErrorBtn = document.getElementById('close-error-btn');
const debugBtn = document.getElementById('dropbox-debug-btn');
const debugInfo = document.getElementById('dropbox-debug-info');
let currentPath = '';
let lastApiResponse = null;
// Open the Dropbox folder browser modal
browseDropboxBtn.addEventListener('click', function() {
dropboxFolderModal.classList.remove('hidden');
// Always start at root when opening the browser
currentPath = '/';
loadFolders(currentPath);
});
// Close the modal when clicking close button or cancel
closeDropboxModal.addEventListener('click', closeModal);
cancelSelectionBtn.addEventListener('click', closeModal);
// Close modal when clicking outside
dropboxFolderModal.addEventListener('click', function(event) {
if (event.target === dropboxFolderModal) {
closeModal();
}
});
// Close error display
closeErrorBtn.addEventListener('click', function() {
errorContainer.classList.add('hidden');
});
// Toggle debug info
debugBtn.addEventListener('click', function() {
if (debugInfo.classList.contains('hidden')) {
debugInfo.classList.remove('hidden');
debugBtn.textContent = 'Hide Technical Details';
} else {
debugInfo.classList.add('hidden');
debugBtn.textContent = 'Show Technical Details';
}
});
// Select the current folder
selectFolderBtn.addEventListener('click', function() {
dropboxExportPath.value = currentPath;
closeModal();
});
// Navigate to parent folder
parentFolderBtn.addEventListener('click', function() {
if (currentPath === '/' || currentPath === '') {
return; // Already at root
}
// Get parent path
const parts = currentPath.split('/').filter(p => p);
parts.pop(); // Remove last folder
const parentPath = '/' + parts.join('/');
loadFolders(parentPath);
});
// Refresh current folder
refreshFoldersBtn.addEventListener('click', function() {
loadFolders(currentPath);
});
// Open the Create Folder modal
createFolderBtn.addEventListener('click', function() {
createFolderModal.classList.remove('hidden');
parentFolderPathDisplay.textContent = currentPath;
newFolderNameInput.value = '';
folderNameError.classList.add('hidden');
// Focus the input field for better UX
setTimeout(() => {
newFolderNameInput.focus();
}, 100);
});
// Close the Create Folder modal when clicking outside
createFolderModal.addEventListener('click', function(event) {
if (event.target === createFolderModal) {
createFolderModal.classList.add('hidden');
}
});
// Close the Create Folder modal
createFolderCancel.addEventListener('click', function() {
createFolderModal.classList.add('hidden');
});
// Handle Enter key in the folder name input
newFolderNameInput.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
createFolderSubmit.click();
}
});
// Submit new folder creation
createFolderSubmit.addEventListener('click', function() {
const folderName = newFolderNameInput.value.trim();
if (!folderName) {
folderNameError.textContent = 'Folder name cannot be empty.';
folderNameError.classList.remove('hidden');
return;
}
// Check for invalid characters
if (/[\/\\:*?"<>|]/.test(folderName)) {
folderNameError.textContent = 'Folder name contains invalid characters.';
folderNameError.classList.remove('hidden');
return;
}
// Show loading state
createFolderSubmit.disabled = true;
createFolderSubmit.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Creating...';
folderNameError.classList.add('hidden');
// Call API to create folder
fetch('/api/dropbox/create-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value
},
body: JSON.stringify({
parent_path: currentPath,
folder_name: folderName
})
})
.then(response => {
if (!response.ok) {
return response.json().then(errorData => {
throw {
status: response.status,
statusText: response.statusText,
data: errorData
};
});
}
return response.json();
})
.then(data => {
if (data.error) {
throw {
status: 500,
statusText: 'Server Error',
data: { error: data.error }
};
}
// Close modal and refresh folder list
createFolderModal.classList.add('hidden');
// If the folder was created successfully, navigate to it
if (data.path) {
loadFolders(data.path);
} else {
// Just refresh the current folder
loadFolders(currentPath);
}
// Show success message
const successDiv = document.createElement('div');
successDiv.className = 'p-2 mb-3 bg-green-100 text-green-800 rounded text-sm';
successDiv.textContent = `Folder "${folderName}" created successfully.`;
successDiv.style.opacity = '1';
successDiv.style.transition = 'opacity 0.5s ease-in-out';
folderList.insertBefore(successDiv, folderList.firstChild);
// Fade out success message after 3 seconds
setTimeout(() => {
successDiv.style.opacity = '0';
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 500);
}, 3000);
})
.catch(error => {
console.error('Error creating folder:', error);
// Show error message in the modal
folderNameError.textContent = error.data?.error ||
(error.status === 409 ? 'A folder with this name already exists.' :
error.statusText || 'Error creating folder');
folderNameError.classList.remove('hidden');
// Also show detailed error in the main error display if it's a server error
if (error.status >= 500) {
showError(error);
}
})
.finally(() => {
// Reset button state
createFolderSubmit.disabled = false;
createFolderSubmit.innerHTML = 'Create';
});
});
// Function to load folders from Dropbox
function loadFolders(path) {
// Hide any previous errors
errorContainer.classList.add('hidden');
// Update UI
currentPath = path;
currentPathDisplay.textContent = path || '/';
// Show loading indicator
folderList.innerHTML = `
<div class="text-center p-8">
<div class="animate-spin inline-block w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mb-2"></div>
<p>Loading folders...</p>
</div>
`;
// Make API request to get folders
fetch('/api/dropbox/folders?path=' + encodeURIComponent(path))
.then(response => {
if (!response.ok) {
return response.json().then(errorData => {
throw {
status: response.status,
statusText: response.statusText,
data: errorData
};
});
}
return response.json();
})
.then(data => {
// Save the response for debugging
lastApiResponse = data;
if (data.error) {
throw {
status: 500,
statusText: 'Server Error',
data: data
};
}
// Check if there's a warning message (returned when a path doesn't exist)
if (data.warning) {
// Display warning message
const warningDiv = document.createElement('div');
warningDiv.className = 'p-2 mb-3 bg-yellow-100 text-yellow-800 rounded text-sm';
warningDiv.textContent = data.warning;
folderList.innerHTML = '';
folderList.appendChild(warningDiv);
// Update the current path
currentPath = data.path;
currentPathDisplay.textContent = data.path;
} else {
// Clear folder list
folderList.innerHTML = '';
}
if (data.folders.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'text-center p-8 text-gray-500';
emptyDiv.innerHTML = `
<i class="fas fa-folder-open text-4xl mb-2"></i>
<p>No folders found in this location</p>
`;
folderList.appendChild(emptyDiv);
return;
}
// Sort folders alphabetically
data.folders.sort((a, b) => a.name.localeCompare(b.name));
// Add each folder to the list
data.folders.forEach(folder => {
const folderItem = document.createElement('div');
folderItem.className = 'p-2 hover:bg-gray-100 rounded cursor-pointer flex items-center';
folderItem.innerHTML = `
<i class="fas fa-folder text-blue-500 mr-2"></i>
<span>${folder.name}</span>
`;
folderItem.addEventListener('click', function() {
loadFolders(folder.path_display);
});
folderList.appendChild(folderItem);
});
})
.catch(error => {
console.error('Error loading folders:', error);
// Display error in the folder list
folderList.innerHTML = `
<div class="text-center p-8 text-red-500">
<i class="fas fa-exclamation-circle text-4xl mb-2"></i>
<p>${error.data?.error || error.statusText || 'Error loading folders'}</p>
<p class="text-sm mt-2">Make sure your Dropbox account is connected</p>
</div>
`;
// Also show detailed error in the profile page
showError(error);
});
}
function showError(error) {
// Format and display the error information
errorContainer.classList.remove('hidden');
let errorMessage = '';
if (error.status) {
errorMessage += `HTTP ${error.status}: `;
}
if (error.data && error.data.error) {
errorMessage += error.data.error;
} else if (error.statusText) {
errorMessage += error.statusText;
} else {
errorMessage += 'Unknown error occurred';
}
errorDetails.textContent = errorMessage;
// Show technical details
let debugText = '';
if (error.data) {
if (error.data.details) {
debugText += "Error Details:\n" + JSON.stringify(error.data.details, null, 2) + "\n\n";
}
if (error.data.traceback) {
debugText += "Traceback:\n" + error.data.traceback + "\n\n";
}
if (error.data.raw_response) {
debugText += "Raw Response:\n" + error.data.raw_response + "\n\n";
}
}
if (!debugText) {
debugText = JSON.stringify(error, null, 2);
}
debugInfo.textContent = debugText;
}
function closeModal() {
dropboxFolderModal.classList.add('hidden');
}
});
</script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More