diff --git a/TODO.md b/TODO.md index 6322fd8..08094b9 100644 --- a/TODO.md +++ b/TODO.md @@ -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 2–3 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 @@ -131,12 +173,12 @@ * [ ] Add support for S3-compatible storage (MinIO, Wasabi, etc.) * [ ] Integrate Dropbox as a storage backend * [ ] Create unified storage management UI -* [ ] Export backups to cloud storage +* [ ] Export backups to cloud storage * [ ] Store and retrieve music rounds from cloud storage * [ ] Add background synchronization and status tracking * [ ] Implement bandwidth-efficient differential uploads -### 🎯 Milestone 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* diff --git a/debug_spotify_auth.py b/debug_spotify_auth.py new file mode 100644 index 0000000..e69de29 diff --git a/debug_spotify_client.py b/debug_spotify_client.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 2098c34..20a3c2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3d7b430..d2b7654 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 diff --git a/fix_spotify.py b/fix_spotify.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/add_spotify_oauth_columns.py b/migrations/add_spotify_oauth_columns.py new file mode 100644 index 0000000..30005e6 --- /dev/null +++ b/migrations/add_spotify_oauth_columns.py @@ -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) diff --git a/milestone_notes_and_ideas/milestone10_ideas.md b/milestone_notes_and_ideas/milestone10_ideas.md new file mode 100644 index 0000000..8da696d --- /dev/null +++ b/milestone_notes_and_ideas/milestone10_ideas.md @@ -0,0 +1,94 @@ +Thanks, that’s perfect context. I’ll now identify high-quality, scrape-friendly sources for historical and current music charts focused on US, UK, Ireland, Germany, and broader Europe. I’ll 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. I’ll 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 100’s start in 1958 to the present. +* **Available Metadata:** Weekly charts list each song’s **current rank**, **song title**, **artist**, as well as meta-data like **last week’s 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 Billboard’s website via HTML pages. Each chart has a structured URL (e.g. `billboard.com/charts/hot-100/` where `` is a Monday chart date in YYYY-MM-DD format) to access a specific week’s Hot 100. The site displays 100 entries per page with the relevant fields. There is **no free official API** (Billboard’s 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; Billboard’s 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 Billboard’s site must be done **respectfully** to avoid IP blocks – e.g. rate-limit requests since it’s a dynamic content site. Billboard content is copyrighted, so reuse of large datasets commercially may violate terms; it’s best to use the data for internal analysis or ensure you have rights for any public use. Technically, the site’s 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 song’s **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 song’s 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 week’s 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/`, and the Irish Singles at `.../irish-singles-chart//`. The `` 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 OCC’s 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 OCC’s 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 IRMA’s own site provides all-time records in a less accessible format. +* **Legal/Technical Considerations:** The OCC site’s 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 Germany’s 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 week’s rank**, **title**, **artist** (including featured artists, separated by slashes or ampersands), and the record **label**. It also lists the song’s **weeks on chart** and **peak position** to date as inline metadata. For example, an entry might read: “1 1 **DNA feat. Suzanne Vega – Tom’s 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-` 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 it’s 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 Friday–Thursday, 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 1950s–60s are not part of this electronic archive because those were published in magazines, but from about 1977 forward, it’s 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 week’s position or a country-by-country breakout in some cases. However, as a scraped dataset today, you won’t 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 1–100 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 (1980s–2000s). 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 it’s global minus US, not Europe-only). +* **Frequency & Archive Depth:** The historical Eurochart was **weekly** (matching the Billboard publication cycle). Archives exist for **1984–2010** weekly. If using WorldRadioHistory, one can get nearly every week’s chart in that range. The Euro Digital Songs chart (2000s–2022) was also weekly; Billboard’s 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 it’s 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 it’s 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 site’s “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//for-date-`). | 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 1984–2010 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., OCC’s 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 doesn’t 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 Don’t 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`:** It’s 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, it’s 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 song’s `year` field if it’s 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. It’s usually better to get genre from another source. If your model has genre and it’s 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 song’s 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 UK’s 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 “ #1” tag; if `peak_position <= 10`, generate “ Top 10”, etc. This way, as you update chart data, tags are consistently applied. + +* **Data Model Extensions (if needed):** If your current Song model doesn’t 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 they’re 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, it’s 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 it’s missing and the project allows). + +By following these steps, you will enrich your Song model with valuable chart metadata. You’ll 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 song’s page something like “🏆 Chart Achievements: #1 in UK, Top 10 in US (20 weeks on chart)” drawn from the data you’ve 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 (Billboard’s pan-European chart) history diff --git a/musicround/__init__.py b/musicround/__init__.py index c35843c..0fae7cc 100644 --- a/musicround/__init__.py +++ b/musicround/__init__.py @@ -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,148 +178,132 @@ def create_app(config=None): except OSError: pass - # Initialize OAuth providers (Google, Authentik) + # 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: + 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 '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.") + + 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.") + + 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.") + + if 'expires_in' in token: + del token['expires_in'] + + 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.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 + + 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')}}}") + + current_user.spotify_token = json.dumps(token) + + 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 '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: + 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: + 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 + + # Initialize OAuth providers (Google, Authentik, Spotify via Authlib) 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 + 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() - # Add before_request handler to ensure Spotify token is available - @app.before_request - def ensure_spotify_token(): - """ - Ensure a valid Spotify token is available in the session. - Priority: - 1. Use existing manual bearer token if present in session - 2. Try to refresh user's token if they have a refresh token - 3. Use client credentials flow as fallback (no user login required) - """ - # Skip for static files and certain paths - if request.path.startswith('/static') or request.path.startswith('/favicon.ico'): - return - - # If we already have a manual token in session, don't do anything - # Manual tokens take priority over everything else - if 'access_token' in session and session.get('token_source') != 'user' and session.get('token_source') != 'client_credentials': - app.logger.debug("Using existing manual bearer token") - return - - from datetime import datetime - from spotipy.oauth2 import SpotifyOAuth - from .models import SystemSetting - import base64 - import requests - - try: - # Only check user token if user is logged in - if current_user.is_authenticated: - # Step 1: Try to use user's refresh token - if current_user.spotify_refresh_token: - app.logger.debug(f"Attempting to refresh token for user {current_user.username}") - - # Create OAuth manager for token refresh - sp_oauth = SpotifyOAuth( - client_id=app.config['SPOTIFY_CLIENT_ID'], - client_secret=app.config['SPOTIFY_CLIENT_SECRET'], - redirect_uri=url_for('users.spotify_callback', _external=True), - scope=app.config['SPOTIFY_SCOPE'] - ) - - try: - # Refresh user's token - token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token) - - if token_info and 'access_token' in token_info: - # Update user's tokens in database - current_user.spotify_token = token_info['access_token'] - current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) - - # If we got a new refresh token (rare but possible), update it - if 'refresh_token' in token_info: - current_user.spotify_refresh_token = token_info['refresh_token'] - - # Save to database - db.session.commit() - - # Store token in session - session['access_token'] = token_info['access_token'] - session['token_source'] = 'user' - - app.logger.debug(f"Generated new token for user {current_user.username}") - return - except Exception as e: - app.logger.warning(f"Failed to refresh user token: {str(e)}") - - # Step 2: If no user token or user not logged in, use client credentials flow - # Check if we already have a valid client credentials token - client_token_expiry = session.get('client_token_expiry', 0) - - if 'access_token' in session and session.get('token_source') == 'client_credentials' and client_token_expiry > datetime.now().timestamp(): - app.logger.debug("Using existing client credentials token") - return - - # Get client credentials from config - client_id = app.config['SPOTIFY_CLIENT_ID'] - client_secret = app.config['SPOTIFY_CLIENT_SECRET'] - - if client_id and client_secret: - app.logger.debug("Getting new token via client credentials flow") - - # Encode client credentials - auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - - # Prepare headers and payload - headers = { - 'Authorization': f'Basic {auth_header}', - 'Content-Type': 'application/x-www-form-urlencoded' - } - data = { - 'grant_type': 'client_credentials' - } - - try: - # Make the POST request - response = requests.post('https://accounts.spotify.com/api/token', headers=headers, data=data) - response.raise_for_status() - - token_data = response.json() - - if 'access_token' in token_data: - # Store the token in session - session['access_token'] = token_data['access_token'] - session['token_source'] = 'client_credentials' - - # Calculate and store expiry time (typically 1 hour from now) - expires_in = token_data.get('expires_in', 3600) # Default to 1 hour - expiry_timestamp = datetime.now().timestamp() + expires_in - session['client_token_expiry'] = expiry_timestamp - - app.logger.debug("Successfully obtained client credentials token") - return - else: - app.logger.warning("No access token in client credentials response") - - except Exception as e: - app.logger.error(f"Error getting client credentials token: {str(e)}") - - except Exception as e: - app.logger.error(f"Error in ensure_spotify_token: {str(e)}") - pass # Continue without a token if all methods fail - # Register blueprints from musicround.routes.core import core_bp from musicround.routes.users import users_bp diff --git a/musicround/config.py b/musicround/config.py index 58ce92f..2e1a76b 100644 --- a/musicround/config.py +++ b/musicround/config.py @@ -34,11 +34,11 @@ class Config: SQLALCHEMY_TRACK_MODIFICATIONS = False - # Spotify API credentials + # Spotify API credentials SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI") - SPOTIFY_SCOPE = "playlist-read-private playlist-read-collaborative user-library-read user-top-read" + 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", "") diff --git a/musicround/deezer_client.py b/musicround/deezer_client.py index 45748d8..f076969 100644 --- a/musicround/deezer_client.py +++ b/musicround/deezer_client.py @@ -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__) @@ -161,11 +162,55 @@ class DeezerClient: if not preview_url: self.logger.warning(f"Track {track_info.get('title')} has no preview URL") return None + + # Extract ISRC + isrc = track_info.get('isrc') - # Check if this track is already in our database - existing_song = Song.query.filter_by(deezer_id=str(track_info['id'])).first() + # 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() diff --git a/musicround/helpers/auth_helpers.py b/musicround/helpers/auth_helpers.py index 8d59c44..f51eb7a 100644 --- a/musicround/helpers/auth_helpers.py +++ b/musicround/helpers/auth_helpers.py @@ -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() diff --git a/musicround/helpers/import_helper.py b/musicround/helpers/import_helper.py index 17a0f16..b57fbf8 100644 --- a/musicround/helpers/import_helper.py +++ b/musicround/helpers/import_helper.py @@ -7,9 +7,14 @@ import json import logging import secrets import string -from flask import current_app, flash +from flask import current_app, flash, session +from flask_login import current_user +from authlib.integrations.base_client.errors import MissingTokenError # Corrected import path +from httpx import HTTPStatusError from musicround.models import Song, Tag, db from musicround.helpers.metadata import get_song_metadata_by_isrc +from musicround.helpers.auth_helpers import oauth, update_oauth_tokens # Ensure update_oauth_tokens is imported +from datetime import datetime # Ensure datetime is imported def generate_token(length=32): """ @@ -29,6 +34,111 @@ def generate_token(length=32): class ImportHelper: """Unified helper for importing music content from different services.""" + @staticmethod + def _create_song_from_spotify(track_info): + """Helper to create a Song object from Spotify track_info when ISRC/rich metadata is not found.""" + if not track_info: + return None + + artist_names = ", ".join([artist['name'] for artist in track_info.get('artists', [])]) + cover_url = None + if track_info.get('album', {}).get('images'): + cover_url = track_info['album']['images'][0]['url'] + + song = Song( + spotify_id=track_info.get('id'), + title=track_info.get('name'), + artist=artist_names, + album_name=track_info.get('album', {}).get('name'), + preview_url=track_info.get('preview_url'), + cover_url=cover_url, + popularity=track_info.get('popularity'), + source='spotify' + ) + release_date = track_info.get('album', {}).get('release_date') + if release_date: + try: + song.year = int(release_date.split('-')[0]) + except (ValueError, IndexError, TypeError): + current_app.logger.warning(f"Could not parse year from release_date: {release_date} for track {song.title}") + + return song + + @staticmethod + def _fetch_audio_features_for_song(sp, song_obj, spotify_track_id, token=None): # Added token parameter + """Fetches audio features for a given song and updates the song object.""" + if not spotify_track_id: + current_app.logger.warning(f"Cannot fetch audio features: Spotify track ID missing for song {song_obj.title if song_obj else 'Unknown'}.") + return + + if not token and (not current_user or not current_user.is_authenticated or not current_user.spotify_token): + current_app.logger.error(f"Cannot fetch audio features for {spotify_track_id}: User not authenticated or no Spotify token, and no explicit token passed.") + return + + # Construct token if not passed explicitly but current_user is available + # This provides a fallback if the calling context didn't pass it but expects this method to handle it. + # However, for consistency, it's better if the caller (import_spotify_track) always passes it. + token_to_use = token + if not token_to_use: + 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: + expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp()) + except ValueError: + pass # Logged by caller + token_to_use = { + 'access_token': current_user.spotify_token, + 'refresh_token': current_user.spotify_refresh_token, + 'token_type': 'Bearer', + 'expires_at': expires_at_timestamp + } + current_app.logger.info(f"Constructed token within _fetch_audio_features_for_song for {spotify_track_id}") + + original_access_token = token_to_use.get('access_token') if token_to_use else None + + try: + current_app.logger.info(f"Fetching audio features for Spotify track ID: {spotify_track_id}") + audio_features_resp = sp.get(f'audio-features/{spotify_track_id}', token=token_to_use) # Pass token + audio_features_resp.raise_for_status() + features = audio_features_resp.json() + + if sp.token and original_access_token and sp.token.get('access_token') != original_access_token: + current_app.logger.info(f"Spotify token refreshed during _fetch_audio_features for {spotify_track_id}, user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + # token_to_use = sp.token # Update local token if it were to be used again in this function + current_app.logger.info(f"Refreshed Spotify token saved (audio features) for user {current_user.id}.") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token (audio features) for user {current_user.id}.") + + if features: + song_obj.danceability = features.get('danceability') + song_obj.energy = features.get('energy') + song_obj.key = features.get('key') + song_obj.loudness = features.get('loudness') + song_obj.mode = features.get('mode') + song_obj.speechiness = features.get('speechiness') + song_obj.acousticness = features.get('acousticness') + song_obj.instrumentalness = features.get('instrumentalness') + song_obj.liveness = features.get('liveness') + song_obj.valence = features.get('valence') + song_obj.tempo = features.get('tempo') + song_obj.duration_ms = features.get('duration_ms') + song_obj.time_signature = features.get('time_signature') + + current_app.logger.info(f"Audio features updated for song: {song_obj.title}") + else: + current_app.logger.warning(f"No audio features returned for Spotify track ID: {spotify_track_id}") + + except MissingTokenError as mte: + current_app.logger.error(f"Authlib MissingTokenError fetching audio features for {spotify_track_id}: {str(mte)}") + except HTTPStatusError as hse: + current_app.logger.error(f"HTTPStatusError ({hse.response.status_code}) fetching audio features for {spotify_track_id}: {hse.response.text}") + except Exception as e: + current_app.logger.error(f"Error fetching audio features for {spotify_track_id}: {str(e)}", exc_info=True) + # Helper method to create tags from genres @staticmethod def create_tags_from_genre(song, genre_data): @@ -66,7 +176,7 @@ class ImportHelper: tag = Tag(name=genre_name) db.session.add(tag) try: - db.session.flush() # Flush to get ID but don't commit yet + db.session.flush() except Exception as e: current_app.logger.error(f"Error creating tag '{genre_name}': {e}") continue @@ -77,87 +187,100 @@ class ImportHelper: current_app.logger.info(f"Added tag '{tag.name}' to song '{song.title}'") @staticmethod - def import_item(service_name, item_type, item_id): + def import_item(service_name, item_type, item_id, oauth_spotify=None): # Added oauth_spotify parameter """ - Import a track, album, or playlist from a specific service. + Generic import function to import items from various services. Args: - service_name (str): Name of the service (e.g., 'spotify', 'deezer') - item_type (str): Type of item ('track', 'album', 'playlist') - item_id (str): ID of the item to import + service_name (str): The name of the service (e.g., 'spotify', 'deezer'). + item_type (str): The type of item to import (e.g., 'track', 'album', 'playlist'). + item_id (str): The ID of the item to import. + oauth_spotify: Optional Authlib Spotify client instance. Returns: - dict: Summary of import operation with counts of imported items + dict: A dictionary containing import statistics (imported_count, skipped_count, error_count, errors). """ - current_app.logger.info(f"Importing {item_type} {item_id} from {service_name}") - - result = { - 'success': False, - 'imported_count': 0, - 'skipped_count': 0, - 'error_count': 0, - 'errors': [], - 'service': service_name, - 'item_type': item_type, - 'item_id': item_id - } - - try: - if service_name.lower() == 'spotify': - # Get Spotify client - sp = current_app.config.get('sp') - if not sp: - result['errors'].append("Spotify client not configured") - return result - - # Handle based on item type - if item_type.lower() == 'track': - track_result = ImportHelper.import_spotify_track(sp, item_id) - result.update(track_result) - elif item_type.lower() == 'album': - album_result = ImportHelper.import_spotify_album(sp, item_id) - result.update(album_result) - elif item_type.lower() == 'playlist': - playlist_result = ImportHelper.import_spotify_playlist(sp, item_id) - result.update(playlist_result) - else: - result['errors'].append(f"Unknown item type: {item_type}") - return result - - elif service_name.lower() == 'deezer': - # Get Deezer client - deezer_client = current_app.config.get('deezer') - if not deezer_client: - result['errors'].append("Deezer client not configured") - return result - - # Handle based on item type - if item_type.lower() == 'track': - track_result = ImportHelper.import_deezer_track(deezer_client, item_id) - result.update(track_result) - elif item_type.lower() == 'album': - album_result = ImportHelper.import_deezer_album(deezer_client, item_id) - result.update(album_result) - elif item_type.lower() == 'playlist': - playlist_result = ImportHelper.import_deezer_playlist(deezer_client, item_id) - result.update(playlist_result) - else: - result['errors'].append(f"Unknown item type: {item_type}") - return result - else: - result['errors'].append(f"Unsupported service: {service_name}") - return result - - result['success'] = len(result['errors']) == 0 - return result + current_app.logger.info(f"Import item called: service='{service_name}', type='{item_type}', id='{item_id}'") + if service_name.lower() == 'spotify': + # Use the provided oauth_spotify client, or fallback to the global one if not provided + spotify_client = oauth_spotify if oauth_spotify else oauth.spotify - except Exception as e: - current_app.logger.error(f"Error importing {item_type} {item_id} from {service_name}: {str(e)}") - result['errors'].append(str(e)) - result['success'] = False - return result + if not spotify_client: + current_app.logger.error("Spotify client not available for import.") + return { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 1, + 'errors': ["Spotify client not configured or passed correctly."] + } - # ------------- SPOTIFY IMPORT METHODS ------------- + if item_type.lower() == 'track': + return ImportHelper.import_spotify_track(spotify_client, item_id) + elif item_type.lower() == 'album': + return ImportHelper.import_spotify_album(spotify_client, item_id) + elif item_type.lower() == 'playlist': + return ImportHelper.import_spotify_playlist(spotify_client, item_id) + else: + current_app.logger.error(f"Unsupported item_type '{item_type}' for Spotify import.") + return { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 1, + 'errors': [f"Unsupported item type '{item_type}' for Spotify."] + } + elif service_name.lower() == 'deezer': + # Ensure we have the Deezer client available + # The Deezer client is typically initialized in create_app and stored in app.config['deezer'] + deezer_client = current_app.config.get('deezer') + if not deezer_client: + current_app.logger.error("Deezer client not found in app.config.") + return { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 1, + 'errors': ["Deezer client not configured."] + } + + lastfm_api_key = current_app.config.get('LASTFM_API_KEY') + + if item_type.lower() == 'track': + song = deezer_client.import_track(item_id, lastfm_api_key=lastfm_api_key) + if song: + return {'imported_count': 1, 'skipped_count': 0, 'error_count': 0, 'errors': []} + else: + return {'imported_count': 0, 'skipped_count': 0, 'error_count': 1, 'errors': [f"Failed to import Deezer track {item_id}."]} + elif item_type.lower() == 'album': + imported_songs = deezer_client.import_album(item_id, lastfm_api_key=lastfm_api_key) + return { + 'imported_count': len(imported_songs), + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + elif item_type.lower() == 'playlist': + imported_songs = deezer_client.import_playlist(item_id, lastfm_api_key=lastfm_api_key) + return { + 'imported_count': len(imported_songs), + 'skipped_count': 0, + 'error_count': 0, + 'errors': [] + } + else: + current_app.logger.error(f"Unsupported item_type '{item_type}' for Deezer import.") + return { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 1, + 'errors': [f"Unsupported item type '{item_type}' for Deezer."] + } + else: + current_app.logger.error(f"Unsupported service_name '{service_name}'.") + return { + 'imported_count': 0, + 'skipped_count': 0, + 'error_count': 1, + 'errors': [f"Unsupported service '{service_name}'."] + } @staticmethod def import_spotify_track(sp, track_id): @@ -168,40 +291,76 @@ class ImportHelper: 'error_count': 0, 'errors': [] } + + if not current_user or not current_user.is_authenticated or not current_user.spotify_token: + current_app.logger.error(f"Import Spotify track: User not authenticated or no Spotify token for track {track_id}.") + result['errors'].append("User not authenticated or no Spotify token.") + result['error_count'] += 1 + return result + + 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: + 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} in import_spotify_track.") + + authlib_token_for_request = { + 'access_token': current_user.spotify_token, + 'refresh_token': current_user.spotify_refresh_token, + 'token_type': 'Bearer', + 'expires_at': expires_at_timestamp + } try: - # First check if this Spotify track is already in our database + current_app.logger.info(f"Attempting to import Spotify track ID: {track_id} for user {current_user.id}") existing_song = Song.query.filter_by(spotify_id=track_id).first() if existing_song: - current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') + current_app.logger.info(f'Spotify track ID {track_id} already exists as song: {existing_song.title} by {existing_song.artist}') result['skipped_count'] += 1 return result - # Get track info from Spotify - track_info = sp.track(track_id) + resp = sp.get(f'tracks/{track_id}', token=authlib_token_for_request) + resp.raise_for_status() + track_info = resp.json() + + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during import_spotify_track (track ID: {track_id}) for user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token # Update local token for any further use in this scope + current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after track import.") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after track import.") + if not track_info: - result['errors'].append(f"Track with ID {track_id} not found on Spotify") + result['errors'].append(f"Track with ID {track_id} not found on Spotify or empty response.") result['error_count'] += 1 + current_app.logger.warning(f"Track with ID {track_id} not found on Spotify or empty response.") return result - # Try to get ISRC if available isrc = track_info.get('external_ids', {}).get('isrc') song = None - # If we have an ISRC, check if a song with this ISRC already exists if isrc: + current_app.logger.info(f"Track {track_id} has ISRC: {isrc}. Checking existing songs by ISRC.") existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() if existing_by_isrc: - current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') + current_app.logger.info(f'Song already exists by ISRC {isrc}: {existing_by_isrc.title} by {existing_by_isrc.artist}. Updating Spotify ID if needed.') + if not existing_by_isrc.spotify_id: + existing_by_isrc.spotify_id = track_id + # Potentially update other Spotify-specific fields if they are missing or different + db.session.commit() result['skipped_count'] += 1 return result - # Get comprehensive metadata using ISRC - current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") + current_app.logger.info(f"Looking up comprehensive metadata for ISRC: {isrc}") metadata = get_song_metadata_by_isrc(isrc, current_app) if metadata and metadata.get("title"): - # Create song with enriched metadata + current_app.logger.info(f"Creating song for ISRC {isrc} using enriched metadata.") song = Song( spotify_id=track_id, deezer_id=metadata.get("deezer_id"), @@ -215,7 +374,7 @@ class ImportHelper: isrc=isrc, album_name=track_info.get('album', {}).get('name'), metadata_sources=','.join(metadata.get("sources", [])), - source='spotify', + source='spotify', # Indicate primary import source spotify_preview_url=metadata.get("spotify_preview_url"), deezer_preview_url=metadata.get("deezer_preview_url"), apple_preview_url=metadata.get("apple_preview_url"), @@ -233,46 +392,106 @@ class ImportHelper: 'deezer_cover_url', 'apple_cover_url'] }) if metadata else None ) - current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") + current_app.logger.info(f"Enriched metadata found from sources: {metadata.get('sources', [])} for track {track_id}") else: - # Fallback to just Spotify data with the ISRC - song = ImportHelper._create_song_from_spotify(track_info, isrc) - else: - # No ISRC available, just use Spotify data - song = ImportHelper._create_song_from_spotify(track_info) + current_app.logger.info(f"No comprehensive metadata found for ISRC {isrc}, or metadata was incomplete. Falling back for track {track_id}.") + + if song is None: # If no ISRC, or ISRC lookup failed to produce a song object + current_app.logger.info(f"Creating song for track {track_id} using basic Spotify data (no ISRC or failed ISRC enrichment).") + song = ImportHelper._create_song_from_spotify(track_info) # Ensure this helper is robust - # We already checked for duplicates above, so we can add the song directly if song: + # Ensure spotify_id is set if created via ISRC path primarily + if not song.spotify_id: song.spotify_id = track_id + if not song.source: song.source = 'spotify' + + db.session.add(song) + try: + db.session.flush() # Flush to get song.id for relationships if needed, and catch early DB errors + except Exception as e_flush: + current_app.logger.error(f"Error flushing session for song {song.title} (Spotify ID: {track_id}): {str(e_flush)}", exc_info=True) + db.session.rollback() + result['errors'].append(f"DB flush error for {song.title}: {str(e_flush)}") + result['error_count'] += 1 + return result + + # Fetch and apply genres if not already set by metadata service + if not song.genre and track_info.get('album'): + album_id_for_genre = track_info['album'].get('id') + if album_id_for_genre: + try: + # Use the potentially refreshed token for this new call + album_details_resp = sp.get(f'albums/{album_id_for_genre}', token=authlib_token_for_request) + album_details_resp.raise_for_status() + album_details = album_details_resp.json() + + # Check for token refresh again after this call + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during album genre fetch for track {track_id}, user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token + current_app.logger.info(f"Refreshed Spotify token saved (album genre fetch) for user {current_user.id}.") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token (album genre fetch) for user {current_user.id}.") + + if album_details.get('genres'): + current_app.logger.info(f"Found genres from album {album_id_for_genre} for track {track_id}: {album_details['genres']}") + ImportHelper.create_tags_from_genre(song, album_details['genres']) + if not song.genre: # Set primary genre field if still empty + song.genre = ", ".join(album_details['genres']) if isinstance(album_details['genres'], list) else str(album_details['genres']) + except Exception as e_album_genre: + current_app.logger.warning(f"Could not fetch/process album genres for track {track_id}: {str(e_album_genre)}") - # Create tags from genre information - if song.genre: - ImportHelper.create_tags_from_genre(song, song.genre) - - # Also check additional data for genres + # Process genres from additional_data if present if song.additional_data: try: - additional_data = json.loads(song.additional_data) - if 'genres' in additional_data: - ImportHelper.create_tags_from_genre(song, additional_data['genres']) - except Exception as e: - current_app.logger.error(f"Error parsing additional data for genres: {e}") + additional_data_json = json.loads(song.additional_data) + if 'genres' in additional_data_json: + ImportHelper.create_tags_from_genre(song, additional_data_json['genres']) + elif 'tags' in additional_data_json: # Support 'tags' field as well + ImportHelper.create_tags_from_genre(song, additional_data_json['tags']) + except (json.JSONDecodeError, TypeError): + current_app.logger.warning(f"Could not parse additional_data for genres for song {song.title} (Spotify ID: {track_id})") - # Get audio features if this is a Spotify track - NEW ADDITION - ImportHelper._fetch_audio_features_for_song(sp, song) + # Fetch audio features + ImportHelper._fetch_audio_features_for_song(sp, song, track_id, token=authlib_token_for_request) # Pass token + # Final commit db.session.commit() - current_app.logger.info(f'Imported Spotify track {song.title} by {song.artist}') result['imported_count'] += 1 + current_app.logger.info(f"Successfully imported Spotify track {track_id} as '{song.title}' with ID {song.id}") else: - current_app.logger.warning(f'Could not create song from Spotify track {track_id}') - result['skipped_count'] += 1 - + result['errors'].append(f"Failed to create song object for track {track_id}") + result['error_count'] += 1 + current_app.logger.error(f"Song object creation failed for Spotify track ID: {track_id}") + return result + except MissingTokenError as mte: + # This specific error should ideally be prevented by the explicit token passing + current_app.logger.error(f"Authlib MissingTokenError (should not happen with explicit token) for Spotify track {track_id}: {str(mte)}", exc_info=True) + result['errors'].append(f"Spotify authentication error (missing token) for track {track_id}.") + result['error_count'] += 1 + db.session.rollback() + return result + except HTTPStatusError as hse: + status_code = hse.response.status_code + error_text = hse.response.text + current_app.logger.error(f"HTTPStatusError ({status_code}) importing Spotify track {track_id}: {error_text}", exc_info=True) + error_detail = f"Spotify API error ({status_code}) for track {track_id}." + try: + error_json = hse.response.json() + if error_json.get('error', {}).get('message'): + error_detail = f"Spotify error for track {track_id}: {error_json['error']['message']}" + except ValueError: + pass + result['errors'].append(error_detail) + result['error_count'] += 1 + return result except Exception as e: - current_app.logger.error(f"Error importing Spotify track {track_id}: {str(e)}") - result['errors'].append(str(e)) + current_app.logger.error(f"Generic error importing Spotify track {track_id}: {str(e)}", exc_info=True) + result['errors'].append(f"Unexpected error for track {track_id}: {str(e)}") result['error_count'] += 1 return result @@ -285,32 +504,120 @@ class ImportHelper: 'error_count': 0, 'errors': [] } - - try: - # Get album tracks from Spotify - album_tracks = sp.album_tracks(album_id) - if not album_tracks or 'items' not in album_tracks: - result['errors'].append(f"Album with ID {album_id} not found on Spotify") - result['error_count'] += 1 - return result - - # Import each track in the album - for track in album_tracks['items']: - if track and 'id' in track: - track_result = ImportHelper.import_spotify_track(sp, track['id']) - result['imported_count'] += track_result['imported_count'] - result['skipped_count'] += track_result['skipped_count'] - result['error_count'] += track_result['error_count'] - result['errors'].extend(track_result['errors']) - - return result - - except Exception as e: - current_app.logger.error(f"Error importing Spotify album {album_id}: {str(e)}") - result['errors'].append(str(e)) + + if not current_user or not current_user.is_authenticated or not current_user.spotify_token: + current_app.logger.error(f"Import Spotify album: User not authenticated or no Spotify token for album {album_id}.") + result['errors'].append("User not authenticated or no Spotify token.") result['error_count'] += 1 return result + 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: + 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} in import_spotify_album.") + + authlib_token_for_request = { + 'access_token': current_user.spotify_token, + 'refresh_token': current_user.spotify_refresh_token, + 'token_type': 'Bearer', + 'expires_at': expires_at_timestamp + } + + try: + current_app.logger.info(f"Starting import for Spotify album ID: {album_id} for user {current_user.id}") + album_resp = sp.get(f'albums/{album_id}', token=authlib_token_for_request) + album_resp.raise_for_status() + album_data = album_resp.json() + album_name = album_data.get('name', 'Unknown Album') + current_app.logger.info(f"Importing tracks from album: '{album_name}' (ID: {album_id})") + + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during album metadata fetch (album ID: {album_id}) for user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token # Update local token + current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after album metadata.") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after album metadata.") + + tracks_url = f'albums/{album_id}/tracks' # Initial URL + # Spotify API for album tracks might be relative, ensure sp.get handles it or construct full URL if needed. + # Authlib's client.get usually handles relative URLs by joining with the base_url. + + page_count = 0 + while tracks_url: + page_count += 1 + current_app.logger.info(f"Fetching page {page_count} of tracks for album '{album_name}' from URL: {tracks_url}") + + # For subsequent calls in pagination, use the potentially updated authlib_token_for_request + tracks_resp = sp.get(tracks_url, token=authlib_token_for_request) + tracks_resp.raise_for_status() + tracks_data = tracks_resp.json() + + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during album tracks fetch (album ID: {album_id}, page: {page_count}) for user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token # Update local token for next iteration / track import + current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} (album tracks page {page_count}).") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} (album tracks page {page_count}).") + + track_items = tracks_data.get('items', []) + if not track_items and page_count == 1: + current_app.logger.warning(f"No tracks found in album '{album_name}' (ID: {album_id}) on the first page.") + + for track_item_simplified in track_items: + track_id = track_item_simplified.get('id') + if not track_id: + current_app.logger.warning(f"Skipping track with no ID in album '{album_name}' (ID: {album_id})") + result['errors'].append(f"Found a track with no ID in album {album_id}.") + result['error_count'] +=1 + continue + + # Call import_spotify_track. It will handle its own token now. + track_import_result = ImportHelper.import_spotify_track(sp, track_id) + + result['imported_count'] += track_import_result.get('imported_count', 0) + result['skipped_count'] += track_import_result.get('skipped_count', 0) + result['error_count'] += track_import_result.get('error_count', 0) + if track_import_result.get('errors'): + result['errors'].extend(track_import_result['errors']) + + tracks_url = tracks_data.get('next') + if tracks_url: + current_app.logger.info(f"Next page of tracks for album '{album_name}' at: {tracks_url}") + else: + current_app.logger.info(f"No more track pages for album '{album_name}' (ID: {album_id}).") + db.session.commit() # Commit any changes made by track imports + + except MissingTokenError as mte: + current_app.logger.error(f"Authlib MissingTokenError while importing Spotify album {album_id}: {str(mte)}", exc_info=True) + result['errors'].append(f"Spotify authentication error (missing token) for album {album_id}.") + result['error_count'] += 1 + except HTTPStatusError as hse: + status_code = hse.response.status_code + error_text = hse.response.text + current_app.logger.error(f"HTTPStatusError ({status_code}) importing Spotify album {album_id}: {error_text}", exc_info=True) + error_detail = f"Spotify API error ({status_code}) for album {album_id}." + try: + error_json = hse.response.json() + if error_json.get('error', {}).get('message'): + error_detail = f"Spotify error for album {album_id}: {error_json['error']['message']}" + except ValueError: + pass + result['errors'].append(error_detail) + result['error_count'] += 1 + except Exception as e: + current_app.logger.error(f"Unexpected error importing Spotify album {album_id}: {str(e)}", exc_info=True) + result['errors'].append(f"Failed to import album {album_id} due to unexpected error: {str(e)}") + result['error_count'] += 1 + + return result + @staticmethod def import_spotify_playlist(sp, playlist_id): """Import all tracks from a Spotify playlist""" @@ -320,292 +627,102 @@ class ImportHelper: 'error_count': 0, 'errors': [] } - - try: - # Get playlist tracks from Spotify - tracks_info = sp.playlist_tracks(playlist_id) - if not tracks_info or 'items' not in tracks_info: - result['errors'].append(f"Playlist with ID {playlist_id} not found on Spotify") - result['error_count'] += 1 - return result - - # Import each track in the playlist - items = tracks_info.get('items', []) - for item in items: - # 'track' can be None if it's a local or unavailable track - track_obj = item.get('track') - if track_obj and 'id' in track_obj: - track_result = ImportHelper.import_spotify_track(sp, track_obj['id']) - result['imported_count'] += track_result['imported_count'] - result['skipped_count'] += track_result['skipped_count'] - result['error_count'] += track_result['error_count'] - result['errors'].extend(track_result['errors']) - + + if not current_user or not current_user.is_authenticated or not current_user.spotify_token: + current_app.logger.error(f"Import Spotify playlist: User not authenticated or no Spotify token for playlist {playlist_id}.") + result['errors'].append("User not authenticated or no Spotify token.") + result['error_count'] += 1 return result + + 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: + 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} in import_spotify_playlist.") + + authlib_token_for_request = { + 'access_token': current_user.spotify_token, + 'refresh_token': current_user.spotify_refresh_token, + 'token_type': 'Bearer', + 'expires_at': expires_at_timestamp + } + + try: + current_app.logger.info(f"Starting import for Spotify playlist ID: {playlist_id} for user {current_user.id}") + # First, get playlist details to get the name (optional, but good for logging) + playlist_details_resp = sp.get(f'playlists/{playlist_id}?fields=name,tracks.next', token=authlib_token_for_request) + playlist_details_resp.raise_for_status() + playlist_data = playlist_details_resp.json() + playlist_name = playlist_data.get('name', 'Unknown Playlist') + current_app.logger.info(f"Importing tracks from playlist: '{playlist_name}' (ID: {playlist_id})") + + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during playlist metadata fetch (playlist ID: {playlist_id}) for user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token # Update local token + current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} after playlist metadata.") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} after playlist metadata.") + tracks_url = f'playlists/{playlist_id}/tracks' # Initial URL + page_count = 0 + + while tracks_url: + page_count += 1 + current_app.logger.info(f"Fetching page {page_count} of tracks for playlist '{playlist_name}' from URL: {tracks_url}") + + tracks_resp = sp.get(tracks_url, token=authlib_token_for_request) + tracks_resp.raise_for_status() + tracks_data = tracks_resp.json() + + if sp.token and sp.token.get('access_token') != authlib_token_for_request.get('access_token'): + current_app.logger.info(f"Spotify token refreshed during playlist tracks fetch (playlist ID: {playlist_id}, page: {page_count}) for user {current_user.id}.") + if update_oauth_tokens(current_user, sp.token, 'spotify'): + authlib_token_for_request = sp.token # Update local token + current_app.logger.info(f"Refreshed Spotify token saved for user {current_user.id} (playlist tracks page {page_count}).") + else: + current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id} (playlist tracks page {page_count}).") + + track_items = tracks_data.get('items', []) + if not track_items and page_count == 1 and not tracks_data.get('next'): # Check if playlist is actually empty + current_app.logger.warning(f"No tracks found in playlist '{playlist_name}' (ID: {playlist_id}). It might be empty.") + + for item_wrapper in track_items: + track_info_obj = item_wrapper.get('track') + if not track_info_obj or not isinstance(track_info_obj, dict): # Skip if track is None (e.g., local file) or not a dict + current_app.logger.warning(f"Skipping item in playlist '{playlist_name}' (ID: {playlist_id}) as it's not a valid track object or is unavailable: {track_info_obj}") + result['errors'].append(f"Skipped an invalid/unavailable item in playlist {playlist_id}.") + # Not necessarily an error_count increment unless we want to be strict + continue + + track_id = track_info_obj.get('id') + if not track_id: # Should not happen if track_info_obj is valid + current_app.logger.warning(f"Skipping track with no ID in playlist '{playlist_name}' (ID: {playlist_id})") + result['errors'].append(f"Found a track with no ID in playlist {playlist_id}.") + result['error_count'] +=1 + continue + + # Call import_spotify_track. It will handle its own token. + track_import_result = ImportHelper.import_spotify_track(sp, track_id) + + result['imported_count'] += track_import_result.get('imported_count', 0) + result['skipped_count'] += track_import_result.get('skipped_count', 0) + result['error_count'] += track_import_result.get('error_count', 0) + if track_import_result.get('errors'): + result['errors'].extend(track_import_result['errors']) + + tracks_url = tracks_data.get('next') + if tracks_url: + current_app.logger.info(f"Next page of tracks for playlist '{playlist_name}' at: {tracks_url}") + else: + current_app.logger.info(f"No more track pages for playlist '{playlist_name}' (ID: {playlist_id}).") + db.session.commit() # Commit any changes made by track imports except Exception as e: current_app.logger.error(f"Error importing Spotify playlist {playlist_id}: {str(e)}") result['errors'].append(str(e)) result['error_count'] += 1 - return result - - @staticmethod - def _create_song_from_spotify(track_info, isrc=None): - """Create a song object from Spotify track data""" - # Basic song with just Spotify data - preview_url = track_info.get('preview_url') - cover_url = track_info['album']['images'][0]['url'] if track_info.get('album', {}).get('images') else None - - # Extract year from album if available - year = None - if track_info.get('album') and track_info['album'].get('release_date'): - year = track_info['album']['release_date'][:4] - - # Try to get genre from album - genre = None - - # Create song object - return Song( - spotify_id=track_info['id'], - title=track_info['name'], - artist=", ".join([artist['name'] for artist in track_info['artists']]), - genre=genre, - year=year, - preview_url=preview_url, - cover_url=cover_url, - popularity=track_info.get('popularity'), - isrc=isrc, - album_name=track_info.get('album', {}).get('name'), - metadata_sources='spotify', - source='spotify' - ) - - @staticmethod - def _fetch_audio_features_for_song(sp, song): - """Fetch audio features for a Spotify song and update the song object""" - try: - audio_features = sp.audio_features(song.spotify_id) - if audio_features and len(audio_features) > 0: - features = audio_features[0] - song.danceability = features.get('danceability') - song.energy = features.get('energy') - song.key = features.get('key') - song.loudness = features.get('loudness') - song.mode = features.get('mode') - song.speechiness = features.get('speechiness') - song.acousticness = features.get('acousticness') - song.instrumentalness = features.get('instrumentalness') - song.liveness = features.get('liveness') - song.valence = features.get('valence') - song.tempo = features.get('tempo') - current_app.logger.info(f"Audio features fetched for song '{song.title}'") - except Exception as e: - current_app.logger.error(f"Error fetching audio features for song '{song.title}': {str(e)}") - - # ------------- DEEZER IMPORT METHODS ------------- - - @staticmethod - def import_deezer_track(deezer_client, track_id): - """Import a single track from Deezer""" - result = { - 'imported_count': 0, - 'skipped_count': 0, - 'error_count': 0, - 'errors': [] - } - - try: - # First check if this Deezer track is already in our database - existing_song = Song.query.filter_by(deezer_id=track_id).first() - if existing_song: - current_app.logger.info(f'Song already exists: {existing_song.title} by {existing_song.artist}') - result['skipped_count'] += 1 - return result - - # Get track info from Deezer - track = deezer_client.get_track(track_id) - if not track: - result['errors'].append(f"Track with ID {track_id} not found on Deezer") - result['error_count'] += 1 - return result - - # Check if ISRC is available - isrc = track.get('isrc') - song = None - - # If we have an ISRC, check if a song with this ISRC already exists - if isrc: - existing_by_isrc = Song.query.filter(Song.isrc == isrc).first() - if existing_by_isrc: - current_app.logger.info(f'Song already exists by ISRC: {existing_by_isrc.title} by {existing_by_isrc.artist}') - result['skipped_count'] += 1 - return result - - # Use metadata helper function to get comprehensive metadata - current_app.logger.info(f"Looking up metadata for ISRC: {isrc}") - metadata = get_song_metadata_by_isrc(isrc, current_app) - - # Use metadata if found, otherwise fall back to Deezer data only - if metadata and metadata.get("title"): - song = Song( - deezer_id=track.get('id'), - spotify_id=metadata.get("spotify_id"), - title=metadata.get("title", track.get('title')), - artist=metadata.get("artist_name", track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist'), - preview_url=metadata.get("preview_url", track.get('preview')), - cover_url=metadata.get("cover_url") or track.get('album', {}).get('cover'), - genre=metadata.get("genre"), - year=metadata.get("year"), - popularity=metadata.get("popularity"), - isrc=isrc, - album_name=track.get('album', {}).get('title'), - metadata_sources=','.join(metadata.get("sources", [])), - source='deezer', - spotify_preview_url=metadata.get("spotify_preview_url"), - deezer_preview_url=metadata.get("deezer_preview_url"), - apple_preview_url=metadata.get("apple_preview_url"), - youtube_preview_url=metadata.get("youtube_preview_url"), - spotify_cover_url=metadata.get("spotify_cover_url"), - deezer_cover_url=metadata.get("deezer_cover_url"), - apple_cover_url=metadata.get("apple_cover_url"), - additional_data=json.dumps( - {k: v for k, v in metadata.items() if k not in ['artist_name', 'title', 'year', 'genre', - 'popularity', 'preview_url', 'sources', - 'isrc', 'spotify_id', 'deezer_id', 'cover_url', - 'spotify_preview_url', 'deezer_preview_url', - 'apple_preview_url', 'youtube_preview_url', - 'spotify_cover_url', 'deezer_cover_url', - 'apple_cover_url']} - ) if metadata else None - ) - current_app.logger.info(f"Metadata found from sources: {metadata.get('sources', [])}") - else: - # Fallback to just Deezer data - song = Song( - deezer_id=track.get('id'), - title=track.get('title'), - artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', - preview_url=track.get('preview'), - cover_url=track.get('album', {}).get('cover'), - album_name=track.get('album', {}).get('title'), - metadata_sources='deezer', - source='deezer' - ) - else: - # No ISRC available, just use Deezer data - song = Song( - deezer_id=track.get('id'), - title=track.get('title'), - artist=track.get('artist', {}).get('name') if track.get('artist') else 'Unknown Artist', - preview_url=track.get('preview'), - cover_url=track.get('album', {}).get('cover'), - album_name=track.get('album', {}).get('title'), - metadata_sources='deezer', - source='deezer' - ) - - # We already checked for duplicates above, so we can add the song directly - if song: - db.session.add(song) - - # Create tags from genre information - if song.genre: - ImportHelper.create_tags_from_genre(song, song.genre) - - # Also check additional data for genres - if song.additional_data: - try: - additional_data = json.loads(song.additional_data) - if 'genres' in additional_data: - ImportHelper.create_tags_from_genre(song, additional_data['genres']) - except Exception as e: - current_app.logger.error(f"Error parsing additional data for genres: {e}") - - db.session.commit() - current_app.logger.info(f'Imported Deezer track {song.title} by {song.artist}') - result['imported_count'] += 1 - else: - current_app.logger.warning(f'Could not create song from Deezer track {track_id}') - result['skipped_count'] += 1 - - return result - - except Exception as e: - current_app.logger.error(f"Error importing Deezer track {track_id}: {str(e)}") - result['errors'].append(str(e)) - result['error_count'] += 1 - return result - - @staticmethod - def import_deezer_album(deezer_client, album_id): - """Import all tracks from a Deezer album""" - result = { - 'imported_count': 0, - 'skipped_count': 0, - 'error_count': 0, - 'errors': [] - } - - try: - # Get album info from Deezer - album = deezer_client.get_album(album_id) - if not album or not album.get('tracks') or not album['tracks'].get('data'): - result['errors'].append(f"Album with ID {album_id} not found on Deezer") - result['error_count'] += 1 - return result - - # Import each track in the album - tracks = album['tracks']['data'] - for track in tracks: - track_id = track.get('id') - if track_id: - track_result = ImportHelper.import_deezer_track(deezer_client, track_id) - result['imported_count'] += track_result['imported_count'] - result['skipped_count'] += track_result['skipped_count'] - result['error_count'] += track_result['error_count'] - result['errors'].extend(track_result['errors']) - - return result - - except Exception as e: - current_app.logger.error(f"Error importing Deezer album {album_id}: {str(e)}") - result['errors'].append(str(e)) - result['error_count'] += 1 - return result - - @staticmethod - def import_deezer_playlist(deezer_client, playlist_id): - """Import all tracks from a Deezer playlist""" - result = { - 'imported_count': 0, - 'skipped_count': 0, - 'error_count': 0, - 'errors': [] - } - - try: - # Get playlist info from Deezer - playlist = deezer_client.get_playlist(playlist_id) - if not playlist or not playlist.get('tracks') or not playlist['tracks'].get('data'): - result['errors'].append(f"Playlist with ID {playlist_id} not found on Deezer") - result['error_count'] += 1 - return result - - # Import each track in the playlist - tracks = playlist['tracks']['data'] - for track in tracks: - track_id = track.get('id') - if track_id: - track_result = ImportHelper.import_deezer_track(deezer_client, track_id) - result['imported_count'] += track_result['imported_count'] - result['skipped_count'] += track_result['skipped_count'] - result['error_count'] += track_result['error_count'] - result['errors'].extend(track_result['errors']) - - return result - - except Exception as e: - current_app.logger.error(f"Error importing Deezer playlist {playlist_id}: {str(e)}") - result['errors'].append(str(e)) - result['error_count'] += 1 return result \ No newline at end of file diff --git a/musicround/helpers/spotify_client_manager.py b/musicround/helpers/spotify_client_manager.py new file mode 100644 index 0000000..8f19d16 --- /dev/null +++ b/musicround/helpers/spotify_client_manager.py @@ -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 diff --git a/musicround/helpers/spotify_debug.py b/musicround/helpers/spotify_debug.py new file mode 100644 index 0000000..e69de29 diff --git a/musicround/helpers/spotify_helper.py b/musicround/helpers/spotify_helper.py new file mode 100644 index 0000000..02db085 --- /dev/null +++ b/musicround/helpers/spotify_helper.py @@ -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 diff --git a/musicround/helpers/spotify_oauth_debug.py b/musicround/helpers/spotify_oauth_debug.py new file mode 100644 index 0000000..e69de29 diff --git a/musicround/helpers/spotify_token.py b/musicround/helpers/spotify_token.py new file mode 100644 index 0000000..e69de29 diff --git a/musicround/models.py b/musicround/models.py index fc94dbc..83616e3 100644 --- a/musicround/models.py +++ b/musicround/models.py @@ -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) diff --git a/musicround/routes/api.py b/musicround/routes/api.py index ce07f1a..21b9498 100644 --- a/musicround/routes/api.py +++ b/musicround/routes/api.py @@ -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/', methods=['GET']) def get_spotify_playlist(playlist_id): try: # Check for Spotify access token - if 'access_token' not in session: - return jsonify({'error': 'You must be logged in to access this feature'}), 401 - - # Initialize Spotify client with access token - sp = spotify.Spotify(auth=session.get('access_token')) + 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 + + 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/', methods=['GET']) def get_deezer_album(album_id): try: diff --git a/musicround/routes/auth.py b/musicround/routes/auth.py index f1c550e..bb3c119 100644 --- a/musicround/routes/auth.py +++ b/musicround/routes/auth.py @@ -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')) + + 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')) + + # The redirect URI should point to *this* blueprint's callback + redirect_uri = url_for('auth.callback', _external=True) - # Create a new OAuth object - sp_oauth = current_app.config['sp_oauth'] - - # Get the authorization URL - auth_url = sp_oauth.get_authorize_url() - - # Store state in session for validation - session['oauth_state'] = sp_oauth.state - - # Set flag that we're using OAuth for login, not just connection - session['spotify_login_flow'] = True - - return redirect(auth_url) + # 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')) - - # Get the authorization code - code = request.args.get('code') - if not code: - flash("No authorization code received from Spotify.", "danger") - return redirect(url_for('auth.index')) - - # Exchange the code for an access token - sp_oauth = current_app.config['sp_oauth'] - token_info = sp_oauth.get_access_token(code) - - if not token_info or 'access_token' not in token_info: - flash("Failed to obtain access token from Spotify.", "danger") - return redirect(url_for('auth.index')) - - # Store the token in the session - session['access_token'] = token_info['access_token'] - session['refresh_token'] = token_info.get('refresh_token') - session['token_expiration'] = token_info.get('expires_at') - session['token_source'] = 'user' - - # Get user info from Spotify to find or create the user account - sp = spotipy.Spotify(auth=token_info['access_token']) - spotify_user_info = sp.current_user() - - if not spotify_user_info or 'id' not in spotify_user_info: - flash("Could not fetch user information from Spotify.", "danger") - return redirect(url_for('auth.index')) - - spotify_id = spotify_user_info['id'] - email = spotify_user_info.get('email') - display_name = spotify_user_info.get('display_name', spotify_id) - - # Log the Spotify login attempt - current_app.logger.info(f"Spotify login attempt: ID={spotify_id}, Email={email}, Name={display_name}") - - # Look for an existing user with this Spotify ID - user = User.query.filter_by(oauth_id=spotify_id).first() - - # If no user found with this Spotify ID but we have an email, try to find by email - if not user and email: - user = User.query.filter_by(email=email).first() - if user: - # Update the user's Spotify ID if they have an account with the same email - user.oauth_id = spotify_id - current_app.logger.info(f"Linked Spotify ID {spotify_id} to existing account: {user.username}") - - # If we still don't have a user, create a new one + token = oauth.spotify.authorize_access_token() + current_app.logger.debug(f"Spotify token received for login: {token}") + + # Fetch user info using the token + spotify_info = get_spotify_user_info(token) + + 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')) + + # 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') + 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") + 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')) - # Log the user in - login_user(user) - - # Update the Spotify client with the new token - current_app.config['sp'].set_auth(token_info['access_token']) - - flash("Successfully logged in with Spotify!", "success") - return redirect(url_for('core.index')) - + # 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')) \ No newline at end of file + 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')) \ No newline at end of file diff --git a/musicround/routes/core.py b/musicround/routes/core.py index 6a39af4..2620f10 100644 --- a/musicround/routes/core.py +++ b/musicround/routes/core.py @@ -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 = [] - - # Try different search strategies until we get results - for strategy in search_strategies: - current_app.logger.info(f"Trying search strategy: {strategy}") + results_found = False + + 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() + + # 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}.") + + 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}") + + 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}") + + 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}") + + if results_found: + current_app.logger.info(f"Results found with strategy: {strategy_params}") + break - # Perform search with current strategy - results = sp.search(**strategy) - - # Extract track results if available - if 'tracks' in results and results['tracks']['items']: - for item in results['tracks']['items']: - # Skip None items or items without required fields - if item is None or 'id' not in item or 'artists' not in item or 'album' not in item: - continue - - artist_names = [artist['name'] for artist in item['artists']] - - # Get album image safely - image_url = None - if 'album' in item and item['album'] and 'images' in item['album'] and item['album']['images']: - image_url = item['album']['images'][0]['url'] if item['album']['images'] else None - - # Get album name safely - album_name = item['album']['name'] if 'album' in item and item['album'] and 'name' in item['album'] else 'Unknown Album' - - tracks.append({ - 'id': item['id'], - 'name': item['name'], - 'artist': ', '.join(artist_names), - 'album': album_name, - 'image_url': image_url, - 'preview_url': item.get('preview_url'), - 'duration_ms': item.get('duration_ms', 0) - }) - - # Extract album results if available - if 'albums' in results and results['albums']['items']: - for item in results['albums']['items']: - # Skip None items or items without required fields - if item is None or 'id' not in item or 'artists' not in item: - continue - - artist_names = [artist['name'] for artist in item['artists']] - - # Get album image safely - image_url = None - if 'images' in item and item['images']: - image_url = item['images'][0]['url'] if item['images'] else None - - albums.append({ - 'id': item['id'], - 'name': item['name'], - 'artist': ', '.join(artist_names), - 'image_url': image_url, - 'total_tracks': item.get('total_tracks', 0) - }) - - # Extract playlist results if available - if 'playlists' in results and results['playlists']['items']: - for item in results['playlists']['items']: - # Skip None items or items without required fields - if item is None or 'id' not in item or 'owner' not in item: - continue - - # Get playlist image safely - image_url = None - if 'images' in item and item['images']: - image_url = item['images'][0]['url'] if item['images'] else None - - # Get track count safely - track_count = 0 - if 'tracks' in item and item['tracks'] is not None and 'total' in item['tracks']: - track_count = item['tracks']['total'] - - # Get owner name safely - owner_name = item['owner'].get('display_name') or item['owner'].get('id', 'Unknown') - - playlists.append({ - 'id': item['id'], - 'name': item['name'], - 'owner': owner_name, - 'image_url': image_url, - 'tracks': track_count - }) - - # If we got any results, break the loop - if tracks or albums or playlists: - break + 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 - # 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'] - }) + 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() + + # 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}.") + + 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 - # 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_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()) - 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) + 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") - 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) \ No newline at end of file diff --git a/musicround/routes/generate.py b/musicround/routes/generate.py index 6180d2d..ea50142 100644 --- a/musicround/routes/generate.py +++ b/musicround/routes/generate.py @@ -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 [] diff --git a/musicround/routes/import.py b/musicround/routes/import.py index 504f7ff..77c421b 100644 --- a/musicround/routes/import.py +++ b/musicround/routes/import.py @@ -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) diff --git a/musicround/routes/import_routes.py b/musicround/routes/import_routes.py index b9937fa..5fe598f 100644 --- a/musicround/routes/import_routes.py +++ b/musicround/routes/import_routes.py @@ -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}") + if len(all_playlists) >= total: + current_app.logger.info(f"Fetched all {total} playlists for {user_id}.") break - - # Break if we've exhausted all playlists (next URL is None) - if not results.get('next'): - current_app.logger.info(f"No more 'next' URL for {user_id} at offset {offset}") - # Check if we should have more results based on 'total' - if offset < total: - current_app.logger.warning( - f"API inconsistency: 'next' is None but we've only fetched {offset} out of {total} playlists" - ) - break - + except Exception as e: current_app.logger.error(f"Error fetching playlists for user '{user_id}' at offset {offset}: {str(e)}") - # Try to get more specific error information import traceback current_app.logger.error(f"Traceback: {traceback.format_exc()}") break - # Check if we hit the max loops limit if loop_count >= max_loops: current_app.logger.warning(f"Reached maximum loop count ({max_loops}) for user {user_id}") end_time = time.time() duration = int((end_time - start_time) * 1000) - current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists for user '{user_id}' in {duration}ms") + 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,58 +185,32 @@ 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 + 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) - for account in accounts_to_process: - if account not in spotify_accounts and account != 'all': - continue - - account_debug = { - 'total': 0, - 'fetched': 0, - 'filtered': 0, - 'time_ms': 0 - } - - account_start = time.time() - - # Fetch all playlists for this account - account_playlists = fetch_all_user_playlists(sp, account) - - account_end = time.time() - account_debug['time_ms'] = int((account_end - account_start) * 1000) - - account_debug['total'] = len(account_playlists) - account_debug['fetched'] = len(account_playlists) - debug_info['total_fetched'] += len(account_playlists) - - # Apply keyword filtering if keywords provided - if filter_keywords: - filtered_playlists = filter_playlists_by_keywords( - account_playlists, - filter_keywords, - debug_info - ) - account_debug['filtered'] = len(filtered_playlists) - debug_info['filtered_out'] += (len(account_playlists) - len(filtered_playlists)) - all_playlists.extend(filtered_playlists) - else: - # No filtering, use all playlists - all_playlists.extend(account_playlists) - account_debug['filtered'] = len(account_playlists) - - debug_info['accounts'][account] = account_debug - # Remove duplicates based on playlist ID unique_playlists = [] seen_ids = set() @@ -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) @@ -508,12 +489,7 @@ def test_spotify_client(): results['spotipy']['playlists'] = spotipy_playlists results['spotipy']['count'] = len(spotipy_playlists) results['spotipy']['time_ms'] = duration_ms - - # Get total from first API call if available - if spotipy_playlists: - first_result = sp.user_playlists(account, limit=1) - results['spotipy']['total'] = first_result.get('total', 'unknown') - + except Exception as e: import traceback current_app.logger.error(f"Error testing spotipy: {e}") @@ -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}") diff --git a/musicround/routes/import_songs.py b/musicround/routes/import_songs.py index d774113..40e72ba 100644 --- a/musicround/routes/import_songs.py +++ b/musicround/routes/import_songs.py @@ -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 not current_user.spotify_token: + flash("Please connect your Spotify account to import songs.", "warning") + return redirect(url_for('users.spotify_auth')) + if request.method == 'POST': - track_id = request.form['song_id'] - result = ImportHelper.import_item('spotify', 'track', track_id) + 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) - if result['imported_count'] > 0: - flash(f'Successfully imported {result["imported_count"]} song!', 'success') - elif result['skipped_count'] > 0: + 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 not current_user.spotify_token: + flash("Please connect your Spotify account to import playlists.", "warning") + return redirect(url_for('users.spotify_auth')) if request.method == 'POST': - playlist_id = request.form['playlist_id'] - result = ImportHelper.import_item('spotify', 'playlist', playlist_id) + 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) - 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') + 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 not current_user.spotify_token: + flash("Please connect your Spotify account to import albums.", "warning") + return redirect(url_for('users.spotify_auth')) if request.method == 'POST': - album_id = request.form['album_id'] - result = ImportHelper.import_item('spotify', 'album', album_id) + 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) - 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') + 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')) diff --git a/musicround/routes/rounds.py b/musicround/routes/rounds.py index c025dd8..be0f414 100644 --- a/musicround/routes/rounds.py +++ b/musicround/routes/rounds.py @@ -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: diff --git a/musicround/routes/users.py b/musicround/routes/users.py index 3a48aa5..2e12578 100644 --- a/musicround/routes/users.py +++ b/musicround/routes/users.py @@ -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') + + # 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 + + 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}") - # Client credentials don't have user context - if token_source != 'client_credentials': - try: - # Use the token to get user info - spotify_user_info = sp.current_user() - - if spotify_user_info: + # 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') - - except Exception as user_info_error: - current_app.logger.error(f"Error fetching Spotify user info: {str(user_info_error)}") - - # For manual bearer tokens, try to determine expiry time - if token_source == '' or token_source not in ['user', 'client_credentials', 'system']: - # This is likely a manual bearer token - # Most bearer tokens are valid for 1 hour from issue - # We don't know when it was issued, but we can notify the user - # that these tokens typically expire after 1 hour - from datetime import timedelta - # Manual tokens stored in session likely were just added - token_added_time = session.get('bearer_token_added', now.timestamp()) - typical_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1) - active_token_expiry = typical_expiry - - # Mark it as a manual token for clarity - token_source = 'manual' - session['token_source'] = 'manual' - - except Exception as e: - current_app.logger.error(f"Error setting up Spotify client: {e}") - - # Determine Spotify connection status with corrected priority order - spotify_status = 'none' # Default: no connection - - # Check for manually set bearer token (highest priority) - has_manual_bearer = 'access_token' in session and token_source == 'manual' - if has_manual_bearer: - spotify_status = 'bearer' - - # Check user's own Spotify connection (second priority) - elif token_source == 'user' or (current_user.spotify_token and current_user.spotify_refresh_token): - if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now: - # User has valid token - spotify_status = 'user' - elif check_spotify_token(current_user): - # Token was refreshed successfully - spotify_status = 'user' - - # Check for client credentials token (third priority) - elif token_source == 'client_credentials': + 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"Exception fetching Spotify user info with manual bearer token: {str(user_info_error)}") + spotify_user_info = None + + # 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") + + except Exception as cc_error: + current_app.logger.error(f"Exception validating client credentials token from session: {str(cc_error)}") + + # 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' + 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') - - if action == 'disconnect': - # Disconnect Spotify account - current_user.spotify_token = None - current_user.spotify_refresh_token = None - current_user.spotify_token_expiry = None - current_user.oauth_id = None - - try: - db.session.commit() - flash('Your Spotify account has been disconnected', 'success') - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error disconnecting Spotify: {e}") - flash('An error occurred while disconnecting your Spotify account', 'danger') + # 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') + + # 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) + } - return render_template('users/spotify_link.html', now=now) + 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) -@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 - - 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')) + 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 + + _process_spotify_link(current_user, token, user_info) -@users_bp.route('/spotify-callback') -@login_required -def spotify_callback(): - """Handle Spotify OAuth callback""" - try: - # Verify state parameter - if request.args.get('state') != session.get('oauth_state'): - flash('Authentication state mismatch. Please try again.', 'danger') - return redirect(url_for('users.spotify_link')) - - # Get authorization code - code = request.args.get('code') - if not code: - flash('No authorization code received from Spotify.', 'danger') - return redirect(url_for('users.spotify_link')) - - # Exchange code for token - sp_oauth = SpotifyOAuth( - client_id=current_app.config['SPOTIFY_CLIENT_ID'], - client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'], - redirect_uri=url_for('users.spotify_callback', _external=True), - scope=current_app.config['SPOTIFY_SCOPE'] - ) - - token_info = sp_oauth.get_access_token(code) - - if not token_info or 'access_token' not in token_info: - flash('Failed to obtain access token from Spotify.', 'danger') - return redirect(url_for('users.spotify_link')) - - # Save token to user - current_user.spotify_token = token_info['access_token'] - current_user.spotify_refresh_token = token_info.get('refresh_token') - expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None - current_user.spotify_token_expiry = expiry - - # Get Spotify user ID - try: - sp = current_app.config['sp'] - sp.set_auth(token_info['access_token']) - user_info = sp.current_user() - current_user.oauth_id = user_info['id'] - except: - # Continue even if we can't get the Spotify ID - current_app.logger.warning("Could not fetch Spotify user ID") - - # Save to database - try: - db.session.commit() - flash('Successfully connected to Spotify!', 'success') - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error saving Spotify token: {e}") - flash('Error saving Spotify connection.', 'danger') - - return redirect(url_for('users.spotify_link')) - except Exception as e: - current_app.logger.error(f"Error during Spotify callback: {e}") - flash('Error during Spotify authentication. Please try again.', 'danger') - return redirect(url_for('users.spotify_link')) + 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() - - if user_info and 'id' in user_info: - username = user_info.get('display_name') or user_info.get('id') - flash(f'Successfully authenticated with Spotify as {username}', 'success') - - # Log who this token belongs to - current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})") - else: - flash('Token saved but validation failed. The token may be invalid or expired.', 'warning') - except Exception as e: - current_app.logger.error(f"Error validating bearer token: {e}") - flash(f'Token saved but error during validation: {str(e)}', 'warning') + 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: + # This is a user OAuth token + username = user_info.get('display_name') or user_info.get('id') + + # 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 diff --git a/musicround/templates/base.html b/musicround/templates/base.html index 7863fb6..33d5284 100644 --- a/musicround/templates/base.html +++ b/musicround/templates/base.html @@ -201,9 +201,23 @@ - - -
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+
+ {{ message }} + +
+
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %} {% endblock %}
diff --git a/musicround/templates/import_playlist.html b/musicround/templates/import_playlist.html index 53e81b5..5d7558d 100644 --- a/musicround/templates/import_playlist.html +++ b/musicround/templates/import_playlist.html @@ -7,19 +7,7 @@

Import Playlist

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

-
- -
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} - +
diff --git a/musicround/templates/round_detail.html b/musicround/templates/round_detail.html index c970b66..b6e994e 100644 --- a/musicround/templates/round_detail.html +++ b/musicround/templates/round_detail.html @@ -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 %} diff --git a/musicround/templates/spotify_client_test.html b/musicround/templates/spotify_client_test.html index 637e2ee..e69de29 100644 --- a/musicround/templates/spotify_client_test.html +++ b/musicround/templates/spotify_client_test.html @@ -1,172 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}Spotify Client Test{% endblock %} - -{% block content %} -
-

Spotify Client Comparison

-

Comparing spotipy library vs direct API implementation.

- - -
- -
- - -
- -
- -
- -
- - -
-

Results Summary

- -
- -
-

Spotipy Implementation

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

Direct API Implementation

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

Implementation Comparison

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

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

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

No playlists retrieved.

- {% endif %} -
-
- - -
-
-

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

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

No playlists retrieved.

- {% endif %} -
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/musicround/templates/users/audio_settings.html b/musicround/templates/users/audio_settings.html index 514a3b5..d148afe 100644 --- a/musicround/templates/users/audio_settings.html +++ b/musicround/templates/users/audio_settings.html @@ -9,12 +9,6 @@

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

- {% for category, message in get_flashed_messages(with_categories=true) %} -
- {{ message }} -
- {% endfor %} -
diff --git a/musicround/templates/users/change_password.html b/musicround/templates/users/change_password.html index 89fb500..eb81de5 100644 --- a/musicround/templates/users/change_password.html +++ b/musicround/templates/users/change_password.html @@ -6,16 +6,6 @@

Change Password

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} -
diff --git a/musicround/templates/users/edit_profile.html b/musicround/templates/users/edit_profile.html index 8a4015b..159b9a7 100644 --- a/musicround/templates/users/edit_profile.html +++ b/musicround/templates/users/edit_profile.html @@ -6,16 +6,6 @@

Edit Profile

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} - diff --git a/musicround/templates/users/forgot_password.html b/musicround/templates/users/forgot_password.html index 758aa57..0403c35 100644 --- a/musicround/templates/users/forgot_password.html +++ b/musicround/templates/users/forgot_password.html @@ -13,14 +13,7 @@ Enter your email address and we'll send a reset link

- -
- {% for category, message in get_flashed_messages(with_categories=true) %} -
- {{ message }} -
- {% endfor %} - +
diff --git a/musicround/templates/users/login.html b/musicround/templates/users/login.html index 5e6191f..43e6e1d 100644 --- a/musicround/templates/users/login.html +++ b/musicround/templates/users/login.html @@ -6,16 +6,6 @@

Login to Quizzical Beats

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} - diff --git a/musicround/templates/users/manage_spotify.html b/musicround/templates/users/manage_spotify.html new file mode 100644 index 0000000..bfe365f --- /dev/null +++ b/musicround/templates/users/manage_spotify.html @@ -0,0 +1,142 @@ +{% extends 'base.html' %} + +{% block title %}Connect Spotify - Quizzical Beats{% endblock %} + +{% block content %} +
+

Connect Spotify Account

+ +
+ +
+

Spotify Connection Status

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

Connected to Spotify

+

Your account is linked to Spotify

+ {% if spotify_user_info %} +

Spotify ID: {{ spotify_user_info.id }}

+

Display Name: {{ spotify_user_info.display_name }}

+ {% if spotify_user_info.email %} +

Email: {{ spotify_user_info.email }}

+ {% endif %} + {% if spotify_user_info.images and spotify_user_info.images[0] %} + Spotify Profile Image + {% endif %} + {% endif %} +
+
+ +
+

Token Information

+
+

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

+ {% if current_user.spotify_token_expiry %} +

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

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

Not Connected

+

Your account is not linked to Spotify

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

Why Connect Spotify?

+ +
+

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

+
+ +
+
+
+ +
+
+

Access Your Playlists

+

Import songs from your personal Spotify playlists

+
+
+ +
+
+ +
+
+

Search the Spotify Catalog

+

Find and import any track from Spotify's huge library

+
+
+ +
+
+ +
+
+

Trending Playlists

+

Access Spotify's official and trending playlists

+
+
+
+ +
+

Privacy Note

+

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

+
+
+
+ + +
+{% endblock %} diff --git a/musicround/templates/users/profile.html b/musicround/templates/users/profile.html index 30b010b..131e1a8 100644 --- a/musicround/templates/users/profile.html +++ b/musicround/templates/users/profile.html @@ -6,16 +6,6 @@

My Profile

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} -
diff --git a/musicround/templates/users/register.html b/musicround/templates/users/register.html index 18c9869..c28e408 100644 --- a/musicround/templates/users/register.html +++ b/musicround/templates/users/register.html @@ -6,16 +6,6 @@

Create an Account

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} -
diff --git a/musicround/templates/users/reset_password.html b/musicround/templates/users/reset_password.html index 5d94ce5..a9ad968 100644 --- a/musicround/templates/users/reset_password.html +++ b/musicround/templates/users/reset_password.html @@ -13,14 +13,7 @@ Please enter your new password below

- -
- {% for category, message in get_flashed_messages(with_categories=true) %} -
- {{ message }} -
- {% endfor %} - +
diff --git a/musicround/templates/users/spotify_link.html b/musicround/templates/users/spotify_link.html index 2701ee1..e69de29 100644 --- a/musicround/templates/users/spotify_link.html +++ b/musicround/templates/users/spotify_link.html @@ -1,143 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}Connect Spotify - Quizzical Beats{% endblock %} - -{% block content %} -
-

Connect Spotify Account

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

Spotify Connection Status

- - {% if current_user.spotify_token %} -
-
- -
-
-

Connected to Spotify

-

Your account is linked to Spotify

- {% if current_user.oauth_id %} -

Spotify ID: {{ current_user.oauth_id }}

- {% endif %} -
-
- -
-

Token Information

-
-

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

- {% if current_user.spotify_token_expiry %} -

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

- {% endif %} -
-
- - - - - - - - {% else %} -
-
- -
-
-

Not Connected

-

Your account is not linked to Spotify

-
-
- - - Connect with Spotify - - {% endif %} -
- - -
-

Why Connect Spotify?

- -
-

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

-
- -
-
-
- -
-
-

Access Your Playlists

-

Import songs from your personal Spotify playlists

-
-
- -
-
- -
-
-

Search the Spotify Catalog

-

Find and import any track from Spotify's huge library

-
-
- -
-
- -
-
-

Trending Playlists

-

Access Spotify's official and trending playlists

-
-
-
- -
-

Privacy Note

-

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

-
-
-
- - -
-{% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a5de272..8056f6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ Flask Flask-WTF Flask-SQLAlchemy flask_migrate -spotipy requests pydub reportlab diff --git a/run_migration.py b/run_migration.py index f73b7b7..781a8c3 100644 --- a/run_migration.py +++ b/run_migration.py @@ -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) \ No newline at end of file