Add Spotify integration with improved token management and user interface

- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users.
- Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information.
- Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information.
- Added logging for token management processes to enhance debugging and monitoring.
- Introduced a debug client for Spotify interactions to facilitate easier testing and development.
This commit is contained in:
Christian Krakau-Louis
2025-05-27 21:40:37 +02:00
parent d2772b88fe
commit 2f55f898ed
44 changed files with 2317 additions and 1884 deletions
+51 -9
View File
@@ -91,7 +91,49 @@
## 🆕 Upcoming Milestones
### 🎯 Milestone 10: **"Scraper Symphony" Release** External Music Data
### 🎯 Milestone 10: **"Import Intelligent" Release** Enhanced Song Import System
* [ ] Implement parallel playlist import processing
* [ ] Design and implement import queue system
* [ ] Add background worker system for processing imports
* [ ] Enable multiple concurrent import jobs
* [ ] Implement priority system for import jobs
* [ ] Create comprehensive import progress reporting
* [ ] Real-time progress indicators for active imports
* [ ] Detailed error reporting with recovery options
* [ ] Email notifications when imports complete
* [ ] Redesign Spotify search functionality
* [ ] Improve search algorithm and relevance scoring
* [ ] Add advanced filtering options (year, genre, etc.)
* [ ] Implement caching for frequent searches
* [ ] Ensure proper pagination and performance
* [ ] Add text-based playlist import capability
* [ ] Support for pasting plain text playlists
* [ ] Line-by-line parsing with artist/song detection
* [ ] Confidence scoring for matches
* [ ] Manual review interface for low-confidence matches
* [ ] Support for various text formats (CSV, plain text, etc.)
* [ ] Build playlist scraper for "curated" Spotify playlists
* [ ] Web scraper for Spotify web interface
* [ ] Extract track names and artists from HTML/JSON
* [ ] Format compatibility with text-based import
* [ ] Handle rate limiting and detection prevention
* [ ] Implement production-grade web server
* [ ] Replace Flask development server with Gunicorn/uWSGI
* [ ] Configure proper worker processes and threads
* [ ] Implement graceful shutdown and restart capabilities
* [ ] Add Nginx as reverse proxy with proper caching
* [ ] Set up proper SSL termination and security headers
* [ ] Optimize static file serving
* [ ] Scale database for concurrent operations
* [ ] Implement connection pooling
* [ ] Add database query optimizations and indexing
* [ ] Configure database for concurrent write operations
* [ ] Set up monitoring for database performance
* [ ] Implement read/write splitting if necessary
* [ ] Add database migration safety for zero-downtime upgrades
### 🎯 Milestone 11: **"Scraper Symphony" Release** External Music Data
* [ ] Identify 23 public music chart sources (Billboard, Official Charts, etc.)
* [ ] Build scraper with user-agent rotation and proxy support
@@ -101,7 +143,7 @@
* [ ] Store scraper runs and log errors transparently
* [ ] Add cron-based scheduler for scraper refresh
### 🎯 Milestone 11: **"Alert Amplifier" Release** Notifications & Emails
### 🎯 Milestone 12: **"Alert Amplifier" Release** Notifications & Emails
* [ ] Email verification for new accounts
* [ ] Notify users when round generation completes
@@ -109,7 +151,7 @@
* [ ] Optional weekly usage summary for admins
* [ ] Push notification support via browser or Telegram
### 🎯 Milestone 12: **"Rhythm Roundsmith" Release** AI-Generated Quiz Rounds
### 🎯 Milestone 13: **"Rhythm Roundsmith" Release** AI-Generated Quiz Rounds
* [ ] Develop AI module to generate full quiz rounds
* [ ] Use existing song metadata (genre, year, tempo, artist)
@@ -122,7 +164,7 @@
* [ ] Add backend abstraction to swap AI providers (OpenAI, Mistral, etc.)
* [ ] Build tuning pipeline for prompt quality testing
### 🎯 Milestone 13: **"Storage Sanctuary" Release** Multi-Provider Storage
### 🎯 Milestone 14: **"Storage Sanctuary" Release** Multi-Provider Storage
* [ ] Implement cloud storage backend abstraction
* [ ] Add support for AWS S3 storage
@@ -136,7 +178,7 @@
* [ ] Add background synchronization and status tracking
* [ ] Implement bandwidth-efficient differential uploads
### 🎯 Milestone 14: **"Collaboration Core" Release** Multi-User Round Sharing
### 🎯 Milestone 15: **"Collaboration Core" Release** Multi-User Round Sharing
* [ ] Allow shared editing of rounds
* [ ] Add collaboration roles (view, comment, edit)
@@ -146,7 +188,7 @@
* [ ] Allow public view-only sharing links with optional expiration
* [ ] Display access audit log (who opened/edited and when)
### 🎯 Milestone 15: **"Profile Personalizer" Release** User Preferences & Tagging
### 🎯 Milestone 16: **"Profile Personalizer" Release** User Preferences & Tagging
* [x] User-specific intro/outro/replay MP3 fallback system
* [x] Persistent custom user settings
@@ -155,7 +197,7 @@
* [ ] Add filtering and sorting by tag
* [ ] Dark mode toggle
### 🎯 Milestone 16: **"Performance Pulse" Release** Scaling & Speed
### 🎯 Milestone 17: **"Performance Pulse" Release** Scaling & Speed
* [ ] Index high-traffic database fields (tags, dates, users)
* [ ] Paginate round/song lists
@@ -163,7 +205,7 @@
* [ ] Add Redis/memory cache layer for read-heavy endpoints
* [ ] Load test with simulated users and large playlists
### 🎯 Milestone 17: **"Deployment Dynamo" Release** CI/CD and Maintenance
### 🎯 Milestone 18: **"Deployment Dynamo" Release** CI/CD and Maintenance
* [ ] GitHub Actions or GitLab CI/CD pipeline for builds and tests
* [ ] Nightly backup job with status alert
@@ -186,5 +228,5 @@
* [ ] Implement keyboard shortcuts in the application
* [ ] Document keyboard shortcuts for power users when implemented
*Last updated: May 12, 2025*
*Last updated: May 14, 2025*
View File
View File
+1 -1
View File
@@ -41,7 +41,7 @@ services:
- 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
command: python -m flask run --host=0.0.0.0 --port=5000 --reload
scheduler:
image: mcuadros/ofelia:latest
-4
View File
@@ -1,10 +1,6 @@
#!/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
View File
+118
View File
@@ -0,0 +1,118 @@
import sys
import os
import contextlib
from sqlalchemy import text
# Add project root to Python path
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from musicround import db, create_app
from musicround.models import User # Import your User model
# Migration name (used for logging and tracking)
migration_name = os.path.splitext(os.path.basename(__file__))[0]
def run_migration():
# Check if an app context already exists (e.g., if called from within Flask app)
from flask import current_app
try:
app = current_app._get_current_object()
app_context_needed = False
except Exception:
app = create_app()
app_context_needed = True
context_manager = app.app_context() if app_context_needed else contextlib.nullcontext()
with context_manager:
try:
print(f"Starting migration: {migration_name}")
# Use raw SQL for schema changes to avoid issues with model definitions
# that might already expect the columns to exist.
# Check if spotify_id column exists
inspector = db.inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('user')]
if 'oauth_id' in columns and 'spotify_id' not in columns:
# Option 1: Rename oauth_id to spotify_id if oauth_id was intended for Spotify
# This is less safe if oauth_id was used for something else or if data types differ.
# print("Attempting to rename column 'oauth_id' to 'spotify_id'.")
# with db.engine.connect() as connection:
# connection.execute(text('ALTER TABLE user RENAME COLUMN oauth_id TO spotify_id'))
# connection.commit()
# print("Renamed 'oauth_id' to 'spotify_id'.")
#
# Or, if oauth_id is definitely old and spotify_id is new:
print("Column 'oauth_id' exists. It will be kept for now. Adding 'spotify_id'.")
if 'spotify_id' not in columns:
print("Adding column 'spotify_id' to 'user' table.")
with db.engine.connect() as connection:
connection.execute(text('ALTER TABLE user ADD COLUMN spotify_id VARCHAR(100)'))
connection.commit()
print("Added 'spotify_id'.")
else:
print("Column 'spotify_id' already exists.")
if 'spotify_token' not in columns:
print("Adding column 'spotify_token' to 'user' table.")
with db.engine.connect() as connection:
connection.execute(text('ALTER TABLE user ADD COLUMN spotify_token TEXT'))
connection.commit()
print("Added 'spotify_token'.")
else:
print("Column 'spotify_token' already exists.")
if 'spotify_refresh_token' not in columns:
print("Adding column 'spotify_refresh_token' to 'user' table.")
with db.engine.connect() as connection:
connection.execute(text('ALTER TABLE user ADD COLUMN spotify_refresh_token TEXT'))
connection.commit()
print("Added 'spotify_refresh_token'.")
else:
print("Column 'spotify_refresh_token' already exists.")
if 'spotify_token_expiry' not in columns:
print("Adding column 'spotify_token_expiry' to 'user' table.")
with db.engine.connect() as connection:
connection.execute(text('ALTER TABLE user ADD COLUMN spotify_token_expiry DATETIME'))
connection.commit()
print("Added 'spotify_token_expiry'.")
else:
print("Column 'spotify_token_expiry' already exists.")
# Add index to spotify_id if it doesn't exist
# Index creation syntax can vary between DBs (e.g., SQLite vs PostgreSQL)
# For SQLite, it's generally: CREATE INDEX IF NOT EXISTS idx_user_spotify_id ON user (spotify_id);
# For SQLAlchemy, it's better to define this in the model and let Alembic/Flask-Migrate handle it,
# but for a manual script:
try:
with db.engine.connect() as connection:
# Check if index exists first (SQLite specific query)
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='user' AND name='idx_user_spotify_id';")).fetchone()
if not result:
print("Adding index 'idx_user_spotify_id' to 'user.spotify_id'.")
connection.execute(text('CREATE UNIQUE INDEX idx_user_spotify_id ON user (spotify_id) WHERE spotify_id IS NOT NULL;'))
connection.commit()
print("Added unique index 'idx_user_spotify_id'.")
else:
print("Index 'idx_user_spotify_id' already exists.")
except Exception as e:
print(f"Could not create index on spotify_id (this might be okay if using a different DB or if it exists): {e}")
print(f"Migration {migration_name} completed successfully.")
return True
except Exception as e:
print(f"Error during migration {migration_name}: {e}")
# db.session.rollback() # Not needed with raw SQL execution and individual commits
return False
if __name__ == '__main__':
# This allows running the migration script directly
# Ensure your Flask app and db are initialized correctly
if not run_migration():
sys.exit(1)
@@ -0,0 +1,94 @@
Thanks, thats perfect context. Ill now identify high-quality, scrape-friendly sources for historical and current music charts focused on US, UK, Ireland, Germany, and broader Europe. Ill also dig into the types of metadata these sources provide—like chart placements, debut dates, and impact metrics—and explore how to enrich your existing `Song` model with this info. Ill get back to you shortly with detailed findings and scraper strategy recommendations.
# High-Quality Sources for Music Chart Data (US, UK, Ireland, Germany, Europe)
## Official Sources for Chart Data
### Billboard (US *Billboard* Charts)
* **Description & Scope:** *Billboard* is the standard for U.S. music charts, including the weekly **Hot 100** (singles) and **Billboard 200** (albums), among many others. It covers all genres and is widely regarded as an authoritative source. Historical data spans from the Hot 100s start in 1958 to the present.
* **Available Metadata:** Weekly charts list each songs **current rank**, **song title**, **artist**, as well as meta-data like **last weeks position**, **peak position**, and **weeks on chart**. For example, each Hot 100 entry includes *Last Week, Peak Position,* and *Weeks on Chart* in addition to the title and artist. (Commentary or editorial notes are sometimes provided in articles but not as structured data in the chart listing.)
* **Access Method:** Data can be scraped from Billboards website via HTML pages. Each chart has a structured URL (e.g. `billboard.com/charts/hot-100/<DATE>` where `<DATE>` is a Monday chart date in YYYY-MM-DD format) to access a specific weeks Hot 100. The site displays 100 entries per page with the relevant fields. There is **no free official API** (Billboards old API was shut down in 2013), but the HTML is parseable. Third-party solutions exist (e.g. the `billboard.py` Python module or RapidAPI endpoints) which essentially scrape these pages in a controlled way.
* **Frequency & Archive Depth:** Billboard charts are **updated weekly** (typically once a week, with charts dated to the week-ending Saturday). Archives are extensive the Hot 100 archive goes back to 1958. Older charts can be accessed by specifying historical dates; Billboards site allows navigating by week, or using third-party scrapers to iterate dates. For example, one can fetch the Hot 100 for a given past date by using the URL format or a library. Peak positions and weeks-on-chart are cumulatively tracked (so even an older entry will show its historical peak and total weeks up to that point).
* **Legal/Technical Considerations:** Scraping Billboards site must be done **respectfully** to avoid IP blocks e.g. rate-limit requests since its a dynamic content site. Billboard content is copyrighted, so reuse of large datasets commercially may violate terms; its best to use the data for internal analysis or ensure you have rights for any public use. Technically, the sites HTML includes the needed info in a consistent format (making it amenable to scraping), but be aware that **Billboard may change its layout** periodically. An alternative is to use unofficial JSON data compiled by others (for example, a GitHub project that scraped every Hot 100 entry includes fields like last\_week, peak\_position, weeks\_on\_chart) but if freshness and official sourcing are priorities, direct scraping or licensed API access is preferable.
### Official Charts Company (UK & Ireland *Official* Charts)
* **Description & Scope:** The **Official Charts Company (OCC)** provides the official weekly charts for the UK and, in partnership with IRMA, the Republic of **Ireland**. This includes the **Official UK Singles Chart Top 100** and **Albums Chart**, and the **Official Irish Singles Top 50** and Albums, among others. The OCC site is a comprehensive repository for British chart history (with data back to the **1950s**) and also hosts Irish charts (the Irish Singles Chart is compiled by OCC for IRMA).
* **Available Metadata:** Chart listings on **officialcharts.com** show each songs **position**, **title**, **artist**, and key metadata: **Last week (LW) position**, **Peak** to-date, and **Weeks on Chart**. New entries or re-entries are indicated by a blank or special notation for last week. The site also provides richer detail on dedicated pages for each track: clicking a song leads to a page with **chart facts** such as *Peak position, First charted date (debut date)*, total weeks on chart, label, and even a week-by-week **chart run** history. For example, a songs page might show “Peak position: 1, First Chart Date: 27/02/2025” along with a list of its position on each week of its chart run. This means the data source can yield not only the current weeks stats but also historical context (debut date and trajectory).
* **Access Method:** The OCC website is accessible via HTML. Charts can be accessed through structured URLs: for instance, the UK Singles Top 100 usually at `officialcharts.com/charts/uk-top-40/<DATE>`, and the Irish Singles at `.../irish-singles-chart/<DATE>/`. The `<DATE>` is typically the chart week ending date or an identifier; the site has an archive navigation that can generate the URL (e.g., an Irish chart URL looks like `.../irish-singles-chart/20250221/ie7501/` for the week of Feb 21, 2025). In many cases, if no date is given, the URL shows the latest chart, and older charts are retrieved by inserting the week ID. **No public API** is provided, but HTML scraping is feasible. Each chart page lists 100 entries (for UK) or 50 (for Ireland) in a consistent format, which can be parsed. The site is mostly static content (one page per chart week), making it straightforward to script through archive pages.
* **Frequency & Archive Depth:** Charts are **updated weekly**. The UK charts are typically refreshed every Friday (reflecting sales/streams through Thursday night), and Irish charts weekly as well. **Archive depth is very deep**: the OCCs database includes every UK weekly chart back to **November 1952** for singles and to 1956 for albums. (They merged older charts like NME into the official archive.) The website allows browsing these historical charts (the “Archive” feature). Many specialty UK charts (like genre-specific or sales-only charts) are available from 1994 onward. The Irish chart archive on OCCs site covers the period since the OCC took over compilation (in recent years); earlier Irish data (from 1960s onward) might not be fully on the OCC site, but IRMAs own site provides all-time records in a less accessible format.
* **Legal/Technical Considerations:** The OCC sites content is subject to database rights they famously protect their data. **Automated scraping may violate their terms** if done extensively, so it should be done carefully (and only for legitimate use). In practical terms, the HTML is easily parseable (entries are in a predictable structure with position and meta fields). Be mindful of not overwhelming the server throttle requests or cache results, especially if pulling decades of weekly data. Also note that some data (like full chart runs or certain archives) might require login or might be rate-limited if done via the front-end interface. Generally, however, basic weekly charts are publicly viewable. Make sure to preserve attribution (for internal use, keep track of OCC as the source in your `metadata_sources` field).
### GfK Offizielle Deutsche Charts (Germany)
* **Description & Scope:** The **Offizielle Deutsche Charts** (managed by GfK Entertainment for the Bundesverband Musikindustrie) are the official German music charts. The main charts include the **Top 100 Singles** and **Top 100 Albums** (weekly), and the site also features a variety of genre and format charts (e.g., Top 20 for genres like Dance, Rock/Metal, etc., plus Midweek and Year-end charts). This source covers Germanys chart data comprehensively, with an archive of almost **40 years** of history available online.
* **Available Metadata:** The German chart portal provides structured data for each entry: **current rank**, **previous weeks rank**, **title**, **artist** (including featured artists, separated by slashes or ampersands), and the record **label**. It also lists the songs **weeks on chart** and **peak position** to date as inline metadata. For example, an entry might read: “1 1 **DNA feat. Suzanne Vega Toms Diner** (A\&M/Polydor) **In Charts: 6 W** **Peak: 1**”, indicating the song is at #1, was #1 last week, has spent 6 weeks in the charts, and peaked at #1. New entries are shown with no previous rank (or a dash) and typically “In Charts: 1 W” as weeks. Re-entries can be detected when a song has weeks >1 but no prior week position listed. (The site does not explicitly give the debut date on the listing page, but the first chart date can be inferred by looking at the archive and the site includes artist pages/discographies with “Erstchartdatum” if logged in).
* **Access Method:** The official German charts site **offiziellecharts.de** is interactive, but can be scraped via its internal endpoints. The charts are displayed by selecting a date range; under the hood, the site uses a call like `.../charts/single/for-date-<timestamp>` to fetch the HTML snippet for that week. For scraping, one approach is to simulate weekly date selections. Each weekly chart corresponds to a Friday-to-Thursday period; for example, the week 01.10.1990 07.10.1990 has an internal identifier (timestamp) used in the URL. By incrementing by 7-day intervals (604800000 ms in timestamp), one can retrieve consecutive weeks. Alternatively, the site provides dropdowns to navigate; you could scrape by programmatically selecting dates (the HTML of the “for-date” response contains the full Top 100 list). **No public API** is provided by GfK for chart data the official route is paid reports so HTML scraping is the viable approach. The data is well-structured in the HTML, but note its in German (e.g., “In Charts: X W”).
* **Frequency & Archive Depth:** The German charts are updated **weekly**, with the new Top 100 released every **Friday** afternoon (reflecting sales/streams from FridayThursday, following a 2015 change to align release day and chart day). The site also posts midweek updates on Wednesdays (Top 20/100 midweeks) and daily trends (these might be behind login or press releases). **Archive depth:** The online archive covers **all weekly charts since the start of the modern German charts data collection** by Media Control/GfK (the portal boasts a complete archive “seit Beginn der Datenermittlung,” i.e., since official electronic chart data began). In practice, this means you can find weekly charts from the early 1980s (and even late 1970s) onward. For example, charts from 1990 are readily accessible, and one can retrieve even earlier 1980s charts. (Earlier historical German charts from the 1950s60s are not part of this electronic archive because those were published in magazines, but from about 1977 forward, its complete).
* **Legal/Technical Considerations:** The Offiziellecharts site is intended for user exploration and is free to browse, but **scraping should be done gently**. The site may use JavaScript to load content, so a scraper might need to either replicate the XHR calls (as mentioned) or use a headless browser approach. Since this is an official industry site, its **robots.txt** may disallow scraping (worth checking). As with other official data, it is protected by copyright/database rights; using it for commercial purposes would require permission. GfK offers the data via subscription, implying that heavy automated use could be frowned upon. However, for internal use and research, pulling the data with moderation (and attributing it) is typically acceptable. Also note that the site requires character encoding handling (umlauts in titles, etc.) and that labels and artist names are exactly as listed (might need normalization when matching with your database).
### Pan-European Chart Sources (Europe-wide)
* **Description & Scope:** A **pan-European chart** aggregates music popularity across Europe. The primary historic source was the **Eurochart Hot 100 Singles** (also known as the European Hot 100) which was compiled by *Music & Media* magazine and later by *Billboard* from 1984 until it was discontinued in December 2010. This chart combined national singles charts from across European countries into a single top 100 ranking. After 2010, there has been no official unified European singles chart. (Billboard did maintain a **Euro Digital Songs** chart (top 10 digital sales in Europe) until Feb 2022, but that is a limited subset.) For albums, a European Top 100 Albums chart also existed during that period via *Music & Media*.
* **Available Metadata:** During its run, the Eurochart Hot 100 provided weekly ranks for songs. In *Music & Media* magazine issues, each entry was listed with its current position, song and artist, and often last weeks position or a country-by-country breakout in some cases. However, as a scraped dataset today, you wont find a readily queryable official site with structured fields (since the service ended). What **is** available are archives and scanned data: for example, the **World Radio History** library hosts PDF scans of *Music & Media* (and the Eurotipsheet) which contain the weekly Eurochart listings. These scans include all the info (positions 1100 each week, and sometimes peak or weeks if mentioned in year-end summaries). If one processes these, one can obtain fields like rank, song, artist, and sometimes flags for new entries or notable movements. Additionally, fan-maintained databases or wiki pages have captured parts of this data. For instance, the Billboard fandom wiki and other chart archives might list the number-ones or top 20 of each week.
* **Access Method:** Since there is **no official live website** for historical Eurocharts, accessing this data means relying on archives or third-party compilations. Two main approaches: **(1) Archive scraping** using sources like the PDFs on *worldradiohistory.com* which has *Music & Media* issues (1980s2000s). One could scrape text from these PDFs (OCR or text extraction) to build a dataset of the Eurochart. This requires substantial effort and parsing of semi-structured text. **(2) Third-party websites** e.g., **top40-charts.com** provides a “Europe Official Top 100” which appears to be an aggregated chart updated weekly, and sites like **acharts.co** or **ChartsAroundTheWorld** track multiple countries (though a specific Eurochart might not be explicitly given, some provide a “Europe” section). The top40-charts.com version might not be an *official* industry-sanctioned chart, but it could be scraped via HTML as it presents a ranked list of songs for Europe. If a more current pan-European perspective is needed, one might also use the **Billboard Global Excl. US** chart as a proxy (since European songs would dominate there, though its global minus US, not Europe-only).
* **Frequency & Archive Depth:** The historical Eurochart was **weekly** (matching the Billboard publication cycle). Archives exist for **19842010** weekly. If using WorldRadioHistory, one can get nearly every weeks chart in that range. The Euro Digital Songs chart (2000s2022) was also weekly; Billboards site had those top 10 lists (which could be scraped for those years if needed, via billboard.com, similar method as Hot 100 but chart name “Euro Digital Song Sales”). The top40-charts.com “Europe Top 100” is updated weekly in current time. There is no single official archive for pan-Europe after 2010, so any post-2010 “Euro chart” is either unofficial or derived from combining national charts.
* **Legal/Technical Considerations:** The **Music & Media** Eurochart data is © Billboard/BPI etc. Since its historical and out-of-print, using it for research is usually fine, but any republishing of a compiled Eurochart database might violate copyrights (the OCC, for instance, objected to unlicensed archive postings of UK charts, and similar could apply to Eurocharts). Scraping the PDFs is technically challenging (OCR needed for uniformity). If using a site like top40-charts.com, note that its not an official source and the methodology might not be transparent but it is a *convenient* HTML source. Ensure your scraping code handles variations in song naming and that you verify if the sites “Europe Official Top 100” indeed corresponds to an aggregate of official data. Also, as always, be mindful of load; if pulling large historical data from a fan site, do so politely. In summary, for pan-European data, **high-quality** officially means historical reconstruction (and perhaps supplementing with the now-ended Euro Digital Songs). This data can then be mapped into your system similar to national charts.
### Comparison of Identified Sources
| **Source (Region)** | **Metadata Provided** | **Access Method** | **Archive Depth & Update** | **Scraping Considerations** |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Billboard** (US) | Rank, Title, Artist; *Last Week*, *Peak*, *Weeks on Chart* for each song. Some charts include icons for “Greatest Gainer” etc. | HTML pages on Billboard.com (structured URLs per chart and date); unofficial API wrappers available. | Weekly updates; archives back to 1958 (Hot 100). All weekly charts accessible via date queries (manual or scripted). | No free official API; site can throttle heavy use use rate limiting. Data copyrighted (for internal use unless licensed). |
| **Official Charts** (UK & IRL) | Rank, Title, Artist; *Last Week*, *Peak*, *Weeks* on chart. Song pages give debut date and full run. | HTML pages on officialcharts.com (with endpoints for each chart and date). No public API. | Weekly; UK data back to 1952 (complete archive), Ireland back to at least 1990s (OCC era). Current charts on site, historical via archive picker. | Strong copyright enforcement by OCC. Scrape gently. Structured HTML easy to parse. Some details (full runs) may need clicking into song pages. |
| **Offizielle Charts** (Germany) | Rank, Title, Artist(s), Label; *Prev Week* rank, *Weeks* on chart, *Peak* to-date. (German titles/labels as listed.) | Interactive HTML site (offiziellecharts.de). Scrape via XHR endpoint (e.g. `/charts/<category>/for-date-<timestamp>`). | Weekly; archive from \~1977/1980s onward (almost 40 years). Full Top100 available for each week. Updates every Friday (midweeks on Wed). | Dynamic content (JS) may need to simulate calls. Data is official and comprehensive. Throttle requests. Content in German (take care with encoding). |
| **Pan-European** (Eurochart) | Rank, Title, Artist as published. Historical *last week* or country breakdowns in magazines. (No live peak/weeks metadata after the fact without computing yourself.) | Archive PDFs (Music & Media magazine scans) or third-party chart sites. No official website post-2010. | Weekly (historical 19842010 for Euro Hot 100). No current official chart; some sites provide unofficial weekly Euro Top 100. | Data must be reconstructed. Scraping PDFs requires OCR. Third-party “Euro charts” may not be official. Ensure legality if using historical data; likely okay for research. |
## Mapping Scraped Data to the `Song` Model
Once chart data is scraped from the above sources, the next challenge is integrating it with your existing `Song` model. Below are recommendations for linking and enriching your song records with the new metadata:
* **Linking by Unique Identifier (ISRC):** Whenever possible, use a stable identifier like the **ISRC** to match scraped songs to your database. Official chart sources sometimes provide identifiers for example, the Official Charts site lists a “Catalogue number” for each song, which often corresponds to an ISRC or label catalog code. If your `Song` model stores ISRCs (or you can obtain them via an external lookup by song title/artist), matching on ISRC is most reliable for linking the exact same recording. This helps avoid issues with songs that have common titles or multiple versions. **Recommendation:** parse any available code from sources (e.g., OCCs catalogue number, or use the song+artist to query an external service for ISRC) and store it in the Song record for future cross-referencing.
* **Linking by Title/Artist (Exact or Fuzzy):** In many cases, chart data will have only the **song title and artist name**. You should prepare to do a robust string match against your `Song` records:
* Start with **normalized exact matching**: Normalize the scraped title and artist strings (trim punctuation, convert to a standard case, remove common suffixes like “(feat. X)” or “feat. X” if your DB doesnt store them, etc.) and compare to your song entries. If your `Song` model has separate fields for title and artist(s), ensure both match.
* Handle **feat./collaboration variations**: Chart listings might abbreviate or include featured artists differently than your database. For example, a song listed as “Artist A & Artist B” on a chart might be “Artist A feat. Artist B” in your database. Consider using a fuzzy matching library or custom logic to account for ampersands, “x”, “feat.”, etc., so that these count as a match.
* **Fuzzy matching**: If exact match fails, employ a fuzzy string match (Levenshtein distance or token-based matching) on title + primary artist. This can catch minor spelling differences or punctuation mismatches. For instance, “Beyonce” vs “Beyoncé” or “Hips Dont Lie” vs “Hips Dont Lie” (missing apostrophe) would be caught by a fuzzy match.
* **Manual review for edge cases:** Flag any ambiguous matches (like two songs with the same title in your database) for human verification or use additional data (like release year or genre) to pick the correct one.
* **Populating Song Fields with Chart Metadata:** Once a song is identified, decide which fields in your `Song` model to update:
* **`metadata_sources`:** Its good practice to record where information came from. For each song, add an entry noting the chart source and perhaps the specific chart name and date range. For example, for a song that charted, you might add `"Billboard Hot 100 (peak #5, 20 weeks)"` or a structured object detailing peak and weeks. At minimum, store that the song has data from “Billboard Hot 100” or “Official Charts UK” etc., so you know which sources confirmed its popularity.
* **`year`:** If your Song model has a year (often release year or year of greatest popularity), you can use chart debut year as a proxy if the release year is missing. For instance, if a song first appears on a chart in 2021, its likely a 2020 or 2021 release. Chart data gives you the debut date or first chart year (OCC explicitly gives first chart date). You might update the songs `year` field if its empty or if you want to store “year first popular”. (Be cautious not to override an actual release year if you have it; perhaps store chart year separately or only fill if year was unknown.)
* **`genre`:** Chart sources typically do **not** provide genre per track. However, chart context can imply something (e.g., if you scraped a genre-specific chart like “Rock/Metal Charts” from Germany, you know those songs are rock). For mainstream pop charts like Hot 100 or UK Top 100, the genre will be varied. Its usually better to get genre from another source. If your model has genre and its empty, you might consider using the presence on a **genre chart** as a clue (e.g., if a song appears on “US Hot Country Songs”, tag its genre as Country).
* **`tags`:** This is where you can richly annotate the song with chart-related information. See next point on chart milestone tags.
* **Adding Tags for Chart Achievements:** To enhance the dataset, create new tags based on the songs chart performance milestones:
* For example, if a song reached **#1** on the UK Official Singles Chart, add a tag like **“UK #1 Hit”** to that song. This highlights a major achievement.
* If a song made the **Top 10** on a chart, you can tag it “Billboard Top 10” or “UK Top 10”. Some songs might get multiple tags (e.g. a song that hit #1 in the US and Top 10 in UK gets both “US #1” and “UK Top 10”). Decide on a consistent tagging scheme (perhaps “Billboard Hot 100 #1”, “UK Official Chart Top10”, etc.).
* You could also tag long chart runs or notable records: e.g. “20+ Weeks on Hot 100”, “Christmas #1 UK” (for the UKs holiday chart-toppers), “Eurochart Top 5”, depending on what insights you want to surface. These tags can later be used for generating playlists or highlighting songs in your app.
* Consider automated rules: e.g., if `peak_position == 1` on a given chart, generate a “<ChartName> #1” tag; if `peak_position <= 10`, generate “<ChartName> Top 10”, etc. This way, as you update chart data, tags are consistently applied.
* **Data Model Extensions (if needed):** If your current Song model doesnt have a place for detailed chart info, you have options:
* Use an **auxiliary table** or object for chart entries (SongChartPerformance with fields: song\_id, chart\_name, peak, weeks, debut\_date, etc.). This is more structured but requires schema changes. It might be worthwhile if you plan extensive analytics.
* If not, storing summary info in tags/metadata as described is fine for many use cases. Tags are flexible for queries (e.g., one can query all songs with tag “US #1 Hit”). The drawback is tags are untyped strings, but theyre quick to implement.
* Another field you might utilize is a **“chart history” blob** in `metadata_sources` or a JSON field, where you keep the raw info (like an array of weekly positions or a highest achievement summary). For example, you could store: `{"Billboard Hot 100": {"peak": 5, "weeks": 20, "debut_date": "2021-05-01"}}` in a JSON field. This preserves more detail in a structured way.
* **Quality Assurance Matching Validation:** After linking and populating, its good to run a sanity check. For instance, verify that the number of songs tagged as #1 matches the number of #1 entries scraped, etc. Also, for any songs where the scraper found a chart entry but no match in your database, log those for review you might have the song under a slightly different name or not have it at all (perhaps add it as a new Song entry if its missing and the project allows).
By following these steps, you will enrich your Song model with valuable chart metadata. Youll have tags highlighting hits (“UK #1”, “Germany Top 5”, etc.), year and source info to contextualize each song, and a robust linking using identifiers or fuzzy logic to ensure accuracy. This integration will allow you to query, for example, all songs that were hits in multiple countries, or display on a songs page something like “🏆 Chart Achievements: #1 in UK, Top 10 in US (20 weeks on chart)” drawn from the data youve mapped.
By leveraging the above high-quality sources and carefully mapping the data, you can significantly enhance the informational richness of your music database while maintaining data integrity and respecting the sources terms.
**Sources:**
* Billboard Charts data fields (song, artist, current rank, last week, peak, weeks)
* Official Charts Company UK and Irish chart listings and archive info
* OCC song page example (peak, first chart date, etc.)
* Offizielle Deutsche Charts data fields and archive confirmation
* European Hot 100 (Billboards pan-European chart) history
+113 -128
View File
@@ -1,6 +1,7 @@
import os
import logging
import importlib.util
import json
from flask import Flask, session, redirect, url_for, request
from flask_login import LoginManager, current_user
from flask_sqlalchemy import SQLAlchemy
@@ -8,10 +9,10 @@ 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
from datetime import datetime
from musicround.helpers.auth_helpers import oauth # Import the oauth object
# Initialize SQLAlchemy
db = SQLAlchemy()
@@ -177,147 +178,131 @@ def create_app(config=None):
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']
)
# Define token handling functions within create_app
def _app_fetch_token(name):
app.logger.debug(f"_app_fetch_token: Called for service '{name}', user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
if current_user.is_authenticated:
if name == 'spotify':
token_str = current_user.spotify_token
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Raw token string from DB: {token_str[:150] if token_str else 'None'}...")
if token_str:
try:
# Refresh user's token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
token = json.loads(token_str)
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Token after json.loads: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'expires_in': {token.get('expires_in')}, 'scope': {token.get('scope')}, 'token_type': '{token.get('token_type')}'}}")
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 'refresh_token' not in token or not token.get('refresh_token'):
if hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
token['refresh_token'] = current_user.spotify_refresh_token
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Added refresh_token from current_user.spotify_refresh_token.")
else:
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): refresh_token missing in JSON and not found in current_user.spotify_refresh_token.")
# 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']
current_time = int(datetime.utcnow().timestamp())
if 'expires_at' in token:
if not isinstance(token['expires_at'], int):
try:
token['expires_at'] = int(float(token['expires_at']))
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Converted existing expires_at to int: {token['expires_at']}")
except (ValueError, TypeError):
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): Could not convert existing expires_at '{token['expires_at']}' to int. Recalculating if possible.")
if 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
token['expires_at'] = current_time + int(token['expires_in']) - 30
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Recalculated expires_at from expires_in: {token['expires_at']}")
else:
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Cannot determine expires_at. Original problematic value: {token['expires_at']}")
elif 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
token['expires_at'] = current_time + int(token['expires_in']) - 30
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Calculated expires_at from expires_in: {token['expires_at']}")
elif hasattr(current_user, 'spotify_token_expires_at') and current_user.spotify_token_expires_at:
token['expires_at'] = int(current_user.spotify_token_expires_at.timestamp())
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Used expires_at from current_user.spotify_token_expires_at: {token['expires_at']}")
else:
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): expires_at missing and cannot be calculated.")
# Save to database
db.session.commit()
if 'token_type' not in token or not token.get('token_type'):
token['token_type'] = 'Bearer'
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Set token_type to Bearer.")
# Store token in session
session['access_token'] = token_info['access_token']
session['token_source'] = 'user'
if 'expires_in' in token:
del token['expires_in']
app.logger.debug(f"Generated new token for user {current_user.username}")
return
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Final token prepared for Authlib: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
return token
except json.JSONDecodeError:
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Failed to decode token JSON: {token_str[:100]}...")
return None
except Exception as e:
app.logger.warning(f"Failed to refresh user token: {str(e)}")
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Error processing token: {str(e)}", exc_info=True)
return None
else:
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): No token string found in DB.")
return None
app.logger.debug(f"_app_fetch_token: User not authenticated or service not matched for '{name}'.")
return None
# 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)
def _app_update_token(name, token, refresh_token=None, access_token=None):
app.logger.debug(f"_app_update_token: Called for service: {name}, user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
if name == 'spotify':
if current_user.is_authenticated:
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Received new token data to update. Keys: {list(token.keys()) if token else 'None'}")
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Full new token: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
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
current_user.spotify_token = json.dumps(token)
# Get client credentials from config
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
if 'expires_at' in token and token['expires_at'] is not None and hasattr(current_user, 'spotify_token_expires_at'):
try:
current_user.spotify_token_expires_at = datetime.fromtimestamp(int(token['expires_at']))
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_token_expires_at to {current_user.spotify_token_expires_at}")
except (TypeError, ValueError) as e:
app.logger.warning(f"_app_update_token for Spotify (user {current_user.id}): Could not update spotify_token_expires_at from token's expires_at ('{token['expires_at']}'): {str(e)}")
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'
}
if 'refresh_token' in token and token['refresh_token'] and hasattr(current_user, 'spotify_refresh_token'):
current_user.spotify_refresh_token = token['refresh_token']
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_refresh_token.")
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")
db.session.commit()
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Token successfully updated and committed to DB.")
except Exception as e:
app.logger.error(f"Error getting client credentials token: {str(e)}")
db.session.rollback()
app.logger.error(f"_app_update_token for Spotify (user {current_user.id}): Error committing token to DB: {str(e)}", exc_info=True)
else:
app.logger.warning(f"_app_update_token for Spotify: Attempted to update token for unauthenticated user.")
# Add similar blocks for other services if needed
except Exception as e:
app.logger.error(f"Error in ensure_spotify_token: {str(e)}")
pass # Continue without a token if all methods fail
# Initialize OAuth providers (Google, Authentik, Spotify via Authlib)
from musicround.helpers.auth_helpers import init_oauth
init_oauth(app) # This will use the imported oauth object
# Manually register token handling functions
app.logger.info(f"Attempting to manually register token functions. oauth object id: {id(oauth)}")
if hasattr(oauth, 'tokengetter') and callable(oauth.tokengetter):
oauth.tokengetter(_app_fetch_token)
app.logger.info("SUCCESS: Manually registered _app_fetch_token using oauth.tokengetter().")
else:
app.logger.error("FAILURE: oauth.tokengetter method not found or not callable.")
# Fallback for extreme cases - not recommended for production
if isinstance(oauth, object) and hasattr(oauth, '_fetch_token_funcs') and isinstance(oauth._fetch_token_funcs, dict): # Basic check
oauth._fetch_token_funcs['_app_fetch_token'] = _app_fetch_token
app.logger.warning("MANUAL HACK: Injected _app_fetch_token into oauth._fetch_token_funcs.")
else:
app.logger.error("CRITICAL FAILURE: Cannot register fetch token function via method or hack.")
if hasattr(oauth, 'tokenupdater') and callable(oauth.tokenupdater):
oauth.tokenupdater(_app_update_token)
app.logger.info("SUCCESS: Manually registered _app_update_token using oauth.tokenupdater().")
else:
app.logger.error("FAILURE: oauth.tokenupdater method not found or not callable.")
if isinstance(oauth, object) and hasattr(oauth, '_update_token_funcs') and isinstance(oauth._update_token_funcs, dict): # Basic check
oauth._update_token_funcs['_app_update_token'] = _app_update_token
app.logger.warning("MANUAL HACK: Injected _app_update_token into oauth._update_token_funcs.")
else:
app.logger.error("CRITICAL FAILURE: Cannot register update token function via method or hack.")
# Initialize Deezer client - import inside the function to avoid circular dependency
from musicround.deezer_client import DeezerClient
app.config['deezer'] = DeezerClient()
# Register blueprints
from musicround.routes.core import core_bp
+1 -1
View File
@@ -38,7 +38,7 @@ class Config:
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"
SPOTIFY_SCOPE = "playlist-read-private playlist-read-collaborative user-library-read user-top-read user-read-private user-read-email user-read-recently-played user-follow-read playlist-modify-public playlist-modify-private"
# Deezer API credentials
DEEZER_APP_ID = os.getenv("DEEZER_APP_ID", "")
+79 -8
View File
@@ -5,6 +5,7 @@ import random
import time
from flask import current_app
from musicround.models import Song, db
from musicround.helpers.metadata import get_song_metadata_by_isrc
logger = logging.getLogger(__name__)
@@ -162,10 +163,54 @@ class DeezerClient:
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()
# Extract ISRC
isrc = track_info.get('isrc')
# Check if this track is already in our database by Deezer ID or ISRC
existing_song = None
if isrc:
existing_song = Song.query.filter_by(isrc=isrc).first()
if not existing_song:
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")
self.logger.info(f"Track {track_info.get('title')} (Deezer ID: {track_info['id']}, ISRC: {isrc}) already exists in database with ID {existing_song.id}")
# If ISRC was missing and we found it now, update the existing record
if isrc and not existing_song.isrc:
existing_song.isrc = isrc
if existing_song.deezer_id is None: # If it was matched by ISRC but didn't have deezer_id
existing_song.deezer_id = str(track_info['id'])
try:
db.session.commit()
self.logger.info(f"Updated ISRC for existing song {existing_song.id} to {isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error updating ISRC for existing song {existing_song.id}: {e}")
# Optionally, trigger metadata refresh if ISRC is now available or if desired
if existing_song.isrc:
try:
app_context = current_app._get_current_object()
updated_metadata = get_song_metadata_by_isrc(existing_song.isrc, app=app_context)
if updated_metadata:
# Update song fields from aggregated metadata
existing_song.title = updated_metadata.get('title', existing_song.title)
existing_song.artist = updated_metadata.get('artist_name', existing_song.artist)
existing_song.year = updated_metadata.get('year', existing_song.year)
existing_song.genre = updated_metadata.get('genre', existing_song.genre)
# ... update other relevant fields ...
if updated_metadata.get('spotify_id') and not existing_song.spotify_id:
existing_song.spotify_id = updated_metadata.get('spotify_id')
if updated_metadata.get('cover_url') and not existing_song.cover_url: # Prioritize existing cover if any
existing_song.cover_url = updated_metadata.get('cover_url')
if updated_metadata.get('preview_url') and not existing_song.preview_url: # Prioritize existing preview if any
existing_song.preview_url = updated_metadata.get('preview_url')
db.session.commit()
self.logger.info(f"Refreshed metadata for existing song {existing_song.id} using ISRC {existing_song.isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error refreshing metadata for existing song {existing_song.id}: {e}")
return existing_song
# Get additional artist details if needed
@@ -184,27 +229,53 @@ class DeezerClient:
# 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)
# Get genre from Last.fm (can be removed if metadata aggregation handles it)
# genre = self.get_genre_from_lastfm(artist_name, track_info.get('title', ''), lastfm_api_key)
genre = None # Will be populated by metadata aggregation if ISRC is present
# 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
spotify_id=None, # Will be populated by metadata aggregation if ISRC is present
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),
popularity=track_info.get('rank', 0), # Deezer 'rank' can be used as popularity
isrc=isrc, # Save the ISRC
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}")
self.logger.info(f"Imported track '{new_song.title}' by {new_song.artist} with Deezer ID {new_song.deezer_id} and ISRC {new_song.isrc}")
# If ISRC is present, fetch and update with aggregated metadata
if new_song.isrc:
try:
app_context = current_app._get_current_object()
aggregated_metadata = get_song_metadata_by_isrc(new_song.isrc, app=app_context)
if aggregated_metadata:
new_song.title = aggregated_metadata.get('title', new_song.title)
new_song.artist = aggregated_metadata.get('artist_name', new_song.artist)
new_song.year = aggregated_metadata.get('year', new_song.year)
new_song.genre = aggregated_metadata.get('genre', new_song.genre)
new_song.spotify_id = aggregated_metadata.get('spotify_id', new_song.spotify_id)
# Update cover and preview URLs if they are better or missing
if aggregated_metadata.get('cover_url'):
new_song.cover_url = aggregated_metadata.get('cover_url')
if aggregated_metadata.get('preview_url'):
new_song.preview_url = aggregated_metadata.get('preview_url')
# Potentially update popularity if a more universal score is available
# new_song.popularity = aggregated_metadata.get('popularity', new_song.popularity)
db.session.commit()
self.logger.info(f"Updated new song {new_song.id} with aggregated metadata using ISRC {new_song.isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error updating new song {new_song.id} with aggregated metadata: {e}")
return new_song
except Exception as e:
db.session.rollback()
+70 -3
View File
@@ -4,13 +4,10 @@ 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()
@@ -68,6 +65,26 @@ def init_oauth(app):
app.logger.info("Dropbox OAuth client registered")
else:
app.logger.warning("Dropbox OAuth client not registered - missing app key or secret")
# Register Spotify OAuth client
if app.config.get('SPOTIFY_CLIENT_ID') and app.config.get('SPOTIFY_CLIENT_SECRET'):
oauth.register(
name='spotify',
client_id=app.config.get('SPOTIFY_CLIENT_ID'),
client_secret=app.config.get('SPOTIFY_CLIENT_SECRET'),
api_base_url='https://api.spotify.com/v1/',
authorize_url='https://accounts.spotify.com/authorize',
authorize_params={'show_dialog': 'true'}, # Force re-approval
access_token_url='https://accounts.spotify.com/api/token',
access_token_params=None,
refresh_token_url='https://accounts.spotify.com/api/token',
client_kwargs={
'scope': app.config.get('SPOTIFY_SCOPE')
},
userinfo_endpoint='https://api.spotify.com/v1/me' # Added for fetching user info
)
app.logger.info("Spotify OAuth client registered")
else:
app.logger.warning("Spotify OAuth client not registered - missing client ID or secret")
return oauth
@@ -171,10 +188,48 @@ def get_dropbox_user_info(token):
current_app.logger.error(f"Error getting Dropbox user info: {str(e)}")
return None
def get_spotify_user_info(token):
"""
Get Spotify user info from the token
"""
try:
# Authlib should handle token refresh automatically if configured correctly
# and if the token object is managed by Authlib's token session or similar mechanism.
# Use the registered Authlib client to fetch user info
# The 'userinfo_endpoint' configured during registration will be used.
# We pass the token explicitly to ensure it's used for this request.
# Authlib's `oauth.spotify.get()` will prepend the base URL if 'userinfo_endpoint' is relative,
# but since we provided an absolute one, it should use that.
# The error "Invalid URL 'me'" suggests that 'me' alone was passed somewhere.
# Let's ensure we are calling the fully qualified endpoint via the client.
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token=token)
resp.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
profile = resp.json()
current_app.logger.debug(f"Spotify user info response: {profile}")
user_info = {
'id': profile.get('id'),
'email': profile.get('email'), # Note: Spotify email might be private
'name': profile.get('display_name'),
'picture': profile.get('images')[0]['url'] if profile.get('images') else None,
# Spotify doesn't provide given_name and family_name directly
'given_name': profile.get('display_name', '').split(' ')[0] if profile.get('display_name') else '',
'family_name': ' '.join(profile.get('display_name', '').split(' ')[1:]) if profile.get('display_name') and ' ' in profile.get('display_name') else ''
}
return user_info
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error getting Spotify user info: {http_err} - Response: {http_err.response.text}")
return None
except Exception as e:
current_app.logger.error(f"Error getting Spotify 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
"""
from musicround.models import db, User
if not user_info:
return None
@@ -185,6 +240,8 @@ def find_or_create_user(user_info, auth_provider):
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()
elif auth_provider == 'spotify':
user = User.query.filter_by(spotify_id=user_info['id']).first()
else:
return None
@@ -200,6 +257,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.commit()
current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID")
@@ -243,6 +302,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.add(user)
try:
@@ -259,6 +320,7 @@ def update_oauth_tokens(user, tokens, auth_provider):
"""
Update user's OAuth tokens
"""
from musicround.models import db
if auth_provider == 'google':
user.google_token = tokens.get('access_token')
user.google_refresh_token = tokens.get('refresh_token')
@@ -270,6 +332,11 @@ def update_oauth_tokens(user, tokens, auth_provider):
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')))
elif auth_provider == 'spotify':
user.spotify_token = tokens.get('access_token')
user.spotify_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.spotify_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
user.last_login = datetime.now()
try:
db.session.commit()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
"""
Updated core.py with improved Spotify OAuth handling
"""
import logging
import datetime
from flask import current_app, redirect, url_for, flash, session
from flask_login import current_user
from musicround.helpers.spotify_debug import DebugSpotifyClient
from musicround.models import db
logger = logging.getLogger("spotify.token")
def ensure_valid_spotify_token(session, current_user):
"""
Ensure a valid Spotify token is available and return a Spotify client
Args:
session: The Flask session object
current_user: The current user object
Returns:
DebugSpotifyClient or None if no valid token
"""
# Get token from session
token = session.get('access_token')
# If no token in session but user is logged in with a token
if not token and hasattr(current_user, 'is_authenticated') and current_user.is_authenticated:
if hasattr(current_user, 'spotify_token') and current_user.spotify_token:
token = current_user.spotify_token
session['access_token'] = token
if not token:
logger.warning("No Spotify token available")
return None
# Create Spotify client
sp = DebugSpotifyClient(auth=token)
# Verify token is valid
try:
# Try a simple API call
sp.current_user()
return sp
except Exception as e:
logger.warning(f"Spotify token validation failed: {str(e)}")
# Try to refresh the token
if hasattr(current_user, 'is_authenticated') and current_user.is_authenticated and hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
try:
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
# Create OAuth object to refresh token
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
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 = DebugSpotifyClient(auth=token_info['access_token'])
return sp
except Exception as refresh_error:
logger.error(f"Error refreshing token: {str(refresh_error)}")
return None
View File
+193
View File
@@ -0,0 +1,193 @@
"""
Spotify token management helper functions
Provides centralized token refresh functionality similar to Dropbox helper
"""
import requests
import time
from datetime import datetime, timedelta
from flask import current_app
from flask_login import current_user
from musicround.models import db, SystemSetting
def refresh_spotify_token(refresh_token):
"""Refresh an expired Spotify access token"""
client_id = current_app.config.get('SPOTIFY_CLIENT_ID')
client_secret = current_app.config.get('SPOTIFY_CLIENT_SECRET')
if not client_id or not client_secret:
current_app.logger.error("Spotify client credentials not configured")
return None
data = {
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret
}
response = requests.post('https://accounts.spotify.com/api/token', data=data)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error refreshing Spotify token: {response.text}")
return None
def get_current_user_spotify_token():
"""Get a valid Spotify 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.spotify_token and
current_user.spotify_token_expiry and
current_user.spotify_token_expiry > datetime.now() + timedelta(minutes=5)):
# Token is valid and not about to expire
current_app.logger.debug(f"Using valid Spotify token for user {current_user.id}")
return current_user.spotify_token
# Token is missing or about to expire - try to refresh
if current_user.spotify_refresh_token:
current_app.logger.info(f"Refreshing Spotify token for user {current_user.id}")
# Try to refresh the token
token_info = refresh_spotify_token(current_user.spotify_refresh_token)
if token_info and 'access_token' in token_info:
# Update token in database
current_user.spotify_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
current_user.spotify_token_expiry = datetime.now() + timedelta(seconds=expires_in)
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
db.session.commit()
current_app.logger.info(f"Successfully refreshed Spotify token for user {current_user.id}")
return current_user.spotify_token
# If we get here, we couldn't refresh the user's token
current_app.logger.error(f"Failed to get valid Spotify token for user {current_user.id}")
return None
def get_system_spotify_token():
"""Get a valid Spotify access token from system refresh token, refreshing if needed"""
system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '')
if not system_refresh_token:
current_app.logger.debug("No system Spotify refresh token available")
return None
# Check if we have a cached system token that's still valid
system_token = SystemSetting.get('system_spotify_token', '')
system_token_expiry_str = SystemSetting.get('system_spotify_token_expiry', '')
if system_token and system_token_expiry_str:
try:
system_token_expiry = datetime.fromisoformat(system_token_expiry_str)
if system_token_expiry > datetime.now() + timedelta(minutes=5):
current_app.logger.debug("Using valid cached system Spotify token")
return system_token
except ValueError:
current_app.logger.warning("Invalid system token expiry format")
# Token is missing or about to expire - try to refresh
current_app.logger.info("Refreshing system Spotify token")
token_info = refresh_spotify_token(system_refresh_token)
if token_info and 'access_token' in token_info:
# Cache the new token
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
expiry = datetime.now() + timedelta(seconds=expires_in)
SystemSetting.set('system_spotify_token', new_token)
SystemSetting.set('system_spotify_token_expiry', expiry.isoformat())
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
SystemSetting.set('fallback_spotify_refresh_token', token_info['refresh_token'])
current_app.logger.info("Successfully refreshed system Spotify token")
return new_token
# If we get here, we couldn't refresh the system token
current_app.logger.error("Failed to get valid system Spotify token")
return None
def get_spotify_token():
"""
Get the best available Spotify token with automatic refresh
Priority: User token -> System token -> None
"""
# Try user token first
user_token = get_current_user_spotify_token()
if user_token:
return user_token, 'user'
# Fall back to system token
system_token = get_system_spotify_token()
if system_token:
return system_token, 'system'
# No valid tokens available
current_app.logger.warning("No valid Spotify tokens available")
return None, 'none'
def refresh_spotify_token_if_needed(token, refresh_token, token_expiry):
"""
Check if a token needs refresh and refresh it if necessary
Returns: (new_token, new_refresh_token, new_expiry) or (None, None, None) if failed
"""
# Check if token is still valid
if token and token_expiry and token_expiry > datetime.now() + timedelta(minutes=5):
return token, refresh_token, token_expiry
# Token needs refresh
if not refresh_token:
current_app.logger.error("Token expired but no refresh token available")
return None, None, None
token_info = refresh_spotify_token(refresh_token)
if token_info and 'access_token' in token_info:
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600)
new_expiry = datetime.now() + timedelta(seconds=expires_in)
new_refresh_token = token_info.get('refresh_token', refresh_token)
return new_token, new_refresh_token, new_expiry
return None, None, None
def get_spotify_user_info(access_token):
"""Get Spotify user info using an access token"""
if not access_token:
return None
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
try:
response = requests.get('https://api.spotify.com/v1/me', headers=headers)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error getting Spotify user info: {response.status_code} - {response.text}")
return None
except Exception as e:
current_app.logger.error(f"Exception getting Spotify user info: {str(e)}")
return None
View File
+1 -1
View File
@@ -57,7 +57,7 @@ class User(db.Model, UserMixin):
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_id = db.Column(db.String(100), index=True, unique=True, nullable=True) # 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)
+76 -13
View File
@@ -6,10 +6,10 @@ from musicround.models import Song, Tag, SongTag, db, Round
from musicround.helpers.metadata import get_song_metadata_by_isrc
from flask_wtf.csrf import CSRFProtect
import traceback # Add import at the top
import spotipy as spotify # Changed import from spotify to spotipy as spotify
import logging
from sqlalchemy import or_
from flask_login import login_required # Add import for login_required
import requests # Import requests for direct API calls
api_bp = Blueprint('api', __name__, url_prefix='/api')
@@ -421,15 +421,25 @@ def get_songs_by_tag(tag_id):
def get_spotify_album(album_id):
try:
# Check for Spotify access token
if 'access_token' not in session:
return jsonify({'error': 'You must be logged in to access this feature'}), 401
if 'spotify_token' not in session: # Assuming token will be stored as 'spotify_token' in session
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
# Initialize Spotify client with access token
sp = spotify.Spotify(auth=session.get('access_token'))
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
# Get album details
album = sp.album(album_id)
album_tracks = sp.album_tracks(album_id, limit=50)
album_url = f'https://api.spotify.com/v1/albums/{album_id}'
album_response = requests.get(album_url, headers=headers)
album_response.raise_for_status() # Raise an exception for HTTP errors
album = album_response.json()
# Get album tracks
album_tracks_url = f'https://api.spotify.com/v1/albums/{album_id}/tracks?limit=50'
album_tracks_response = requests.get(album_tracks_url, headers=headers)
album_tracks_response.raise_for_status()
album_tracks = album_tracks_response.json()
# Format tracks
tracks = []
@@ -454,27 +464,38 @@ def get_spotify_album(album_id):
}
return jsonify(album_data)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error fetching Spotify album: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error fetching Spotify album: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to fetch album details'}), 500
@api_bp.route('/spotify/playlist/<playlist_id>', methods=['GET'])
def get_spotify_playlist(playlist_id):
try:
# Check for Spotify access token
if 'access_token' not in session:
return jsonify({'error': 'You must be logged in to access this feature'}), 401
if 'spotify_token' not in session: # Assuming token will be stored as 'spotify_token' in session
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
# Initialize Spotify client with access token
sp = spotify.Spotify(auth=session.get('access_token'))
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
# Get playlist details
playlist = sp.playlist(playlist_id)
playlist_url = f'https://api.spotify.com/v1/playlists/{playlist_id}'
playlist_response = requests.get(playlist_url, headers=headers)
playlist_response.raise_for_status()
playlist = playlist_response.json()
# Format tracks
tracks = []
# Spotify API for playlist items might be paginated, this example fetches first page
# A more robust solution would handle pagination if necessary
for item in playlist['tracks']['items']:
if not item['track']:
if not item['track']: # Handle cases where track might be None (e.g., local files in playlist)
continue
track = item['track']
@@ -498,10 +519,52 @@ def get_spotify_playlist(playlist_id):
}
return jsonify(playlist_data)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error fetching Spotify playlist: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error fetching Spotify playlist: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to fetch playlist details'}), 500
@api_bp.route('/spotify/search', methods=['GET'])
@login_required
def spotify_search():
query = request.args.get('q', '')
search_type = request.args.get('type', 'track,artist,album') # Default to searching for tracks, artists, and albums
limit = request.args.get('limit', 20)
if not query:
return jsonify({"error": "Search query cannot be empty"}), 400
if 'spotify_token' not in session:
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
params = {
'q': query,
'type': search_type,
'limit': limit
}
try:
search_url = 'https://api.spotify.com/v1/search'
response = requests.get(search_url, headers=headers, params=params)
response.raise_for_status() # Raise an exception for HTTP errors
search_results = response.json()
return jsonify(search_results)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error during Spotify search: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error during Spotify search: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to perform Spotify search'}), 500
@api_bp.route('/deezer/album/<album_id>', methods=['GET'])
def get_deezer_album(album_id):
try:
+48 -136
View File
@@ -4,10 +4,12 @@ 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 werkzeug.security import check_password_hash, generate_password_hash
from musicround.models import User, db
from datetime import datetime
import spotipy
import requests
import secrets
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_spotify_user_info
# Create blueprint
auth_bp = Blueprint('auth', __name__)
@@ -26,154 +28,64 @@ def 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
"""Start Spotify OAuth flow for login using Authlib."""
if current_user.is_authenticated:
return redirect(url_for('core.index'))
# Create a new OAuth object
sp_oauth = current_app.config['sp_oauth']
if not current_app.config.get('SPOTIFY_CLIENT_ID') or not current_app.config.get('SPOTIFY_CLIENT_SECRET'):
flash('Spotify login is not configured.', 'danger')
return redirect(url_for('users.login'))
# Get the authorization URL
auth_url = sp_oauth.get_authorize_url()
# The redirect URI should point to *this* blueprint's callback
redirect_uri = url_for('auth.callback', _external=True)
# 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)
# Ensure 'show_dialog': 'true' is part of authorize_params in auth_helpers.py
# when registering the Spotify client.
return oauth.spotify.authorize_redirect(redirect_uri)
@auth_bp.route('/callback')
def callback():
"""Handle Spotify OAuth callback for login"""
"""Handle Spotify OAuth callback for login using Authlib."""
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'))
token = oauth.spotify.authorize_access_token()
current_app.logger.debug(f"Spotify token received for login: {token}")
# 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'))
# Fetch user info using the token
spotify_info = get_spotify_user_info(token)
# 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")
if not spotify_info or not spotify_info.get('id'):
flash('Could not fetch Spotify user information. Please try again.', 'danger')
current_app.logger.error(f"Failed to get Spotify user info for login. Response: {spotify_info}")
return redirect(url_for('users.login'))
# Log the user in
login_user(user)
# Find or create user based on Spotify profile
# This function needs to handle new user creation if they don't exist
# or link to an existing user if email matches, etc.
user = find_or_create_user(spotify_info, 'spotify')
# Update the Spotify client with the new token
current_app.config['sp'].set_auth(token_info['access_token'])
if not user:
flash('Could not sign in with Spotify. If you are a new user, registration might be disabled. Please try again or contact support.', 'danger')
current_app.logger.error(f"Failed to find or create user for Spotify login: {spotify_info.get('email')}")
return redirect(url_for('users.login'))
flash("Successfully logged in with Spotify!", "success")
return redirect(url_for('core.index'))
# Update tokens in the User model
if update_oauth_tokens(user, token, 'spotify'):
login_user(user) # Log in the user
user.last_login = datetime.now()
db.session.commit()
flash('Successfully logged in with Spotify!', 'success')
current_app.logger.info(f"User {user.username} logged in via Spotify ({spotify_info.get('name')})")
next_page = request.args.get('next') or session.pop('next_url', None)
if not next_page or not next_page.startswith('/'):
next_page = url_for('core.index')
return redirect(next_page)
else:
flash('Failed to store Spotify tokens. Please try again.', 'danger')
current_app.logger.error(f"Failed to update Spotify tokens for user {user.username} during login.")
return redirect(url_for('users.login'))
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'))
current_app.logger.error(f"Error in Spotify login callback: {str(e)}")
flash(f'An error occurred during Spotify login: {str(e)}.', 'danger')
return redirect(url_for('users.login'))
+214 -222
View File
@@ -1,9 +1,19 @@
"""
Core routes that form the basic navigation structure of the app.
Core routes for the Music Round application
"""
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
import os
import json
import time
import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, jsonify, abort, send_from_directory
from flask_login import login_required, current_user
from musicround.models import db, Round, Song
from musicround.config import Config
import requests
import traceback
from musicround.helpers.auth_helpers import oauth, update_oauth_tokens
from musicround.helpers.spotify_helper import get_spotify_token, get_spotify_user_info
from datetime import datetime
core_bp = Blueprint('core', __name__)
@@ -35,249 +45,239 @@ def search():
@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'))
"""Process Spotify search and display results using Authlib"""
if not current_user.spotify_token:
current_app.logger.warning(f"User {current_user.id} does not have a Spotify token for search.")
flash("Please connect your Spotify account to search.", "warning")
return redirect(url_for('users.spotify_auth'))
# Prepare Authlib token object from current_user
expires_at_timestamp = None
if current_user.spotify_token_expiry:
if isinstance(current_user.spotify_token_expiry, datetime):
expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp())
else:
try: # Should be a datetime object from DB, but being defensive
expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp())
except ValueError:
current_app.logger.warning(f"Could not parse spotify_token_expiry for user {current_user.id}.")
authlib_token = {
'access_token': current_user.spotify_token,
'refresh_token': current_user.spotify_refresh_token,
'token_type': 'Bearer',
'expires_at': expires_at_timestamp
}
if current_user.spotify_token_expiry and current_user.spotify_token_expiry < datetime.now():
current_app.logger.info(f"User {current_user.id}'s Spotify token appears expired. Authlib will attempt refresh.")
search_api_url = 'https://api.spotify.com/v1/search'
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}' for user {current_user.id}")
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}
{'q': f'artist:{search_term}', 'type': 'track,album,playlist', 'limit': 10},
{'q': f'track:{search_term}', 'type': 'track,album,playlist', 'limit': 10},
{'q': search_term, 'type': 'track,album,playlist', 'limit': 10, 'market': 'US'},
{'q': f'{search_term}', 'type': 'track,album,playlist', 'limit': 20, 'include_external': 'audio'}
]
tracks = []
albums = []
playlists = []
results_found = False
# Try different search strategies until we get results
for strategy in search_strategies:
current_app.logger.info(f"Trying search strategy: {strategy}")
for strategy_params in search_strategies:
current_app.logger.info(f"Trying search strategy: {strategy_params} for user {current_user.id}")
try:
response = oauth.spotify.get(search_api_url, params=strategy_params, token=authlib_token)
response.raise_for_status()
results = response.json()
# Perform search with current strategy
results = sp.search(**strategy)
# Check if the token was refreshed by Authlib
# The new token would be in oauth.spotify.token
if oauth.spotify.token and oauth.spotify.token.get('access_token') != authlib_token.get('access_token'):
current_app.logger.info(f"Spotify token refreshed for user {current_user.id}.")
if update_oauth_tokens(current_user, oauth.spotify.token, 'spotify'):
# Update the local authlib_token variable to use the new token for subsequent requests in this function
authlib_token = oauth.spotify.token
current_app.logger.info(f"Refreshed Spotify token saved and authlib_token updated for user {current_user.id}.")
else:
current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id}.")
# 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
if results:
if 'tracks' in results and results['tracks']['items']:
results_found = True
for item in results['tracks']['items']:
if item is None or 'id' not in item or 'artists' not in item or 'album' not in item:
continue
try:
artist_names = [artist['name'] for artist in item['artists']]
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']
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)
})
except Exception as item_error:
current_app.logger.error(f"Error processing track item: {str(item_error)} for item {item}")
artist_names = [artist['name'] for artist in item['artists']]
if 'albums' in results and results['albums']['items']:
results_found = True
for item in results['albums']['items']:
if item is None or 'id' not in item or 'artists' not in item:
continue
try:
artist_names = [artist['name'] for artist in item['artists']]
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url']
albums.append({
'id': item['id'], 'name': item['name'], 'artist': ', '.join(artist_names),
'image_url': image_url, 'total_tracks': item.get('total_tracks', 0)
})
except Exception as item_error:
current_app.logger.error(f"Error processing album item: {str(item_error)} for item {item}")
# 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
if 'playlists' in results and results['playlists']['items']:
results_found = True
for item in results['playlists']['items']:
if item is None or 'id' not in item or 'owner' not in item:
continue
try:
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url']
track_count = item['tracks']['total'] if 'tracks' in item and item['tracks'] else 0
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
})
except Exception as item_error:
current_app.logger.error(f"Error processing playlist item: {str(item_error)} for item {item}")
# Get album name safely
album_name = item['album']['name'] if 'album' in item and item['album'] and 'name' in item['album'] else 'Unknown Album'
if results_found:
current_app.logger.info(f"Results found with strategy: {strategy_params}")
break
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)
})
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error with search strategy {strategy_params} for user {current_user.id}: {http_err}")
if hasattr(http_err, 'response') and http_err.response is not None:
current_app.logger.error(f"Response status: {http_err.response.status_code}, Response text: {http_err.response.text}")
if http_err.response.status_code == 401:
current_app.logger.warning(f"Spotify token invalid/expired for user {current_user.id} during search. Clearing tokens.")
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.spotify_id = None
db.session.commit()
flash("Your Spotify session has expired or is invalid. Please reconnect your Spotify account.", "warning")
return redirect(url_for('users.spotify_auth'))
continue
except Exception as search_error:
current_app.logger.error(f"Error with search strategy {strategy_params} for user {current_user.id}: {str(search_error)}")
current_app.logger.error(traceback.format_exc())
continue
# 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
if not results_found:
current_app.logger.info(f"No results from primary searches for user {current_user.id}, trying fallback approaches")
fallback_strategies = [
{'q': search_term, 'type': 'track,album,playlist', 'limit': 20, 'market': 'US'},
{'q': f'{search_term}*', 'type': 'track', 'limit': 20},
{'q': search_term, 'type': 'track', 'limit': 50}
]
for strategy_params in fallback_strategies:
current_app.logger.info(f"Trying fallback strategy: {strategy_params} for user {current_user.id}")
try:
response = oauth.spotify.get(search_api_url, params=strategy_params, token=authlib_token)
response.raise_for_status()
results = response.json()
artist_names = [artist['name'] for artist in item['artists']]
# Check if the token was refreshed by Authlib
if oauth.spotify.token and oauth.spotify.token.get('access_token') != authlib_token.get('access_token'):
current_app.logger.info(f"Spotify token refreshed during fallback for user {current_user.id}.")
if update_oauth_tokens(current_user, oauth.spotify.token, 'spotify'):
# Update the local authlib_token variable
authlib_token = oauth.spotify.token
current_app.logger.info(f"Refreshed Spotify token saved (fallback) and authlib_token updated for user {current_user.id}.")
else:
current_app.logger.error(f"Failed to save refreshed Spotify token (fallback) for user {current_user.id}.")
# 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
if results:
if 'tracks' in results and results['tracks']['items']:
results_found = True
for item in results['tracks']['items']:
if item is None or 'id' not in item or 'artists' not in item:
continue
try:
artist_names = [artist.get('name', 'Unknown Artist') for artist in item.get('artists', [])]
album_name = "Unknown Album"
image_url = None
if 'album' in item and item['album']:
album_name = item['album'].get('name', 'Unknown Album')
if 'images' in item['album'] and item['album']['images']:
image_url = item['album']['images'][0].get('url')
tracks.append({
'id': item['id'], 'name': item.get('name', 'Unknown Track'),
'artist': ', '.join(artist_names), 'album': album_name, 'image_url': image_url,
'preview_url': item.get('preview_url'), 'duration_ms': item.get('duration_ms', 0)
})
except Exception as item_error:
current_app.logger.error(f"Error processing fallback track item: {str(item_error)} for item {item}")
if results_found:
current_app.logger.info(f"Results found with fallback strategy: {strategy_params}")
break
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error with fallback strategy {strategy_params} for user {current_user.id}: {http_err}")
if hasattr(http_err, 'response') and http_err.response is not None:
current_app.logger.error(f"Response status: {http_err.response.status_code}, Response text: {http_err.response.text}")
if http_err.response.status_code == 401:
current_app.logger.warning(f"Spotify token invalid/expired for user {current_user.id} during fallback. Clearing tokens.")
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.spotify_id = None
db.session.commit()
flash("Your Spotify session has expired or is invalid. Please reconnect your Spotify account.", "warning")
return redirect(url_for('users.spotify_auth'))
continue
except Exception as fallback_error:
current_app.logger.error(f"Error with fallback strategy {strategy_params} for user {current_user.id}: {str(fallback_error)}")
current_app.logger.error(traceback.format_exc())
continue
albums.append({
'id': item['id'],
'name': item['name'],
'artist': ', '.join(artist_names),
'image_url': image_url,
'total_tracks': item.get('total_tracks', 0)
})
unique_tracks = list({track['id']: track for track in tracks}.values())
unique_albums = list({album['id']: album for album in albums}.values())
unique_playlists = list({playlist['id']: playlist for playlist in playlists}.values())
# 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
current_app.logger.info(f"Search for '{search_term}' by user {current_user.id} yielded: {len(unique_tracks)} tracks, {len(unique_albums)} albums, {len(unique_playlists)} playlists")
# 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,
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'))
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(f"Generic Spotify search error for user {current_user.id} ({search_term}): {str(e)}")
current_app.logger.error(traceback.format_exc())
# Render error template
if "token" in str(e).lower() or "auth" in str(e).lower() or "401" in str(e):
flash("An authentication error occurred with Spotify. Please try reconnecting your account.", "danger")
return redirect(url_for('users.spotify_auth'))
return render_template('error.html',
error_message="An error occurred while searching Spotify.",
error_details=str(e),
@@ -291,10 +291,7 @@ def view_songs():
"""
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)
@@ -305,19 +302,14 @@ 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
abort(403)
return send_from_directory('/data', filepath)
+24 -61
View File
@@ -3,6 +3,8 @@ 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
from musicround.helpers.auth_helpers import oauth
from musicround.helpers.import_helper import ImportHelper
generate_bp = Blueprint('generate', __name__)
@@ -330,39 +332,20 @@ def get_songs_from_deezer_playlist(playlist_id):
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:
imported_songs = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
source='deezer',
deezer_client=deezer_client
)
if not imported_songs:
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Deezer playlist {playlist_id}")
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
return imported_songs[:songs_per_round]
except Exception as e:
current_app.logger.error(f"Error fetching Deezer playlist: {e}")
current_app.logger.error(f"Error fetching or importing Deezer playlist {playlist_id}: {e}")
import traceback
current_app.logger.error(traceback.format_exc())
return []
@@ -372,42 +355,22 @@ 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:
imported_songs = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
source='spotify',
oauth_spotify=oauth.spotify
)
if not imported_songs:
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Spotify playlist {playlist_id}")
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
return imported_songs[:songs_per_round]
except Exception as e:
current_app.logger.error(f"Error fetching Spotify playlist: {e}")
current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}")
import traceback
current_app.logger.error(traceback.format_exc())
return []
+16 -8
View File
@@ -7,15 +7,15 @@ 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
from musicround.helpers.auth_helpers import oauth # Import the oauth object
import_bp = Blueprint('import', __name__, url_prefix='/import')
def fetch_all_user_playlists(sp, user_id, limit=50):
def fetch_all_user_playlists(user_id, limit=50): # Removed sp argument
"""
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)
@@ -32,7 +32,8 @@ def fetch_all_user_playlists(sp, user_id, limit=50):
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)
# Use oauth.spotify instead of sp
results = oauth.spotify.get(f'users/{user_id}/playlists', params={'limit': limit, 'offset': offset}).json()
# If first request, get the total
if total is None:
@@ -101,13 +102,20 @@ def import_official_playlists():
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')
from musicround.helpers.import_helper import ImportHelper
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"Playlist import completed with {result['imported_count']} new songs, {result['skipped_count']} skipped, and {result['error_count']} errors.", 'warning')
else:
flash('No songs were imported from the playlist. It might be empty or an issue occurred.', 'info')
return redirect(url_for('core.view_songs'))
# Get filter keywords from the query string (default to empty list)
@@ -164,7 +172,7 @@ def import_official_playlists():
account_start = time.time()
# Fetch all playlists for this account
account_playlists = fetch_all_user_playlists(sp, account)
account_playlists = fetch_all_user_playlists(account) # Removed sp argument
account_end = time.time()
account_debug['time_ms'] = int((account_end - account_start) * 1000)
+80 -109
View File
@@ -4,108 +4,92 @@ Import routes for the Music Round application
import json
import time
import random
from datetime import datetime # Add datetime import
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify
from flask_login import current_user
from musicround.models import Song, db
from musicround.routes.import_songs import import_pl
from musicround.helpers.import_helper import ImportHelper
from musicround.helpers.auth_helpers import oauth
import_bp = Blueprint('import', __name__, url_prefix='/import')
def fetch_all_user_playlists(sp, user_id, limit=50):
def fetch_all_user_playlists(oauth_client, token, user_id, limit=50):
"""
Fetch all playlists from a specific Spotify user account with pagination
Fetch all playlists from a specific Spotify user account with pagination using Authlib.
Args:
sp: Spotify API client
user_id: Spotify user ID to fetch playlists from
limit: Number of playlists to fetch per request (max 50)
oauth_client: The Authlib Spotify client (e.g., oauth.spotify).
token: An Authlib token object for authentication.
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
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}'")
current_app.logger.info(f"Started fetching playlists for user '{user_id}' using Authlib")
# Hard limit to prevent infinite loops (should never be needed if API works correctly)
max_loops = 100
max_loops = 100 # Hard limit to prevent infinite loops
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)
api_url = f'https://api.spotify.com/v1/users/{user_id}/playlists'
params = {'limit': limit, 'offset': offset}
current_app.logger.info(f"Fetching playlists for {user_id} with offset={offset}, limit={limit}, loop={loop_count}")
# Use Authlib client to make the GET request
resp = oauth_client.get(api_url, token=token, params=params)
resp.raise_for_status() # Raise an exception for HTTP errors
results = resp.json()
# 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")
current_app.logger.warning(f"API reported 0 total playlists for {user_id} - possible API error or no playlists")
# 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}")
if batch_count == 0 and offset < total:
current_app.logger.warning(f"Received 0 playlists for {user_id} at offset {offset} but expected more (total: {total}) - stopping.")
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})")
if not results.get('next'): # Spotify API uses 'next' field to indicate more pages
current_app.logger.info(f"No more 'next' URL for {user_id} at offset {offset}. Fetched {len(all_playlists)}/{total}.")
break
# Update offset for next batch
offset += batch_count
offset += batch_count # Correctly increment offset by the number of items received
# 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"
)
if len(all_playlists) >= total:
current_app.logger.info(f"Fetched all {total} playlists for {user_id}.")
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")
current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total if total is not None else 'unknown'} playlists for user '{user_id}' in {duration}ms")
return all_playlists
@@ -144,15 +128,22 @@ def filter_playlists_by_keywords(playlists, keywords, debug_info=None):
@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']
# Ensure user is logged in and has a Spotify token in session or on their user object
auth_token = session.get('spotify_token') # Attempt to get token from session
if not auth_token and current_user.is_authenticated and current_user.spotify_token:
auth_token = {
'access_token': current_user.spotify_token,
'token_type': 'Bearer',
'expires_at': current_user.spotify_token_expiry.timestamp() if current_user.spotify_token_expiry else None,
'refresh_token': current_user.spotify_refresh_token
}
elif not auth_token:
flash("No active Spotify session. Please connect your Spotify account.", "warning")
return redirect(url_for('users.spotify_link'))
# 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:
@@ -194,57 +185,31 @@ def import_official_playlists():
'filtered_out': 0,
'matched_keywords': {},
'query_time_ms': 0,
'duplicates_removed': 0
'duplicates_removed': 0,
'token_source': 'user_session_or_db' if auth_token.get('access_token') == session.get('spotify_token', {}).get('access_token') or (current_user.is_authenticated and auth_token.get('access_token') == current_user.spotify_token) else 'unknown'
}
# Initialize playlists list
all_playlists = []
try:
start_time = time.time()
start_query_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
if selected_account == 'all':
for acc_id in spotify_accounts:
current_app.logger.info(f"Fetching playlists for official account: {acc_id}")
playlists = fetch_all_user_playlists(oauth.spotify, auth_token, acc_id)
if debug_mode:
debug_info['accounts'][acc_id] = {'fetched': len(playlists), 'filtered_in': 0, 'filtered_out': 0}
all_playlists.extend(playlists)
debug_info['total_fetched'] += len(playlists)
else:
current_app.logger.info(f"Fetching playlists for selected official account: {selected_account}")
playlists = fetch_all_user_playlists(oauth.spotify, auth_token, selected_account)
if debug_mode:
debug_info['accounts'][selected_account] = {'fetched': len(playlists), 'filtered_in': 0, 'filtered_out': 0}
all_playlists.extend(playlists)
debug_info['total_fetched'] += len(playlists)
# Remove duplicates based on playlist ID
unique_playlists = []
@@ -259,8 +224,8 @@ def import_official_playlists():
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)
end_query_time = time.time()
debug_info['query_time_ms'] = int((end_query_time - start_query_time) * 1000)
# Log summary
current_app.logger.info(
@@ -496,11 +461,27 @@ def test_spotify_client():
# Test spotipy implementation
try:
sp = current_app.config['sp']
auth_token = session.get('spotify_token')
if not auth_token and current_user.is_authenticated and current_user.spotify_token:
# Attempt to build a token object compatible with Authlib from user's stored token
auth_token = {
'access_token': current_user.spotify_token, # Assuming this is the access token string
'token_type': 'Bearer', # Default token type
'expires_at': current_user.spotify_token_expiry.timestamp() if current_user.spotify_token_expiry else None,
'refresh_token': current_user.spotify_refresh_token
}
# Ensure expires_at is a Unix timestamp if present
if auth_token.get('expires_at') and isinstance(auth_token['expires_at'], datetime):
auth_token['expires_at'] = int(auth_token['expires_at'].timestamp())
if not auth_token:
raise Exception("Spotify token not found for current user or session.")
start_time = time.time()
current_app.logger.info(f"Testing spotipy implementation for account {account}")
spotipy_playlists = fetch_all_user_playlists(sp, account)
current_app.logger.info(f"Testing Authlib Spotify implementation for account {account}")
# Use the fetch_all_user_playlists function which now uses oauth.spotify
spotipy_playlists = fetch_all_user_playlists(oauth.spotify, auth_token, account)
end_time = time.time()
duration_ms = int((end_time - start_time) * 1000)
@@ -509,11 +490,6 @@ def test_spotify_client():
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}")
@@ -543,11 +519,6 @@ def test_spotify_client():
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}")
+80 -34
View File
@@ -3,43 +3,59 @@ import os
import requests
import json
from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, flash
from flask_login import login_required, current_user
from musicround.models import Song, db
from musicround.helpers.metadata import get_song_metadata_by_isrc
from musicround.helpers.import_helper import ImportHelper
from musicround.helpers.auth_helpers import oauth
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
result = ImportHelper.import_item(service_name='spotify', item_type='track', item_id=track_id, oauth_spotify=oauth.spotify)
return result.get('imported_count', 0) > 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)
ImportHelper.import_item(service_name='spotify', item_type='playlist', item_id=playlist_id, oauth_spotify=oauth.spotify)
# 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)
ImportHelper.import_item(service_name='spotify', item_type='album', item_id=album_id, oauth_spotify=oauth.spotify)
@import_songs_bp.route('/song', methods=['GET', 'POST'])
@login_required
def import_song():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import songs.", "warning")
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 not current_user.spotify_token:
flash("Please connect your Spotify account to import songs.", "warning")
return redirect(url_for('users.spotify_auth'))
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} song!', 'success')
elif result['skipped_count'] > 0:
if request.method == 'POST':
track_id = request.form.get('song_id')
if not track_id:
flash("No song ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='track', item_id=track_id, oauth_spotify=oauth.spotify)
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} song!', 'success')
elif skipped_count > 0:
flash('Song was already in the database.', 'info')
else:
flash(f'Error importing song: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing song: {", ".join(errors) if errors else "Unknown error"}', 'danger')
return redirect(url_for('core.view_songs'))
@@ -53,22 +69,37 @@ def import_song():
back_url=url_for('core.search'))
@import_songs_bp.route('/playlist', methods=['GET', 'POST'])
@login_required
def import_playlist():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import playlists.", "warning")
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 not current_user.spotify_token:
flash("Please connect your Spotify account to import playlists.", "warning")
return redirect(url_for('users.spotify_auth'))
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')
if request.method == 'POST':
playlist_id = request.form.get('playlist_id')
if not playlist_id:
flash("No playlist ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='playlist', item_id=playlist_id, oauth_spotify=oauth.spotify)
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
error_count = result.get('error_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} songs from playlist! ({skipped_count} skipped, {error_count} errors).', 'success')
elif skipped_count > 0 and error_count == 0:
flash(f'All {skipped_count} songs were already in the database.', 'info')
elif error_count > 0:
flash(f'Playlist import: {imported_count} new, {skipped_count} skipped, {error_count} errors. Errors: {", ".join(errors)}', 'warning')
else:
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing playlist: {", ".join(errors) if errors else "No songs imported, playlist might be empty or an unknown issue occurred."}', 'danger')
return redirect(url_for('core.view_songs'))
@@ -82,22 +113,37 @@ def import_playlist():
back_url=url_for('core.search'))
@import_songs_bp.route('/album', methods=['GET', 'POST'])
@login_required
def import_album():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import albums.", "warning")
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 not current_user.spotify_token:
flash("Please connect your Spotify account to import albums.", "warning")
return redirect(url_for('users.spotify_auth'))
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')
if request.method == 'POST':
album_id = request.form.get('album_id')
if not album_id:
flash("No album ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='album', item_id=album_id, oauth_spotify=oauth.spotify)
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
error_count = result.get('error_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} songs from album! ({skipped_count} skipped, {error_count} errors).', 'success')
elif skipped_count > 0 and error_count == 0:
flash(f'All {skipped_count} songs were already in the database.', 'info')
elif error_count > 0:
flash(f'Album import: {imported_count} new, {skipped_count} skipped, {error_count} errors. Errors: {", ".join(errors)}', 'warning')
else:
flash(f'Error importing album: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing album: {", ".join(errors) if errors else "No songs imported, album might be empty or an unknown issue occurred."}', 'danger')
return redirect(url_for('core.view_songs'))
+10 -7
View File
@@ -22,6 +22,7 @@ from musicround.models import Round, Song, db
from pydub import AudioSegment
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from musicround.helpers.auth_helpers import oauth
rounds_bp = Blueprint('rounds', __name__, url_prefix='/rounds')
@@ -37,7 +38,6 @@ def rounds_list():
def round_detail(round_id):
"""Display details of a specific round"""
rnd = Round.query.get(round_id)
sp = current_app.config['sp']
if rnd:
song_ids = rnd.songs.split(',')
@@ -49,14 +49,17 @@ def round_detail(round_id):
email_error = session.pop('email_error', None) # Retrieve and remove the error message from the session
# For sp.current_user(), we need to ensure we have a valid token if needed for this view
# For oauth.spotify, we need to ensure we have a valid token if needed for this view
user_info = None
try:
if 'access_token' in session: # Only try to get user info if we have a token
user_info = sp.current_user()
except:
# If we can't get user info, continue without it
current_app.logger.warning("Could not get Spotify user info")
if current_user.is_authenticated and current_user.spotify_token:
# Use oauth.spotify to get user info
# Ensure the token is fresh or handle potential MissingTokenError
user_info_response = oauth.spotify.get('https://api.spotify.com/v1/me')
user_info_response.raise_for_status() # Raise an exception for bad status codes
user_info = user_info_response.json()
except Exception as e: # Catch a broader range of exceptions, including MissingTokenError
current_app.logger.warning(f"Could not get Spotify user info using Authlib: {str(e)}")
return render_template('round_detail.html', round=rnd, songs=ordered_songs, user_info=user_info, email_error=email_error)
else:
+217 -260
View File
@@ -1,23 +1,62 @@
"""
User authentication and profile management routes
"""
import os
import time
import uuid
import uuid # Add missing import
import time # Add missing import
from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, session, jsonify
from flask_login import login_user, current_user, logout_user, login_required
from flask_login import login_user, current_user, logout_user, login_required # Ensure flask_login is imported
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from spotipy.oauth2 import SpotifyOAuth
from spotipy.exceptions import SpotifyException
from musicround.models import db, User, Role, SystemSetting
from musicround.helpers.utils import get_available_voices
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_google_user_info, get_authentik_user_info
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_google_user_info, get_authentik_user_info, get_spotify_user_info
from musicround.helpers.spotify_helper import get_spotify_token, get_current_user_spotify_token, get_spotify_user_info as spotify_helper_get_user_info
users_bp = Blueprint('users', __name__, url_prefix='/users')
# Helper function for processing Spotify account linking
def _process_spotify_link(user, token_payload, spotify_user_data):
"""Processes the linking of a Spotify account to a user."""
spotify_id = spotify_user_data['id']
# Check if this Spotify account is already linked to another user
existing_user_with_spotify_id = User.query.filter(User.spotify_id == spotify_id, User.id != user.id).first()
if existing_user_with_spotify_id:
flash(f"This Spotify account is already linked to another user ({existing_user_with_spotify_id.username}). "
"Please use a different Spotify account or log in with that user.", "danger")
return False
user.spotify_id = spotify_id
update_oauth_tokens(user, token_payload, 'spotify') # Saves token, refresh_token, expiry to user model
# Store comprehensive user info in session
session['spotify_user_info'] = spotify_user_data
db.session.commit()
flash('Your Spotify account has been successfully linked!', 'success')
current_app.logger.info(f"User {user.id} successfully linked Spotify account {spotify_id}.")
return True
# Helper function for processing Spotify account disconnection
def _process_spotify_disconnect(user):
"""Processes the disconnection of a user's Spotify account."""
current_app.logger.info(f"User {user.id} requested Spotify disconnect.")
user.spotify_id = None
user.spotify_token = None
user.spotify_refresh_token = None
user.spotify_token_expiry = None
# Clear related session variables
session.pop('spotify_display_name', None)
session.pop('spotify_user_info', None)
db.session.commit()
flash('Your Spotify account has been disconnected.', 'success')
current_app.logger.info(f"User {user.id} successfully disconnected their Spotify account.")
def admin_required(f):
from functools import wraps
@wraps(f)
@@ -393,87 +432,115 @@ def profile():
admin_exists = False
if admin_role:
admin_exists = admin_role.users.count() > 0
# Get current time for token expiry checks
# Get current time for token expiry checks
now = datetime.now()
# Get info about current tokens
system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '')
session_bearer = session.get('access_token', '')
session_bearer = session.get('access_token', '') # This is the manually entered token or system token
token_source = session.get('token_source', '')
client_token_expiry = session.get('client_token_expiry', 0)
client_token_expiry = session.get('client_token_expiry', 0) # For system client_credentials token
# Use centralized token management to get the best available token
spotify_token, spotify_token_source = get_spotify_token()
# Fetch user info for the active token
spotify_user_info = None
spotify_user_info = None # This is passed to the template
active_username = None
active_user_id = None
active_user_image = None
active_token_expiry = None
# Check for an active token in the session
if session_bearer:
# If we have a valid token from centralized management, use it
if spotify_token and spotify_token_source in ['user', 'system']:
try:
# Set up the Spotify client with the token
sp = current_app.config['sp']
sp.set_auth(session_bearer)
spotify_user_info = spotify_helper_get_user_info(spotify_token)
if spotify_user_info:
token_source = spotify_token_source
session['token_source'] = spotify_token_source
active_user_id = spotify_user_info.get('id')
active_username = spotify_user_info.get('display_name') or active_user_id
images = spotify_user_info.get('images', [])
if images:
active_user_image = images[0].get('url')
# Client credentials don't have user context
if token_source != 'client_credentials':
try:
# Use the token to get user info
spotify_user_info = sp.current_user()
# Set token expiry based on source
if spotify_token_source == 'user':
active_token_expiry = current_user.spotify_token_expiry
else: # system token
system_token_expiry_str = SystemSetting.get('system_spotify_token_expiry', '')
if system_token_expiry_str:
try:
active_token_expiry = datetime.fromisoformat(system_token_expiry_str)
except ValueError:
pass
if spotify_user_info:
current_app.logger.debug(f"Fetched Spotify user info for {active_username} using {spotify_token_source} token.")
except Exception as e:
current_app.logger.error(f"Error fetching Spotify user info with {spotify_token_source} token: {str(e)}")
if spotify_token_source == 'user':
flash("Your Spotify connection may have expired. Please try re-linking.", "warning")
# Priority 2: Manually entered bearer token from session (fallback)
if not spotify_user_info and session_bearer: # if no valid token from centralized management, check manual token
current_app.logger.debug(f"Attempting to use session_bearer token. Source: {token_source}")
# If token_source indicates it's a user-like token or generic 'manual'
if token_source in ['manual', 'user_manual', 'user']: # 'user' if somehow set without db token
try:
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token={'access_token': session_bearer, 'token_type': 'Bearer'})
if resp.ok:
spotify_user_info = resp.json()
if spotify_user_info and 'id' in spotify_user_info:
active_user_id = spotify_user_info.get('id')
active_username = spotify_user_info.get('display_name') or active_user_id
current_app.logger.debug(f"Found Spotify user: {active_username} (ID: {active_user_id})")
# Get profile image if available
images = spotify_user_info.get('images', [])
if images and len(images) > 0:
active_user_image = images[0].get('url')
if images: active_user_image = images[0].get('url')
current_app.logger.debug(f"Fetched Spotify user info for {active_username} using manual bearer token.")
# Determine expiry for manual token (typically 1 hour from when it was added)
token_added_time = session.get('bearer_token_added', now.timestamp())
active_token_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
session['token_source'] = 'user_manual' # Clarify token source
token_source = 'user_manual'
else: # Token worked but no user ID, or not OK
current_app.logger.warning(f"Manual bearer token ({token_source}) did not return valid user info. Status: {resp.status_code}")
spotify_user_info = None # Ensure it's None
else:
current_app.logger.error(f"Error fetching Spotify user info with manual bearer token: {resp.status_code} {resp.text}")
spotify_user_info = None
if resp.status_code in [401, 403]: flash("Manually entered Spotify token is invalid or expired.", "warning")
except Exception as user_info_error:
current_app.logger.error(f"Error fetching Spotify user info: {str(user_info_error)}")
except Exception as user_info_error:
current_app.logger.error(f"Exception fetching Spotify user info with manual bearer token: {str(user_info_error)}")
spotify_user_info = None
# For manual bearer tokens, try to determine expiry time
if token_source == '' or token_source not in ['user', 'client_credentials', 'system']:
# This is likely a manual bearer token
# Most bearer tokens are valid for 1 hour from issue
# We don't know when it was issued, but we can notify the user
# that these tokens typically expire after 1 hour
from datetime import timedelta
# Manual tokens stored in session likely were just added
token_added_time = session.get('bearer_token_added', now.timestamp())
typical_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
active_token_expiry = typical_expiry
# If token_source indicates it's client_credentials or if user fetch failed, it might be client_credentials
if not spotify_user_info and token_source in ['client_credentials', 'client_credentials_manual']:
try:
resp_cc = oauth.spotify.get('https://api.spotify.com/v1/browse/new-releases', params={'limit':1}, token={'access_token': session_bearer, 'token_type': 'Bearer'})
if resp_cc.ok:
current_app.logger.info("Manual/Session token confirmed as working client credentials.")
if token_source == 'client_credentials_manual':
token_added_time = session.get('bearer_token_added', now.timestamp())
active_token_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
else: # system client_credentials
active_token_expiry = datetime.fromtimestamp(client_token_expiry) if client_token_expiry else None
else:
current_app.logger.warning(f"Manual/Session client credentials token validation failed. Status: {resp_cc.status_code}")
if resp_cc.status_code in [401, 403]: flash("The client credentials token in session is invalid.", "warning")
# Mark it as a manual token for clarity
token_source = 'manual'
session['token_source'] = 'manual'
except Exception as cc_error:
current_app.logger.error(f"Exception validating client credentials token from session: {str(cc_error)}")
except Exception as e:
current_app.logger.error(f"Error setting up Spotify client: {e}")
# Determine Spotify connection status with corrected priority order
spotify_status = 'none' # Default: no connection
# Check for manually set bearer token (highest priority)
has_manual_bearer = 'access_token' in session and token_source == 'manual'
if has_manual_bearer:
# Determine Spotify connection status based on the findings
spotify_status = 'none'
if spotify_token_source == 'user':
spotify_status = 'user'
elif spotify_token_source == 'system':
spotify_status = 'system'
elif token_source == 'user_manual' and spotify_user_info: # Successfully used manual token as user
spotify_status = 'bearer'
# Check user's own Spotify connection (second priority)
elif token_source == 'user' or (current_user.spotify_token and current_user.spotify_refresh_token):
if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now:
# User has valid token
spotify_status = 'user'
elif check_spotify_token(current_user):
# Token was refreshed successfully
spotify_status = 'user'
# Check for client credentials token (third priority)
elif token_source == 'client_credentials':
elif token_source in ['client_credentials', 'client_credentials_manual'] and session_bearer:
spotify_status = 'client_credentials'
return render_template(
@@ -688,112 +755,60 @@ The Quizzical Beats Team
@users_bp.route('/spotify-link', methods=['GET', 'POST'])
@login_required
def spotify_link():
"""Manage Spotify account connection"""
now = datetime.now()
"""
GET: Show management UI (manage_spotify.html) with current status and options.
POST: Trigger Spotify OAuth flow for linking/re-linking.
"""
if request.method == 'POST':
action = request.form.get('action')
# Only POST triggers the OAuth flow
if not current_app.config.get('SPOTIFY_CLIENT_ID'):
flash('Spotify integration is not configured.', 'danger')
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
redirect_uri = url_for('users.spotify_link_callback', _external=True)
return oauth.spotify.authorize_redirect(redirect_uri, show_dialog='true')
if action == 'disconnect':
# Disconnect Spotify account
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.oauth_id = None
# GET: Show management UI
spotify_user_details = None
if current_user.spotify_id:
spotify_user_details = {
"id": current_user.spotify_id,
"display_name": session.get('spotify_display_name', current_user.spotify_id)
}
try:
db.session.commit()
flash('Your Spotify account has been disconnected', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error disconnecting Spotify: {e}")
flash('An error occurred while disconnecting your Spotify account', 'danger')
now = datetime.now()
spotify_user_info = session.get('spotify_user_info')
return render_template('users/manage_spotify.html',
spotify_user_details=spotify_user_details,
now=now,
spotify_user_info=spotify_user_info)
return render_template('users/spotify_link.html', now=now)
@users_bp.route('/spotify-auth')
@users_bp.route('/spotify-link/callback')
@login_required
def spotify_auth():
"""Initiate Spotify OAuth flow"""
def spotify_link_callback():
"""Callback for linking Spotify to an existing user account."""
try:
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
auth_url = sp_oauth.get_authorize_url()
token = oauth.spotify.authorize_access_token()
user_info = get_spotify_user_info(token)
# Store state in session for validation
session['oauth_state'] = sp_oauth.state
if not user_info or 'id' not in user_info:
flash('Failed to get user information from Spotify.', 'danger')
current_app.logger.error(f"Spotify link callback: Missing ID in user_info for user {current_user.id}. Info: {user_info}")
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
return redirect(auth_url)
except Exception as e:
current_app.logger.error(f"Error initiating Spotify auth: {e}")
flash('Error connecting to Spotify. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
@users_bp.route('/spotify-callback')
@login_required
def spotify_callback():
"""Handle Spotify OAuth callback"""
try:
# Verify state parameter
if request.args.get('state') != session.get('oauth_state'):
flash('Authentication state mismatch. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
# Get authorization code
code = request.args.get('code')
if not code:
flash('No authorization code received from Spotify.', 'danger')
return redirect(url_for('users.spotify_link'))
# Exchange code for token
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
token_info = sp_oauth.get_access_token(code)
if not token_info or 'access_token' not in token_info:
flash('Failed to obtain access token from Spotify.', 'danger')
return redirect(url_for('users.spotify_link'))
# Save token to user
current_user.spotify_token = token_info['access_token']
current_user.spotify_refresh_token = token_info.get('refresh_token')
expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None
current_user.spotify_token_expiry = expiry
# Get Spotify user ID
try:
sp = current_app.config['sp']
sp.set_auth(token_info['access_token'])
user_info = sp.current_user()
current_user.oauth_id = user_info['id']
except:
# Continue even if we can't get the Spotify ID
current_app.logger.warning("Could not fetch Spotify user ID")
# Save to database
try:
db.session.commit()
flash('Successfully connected to Spotify!', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error saving Spotify token: {e}")
flash('Error saving Spotify connection.', 'danger')
return redirect(url_for('users.spotify_link'))
_process_spotify_link(current_user, token, user_info)
except Exception as e:
current_app.logger.error(f"Error during Spotify callback: {e}")
flash('Error during Spotify authentication. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
current_app.logger.error(f"Error in Spotify link callback for user {current_user.id}: {str(e)}")
flash('An error occurred while linking your Spotify account. Please try again.', 'danger')
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
@users_bp.route('/spotify/disconnect', methods=['POST'])
@login_required
def spotify_disconnect():
"""Disconnect user's Spotify account."""
_process_spotify_disconnect(current_user)
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
@users_bp.route('/update-bearer-token', methods=['POST'])
@login_required
@@ -804,6 +819,7 @@ def update_bearer_token():
session.pop('access_token', None)
session.pop('token_source', None)
session.pop('bearer_token_added', None)
current_app.logger.info(f"User {current_user.id} cleared Spotify bearer token from session")
flash('Spotify bearer token has been cleared', 'success')
return redirect(url_for('users.profile'))
@@ -814,117 +830,58 @@ def update_bearer_token():
return redirect(url_for('users.profile'))
try:
# Store the token in session with timestamp and mark as manual
# Store the token in session with timestamp and mark as manual initially
session['access_token'] = bearer_token
session['token_source'] = 'manual'
session['token_source'] = 'manual' # Initial assumption
session['bearer_token_added'] = datetime.now().timestamp()
# Test the token with a simple request to validate it
sp = current_app.config['sp']
sp.set_auth(bearer_token)
current_app.logger.info(f"User {current_user.id} added a manual bearer token to session. Validating: {bearer_token[:10]}...")
# Try to get current user info as a test
user_info = sp.current_user()
try:
resp_me = oauth.spotify.get('https://api.spotify.com/v1/me', token={'access_token': bearer_token, 'token_type': 'Bearer'})
user_info = resp_me.json() if resp_me.ok else None
if user_info and 'id' in user_info:
username = user_info.get('display_name') or user_info.get('id')
flash(f'Successfully authenticated with Spotify as {username}', 'success')
if user_info and 'id' in user_info:
# This is a user OAuth token
username = user_info.get('display_name') or user_info.get('id')
# Log who this token belongs to
current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})")
else:
flash('Token saved but validation failed. The token may be invalid or expired.', 'warning')
except Exception as e:
current_app.logger.error(f"Error validating bearer token: {e}")
flash(f'Token saved but error during validation: {str(e)}', 'warning')
# Update token source to reflect it's a user token
session['token_source'] = 'user_manual'
current_app.logger.info(f"Token identified as user OAuth token for: {username} (ID: {user_info.get('id')})")
flash(f'Successfully authenticated with Spotify as {username}', 'success')
current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})")
else:
current_app.logger.warning(f"Manual token is not a valid user token. Response: {resp_me.status_code if not resp_me.ok else 'OK, but no ID or user_info was None'}")
raise Exception("Not a user token or failed to fetch user info")
except Exception as user_error:
current_app.logger.warning(f"Validating as user token failed: {str(user_error)}. Checking if client credentials token...")
try:
resp_browse = oauth.spotify.get('https://api.spotify.com/v1/browse/new-releases', params={'limit':1}, token={'access_token': bearer_token, 'token_type': 'Bearer'})
browse_results = resp_browse.json() if resp_browse.ok else None
if browse_results and 'albums' in browse_results:
# This looks like a client credentials token
session['token_source'] = 'client_credentials_manual'
current_app.logger.info("Token identified as client credentials token (manual)")
flash('Token saved as client credentials token. This type of token cannot access user-specific data.', 'warning')
else:
flash('Token saved but validation failed (cannot fetch new releases). The token may be invalid or expired.', 'warning')
current_app.logger.warning(f"Manual token validation as client_credentials failed. Response: {resp_browse.status_code if not resp_browse.ok else 'OK, but no albums or browse_results was None'}")
except Exception as e_browse:
current_app.logger.error(f"Error validating bearer token as client credentials: {e_browse}")
flash(f'Token saved but error during client credentials validation: {str(e_browse)}', 'warning')
except Exception as e_main:
current_app.logger.error(f"Error processing bearer token: {e_main}")
flash(f'Error processing token: {str(e_main)}', 'warning')
return redirect(url_for('users.profile'))
@users_bp.route('/use-refresh-token', methods=['POST'])
@login_required
def use_refresh_token():
"""Generate a new access token using the stored refresh token"""
# Check if user has a refresh token
if not current_user.spotify_refresh_token:
flash('No Spotify refresh token found. Please connect your Spotify account first.', 'warning')
return redirect(url_for('users.profile'))
try:
# Create OAuth object
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
# Refresh the token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
if not token_info or 'access_token' not in token_info:
flash('Failed to refresh access token from Spotify.', 'danger')
return redirect(url_for('users.profile'))
# Update user model with new token information
current_user.spotify_token = token_info['access_token']
current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None
# If we got a new refresh token (unusual but possible), store it
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Save to database
db.session.commit()
# Also set token in the session for direct API access
session['access_token'] = token_info['access_token']
# Validate the token by getting user info
sp = current_app.config['sp']
sp.set_auth(token_info['access_token'])
user_info = sp.current_user()
if user_info and 'id' in user_info:
flash(f'Successfully generated new token for {user_info.get("display_name", user_info["id"])}', 'success')
else:
flash('Token generated but validation failed.', 'warning')
except Exception as e:
current_app.logger.error(f"Error refreshing token: {e}")
flash(f'Error refreshing token: {str(e)}', 'danger')
return redirect(url_for('users.profile'))
def check_spotify_token(user):
"""
Helper function to check if user's Spotify token needs to be refreshed
Returns True if token is valid, False if not
"""
if not user.spotify_token or not user.spotify_refresh_token or not user.spotify_token_expiry:
return False
now = datetime.now()
# If token expires in less than 5 minutes, refresh it
if user.spotify_token_expiry - now < timedelta(minutes=5):
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
try:
token_info = sp_oauth.refresh_access_token(user.spotify_refresh_token)
user.spotify_token = token_info['access_token']
user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
db.session.commit()
return True
except Exception as e:
current_app.logger.error(f"Error refreshing Spotify token: {e}")
return False
return True
# This will be reviewed and updated for authlib
pass
@users_bp.route('/setup')
@login_required
+16 -2
View File
@@ -201,9 +201,23 @@
</div>
</div>
</nav>
</header>
</header> <main class="flex-grow container mx-auto px-4 py-6">
<!-- Flash Messages - Global display for all pages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6">
{% 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{% elif category == 'warning' %}bg-yellow-100 text-yellow-700{% elif category == 'info' %}bg-blue-100 text-blue-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded shadow-sm">
<div class="flex items-center justify-between">
<span>{{ message }}</span>
<button onclick="this.parentElement.parentElement.style.display='none'" class="text-lg leading-none hover:opacity-75 ml-2">&times;</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="flex-grow container mx-auto px-4 py-6">
{% block content %}
{% endblock %}
</main>
+1 -13
View File
@@ -7,19 +7,7 @@
<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 %}
</div> <div class="bg-white shadow-md rounded-lg p-6">
<form method="POST" action="{{ url_for('generate.import_playlist') }}" id="importPlaylistForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+1 -11
View File
@@ -538,17 +538,7 @@
// Close toast button event
toastCloseBtn.addEventListener('click', hideToast);
// Process flash messages from server
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
showToast('{{ message|safe }}', '{{ category }}');
{% endfor %}
{% endif %}
{% endwith %}
// Process email error if any
// Process email error if any
{% if email_error %}
showToast('{{ email_error }}', 'error');
{% endif %}
@@ -1,172 +0,0 @@
{% 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 %}
@@ -9,12 +9,6 @@
<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">
@@ -6,16 +6,6 @@
<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() }}"/>
@@ -6,16 +6,6 @@
<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() }}"/>
@@ -13,14 +13,7 @@
Enter your email address and we'll send a reset link
</p>
</div>
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
{{ message }}
</div>
{% endfor %}
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
<form class="space-y-6" action="{{ url_for('users.forgot_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
-10
View File
@@ -6,16 +6,6 @@
<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">Login to Quizzical Beats</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.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -0,0 +1,142 @@
{% extends 'base.html' %}
{% block title %}Connect Spotify - Quizzical Beats{% 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">Connect Spotify Account</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left column: Current status -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-navy-700">Spotify Connection Status</h3>
{% if current_user.spotify_token %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-green-100 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-green-600"></i>
</div>
<div>
<p class="font-medium text-green-700">Connected to Spotify</p>
<p class="text-sm text-gray-600">Your account is linked to Spotify</p>
{% if spotify_user_info %}
<p class="text-sm text-gray-600 mt-1">Spotify ID: {{ spotify_user_info.id }}</p>
<p class="text-sm text-gray-600 mt-1">Display Name: {{ spotify_user_info.display_name }}</p>
{% if spotify_user_info.email %}
<p class="text-sm text-gray-600 mt-1">Email: {{ spotify_user_info.email }}</p>
{% endif %}
{% if spotify_user_info.images and spotify_user_info.images[0] %}
<img src="{{ spotify_user_info.images[0].url }}" alt="Spotify Profile Image" class="rounded-full mt-2" style="width:64px;height:64px;">
{% endif %}
{% endif %}
</div>
</div>
<div>
<h4 class="text-md font-medium mb-2">Token Information</h4>
<div class="bg-white p-4 rounded-lg border border-gray-200 text-sm">
<p class="flex justify-between mb-2">
<span class="font-medium">Token Status:</span>
<span class="{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}text-green-600{% else %}text-red-600{% endif %}">
{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}
Valid
{% else %}
Expired
{% endif %}
</span>
</p>
{% if current_user.spotify_token_expiry %}
<p class="flex justify-between">
<span class="font-medium">Expires:</span>
<span>{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</p>
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_disconnect') }}" class="mt-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-md font-medium">
<i class="fas fa-unlink mr-2"></i> Disconnect Spotify
</button>
</form>
{% else %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-gray-200 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-gray-500"></i>
</div>
<div>
<p class="font-medium text-gray-700">Not Connected</p>
<p class="text-sm text-gray-600">Your account is not linked to Spotify</p>
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_link') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="inline-block bg-[#1DB954] hover:bg-[#1ed760] text-white font-medium py-2 px-4 rounded-md">
<i class="fab fa-spotify mr-2"></i> Connect with Spotify
</button>
</form>
{% endif %}
</div>
<!-- Right column: Info and benefits -->
<div>
<h3 class="text-lg font-semibold mb-4 text-navy-700">Why Connect Spotify?</h3>
<div class="bg-teal-50 border-l-4 border-teal-500 p-4 rounded mb-6">
<p class="text-teal-700">
Connecting your Spotify account enhances your music quiz creation experience
</p>
</div>
<div class="space-y-4">
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-music"></i>
</div>
<div>
<h4 class="font-medium">Access Your Playlists</h4>
<p class="text-gray-600 text-sm">Import songs from your personal Spotify playlists</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-search"></i>
</div>
<div>
<h4 class="font-medium">Search the Spotify Catalog</h4>
<p class="text-gray-600 text-sm">Find and import any track from Spotify's huge library</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-chart-line"></i>
</div>
<div>
<h4 class="font-medium">Trending Playlists</h4>
<p class="text-gray-600 text-sm">Access Spotify's official and trending playlists</p>
</div>
</div>
</div>
<div class="mt-8 bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">Privacy Note</h4>
<p class="text-sm text-gray-600">
We only access your Spotify data to help you create music quizzes.
We don't share your information or post to your account.
</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<a href="{{ url_for('users.profile') }}" class="text-teal-600 hover:text-teal-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Profile
</a>
</div>
</div>
{% endblock %}
-10
View File
@@ -6,16 +6,6 @@
<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">My 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 %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Profile Info -->
<div class="md:col-span-2">
-10
View File
@@ -6,16 +6,6 @@
<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">Create an Account</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.register') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -13,14 +13,7 @@
Please enter your new password below
</p>
</div>
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
{{ message }}
</div>
{% endfor %}
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
<form class="space-y-6" action="{{ url_for('users.reset_password', token=token) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -1,143 +0,0 @@
{% extends 'base.html' %}
{% block title %}Connect Spotify - Quizzical Beats{% 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">Connect Spotify Account</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 %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left column: Current status -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-navy-700">Spotify Connection Status</h3>
{% if current_user.spotify_token %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-green-100 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-green-600"></i>
</div>
<div>
<p class="font-medium text-green-700">Connected to Spotify</p>
<p class="text-sm text-gray-600">Your account is linked to Spotify</p>
{% if current_user.oauth_id %}
<p class="text-sm text-gray-600 mt-1">Spotify ID: {{ current_user.oauth_id }}</p>
{% endif %}
</div>
</div>
<div>
<h4 class="text-md font-medium mb-2">Token Information</h4>
<div class="bg-white p-4 rounded-lg border border-gray-200 text-sm">
<p class="flex justify-between mb-2">
<span class="font-medium">Token Status:</span>
<span class="{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}text-green-600{% else %}text-red-600{% endif %}">
{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}
Valid
{% else %}
Expired
{% endif %}
</span>
</p>
{% if current_user.spotify_token_expiry %}
<p class="flex justify-between">
<span class="font-medium">Expires:</span>
<span>{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</p>
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_link') }}" class="mt-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="disconnect">
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-md font-medium">
<i class="fas fa-unlink mr-2"></i> Disconnect Spotify
</button>
</form>
{% else %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-gray-200 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-gray-500"></i>
</div>
<div>
<p class="font-medium text-gray-700">Not Connected</p>
<p class="text-sm text-gray-600">Your account is not linked to Spotify</p>
</div>
</div>
<a href="{{ url_for('users.spotify_auth') }}" class="inline-block bg-[#1DB954] hover:bg-[#1ed760] text-white font-medium py-2 px-4 rounded-md">
<i class="fab fa-spotify mr-2"></i> Connect with Spotify
</a>
{% endif %}
</div>
<!-- Right column: Info and benefits -->
<div>
<h3 class="text-lg font-semibold mb-4 text-navy-700">Why Connect Spotify?</h3>
<div class="bg-teal-50 border-l-4 border-teal-500 p-4 rounded mb-6">
<p class="text-teal-700">
Connecting your Spotify account enhances your music quiz creation experience
</p>
</div>
<div class="space-y-4">
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-music"></i>
</div>
<div>
<h4 class="font-medium">Access Your Playlists</h4>
<p class="text-gray-600 text-sm">Import songs from your personal Spotify playlists</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-search"></i>
</div>
<div>
<h4 class="font-medium">Search the Spotify Catalog</h4>
<p class="text-gray-600 text-sm">Find and import any track from Spotify's huge library</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-chart-line"></i>
</div>
<div>
<h4 class="font-medium">Trending Playlists</h4>
<p class="text-gray-600 text-sm">Access Spotify's official and trending playlists</p>
</div>
</div>
</div>
<div class="mt-8 bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">Privacy Note</h4>
<p class="text-sm text-gray-600">
We only access your Spotify data to help you create music quizzes.
We don't share your information or post to your account.
</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<a href="{{ url_for('users.profile') }}" class="text-teal-600 hover:text-teal-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Profile
</a>
</div>
</div>
{% endblock %}
-1
View File
@@ -2,7 +2,6 @@ Flask
Flask-WTF
Flask-SQLAlchemy
flask_migrate
spotipy
requests
pydub
reportlab
+10 -6
View File
@@ -13,25 +13,29 @@ logger = logging.getLogger(__name__)
# Add the parent directory to the path so we can import modules
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
def run_oauth_migration():
"""Run the migration to add OAuth provider columns"""
# Ensure the project root is also in the path for musicround imports
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '.')) # Assuming run_migration.py is in project root
if project_root not in sys.path:
sys.path.insert(0, project_root)
def run_spotify_oauth_migration():
"""Run the migration to add Spotify OAuth columns"""
try:
# Create a minimal Flask app with database connection
from musicround import db, create_app
app = create_app()
with app.app_context():
logger.info("Starting OAuth providers migration...")
logger.info("Starting Spotify OAuth columns migration...")
# Import and run the migration script
from migrations.add_oauth_providers import run_migration
from migrations.add_spotify_oauth_columns import run_migration
success = run_migration()
if success:
logger.info("Migration completed successfully!")
return True
else:
# This is the key change: don't treat "no changes" as a failure
logger.info("Migration reported no changes needed - continuing anyway")
return True # Return True even when no changes were made
@@ -40,5 +44,5 @@ def run_oauth_migration():
return False
if __name__ == "__main__":
success = run_oauth_migration()
success = run_spotify_oauth_migration()
sys.exit(0 if success else 1) # Exit with 0 (success) or 1 (error)