Initial clean commit
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,166 @@
|
||||
# Quizzical Beats TODO List
|
||||
|
||||
## ✅ Completed Milestones
|
||||
|
||||
### Milestone 1: Spotify Integration Fix (Immediate Priority)
|
||||
|
||||
* [x] Fix Spotify playlist import functionality
|
||||
|
||||
* [x] Implement proper pagination for playlist retrieval
|
||||
* [x] Add better error handling for API rate limits
|
||||
* [x] Refactor Spotify client code for maintainability
|
||||
* [x] Add logging to track API requests and responses for debugging
|
||||
|
||||
### Milestone 2: Authentication Foundation
|
||||
|
||||
* [x] Design database schema for users and roles
|
||||
* [x] Implement basic authentication system with local username/password
|
||||
* [x] Create user management interfaces (register, login, profile)
|
||||
* [x] Set up admin role functionality
|
||||
* [x] Implement secure password handling and session management
|
||||
|
||||
### Milestone 3: Spotify Integration with User Accounts
|
||||
|
||||
* [x] Migrate Spotify token storage to user-specific model
|
||||
* [x] Add Spotify OAuth login option
|
||||
* [x] Create fallback mechanism for service account
|
||||
* [x] Link user playlists with their Spotify accounts
|
||||
|
||||
### Milestone 4: Enhanced User Experience
|
||||
|
||||
* [x] Enable user-specific intro/outro/replay MP3s
|
||||
* [x] Update email system to use logged-in user's email
|
||||
* [x] Implement user preferences and settings
|
||||
|
||||
### Milestone 5: Additional OAuth Providers
|
||||
|
||||
* [x] Add Google OAuth integration
|
||||
* [x] Add Authentik OAuth integration
|
||||
* [x] Ensure consistent user experience across auth methods
|
||||
|
||||
### Milestone 6: Advanced Features & Optimizations
|
||||
|
||||
* [x] Create comprehensive logging and monitoring
|
||||
|
||||
### Milestone 7: **"Bulletproof Backups" Release** – System Backup & Restore
|
||||
|
||||
* [x] Implement full system-wide backup and restore
|
||||
|
||||
* [x] Backup all critical data: DB, rounds, MP3s, user settings
|
||||
* [x] Support both manual and scheduled backups
|
||||
* [x] Admin UI to download, manage, and restore backups
|
||||
* [x] Store backups to local filesystem or optional cloud locations
|
||||
* [x] Include versioning to support future schema migration compatibility
|
||||
* [x] Add internal backup verification/checksum logic
|
||||
* [x] Integrate with Ofelia scheduler for automatic backups
|
||||
* [x] Add command-line backup functionality for scripting
|
||||
* [x] Implement retention policy for automatic cleanup of old backups
|
||||
* [x] Add system health check and status dashboard
|
||||
|
||||
### Milestone 8: **"Dropbox Dispatch" Release** – Round Export via Dropbox
|
||||
|
||||
* [x] Add Dropbox OAuth login per user
|
||||
* [x] Let users link/unlink and view Dropbox account info
|
||||
* [x] Export full rounds (metadata + MP3s) as ZIP or PDF
|
||||
* [x] Push selected rounds to user's Dropbox via UI
|
||||
* [x] Add fallback handling and Dropbox access token refresh
|
||||
* [x] Log export actions and errors for transparency
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Upcoming Milestones
|
||||
|
||||
### 🎯 Milestone 9: **"Storage Sanctuary" Release** – Multi-Provider Storage
|
||||
|
||||
* [ ] Implement cloud storage backend abstraction
|
||||
* [ ] Add support for AWS S3 storage
|
||||
* [ ] Configure S3 credentials and bucket management
|
||||
* [ ] Support for optional encryption and lifecycle policies
|
||||
* [ ] Add support for S3-compatible storage (MinIO, Wasabi, etc.)
|
||||
* [ ] Integrate Dropbox as a storage backend
|
||||
* [ ] Create unified storage management UI
|
||||
* [ ] Export backups to cloud storage
|
||||
* [ ] Store and retrieve music rounds from cloud storage
|
||||
* [ ] Add background synchronization and status tracking
|
||||
* [ ] Implement bandwidth-efficient differential uploads
|
||||
|
||||
### 🎯 Milestone 10: **"Rhythm Roundsmith" Release** – AI-Generated Quiz Rounds
|
||||
|
||||
* [ ] Develop AI module to generate full quiz rounds
|
||||
|
||||
* [ ] Use existing song metadata (genre, year, tempo, artist)
|
||||
* [ ] Support different quiz formats: multiple-choice, guess-the-clip, open-ended
|
||||
* [ ] Create quiz rounds from a playlist
|
||||
* [ ] Let users prompt AI with a theme or vibe (e.g., "Chill 80s", "Dancefloor Divas")
|
||||
* [ ] Include fallback logic when metadata is sparse
|
||||
* [ ] Let users review/edit AI-generated questions before saving
|
||||
* [ ] Log prompt/response pairs for model improvement
|
||||
* [ ] Add backend abstraction to swap AI providers (OpenAI, Mistral, etc.)
|
||||
* [ ] Build tuning pipeline for prompt quality testing
|
||||
|
||||
### 🎯 Milestone 11: **"Collaboration Core" Release** – Multi-User Round Sharing
|
||||
|
||||
* [ ] Allow shared editing of rounds
|
||||
* [ ] Add collaboration roles (view, comment, edit)
|
||||
* [ ] Invite others to rounds via username/email
|
||||
* [ ] Show who is currently editing (presence indicator)
|
||||
* [ ] Track revision history and changes
|
||||
* [ ] Allow public view-only sharing links with optional expiration
|
||||
* [ ] Display access audit log (who opened/edited and when)
|
||||
|
||||
### 🎯 Milestone 12: **"Profile Personalizer" Release** – User Preferences & Tagging
|
||||
|
||||
* [x] User-specific intro/outro/replay MP3 fallback system
|
||||
* [x] Persistent custom user settings
|
||||
* [ ] Let users define default round format
|
||||
* [ ] Implement personal tags for rounds (e.g., "pub night", "2020s", "pop")
|
||||
* [ ] Add filtering and sorting by tag
|
||||
* [ ] Dark mode toggle
|
||||
|
||||
### 🎯 Milestone 13: **"Scraper Symphony" Release** – External Music Data
|
||||
|
||||
* [ ] Identify 2–3 public music chart sources (Billboard, Official Charts, etc.)
|
||||
* [ ] Build scraper with user-agent rotation and proxy support
|
||||
* [ ] Normalize results into song data model
|
||||
* [ ] Link scraped data to existing Spotify records
|
||||
* [ ] Review interface for admins to validate scraped data
|
||||
* [ ] Store scraper runs and log errors transparently
|
||||
* [ ] Add cron-based scheduler for scraper refresh
|
||||
|
||||
### 🎯 Milestone 14: **"Alert Amplifier" Release** – Notifications & Emails
|
||||
|
||||
* [ ] Email verification for new accounts
|
||||
* [ ] Notify users when round generation completes
|
||||
* [ ] Notify users of expiring OAuth tokens (Spotify, Dropbox)
|
||||
* [ ] Optional weekly usage summary for admins
|
||||
* [ ] Push notification support via browser or Telegram
|
||||
|
||||
### 🎯 Milestone 15: **"Performance Pulse" Release** – Scaling & Speed
|
||||
|
||||
* [ ] Index high-traffic database fields (tags, dates, users)
|
||||
* [ ] Paginate round/song lists
|
||||
* [ ] Lazy load MP3 previews and large content
|
||||
* [ ] Add Redis/memory cache layer for read-heavy endpoints
|
||||
* [ ] Load test with simulated users and large playlists
|
||||
|
||||
### 🎯 Milestone 16: **"Deployment Dynamo" Release** – CI/CD and Maintenance
|
||||
|
||||
* [ ] GitHub Actions or GitLab CI/CD pipeline for builds and tests
|
||||
* [ ] Nightly backup job with status alert
|
||||
* [ ] Add Sentry or similar for exception tracking
|
||||
* [ ] Updater script for pulling latest Git commits safely
|
||||
* [ ] Add status endpoint (`/healthz`) for uptime monitors
|
||||
|
||||
---
|
||||
|
||||
## Other Features / Ideas in Progress
|
||||
|
||||
* [ ] "Blind Test" toggle per round (hide title/artist metadata)
|
||||
* [ ] Team scoreboard (live projection mode)
|
||||
* [ ] Public round library with clone/save features
|
||||
* [ ] REST API for third-party integration (e.g., with trivia bots)
|
||||
* [ ] Audio fingerprint validation for user-uploaded MP3s
|
||||
* [ ] Round analytics: usage frequency, popularity, ratings
|
||||
|
||||
*Last updated: May 8, 2025*
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# This file makes the helpers directory a proper Python package
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
|
||||
@@ -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'))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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=[])
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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'))
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 579 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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(/&/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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
« 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 »
|
||||
</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">
|
||||
« 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 »
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">×</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 %}
|
||||