commit c803de020e73f451a15989657b7da554b509808f Author: Percy Date: Tue Jan 13 22:26:48 2026 +0000 Initial commit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fd41909 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,393 @@ +name: Build Multi-Platform + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + GO_VERSION: '1.25.5' + NODE_VERSION: '24' + +jobs: + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from tag + id: version + shell: bash + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + else + VERSION="dev" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Install frontend dependencies + working-directory: frontend + run: | + pnpm install + pnpm run generate-icon + + - name: Install UPX + run: | + choco install upx -y + + - name: Build application + run: wails build -platform windows/amd64 + + - name: Compress with UPX + run: | + upx --best --lzma "build\bin\SpotiFLAC.exe" + + - name: Prepare artifacts + run: | + mkdir -p dist + Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-portable + path: dist/SpotiFLAC.exe + retention-days: 7 + + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + else + VERSION="dev" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Get pnpm store directory + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Install frontend dependencies + working-directory: frontend + run: | + pnpm install + pnpm run generate-icon + + - name: Build application + run: wails build -platform darwin/universal + + - name: Create DMG + run: | + mkdir -p dist + # Install create-dmg if not available + brew install create-dmg || true + + # Create DMG + create-dmg \ + --volname "SpotiFLAC" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "SpotiFLAC.app" 175 120 \ + --hide-extension "SpotiFLAC.app" \ + --app-drop-link 425 120 \ + "dist/SpotiFLAC.dmg" \ + "build/bin/SpotiFLAC.app" || \ + # Fallback to hdiutil if create-dmg fails + hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-portable + path: dist/SpotiFLAC.dmg + retention-days: 7 + + build-linux: + name: Build Linux + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + else + VERSION="dev" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Get pnpm store directory + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl + + # Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility) + sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Install frontend dependencies + working-directory: frontend + run: | + pnpm install + pnpm run generate-icon + + - name: Build application + run: wails build -platform linux/amd64 + + - name: Compress with UPX + run: | + upx --best --lzma build/bin/SpotiFLAC + + - name: Cache appimagetool + id: cache-appimagetool + uses: actions/cache@v4 + with: + path: appimagetool + key: appimagetool-x86_64-v1 + + - name: Download appimagetool + if: steps.cache-appimagetool.outputs.cache-hit != 'true' + run: | + wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \ + wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage + + - name: Make appimagetool executable + run: chmod +x appimagetool + + - name: Create AppImage + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + # Copy binary + cp build/bin/SpotiFLAC AppDir/usr/bin/ + + # Create desktop file + cat > AppDir/spotiflac.desktop << 'EOF' + [Desktop Entry] + Name=SpotiFLAC + Exec=SpotiFLAC + Icon=spotiflac + Type=Application + Categories=Audio;AudioVideo; + Comment=Get Spotify tracks in true FLAC from Tidal/Deezer + EOF + + cp AppDir/spotiflac.desktop AppDir/usr/share/applications/ + + # Create icon + if [ -f "build/appicon.png" ]; then + convert build/appicon.png -resize 256x256 AppDir/spotiflac.png + elif [ -f "frontend/public/icon.svg" ]; then + convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png + else + echo "Warning: No icon found, building without icon" + fi + + # Copy icon if exists + if [ -f "AppDir/spotiflac.png" ]; then + cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/ + cp AppDir/spotiflac.png AppDir/.DirIcon + fi + + # Create AppRun + cat > AppDir/AppRun << 'EOF' + #!/bin/sh + SELF=$(readlink -f "$0") + HERE=${SELF%/*} + export PATH="${HERE}/usr/bin/:${PATH}" + export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}" + exec "${HERE}/usr/bin/SpotiFLAC" "$@" + EOF + chmod +x AppDir/AppRun + + # Create AppImage + mkdir -p dist + ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-portable + path: dist/SpotiFLAC.AppImage + retention-days: 7 + + create-release: + name: Create Release + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure of downloaded files + run: ls -R artifacts + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + draft: true + prerelease: false + generate_release_notes: false + body: | + ## Changelog + + ## Downloads + + - `SpotiFLAC.exe` - Windows + - `SpotiFLAC.dmg` - macOS + - `SpotiFLAC.AppImage` - Linux + +
+ Linux Requirements + + The AppImage requires `webkit2gtk-4.1` to be installed on your system: + + **Ubuntu/Debian:** + ```bash + sudo apt install libwebkit2gtk-4.1-0 + ``` + + **Arch Linux:** + ```bash + sudo pacman -S webkit2gtk-4.1 + ``` + + **Fedora:** + ```bash + sudo dnf install webkit2gtk4.1 + ``` + + After installing the dependency, make the AppImage executable: + ```bash + chmod +x SpotiFLAC.AppImage + ./SpotiFLAC.AppImage + ``` + +
+ files: | + artifacts/windows-portable/*.exe + artifacts/macos-portable/*.dmg + artifacts/linux-portable/*.AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f67ffa --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Cache +cache/ +*.cache + +# OS +.DS_Store +Thumbs.db + +# Secrets (if any) +secrets.json +*.key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..203c90a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +# Install FFmpeg +RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy requirements and install +COPY app/requirements.txt ./app/ +RUN pip install --no-cache-dir -r app/requirements.txt + +# Copy application code +COPY . . + +# Expose port (Railway overrides this) +EXPOSE 8000 + +# Run the application - Use exec form with shell for variable expansion and signal handling +CMD ["sh", "-c", "exec python -m uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c7b986 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BioHapHazard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a424e6d --- /dev/null +++ b/README.md @@ -0,0 +1,401 @@ +# Freedify - Music Streaming Web App + +*Last updated: January 11, 2026* + +Stream music and podcasts from anywhere. **Generate smart playlists with AI**, search songs, albums, artists, podcasts or paste URLs from Spotify, SoundCloud, Bandcamp, Archive.org, Phish.in, and more. + +## ✨ Features + +### 🎧 HiFi & Hi-Res Streaming +- **Lossless FLAC** - Direct 16-bit FLAC streaming from Tidal (HiFi) +- **Hi-Res Audio** - **24-bit/96kHz** support powered by **Dab Music** (Qobuz Proxy) +- **Hi-Res Mode Toggle** - Click the HiFi button to switch between: + - **Hi-Res Mode** (Cyan) - Prioritizes 24-bit lossless when available + - **HiFi Mode** (Green) - Standard 16-bit lossless streaming +- **HI-RES Album Badge** - Cyan "HI-RES" sticker on album cards indicates 24-bit availability +- **Audio Quality Display** - Album modal shows actual bit depth (e.g., "24bit / 96kHz") +- **Direct Stream** - No more MP3 transcoding! Fast, pure lossless audio. +- **Fast Playback** - Audio starts in ~5 seconds (streams progressively, no transcode wait) +- **Format Indicator** - Badge next to artist shows FLAC (green/cyan), AAC (green), or MP3 (grey) +- **EQ Compatible** - Full equalizer support even with lossless streams +- **Seek Support** - Instant seeking/skipping even while streaming Hi-Res +- **Gapless Playback** - Seamless music transitions (default) with optional 1-second crossfade +- **Music Discovery** - Click Artist name to search or Album name to view full tracklist instantly + +### 🧠 AI & Smart Features - Needs Gemini API Key to work +- **Smart Playlist Generator** - Create custom playlists instantly by describing a vibe, genre, or activity. +- **AI Radio** - Infinite queue recommendations based on your seed track (prevents genre drift) +- **DJ Mode** - AI-powered mixing tips (transition technique, timing, key compatibility) - accuracy undetermined +- **Mix Analysis** - Learn how to mix compatible tracks by Key and BPM + +### 🔍 Search +- **Deezer-powered** - Search tracks, albums, or artists with no rate limits +- **YouTube Music** - Search YT Music catalog via **More → YT Music** +- **Jamendo Fallback** - 600K+ independent/Creative Commons tracks (auto-fallback if main sources miss) +- **Live Show Search** - Search "Phish 2025" or "Grateful Dead 1977" to find live shows +- **Setlist.fm** - Search concert setlists via **More → Setlists**, auto-matches to audio sources + - Added Setlist Detail Modal to preview shows before listening +- **Podcast Search** - Search and stream podcasts via PodcastIndex API +- **Episode Details** - Click any episode to see full title, description, and publish date +- **Concert Search** - Find upcoming shows via **More → Concert Search** (Ticketmaster + SeatGeek) +- **URL Import** - Paste links from Spotify, Bandcamp, Soundcloud, Archive.org, Phish.in + +### 🎵 Live Show Archives +- **Phish.in** - Search by year/month (e.g., Phish 2025 or Phish 2024/12) +- **Archive.org** - Grateful Dead, Billy Strings, Ween, King Gizzard +- **Direct URLs** - Paste any phish.in or archive.org show URL + +### 🧠 ListenBrainz Integration +- **Scrobbling** - Automatically tracks what you listen to (triggers after 50% duration or 4 minutes) +- **Recommendations** - "For You" section (via **More → For You**) offers personalized tracks based on your history +- **Stats Dashboard** - See your total scrobbles and top artists this week in the For You section +- **Easy Setup** - Configure via `LISTENBRAINZ_TOKEN` environment variable + +### 📝 Genius Lyrics +- **Lyrics Modal** - Press **L** or click 📝 in player controls to view lyrics +- **About Tab** - Song descriptions, release date, writers, and producers +- **Powered by Genius** - Searches and scrapes lyrics from Genius.com +- **Fullscreen Access** - Lyrics button available in fullscreen mode too + +### 🎛️ Player Controls +- **Volume Control** - Slider + mute button (volume remembered between sessions) +- **Repeat Modes** - Off / Repeat All / Repeat One +- **Shuffle** - Shuffle playlist or current queue +- **Fullscreen Mode** - Click album art to expand +- **Mini Player** - Pop-out window for always-on-top playback control +- **Album Art Colors** - Player background tints to match the current album art + +### 🖼️ Pop-out Mini Player +- **Always-on-Top** - Built with the latest Document Picture-in-Picture API to stay visible over other windows +- **Scrolling Marquee** - Animated artist and track names for long titles +- **Full Control** - Play, pause, skip, and volume adjustment directly from the mini window +- **Retro Aesthetic** - Winamp-inspired classic display for a nostalgic feel +- **Automatic Sync** - Seamlessly stays in sync with the main player state + +### 🎬 Music Videos +- **Quick Access** - Press **V** or click 🎬 in fullscreen to find official music video +- **YouTube Search** - Opens YouTube with optimized search for official video + + +### 🌈 Audio Visualizer +- **Fullscreen Overlay** - Click 🌈 in "More" menu or `Alt+V` +- **MilkDrop Integration** - Powered by Butterchurn with hundreds of psychedelic presets +- **Next Preset** - Cycle through visuals with button or `N` key +- **Basic Modes** - Bars, Wave, Circular, and Particles +- **Audio-Reactive** - Responds to frequency data in real-time + +### 💾 Download & Save +- **Save to Drive** - Direct save to Google Drive (FLAC/AIFF/MP3) +- **Single Tracks** - Download locally as Artist - Song.ext +- **Full Albums/Playlists** - Batch download as Artist - Album.zip +- **Multiple Formats** - FLAC (Hi-Res), WAV (16/24-bit), AIFF (16/24-bit), ALAC, 320kbps MP3 +- **Current Track** - Press ⬇ on player bar or fullscreen to download now playing +- **MusicBrainz Metadata** - Downloads enriched with release year, label, and high-res cover art + +### 📋 Queue Management +- **Drag to Reorder** - Drag tracks to rearrange +- **Add All / Shuffle All** - From any album or playlist +- **Smart Preloading** - Next track buffers automatically for gapless play +- **Auto-Queue** - Click any track in an album/playlist to queue and play all following tracks automatically +- **Queue Persistence** - Queue survives page refresh (saved to localStorage) +- **Volume Memory** - Volume level remembered between sessions + +### Playlists +- **Add to Playlist** - Click the heart icon on any track to add it to a playlist +- **Create Playlists** - Create new playlists on the fly from the Add to Playlist modal +- **Playlists Tab** - Click **More → Playlists** to view all saved playlists +- **Delete Songs** - Remove individual songs from any playlist +- **Google Drive Sync** - Playlists sync to Google Drive for access across all your devices +- **Local Backup** - Also stored in browser localStorage (survives restarts) +- **Delete Playlists** - Hover over playlist and click 🗑️ to remove + +### 🎛️ Equalizer +- **5-Band EQ** - Adjust 60Hz, 230Hz, 910Hz, 3.6kHz, 7.5kHz +- **Bass Boost** - Extra low-end punch +- **Volume Boost** - Up to +6dB gain +- **Presets** - Flat, Bass Boost, Treble, Vocal + +### 🎨 Custom Themes +- **6 Color Themes** - Default, Purple, Blue, Green, Pink, Orange +- **Persistent** - Theme saved to localStorage + +### ☁️ Google Drive Sync +- **Sync Modal** - Click ☁️ or press `Shift+S` to open the Drive Sync panel +- **Granular Control** - Choose to sync: + - **Everything** (Playlists + Queue) + - **Playlists Only** (keeps cloud queue unchanged) + - **Queue Only** (keeps cloud playlists unchanged) +- **Cross-Device Resume** - Start listening on one device, continue on another +- **Smart Merge** - Partial uploads preserve existing cloud data +- **Save Tracks** - Save audio directly to your "Freedify" folder +- **Privacy** - Uses Drive appDataFolder (hidden from Drive UI) + +--- + +## 🔑 Google Cloud Setup (Required for Drive Sync & AI) + +To enable **Google Drive Sync** and **AI features (Smart Playlist, AI Radio, DJ Mode)**, you need to set up a Google Cloud Project. + +### Step 1: Create a Google Cloud Project + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Click the project dropdown (top-left) → **New Project** +3. Name it (e.g., "Freedify") → **Create** +4. Select your new project from the dropdown + +### Step 2: Enable Required APIs + +1. Go to **APIs & Services → Library** +2. Search for and **Enable** each of these: + - **Google Drive API** (for cloud sync) + - **Generative Language API** (for Gemini AI features) + +### Step 3: Create OAuth 2.0 Credentials (for Drive Sign-In) + +1. Go to **APIs & Services → Credentials** +2. Click **+ CREATE CREDENTIALS → OAuth client ID** +3. If prompted, configure the **OAuth consent screen**: + - Choose **External** (unless you're a Google Workspace user) + - Fill in App name, support email + - Add **Scopes**: `../auth/drive.appdata`, `../auth/drive.file` + - Add your email as a **Test User** (required during testing) + - Save and continue +4. Back in Credentials, create an **OAuth client ID**: + - Application type: **Web application** + - Name: "Freedify Web" + - **Authorized JavaScript origins**: Add your domains, e.g.: + - `http://localhost:8000` (for local dev) + - `https://your-app.up.railway.app` (for production) + - **Authorized redirect URIs**: (optional, not needed for implicit flow) + - Click **Create** +5. Copy your **Client ID** (looks like `123456789-abc.apps.googleusercontent.com`) +6. Set it as `GOOGLE_CLIENT_ID` environment variable + +### Step 4: Create a Gemini API Key (for AI Features) + +1. In Google Cloud Console, go to **APIs & Services → Credentials** +2. Click **+ CREATE CREDENTIALS → API key** +3. Copy the generated API key +4. (Optional) Click **Edit API key** to restrict it to "Generative Language API" only +5. Set it as `GEMINI_API_KEY` environment variable + +### Environment Variables Summary + +| Variable | Purpose | +|----------|---------| +| `GOOGLE_CLIENT_ID` | OAuth2 Client ID for Google Sign-In (Drive Sync) | +| `GEMINI_API_KEY` | API Key for Gemini AI (Smart Playlist, AI Radio, DJ Mode) | + +> **Note:** For local development on `localhost`, you may see a "This app isn't verified" warning during sign-in. Click **Advanced → Go to Freedify (unsafe)** to proceed. For production, submit your app for verification in the OAuth consent screen settings. + +### 📱 Mobile Ready +- **PWA Support** - Install on your phone's home screen +- **Responsive Design** - Works on any screen size +- **320kbps MP3** - High quality streaming +- **Lock Screen Controls** - Play/pause/skip from lock screen + +--- + +### ⌨️ Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| Space | Play/Pause | +| ← / → | Previous/Next track | +| Shift+← / Shift+→ | Seek -/+ 10 seconds | +| ↑ / ↓ | Volume up/down | +| M | Mute/Unmute | +| S | Shuffle queue | +| R | Cycle repeat mode | +| F | Toggle fullscreen | +| Q | Toggle queue | +| E | Toggle EQ | +| P | Add to Playlist (Global) / Prev Preset (Visualizer) | +| H | Toggle HiFi/Hi-Res | +| D | Download current track | +| A | Toggle AI Radio | +| L | Open Lyrics | +| V | Find Music Video | +| Shift+V | Toggle Visualizer | +| N | Next Preset (Visualizer) | +| ESC | Exit Visualizer | +| Shift+S | Sync to Drive | +| ? | Show shortcuts help | + +--- + +## 🚀 Quick Start + +```bash +# Install dependencies +pip install -r app/requirements.txt + +# Install FFmpeg (required) +# Windows: winget install ffmpeg +# macOS: brew install ffmpeg +# Linux: apt install ffmpeg + +# Run the server +python -m uvicorn app.main:app --port 8000 +``` + +Open http://localhost:8000 + +--- + +## 🌐 Deploy to Railway (Recommended for Hi-Res) + +**Railway is recommended** for full Hi-Res (24-bit) support. Render blocks Dab Music API requests. + +1. Go to [railway.app](https://railway.app) → New Project +2. Deploy from GitHub repo +3. Add environment variables (see below) +4. Go to Settings → Networking → Generate Domain +5. Your app will be live at `your-app.up.railway.app` + +> **Pricing:** Railway offers a 30-day trial with $5 credit. After that, the Hobby plan is **$5/month**. If you want free hosting (with 16-bit FLAC only), use Render instead. + +--- + +## 🌐 Deploy to Render (16-bit only) + +Render works but **Hi-Res (24-bit) streaming is not available** due to IP restrictions on Dab Music API. You'll still get 16-bit FLAC from Tidal. + +1. Fork/push this repo to GitHub +2. Go to render.com → New Web Service +3. Connect your GitHub repo +4. Render auto-detects render.yaml +5. Click Deploy + +--- + +## ⚙️ Environment Variables (Deployment Secrets) + +When deploying to Render (or other hosts), set these in your Dashboard: + +| Variable | Required? | Description | +|----------|-----------|-------------| +| `GEMINI_API_KEY` | **YES** | Required for AI Radio and DJ Tips | +| `DAB_SESSION` | **YES** (for Hi-Res) | Dab Music session token for 24-bit streaming | +| `DAB_VISITOR_ID` | **YES** (for Hi-Res) | Dab Music visitor ID | +| `MP3_BITRATE` | No | Default: 320k | +| `PORT` | No | Default: 8000 | + +### Optional Spotify Credentials (for high traffic) + +If you hit rate limits, you can add your own keys: + +| Variable | Description | +|----------|-------------| +| `SPOTIFY_CLIENT_ID` | Your App Client ID | +| `SPOTIFY_CLIENT_SECRET` | Your App Client Secret | +| `SPOTIFY_SP_DC` | Cookie for authenticated web player access | +| `PODCASTINDEX_KEY` | For Podcast Search (better results) | +| `PODCASTINDEX_SECRET` | For Podcast Search (required if KEY is used) | +| `SETLIST_FM_API_KEY` | For Setlist.fm concert search (free at setlist.fm/settings/api) | +| `LISTENBRAINZ_TOKEN` | For Scrobbling & Recommendations (get at listenbrainz.org/settings) | +| `GOOGLE_CLIENT_ID` | For Google Drive sync (get at console.cloud.google.com) | +| `JAMENDO_CLIENT_ID` | For Jamendo indie music fallback (get at developer.jamendo.com) | +| `GENIUS_ACCESS_TOKEN` | For Genius lyrics (get at genius.com/api-clients) | +| `TICKETMASTER_API_KEY` | For Concert Search (free at developer.ticketmaster.com) | +| `SEATGEEK_CLIENT_ID` | For Concert Search fallback (free at seatgeek.com/account/develop) | +| `DAB_SESSION` | **Recommended** - For Hi-Res (24-bit) Audio (from Dab/Qobuz) | +| `DAB_VISITOR_ID` | **Recommended** - For Hi-Res (24-bit) Audio (from Dab/Qobuz) | + +--- + +## Live Show Search Examples: + +- `Phish 2025` - All 2025 Phish shows +- `Phish 2024/12` - December 2024 shows +- `Grateful Dead 1977` - 1977 Dead from Archive.org +- `KGLW 2025` - 2025 King Gizzard & the Wizard Lizard shows + +--- + +## Setlist.fm Search Examples: + +Select **More → Setlists** and search using these formats: + +- `Phish 31-12-2025` - Specific date (DD-MM-YYYY format) +- `Phish 2025-12-31` - Specific date (YYYY-MM-DD format) +- `Phish December 31 2025` - Natural language date +- `Pearl Jam 2024` - All shows from a year + +Click a result to see the full setlist with song annotations, then click "Listen on Phish.in" or "Search on Archive.org" to play the show. + +--- + +## Supported URL Sources: + +- Spotify (playlists, albums, tracks) +- Bandcamp +- Soundcloud +- YouTube +- Archive.org +- Phish.in +- And 1000+ more via yt-dlp + +--- + +## 📸 Screenshots + +

+ Album Search +
Search albums with Hi-Res badges — stream in 24-bit lossless quality from Qobuz +

+ +

+ Album Details +
Album view with format info, track listing, and one-click download as ZIP +

+ +

+ Fullscreen Player +
Immersive fullscreen mode with album art, playback controls, and visualizer toggle +

+ +

+ Download Formats +
Smart format selection — options adapt based on source quality (lossy, 16-bit, or 24-bit Hi-Res) +

+ +

+ Equalizer +
5-band EQ with presets (Flat, Bass Boost, Treble, Vocal) plus bass and volume boost +

+ +

+ Genius Lyrics +
Full lyrics with verse/chorus sections synced from Genius +

+ +

+ Genius Annotations +
Genius annotations explaining song meanings and references +

+ +

+ Podcast Episode +
Podcast support with episode details, show notes, and streaming playback +

+ +

+ MilkDrop Visualizer +
MilkDrop visualizer powered by Butterchurn — hundreds of audio-reactive presets +

+ +

+ MilkDrop Visualizer 2 +
Switch between MilkDrop, Bars, Wave, and Particles modes with keyboard shortcuts +

+ +--- + +## Credits +Inspired by and built off of [Spotiflac](https://github.com/afkarxyz/Spotiflac) by afkarxyz. +**Hi-Res Audio Source** provided by [Dab Music](https://dabmusic.xyz). + +--- + +Made with 💖 by a music lover, for music lovers. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..edabda9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# App package diff --git a/app/ai_radio_service.py b/app/ai_radio_service.py new file mode 100644 index 0000000..a125848 --- /dev/null +++ b/app/ai_radio_service.py @@ -0,0 +1,273 @@ +""" +AI Radio Service for Freedify. +Generates continuous playlist recommendations based on a seed track or mood. +""" +import os +import logging +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class AIRadioService: + """AI-powered radio that generates track recommendations.""" + + def __init__(self): + # Helper for key fallback logic if needed, but primary is env var + self.api_key = os.environ.get("GEMINI_API_KEY") + self._genai = None + self._model = None + + def _init_genai(self): + """Lazy initialization of Gemini client.""" + if self._genai is None: + try: + import google.generativeai as genai + if not self.api_key: + logger.warning("GEMINI_API_KEY not set - AI Radio will use basic mode") + return False + genai.configure(api_key=self.api_key) + self._genai = genai + self._model = genai.GenerativeModel('gemini-2.0-flash') + logger.info("AI Radio: Gemini initialized") + return True + except ImportError: + logger.warning("google-generativeai not installed") + return False + except Exception as e: + logger.error(f"Failed to initialize Gemini for AI Radio: {e}") + return False + return True + + async def generate_recommendations( + self, + seed_track: Optional[Dict[str, Any]] = None, + mood: Optional[str] = None, + current_queue: List[Dict[str, Any]] = None, + count: int = 5 + ) -> Dict[str, Any]: + """ + Generate track recommendations for AI Radio. + + Args: + seed_track: A track to base recommendations on (name, artist, bpm, key) + mood: A mood/vibe description if no seed track + current_queue: Current queue to avoid duplicates + count: Number of recommendations to generate + + Returns: + Dict with search_terms to find recommended tracks + """ + current_queue = current_queue or [] + + # Build context + if seed_track: + context = f"""Based on this seed track: +Title: "{seed_track.get('name', 'Unknown')}" +Artist: {seed_track.get('artists', 'Unknown')} +BPM: {seed_track.get('bpm', 'Unknown')} +Key: {seed_track.get('camelot', 'Unknown')}""" + elif mood: + context = f'Based on this mood/vibe: "{mood}"' + else: + context = "Generate a diverse mix of popular tracks" + + # Exclude current queue tracks + exclude_list = [] + for t in current_queue[:10]: # Limit to last 10 + exclude_list.append(f"- {t.get('name', '')} by {t.get('artists', '')}") + + exclude_str = "\n".join(exclude_list) if exclude_list else "None" + + # Try AI generation + if self._init_genai() and self._model: + try: + return await self._ai_generate_recommendations( + context, exclude_str, count, seed_track + ) + except Exception as e: + logger.error(f"AI recommendation failed: {e}") + + # Fallback: return genre-based search terms + return self._fallback_recommendations(seed_track, mood, count) + + async def _ai_generate_recommendations( + self, + context: str, + exclude_str: str, + count: int, + seed_track: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Generate recommendations using Gemini AI.""" + import json + + prompt = f"""{context} + +TASK: Recommend {count} songs that would flow well in a DJ mix or playlist. + +RULES: +1. Match the energy, tempo, and vibe of the seed track or mood +2. Consider harmonic compatibility (Camelot wheel) +3. Mix well-known tracks with hidden gems +4. Vary artists but keep genre/style consistent + +EXCLUDE these tracks already in queue: +{exclude_str} + +Respond ONLY with valid JSON: +{{ + "recommendations": [ + {{"artist": "Artist Name", "title": "Song Title", "reason": "Why it fits"}}, + ... + ], + "suggested_searches": ["search term 1", "search term 2", ...], + "vibe_description": "Brief description of the vibe" +}}""" + + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + + # Build search terms from recommendations + search_terms = [] + for rec in data.get("recommendations", [])[:count]: + artist = rec.get("artist", "") + title = rec.get("title", "") + if artist and title: + search_terms.append(f"{artist} {title}") + + # Add suggested searches as fallback + search_terms.extend(data.get("suggested_searches", [])[:3]) + + logger.info(f"AI Radio generated {len(search_terms)} recommendations") + + return { + "search_terms": search_terms, + "recommendations": data.get("recommendations", []), + "vibe_description": data.get("vibe_description", ""), + "method": "ai" + } + + def _fallback_recommendations( + self, + seed_track: Optional[Dict[str, Any]], + mood: Optional[str], + count: int + ) -> Dict[str, Any]: + """Fallback when AI is unavailable.""" + search_terms = [] + + if seed_track: + # Search for similar based on artist + artist = seed_track.get("artists", "").split(",")[0].strip() + if artist: + search_terms.append(f"{artist}") + search_terms.append(f"{artist} remix") + + if mood: + search_terms.append(mood) + + # Generic fallback + if not search_terms: + search_terms = ["popular electronic", "chill beats", "dance hits"] + + return { + "search_terms": search_terms[:count], + "recommendations": [], + "vibe_description": "Based on your selection", + "method": "fallback" + } + + + + async def generate_playlist( + self, + description: str, + duration_mins: int = 60, + track_count: int = 15 + ) -> Dict[str, Any]: + """ + Generate a playlist from a natural language description. + + Args: + description: Playlist description like "morning coffee jazz" or "high energy workout" + duration_mins: Target duration in minutes + track_count: Number of tracks to generate + + Returns: + Dict with tracks (artist + title pairs), playlist name, description + """ + if not self._init_genai() or not self._model: + return { + "tracks": [], + "playlist_name": "Generated Playlist", + "description": description, + "method": "fallback", + "error": "AI not available" + } + + try: + import json + + # Estimate tracks based on duration (avg 3.5 min per track) + estimated_tracks = min(max(duration_mins // 4, 5), track_count) + + prompt = f"""You are a music curator. Create a playlist based on this description. + +DESCRIPTION: "{description}" +TARGET DURATION: ~{duration_mins} minutes ({estimated_tracks} tracks) + +TASK: Generate a cohesive playlist that matches the vibe and purpose. + +RULES: +1. Mix popular tracks with quality deep cuts +2. Consider flow and energy progression +3. Vary artists while maintaining style consistency +4. Include specific, real songs (not made-up titles) + +Respond ONLY with valid JSON: +{{ + "playlist_name": "Creative name for this playlist", + "description": "Brief description of the vibe", + "tracks": [ + {{"artist": "Artist Name", "title": "Song Title"}}, + ... + ] +}}""" + + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + data["method"] = "ai" + data["requested_duration"] = duration_mins + + logger.info(f"Generated playlist '{data.get('playlist_name')}' with {len(data.get('tracks', []))} tracks") + return data + + except Exception as e: + logger.error(f"Playlist generation error: {e}") + return { + "tracks": [], + "playlist_name": "Generated Playlist", + "description": description, + "method": "fallback", + "error": str(e) + } + + +# Singleton instance +ai_radio_service = AIRadioService() diff --git a/app/audio_service.py b/app/audio_service.py new file mode 100644 index 0000000..e296461 --- /dev/null +++ b/app/audio_service.py @@ -0,0 +1,1036 @@ +""" +Audio service for fetching and transcoding music. +Fetches FLAC from Tidal/Deezer and transcodes to MP3 using FFmpeg. +Uses multiple API endpoints with fallback for reliability. +""" +import os +import subprocess +import asyncio +import httpx +import base64 +from typing import Optional, Dict, Any, List, Union +import logging +import json +import tempfile +from mutagen.flac import FLAC, Picture +from mutagen.id3 import ID3, TIT2, TPE1, TALB, TRCK, TDRC, TCON, APIC, COMM +from mutagen.mp3 import MP3, EasyMP3 +from mutagen.mp4 import MP4, MP4Cover + +import re +from app.cache import is_cached, get_cached_file, cache_file, get_cache_path + +logger = logging.getLogger(__name__) + +# Configuration +BITRATE = os.environ.get("MP3_BITRATE", "320k") +DEEZER_API_URL = os.environ.get("DEEZER_API_URL", "https://api.deezmate.com") + +# FFmpeg path - check common locations on Windows +FFMPEG_PATH = os.environ.get("FFMPEG_PATH", "ffmpeg") +if os.name == 'nt' and FFMPEG_PATH == "ffmpeg": + # Try common Windows locations + winget_path = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages") + if os.path.exists(winget_path): + for root, dirs, files in os.walk(winget_path): + if "ffmpeg.exe" in files: + FFMPEG_PATH = os.path.join(root, "ffmpeg.exe") + break + +# List of Tidal API endpoints with fallback (fastest/most reliable first) +TIDAL_APIS = [ + "https://triton.squid.wtf", # From Monochrome - fast primary + "https://hifi.401658.xyz", + "https://tidal.kinoplus.online", + "https://tidal-api.binimum.org", + "https://wolf.qqdl.site", + "https://maus.qqdl.site", + "https://vogel.qqdl.site", + "https://katze.qqdl.site", + "https://hund.qqdl.site", +] + + + +class AudioService: + """Service for fetching and transcoding audio.""" + + # Tidal credentials (same as SpotiFLAC) + TIDAL_CLIENT_ID = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode() + TIDAL_CLIENT_SECRET = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode() + + async def import_url(self, url: str) -> Optional[Dict[str, Any]]: + """Import track or playlist from URL using yt-dlp.""" + try: + # Phish.in Custom Handler (Fast Path) + if "phish.in" in url: + logger.info("Detected Phish.in URL, using custom API handler") + phish_data = await self._import_phish_in(url) + if phish_data: return phish_data + + loop = asyncio.get_event_loop() + info = await loop.run_in_executor(None, lambda: self._extract_info_safe(url)) + + if not info: + return None + + # Check if it's a playlist/album + if 'entries' in info and info['entries']: + logger.info(f"Detected playlist: {info.get('title')}") + tracks = [] + for entry in info['entries']: + if not entry: continue + + # Determine playback URL (webpage_url for recalculation, or direct url) + # For stability, we prefer the webpage_url if it's a separate page, + # OR we use the original URL with an index? + # Ideally, entry has 'webpage_url' or 'url'. + # For yt-dlp, 'url' might be the stream url (which expires). 'webpage_url' is persistent. + play_url = entry.get('webpage_url') or entry.get('url') + if not play_url: continue + + safe_t_id = f"LINK:{base64.urlsafe_b64encode(play_url.encode()).decode()}" + duration_s = entry.get('duration', 0) + + tracks.append({ + 'id': safe_t_id, + 'name': entry.get('title', 'Unknown Title'), + 'artists': entry.get('uploader', entry.get('artist', 'Unknown Artist')), + 'album_art': entry.get('thumbnail', info.get('thumbnail', '/static/icon.svg')), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'album': info.get('title', 'Imported Playlist'), + 'isrc': safe_t_id # Use ID as ISRC for internal logic + }) + + if not tracks: return None + + return { + 'type': 'album', + 'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}", + 'name': info.get('title', 'Imported Playlist'), + 'artists': info.get('uploader', 'Various'), + 'image': info.get('thumbnail', '/static/icon.svg'), # Use album art + 'release_date': info.get('upload_date', ''), + 'tracks': tracks, + 'total_tracks': len(tracks), + 'is_custom': True + } + + # Single Track Logic + safe_id = f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}" + duration_s = info.get('duration', 0) + + track = { + 'id': safe_id, + 'name': info.get('title', 'Unknown Title'), + 'artists': info.get('uploader', info.get('artist', 'Unknown Artist')), + 'album_art': info.get('thumbnail', '/static/icon.svg'), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'album': info.get('extractor_key', 'Imported'), + 'isrc': safe_id + } + return track + except Exception as e: + logger.error(f"Import error: {e}") + return None + + def _extract_info_safe(self, url): + try: + import yt_dlp + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'format': 'bestaudio/best', + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + return ydl.extract_info(url, download=False) + except Exception as e: + logger.error(f"yt-dlp error: {e}") + return None + + async def _import_phish_in(self, url: str) -> Optional[Dict[str, Any]]: + """Import show from Phish.in API.""" + try: + # Extract date YYYY-MM-DD + match = re.search(r'(\d{4}-\d{2}-\d{2})', url) + if not match: + logger.warning("Could not extract date from Phish.in URL") + return None + + date = match.group(1) + api_url = f"https://phish.in/api/v2/shows/{date}" + + logger.info(f"Fetching Phish.in API: {api_url}") + async with httpx.AsyncClient() as client: + response = await client.get(api_url, timeout=15.0) + if response.status_code != 200: + return None + + data = response.json() + + tracks_list = [] + show_meta = {} + + # Handle v2 (List of tracks? or Object with tracks?) + # Swagger says implementation differs. Based on curl, likely a List. + if isinstance(data, list): + tracks_list = data + if tracks_list: + show_meta = tracks_list[0] + elif isinstance(data, dict): + if 'data' in data: data = data['data'] + if 'tracks' in data: + tracks_list = data['tracks'] + show_meta = data + else: + # Maybe data IS the track list? + pass + + if not tracks_list: return None + + tracks = [] + # extracting metadata + venue = show_meta.get('venue_name', show_meta.get('venue', {}).get('name', 'Unknown Venue')) + show_date = show_meta.get('show_date', show_meta.get('date', date)) + + album_name = f"{show_date} - {venue}" + + for t in tracks_list: + # mp3 url is usually http, ensure https if possible or leave as is + mp3_url = t.get('mp3_url') or t.get('mp3') + if not mp3_url: continue + + safe_id = f"LINK:{base64.urlsafe_b64encode(mp3_url.encode()).decode()}" + duration_s = t.get('duration', 0) / 1000.0 if t.get('duration', 0) > 10000 else t.get('duration', 0) + # v2 duration seems to be ms? curl say 666600 (666s = 11m). So ms. + + tracks.append({ + 'id': safe_id, + 'name': t.get('title', 'Unknown'), + 'artists': 'Phish', + 'album': album_name, + 'album_art': t.get('show_album_cover_url', '/static/icon.svg'), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'isrc': safe_id + }) + + if not tracks: return None + + return { + 'type': 'album', + 'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}", + 'name': album_name, + 'artists': 'Phish', + 'image': tracks[0]['album_art'], + 'release_date': show_date, + 'tracks': tracks, + 'total_tracks': len(tracks), + 'is_custom': True + } + except Exception as e: + logger.error(f"Phish.in import error: {e}") + return None + + def _get_stream_url(self, url: str) -> Optional[str]: + """Get the actual stream URL from a page URL using yt-dlp. + For direct audio files (.mp3, .m4a, etc.), return as-is. + """ + # Check if URL is already a direct audio file + from urllib.parse import urlparse + parsed = urlparse(url) + path_lower = parsed.path.lower() + audio_extensions = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.flac', '.opus') + if any(path_lower.endswith(ext) for ext in audio_extensions): + logger.info(f"Direct audio URL detected, bypassing yt-dlp: {url[:60]}...") + return url + + + # Check cache + import time + now = time.time() + if url in self._stream_url_cache: + cached_url, expiry = self._stream_url_cache[url] + if now < expiry: + logger.info("Stream URL cache hit") + return cached_url + else: + del self._stream_url_cache[url] + + # Use yt-dlp for page URLs (YouTube, Bandcamp, etc.) + info = self._extract_info_safe(url) + if not info: return None + if 'entries' in info: info = info['entries'][0] + + stream_url = info.get('url') + if stream_url: + # Cache for 1 hour (Google URLs usually expire in ~4-6 hours) + self._stream_url_cache[url] = (stream_url, now + 3600) + + return stream_url + + + + + + # Simple in-memory cache for resolved stream URLs (to speed up seeking) + _stream_url_cache = {} # {url: (stream_url, expire_time)} + + def __init__(self): + # Enable redirect following and increase timeout + # Using a shared client with a connection pool to avoid socket exhaustion + limits = httpx.Limits(max_keepalive_connections=50, max_connections=100) + self.client = httpx.AsyncClient(timeout=60.0, follow_redirects=True, limits=limits) + + self.tidal_token: Optional[str] = None + self.working_api: Optional[str] = None # Cache the last working API + + async def get_tidal_token(self) -> str: + """Get Tidal access token.""" + if self.tidal_token: + return self.tidal_token + + response = await self.client.post( + "https://auth.tidal.com/v1/oauth2/token", + data={ + "client_id": self.TIDAL_CLIENT_ID, + "grant_type": "client_credentials" + }, + auth=(self.TIDAL_CLIENT_ID, self.TIDAL_CLIENT_SECRET) + ) + response.raise_for_status() + self.tidal_token = response.json()["access_token"] + return self.tidal_token + + async def search_tidal_by_isrc(self, isrc: str, query: str = "") -> Optional[Dict[str, Any]]: + """Search Tidal for a track by ISRC.""" + try: + token = await self.get_tidal_token() + search_query = query or isrc + + response = await self.client.get( + "https://api.tidal.com/v1/search/tracks", + headers={"Authorization": f"Bearer {token}"}, + params={ + "query": search_query, + "limit": 25, + "offset": 0, + "countryCode": "US" + } + ) + response.raise_for_status() + + data = response.json() + items = data.get("items", []) + + # Find by ISRC match + for item in items: + if item.get("isrc") == isrc: + return item + + # Fall back to first result + return items[0] if items else None + + except Exception as e: + logger.error(f"Tidal search error: {e}") + return None + + async def get_tidal_download_url_from_api(self, api_url: str, track_id: int, quality: str = "LOSSLESS") -> Optional[str]: + """Get download URL from a specific Tidal API.""" + import base64 + import json as json_module + + try: + full_url = f"{api_url}/track/?id={track_id}&quality={quality}" + logger.info(f"Trying API: {api_url}") + + response = await self.client.get(full_url, timeout=30.0) + + if response.status_code != 200: + logger.warning(f"API {api_url} returned {response.status_code}") + return None + + # Check if we got HTML instead of JSON + content_type = response.headers.get("content-type", "") + if "html" in content_type.lower(): + logger.warning(f"API {api_url} returned HTML instead of JSON") + return None + + try: + data = response.json() + except Exception: + logger.warning(f"API {api_url} returned invalid JSON") + return None + + # Handle API v2.0 format with manifest + if isinstance(data, dict) and "version" in data and "data" in data: + inner_data = data.get("data", {}) + manifest_b64 = inner_data.get("manifest") + + if manifest_b64: + try: + manifest_json = base64.b64decode(manifest_b64).decode('utf-8') + manifest = json_module.loads(manifest_json) + urls = manifest.get("urls", []) + + if urls: + download_url = urls[0] + logger.info(f"Got download URL from {api_url} (v2.0 manifest)") + self.working_api = api_url + return download_url + except Exception as e: + logger.warning(f"Failed to decode manifest from {api_url}: {e}") + + # Handle legacy format (list with OriginalTrackUrl) + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and "OriginalTrackUrl" in item: + logger.info(f"Got download URL from {api_url} (legacy format)") + self.working_api = api_url + return item["OriginalTrackUrl"] + + # Handle other dict formats + elif isinstance(data, dict): + if "OriginalTrackUrl" in data: + self.working_api = api_url + return data["OriginalTrackUrl"] + if "url" in data: + self.working_api = api_url + return data["url"] + + logger.warning(f"API {api_url} returned unexpected format") + return None + + except httpx.TimeoutException: + logger.warning(f"API {api_url} timed out") + return None + except Exception as e: + logger.warning(f"API {api_url} error: {e}") + return None + + async def update_tidal_apis(self): + """Update available Tidal APIs from status server.""" + try: + # Only update once per session to avoid delay + if hasattr(self, '_apis_updated') and self._apis_updated: + return + + logger.info("Updating Tidal API list...") + async with httpx.AsyncClient(timeout=5.0) as client: + async with client.stream("GET", "https://status.monochrome.tf/api/stream") as response: + async for line in response.aiter_lines(): + if line.startswith("data: "): + data = json.loads(line[6:]) + + api_instances = [ + inst for inst in data.get('instances', []) + if inst.get('instance_type') == 'api' and inst.get('last_check', {}).get('success') + ] + + # Sort by avg_response_time + api_instances.sort(key=lambda x: x.get('avg_response_time', 9999)) + + new_apis = [api['url'] for api in api_instances if api.get('url')] + + if new_apis: + global TIDAL_APIS + TIDAL_APIS = new_apis + self._apis_updated = True + logger.info(f"Updated Tidal API list with {len(new_apis)} servers") + break # Found data, done + except Exception as e: + logger.warning(f"Failed to update Tidal APIs: {e}") + + def embed_metadata(self, audio_data: bytes, format: str, metadata: Dict) -> bytes: + """Embed metadata into audio file (MP3/FLAC).""" + if not metadata: return audio_data + + logger.info(f"Embedding metadata for {format}: {metadata.get('title')} - {metadata.get('year')}") + + try: + suffix = ".flac" if format == "flac" else ".mp3" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(audio_data) + tmp_path = tmp.name + + if format == "flac": + audio = FLAC(tmp_path) + audio.clear_pictures() + + if metadata.get("title"): audio["TITLE"] = metadata["title"] + if metadata.get("artists"): audio["ARTIST"] = metadata["artists"] + if metadata.get("album"): audio["ALBUM"] = metadata["album"] + if metadata.get("year"): audio["DATE"] = str(metadata["year"])[:4] + if metadata.get("track_number"): audio["TRACKNUMBER"] = str(metadata["track_number"]) + + if metadata.get("album_art_data"): + picture = Picture() + picture.data = metadata["album_art_data"] + picture.type = PictureType.COVER_FRONT + picture.mime = "image/jpeg" + audio.add_picture(picture) + audio.save() + + elif format in ["mp3", "mp3_128"]: + try: + audio = MP3(tmp_path, ID3=ID3) + except: + audio = MP3(tmp_path) + audio.add_tags() + + # ID3 Tags + if metadata.get("title"): audio.tags.add(TIT2(encoding=3, text=metadata["title"])) + if metadata.get("artists"): audio.tags.add(TPE1(encoding=3, text=metadata["artists"])) + if metadata.get("album"): audio.tags.add(TALB(encoding=3, text=metadata["album"])) + if metadata.get("year"): audio.tags.add(TDRC(encoding=3, text=str(metadata["year"])[:4])) + if metadata.get("track_number"): audio.tags.add(TRCK(encoding=3, text=str(metadata["track_number"]))) + + if metadata.get("album_art_data"): + audio.tags.add( + APIC( + encoding=3, + mime='image/jpeg', + type=3, + desc='Cover', + data=metadata["album_art_data"] + ) + ) + audio.save() + + with open(tmp_path, 'rb') as f: + tagged_data = f.read() + + os.remove(tmp_path) + return tagged_data + + except Exception as e: + logger.error(f"Metadata tagging error: {e}") + if os.path.exists(tmp_path): os.remove(tmp_path) + return audio_data + + async def get_tidal_download_url(self, track_id: int, quality: str = "LOSSLESS") -> Optional[str]: + """Get download URL from Tidal APIs with fallback.""" + + # Update APIs first + await self.update_tidal_apis() + + # Build API list with the last working API first + apis_to_try = list(TIDAL_APIS) + if self.working_api and self.working_api in apis_to_try: + apis_to_try.remove(self.working_api) + apis_to_try.insert(0, self.working_api) + + # Try each API until one works + for api_url in apis_to_try: + download_url = await self.get_tidal_download_url_from_api(api_url, track_id, quality) + if download_url: + return download_url + + logger.error("All Tidal APIs failed") + return None + + async def _fetch_tidal_cover(self, cover_uuid: str) -> Optional[bytes]: + """Fetch Tidal album art.""" + try: + url = f"https://resources.tidal.com/images/{cover_uuid.replace('-', '/')}/1280x1280.jpg" + response = await self.client.get(url) + if response.status_code == 200: + return response.content + except Exception: + pass + return None + + async def get_deezer_track_info(self, isrc: str) -> Optional[Dict]: + """Get Deezer track info from ISRC.""" + try: + response = await self.client.get( + f"https://api.deezer.com/2.0/track/isrc:{isrc}" + ) + if response.status_code == 200: + data = response.json() + if "error" not in data: + return data + return None + except Exception as e: + logger.error(f"Deezer lookup error: {e}") + return None + + async def get_deezer_download_url(self, track_id: int) -> Optional[str]: + """Get FLAC download URL from Deezer API.""" + try: + response = await self.client.get( + f"{DEEZER_API_URL}/dl/{track_id}", + timeout=30.0 + ) + + if response.status_code != 200: + logger.warning(f"Deezer API returned {response.status_code}") + return None + + data = response.json() + if data.get("success"): + return data.get("links", {}).get("flac") + + return None + + except Exception as e: + logger.error(f"Deezer download URL error: {e}") + return None + + async def fetch_tidal_metadata(self, track: Dict) -> Dict: + """Extract metadata from Tidal track object.""" + try: + album = track.get("album", {}) + artist = track.get("artist", {}) + if not artist and track.get("artists"): + artist = track.get("artists")[0] + + cover_uuid = album.get("cover") + album_art_data = None + if cover_uuid: + album_art_data = await self._fetch_tidal_cover(cover_uuid) + + return { + "title": track.get("title"), + "artist": artist.get("name"), + "artists": artist.get("name"), # For embed_metadata + "album": album.get("title"), + "year": track.get("releaseDate", "")[:4], + "track_number": track.get("trackNumber"), + "album_art_data": album_art_data, + "album_art_url": None + } + except Exception as e: + logger.error(f"Metadata extraction error: {e}") + return {} + + async def fetch_flac(self, isrc: str, query: str = "", hires: bool = True) -> Optional[Union[tuple[bytes, Dict], tuple[str, Dict]]]: + """Fetch FLAC audio and metadata from Tidal or Deezer (with fallback).""" + + deezer_info = None # Cache for potential metadata use + + # Handle Deezer track IDs (dz_XXXXX format) - extract ISRC first + if isrc.startswith("dz_"): + deezer_track_id = isrc.replace("dz_", "") + logger.info(f"Deezer track ID detected: {deezer_track_id}") + + try: + # Fetch track info from Deezer public API to get ISRC + response = await self.client.get( + f"https://api.deezer.com/track/{deezer_track_id}" + ) + if response.status_code == 200: + deezer_info = response.json() + if "error" not in deezer_info: + extracted_isrc = deezer_info.get("isrc") + if extracted_isrc: + logger.info(f"Extracted ISRC from Deezer: {extracted_isrc}") + isrc = extracted_isrc # Use real ISRC for Tidal lookup + query = query or f"{deezer_info.get('title', '')} {deezer_info.get('artist', {}).get('name', '')}" + else: + logger.warning("No ISRC in Deezer track - will try Deezer download directly") + except Exception as e: + logger.error(f"Deezer track info fetch error: {e}") + + # Handle query: prefixed IDs (from ListenBrainz playlists) + # These are searchable by artist + title, no ISRC available + if isrc.startswith("query:"): + query = isrc.replace("query:", "") + isrc = "" # Clear ISRC, will search by query only + logger.info(f"ListenBrainz track - searching by query: {query}") + + + # 0. Try Dab Music (Qobuz Hi-Res Proxy) - New! Priority #1 + try: + from app.dab_service import dab_service + + dab_id = None + dab_track = None # Initialize to avoid UnboundLocalError + + if isrc.startswith("dab_"): + dab_id = isrc + else: + # Search prioritization: + # Prefer standard query (Artist + Title) because ISRC support on Dab is flaky. + dab_query = query + if not dab_query and isrc and not isrc.startswith("dz_"): + dab_query = f"isrc:{isrc}" + + if dab_query: + dab_tracks = await dab_service.search_tracks(dab_query, limit=1) + if dab_tracks: + dab_track = dab_tracks[0] + dab_id = dab_track.get('id') + + # Optional: Verify title match if using query? + # For now assume top result is correct as Dab/Qobuz search is usually okay for metadata. + + if dab_id: + # Select quality + quality = "27" if hires else "7" + stream_url = await dab_service.get_stream_url(dab_id, quality=quality) + + if stream_url: + logger.info(f"Dab Stream URL found: {stream_url[:40]}...") + + # Metadata gathering + # Use Dab metadata if available, otherwise fallback to query string + if dab_track: + metadata = { + "title": dab_track.get("title"), + "artist": dab_track.get("artist"), + "album": dab_track.get("albumTitle"), + "year": dab_track.get("releaseDate", "")[:4] if dab_track.get("releaseDate") else "", + "album_art_url": dab_track.get("albumCover"), + "album_art_data": None + } + # Ensure artist/album are strings + if isinstance(metadata["artist"], dict): metadata["artist"] = metadata["artist"].get("name") + if isinstance(metadata["album"], dict): metadata["album"] = metadata["album"].get("title") + else: + # Fallback: extract from query string (format: "Title Artist") + parts = query.split(" ") if query else [] + metadata = { + "title": query or "Unknown", + "artist": "", + "album": "", + "year": "", + "album_art_url": None, + "album_art_data": None + } + + metadata["is_hi_res"] = True + return (stream_url, metadata) + except Exception as e: + logger.error(f"Dab Music fetch error: {e}") + + # 1. Try Tidal (Primary Source) + if not isrc.startswith("dz_"): # Only if we have a real ISRC + logger.info(f"Trying Tidal APIs for ISRC: {isrc}") + tidal_track = await self.search_tidal_by_isrc(isrc, query) + + if tidal_track: + track_id = tidal_track.get("id") + download_url = await self.get_tidal_download_url(track_id) + + if download_url: + logger.info(f"Downloading from Tidal: {download_url[:80]}...") + try: + response = await self.client.get(download_url, timeout=180.0) + if response.status_code == 200: + size_mb = len(response.content) / 1024 / 1024 + logger.info(f"Downloaded {size_mb:.2f} MB from Tidal") + + meta = { + "title": tidal_track.get("title"), + "artists": [a["name"] for a in tidal_track.get("artists", [])], + "album": tidal_track.get("album", {}).get("title"), + "year": tidal_track.get("album", {}).get("releaseDate"), + "track_number": tidal_track.get("trackNumber"), + } + cover_uuid = tidal_track.get("album", {}).get("cover") + if cover_uuid: + meta["album_art_data"] = await self._fetch_tidal_cover(cover_uuid) + + return (response.content, meta) + except Exception as e: + logger.error(f"Tidal download error: {e}") + else: + logger.warning(f"Tidal search returned no results for: {isrc}") + + # Fallback to Deezer FLAC download (deezmate API) + logger.info(f"Falling back to Deezer for: {isrc or query}") + + # If we have cached deezer_info from above, use it; otherwise fetch + if not deezer_info and isrc.startswith("dz_"): + deezer_track_id = isrc.replace("dz_", "") + try: + response = await self.client.get(f"https://api.deezer.com/track/{deezer_track_id}") + if response.status_code == 200: + deezer_info = response.json() + except: + pass + elif not deezer_info and isrc: + # Lookup by ISRC + deezer_info = await self.get_deezer_track_info(isrc) + elif not deezer_info and query: + # No ISRC - search by query (for ListenBrainz tracks) + try: + response = await self.client.get( + "https://api.deezer.com/search/track", + params={"q": query, "limit": 1} + ) + if response.status_code == 200: + data = response.json() + tracks = data.get("data", []) + if tracks: + deezer_info = tracks[0] + logger.info(f"Deezer search found: {deezer_info.get('title')} by {deezer_info.get('artist', {}).get('name')}") + except Exception as e: + logger.error(f"Deezer search error: {e}") + + if deezer_info and "error" not in deezer_info: + deezer_id = deezer_info.get("id") + download_url = await self.get_deezer_download_url(deezer_id) + + if download_url: + logger.info(f"Downloading from Deezer (deezmate)...") + try: + response = await self.client.get(download_url, timeout=180.0) + if response.status_code == 200: + logger.info(f"Downloaded {len(response.content) / 1024 / 1024:.2f} MB from Deezer") + + meta = { + "title": deezer_info.get("title"), + "artists": [a["name"] for a in deezer_info.get("contributors", [])] or [deezer_info.get("artist", {}).get("name")], + "album": deezer_info.get("album", {}).get("title"), + "year": deezer_info.get("release_date"), + "track_number": deezer_info.get("track_position"), + } + cover_url = deezer_info.get("album", {}).get("cover_xl") + if cover_url: + try: + cover_resp = await self.client.get(cover_url) + if cover_resp.status_code == 200: + meta["album_art_data"] = cover_resp.content + except: pass + + return (response.content, meta) + except Exception as e: + logger.error(f"Deezer download error: {e}") + + logger.error(f"Could not fetch audio for: {isrc}") + return None + + def transcode_to_mp3(self, flac_data: bytes, bitrate: str = BITRATE) -> Optional[bytes]: + """Transcode FLAC to MP3 using FFmpeg.""" + try: + # Use FFmpeg with stdin/stdout for streaming + process = subprocess.Popen( + [ + FFMPEG_PATH, + "-i", "pipe:0", # Read from stdin + "-vn", # No video + "-acodec", "libmp3lame", # MP3 encoder + "-b:a", bitrate, # Bitrate + "-f", "mp3", # Output format + "pipe:1" # Write to stdout + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + mp3_data, stderr = process.communicate(input=flac_data) + + if process.returncode != 0: + logger.error(f"FFmpeg error: {stderr.decode()[:500]}") + return None + + logger.info(f"Transcoded to MP3: {len(mp3_data) / 1024 / 1024:.2f} MB") + return mp3_data + + except FileNotFoundError: + logger.error("FFmpeg not found! Please install FFmpeg.") + return None + except Exception as e: + logger.error(f"Transcode error: {e}") + return None + + async def get_audio_stream(self, isrc: str, query: str = "") -> Optional[bytes]: + """Get transcoded MP3 audio, using cache if available.""" + + # Check cache first + if is_cached(isrc, "mp3"): + logger.info(f"Cache hit for {isrc}") + cached_data = await get_cached_file(isrc, "mp3") + if cached_data: + return cached_data + + + + # Fetch and transcode + logger.info(f"Cache miss for {isrc}, fetching...") + result = await self.fetch_flac(isrc, query) + + if not result: + return None + + flac_data, metadata = result + + # Transcode (run in executor to not block) + loop = asyncio.get_event_loop() + mp3_data = await loop.run_in_executor(None, self.transcode_to_mp3, flac_data) + + if mp3_data: + # Cache the result + await cache_file(isrc, mp3_data, "mp3") + + return mp3_data + + + + # Format configurations for FFmpeg + FORMAT_CONFIG = { + "mp3": { + "ext": ".mp3", + "mime": "audio/mpeg", + "args": ["-acodec", "libmp3lame", "-b:a", "320k", "-f", "mp3"] + }, + "mp3_128": { + "ext": ".mp3", + "mime": "audio/mpeg", + "args": ["-acodec", "libmp3lame", "-b:a", "128k", "-f", "mp3"] + }, + "flac": { + "ext": ".flac", + "mime": "audio/flac", + "args": ["-acodec", "flac", "-sample_fmt", "s16", "-f", "flac"] # Force 16-bit + }, + "flac_24": { + "ext": ".flac", + "mime": "audio/flac", + "args": ["-acodec", "flac", "-sample_fmt", "s32", "-f", "flac"] # 24-bit preserved + }, + "aiff": { + "ext": ".aiff", + "mime": "audio/aiff", + "args": ["-acodec", "pcm_s16be", "-f", "aiff"] + }, + "wav": { + "ext": ".wav", + "mime": "audio/wav", + "args": ["-acodec", "pcm_s16le", "-f", "wav"] + }, + "wav_24": { + "ext": ".wav", + "mime": "audio/wav", + "args": ["-acodec", "pcm_s24le", "-f", "wav"] + }, + "alac": { + "ext": ".m4a", + "mime": "audio/mp4", + "args": ["-acodec", "alac", "-f", "ipod"] + }, + "aiff_24": { + "ext": ".aiff", + "mime": "audio/aiff", + "args": ["-acodec", "pcm_s24be", "-f", "aiff"] + } + } + + def transcode_to_format(self, flac_data: bytes, format: str = "mp3") -> Optional[bytes]: + """Transcode FLAC to specified format using FFmpeg.""" + config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"]) + + try: + logger.info(f"Transcoding to {format} using FFmpeg at: {FFMPEG_PATH}") + # Note: flac_24 goes through FFmpeg to ensure proper sample format + + cmd = [ + FFMPEG_PATH, + "-i", "pipe:0", # Read from stdin + "-vn", # No video + ] + config["args"] + [ + "pipe:1" # Write to stdout + ] + + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + output_data, stderr = process.communicate(input=flac_data) + + if process.returncode != 0: + logger.error(f"FFmpeg error: {stderr.decode()[:500]}") + return None + + logger.info(f"Transcoded to {format}: {len(output_data) / 1024 / 1024:.2f} MB") + return output_data + + except FileNotFoundError: + logger.error("FFmpeg not found!") + return None + except Exception as e: + logger.error(f"Transcode error: {e}") + return None + + async def get_download_audio(self, isrc: str, query: str, format: str = "mp3") -> Optional[tuple]: + """Get audio in specified format for download. Returns (data, extension, mime_type).""" + + config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"]) + cache_ext = format if format != "mp3_128" else "mp3_128" + + # Skip cache for downloads to ensure we get fresh metadata + # if is_cached(isrc, cache_ext): + # ... + + # Fetch FLAC + logger.info(f"Fetching audio for download (skipping cache to ensure tags): {isrc}") + + # Handle Imported Links + if isrc.startswith("LINK:"): + # ... (existing link handling) + return None # Todo: handle link tagging similarly if possible + + result = await self.fetch_flac(isrc, query) + + if not result: + return None + + flac_data, metadata = result + + # Enrich metadata with MusicBrainz (release year, label, better cover art) + try: + from app.musicbrainz_service import musicbrainz_service + mb_data = await musicbrainz_service.lookup_by_isrc(isrc) + if mb_data: + # Fill in missing fields from MusicBrainz + if not metadata.get("year") and mb_data.get("release_date"): + metadata["year"] = mb_data["release_date"] + if mb_data.get("label"): + metadata["label"] = mb_data["label"] + # Use MusicBrainz cover art if we don't have one + if not metadata.get("album_art_data") and mb_data.get("cover_art_url"): + try: + cover_resp = await self.client.get(mb_data["cover_art_url"]) + if cover_resp.status_code == 200: + metadata["album_art_data"] = cover_resp.content + logger.info("Using cover art from Cover Art Archive") + except: pass + except Exception as e: + logger.debug(f"MusicBrainz enrichment skipped: {e}") + + # Transcode/Passthrough + loop = asyncio.get_event_loop() + output_data = await loop.run_in_executor( + None, self.transcode_to_format, flac_data, format + ) + + if output_data: + # Embed Metadata + output_data = await loop.run_in_executor( + None, self.embed_metadata, output_data, format, metadata + ) + + # Cache it? Maybe not since it has user-specific tags? + # Actually standard tags are fine. + # await cache_file(isrc, output_data, cache_ext) + return (output_data, config["ext"], config["mime"]) + + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + # Close Dab Service + try: + from app.dab_service import dab_service + await dab_service.close() + except: pass + + +# Singleton instance +audio_service = AudioService() diff --git a/app/cache.py b/app/cache.py new file mode 100644 index 0000000..3a19118 --- /dev/null +++ b/app/cache.py @@ -0,0 +1,133 @@ +""" +Cache service for storing transcoded audio files. +Implements auto-cleanup to stay within storage limits. +""" +import os +import time +import asyncio +import aiofiles +from pathlib import Path +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# Cache configuration +CACHE_DIR = Path(os.environ.get("CACHE_DIR", "/tmp/spotiflac_cache")) +MAX_CACHE_SIZE_MB = int(os.environ.get("MAX_CACHE_SIZE_MB", "500")) +CACHE_TTL_HOURS = int(os.environ.get("CACHE_TTL_HOURS", "24")) + + +def ensure_cache_dir(): + """Ensure cache directory exists.""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + return CACHE_DIR + + +def get_cache_path(isrc: str, format: str = "mp3") -> Path: + """Get the cache file path for a given ISRC. + + For LINK: prefixed IDs (which can be very long base64 strings), + we hash the ID to create a shorter, valid filename. + """ + import hashlib + ensure_cache_dir() + + # Hash long IDs to prevent "filename too long" errors + if len(isrc) > 100 or isrc.startswith("LINK:"): + safe_name = hashlib.md5(isrc.encode()).hexdigest() + else: + # Sanitize the ISRC for use as filename + safe_name = isrc.replace("/", "_").replace(":", "_") + + return CACHE_DIR / f"{safe_name}.{format}" + + +def is_cached(isrc: str, format: str = "mp3") -> bool: + """Check if a track is cached.""" + cache_path = get_cache_path(isrc, format) + return cache_path.exists() and cache_path.stat().st_size > 0 + + +async def get_cached_file(isrc: str, format: str = "mp3") -> Optional[bytes]: + """Retrieve a cached file if it exists.""" + cache_path = get_cache_path(isrc, format) + if cache_path.exists(): + try: + # Update access time + cache_path.touch() + async with aiofiles.open(cache_path, 'rb') as f: + return await f.read() + except Exception as e: + logger.error(f"Error reading cache for {isrc}: {e}") + return None + + +async def cache_file(isrc: str, data: bytes, format: str = "mp3") -> bool: + """Cache a transcoded file.""" + try: + cache_path = get_cache_path(isrc, format) + async with aiofiles.open(cache_path, 'wb') as f: + await f.write(data) + logger.info(f"Cached {isrc}.{format} ({len(data) / 1024 / 1024:.2f} MB)") + return True + except Exception as e: + logger.error(f"Error caching {isrc}: {e}") + return False + + +def get_cache_size_mb() -> float: + """Get total cache size in MB.""" + ensure_cache_dir() + total = sum(f.stat().st_size for f in CACHE_DIR.iterdir() if f.is_file()) + return total / 1024 / 1024 + + +async def cleanup_cache(): + """Remove old files to stay within cache limits.""" + ensure_cache_dir() + now = time.time() + ttl_seconds = CACHE_TTL_HOURS * 3600 + max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024 + + files = [] + for f in CACHE_DIR.iterdir(): + if f.is_file(): + stat = f.stat() + files.append({ + 'path': f, + 'size': stat.st_size, + 'atime': stat.st_atime + }) + + # Remove files older than TTL + for file_info in files[:]: + if now - file_info['atime'] > ttl_seconds: + try: + file_info['path'].unlink() + files.remove(file_info) + logger.info(f"Removed expired cache file: {file_info['path'].name}") + except Exception as e: + logger.error(f"Error removing {file_info['path']}: {e}") + + # If still over limit, remove oldest files + files.sort(key=lambda x: x['atime']) + total_size = sum(f['size'] for f in files) + + while total_size > max_bytes and files: + oldest = files.pop(0) + try: + oldest['path'].unlink() + total_size -= oldest['size'] + logger.info(f"Removed cache file to free space: {oldest['path'].name}") + except Exception as e: + logger.error(f"Error removing {oldest['path']}: {e}") + + logger.info(f"Cache size after cleanup: {total_size / 1024 / 1024:.2f} MB") + + +async def periodic_cleanup(interval_minutes: int = 30): + """Run cache cleanup periodically.""" + while True: + await asyncio.sleep(interval_minutes * 60) + await cleanup_cache() diff --git a/app/concert_service.py b/app/concert_service.py new file mode 100644 index 0000000..e26ce9c --- /dev/null +++ b/app/concert_service.py @@ -0,0 +1,324 @@ +""" +Concert Service - Ticketmaster Discovery API + SeatGeek fallback +Provides upcoming concert search for artists +""" + +import os +import httpx +import logging +from typing import List, Dict, Optional, Any +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# API Configuration +TICKETMASTER_API_KEY = os.getenv("TICKETMASTER_API_KEY", "") +SEATGEEK_CLIENT_ID = os.getenv("SEATGEEK_CLIENT_ID", "") + +TICKETMASTER_BASE = "https://app.ticketmaster.com/discovery/v2" +SEATGEEK_BASE = "https://api.seatgeek.com/2" + + +class ConcertService: + """Service for fetching upcoming concerts from Ticketmaster and SeatGeek.""" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + + async def search_ticketmaster( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search Ticketmaster Discovery API for events. + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + if not TICKETMASTER_API_KEY: + logger.warning("TICKETMASTER_API_KEY not set") + return [] + + try: + params = { + "apikey": TICKETMASTER_API_KEY, + "keyword": artist, + "classificationName": "music", + "size": limit, + "sort": "date,asc" + } + + # Normalize city name (remove "City" suffix, common variants) + if city: + normalized_city = city.replace(" City", "").replace(" city", "").strip() + params["city"] = normalized_city + + response = await self.client.get( + f"{TICKETMASTER_BASE}/events.json", + params=params + ) + + if response.status_code != 200: + logger.error(f"Ticketmaster API error: {response.status_code}") + return [] + + data = response.json() + events = data.get("_embedded", {}).get("events", []) + + logger.info(f"Ticketmaster returned {len(events)} events for '{artist}'") + + # If no events found with city filter, try without + if not events and city: + logger.info(f"No events with city filter, trying without...") + del params["city"] + response = await self.client.get( + f"{TICKETMASTER_BASE}/events.json", + params=params + ) + if response.status_code == 200: + data = response.json() + events = data.get("_embedded", {}).get("events", []) + logger.info(f"Ticketmaster (no city) returned {len(events)} events") + + return [self._normalize_ticketmaster_event(e) for e in events] + + except Exception as e: + logger.error(f"Ticketmaster search error: {e}") + return [] + + def _normalize_ticketmaster_event(self, event: Dict) -> Dict[str, Any]: + """Convert Ticketmaster event to normalized format.""" + # Get venue info + venues = event.get("_embedded", {}).get("venues", []) + venue = venues[0] if venues else {} + + # Get date/time + dates = event.get("dates", {}) + start = dates.get("start", {}) + + # Get price range + price_ranges = event.get("priceRanges", []) + price = price_ranges[0] if price_ranges else {} + + # Get image + images = event.get("images", []) + image = next((img["url"] for img in images if img.get("ratio") == "16_9"), None) + if not image and images: + image = images[0].get("url") + + # Get artist name from attractions + attractions = event.get("_embedded", {}).get("attractions", []) + artist_name = attractions[0].get("name") if attractions else event.get("name", "") + + return { + "id": event.get("id", ""), + "name": event.get("name", ""), + "artist": artist_name, + "venue": venue.get("name", "Unknown Venue"), + "city": venue.get("city", {}).get("name", ""), + "state": venue.get("state", {}).get("stateCode", ""), + "country": venue.get("country", {}).get("countryCode", ""), + "date": start.get("localDate", ""), + "time": start.get("localTime", ""), + "ticket_url": event.get("url", ""), + "price_min": price.get("min"), + "price_max": price.get("max"), + "currency": price.get("currency", "USD"), + "image": image, + "source": "ticketmaster" + } + + async def search_seatgeek( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search SeatGeek API for events (fallback). + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + if not SEATGEEK_CLIENT_ID: + logger.warning("SEATGEEK_CLIENT_ID not set") + return [] + + try: + # Use performers.slug for better matching (slugify artist name) + artist_slug = artist.lower().replace(" ", "-").replace("'", "") + + params = { + "client_id": SEATGEEK_CLIENT_ID, + "performers.slug": artist_slug, + "per_page": limit, + "sort": "datetime_utc.asc" + } + + response = await self.client.get( + f"{SEATGEEK_BASE}/events", + params=params + ) + + if response.status_code != 200: + logger.error(f"SeatGeek API error: {response.status_code}") + return [] + + data = response.json() + events = data.get("events", []) + + logger.info(f"SeatGeek returned {len(events)} events for '{artist}'") + + # If no results with slug, try keyword search + if not events: + logger.info(f"SeatGeek slug search failed, trying q=") + params = { + "client_id": SEATGEEK_CLIENT_ID, + "q": artist, + "type": "concert", + "per_page": limit, + "sort": "datetime_utc.asc" + } + response = await self.client.get( + f"{SEATGEEK_BASE}/events", + params=params + ) + if response.status_code == 200: + data = response.json() + events = data.get("events", []) + logger.info(f"SeatGeek (q=) returned {len(events)} events") + + return [self._normalize_seatgeek_event(e) for e in events] + + except Exception as e: + logger.error(f"SeatGeek search error: {e}") + return [] + + def _normalize_seatgeek_event(self, event: Dict) -> Dict[str, Any]: + """Convert SeatGeek event to normalized format.""" + venue = event.get("venue", {}) + performers = event.get("performers", []) + performer = performers[0] if performers else {} + + # Parse datetime + datetime_utc = event.get("datetime_utc", "") + date_str = "" + time_str = "" + if datetime_utc: + try: + dt = datetime.fromisoformat(datetime_utc.replace("Z", "+00:00")) + date_str = dt.strftime("%Y-%m-%d") + time_str = dt.strftime("%H:%M:%S") + except: + pass + + # Get stats for pricing + stats = event.get("stats", {}) + + return { + "id": str(event.get("id", "")), + "name": event.get("title", ""), + "artist": performer.get("name", event.get("title", "")), + "venue": venue.get("name", "Unknown Venue"), + "city": venue.get("city", ""), + "state": venue.get("state", ""), + "country": venue.get("country", ""), + "date": date_str, + "time": time_str, + "ticket_url": event.get("url", ""), + "price_min": stats.get("lowest_price"), + "price_max": stats.get("highest_price"), + "currency": "USD", + "image": performer.get("image"), + "source": "seatgeek" + } + + async def search_events( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search for events with Ticketmaster primary, SeatGeek fallback. + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + # Try Ticketmaster first + events = await self.search_ticketmaster(artist, city, limit) + + # If no results or Ticketmaster unavailable, try SeatGeek + if not events: + logger.info(f"Falling back to SeatGeek for: {artist}") + events = await self.search_seatgeek(artist, city, limit) + + return events + + async def get_events_for_artists( + self, + artists: List[str], + cities: Optional[List[str]] = None, + limit_per_artist: int = 5 + ) -> List[Dict[str, Any]]: + """ + Get upcoming events for multiple artists. + + Args: + artists: List of artist names + cities: Optional list of cities to filter by + limit_per_artist: Max events per artist + + Returns: + List of all events, sorted by date + """ + all_events = [] + + for artist in artists[:10]: # Limit to 10 artists to avoid rate limits + if cities: + # Search each city + for city in cities[:3]: # Limit to 3 cities + events = await self.search_events(artist, city, limit_per_artist) + all_events.extend(events) + else: + events = await self.search_events(artist, None, limit_per_artist) + all_events.extend(events) + + # Deduplicate by event ID + seen_ids = set() + unique_events = [] + for event in all_events: + event_id = f"{event['source']}_{event['id']}" + if event_id not in seen_ids: + seen_ids.add(event_id) + unique_events.append(event) + + # Sort by date + unique_events.sort(key=lambda e: e.get("date", "9999-99-99")) + + return unique_events + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +concert_service = ConcertService() diff --git a/app/dab_service.py b/app/dab_service.py new file mode 100644 index 0000000..4b84b29 --- /dev/null +++ b/app/dab_service.py @@ -0,0 +1,258 @@ +""" +Dab Music Service +Retrieves Hi-Res audio from Dab Music API. +""" +import httpx +import logging +from typing import Optional, List, Dict, Any +import os + +logger = logging.getLogger(__name__) + +class DabService: + BASE_URL = "https://dabmusic.xyz/api" + + def __init__(self): + self._initialized = False + self.client = None + self.session_token = "" + self.visitor_id = "" + + def _ensure_initialized(self): + """Lazy initialization - loads credentials on first use, not import time.""" + if self._initialized: + return + + # Load credentials at runtime (not import time) for cloud deployment compatibility + self.session_token = os.getenv("DAB_SESSION", "") + self.visitor_id = os.getenv("DAB_VISITOR_ID", "") + + # Debug: Log if credentials are present (not the actual values) + if self.session_token: + logger.info(f"Dab credentials loaded: session={len(self.session_token)} chars, visitor={len(self.visitor_id)} chars") + else: + logger.warning("Dab credentials not found - Hi-Res streaming will be unavailable") + + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://dabmusic.xyz/", + "Origin": "https://dabmusic.xyz" + } + self.cookies = { + "session": self.session_token + } + + self.client = httpx.AsyncClient( + headers=self.headers, + cookies=self.cookies, + timeout=15.0, + follow_redirects=True + ) + self._initialized = True + + async def search_tracks(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + """Search for tracks on Dab Music.""" + self._ensure_initialized() + try: + resp = await self.client.get( + f"{self.BASE_URL}/search", + params={"q": query, "type": "track", "limit": limit} + ) + if resp.status_code == 200: + data = resp.json() + tracks = data.get("tracks", []) + + # Debug logging + if tracks and len(tracks) > 0: + logger.info(f"Dab Search Track 0: {type(tracks[0])} - {str(tracks[0])[:50]}...") + + return [self._format_track(t) for t in tracks if isinstance(t, dict)] + elif resp.status_code == 401: + logger.error("Dab API Unauthorized - Token may have expired") + else: + logger.warning(f"Dab search failed: {resp.status_code}") + return [] + except Exception as e: + logger.error(f"Dab search error: {e}") + return [] + + async def search_albums(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + """Search for albums on Dab Music.""" + self._ensure_initialized() + try: + resp = await self.client.get( + f"{self.BASE_URL}/search", + params={"q": query, "type": "album", "limit": limit} + ) + if resp.status_code == 200: + data = resp.json() + albums = data.get("albums", []) + + # Debug logging + if albums and len(albums) > 0: + logger.info(f"Dab Search Album 0: {type(albums[0])} - {albums[0]}") + + return [self._format_album(a) for a in albums if isinstance(a, dict)] + return [] + except Exception as e: + logger.error(f"Dab album search error: {e}") + return [] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album details with tracks.""" + self._ensure_initialized() + try: + clean_id = album_id.replace("dab_", "") + # Try getAlbum endpoint first (based on test results, /getAlbum works usually if /album fails) + # Actually test showed 404 for all... Wait, I need to be careful. + # But earlier test showed /album and /getAlbum failing with 400/404. + # BUT web interface uses: /api/get?id=... (from observation usually) + # Or /api/album?id=... + # I will try /getAlbum based on typical Qobuz proxies, or fallback to search if needed? + # actually if the test failed, I might need to rely on what I saw in other code or assume /getAlbum or /album. + # Let's try /getAlbum with 'albumId' param as that is specific to Dab often. + + resp = await self.client.get(f"{self.BASE_URL}/getAlbum", params={"albumId": clean_id}) + if resp.status_code != 200: + resp = await self.client.get(f"{self.BASE_URL}/album", params={"albumId": clean_id}) + + if resp.status_code == 200: + data = resp.json() + + # Check for nested 'album' key which is common in getAlbum/album endpoints + album_data = data.get("album", data) + + album = self._format_album(album_data) + + tracks = [] + # Tracks are usually inside the album object or 'tracks' key + raw_tracks = album_data.get("tracks", []) + # Sometimes tracks are wrapped in 'items' + if isinstance(raw_tracks, dict) and "items" in raw_tracks: + raw_tracks = raw_tracks["items"] + elif not isinstance(raw_tracks, list): + raw_tracks = [] + + tracks = [self._format_track(t, album_info=album_data) for t in raw_tracks] + album["tracks"] = tracks + return album + + logger.warning(f"Dab get_album failed: {resp.status_code}") + return None + except Exception as e: + logger.error(f"Dab get_album error: {e}") + return None + + def _format_track(self, item: dict, album_info: dict = None) -> dict: + """Format Dab track to frontend schema.""" + # Clean ID + track_id = str(item.get("id")) + + # Album info might come from item or parent + alb_title = item.get("albumTitle") or item.get("album", {}).get("title") + if album_info: alb_title = alb_title or album_info.get("title") + + alb_cover = item.get("albumCover") or item.get("album", {}).get("cover") + if not alb_cover and album_info: + alb_cover = album_info.get("image", {}).get("large") or album_info.get("cover") + + # Artist + artist_obj = item.get("artist") + if isinstance(artist_obj, dict): + artist_name = artist_obj.get("name") + else: + artist_name = artist_obj + + if not artist_name and album_info: + artist_val = album_info.get("artist") + if isinstance(artist_val, dict): + artist_name = artist_val.get("name") + else: + artist_name = artist_val + + return { + "id": f"dab_{track_id}", + "type": "track", + "name": item.get("title", "Unknown"), + "artists": artist_name, + "artist_names": [artist_name], + "album": alb_title, + "album_id": f"dab_{item.get('albumId') or (album_info['id'] if album_info else '')}", + "album_art": alb_cover, + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), # Dab often provides isrc + "release_date": item.get("releaseDate", ""), + "source": "dab", + "is_hi_res": item.get("audioQuality", {}).get("isHiRes", False) + } + + def _format_album(self, item: dict) -> dict: + """Format Dab album to frontend schema.""" + # Extract images + images = item.get("images", {}) + cover = None + if isinstance(images, dict): + cover = images.get("large") or images.get("medium") + + if not cover: cover = item.get("cover") # Fallback to top level + + if isinstance(cover, dict): cover = cover.get("large") # Handle nested cases + + # Handle Artist + artist_obj = item.get("artist") + if isinstance(artist_obj, dict): + artist_name = artist_obj.get("name") + else: + artist_name = artist_obj + + # Extract audio quality info + audio_quality = item.get("audioQuality", {}) + + return { + "id": f"dab_{item.get('id')}", + "type": "album", + "name": item.get("title", ""), + "artists": artist_name, + "album_art": cover, + "release_date": item.get("releaseDate", ""), + "total_tracks": item.get("trackCount", 0), + "source": "dab", + "is_hi_res": audio_quality.get("isHiRes", False), + "audio_quality": { + "maximumBitDepth": audio_quality.get("maximumBitDepth", 16), + "maximumSamplingRate": audio_quality.get("maximumSamplingRate", 44.1), + "isHiRes": audio_quality.get("isHiRes", False) + }, + "format": "FLAC" if audio_quality.get("isHiRes", False) else "FLAC" + } + + def _format_duration(self, ms: int) -> str: + seconds = int(ms // 1000) + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def get_stream_url(self, track_id: str, quality: str = "27") -> Optional[str]: + """Get stream URL for a track. Quality 27=Hi-Res, 7=Lossless.""" + self._ensure_initialized() + try: + clean_id = str(track_id).replace("dab_", "") + resp = await self.client.get( + f"{self.BASE_URL}/stream", + params={"trackId": clean_id, "quality": quality} + ) + if resp.status_code == 200: + data = resp.json() + return data.get("url") + logger.warning(f"Dab stream fetch failed: {resp.status_code} - {resp.text}") + return None + except Exception as e: + logger.error(f"Dab stream error: {e}") + return None + + async def close(self): + await self.client.aclose() + +# Singleton +dab_service = DabService() diff --git a/app/deezer_service.py b/app/deezer_service.py new file mode 100644 index 0000000..a95473e --- /dev/null +++ b/app/deezer_service.py @@ -0,0 +1,160 @@ +""" +Deezer service for Freedify. +Provides search (tracks, albums, artists) as fallback when Spotify is rate limited. +Deezer API is free and doesn't require authentication for basic searches. +""" +import httpx +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class DeezerService: + """Service for searching and fetching metadata from Deezer.""" + + API_BASE = "https://api.deezer.com" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make API request to Deezer.""" + response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params) + response.raise_for_status() + return response.json() + + # ========== TRACK METHODS ========== + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for tracks.""" + data = await self._api_request("/search/track", {"q": query, "limit": limit, "index": offset}) + return [self._format_track(item) for item in data.get("data", [])] + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend (matching Spotify format).""" + album = item.get("album", {}) + artist = item.get("artist", {}) + return { + "id": f"dz_{item['id']}", + "type": "track", + "name": item.get("title", ""), + "artists": artist.get("name", ""), + "artist_names": [artist.get("name", "")], + "album": album.get("title", ""), + "album_id": f"dz_{album.get('id', '')}", + "album_art": album.get("cover_xl") or album.get("cover_big") or album.get("cover_medium"), + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), + "preview_url": item.get("preview"), + "release_date": album.get("release_date", ""), + "source": "deezer", + } + + # ========== ALBUM METHODS ========== + + async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for albums.""" + data = await self._api_request("/search/album", {"q": query, "limit": limit, "index": offset}) + return [self._format_album(item) for item in data.get("data", [])] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + # Remove dz_ prefix if present + clean_id = album_id.replace("dz_", "") + data = await self._api_request(f"/album/{clean_id}") + album = self._format_album(data) + + # Format tracks + tracks = [] + for item in data.get("tracks", {}).get("data", []): + track = { + "id": f"dz_{item['id']}", + "type": "track", + "name": item.get("title", ""), + "artists": data.get("artist", {}).get("name", ""), + "artist_names": [data.get("artist", {}).get("name", "")], + "album": data.get("title", ""), + "album_id": f"dz_{clean_id}", + "album_art": album["album_art"], + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), + "preview_url": item.get("preview"), + "release_date": data.get("release_date", ""), + "source": "deezer", + } + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Deezer album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + artist = item.get("artist", {}) + return { + "id": f"dz_{item['id']}", + "type": "album", + "name": item.get("title", ""), + "artists": artist.get("name", ""), + "album_art": item.get("cover_xl") or item.get("cover_big") or item.get("cover_medium"), + "release_date": item.get("release_date", ""), + "total_tracks": item.get("nb_tracks", 0), + "source": "deezer", + } + + # ========== ARTIST METHODS ========== + + async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for artists.""" + data = await self._api_request("/search/artist", {"q": query, "limit": limit, "index": offset}) + return [self._format_artist(item) for item in data.get("data", [])] + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + clean_id = artist_id.replace("dz_", "") + data = await self._api_request(f"/artist/{clean_id}") + artist = self._format_artist(data) + + # Get top tracks + top_tracks = await self._api_request(f"/artist/{clean_id}/top", {"limit": 10}) + artist["tracks"] = [self._format_track(t) for t in top_tracks.get("data", [])] + + return artist + except Exception as e: + logger.error(f"Error fetching Deezer artist {artist_id}: {e}") + return None + + def _format_artist(self, item: dict) -> dict: + """Format artist data for frontend.""" + return { + "id": f"dz_{item['id']}", + "type": "artist", + "name": item.get("name", ""), + "image": item.get("picture_xl") or item.get("picture_big") or item.get("picture_medium"), + "fans": item.get("nb_fan", 0), + "source": "deezer", + } + + # ========== UTILITIES ========== + + def _format_duration(self, ms: int) -> str: + """Format duration from ms to MM:SS.""" + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +deezer_service = DeezerService() diff --git a/app/dj_service.py b/app/dj_service.py new file mode 100644 index 0000000..ed1a38b --- /dev/null +++ b/app/dj_service.py @@ -0,0 +1,406 @@ +""" +DJ Service for Freedify. +AI-powered setlist generation using Gemini 2.0 Flash. +""" +import os +import logging +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# Camelot wheel compatibility chart +# Compatible keys: same key, +1/-1 on wheel, switch A/B (relative major/minor) +CAMELOT_COMPAT = { + "1A": ["1A", "1B", "12A", "2A"], + "1B": ["1B", "1A", "12B", "2B"], + "2A": ["2A", "2B", "1A", "3A"], + "2B": ["2B", "2A", "1B", "3B"], + "3A": ["3A", "3B", "2A", "4A"], + "3B": ["3B", "3A", "2B", "4B"], + "4A": ["4A", "4B", "3A", "5A"], + "4B": ["4B", "4A", "3B", "5B"], + "5A": ["5A", "5B", "4A", "6A"], + "5B": ["5B", "5A", "4B", "6B"], + "6A": ["6A", "6B", "5A", "7A"], + "6B": ["6B", "6A", "5B", "7B"], + "7A": ["7A", "7B", "6A", "8A"], + "7B": ["7B", "7A", "6B", "8B"], + "8A": ["8A", "8B", "7A", "9A"], + "8B": ["8B", "8A", "7B", "9B"], + "9A": ["9A", "9B", "8A", "10A"], + "9B": ["9B", "9A", "8B", "10B"], + "10A": ["10A", "10B", "9A", "11A"], + "10B": ["10B", "10A", "9B", "11B"], + "11A": ["11A", "11B", "10A", "12A"], + "11B": ["11B", "11A", "10B", "12B"], + "12A": ["12A", "12B", "11A", "1A"], + "12B": ["12B", "12A", "11B", "1B"], +} + + +class DJService: + """AI-powered DJ setlist generator using Gemini 2.0 Flash.""" + + def __init__(self): + self.api_key = os.environ.get("GEMINI_API_KEY") + self._genai = None + self._model = None + + def _init_genai(self): + """Lazy initialization of Gemini client.""" + if self._genai is None: + try: + import google.generativeai as genai + if not self.api_key: + logger.warning("GEMINI_API_KEY not set - AI features will use rule-based fallback") + return False + genai.configure(api_key=self.api_key) + self._genai = genai + self._model = genai.GenerativeModel('gemini-2.0-flash') + logger.info("Gemini 2.0 Flash initialized successfully") + return True + except ImportError: + logger.warning("google-generativeai not installed - using rule-based fallback") + return False + except Exception as e: + logger.error(f"Failed to initialize Gemini: {e}") + return False + return True + + def is_harmonically_compatible(self, camelot1: str, camelot2: str) -> bool: + """Check if two Camelot keys are harmonically compatible.""" + if camelot1 == "?" or camelot2 == "?": + return False + return camelot2 in CAMELOT_COMPAT.get(camelot1, []) + + def _rule_based_setlist(self, tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Generate setlist using rule-based algorithm when AI is unavailable. + Strategy: Sort by energy, then optimize for harmonic compatibility. + """ + if len(tracks) <= 2: + return sorted(tracks, key=lambda t: t.get("energy", 0.5)) + + # Start with lowest energy track + sorted_tracks = sorted(tracks, key=lambda t: t.get("energy", 0.5)) + setlist = [sorted_tracks.pop(0)] + + while sorted_tracks: + last = setlist[-1] + last_camelot = last.get("camelot", "?") + last_bpm = last.get("bpm", 120) + + # Score remaining tracks by compatibility + def score_track(t): + score = 0 + # Harmonic compatibility (+10 points) + if self.is_harmonically_compatible(last_camelot, t.get("camelot", "?")): + score += 10 + # BPM proximity (+5 for within 5 BPM, +3 for within 10) + bpm_diff = abs(t.get("bpm", 120) - last_bpm) + if bpm_diff <= 5: + score += 5 + elif bpm_diff <= 10: + score += 3 + # Slight energy increase preferred (+2) + energy_diff = t.get("energy", 0.5) - last.get("energy", 0.5) + if 0 < energy_diff < 0.15: + score += 2 + return score + + # Pick best scoring track + sorted_tracks.sort(key=score_track, reverse=True) + setlist.append(sorted_tracks.pop(0)) + + return setlist + + async def generate_setlist( + self, + tracks: List[Dict[str, Any]], + style: str = "progressive" # or "peak-time", "chill", "journey" + ) -> Dict[str, Any]: + """ + Generate an AI-optimized DJ setlist. + + Args: + tracks: List of tracks with bpm, camelot, energy, name, artists + style: Setlist style preference + + Returns: + Dict with ordered track IDs and mixing suggestions + """ + if len(tracks) < 2: + return { + "ordered_ids": [t.get("id") for t in tracks], + "suggestions": [], + "method": "passthrough" + } + + # Try AI generation first + if self._init_genai() and self._model: + try: + result = await self._ai_generate_setlist(tracks, style) + if result: + return result + except Exception as e: + logger.error(f"AI setlist generation failed: {e}") + + # Fallback to rule-based + logger.info("Using rule-based setlist generation") + ordered = self._rule_based_setlist(tracks.copy()) + + # Generate basic suggestions + suggestions = [] + for i in range(len(ordered) - 1): + t1, t2 = ordered[i], ordered[i+1] + bpm_diff = abs(t2.get("bpm", 0) - t1.get("bpm", 0)) + compatible = self.is_harmonically_compatible( + t1.get("camelot", "?"), t2.get("camelot", "?") + ) + + suggestion = { + "from_id": t1.get("id"), + "to_id": t2.get("id"), + "harmonic_match": compatible, + "bpm_diff": bpm_diff, + } + + if compatible and bpm_diff <= 5: + suggestion["tip"] = "Perfect mix - smooth harmonic transition" + elif compatible: + suggestion["tip"] = f"Harmonically compatible, adjust BPM by {bpm_diff}" + elif bpm_diff <= 3: + suggestion["tip"] = "BPM locked, consider EQ mixing" + else: + suggestion["tip"] = "Energy transition - use effects or beat drop" + + suggestions.append(suggestion) + + return { + "ordered_ids": [t.get("id") for t in ordered], + "suggestions": suggestions, + "method": "rule-based" + } + + async def _ai_generate_setlist( + self, + tracks: List[Dict[str, Any]], + style: str + ) -> Optional[Dict[str, Any]]: + """Generate setlist using Gemini AI.""" + import json + + # Build track summary for the prompt + track_summary = [] + for i, t in enumerate(tracks): + track_summary.append( + f"{i+1}. \"{t.get('name', 'Unknown')}\" by {t.get('artists', 'Unknown')} | " + f"BPM: {t.get('bpm', '?')} | Key: {t.get('camelot', '?')} | Energy: {t.get('energy', '?')}" + ) + + style_desc = { + "progressive": "gradually build energy from low to high, creating a journey", + "peak-time": "maintain high energy throughout with dramatic moments", + "chill": "keep energy low to medium, prioritizing smooth vibes", + "journey": "create a wave pattern - build up, peak, come down, build again" + }.get(style, "gradually build energy") + + prompt = f"""You are an expert DJ creating an optimal setlist. Analyze these tracks and order them for the best flow. + +TRACKS: +{chr(10).join(track_summary)} + +GOAL: {style_desc} + +MIXING RULES: +1. Harmonically compatible keys mix best (same Camelot number, or ±1, or A↔B switch) +2. Keep BPM changes within ±8 BPM between tracks for smooth mixing +3. Energy should follow the style pattern +4. Consider musical "story" - intro, build, peak, outro + +DJ TECHNIQUES TO SUGGEST: +- "Long Blend" - 16-32 bar crossfade with EQ swapping +- "Filter Sweep" - Use low-pass or high-pass filter on outgoing track +- "Echo Out" - Apply echo/delay while fading out +- "Hard Cut" - Quick switch on phrase start (for genre changes or impact) +- "Beat Drop" - Drop incoming track on a breakdown/drop +- "EQ Swap" - Gradually swap bass/mids/highs between tracks +- "Loop & Build" - Loop outgoing track while bringing in new one +- "Acapella Blend" - If vocal track, layer over instrumental + +Respond ONLY with valid JSON in this exact format: +{{ + "order": [1, 3, 2, ...], + "tips": [ + {{ + "from": 1, + "to": 3, + "technique": "Filter Sweep", + "timing": "16 bars", + "tip": "Filter out the bass of track 1, bring in track 3 on the drop" + }}, + ... + ] +}}""" + + try: + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON from response + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + order = data.get("order", []) + tips = data.get("tips", []) + + # Map back to track IDs + ordered_ids = [] + for idx in order: + if 1 <= idx <= len(tracks): + ordered_ids.append(tracks[idx - 1].get("id")) + + # Map tips to track IDs + suggestions = [] + for tip in tips: + from_idx = tip.get("from", 0) + to_idx = tip.get("to", 0) + if 1 <= from_idx <= len(tracks) and 1 <= to_idx <= len(tracks): + t1 = tracks[from_idx - 1] + t2 = tracks[to_idx - 1] + suggestions.append({ + "from_id": t1.get("id"), + "to_id": t2.get("id"), + "harmonic_match": self.is_harmonically_compatible( + t1.get("camelot", "?"), t2.get("camelot", "?") + ), + "bpm_diff": abs(t2.get("bpm", 0) - t1.get("bpm", 0)), + "technique": tip.get("technique", ""), + "timing": tip.get("timing", ""), + "tip": tip.get("tip", "") + }) + + logger.info(f"AI generated setlist with {len(ordered_ids)} tracks") + return { + "ordered_ids": ordered_ids, + "suggestions": suggestions, + "method": "ai-gemini-2.0-flash" + } + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse AI response as JSON: {e}") + return None + except Exception as e: + logger.error(f"AI generation error: {e}") + return None + + + return None + + async def get_audio_features_ai(self, name: str, artist: str) -> Optional[Dict[str, Any]]: + """ + Estimate audio features using AI when Spotify data is unavailable. + """ + if not self._init_genai() or not self._model: + return None + + prompt = f"""Act as an expert musicologist and DJ. +Provide the OFFICIAL studio audio analysis for the track: +Title: "{name}" +Artist: "{artist}" + +Analyze the genre and style. (e.g. Dubstep/Mid-tempo is usually 90-110 BPM, House is 120-130). +Provide the most accurate: +1. BPM (Integer) - Check for half-time/double-time ambiguities. +2. Key (Camelot Notation, e.g. 5A, 11B) +3. Energy (0.0 to 1.0) + +Respond ONLY with valid JSON: +{{ + "bpm": 100, + "camelot": "5A", + "energy": 0.8 +}}""" + + try: + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + import json + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + + # Validate + return { + "track_id": f"ai_{abs(hash(name + artist))}", # Dummy ID + "bpm": int(data.get("bpm", 120)), + "camelot": data.get("camelot", "?"), + "energy": float(data.get("energy", 0.5)), + "key": -1, # Unknown + "mode": 0, + "danceability": 0.5, + "valence": 0.5, + "source": "ai_estimate" + } + + except Exception as e: + logger.warning(f"AI audio features estimation failed (using fallback): {e}") + return None + + async def interpret_mood_query(self, query: str) -> Optional[Dict[str, Any]]: + """ + Interpret a natural language mood query using AI. + Returns structured search terms and mood metadata. + """ + if not self._init_genai() or not self._model: + return None + + prompt = f"""You are a music discovery AI. The user wants to find music based on a mood or vibe description. + +USER QUERY: "{query}" + +Interpret this mood/vibe and provide: +1. 3-5 search terms that would find matching songs (artist names, genres, song characteristics) +2. Mood keywords that describe this vibe +3. Suggested BPM range +4. Energy level (low, medium, high) + +Respond ONLY with valid JSON: +{{ + "search_terms": ["term1", "term2", "term3"], + "moods": ["chill", "relaxed"], + "bpm_range": {{"min": 70, "max": 100}}, + "energy": "low", + "description": "Brief 1-sentence description of the vibe" +}}""" + + try: + import json + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + logger.info(f"AI interpreted mood query: {query} -> {data.get('search_terms', [])}") + return data + + except Exception as e: + logger.warning(f"AI mood interpretation failed: {e}") + return None + + +# Singleton instance +dj_service = DJService() diff --git a/app/genius_service.py b/app/genius_service.py new file mode 100644 index 0000000..8eaa134 --- /dev/null +++ b/app/genius_service.py @@ -0,0 +1,241 @@ +""" +Genius service for Freedify. +Provides lyrics, annotations, and song information from Genius. +API docs: https://docs.genius.com/ +""" +import os +import re +import httpx +from typing import Optional, Dict, Any +import logging +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + + +class GeniusService: + """Service for fetching lyrics and annotations from Genius.""" + + API_BASE = "https://api.genius.com" + + def __init__(self): + # Access token: use env var (required for production) + self.access_token = os.environ.get("GENIUS_ACCESS_TOKEN", "") + if not self.access_token: + logger.warning("GENIUS_ACCESS_TOKEN not set - lyrics will not work") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make authenticated API request to Genius.""" + headers = {"Authorization": f"Bearer {self.access_token}"} + if params is None: + params = {} + + response = await self.client.get( + f"{self.API_BASE}{endpoint}", + headers=headers, + params=params + ) + response.raise_for_status() + return response.json() + + async def search_song(self, query: str) -> Optional[Dict[str, Any]]: + """Search for a song on Genius. Returns the best match.""" + try: + data = await self._api_request("/search", {"q": query}) + hits = data.get("response", {}).get("hits", []) + + # Find first song result + for hit in hits: + if hit.get("type") == "song": + song = hit.get("result", {}) + return { + "id": song.get("id"), + "title": song.get("title"), + "artist": song.get("primary_artist", {}).get("name"), + "url": song.get("url"), + "thumbnail": song.get("song_art_image_thumbnail_url"), + "full_title": song.get("full_title"), + } + return None + except Exception as e: + logger.error(f"Genius search error: {e}") + return None + + async def get_song_details(self, song_id: int) -> Optional[Dict[str, Any]]: + """Get detailed song information including annotations.""" + try: + data = await self._api_request(f"/songs/{song_id}") + song = data.get("response", {}).get("song", {}) + + # Extract useful info + description = song.get("description", {}) + if isinstance(description, dict): + description_text = description.get("plain", "") + else: + description_text = str(description) if description else "" + + return { + "id": song.get("id"), + "title": song.get("title"), + "artist": song.get("primary_artist", {}).get("name"), + "album": song.get("album", {}).get("name") if song.get("album") else None, + "release_date": song.get("release_date_for_display"), + "url": song.get("url"), + "thumbnail": song.get("song_art_image_url"), + "description": description_text, + "apple_music_id": song.get("apple_music_id"), + "recording_location": song.get("recording_location"), + "producer_artists": [p.get("name") for p in song.get("producer_artists", [])], + "writer_artists": [w.get("name") for w in song.get("writer_artists", [])], + "featured_artists": [f.get("name") for f in song.get("featured_artists", [])], + } + except Exception as e: + logger.error(f"Genius song details error: {e}") + return None + + async def scrape_lyrics(self, genius_url: str) -> Optional[str]: + """Scrape lyrics from a Genius song page.""" + try: + # Fetch the page + response = await self.client.get(genius_url, follow_redirects=True) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # Genius uses data-lyrics-container for lyrics sections + lyrics_containers = soup.find_all("div", {"data-lyrics-container": "true"}) + + if lyrics_containers: + lyrics_parts = [] + for container in lyrics_containers: + # Get text, preserving line breaks + for br in container.find_all("br"): + br.replace_with("\n") + lyrics_parts.append(container.get_text()) + + lyrics = "\n".join(lyrics_parts) + # Clean up extra whitespace + lyrics = re.sub(r'\n{3,}', '\n\n', lyrics) + return lyrics.strip() + + # Fallback: try older format + lyrics_div = soup.find("div", class_="lyrics") + if lyrics_div: + return lyrics_div.get_text().strip() + + logger.warning(f"Could not find lyrics on page: {genius_url}") + return None + + except Exception as e: + logger.error(f"Genius lyrics scrape error: {e}") + return None + + async def get_song_referents(self, song_id: int) -> list: + """Get annotations for a song using the Genius API referents endpoint.""" + annotations = [] + try: + # Use API to get referents (annotated sections) + data = await self._api_request(f"/referents", { + "song_id": song_id, + "text_format": "plain", + "per_page": 20 + }) + + referents = data.get("response", {}).get("referents", []) + + for ref in referents[:15]: # Limit to 15 annotations + fragment = ref.get("fragment", "") + annotation_list = ref.get("annotations", []) + + for ann in annotation_list: + # Get the annotation body + body = ann.get("body", {}) + if isinstance(body, dict): + plain_text = body.get("plain", "") + else: + plain_text = str(body) if body else "" + + # Also get the annotation state/votes for quality filtering + votes_total = ann.get("votes_total", 0) + + if plain_text and len(plain_text) > 10: + annotations.append({ + "fragment": fragment[:150] + "..." if len(fragment) > 150 else fragment, + "text": plain_text, + "votes": votes_total + }) + + # Sort by votes (most upvoted first) + annotations.sort(key=lambda x: x.get("votes", 0), reverse=True) + + return annotations + + except Exception as e: + logger.error(f"Genius referents API error: {e}") + return [] + + async def get_lyrics_and_info(self, artist: str, title: str) -> Dict[str, Any]: + """ + Main method: Search for a song, get lyrics and details. + Returns a dict with lyrics, about info, annotations, and metadata. + """ + result = { + "found": False, + "lyrics": None, + "title": title, + "artist": artist, + "about": None, + "album": None, + "release_date": None, + "producers": [], + "writers": [], + "annotations": [], + "genius_url": None, + "thumbnail": None, + } + + # Search for the song + query = f"{artist} {title}" + song = await self.search_song(query) + + if not song: + logger.info(f"No Genius match for: {query}") + return result + + result["found"] = True + result["genius_url"] = song.get("url") + result["thumbnail"] = song.get("thumbnail") + result["title"] = song.get("title", title) + result["artist"] = song.get("artist", artist) + + # Get detailed info + song_id = song.get("id") + if song_id: + details = await self.get_song_details(song_id) + if details: + result["about"] = details.get("description") + result["album"] = details.get("album") + result["release_date"] = details.get("release_date") + result["producers"] = details.get("producer_artists", []) + result["writers"] = details.get("writer_artists", []) + + # Scrape lyrics + if song.get("url"): + lyrics = await self.scrape_lyrics(song["url"]) + result["lyrics"] = lyrics + + # Get annotations via API (requires song_id) + if song_id: + annotations = await self.get_song_referents(song_id) + result["annotations"] = annotations + + return result + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +genius_service = GeniusService() diff --git a/app/jamendo_service.py b/app/jamendo_service.py new file mode 100644 index 0000000..143e75e --- /dev/null +++ b/app/jamendo_service.py @@ -0,0 +1,254 @@ +""" +Jamendo service for Freedify. +Provides access to 600,000+ independent and Creative Commons licensed tracks. +Jamendo API docs: https://developer.jamendo.com/v3.0 +""" +import os +import httpx +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class JamendoService: + """Service for searching and streaming from Jamendo.""" + + API_BASE = "https://api.jamendo.com/v3.0" + + def __init__(self): + # Client ID: use env var or fallback for local testing + self.client_id = os.environ.get("JAMENDO_CLIENT_ID", "90aefcef") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make API request to Jamendo.""" + if params is None: + params = {} + params["client_id"] = self.client_id + params["format"] = "json" + + response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params) + response.raise_for_status() + return response.json() + + # ========== TRACK METHODS ========== + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for tracks.""" + data = await self._api_request("/tracks/", { + "search": query, + "limit": limit, + "offset": offset, + "include": "musicinfo licenses", + "audioformat": "flac", # Request FLAC URLs + }) + return [self._format_track(item) for item in data.get("results", [])] + + async def get_track(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get single track details.""" + try: + clean_id = track_id.replace("jm_", "") + data = await self._api_request("/tracks/", { + "id": clean_id, + "include": "musicinfo licenses", + "audioformat": "flac", + }) + results = data.get("results", []) + if results: + return self._format_track(results[0]) + return None + except Exception as e: + logger.error(f"Error fetching Jamendo track {track_id}: {e}") + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend (matching Spotify/Deezer format).""" + # Get best quality audio URL (prefer FLAC, fallback to MP3) + audio_url = item.get("audiodownload") or item.get("audio") or "" + + # Jamendo returns audio URL with format parameter + # We'll use the direct audio field which respects audioformat param + return { + "id": f"jm_{item['id']}", + "type": "track", + "name": item.get("name", ""), + "artists": item.get("artist_name", ""), + "artist_names": [item.get("artist_name", "")], + "artist_id": f"jm_artist_{item.get('artist_id', '')}", + "album": item.get("album_name", ""), + "album_id": f"jm_{item.get('album_id', '')}", + "album_art": item.get("album_image") or item.get("image") or "", + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "audio_url": audio_url, # Direct stream URL + "license": item.get("license_ccurl", ""), + "release_date": item.get("releasedate", ""), + "source": "jamendo", + "format": "flac" if "flac" in audio_url.lower() else "mp3", + } + + # ========== ALBUM METHODS ========== + + async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for albums.""" + data = await self._api_request("/albums/", { + "search": query, + "limit": limit, + "offset": offset, + }) + return [self._format_album(item) for item in data.get("results", [])] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + clean_id = album_id.replace("jm_", "") + + # Get album info + album_data = await self._api_request("/albums/", {"id": clean_id}) + albums = album_data.get("results", []) + if not albums: + return None + + album = self._format_album(albums[0]) + + # Get album tracks + tracks_data = await self._api_request("/albums/tracks/", { + "id": clean_id, + "audioformat": "flac", + }) + + tracks = [] + for item in tracks_data.get("results", []): + for track in item.get("tracks", []): + track["album_name"] = album["name"] + track["album_image"] = album["album_art"] + track["album_id"] = clean_id + track["artist_name"] = album["artists"] + track["artist_id"] = albums[0].get("artist_id", "") + tracks.append(self._format_track(track)) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Jamendo album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + return { + "id": f"jm_{item['id']}", + "type": "album", + "name": item.get("name", ""), + "artists": item.get("artist_name", ""), + "artist_id": f"jm_artist_{item.get('artist_id', '')}", + "album_art": item.get("image") or "", + "release_date": item.get("releasedate", ""), + "total_tracks": 0, # Not always provided + "source": "jamendo", + } + + # ========== ARTIST METHODS ========== + + async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for artists.""" + data = await self._api_request("/artists/", { + "search": query, + "limit": limit, + "offset": offset, + }) + return [self._format_artist(item) for item in data.get("results", [])] + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + clean_id = artist_id.replace("jm_artist_", "").replace("jm_", "") + + # Get artist info + artist_data = await self._api_request("/artists/", {"id": clean_id}) + artists = artist_data.get("results", []) + if not artists: + return None + + artist = self._format_artist(artists[0]) + + # Get artist's tracks + tracks_data = await self._api_request("/artists/tracks/", { + "id": clean_id, + "limit": 20, + "audioformat": "flac", + }) + + tracks = [] + for item in tracks_data.get("results", []): + for track in item.get("tracks", []): + track["artist_name"] = artist["name"] + track["artist_id"] = clean_id + tracks.append(self._format_track(track)) + + artist["tracks"] = tracks + return artist + except Exception as e: + logger.error(f"Error fetching Jamendo artist {artist_id}: {e}") + return None + + def _format_artist(self, item: dict) -> dict: + """Format artist data for frontend.""" + return { + "id": f"jm_artist_{item['id']}", + "type": "artist", + "name": item.get("name", ""), + "image": item.get("image") or "", + "website": item.get("website", ""), + "source": "jamendo", + } + + # ========== STREAM URL ========== + + async def get_stream_url(self, track_id: str, prefer_flac: bool = True) -> Optional[str]: + """Get direct stream URL for a track. Tries FLAC first, falls back to MP3.""" + try: + clean_id = track_id.replace("jm_", "") + + # Try FLAC first + if prefer_flac: + data = await self._api_request("/tracks/", { + "id": clean_id, + "audioformat": "flac", + }) + results = data.get("results", []) + if results: + url = results[0].get("audiodownload") or results[0].get("audio") + if url: + return url + + # Fallback to MP3 (mp32 = VBR good quality) + data = await self._api_request("/tracks/", { + "id": clean_id, + "audioformat": "mp32", + }) + results = data.get("results", []) + if results: + return results[0].get("audiodownload") or results[0].get("audio") + + return None + except Exception as e: + logger.error(f"Error getting Jamendo stream URL for {track_id}: {e}") + return None + + # ========== UTILITIES ========== + + def _format_duration(self, ms: int) -> str: + """Format duration from ms to MM:SS.""" + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +jamendo_service = JamendoService() diff --git a/app/listenbrainz_service.py b/app/listenbrainz_service.py new file mode 100644 index 0000000..24f28d8 --- /dev/null +++ b/app/listenbrainz_service.py @@ -0,0 +1,409 @@ +""" +ListenBrainz service for Freedify. +Handles scrobbling (listening history) and personalized recommendations. +""" +import os +import time +import httpx +from typing import Optional, Dict, List, Any +import logging +from app.musicbrainz_service import musicbrainz_service + +logger = logging.getLogger(__name__) + +# User token from environment (can also be set via frontend settings) +LISTENBRAINZ_TOKEN = os.getenv("LISTENBRAINZ_TOKEN") + + +class ListenBrainzService: + """Service for ListenBrainz scrobbling and recommendations.""" + + API_BASE = "https://api.listenbrainz.org" + + def __init__(self): + self.token = LISTENBRAINZ_TOKEN + self.client = httpx.AsyncClient(timeout=15.0) + + def set_token(self, token: str): + """Set user token (from settings UI).""" + self.token = token + + def is_configured(self) -> bool: + """Check if ListenBrainz token is configured.""" + return bool(self.token) + + def _get_headers(self) -> dict: + """Get headers with authorization.""" + return { + "Authorization": f"Token {self.token}", + "Content-Type": "application/json" + } + + async def submit_now_playing(self, track: Dict[str, Any]) -> bool: + """Submit 'now playing' status when a track starts. + + Args: + track: Track info with name, artists, album, duration_ms + """ + if not self.is_configured(): + logger.debug("ListenBrainz not configured, skipping now playing") + return False + + try: + payload = { + "listen_type": "playing_now", + "payload": [self._format_track_payload(track)] + } + + response = await self.client.post( + f"{self.API_BASE}/1/submit-listens", + headers=self._get_headers(), + json=payload + ) + + if response.status_code == 200: + logger.info(f"ListenBrainz now playing: {track.get('name')}") + return True + else: + logger.warning(f"ListenBrainz now playing failed: {response.status_code}") + return False + + except Exception as e: + logger.error(f"ListenBrainz now playing error: {e}") + return False + + async def submit_listen(self, track: Dict[str, Any], listened_at: Optional[int] = None) -> bool: + """Submit a completed listen (scrobble). + + Should be called after user listens to 50% of track or 4 minutes, whichever is shorter. + + Args: + track: Track info with name, artists, album, duration_ms + listened_at: Unix timestamp when listening started (defaults to now) + """ + if not self.is_configured(): + logger.debug("ListenBrainz not configured, skipping scrobble") + return False + + try: + track_payload = self._format_track_payload(track) + track_payload["listened_at"] = listened_at or int(time.time()) + + payload = { + "listen_type": "single", + "payload": [track_payload] + } + + response = await self.client.post( + f"{self.API_BASE}/1/submit-listens", + headers=self._get_headers(), + json=payload + ) + + if response.status_code == 200: + logger.info(f"ListenBrainz scrobbled: {track.get('name')}") + return True + else: + logger.warning(f"ListenBrainz scrobble failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"ListenBrainz scrobble error: {e}") + return False + + def _format_track_payload(self, track: Dict[str, Any]) -> dict: + """Format track data for ListenBrainz API.""" + # Get artist name (handle both string and list formats) + artist = track.get("artists", "") + if isinstance(artist, list): + artist = ", ".join(artist) + + additional_info = {} + + # Add duration if available + duration_ms = track.get("duration_ms") + if duration_ms: + additional_info["duration_ms"] = duration_ms + + # Add release name (album) + if track.get("album"): + additional_info["release_name"] = track["album"] + + # Add ISRC if available (helps with MusicBrainz matching) + if track.get("isrc") and not track["isrc"].startswith(("dz_", "ytm_", "LINK:", "pod_")): + additional_info["isrc"] = track["isrc"] + + # Add track number if available + if track.get("track_number"): + additional_info["tracknumber"] = track["track_number"] + + return { + "track_metadata": { + "artist_name": artist, + "track_name": track.get("name", "Unknown"), + "additional_info": additional_info if additional_info else None + } + } + + async def get_recommendations(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get personalized recommendations for a user. + + Note: Recommendations are generated weekly by ListenBrainz based on listening history. + + Args: + username: ListenBrainz username + count: Number of recommendations to fetch + """ + try: + response = await self.client.get( + f"{self.API_BASE}/1/cf/recommendation/recording/{username}", + params={"count": count} + ) + + if response.status_code != 200: + logger.warning(f"ListenBrainz recommendations failed: {response.status_code}") + return [] + + data = response.json() + payload = data.get("payload", {}) + + recommendations = [] + mbids = [rec.get("recording_mbid") for rec in payload.get("mbids", [])[:15]] # Limit to 15 for performance + + for mbid in mbids: + if not mbid: continue + # Lookup metadata from MusicBrainz + track_data = await musicbrainz_service.lookup_recording(mbid) + if track_data: + track_data["type"] = "recommendation" + track_data["source"] = "listenbrainz" + recommendations.append(track_data) + + return recommendations + + except Exception as e: + logger.error(f"ListenBrainz recommendations error: {e}") + return [] + + async def get_user_listens(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get recent listens for a user.""" + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/listens", + params={"count": count} + ) + + if response.status_code != 200: + return [] + + data = response.json() + listens = data.get("payload", {}).get("listens", []) + + return [{ + "track_name": l.get("track_metadata", {}).get("track_name"), + "artist_name": l.get("track_metadata", {}).get("artist_name"), + "listened_at": l.get("listened_at"), + "source": "listenbrainz" + } for l in listens] + + except Exception as e: + logger.error(f"ListenBrainz get listens error: {e}") + return [] + + async def validate_token(self) -> Optional[str]: + """Validate token and return username if valid.""" + if not self.is_configured(): + return None + + try: + response = await self.client.get( + f"{self.API_BASE}/1/validate-token", + headers=self._get_headers() + ) + + if response.status_code == 200: + data = response.json() + if data.get("valid"): + return data.get("user_name") + + return None + + except Exception as e: + logger.error(f"ListenBrainz token validation error: {e}") + return None + + async def get_user_playlists(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get user's playlists from ListenBrainz (includes Weekly Exploration).""" + formatted = [] + + # Fetch user-created playlists + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/playlists", + params={"count": count} + ) + + if response.status_code == 200: + data = response.json() + playlists = data.get("playlists", []) + formatted.extend(self._format_playlists(playlists, username)) + except Exception as e: + logger.error(f"ListenBrainz user playlists error: {e}") + + # Fetch "created-for" playlists (Weekly Exploration, Daily Jam, etc.) + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/playlists/createdfor", + params={"count": count} + ) + + if response.status_code == 200: + data = response.json() + playlists = data.get("playlists", []) + # Add these at the beginning since they're the most interesting + formatted = self._format_playlists(playlists, username, is_generated=True) + formatted + except Exception as e: + logger.error(f"ListenBrainz created-for playlists error: {e}") + + return formatted + + async def get_user_stats(self, username: str) -> Dict[str, Any]: + """Get user's listening statistics from ListenBrainz.""" + stats = { + "listen_count": 0, + "top_artists": [], + "top_releases": [], + "username": username + } + + try: + # Get total listen count + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/listen-count" + ) + if response.status_code == 200: + data = response.json() + stats["listen_count"] = data.get("payload", {}).get("count", 0) + except Exception as e: + logger.warning(f"ListenBrainz listen count error: {e}") + + try: + # Get top artists - try this_week first, fall back to all_time + for time_range in ["this_week", "all_time"]: + response = await self.client.get( + f"{self.API_BASE}/1/stats/user/{username}/artists", + params={"count": 5, "range": time_range} + ) + logger.info(f"LB stats for {username} ({time_range}): {response.status_code}") + + if response.status_code == 200: + data = response.json() + artists = data.get("payload", {}).get("artists", []) + if artists: + stats["top_artists"] = [ + { + "name": a.get("artist_name", "Unknown"), + "count": a.get("listen_count", 0) + } + for a in artists[:5] + ] + break # Found artists, stop trying + elif response.status_code == 204: + # No content = no stats available yet + logger.info(f"No {time_range} stats available for {username}") + except Exception as e: + logger.warning(f"ListenBrainz top artists error: {e}") + + return stats + + def _format_playlists(self, playlists: list, username: str, is_generated: bool = False) -> List[Dict[str, Any]]: + """Format playlist data from ListenBrainz API response.""" + formatted = [] + for p in playlists: + playlist = p.get("playlist", {}) + # Extract playlist MBID from identifier URL + identifier = playlist.get("identifier", "") + playlist_id = identifier.split("/")[-1] if identifier else "" + + name = playlist.get("title", "Untitled Playlist") + + formatted.append({ + "id": f"lb_{playlist_id}", + "type": "album", # Treat as album for UI compatibility + "name": name, + "artists": playlist.get("creator", username), + "description": playlist.get("annotation", "")[:150] if playlist.get("annotation") else "", + "album_art": "/static/icon.svg", # LB playlists don't have artwork + "total_tracks": len(playlist.get("track", [])), + "source": "listenbrainz", + "is_playlist": True, + "is_generated": is_generated # True for Weekly Exploration, Daily Jam, etc. + }) + + return formatted + + async def get_playlist_tracks(self, playlist_id: str) -> Optional[Dict[str, Any]]: + """Get tracks from a ListenBrainz playlist.""" + try: + # Remove lb_ prefix if present + clean_id = playlist_id.replace("lb_", "") + + response = await self.client.get( + f"{self.API_BASE}/1/playlist/{clean_id}" + ) + + if response.status_code != 200: + logger.warning(f"ListenBrainz playlist fetch failed: {response.status_code}") + return None + + data = response.json() + playlist = data.get("playlist", {}) + + # Parse JSPF tracks + jspf_tracks = playlist.get("track", []) + tracks = [] + + for i, t in enumerate(jspf_tracks): + # Extract artist and title from JSPF + artist = t.get("creator", "Unknown Artist") + title = t.get("title", "Unknown Track") + + # Build search query for audio lookup + search_query = f"{artist} - {title}" + + # Create a searchable track object + # Use the search query as the track "isrc" so the audio service can find it + tracks.append({ + "id": f"query:{search_query}", # This will trigger a search + "type": "track", + "name": title, + "artists": artist, + "album": playlist.get("title", "ListenBrainz Playlist"), + "album_art": "/static/icon.svg", + "duration": "0:00", # Duration not in JSPF + "isrc": f"query:{search_query}", # Audio service will search by name + "source": "listenbrainz" + }) + + return { + "id": f"lb_{clean_id}", + "type": "album", + "name": playlist.get("title", "ListenBrainz Playlist"), + "artists": playlist.get("creator", "ListenBrainz"), + "album_art": "/static/icon.svg", + "tracks": tracks, + "total_tracks": len(tracks), + "source": "listenbrainz" + } + + except Exception as e: + logger.error(f"ListenBrainz playlist tracks error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +listenbrainz_service = ListenBrainzService() diff --git a/app/live_show_service.py b/app/live_show_service.py new file mode 100644 index 0000000..f05c521 --- /dev/null +++ b/app/live_show_service.py @@ -0,0 +1,206 @@ +""" +Live Show Search Service for Freedify. +Searches Phish.in for Phish shows and Archive.org for other jam bands. +""" +import httpx +import re +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +# Bands that have shows on Archive.org Live Music Archive +ARCHIVE_BANDS = { + "grateful dead": "GratefulDead", + "dead": "GratefulDead", + "gd": "GratefulDead", + "billy strings": "BillyStrings", + "ween": "Ween", + "king gizzard": "KingGizzardAndTheLizardWizard", + "king gizzard & the lizard wizard": "KingGizzardAndTheLizardWizard", + "king gizzard and the lizard wizard": "KingGizzardAndTheLizardWizard", + "kglw": "KingGizzardAndTheLizardWizard", +} + + +class LiveShowService: + """Service for searching live show archives.""" + + PHISH_API = "https://phish.in/api/v2" + ARCHIVE_API = "https://archive.org/advancedsearch.php" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0) + + def detect_live_search(self, query: str) -> Optional[Dict[str, Any]]: + """ + Detect if a search query is looking for live shows. + Returns dict with band, year, month if found, else None. + + Examples: + - "Phish 2025" -> {"band": "phish", "year": "2025", "month": None} + - "Phish 2024/12" -> {"band": "phish", "year": "2024", "month": "12"} + - "Grateful Dead 1977" -> {"band": "grateful dead", "year": "1977", "month": None} + """ + query_lower = query.lower().strip() + + # Pattern: band name + year or year/month + # e.g., "Phish 2025", "Grateful Dead 1977/05", "Billy Strings 2023-08" + pattern = r'^(phish|grateful dead|dead|gd|billy strings|ween|king gizzard.*?|kglw)\s+(\d{4})(?:[/-](\d{1,2}))?$' + + match = re.match(pattern, query_lower) + if match: + band = match.group(1) + year = match.group(2) + month = match.group(3) + return { + "band": band, + "year": year, + "month": month.zfill(2) if month else None + } + return None + + async def search_phish_shows(self, year: str, month: str = None) -> List[Dict[str, Any]]: + """Search Phish.in for shows by year/month.""" + try: + # Phish.in API endpoint for shows by year + url = f"{self.PHISH_API}/shows" + params = {"year": year} + + response = await self.client.get(url, params=params) + if response.status_code != 200: + logger.warning(f"Phish.in API returned {response.status_code}") + return [] + + data = response.json() + # API v2 returns {'data': [...]} or {'shows': [...]} (observed 'shows' in testing) + shows = data.get('data', []) or data.get('shows', []) + + # Filter by month if specified + if month: + shows = [s for s in shows if s.get("date", "").startswith(f"{year}-{month}")] + + # Format as albums for the UI + results = [] + for show in shows[:20]: # Limit to 20 + date = show.get("date", "") + venue = show.get("venue", {}) + venue_name = venue.get("name", "") if isinstance(venue, dict) else str(venue) + location = venue.get("location", "") if isinstance(venue, dict) else "" + + results.append({ + "id": f"phish_{date}", + "type": "album", + "name": f"Phish - {date}", + "artists": "Phish", + "album_art": "/static/icon.svg", # phish.in logo 404s, use local icon + "release_date": date, + "description": f"{venue_name}, {location}" if location else venue_name, + "total_tracks": show.get("tracks_count", 0), + "source": "phish.in", + "import_url": f"https://phish.in/{date}", + }) + + return results + except Exception as e: + logger.error(f"Phish.in search error: {e}") + return [] + + async def search_archive_shows(self, band: str, year: str, month: str = None) -> List[Dict[str, Any]]: + """Search Archive.org Live Music Archive for shows.""" + try: + # Get the Archive.org collection name + band_lower = band.lower() + collection = None + for key, val in ARCHIVE_BANDS.items(): + if key in band_lower or band_lower in key: + collection = val + break + + if not collection: + return [] + + # Build Archive.org search query + date_query = f"{year}-{month}" if month else year + query = f'collection:{collection} AND date:{date_query}* AND mediatype:etree' + + params = { + "q": query, + "fl[]": ["identifier", "title", "date", "venue", "coverage", "description"], + "sort[]": "date asc", + "rows": 20, + "output": "json", + } + + response = await self.client.get(self.ARCHIVE_API, params=params) + if response.status_code != 200: + logger.warning(f"Archive.org API returned {response.status_code}") + return [] + + data = response.json() + docs = data.get("response", {}).get("docs", []) + + # Map band collection to display name + band_names = { + "GratefulDead": "Grateful Dead", + "BillyStrings": "Billy Strings", + "Ween": "Ween", + "KingGizzardAndTheLizardWizard": "King Gizzard & The Lizard Wizard", + } + display_name = band_names.get(collection, collection) + + results = [] + for doc in docs: + identifier = doc.get("identifier", "") + date = doc.get("date", "")[:10] if doc.get("date") else "" + title = doc.get("title", f"{display_name} - {date}") + venue = doc.get("venue", "") + location = doc.get("coverage", "") + + results.append({ + "id": f"archive_{identifier}", + "type": "album", + "name": title if title else f"{display_name} - {date}", + "artists": display_name, + "album_art": f"https://archive.org/services/img/{identifier}", + "release_date": date, + "description": f"{venue}, {location}" if venue and location else (venue or location or ""), + "source": "archive.org", + "import_url": f"https://archive.org/details/{identifier}", + }) + + return results + except Exception as e: + logger.error(f"Archive.org search error: {e}") + return [] + + async def search_live_shows(self, query: str) -> Optional[List[Dict[str, Any]]]: + """ + Main entry point - detect if query is for live shows and search appropriate source. + Returns None if not a live show query. + """ + detected = self.detect_live_search(query) + if not detected: + return None + + band = detected["band"] + year = detected["year"] + month = detected["month"] + + # Phish -> use phish.in + if band == "phish": + logger.info(f"Searching Phish.in for {year}" + (f"/{month}" if month else "")) + return await self.search_phish_shows(year, month) + + # Other bands -> use Archive.org + logger.info(f"Searching Archive.org for {band} {year}" + (f"/{month}" if month else "")) + return await self.search_archive_shows(band, year, month) + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +live_show_service = LiveShowService() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..5dc9ffb --- /dev/null +++ b/app/main.py @@ -0,0 +1,1220 @@ +""" +Freedify Streaming Server +A FastAPI server for streaming music with FFmpeg transcoding. +""" +import os +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, HTTPException, Query, Response, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import zipfile +import io +from typing import List +import httpx + + +from app.deezer_service import deezer_service +from app.live_show_service import live_show_service +from app.spotify_service import spotify_service +from app.audio_service import audio_service +from app.podcast_service import podcast_service +from app.dj_service import dj_service +from app.ai_radio_service import ai_radio_service +from app.ytmusic_service import ytmusic_service +from app.setlist_service import setlist_service +from app.listenbrainz_service import listenbrainz_service +from app.jamendo_service import jamendo_service +from app.genius_service import genius_service +from app.concert_service import concert_service + +from app.cache import cleanup_cache, periodic_cleanup, is_cached, get_cache_path + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager.""" + logger.info("Starting Freedify Streaming Server...") + + # Initial cache cleanup + await cleanup_cache() + + # Start periodic cleanup task + cleanup_task = asyncio.create_task(periodic_cleanup(30)) + + yield + + # Cleanup on shutdown + cleanup_task.cancel() + await deezer_service.close() + await live_show_service.close() + await spotify_service.close() + await audio_service.close() + await podcast_service.close() + logger.info("Server shutdown complete.") + + +app = FastAPI( + title="Freedify Streaming", + description="Stream music from Deezer, Spotify URLs, and Live Archives", + lifespan=lifespan +) + +# CORS for mobile access +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Middleware to set COOP header for Google OAuth popups +@app.middleware("http") +async def add_security_headers(request, call_next): + response = await call_next(request) + # Allow popups (like Google Sign-In) to communicate with window + response.headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups" + return response + + +# ========== MODELS ========== + +class ParseUrlRequest(BaseModel): + url: str + +class ImportRequest(BaseModel): + url: str + + +# ========== API ENDPOINTS ========== + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "service": "freedify-streaming"} + + +@app.get("/api/config") +async def get_config(): + """Get public configuration for the frontend (like Google Client ID).""" + return { + "google_client_id": os.environ.get("GOOGLE_CLIENT_ID", ""), + } + + +@app.get("/api/spotify/made-for-you") +async def get_spotify_made_for_you(): + """Get Spotify 'Made For You' playlists (Daily Mix, Discover Weekly).""" + return await spotify_service.get_made_for_you_playlists() + +@app.get("/api/search") +async def search( + q: str = Query(..., min_length=1, description="Search query"), + type: str = Query("track", description="Search type: track, album, artist, or podcast"), + offset: int = Query(0, description="Offset for pagination") +): + """Search for tracks, albums, artists, or podcasts.""" + try: + # Check for Spotify URL (uses Spotify API - may be rate limited) + if spotify_service.is_spotify_url(q): + parsed = spotify_service.parse_spotify_url(q) + if parsed: + url_type, item_id = parsed + logger.info(f"Detected Spotify URL: {url_type}/{item_id}") + try: + return await get_spotify_content(url_type, item_id) + except HTTPException as e: + # If Spotify fails (rate limited), return error with info + raise HTTPException( + status_code=503, + detail=str(e.detail) + ) + + # Check for other URLs (Bandcamp, Soundcloud, Phish.in, Archive.org, etc.) + if q.startswith("http://") or q.startswith("https://"): + logger.info(f"Detected URL: {q}") + item = await audio_service.import_url(q) + if item: + # Check if it's an album/playlist + if item.get('type') == 'album': + return { + "results": [item], + "type": "album", + "is_url": True, + "source": "import", + "tracks": item.get('tracks', []) + } + # Single track + return {"results": [item], "type": "track", "is_url": True, "source": "import"} + # Podcast Search + if type == "podcast": + results = await podcast_service.search_podcasts(q) + return {"results": results, "query": q, "type": "podcast", "source": "podcast", "offset": offset} + + # YouTube Music Search + if type == "ytmusic": + results = await ytmusic_service.search_tracks(q, limit=20, offset=offset) + return {"results": results, "query": q, "type": "track", "source": "ytmusic", "offset": offset} + + # Setlist.fm Search + if type == "setlist": + results = await setlist_service.search_setlists(q) + return {"results": results, "query": q, "type": "album", "source": "setlist.fm", "offset": offset} + + # Check for live show searches FIRST if no type specified or type is album + # But only if NOT one of the special types above (which returned already) + live_results = await live_show_service.search_live_shows(q) + if live_results is not None: + return {"results": live_results, "query": q, "type": "album", "source": "live_shows"} + + # Regular search - Use Dab Music (Priority) then Deezer + logger.info(f"Searching: {q} (type: {type}, offset: {offset})") + + results = [] + source = "deezer" + + # 1. Try Dab Music (unless offset > 0, as Dab paging is limited/untested or we want fast fallback) + # Actually Dab search wrapper I wrote doesn't support offset yet (defaults limit 10). + # We'll use Dab for generic queries. + if type in ["album", "track"] and offset == 0: + try: + from app.dab_service import dab_service + if type == "album": + dab_results = await dab_service.search_albums(q, limit=10) + else: + dab_results = await dab_service.search_tracks(q, limit=10) + + if dab_results: + logger.info(f"Found {len(dab_results)} results on Dab Music") + results = dab_results + source = "dab" + except Exception as e: + logger.error(f"Dab search error: {e}") + + # 2. Fallback to Deezer if no Dab results + if not results: + logger.info(f"Falling back to Deezer search...") + if type == "album": + results = await deezer_service.search_albums(q, limit=20, offset=offset) + elif type == "artist": + results = await deezer_service.search_artists(q, limit=20, offset=offset) + else: + results = await deezer_service.search_tracks(q, limit=20, offset=offset) + if results: + source = "deezer" + + # 3. Final fallback to Jamendo (independent/CC music) if still no results + if not results and type in ["track", "album", "artist"]: + logger.info(f"Falling back to Jamendo search...") + try: + if type == "album": + results = await jamendo_service.search_albums(q, limit=20, offset=offset) + elif type == "artist": + results = await jamendo_service.search_artists(q, limit=20, offset=offset) + else: + results = await jamendo_service.search_tracks(q, limit=20, offset=offset) + if results: + source = "jamendo" + logger.info(f"Found {len(results)} results on Jamendo") + except Exception as e: + logger.error(f"Jamendo search error: {e}") + + return {"results": results, "query": q, "type": type, "source": source, "offset": offset} + except Exception as e: + logger.error(f"Search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def get_content_by_type(content_type: str, item_id: str): + """Helper to get content by type and ID (uses Deezer, Dab, or Jamendo).""" + + # Handle Dab Music IDs + if item_id.startswith("dab_"): + from app.dab_service import dab_service + if content_type == "album": + album = await dab_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])} + # Dab doesn't really have "get_track" singular metadata endpoint exposed yet, but we can search or use stream. + # But for UI "open track", usually it plays directly. + pass + + # Handle Jamendo IDs (jm_ prefix) + if item_id.startswith("jm_"): + if content_type == "album": + album = await jamendo_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "jamendo"} + elif content_type == "artist" or item_id.startswith("jm_artist_"): + artist = await jamendo_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "jamendo"} + elif content_type == "track": + track = await jamendo_service.get_track(item_id) + if track: + return {"results": [track], "type": "track", "is_url": True, "source": "jamendo"} + raise HTTPException(status_code=404, detail=f"Jamendo {content_type} not found") + + if content_type == "track": + results = await deezer_service.search_tracks(item_id, limit=1) + if results: + return {"results": results, "type": "track", "is_url": True} + elif content_type == "album": + album = await deezer_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])} + elif content_type == "artist": + artist = await deezer_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", [])} + + raise HTTPException(status_code=404, detail=f"{content_type.title()} not found") + + +async def get_spotify_content(content_type: str, item_id: str): + """Helper to get content from Spotify by type and ID.""" + if content_type == "track": + track = await spotify_service.get_track_by_id(item_id) + if track: + return {"results": [track], "type": "track", "is_url": True, "source": "spotify"} + elif content_type == "album": + album = await spotify_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "spotify"} + elif content_type == "playlist": + playlist = await spotify_service.get_playlist(item_id) + if playlist: + return {"results": [playlist], "type": "playlist", "is_url": True, "tracks": playlist.get("tracks", []), "source": "spotify"} + elif content_type == "artist": + artist = await spotify_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "spotify"} + + raise HTTPException(status_code=404, detail=f"Spotify {content_type.title()} not found") + + +@app.post("/api/import") +async def import_url_endpoint(request: ImportRequest): + """Import a track from a URL (Bandcamp, Soundcloud, etc.).""" + try: + track = await audio_service.import_url(request.url) + if not track: + raise HTTPException(status_code=400, detail="Could not import URL") + return track + except Exception as e: + logger.error(f"Import endpoint error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/track/{track_id}") +async def get_track(track_id: str): + """Get track details by Spotify ID.""" + try: + track = await spotify_service.get_track_by_id(track_id) + if not track: + raise HTTPException(status_code=404, detail="Track not found") + return track + except HTTPException: + raise + except Exception as e: + logger.error(f"Track fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/album/{album_id}") +async def get_album(album_id: str): + # Support Dab Albums + if album_id.startswith("dab_"): + from app.dab_service import dab_service + album = await dab_service.get_album(album_id) + if album: return album + raise HTTPException(status_code=404, detail="Dab album not found") + + # Support Deezer Albums (fallback logic handled in dedicated service or here) + if album_id.startswith("dz_"): + album = await deezer_service.get_album(album_id) + if album: return album + raise HTTPException(status_code=404, detail="Deezer album not found") + + """Get album details with all tracks.""" + try: + # Handle different sources based on ID prefix + if album_id.startswith("dz_"): + # Deezer album + album = await deezer_service.get_album(album_id) + elif album_id.startswith("archive_"): + # Archive.org show - import via URL + identifier = album_id.replace("archive_", "") + url = f"https://archive.org/details/{identifier}" + logger.info(f"Importing Archive.org show: {url}") + album = await audio_service.import_url(url) + elif album_id.startswith("phish_"): + # Phish.in show - import via URL + date = album_id.replace("phish_", "") + url = f"https://phish.in/{date}" + logger.info(f"Importing Phish.in show: {url}") + album = await audio_service.import_url(url) + elif album_id.startswith("pod_"): + # Podcast Import (PodcastIndex) + feed_id = album_id.replace("pod_", "") + album = await podcast_service.get_podcast_episodes(feed_id) + elif album_id.startswith("setlist_"): + # Setlist.fm - get full setlist with tracks + setlist_id = album_id.replace("setlist_", "") + album = await setlist_service.get_setlist(setlist_id) + if album and album.get("audio_source") == "phish.in": + # Phish show - fetch audio from phish.in + album["audio_available"] = True + elif album and album.get("audio_source") == "archive.org": + # Other artist - find best Archive.org version + archive_url = await setlist_service.find_best_archive_show( + album.get("artists", ""), + album.get("iso_date", "") + ) + if archive_url: + album["audio_url"] = archive_url + album["audio_available"] = True + else: + # Fallback to search if no direct match + album["audio_available"] = True + else: + # Unknown source - try Deezer + album = await deezer_service.get_album(album_id) + + if not album: + raise HTTPException(status_code=404, detail="Album not found") + return album + except HTTPException: + raise + except Exception as e: + logger.error(f"Album fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/playlist/{playlist_id}") +async def get_playlist(playlist_id: str): + """Get playlist details with all tracks.""" + try: + playlist = await spotify_service.get_playlist(playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + return playlist + except HTTPException: + raise + except Exception as e: + logger.error(f"Playlist fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/artist/{artist_id}") +async def get_artist(artist_id: str): + """Get artist details with top tracks.""" + try: + # Use Deezer for dz_ prefixed IDs + if artist_id.startswith("dz_"): + artist = await deezer_service.get_artist(artist_id) + else: + artist = await spotify_service.get_artist(artist_id) + if not artist: + raise HTTPException(status_code=404, detail="Artist not found") + return artist + except HTTPException: + raise + except Exception as e: + logger.error(f"Artist fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.api_route("/api/stream/{isrc}", methods=["GET", "HEAD"]) +async def stream_audio( + request: Request, + isrc: str, + q: Optional[str] = Query(None, description="Search query hint"), + hires: bool = Query(True, description="Prefer Hi-Res 24-bit audio") +): + """Stream audio for a track by ISRC.""" + try: + logger.info(f"Stream request for ISRC: {isrc} (hires={hires})") + + target_stream_url = None + + # 1. Resolve Target Stream URL (Direct or via yt-dlp) + + # Handle Imported Links (LINK:) + if isrc.startswith("LINK:"): + import base64 + from urllib.parse import urlparse + try: + encoded_url = isrc.replace("LINK:", "") + original_url = base64.urlsafe_b64decode(encoded_url).decode() + + # Check for direct file extension first (fast path) + parsed = urlparse(original_url) + audio_exts = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.opus', '.flac') + if any(parsed.path.lower().endswith(ext) for ext in audio_exts): + target_stream_url = original_url + else: + # Try to extract stream via yt-dlp (for YouTube/SoundCloud links) + # Run in executor to avoid blocking + loop = asyncio.get_event_loop() + target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, original_url) + + except Exception as e: + logger.warning(f"Failed to parse/extract LINK: {e}") + + # Handle YouTube Music (ytm_) + elif isrc.startswith("ytm_"): + video_id = isrc.replace("ytm_", "") + youtube_url = f"https://music.youtube.com/watch?v={video_id}" + loop = asyncio.get_event_loop() + target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, youtube_url) + + # Handle Jamendo (jm_) - Direct stream/download URLs + elif isrc.startswith("jm_"): + track_id = isrc.replace("jm_", "") + target_stream_url = await jamendo_service.get_stream_url(track_id, prefer_flac=hires) + + # 2. Proxy the Target Stream (if found) + if target_stream_url: + logger.info(f"Proxying direct stream: {target_stream_url[:60]}...") + + # Forward Range header to support seeking + req_headers = {} + if request.headers.get("Range"): + req_headers["Range"] = request.headers.get("Range") + logger.info(f"Forwarding Range header: {req_headers['Range']}") + + + + try: + # Use a dedicated client per request, managed by a generator with context manager. + # This guarantees cleanup when the generator is closed (e.g. client disconnect). + # This avoids "Shared Client" pool exhaustion/deadlocks. + + async def stream_generator(): + try: + # 60s timeout, follow redirects + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + req = client.build_request("GET", target_stream_url, headers=req_headers) + async with client.stream(req.method, req.url, headers=req.headers) as r: + # We've started the stream. Now we need to yield data. + # But wait, we need to return status_code and headers to FastAPI *before* we yield data + # if we use standard StreamingResponse... + + # Actually, StreamingResponse takes a generator for content. + # But it needs status and headers passed in the constructor. + # Be we can't get them until we make the request! + + # This is the "Streaming Problem". + # Solution: use a separate setup request (HEAD) or... + # Accept that we make the request *outside* the generator for headers, + # but risking the cleanup issue? + + # NO. The cleanup issue is paramount. + # If we can't get headers cleanly without risking leaks, we should assume defaults + # or use a dummy request? + + # ALTERNATIVE: Use the shared client again, BUT with a much more aggressive timeout? + # OR use the pattern where we don't return StreamingResponse until we have headers, + # ensuring we use a try/finally block that closes the client. + + # Let's stick to the pattern I implemented before but simpler: + # 1. Create client + # 2. Make request + # 3. Return StreamingResponse with a generator that CLOSES the client. + # 4. BUT ensure `client` is a LOCALLY created instance, not shared. + pass + + except Exception as e: + logger.error(f"Generator error: {e}") + + # Real Implementation: + # Create a local client instance (not shared). + client = httpx.AsyncClient(follow_redirects=True, timeout=60.0) + req = client.build_request("GET", target_stream_url, headers=req_headers) + r = await client.send(req, stream=True) + + # Prepare headers + resp_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*" + } + for key in ["Content-Range", "Content-Length", "Content-Type", "Last-Modified", "ETag"]: + if r.headers.get(key): + resp_headers[key] = r.headers[key] + + # Custom iterator that closes the LOCAL client + async def response_iterator(): + try: + async for chunk in r.aiter_bytes(chunk_size=65536): + yield chunk + except Exception as e: + logger.error(f"Stream iteration error: {e}") + finally: + # Close the response AND the client + await r.aclose() + await client.aclose() + + return StreamingResponse( + response_iterator(), + status_code=r.status_code, + media_type=r.headers.get("Content-Type", "audio/mpeg"), + headers=resp_headers + ) + except Exception as e: + logger.error(f"Proxying stream failed: {e}") + # Fall through to standard playback if proxy fails + + # 3. Standard / HiFi Playback (Fallback or standard sources) + + # Force FLAC/Hi-Res path (MP3 option removed) + cache_ext = "flac" + mime_type = "audio/flac" + + # Check cache + if is_cached(isrc, cache_ext): + cache_path = get_cache_path(isrc, cache_ext) + logger.info(f"Serving from cache ({cache_ext}): {cache_path}") + return FileResponse( + cache_path, + media_type=mime_type, + headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"} + ) + + + # 4. Standard / HiFi Playback (Uses fetch_flac with internal priorities: Dab -> Tidal -> Deezer) + + # Standard: Fetch FLAC directly (Hifi/Hi-Res) - Skip MP3 transcoding + # The user requested to remove non-hifi options for efficiency. + result = await audio_service.fetch_flac(isrc, q or "", hires=hires) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio") + + # Check if result is URL (tuple[str, dict]) or Bytes (tuple[bytes, dict]) + if isinstance(result[0], str): + # It's a URL! Stream it via proxy + target_stream_url = result[0] + metadata = result[1] + logger.info(f"Streaming via proxy from URL: {target_stream_url[:50]}...") + + # Proxy streaming logic for fetched URL + # Need to handle Range requests properly for seeking + + req_headers = {} + if request.headers.get("Range"): + req_headers["Range"] = request.headers.get("Range") + logger.info(f"Forwarding Range header: {req_headers['Range']}") + + # Make initial request to get status/headers + client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) + try: + upstream_req = client.build_request("GET", target_stream_url, headers=req_headers) + upstream_resp = await client.send(upstream_req, stream=True) + + # Build response headers + resp_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*" + } + + # Forward important headers from upstream + for key in ["Content-Range", "Content-Length", "Content-Type"]: + if upstream_resp.headers.get(key): + resp_headers[key] = upstream_resp.headers[key] + + if metadata and metadata.get("is_hi_res"): + resp_headers["X-Audio-Quality"] = "Hi-Res" + resp_headers["X-Audio-Format"] = "FLAC" + + # Iterator that closes client when done + async def response_iterator(): + try: + async for chunk in upstream_resp.aiter_bytes(chunk_size=65536): + yield chunk + except Exception as e: + logger.error(f"Stream iteration error: {e}") + finally: + await upstream_resp.aclose() + await client.aclose() + + return StreamingResponse( + response_iterator(), + status_code=upstream_resp.status_code, # 200 or 206 + media_type=upstream_resp.headers.get("Content-Type", "audio/flac"), + headers=resp_headers + ) + except Exception as e: + await client.aclose() + raise + + else: + # It's bytes! Serve directly. + flac_data, metadata = result + + headers = { + "Accept-Ranges": "bytes", + "Content-Length": str(len(flac_data)), + "Cache-Control": "public, max-age=86400", + "X-Audio-Format": "FLAC" + } + + if metadata and metadata.get("is_hi_res"): + headers["X-Audio-Quality"] = "Hi-Res" + + return Response( + content=flac_data, + media_type="audio/flac", + headers=headers + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Stream error for {isrc}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/download/{isrc}") +async def download_audio( + isrc: str, + q: Optional[str] = Query(None, description="Search query hint"), + format: str = Query("mp3", description="Audio format: mp3, flac, aiff, wav, alac"), + filename: Optional[str] = Query(None, description="Filename") +): + """Download audio in specified format.""" + try: + logger.info(f"Download request for {isrc} in {format}") + + result = await audio_service.get_download_audio(isrc, q or "", format) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio for download") + + data, ext, mime = result + download_name = filename if filename else f"{isrc}{ext}" + if not download_name.endswith(ext): + download_name += ext + + return Response( + content=data, + media_type=mime, + headers={ + "Content-Disposition": f'attachment; filename="{download_name}"', + "Content-Length": str(len(data)) + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Download error for {isrc}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== DJ MODE ENDPOINTS ========== + +class TrackForFeatures(BaseModel): + id: str + isrc: Optional[str] = None + name: Optional[str] = None + artists: Optional[str] = None + + +class AudioFeaturesBatchRequest(BaseModel): + tracks: List[TrackForFeatures] + + +class TrackForSetlist(BaseModel): + id: str + name: str + artists: str + bpm: int + camelot: str + energy: float + + +class SetlistRequest(BaseModel): + tracks: List[TrackForSetlist] + style: str = "progressive" # progressive, peak-time, chill, journey + + +@app.get("/api/audio-features/{track_id}") +async def get_audio_features( + track_id: str, + isrc: Optional[str] = Query(None), + name: Optional[str] = Query(None), + artist: Optional[str] = Query(None) +): + """Get audio features (BPM, key, energy) for a track.""" + try: + features = await spotify_service.get_audio_features(track_id, isrc, name, artist) + if not features: + raise HTTPException(status_code=404, detail="Audio features not found") + return features + except HTTPException: + raise + except Exception as e: + logger.error(f"Audio features error for {track_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/audio-features/batch") +async def get_audio_features_batch(request: AudioFeaturesBatchRequest): + """Get audio features for multiple tracks.""" + try: + if not request.tracks: + return {"features": []} + + # Process each track, handling Deezer tracks with ISRC/name lookup + features = [] + for track in request.tracks: + feat = await spotify_service.get_audio_features( + track.id, + track.isrc, + track.name, + track.artists + ) + + # Fallback to AI estimation if Spotify fails + if not feat and track.name and track.artists: + feat = await dj_service.get_audio_features_ai(track.name, track.artists) + if feat: + feat['track_id'] = track.id # Match requested ID for frontend cache + + features.append(feat) + + return {"features": features} + except Exception as e: + logger.error(f"Batch audio features error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Batch audio features error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/dj/generate-setlist") +async def generate_setlist(request: SetlistRequest): + """Generate AI-optimized DJ setlist ordering.""" + try: + tracks = [t.model_dump() for t in request.tracks] + result = await dj_service.generate_setlist(tracks, request.style) + return result + except Exception as e: + logger.error(f"Setlist generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class MoodSearchRequest(BaseModel): + query: str + + +@app.post("/api/search/mood") +async def search_by_mood(request: MoodSearchRequest): + """Interpret a natural language mood query using AI and return search terms.""" + try: + result = await dj_service.interpret_mood_query(request.query) + if not result: + # Fallback: just return the query as a search term + return { + "search_terms": [request.query], + "moods": [], + "bpm_range": None, + "energy": "medium", + "description": f"Searching for: {request.query}" + } + return result + except Exception as e: + logger.error(f"Mood search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SeedTrack(BaseModel): + name: str + artists: str + bpm: Optional[int] = None + camelot: Optional[str] = None + + +class QueueTrack(BaseModel): + name: str + artists: str + + +class AIRadioRequest(BaseModel): + seed_track: Optional[SeedTrack] = None + mood: Optional[str] = None + current_queue: Optional[List[QueueTrack]] = None + count: int = 5 + + +@app.post("/api/ai-radio/generate") +async def generate_ai_radio_recommendations(request: AIRadioRequest): + """Generate AI Radio recommendations based on seed track or mood.""" + try: + seed = request.seed_track.model_dump() if request.seed_track else None + queue = [t.model_dump() for t in request.current_queue] if request.current_queue else [] + + result = await ai_radio_service.generate_recommendations( + seed_track=seed, + mood=request.mood, + current_queue=queue, + count=request.count + ) + return result + except Exception as e: + logger.error(f"AI Radio error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== AI ASSISTANT ENDPOINTS ==================== + +class GeneratePlaylistRequest(BaseModel): + description: str + duration_mins: int = 60 + +@app.post("/api/ai/generate-playlist") +async def ai_generate_playlist(request: GeneratePlaylistRequest): + """Generate a playlist from a natural language description.""" + try: + result = await ai_radio_service.generate_playlist( + description=request.description, + duration_mins=request.duration_mins + ) + return result + except Exception as e: + logger.error(f"Playlist generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class BatchDownloadRequest(BaseModel): + tracks: List[str] # List of ISRCs or IDs + names: List[str] # List of track names for filenames + artists: List[str] # List of artist names + album_name: str + format: str = "mp3" + + +@app.post("/api/download-batch") +async def download_batch(request: BatchDownloadRequest): + """Download multiple tracks as a ZIP file.""" + try: + logger.info(f"Batch download request: {len(request.tracks)} tracks from {request.album_name}") + + # In-memory ZIP buffer + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + used_names = set() + + # Process sequentially for better reliability + for i, isrc in enumerate(request.tracks): + try: + query = f"{request.names[i]} {request.artists[i]}" + result = await audio_service.get_download_audio(isrc, query, request.format) + + if result: + data, ext, _ = result + # Clean filename + safe_name = f"{request.artists[i]} - {request.names[i]}".replace("/", "_").replace("\\", "_").replace(":", "_").replace("*", "").replace("?", "").replace('"', "").replace("<", "").replace(">", "").replace("|", "") + filename = f"{safe_name}{ext}" + + # Handle duplicates + count = 1 + base_filename = filename + while filename in used_names: + filename = f"{safe_name} ({count}){ext}" + count += 1 + used_names.add(filename) + + zip_file.writestr(filename, data) + except Exception as e: + logger.error(f"Failed to download track {isrc}: {e}") + # Continue with other tracks + + zip_buffer.seek(0) + safe_album = request.album_name.replace("/", "_").replace("\\", "_").replace(":", "_") + filename = f"{safe_album}.zip" + + return Response( + content=zip_buffer.getvalue(), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + } + ) + + except Exception as e: + logger.error(f"Batch download error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== GOOGLE DRIVE ========== + +class UploadToDriveRequest(BaseModel): + isrc: str + access_token: str + format: str = "aiff" + folder_id: Optional[str] = None + filename: Optional[str] = None + q: Optional[str] = None + + +@app.post("/api/drive/upload") +async def upload_to_drive(request: UploadToDriveRequest): + """Download audio, transcode, and upload to Google Drive.""" + try: + logger.info(f"Drive upload request for {request.isrc} in {request.format}") + + # 1. Get Audio Data (reuse existing logic) + result = await audio_service.get_download_audio(request.isrc, request.q or "", request.format) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio") + + data, ext, mime = result + filename = request.filename if request.filename else f"{request.isrc}{ext}" + if not filename.endswith(ext): + filename += ext + + # 2. Upload to Drive (Multipart upload for metadata + media) + metadata = { + 'name': filename, + 'mimeType': mime + } + if request.folder_id: + metadata['parents'] = [request.folder_id] + + import httpx + import json + + async with httpx.AsyncClient() as client: + # Multipart upload + files_param = { + 'metadata': (None, json.dumps(metadata), 'application/json; charset=UTF-8'), + 'file': (filename, data, mime) + } + + drive_response = await client.post( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', + headers={'Authorization': f'Bearer {request.access_token}'}, + files=files_param, + timeout=300.0 # 5 minutes for upload + ) + + if drive_response.status_code != 200: + logger.error(f"Drive upload failed: {drive_response.text}") + raise HTTPException(status_code=500, detail=f"Drive upload failed: {drive_response.text}") + + file_data = drive_response.json() + return {"file_id": file_data.get('id'), "name": file_data.get('name')} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Drive upload error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== STATIC FILES ========== + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC_DIR = os.path.join(BASE_DIR, "static") + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/") +async def index(): + """Serve the main page.""" + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + return {"message": "Freedify Streaming Server", "docs": "/docs"} + + +@app.get("/manifest.json") +async def manifest(): + """Serve PWA manifest.""" + manifest_path = os.path.join(STATIC_DIR, "manifest.json") + if os.path.exists(manifest_path): + return FileResponse(manifest_path, media_type="application/json") + raise HTTPException(status_code=404) + + +@app.get("/sw.js") +async def service_worker(): + """Serve service worker.""" + sw_path = os.path.join(STATIC_DIR, "sw.js") + if os.path.exists(sw_path): + return FileResponse(sw_path, media_type="application/javascript") + raise HTTPException(status_code=404) + + +# ==================== LISTENBRAINZ ENDPOINTS ==================== + +@app.post("/api/listenbrainz/now-playing") +async def listenbrainz_now_playing(track: dict): + """Submit 'now playing' status to ListenBrainz.""" + success = await listenbrainz_service.submit_now_playing(track) + return {"success": success} + + +@app.post("/api/listenbrainz/scrobble") +async def listenbrainz_scrobble(track: dict, listened_at: Optional[int] = None): + """Submit a completed listen to ListenBrainz.""" + success = await listenbrainz_service.submit_listen(track, listened_at) + return {"success": success} + + +@app.get("/api/listenbrainz/validate") +async def listenbrainz_validate(): + """Validate ListenBrainz token and return username.""" + username = await listenbrainz_service.validate_token() + return {"valid": username is not None, "username": username} + + +@app.get("/api/listenbrainz/recommendations/{username}") +async def listenbrainz_recommendations(username: str, count: int = 25): + """Get personalized recommendations for a user.""" + recommendations = await listenbrainz_service.get_recommendations(username, count) + return {"recommendations": recommendations, "count": len(recommendations)} + + +@app.get("/api/listenbrainz/listens/{username}") +async def listenbrainz_listens(username: str, count: int = 25): + """Get recent listens for a user.""" + listens = await listenbrainz_service.get_user_listens(username, count) + return {"listens": listens, "count": len(listens)} + + +@app.post("/api/listenbrainz/set-token") +async def listenbrainz_set_token(token: str): + """Set ListenBrainz user token (from settings UI).""" + listenbrainz_service.set_token(token) + username = await listenbrainz_service.validate_token() + return {"valid": username is not None, "username": username} + +@app.get("/api/listenbrainz/playlists/{username}") +async def listenbrainz_playlists(username: str, count: int = 25): + """Get user's ListenBrainz playlists (includes Weekly Exploration).""" + playlists = await listenbrainz_service.get_user_playlists(username, count) + return {"playlists": playlists, "count": len(playlists)} + +@app.get("/api/listenbrainz/playlist/{playlist_id}") +async def listenbrainz_playlist_tracks(playlist_id: str): + """Get tracks from a ListenBrainz playlist.""" + playlist = await listenbrainz_service.get_playlist_tracks(playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + return playlist + +@app.get("/api/listenbrainz/stats/{username}") +async def listenbrainz_stats(username: str): + """Get user's ListenBrainz listening statistics.""" + stats = await listenbrainz_service.get_user_stats(username) + return stats + + +# ========== GENIUS LYRICS ========== + +@app.get("/api/lyrics") +async def get_lyrics(artist: str, title: str): + """Get lyrics and song info from Genius.""" + result = await genius_service.get_lyrics_and_info(artist, title) + return result + + +@app.get("/api/proxy_image") +async def proxy_image(url: str): + """Proxy image requests to avoid 429 errors/CORS issues.""" + if not url: + raise HTTPException(status_code=400, detail="No URL provided") + + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, follow_redirects=True) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail="Failed to fetch image") + + return Response( + content=resp.content, + media_type=resp.headers.get("Content-Type", "image/jpeg"), + headers={ + "Cache-Control": "public, max-age=86400" + } + ) + except Exception as e: + logger.error(f"Image proxy error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== CONCERT ALERTS ENDPOINTS ========== + +@app.get("/api/concerts/search") +async def search_concerts( + artist: str = Query(..., description="Artist name to search"), + city: Optional[str] = Query(None, description="City to filter events") +): + """ + Search for upcoming concerts by artist name. + Uses Ticketmaster with SeatGeek fallback. + """ + try: + logger.info(f"Concert search for: {artist} (city: {city})") + events = await concert_service.search_events(artist, city, limit=20) + return {"events": events, "artist": artist, "city": city} + except Exception as e: + logger.error(f"Concert search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/concerts/for-artists") +async def get_concerts_for_artists( + artists: str = Query(..., description="Comma-separated list of artist names"), + cities: Optional[str] = Query(None, description="Comma-separated list of cities") +): + """ + Get upcoming concerts for multiple artists. + Useful for showing concerts from recently listened artists. + """ + try: + artist_list = [a.strip() for a in artists.split(",") if a.strip()] + city_list = [c.strip() for c in cities.split(",")] if cities else None + + if not artist_list: + return {"events": [], "artists": [], "cities": city_list} + + logger.info(f"Concert search for {len(artist_list)} artists, cities: {city_list}") + events = await concert_service.get_events_for_artists(artist_list, city_list) + + return {"events": events, "artists": artist_list, "cities": city_list} + except Exception as e: + logger.error(f"Concerts for artists error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=int(os.environ.get("PORT", 8000)), + reload=True + ) diff --git a/app/musicbrainz_service.py b/app/musicbrainz_service.py new file mode 100644 index 0000000..dc23ccf --- /dev/null +++ b/app/musicbrainz_service.py @@ -0,0 +1,194 @@ +""" +MusicBrainz service for Freedify. +Provides metadata enrichment: release year, label, and cover art from Cover Art Archive. +""" +import httpx +from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger(__name__) + + +class MusicBrainzService: + """Service for enriching track metadata from MusicBrainz.""" + + MB_API = "https://musicbrainz.org/ws/2" + CAA_API = "https://coverartarchive.org" + USER_AGENT = "Freedify/1.0 (https://github.com/freedify)" + + def __init__(self): + self.client = httpx.AsyncClient( + timeout=15.0, + headers={"User-Agent": self.USER_AGENT} + ) + + async def lookup_recording(self, mbid: str) -> Optional[Dict[str, Any]]: + """Look up a recording by MBID. + + Returns: + { + 'id': '...', + 'name': 'Track Name', + 'artists': 'Artist Name', + 'album': 'Album Name', + 'album_art': '...', + 'release_date': '...', + 'duration': '3:45' + } + """ + try: + response = await self.client.get( + f"{self.MB_API}/recording/{mbid}", + params={"fmt": "json", "inc": "releases+artist-credits+release-groups+genres"} + ) + + if response.status_code != 200: + logger.debug(f"MBID lookup failed: {mbid}") + return None + + data = response.json() + + # Helper to get artist name + artist_credit = data.get("artist-credit", []) + artist_name = ", ".join([ac.get("name", "") for ac in artist_credit]) if artist_credit else "Unknown Artist" + + result = { + "id": mbid, + "name": data.get("title", "Unknown Track"), + "artists": artist_name, + "duration": data.get("length", 0) // 1000 if data.get("length") else 0 + } + + # Get release info + releases = data.get("releases", []) + if releases: + release = releases[0] + result["album"] = release.get("title", "") + result["release_date"] = release.get("date", "") + + # Cover Art + release_id = release.get("id") + if release_id: + cover_url = await self._get_cover_art(release_id) + if cover_url: + result["album_art"] = cover_url + + return result + + except Exception as e: + logger.error(f"MusicBrainz recording lookup error: {e}") + return None + + async def lookup_by_isrc(self, isrc: str) -> Optional[Dict[str, Any]]: + """Look up a recording by ISRC and return enriched metadata. + + Returns: + { + 'release_date': '2020-01-15', + 'label': 'Atlantic Records', + 'cover_art_url': 'https://...', + 'genres': ['pop', 'electronic'], + 'release_id': '...' # for further lookups + } + """ + try: + # Skip non-standard ISRCs (like dz_ or ytm_ prefixed IDs) + if not isrc or isrc.startswith(('dz_', 'ytm_', 'LINK:')): + return None + + logger.info(f"Looking up ISRC on MusicBrainz: {isrc}") + + # Search for recording by ISRC + response = await self.client.get( + f"{self.MB_API}/isrc/{isrc}", + params={"fmt": "json", "inc": "releases+release-groups+labels+genres"} + ) + + if response.status_code != 200: + logger.debug(f"No MusicBrainz result for ISRC: {isrc}") + return None + + data = response.json() + recordings = data.get("recordings", []) + + if not recordings: + return None + + # Get the first recording's release info + recording = recordings[0] + releases = recording.get("releases", []) + + if not releases: + return None + + # Use the first release (typically the original) + release = releases[0] + release_id = release.get("id", "") + + result = { + "release_date": release.get("date", ""), + "release_id": release_id, + "label": "", + "cover_art_url": "", + "genres": [] + } + + # Get label from label-info + label_info = release.get("label-info", []) + if label_info and label_info[0].get("label"): + result["label"] = label_info[0]["label"].get("name", "") + + # Get genres from recording + genres = recording.get("genres", []) + result["genres"] = [g.get("name", "") for g in genres[:5]] + + # Try to get cover art from Cover Art Archive + if release_id: + cover_url = await self._get_cover_art(release_id) + if cover_url: + result["cover_art_url"] = cover_url + + logger.info(f"MusicBrainz enrichment found: year={result['release_date']}, label={result['label']}") + return result + + except Exception as e: + logger.debug(f"MusicBrainz lookup error for {isrc}: {e}") + return None + + async def _get_cover_art(self, release_id: str) -> Optional[str]: + """Get cover art URL from Cover Art Archive.""" + try: + response = await self.client.get( + f"{self.CAA_API}/release/{release_id}", + follow_redirects=True + ) + + if response.status_code != 200: + return None + + data = response.json() + images = data.get("images", []) + + # Get front cover, prefer large size + for img in images: + if img.get("front"): + # Prefer 500px version for quality/speed balance + thumbnails = img.get("thumbnails", {}) + return thumbnails.get("500") or thumbnails.get("large") or img.get("image") + + # Fallback to first image + if images: + return images[0].get("image") + + return None + except Exception as e: + logger.debug(f"Cover Art Archive error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +musicbrainz_service = MusicBrainzService() diff --git a/app/podcast_service.py b/app/podcast_service.py new file mode 100644 index 0000000..948162f --- /dev/null +++ b/app/podcast_service.py @@ -0,0 +1,165 @@ +""" +Podcast service using PodcastIndex API. +https://podcastindex-org.github.io/docs-api/ +""" +import httpx +import logging +import hashlib +import time +import base64 +import os +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# API Keys - MUST be set via environment variables +PODCASTINDEX_KEY = os.getenv("PODCASTINDEX_KEY", "") +PODCASTINDEX_SECRET = os.getenv("PODCASTINDEX_SECRET", "") + +class PodcastService: + """Service for searching podcasts via PodcastIndex API.""" + + BASE_URL = "https://api.podcastindex.org/api/1.0" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=15.0) + self.api_key = PODCASTINDEX_KEY + self.api_secret = PODCASTINDEX_SECRET + + def _get_auth_headers(self) -> Dict[str, str]: + """Generate authentication headers for PodcastIndex API.""" + if not self.api_key or not self.api_secret: + logger.warning("PodcastIndex API keys are missing!") + return {} + + epoch_time = int(time.time()) + data_to_hash = self.api_key + self.api_secret + str(epoch_time) + sha1_hash = hashlib.sha1(data_to_hash.encode('utf-8')).hexdigest() + + return { + "X-Auth-Key": self.api_key, + "X-Auth-Date": str(epoch_time), + "Authorization": sha1_hash, + "User-Agent": "Freedify/1.0" + } + + async def search_podcasts(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search for podcasts by term.""" + try: + if not self.api_key: + logger.error("Cannot search podcasts: Missing API Key") + return [] + + params = {"q": query, "max": limit} + response = await self.client.get( + f"{self.BASE_URL}/search/byterm", + params=params, + headers=self._get_auth_headers() + ) + + if response.status_code != 200: + logger.error(f"PodcastIndex search failed: {response.status_code}") + return [] + + data = response.json() + feeds = data.get("feeds", []) + + return [self._format_podcast(feed) for feed in feeds[:limit]] + + except Exception as e: + logger.error(f"Podcast search error: {e}") + return [] + + async def get_podcast_episodes(self, feed_id: str, limit: int = 50) -> Optional[Dict[str, Any]]: + """Get episodes for a podcast by feed ID.""" + try: + if not self.api_key: + return None + + # First get feed info + feed_response = await self.client.get( + f"{self.BASE_URL}/podcasts/byfeedid", + params={"id": feed_id}, + headers=self._get_auth_headers() + ) + + if feed_response.status_code != 200: + logger.error(f"Failed to get feed info: {feed_response.status_code}") + return None + + feed_data = feed_response.json().get("feed", {}) + + # Get episodes + episodes_response = await self.client.get( + f"{self.BASE_URL}/episodes/byfeedid", + params={"id": feed_id, "max": limit}, + headers=self._get_auth_headers() + ) + + if episodes_response.status_code != 200: + logger.error(f"Failed to get episodes: {episodes_response.status_code}") + return None + + episodes_data = episodes_response.json().get("items", []) + + # Format episodes as tracks + tracks = [] + for ep in episodes_data: + audio_url = ep.get("enclosureUrl") + if not audio_url: + continue + + # Create ID that audio_service can decode (LINK:base64) + safe_id = f"LINK:{base64.urlsafe_b64encode(audio_url.encode()).decode()}" + + duration_s = ep.get("duration", 0) + duration_str = f"{int(duration_s // 60)}:{int(duration_s % 60):02d}" if duration_s else "0:00" + + tracks.append({ + "id": safe_id, + "type": "track", + "name": ep.get("title", "Unknown Episode"), + "artists": feed_data.get("author") or feed_data.get("title", "Unknown"), + "album": feed_data.get("title", "Podcast"), + "album_art": ep.get("image") or feed_data.get("image") or "/static/icon.svg", + "duration": duration_str, + "isrc": safe_id, + "source": "podcast", + # Metadata for Info Modal + "description": ep.get("description", ""), + "datePublished": ep.get("datePublishedPretty", "") + }) + + return { + "id": f"pod_{feed_id}", + "type": "album", + "name": feed_data.get("title", "Unknown Podcast"), + "artists": feed_data.get("author") or "Podcast", + "image": feed_data.get("image") or "/static/icon.svg", + "album_art": feed_data.get("image") or "/static/icon.svg", + "tracks": tracks, + "total_tracks": len(tracks), + "source": "podcast" + } + + except Exception as e: + logger.error(f"Error fetching episodes for feed {feed_id}: {e}") + return None + + def _format_podcast(self, feed: dict) -> dict: + """Format PodcastIndex feed to app format.""" + return { + "id": f"pod_{feed.get('id')}", + "type": "album", + "is_podcast": True, + "name": feed.get("title", "Unknown Podcast"), + "artists": feed.get("author") or feed.get("ownerName", "Unknown"), + "album_art": feed.get("image") or feed.get("artwork") or "/static/icon.svg", + "description": feed.get("description", "")[:150], + "source": "podcast" + } + + async def close(self): + await self.client.aclose() + +podcast_service = PodcastService() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..c75bf4c --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +httpx[socks]>=0.25.0 +aiofiles>=23.2.0 +ffmpeg-python>=0.2.0 +mutagen>=1.47.0 +pyotp>=2.9.0 +requests>=2.31.0 +google-generativeai>=0.8.0 +python-dotenv>=1.0.0 +yt-dlp>=2024.1.0 +ytmusicapi>=1.8.0 +packaging>=23.0 +beautifulsoup4>=4.12.0 diff --git a/app/setlist_service.py b/app/setlist_service.py new file mode 100644 index 0000000..cc44b23 --- /dev/null +++ b/app/setlist_service.py @@ -0,0 +1,310 @@ +""" +Setlist.fm service for Freedify. +Searches for concert setlists and matches them to audio sources (Phish.in, Archive.org). +""" +import os +import httpx +from typing import Optional, Dict, List, Any +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +# API key from environment +SETLIST_FM_API_KEY = os.getenv("SETLIST_FM_API_KEY", "") + + +class SetlistService: + """Service for searching and retrieving concert setlists from Setlist.fm.""" + + API_BASE = "https://api.setlist.fm/rest/1.0" + + def __init__(self): + self.client = httpx.AsyncClient( + timeout=15.0, + headers={ + "Accept": "application/json", + "x-api-key": SETLIST_FM_API_KEY + } + ) + + async def search_setlists(self, query: str, page: int = 1) -> List[Dict[str, Any]]: + """Search for setlists by artist name or date. + + Examples: + "Grateful Dead" - search by artist + "Phish 2023" - artist + year + "Pearl Jam 1991-09-20" - specific date + """ + if not SETLIST_FM_API_KEY: + logger.warning("Setlist.fm API key not configured") + return [] + + try: + # Parse query for artist and potential date + # Parse query for artist and potential date + params = {"p": page} + + # Helper to strip date parts from query to get artist name + def clean_query(q, match_str): + return q.replace(match_str, "").strip() + + import re + + # Pattern 1: YYYY-MM-DD + date_match_iso = re.search(r'(\d{4})-(\d{2})-(\d{2})', query) + + # Pattern 2: DD-MM-YYYY (what user tried) + date_match_eu = re.search(r'(\d{2})-(\d{2})-(\d{4})', query) + + # Pattern 3: Month name and day + month_match = re.search(r'(?i)\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,)?\s*(\d{4})?', query) + + # Pattern 4: Simple year + year_match = re.search(r'\b(19|20)\d{2}\b', query) + + if date_match_iso: + # YYYY-MM-DD -> API needs dd-MM-yyyy + dt = datetime.strptime(date_match_iso.group(0), "%Y-%m-%d") + params["date"] = dt.strftime("%d-%m-%Y") + params["artistName"] = clean_query(query, date_match_iso.group(0)) + + elif date_match_eu: + # DD-MM-YYYY -> API needs dd-MM-yyyy (pass as is, or reformat to ensure validity) + # User typed: 31-12-2025 + try: + dt = datetime.strptime(date_match_eu.group(0), "%d-%m-%Y") + params["date"] = dt.strftime("%d-%m-%Y") + params["artistName"] = clean_query(query, date_match_eu.group(0)) + except ValueError: + # Invalid date (e.g. 99-99-2025), fallback to year or artist + logger.warning(f"Invalid date in query: {date_match_eu.group(0)}") + if year_match: + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + else: + params["artistName"] = query + + elif month_match: + # Month Day [Year] + try: + month_str = month_match.group(1) + day_str = month_match.group(2) + year_str = month_match.group(3) + + # Convert month name to number + dt_str = f"{month_str} {day_str} {year_str if year_str else '2000'}" # Dummy year if missing + dt = datetime.strptime(dt_str, "%b %d %Y") + + if year_str: + # Full date found + params["date"] = dt.strftime("%d-%m-%Y") + else: + # Recursive search or just month param? Setlist API only supports full date or year + # If year is missing in "Phish December 31", we might need to guess current year or search by year + # For now, let's assume current year if user says "Phish December 31" + current_year = datetime.now().year + params["date"] = dt.strftime(f"%d-%m-{current_year}") + + params["artistName"] = clean_query(query, month_match.group(0)) + except Exception as e: + logger.warning(f"Date parse error: {e}") + # Fallback to year only if possible + if year_match: + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + else: + params["artistName"] = query + + elif year_match: + # Year only + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + + else: + # Just artist name + params["artistName"] = query + + logger.info(f"Searching Setlist.fm: {params}") + response = await self.client.get(f"{self.API_BASE}/search/setlists", params=params) + + if response.status_code == 404: + return [] + + response.raise_for_status() + data = response.json() + + setlists = data.get("setlist", []) + return [self._format_setlist(s) for s in setlists[:20]] + + except Exception as e: + logger.error(f"Setlist.fm search error: {e}") + return [] + + async def get_setlist(self, setlist_id: str) -> Optional[Dict[str, Any]]: + """Get full setlist details by ID.""" + if not SETLIST_FM_API_KEY: + return None + + try: + response = await self.client.get(f"{self.API_BASE}/setlist/{setlist_id}") + + if response.status_code != 200: + return None + + data = response.json() + return self._format_setlist_detail(data) + + except Exception as e: + logger.error(f"Setlist.fm get_setlist error: {e}") + return None + + def _format_setlist(self, item: dict) -> dict: + """Format setlist data for search results.""" + artist = item.get("artist", {}) + venue = item.get("venue", {}) + city = venue.get("city", {}) + + # Parse date (format: DD-MM-YYYY) + event_date = item.get("eventDate", "") + formatted_date = "" + iso_date = "" + if event_date: + try: + dt = datetime.strptime(event_date, "%d-%m-%Y") + formatted_date = dt.strftime("%B %d, %Y") + iso_date = dt.strftime("%Y-%m-%d") + except: + formatted_date = event_date + + # Count songs + song_count = 0 + for setlist_set in item.get("sets", {}).get("set", []): + song_count += len(setlist_set.get("song", [])) + + return { + "id": f"setlist_{item.get('id', '')}", + "type": "setlist", + "name": f"{artist.get('name', 'Unknown')} at {venue.get('name', 'Unknown Venue')}", + "artists": artist.get("name", ""), + "artist_mbid": artist.get("mbid", ""), + "venue": venue.get("name", ""), + "city": f"{city.get('name', '')}, {city.get('stateCode', '')} {city.get('country', {}).get('code', '')}".strip(", "), + "date": formatted_date, + "iso_date": iso_date, + "song_count": song_count, + "setlist_id": item.get("id", ""), + "url": item.get("url", ""), + "source": "setlist.fm", + # For display + "album_art": "/static/icon.svg", # Use default icon + "total_tracks": song_count, + "release_date": iso_date, + } + + def _format_setlist_detail(self, item: dict) -> dict: + """Format full setlist with all songs.""" + base = self._format_setlist(item) + + # Extract all songs from all sets + tracks = [] + set_idx = 0 + for setlist_set in item.get("sets", {}).get("set", []): + set_name = setlist_set.get("name") or f"Set {set_idx + 1}" + if setlist_set.get("encore"): + set_name = "Encore" + + for song in setlist_set.get("song", []): + song_name = song.get("name", "Unknown") + + # Build track info + track = { + "id": f"setlist_song_{base['setlist_id']}_{len(tracks)}", + "name": song_name, + "artists": base["artists"], + "set_name": set_name, + "with_info": song.get("with", {}).get("name"), # Guest artist + "cover_info": song.get("cover", {}).get("name"), # Original artist if cover + "info": song.get("info", ""), # Additional notes + "duration": "", # Setlist.fm doesn't have duration + "type": "track", + "source": "setlist.fm", + } + tracks.append(track) + + set_idx += 1 + + base["tracks"] = tracks + base["type"] = "album" # Treat as album for detail view + + # Determine audio source + artist_lower = base["artists"].lower() + if "phish" in artist_lower: + base["audio_source"] = "phish.in" + base["audio_url"] = f"https://phish.in/{base['iso_date']}" + else: + base["audio_source"] = "archive.org" + # We'll set audio_url after searching for the best version + base["audio_search"] = f"{base['artists']} {base['iso_date']}" + + return base + + async def find_best_archive_show(self, artist: str, iso_date: str) -> Optional[str]: + """Search Archive.org for the best (most downloaded) version of a show.""" + try: + # Map common artist names to Archive.org collections + artist_lower = artist.lower() + collection = None + + collection_map = { + "grateful dead": "GratefulDead", + "dead": "GratefulDead", + "billy strings": "BillyStrings", + "ween": "Ween", + "king gizzard": "KingGizzardAndTheLizardWizard", + "kglw": "KingGizzardAndTheLizardWizard", + } + + for key, val in collection_map.items(): + if key in artist_lower: + collection = val + break + + if not collection: + # Fallback: search by creator + query = f'creator:"{artist}" AND date:{iso_date}* AND mediatype:etree' + else: + query = f'collection:{collection} AND date:{iso_date}* AND mediatype:etree' + + params = { + "q": query, + "fl[]": ["identifier", "downloads"], + "sort[]": "downloads desc", # Sort by most downloads + "rows": 1, # Just get the top one + "output": "json", + } + + response = await self.client.get("https://archive.org/advancedsearch.php", params=params) + if response.status_code != 200: + return None + + data = response.json() + docs = data.get("response", {}).get("docs", []) + + if docs: + identifier = docs[0].get("identifier") + return f"https://archive.org/details/{identifier}" + + return None + + except Exception as e: + logger.error(f"Archive.org search error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +setlist_service = SetlistService() diff --git a/app/spotify_service.py b/app/spotify_service.py new file mode 100644 index 0000000..8e8df89 --- /dev/null +++ b/app/spotify_service.py @@ -0,0 +1,496 @@ +""" +Spotify service for Freedify. +Provides playlist/album fetching and URL parsing. +ONLY used when a Spotify URL is pasted - not for search (to avoid rate limits). +""" +import httpx +import re +from typing import Optional, Dict, List, Any, Tuple +import logging +from random import randrange + +logger = logging.getLogger(__name__) + + +def get_random_user_agent(): + return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}" + + +class SpotifyService: + """Service for fetching metadata from Spotify URLs (not for search).""" + + TOKEN_URL = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player" + AUTH_URL = "https://accounts.spotify.com/api/token" + API_BASE = "https://api.spotify.com/v1" + + # Regex patterns for Spotify URLs + URL_PATTERNS = { + 'track': re.compile(r'(?:spotify\.com/track/|spotify:track:)([a-zA-Z0-9]+)'), + 'album': re.compile(r'(?:spotify\.com/album/|spotify:album:)([a-zA-Z0-9]+)'), + 'playlist': re.compile(r'(?:spotify\.com/playlist/|spotify:playlist:)([a-zA-Z0-9]+)'), + 'artist': re.compile(r'(?:spotify\.com/artist/|spotify:artist:)([a-zA-Z0-9]+)'), + } + + def __init__(self): + import os + self.access_token: Optional[str] = None + self.client_id = os.environ.get("SPOTIFY_CLIENT_ID") + self.client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET") + self.sp_dc = os.environ.get("SPOTIFY_SP_DC") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _get_access_token(self) -> str: + """Get access token (Client Creds > Cookie > Web Player > Embed).""" + if self.access_token: + return self.access_token + + # 1. Try Client Credentials Flow + if self.client_id and self.client_secret: + try: + import base64 + auth_str = f"{self.client_id}:{self.client_secret}" + b64_auth = base64.b64encode(auth_str.encode()).decode() + + headers = { + "Authorization": f"Basic {b64_auth}", + "Content-Type": "application/x-www-form-urlencoded" + } + data = {"grant_type": "client_credentials"} + + response = await self.client.post(self.AUTH_URL, headers=headers, data=data) + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data.get("access_token") + logger.info("Got Spotify token via Client Credentials") + return self.access_token + except Exception as e: + logger.error(f"Client Credentials auth failed: {e}") + + # 2. Try Cookie Auth (sp_dc) - Mimics logged-in Web Player + # This is the best fallback if Developer App creation is blocked + cookies = None + if self.sp_dc: + cookies = {"sp_dc": self.sp_dc} + logger.info("Using provided sp_dc cookie for authentication") + + # 3. Web Player Token (Anonymous or Authenticated via Cookie) + headers = { + "User-Agent": get_random_user_agent(), + "Accept": "application/json", + "Referer": "https://open.spotify.com/", + } + + try: + # If cookies are passed, this request becomes authenticated! + response = await self.client.get(self.TOKEN_URL, headers=headers, cookies=cookies) + if response.status_code == 200: + data = response.json() + self.access_token = data.get("accessToken") + if self.access_token: + logger.info(f"Got Spotify token via Web Player ({'Authenticated' if cookies else 'Anonymous'})") + return self.access_token + except Exception as e: + logger.warning(f"Web Player token fetch failed: {e}") + + # 4. Fallback: Embed Page + if response.status_code == 200: + data = response.json() + self.access_token = data.get("accessToken") + if self.access_token: + logger.info("Got Spotify token via direct method") + return self.access_token + except Exception as e: + logger.warning(f"Direct token fetch failed: {e}") + + # 3. Fallback: Embed Page + try: + embed_url = "https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT" + response = await self.client.get(embed_url, headers={"User-Agent": get_random_user_agent()}) + if response.status_code == 200: + token_match = re.search(r'"accessToken":"([^"]+)"', response.text) + if token_match: + self.access_token = token_match.group(1) + logger.info("Got Spotify token via embed page") + return self.access_token + except Exception as e: + logger.warning(f"Embed token fetch failed: {e}") + + raise Exception("Failed to get Spotify access token") + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make authenticated API request with rate limit handling.""" + import asyncio + + max_retries = 3 + retry_delay = 2 + + for attempt in range(max_retries): + token = await self._get_access_token() + headers = { + "Authorization": f"Bearer {token}", + "User-Agent": get_random_user_agent(), + "Accept": "application/json", + } + response = await self.client.get(f"{self.API_BASE}{endpoint}", headers=headers, params=params) + + if response.status_code == 401: + logger.warning("Got 401, refreshing Spotify token...") + self.access_token = None + continue + + if response.status_code == 429: + retry_after = min(int(response.headers.get("Retry-After", retry_delay)), 10) + logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry {attempt + 1}/{max_retries}") + await asyncio.sleep(retry_after) + retry_delay *= 2 + continue + + response.raise_for_status() + return response.json() + + response.raise_for_status() + return response.json() + + def parse_spotify_url(self, url: str) -> Optional[Tuple[str, str]]: + """Parse Spotify URL and return (type, id) or None.""" + for url_type, pattern in self.URL_PATTERNS.items(): + match = pattern.search(url) + if match: + return (url_type, match.group(1)) + return None + + def is_spotify_url(self, url: str) -> bool: + """Check if a URL is a Spotify URL.""" + return 'spotify.com/' in url or 'spotify:' in url + + # ========== TRACK METHODS ========== + + async def get_track_by_id(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get a single track by ID.""" + try: + data = await self._api_request(f"/tracks/{track_id}", {"market": "US"}) + return self._format_track(data) + except: + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend.""" + return { + "id": item["id"], + "type": "track", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item["artists"]), + "artist_names": [a["name"] for a in item["artists"]], + "album": item["album"]["name"], + "album_id": item["album"]["id"], + "album_art": self._get_best_image(item["album"]["images"]), + "duration_ms": item["duration_ms"], + "duration": self._format_duration(item["duration_ms"]), + "isrc": item.get("external_ids", {}).get("isrc"), + "source": "spotify", + } + + # ========== ALBUM METHODS ========== + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + data = await self._api_request(f"/albums/{album_id}", {"market": "US"}) + album = self._format_album(data) + + tracks = [] + for item in data.get("tracks", {}).get("items", []): + track = { + "id": item["id"], + "type": "track", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item["artists"]), + "artist_names": [a["name"] for a in item["artists"]], + "album": data["name"], + "album_id": album_id, + "album_art": album["album_art"], + "duration_ms": item["duration_ms"], + "duration": self._format_duration(item["duration_ms"]), + "isrc": None, + "source": "spotify", + } + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Spotify album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + return { + "id": item["id"], + "type": "album", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item.get("artists", [])), + "album_art": self._get_best_image(item.get("images", [])), + "release_date": item.get("release_date", ""), + "total_tracks": item.get("total_tracks", 0), + "source": "spotify", + } + + # ========== PLAYLIST METHODS ========== + + async def get_playlist(self, playlist_id: str) -> Optional[Dict[str, Any]]: + """Get playlist with all tracks.""" + try: + data = await self._api_request(f"/playlists/{playlist_id}", {"market": "US"}) + + playlist = { + "id": data["id"], + "type": "playlist", + "name": data["name"], + "description": data.get("description", ""), + "album_art": self._get_best_image(data.get("images", [])), + "owner": data.get("owner", {}).get("display_name", ""), + "total_tracks": data.get("tracks", {}).get("total", 0), + "source": "spotify", + } + + tracks = [] + for item in data.get("tracks", {}).get("items", []): + track_data = item.get("track") + if track_data and track_data.get("id"): + tracks.append(self._format_track(track_data)) + + playlist["tracks"] = tracks + return playlist + except Exception as e: + logger.error(f"Error fetching Spotify playlist {playlist_id}: {e}") + return None + + # ========== ARTIST METHODS ========== + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + artist_data = await self._api_request(f"/artists/{artist_id}") + artist = { + "id": artist_data["id"], + "type": "artist", + "name": artist_data["name"], + "image": self._get_best_image(artist_data.get("images", [])), + "genres": artist_data.get("genres", []), + "followers": artist_data.get("followers", {}).get("total", 0), + "source": "spotify", + } + + top_tracks = await self._api_request(f"/artists/{artist_id}/top-tracks", {"market": "US"}) + artist["tracks"] = [self._format_track(t) for t in top_tracks.get("tracks", [])] + + return artist + except Exception as e: + logger.error(f"Error fetching Spotify artist {artist_id}: {e}") + return None + + # ========== AUDIO FEATURES & CAMELOT ========== + + # Camelot Wheel: Maps (pitch_class, mode) to Camelot notation + # pitch_class: 0=C, 1=C#, 2=D, ..., 11=B + # mode: 1=Major (B), 0=Minor (A) + CAMELOT_MAP = { + (0, 1): "8B", (0, 0): "5A", # C Major / C Minor + (1, 1): "3B", (1, 0): "12A", # C# Major / C# Minor + (2, 1): "10B", (2, 0): "7A", # D Major / D Minor + (3, 1): "5B", (3, 0): "2A", # D# Major / D# Minor + (4, 1): "12B", (4, 0): "9A", # E Major / E Minor + (5, 1): "7B", (5, 0): "4A", # F Major / F Minor + (6, 1): "2B", (6, 0): "11A", # F# Major / F# Minor + (7, 1): "9B", (7, 0): "6A", # G Major / G Minor + (8, 1): "4B", (8, 0): "1A", # G# Major / G# Minor + (9, 1): "11B", (9, 0): "8A", # A Major / A Minor + (10, 1): "6B", (10, 0): "3A", # A# Major / A# Minor + (11, 1): "1B", (11, 0): "10A", # B Major / B Minor + } + + def _to_camelot(self, key: int, mode: int) -> str: + """Convert Spotify key/mode to Camelot notation.""" + return self.CAMELOT_MAP.get((key, mode), "?") + + async def search_track_by_isrc(self, isrc: str) -> Optional[str]: + """Search for a track by ISRC and return Spotify track ID.""" + try: + data = await self._api_request("/search", {"q": f"isrc:{isrc}", "type": "track", "limit": 1}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + except Exception as e: + logger.warning(f"ISRC search failed for {isrc}: {e}") + return None + + async def search_track_by_name(self, name: str, artist: str) -> Optional[str]: + """Search for a track by name and artist, return Spotify track ID.""" + try: + # 1. Try strict search first + query = f"track:{name} artist:{artist}" + data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + + # 2. Fallback to loose search (just string matching) + # Remove special chars and extra artists for better matching + clean_name = name.split('(')[0].split('-')[0].strip() + clean_artist = artist.split(',')[0].strip() + query = f"{clean_name} {clean_artist}" + data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + + except Exception as e: + logger.warning(f"Name search failed for {name} by {artist}: {e}") + return None + + async def get_audio_features(self, track_id: str, isrc: str = None, name: str = None, artist: str = None) -> Optional[Dict[str, Any]]: + """Get audio features (BPM, key, energy) for a single track. + + If track_id starts with 'dz_' (Deezer), will try ISRC or name/artist lookup first. + """ + spotify_id = track_id + + # Handle Deezer tracks - need to find Spotify equivalent + if track_id.startswith("dz_"): + spotify_id = None + # Try ISRC first + if isrc: + spotify_id = await self.search_track_by_isrc(isrc) + # Fallback to name/artist search + if not spotify_id and name and artist: + spotify_id = await self.search_track_by_name(name, artist) + + if not spotify_id: + logger.warning(f"Could not find Spotify ID for Deezer track {track_id}") + return None + + try: + data = await self._api_request(f"/audio-features/{spotify_id}") + return self._format_audio_features(data) + except Exception as e: + # 403 Forbidden likely means token lacks permission (scraper token) or ID is invalid + if "403" in str(e): + logger.warning(f"Spotify 403 Forbidden for {spotify_id} (token permissions?)") + else: + logger.error(f"Error fetching audio features for {spotify_id}: {e}") + return None + + + async def get_audio_features_batch(self, track_ids: List[str]) -> List[Optional[Dict[str, Any]]]: + """Get audio features for multiple tracks (max 100 per request).""" + if not track_ids: + return [] + + # Spotify API limit is 100 tracks per request + results = [] + for i in range(0, len(track_ids), 100): + batch = track_ids[i:i+100] + try: + data = await self._api_request("/audio-features", {"ids": ",".join(batch)}) + for features in data.get("audio_features", []): + if features: + results.append(self._format_audio_features(features)) + else: + results.append(None) + except Exception as e: + logger.error(f"Error fetching batch audio features: {e}") + results.extend([None] * len(batch)) + + return results + + def _format_audio_features(self, data: dict) -> dict: + """Format audio features for frontend.""" + key = data.get("key", -1) + mode = data.get("mode", 0) + return { + "track_id": data.get("id"), + "bpm": round(data.get("tempo", 0)), + "key": key, + "mode": mode, + "camelot": self._to_camelot(key, mode) if key >= 0 else "?", + "energy": round(data.get("energy", 0), 2), + "danceability": round(data.get("danceability", 0), 2), + "valence": round(data.get("valence", 0), 2), # "happiness" + } + + # ========== UTILITIES ========== + + def _get_best_image(self, images: List[Dict]) -> Optional[str]: + if not images: + return None + sorted_images = sorted(images, key=lambda x: x.get("width", 0), reverse=True) + return sorted_images[0]["url"] if sorted_images else None + + def _format_duration(self, ms: int) -> str: + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def get_made_for_you_playlists(self) -> List[Dict[str, Any]]: + """ + Get 'Made For You' playlists (Daily Mix, Discover Weekly, etc.). + Uses search API with strict filtering for Spotify-owned playlists. + Requires authenticated token (sp_dc cookie). + """ + try: + # Check if we have a valid token (will try cookie auth) + token = await self._get_access_token() + if not token: + logger.warning("No Spotify token available for Made For You") + return [] + + mixes = [] + queries = ["Daily Mix", "Discover Weekly", "Release Radar", "On Repeat", "Repeat Rewind"] + + for q in queries: + try: + data = await self._api_request("/search", {"q": q, "type": "playlist", "limit": 10}) + if not data: + continue + + items = data.get("playlists", {}).get("items", []) + for item in items: + if not item: + continue + + owner_id = item.get("owner", {}).get("id", "") + name = item.get("name", "") + + # Filter: owned by "spotify" OR name starts with one of our keywords + # (Daily Mix 1, Daily Mix 2, etc. are personalized) + is_spotify_owned = owner_id == "spotify" + name_matches = any(name.startswith(kw) or name == kw for kw in queries) + + if is_spotify_owned or name_matches: + mixes.append({ + "id": item["id"], + "name": name, + "description": item.get("description", ""), + "image": self._get_best_image(item.get("images", [])), + "owner": "Spotify", + "type": "playlist", + "source": "spotify" + }) + except Exception as e: + logger.warning(f"Failed to fetch mix '{q}': {e}") + + # Deduplicate by ID + unique_mixes = {m['id']: m for m in mixes}.values() + logger.info(f"Found {len(list(unique_mixes))} Made For You playlists") + return list(unique_mixes) + + except Exception as e: + logger.error(f"Error fetching Made For You playlists: {e}") + return [] + + async def close(self): + await self.client.aclose() + + +# Singleton instance +spotify_service = SpotifyService() diff --git a/app/ytmusic_service.py b/app/ytmusic_service.py new file mode 100644 index 0000000..e9bffc6 --- /dev/null +++ b/app/ytmusic_service.py @@ -0,0 +1,158 @@ +""" +YouTube Music service for Freedify. +Uses ytmusicapi for searching YouTube Music catalog. +Streaming is handled by existing audio_service (yt-dlp). +""" +from ytmusicapi import YTMusic +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class YTMusicService: + """Service for searching YouTube Music.""" + + def __init__(self): + # Initialize without auth (works for search) + self.ytm = YTMusic() + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for songs on YouTube Music.""" + try: + # YTMusic doesn't have native offset, so we fetch more and slice + total_needed = offset + limit + results = self.ytm.search(query, filter="songs", limit=total_needed) + # Slice to get the offset range + sliced = results[offset:offset + limit] if offset > 0 else results[:limit] + return [self._format_track(item) for item in sliced if item.get("videoId")] + except Exception as e: + logger.error(f"YTMusic search error: {e}") + return [] + + async def search_albums(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search for albums on YouTube Music.""" + try: + results = self.ytm.search(query, filter="albums", limit=limit) + return [self._format_album(item) for item in results if item.get("browseId")] + except Exception as e: + logger.error(f"YTMusic album search error: {e}") + return [] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album details with tracks.""" + try: + # Remove ytm_ prefix if present + clean_id = album_id.replace("ytm_", "") + data = self.ytm.get_album(clean_id) + + album = { + "id": f"ytm_{clean_id}", + "type": "album", + "name": data.get("title", ""), + "artists": ", ".join([a.get("name", "") for a in data.get("artists", [])]), + "album_art": self._get_thumbnail(data.get("thumbnails")), + "total_tracks": data.get("trackCount", 0), + "release_date": data.get("year", ""), + "source": "ytmusic", + } + + tracks = [] + for item in data.get("tracks", []): + if not item.get("videoId"): + continue + track = self._format_track(item) + track["album"] = album["name"] + track["album_art"] = album["album_art"] + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"YTMusic get_album error: {e}") + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend.""" + artists = item.get("artists", []) + artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else "" + + # Get album info if available + album = item.get("album", {}) or {} + + # Duration can be in seconds or as string "3:45" + duration_str = item.get("duration", "0:00") + duration_ms = self._parse_duration(duration_str) + + return { + "id": f"ytm_{item.get('videoId', '')}", + "type": "track", + "name": item.get("title", ""), + "artists": artist_str, + "artist_names": [a.get("name", "") for a in artists], + "album": album.get("name", "") if isinstance(album, dict) else str(album), + "album_id": f"ytm_{album.get('id', '')}" if isinstance(album, dict) else "", + "album_art": self._get_thumbnail(item.get("thumbnails")), + "duration_ms": duration_ms, + "duration": duration_str if isinstance(duration_str, str) else self._format_duration(duration_ms), + "isrc": f"ytm_{item.get('videoId', '')}", # Use prefixed videoId for streaming + "source": "ytmusic", + "video_id": item.get("videoId", ""), # Keep for reference + } + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + artists = item.get("artists", []) + artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else "" + + return { + "id": f"ytm_{item.get('browseId', '')}", + "type": "album", + "name": item.get("title", ""), + "artists": artist_str, + "album_art": self._get_thumbnail(item.get("thumbnails")), + "release_date": item.get("year", ""), + "source": "ytmusic", + } + + def _get_thumbnail(self, thumbnails: list) -> str: + """Get highest quality thumbnail.""" + if not thumbnails: + return "/static/icon.svg" + # Sort by width descending and get the largest + sorted_thumbs = sorted(thumbnails, key=lambda x: x.get("width", 0), reverse=True) + url = sorted_thumbs[0].get("url", "/static/icon.svg") + + # Proxy googleusercontent images to avoid 429 + if "googleusercontent.com" in url or "ggpht.com" in url: + import urllib.parse + return f"/api/proxy_image?url={urllib.parse.quote(url)}" + + return url + + def _parse_duration(self, duration) -> int: + """Parse duration string to milliseconds.""" + if isinstance(duration, int): + return duration * 1000 + if not duration or not isinstance(duration, str): + return 0 + try: + parts = duration.split(":") + if len(parts) == 2: + return (int(parts[0]) * 60 + int(parts[1])) * 1000 + elif len(parts) == 3: + return (int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])) * 1000 + return 0 + except: + return 0 + + def _format_duration(self, ms: int) -> str: + """Format milliseconds to mm:ss.""" + seconds = ms // 1000 + mins = seconds // 60 + secs = seconds % 60 + return f"{mins}:{secs:02d}" + + +# Singleton instance +ytmusic_service = YTMusicService() diff --git a/check.json b/check.json new file mode 100644 index 0000000..e69de29 diff --git a/icons/circle-x.svg b/icons/circle-x.svg new file mode 100644 index 0000000..3625f95 --- /dev/null +++ b/icons/circle-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/deezer.png b/icons/deezer.png new file mode 100644 index 0000000..db9cdd1 Binary files /dev/null and b/icons/deezer.png differ diff --git a/icons/download.svg b/icons/download.svg new file mode 100644 index 0000000..c77f62c --- /dev/null +++ b/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..8686c16 Binary files /dev/null and b/icons/icon.ico differ diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..455ce78 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tidal.png b/icons/tidal.png new file mode 100644 index 0000000..8d37f2b Binary files /dev/null and b/icons/tidal.png differ diff --git a/icons/tool.svg b/icons/tool.svg new file mode 100644 index 0000000..29229f9 --- /dev/null +++ b/icons/tool.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/trash.svg b/icons/trash.svg new file mode 100644 index 0000000..20fb5cb --- /dev/null +++ b/icons/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..dbc0c26 --- /dev/null +++ b/render.yaml @@ -0,0 +1,12 @@ +# Render deployment configuration for Freedify + +services: + - type: web + name: freedify + runtime: docker + plan: free + envVars: + - key: MP3_BITRATE + value: "320k" + healthCheckPath: /api/health + autoDeploy: true diff --git a/screenshots/album-details.png b/screenshots/album-details.png new file mode 100644 index 0000000..9e60bc6 Binary files /dev/null and b/screenshots/album-details.png differ diff --git a/screenshots/album-search.png b/screenshots/album-search.png new file mode 100644 index 0000000..53ee81c Binary files /dev/null and b/screenshots/album-search.png differ diff --git a/screenshots/download-formats.png b/screenshots/download-formats.png new file mode 100644 index 0000000..b3e8581 Binary files /dev/null and b/screenshots/download-formats.png differ diff --git a/screenshots/equalizer.png b/screenshots/equalizer.png new file mode 100644 index 0000000..08eacdc Binary files /dev/null and b/screenshots/equalizer.png differ diff --git a/screenshots/fullscreen-player.png b/screenshots/fullscreen-player.png new file mode 100644 index 0000000..7570762 Binary files /dev/null and b/screenshots/fullscreen-player.png differ diff --git a/screenshots/genius-annotations.png b/screenshots/genius-annotations.png new file mode 100644 index 0000000..80010d4 Binary files /dev/null and b/screenshots/genius-annotations.png differ diff --git a/screenshots/genius-lyrics.png b/screenshots/genius-lyrics.png new file mode 100644 index 0000000..747e8b3 Binary files /dev/null and b/screenshots/genius-lyrics.png differ diff --git a/screenshots/milkdrop-visualizer-2.png b/screenshots/milkdrop-visualizer-2.png new file mode 100644 index 0000000..95746a5 Binary files /dev/null and b/screenshots/milkdrop-visualizer-2.png differ diff --git a/screenshots/milkdrop-visualizer.png b/screenshots/milkdrop-visualizer.png new file mode 100644 index 0000000..7a79de0 Binary files /dev/null and b/screenshots/milkdrop-visualizer.png differ diff --git a/screenshots/podcast-episode.png b/screenshots/podcast-episode.png new file mode 100644 index 0000000..2e11916 Binary files /dev/null and b/screenshots/podcast-episode.png differ diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..52cf508 --- /dev/null +++ b/static/app.js @@ -0,0 +1,6536 @@ +/** + * Freedify - Music Streaming PWA + * Enhanced search with albums, artists, playlists, and Spotify URL support + */ + +// ========== STATE ========== +const state = { + queue: [], + currentIndex: -1, + isPlaying: false, + searchType: 'track', + detailTracks: [], // Tracks in current detail view + repeatMode: 'none', // 'none' | 'all' | 'one' + volume: parseFloat(localStorage.getItem('freedify_volume')) || 1, + muted: false, + crossfadeDuration: 1, // seconds (when crossfade is enabled) + crossfadeEnabled: localStorage.getItem('freedify_crossfade') === 'true', // Crossfade toggle + playlists: JSON.parse(localStorage.getItem('freedify_playlists') || '[]'), // User playlists + scrobbledCurrent: false, // Track if current song was scrobbled + listenBrainzConfig: { valid: false, username: null }, // LB status + hiResMode: localStorage.getItem('freedify_hires') !== 'false', // Hi-Res 24-bit mode (Default True) + sortOrder: 'newest', // 'newest' or 'oldest' for album sorting + lastSearchResults: [], // Store last search results for re-rendering + lastSearchType: 'track', // Store last search type +}; + +// ========== DOM ELEMENTS ========== +// App.js v0106L - Robust Proxy Cleanup +console.log("Freedify v0106L Loaded - Robust Proxy Cleanup"); + + +// Helper for multiple selectors (Fix for ReferenceError: $$ is not defined) +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => document.querySelectorAll(selector); + +// Global Event Delegation for Detail Tracks (Fixes click issues) +document.addEventListener('click', (e) => { + // Check if click is inside detail-tracks + const trackItem = e.target.closest('#detail-tracks .track-item'); + if (!trackItem) return; + + // Don't play if clicking buttons + if (e.target.closest('.download-btn') || e.target.closest('.delete-track-btn') || e.target.closest('.info-btn')) return; + + const index = parseInt(trackItem.dataset.index, 10); + if (isNaN(index)) return; + + // Auto-queue logic + const sourceTracks = (state.detailTracks && state.detailTracks.length > 0) ? state.detailTracks : []; + + if (sourceTracks.length === 0) return; + + // Don't auto-queue podcast episodes - let user view details and explicitly play + const clickedTrack = sourceTracks[index]; + if (clickedTrack && clickedTrack.source === 'podcast') { + // Show the podcast modal instead of queuing + showPodcastModal(encodeURIComponent(JSON.stringify(clickedTrack))); + return; + } + + const remainingTracks = sourceTracks.slice(index); + + state.queue = remainingTracks; + state.currentIndex = 0; + + showToast(`Queueing ${remainingTracks.length} tracks...`); + + updateQueueUI(); + + // Check if this track is already preloaded - use it instantly! + if (preloadedTrackId === clickedTrack.id && preloadedReady && preloadedPlayer) { + console.log('Using preloaded track (detail click):', clickedTrack.name); + preloadedTrackId = null; + preloadedReady = false; + updatePlayerUI(); + updateFullscreenUI(clickedTrack); + performGaplessSwitch(); + updateFormatBadge(getActivePlayer().src); + setTimeout(preloadNextTrack, 500); + } else { + loadTrack(clickedTrack); + } +}); + +const searchInput = $('#search-input'); +const searchClear = $('#search-clear'); +const typeBtns = $$('.type-btn'); +const resultsSection = $('#results-section'); +const resultsContainer = $('#results-container'); +const detailView = $('#detail-view'); +const detailInfo = $('#detail-info'); +const detailTracks = $('#detail-tracks'); +const backBtn = $('#back-btn'); +const queueAllBtn = $('#queue-all-btn'); +const shuffleBtn = $('#shuffle-btn'); +const queueSection = $('#queue-section'); +const queueContainer = $('#queue-container'); +const queueClose = $('#queue-close'); +const queueClear = $('#queue-clear'); +const queueCount = $('#queue-count'); +const queueBtn = $('#queue-btn'); + +// Fullscreen Elements +const fsToggleBtn = $('#fs-toggle-btn'); +const fullscreenPlayer = $('#fullscreen-player'); +const fsCloseBtn = $('#fs-close-btn'); +const fsArt = $('#fs-art'); +const fsTitle = $('#fs-title'); +const fsArtist = $('#fs-artist'); +const fsCurrentTime = $('#fs-current-time'); +const fsDuration = $('#fs-duration'); +const fsProgressBar = $('#fs-progress-bar'); +const fsPlayBtn = $('#fs-play-btn'); +const fsPrevBtn = $('#fs-prev-btn'); +const fsNextBtn = $('#fs-next-btn'); +const loadingOverlay = $('#loading-overlay'); +const loadingText = $('#loading-text'); +const errorMessage = $('#error-message'); +const errorText = $('#error-text'); +const errorRetry = $('#error-retry'); +const playerBar = $('#player-bar'); +const playerArt = $('#player-art'); +const playerTitle = $('#player-title'); +const playerArtist = $('#player-artist'); +const playerAlbum = $('#player-album'); +const playerYear = $('#player-year'); +const playBtn = $('#play-btn'); +const prevBtn = $('#prev-btn'); +const nextBtn = $('#next-btn'); +const shuffleQueueBtn = $('#shuffle-queue-btn'); +const repeatBtn = $('#repeat-btn'); +const progressBar = $('#progress-bar'); +const currentTime = $('#current-time'); +const duration = $('#duration'); +const audioPlayer = $('#audio-player'); +const audioPlayer2 = $('#audio-player-2'); +const miniPlayerBtn = $('#mini-player-btn'); +let pipWindow = null; + +// Crossfade / Gapless state +let activePlayer = 1; // 1 or 2, which player is currently active +let crossfadeEnabled = localStorage.getItem('freedify_crossfade') === 'true'; +let CROSSFADE_DURATION = 1000; // Default: 1 second. Options: 500, 1000, 2000 +let crossfadeTimeout = null; +let preloadedPlayer = null; // Ready player with next track loaded +let preloadedReady = false; // True when preloaded track has fired canplaythrough + +// Volume Controls +const volumeSlider = $('#volume-slider'); +const muteBtn = $('#mute-btn'); + +// Toast & Shortcuts +const toastContainer = $('#toast-container'); +const shortcutsHelp = $('#shortcuts-help'); +const shortcutsClose = $('#shortcuts-close'); + +// ========== SEARCH ========== +let searchTimeout = null; +// Only search on Enter key press (not as-you-type to avoid rate limiting) + +searchInput.addEventListener('input', (e) => { + // Just clear empty state when typing + if (!e.target.value.trim()) { + showEmptyState(); + } +}); + +searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + clearTimeout(searchTimeout); + const query = searchInput.value.trim(); + if (query) { + performSearch(query); + } + searchInput.blur(); + } +}); + +searchClear.addEventListener('click', () => { + searchInput.value = ''; + showEmptyState(); + searchInput.focus(); +}); + +const searchMoreBtn = $('#search-more-btn'); +const searchMoreMenu = $('#search-more-menu'); + +// Toggle Search More Menu +if (searchMoreBtn) { + searchMoreBtn.addEventListener('click', (e) => { + e.stopPropagation(); + searchMoreMenu.classList.toggle('hidden'); + }); +} + +// Close menu when clicking elsewhere +document.addEventListener('click', (e) => { + if (searchMoreMenu && !searchMoreMenu.contains(e.target) && e.target !== searchMoreBtn) { + searchMoreMenu.classList.add('hidden'); + } +}); + +// Search type selector +// Re-select all type buttons including new menu items +const allTypeBtns = document.querySelectorAll('.type-btn, .type-btn-menu'); + +allTypeBtns.forEach(btn => { + btn.addEventListener('click', () => { + if (btn.id === 'search-more-btn') return; // Skip the toggle button itself + + allTypeBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // If it's a menu item, highlight the "More" button too as a visual indicator + if (btn.classList.contains('type-btn-menu')) { + searchMoreBtn.classList.add('active'); + searchMoreMenu.classList.add('hidden'); // Close menu on selection + } + + state.searchType = btn.dataset.type; + + // Special types + if (state.searchType === 'favorites') { + renderPlaylistsView(); + return; + } else if (state.searchType === 'rec') { + renderRecommendations(); + return; + } + + const query = searchInput.value.trim(); + if (query) performSearch(query); + }); +}); + +// Sort Filter Removed + + +// Crossfade Toggle +const crossfadeCheckbox = $('#crossfade-checkbox'); +if (crossfadeCheckbox) { + // Initialize from state + crossfadeCheckbox.checked = state.crossfadeEnabled; + + crossfadeCheckbox.addEventListener('change', () => { + state.crossfadeEnabled = crossfadeCheckbox.checked; + localStorage.setItem('freedify_crossfade', state.crossfadeEnabled); + showToast(state.crossfadeEnabled ? 'Crossfade enabled' : 'Crossfade disabled'); + }); +} + +// ========== PLAYLIST MANAGEMENT ========== +function savePlaylists() { + localStorage.setItem('freedify_playlists', JSON.stringify(state.playlists)); +} + +function createPlaylist(name, tracks = []) { + const playlist = { + id: 'playlist_' + Date.now(), + name: name, + created: new Date().toISOString(), + tracks: tracks.map(t => ({ + id: t.id, + name: t.name, + artists: t.artists, + album: t.album || '', + album_art: t.album_art || t.image || '/static/icon.svg', + isrc: t.isrc || t.id, + duration: t.duration || '0:00' + })) + }; + state.playlists.push(playlist); + savePlaylists(); + showToast(`Created playlist "${name}"`); + return playlist; +} + +function addToPlaylist(playlistId, trackOrTracks) { + const playlist = state.playlists.find(p => p.id === playlistId); + if (!playlist) return; + + const tracksToAdd = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; + let addedCount = 0; + + tracksToAdd.forEach(track => { + // Avoid duplicates + if (playlist.tracks.some(t => t.id === track.id)) return; + + playlist.tracks.push({ + id: track.id, + name: track.name, + artists: track.artists, + album: track.album || '', + album_art: track.album_art || track.image || '/static/icon.svg', + isrc: track.isrc || track.id, + duration: track.duration || '0:00' + }); + addedCount++; + }); + + if (addedCount > 0) { + savePlaylists(); + showToast(`Added ${addedCount} tracks to "${playlist.name}"`); + } else { + showToast('Tracks already in playlist'); + } +} + +function deleteFromPlaylist(playlistId, trackId) { + const playlist = state.playlists.find(p => p.id === playlistId); + if (!playlist) return; + + const idx = playlist.tracks.findIndex(t => t.id === trackId); + if (idx !== -1) { + playlist.tracks.splice(idx, 1); + savePlaylists(); + showToast('Track removed'); + // Refresh view if currently viewing this playlist + if (state.currentPlaylistView === playlistId) { + showPlaylistDetail(playlist); + } + } +} + +// Expose for use in detail view +window.deleteFromPlaylist = deleteFromPlaylist; + +function deletePlaylist(playlistId) { + state.playlists = state.playlists.filter(p => p.id !== playlistId); + savePlaylists(); + showToast('Playlist deleted'); + renderPlaylistsView(); +} + +function renderPlaylistsView() { + hideLoading(); + + if (state.playlists.length === 0) { + resultsContainer.innerHTML = ` +
+ ❤️ +

No saved playlists yet

+

Import a Spotify playlist and click "Save to Playlist"

+
+ `; + return; + } + + const grid = document.createElement('div'); + grid.className = 'results-grid'; + + state.playlists.forEach(playlist => { + const trackCount = playlist.tracks.length; + const coverArt = playlist.tracks[0]?.album_art || '/static/icon.svg'; + grid.innerHTML += ` +
+
+ ${playlist.name} +
+ +
+
+
+
${playlist.name}
+
${trackCount} track${trackCount !== 1 ? 's' : ''}
+
+ +
+ `; + }); + + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(grid); + + // Click handlers + grid.querySelectorAll('.playlist-item').forEach(el => { + el.addEventListener('click', (e) => { + if (e.target.closest('.delete-playlist-btn')) { + e.stopPropagation(); + const id = el.dataset.playlistId; + if (confirm('Delete this playlist?')) { + deletePlaylist(id); + } + return; + } + const playlist = state.playlists.find(p => p.id === el.dataset.playlistId); + if (playlist) { + showPlaylistDetail(playlist); + } + }); + }); +} + +function showPlaylistDetail(playlist) { + // Track which playlist is being viewed + state.currentPlaylistView = playlist.id; + + // Reuse the existing detail view + const albumData = { + id: playlist.id, + name: playlist.name, + artists: `${playlist.tracks.length} tracks`, + image: playlist.tracks[0]?.album_art || '/static/icon.svg', + is_playlist: true, + is_user_playlist: true // Flag to indicate this is a user-created playlist (for delete buttons) + }; + showDetailView(albumData, playlist.tracks); +} + +async function performSearch(query, append = false) { + if (!query) return; + + // Track search state for Load More + if (!append) { + state.searchOffset = 0; + state.lastSearchQuery = query; + } + + showLoading(append ? 'Loading more...' : `Searching for "${query}"...`); + + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${state.searchType}&offset=${state.searchOffset}`); + const data = await response.json(); + + if (!response.ok) throw new Error(data.detail || 'Search failed'); + + hideLoading(); + + // Check if it was a Spotify URL + // Check if it was a Spotify/Imported URL + if (data.is_url) { + // Auto-open detail view for albums/playlists + if (data.tracks && (data.type === 'album' || data.type === 'playlist' || data.type === 'artist')) { + showDetailView(data.results[0], data.tracks); + return; + } + // Auto-play single track (e.g. YouTube link) + if (data.results && data.results.length === 1 && data.type === 'track') { + const track = data.results[0]; + playTrack(track); + showToast(`Playing imported track: ${track.name}`); + // Also render it so they can see it + } + } + + renderResults(data.results, data.type || state.searchType, append); + + // Update offset for next load + state.searchOffset += data.results.length; + + // Show/hide Load More button + const loadMoreBtn = $('#load-more-btn'); + if (loadMoreBtn) { + if (data.results.length >= 20) { + loadMoreBtn.classList.remove('hidden'); + } else { + loadMoreBtn.classList.add('hidden'); + } + } + + } catch (error) { + console.error('Search error:', error); + showError(error.message || 'Search failed. Please try again.'); + } +} + +function renderResults(results, type, append = false) { + const loadMoreBtn = $('#load-more-btn'); + + // Store results for re-rendering (when sort changes) + if (!append) { + state.lastSearchResults = results || []; + state.lastSearchType = type; + } else if (results) { + state.lastSearchResults = [...state.lastSearchResults, ...results]; + } + + if (!results || results.length === 0) { + if (!append) { + resultsContainer.innerHTML = ` +
+ 🔍 +

No results found

+
+ `; + if (loadMoreBtn) loadMoreBtn.classList.add('hidden'); + } + return; + } + + let grid; + // Helper to get or create Load More button + let persistentLoadMoreBtn = document.getElementById('load-more-btn'); + if (persistentLoadMoreBtn) { + persistentLoadMoreBtn.remove(); // Rescue it + } else { + // Create fresh if missing (e.g. after view switch) + persistentLoadMoreBtn = document.createElement('button'); + persistentLoadMoreBtn.id = 'load-more-btn'; + persistentLoadMoreBtn.className = 'load-more-btn hidden'; + persistentLoadMoreBtn.textContent = 'Load More Results'; + persistentLoadMoreBtn.addEventListener('click', () => { + if (state.lastSearchQuery) { + performSearch(state.lastSearchQuery, true); + } + }); + } + + if (append) { + // Get existing grid or create new + grid = resultsContainer.querySelector('.results-grid') || resultsContainer.querySelector('.results-list'); + if (!grid) { + grid = document.createElement('div'); + // Use list layout for tracks, grid for others + grid.className = (type === 'track') ? 'results-list' : 'results-grid'; + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(grid); + } + } else { + grid = document.createElement('div'); + // Use list layout for tracks, grid for others + grid.className = (type === 'track') ? 'results-list' : 'results-grid'; + + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(grid); + } + + // For 'podcast' we reuse album card style + if (type === 'podcast') { + // For 'podcast' we reuse album card style + results.forEach(item => { + grid.innerHTML += renderAlbumCard(item); + }); + } else if (type === 'track') { + results.forEach(track => { + grid.innerHTML += renderTrackCard(track); + }); + } else if (type === 'album') { + results.forEach(album => { + grid.innerHTML += renderAlbumCard(album); + }); + } else if (type === 'artist') { + results.forEach(artist => { + grid.innerHTML += renderArtistCard(artist); + }); + } + // Always append Load More button at the very end + if (persistentLoadMoreBtn) { + resultsContainer.appendChild(persistentLoadMoreBtn); + } + + // Attach click listeners + if (type === 'track') { + grid.querySelectorAll('.track-item').forEach(el => { + // Main card click (Play) + el.addEventListener('click', (e) => { + const trackId = String(el.dataset.id); + const track = results.find(t => String(t.id) === trackId); + console.log('Track card clicked, ID:', trackId, 'Found:', track?.name); + if (track) { + playTrack(track); + showToast(`Playing "${track.name}"`); + } + }); + + // Queue button click + const queueBtn = el.querySelector('.queue-btn'); + if (queueBtn) { + queueBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const trackId = String(el.dataset.id); + const track = results.find(t => String(t.id) === trackId); + if (track) addToQueue(track); + }); + } + }); + + // Fetch features regarding DJ Mode + if (state.djMode) { + fetchAudioFeaturesForTracks(results); + } + } else if (type === 'album') { + // Album cards - open album modal + grid.querySelectorAll('.album-card').forEach(el => { + el.addEventListener('click', () => { + console.log('Album card clicked, ID:', el.dataset.id); + openAlbum(el.dataset.id); + }); + }); + } else if (type === 'podcast') { + // Podcast cards - open podcast episodes (not album modal) + grid.querySelectorAll('.album-card').forEach(el => { + el.addEventListener('click', () => { + console.log('Podcast card clicked, ID:', el.dataset.id); + openPodcastEpisodes(el.dataset.id); + }); + }); + } else if (type === 'artist') { + grid.querySelectorAll('.artist-item').forEach((el, i) => { + el.addEventListener('click', () => openArtist(results[i].id)); + }); + } + + +} + +// Add track to queue (called from Queue button click) +function addToQueue(track) { + if (!track) return; + state.queue.push(track); + updateQueueUI(); + showToast(`Added "${track.name}" to queue`); +} + +const downloadModal = $('#download-modal'); +const downloadTrackName = $('#download-track-name'); +const downloadFormat = $('#download-format'); +const downloadCancelBtn = $('#download-cancel-btn'); +const downloadConfirmBtn = $('#download-confirm-btn'); +const downloadAllBtn = $('#download-all-btn'); // New button +let trackToDownload = null; +let isBatchDownload = false; // Flag for batch mode + +// ... functions ... + +// ========== DOWNLOAD LOGIC ========== + +window.openDownloadModal = function(trackJson) { + const track = JSON.parse(decodeURIComponent(trackJson)); + trackToDownload = track; + isBatchDownload = false; + + // Check if we are coming from detailed view (Album/Playlist) + if (!detailView.classList.contains('hidden')) { + state.pendingAlbumReopen = true; + } + + downloadTrackName.textContent = `${track.name} - ${track.artists}`; + downloadModal.classList.remove('hidden'); +}; + +if (downloadAllBtn) { + downloadAllBtn.addEventListener('click', () => { + if (state.detailTracks.length === 0) return; + + isBatchDownload = true; + trackToDownload = null; + + // Track previous view + if (!detailView.classList.contains('hidden')) { + state.pendingAlbumReopen = true; + } + + // Get album/playlist name + const name = $('.detail-name').textContent; + downloadTrackName.textContent = `All tracks from "${name}" (ZIP)`; + downloadModal.classList.remove('hidden'); + }); +} + +// Download current playing track buttons +const downloadCurrentBtn = $('#download-current-btn'); +const fsDownloadBtn = $('#fs-download-btn'); + +function downloadCurrentTrack() { + if (state.currentIndex < 0 || !state.queue[state.currentIndex]) { + showToast('No track playing'); + return; + } + const track = state.queue[state.currentIndex]; + trackToDownload = track; + isBatchDownload = false; + downloadTrackName.textContent = `${track.name} - ${track.artists}`; + + // Filter format options based on track source + updateDownloadFormatOptions(track); + + downloadModal.classList.remove('hidden'); +} + +// Update download format options based on track source quality +function updateDownloadFormatOptions(track) { + const source = track?.source || ''; + const formatSelect = $('#download-format'); + const hiresGroup = $('#hires-formats'); + const sourceHint = $('#download-source-hint'); + + // Categorize sources + const isHiResSource = source === 'dab' || source === 'qobuz'; + const isHiFiSource = source === 'deezer' || source === 'jamendo' || source === 'tidal'; + const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || + source === 'import' || source === 'archive' || source === 'phish' || + source === 'soundcloud' || source === 'bandcamp'; + + // Re-enable all options first + formatSelect.querySelectorAll('option, optgroup').forEach(el => { + el.disabled = false; + el.style.display = ''; + }); + + // Hide/show hint + if (sourceHint) { + sourceHint.classList.add('hidden'); + sourceHint.textContent = ''; + } + + if (isLossySource) { + // Lossy source: only MP3 available + formatSelect.querySelectorAll('option').forEach(opt => { + if (opt.dataset.minQuality !== 'lossy') { + opt.disabled = true; + opt.style.display = 'none'; + } + }); + // Hide optgroups for lossless + formatSelect.querySelectorAll('optgroup').forEach(grp => { + if (grp.label !== 'Lossy') { + grp.style.display = 'none'; + } + }); + formatSelect.value = 'mp3'; + if (sourceHint) { + sourceHint.textContent = `⚠️ Source is ${source || 'external'} - only MP3 available`; + sourceHint.classList.remove('hidden'); + } + } else if (isHiFiSource && !isHiResSource) { + // HiFi source (16-bit lossless): hide 24-bit options + if (hiresGroup) hiresGroup.style.display = 'none'; + formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { + opt.disabled = true; + opt.style.display = 'none'; + }); + formatSelect.value = 'flac'; + } else if (isHiResSource) { + // Hi-Res source: show 24-bit only if Hi-Res mode is enabled + if (!state.hiResMode) { + if (hiresGroup) hiresGroup.style.display = 'none'; + formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { + opt.disabled = true; + opt.style.display = 'none'; + }); + formatSelect.value = 'flac'; + if (sourceHint) { + sourceHint.textContent = '💡 Enable Hi-Res mode for 24-bit options'; + sourceHint.classList.remove('hidden'); + } + } else { + // All options available + formatSelect.value = 'flac_24'; + } + } else { + // Unknown source: default to 16-bit lossless, show 24-bit only if Hi-Res mode + if (!state.hiResMode) { + if (hiresGroup) hiresGroup.style.display = 'none'; + formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { + opt.disabled = true; + opt.style.display = 'none'; + }); + } + formatSelect.value = 'flac'; + } +} + +if (downloadCurrentBtn) { + downloadCurrentBtn.addEventListener('click', downloadCurrentTrack); +} + +if (fsDownloadBtn) { + fsDownloadBtn.addEventListener('click', downloadCurrentTrack); +} + +function closeDownloadModal() { + downloadModal.classList.add('hidden'); + trackToDownload = null; + isBatchDownload = false; + + // Restore Album/Playlist view if it was active + if (state.pendingAlbumReopen) { + detailView.classList.remove('hidden'); + state.pendingAlbumReopen = false; + // Also ensure Results are hidden if we are in detail view + resultsSection.classList.add('hidden'); + } +} + +downloadCancelBtn.addEventListener('click', closeDownloadModal); + +downloadConfirmBtn.addEventListener('click', async () => { + const format = downloadFormat.value; + const track = trackToDownload; // Capture before closing modal clears it + const isBatch = isBatchDownload; + + closeDownloadModal(); + + if (isBatch) { + // Batch Download Logic + const tracks = state.detailTracks; + // ... (rest of batch logic) + const name = $('.detail-name').textContent || 'Batch Download'; + const artist = $('.detail-artist').textContent; + const albumName = artist ? `${artist} - ${name}` : name; + + showLoading(`Preparing ZIP for "${albumName}" (${tracks.length} tracks)...`); + + try { + const response = await fetch('/api/download-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tracks: tracks.map(t => t.isrc || t.id), + names: tracks.map(t => t.name), + artists: tracks.map(t => t.artists), + album_name: albumName, + format: format + }) + }); + + if (!response.ok) throw new Error('Batch download failed'); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `${albumName}.zip`.replace(/[\\/:"*?<>|]/g, "_"); + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + + hideLoading(); + showToast('Batch download started!'); + + } catch (error) { + console.error('Batch download error:', error); + hideLoading(); + showError('Failed to create ZIP. Please try again.'); + } + return; + } + + if (!track) return; + + // Single Track Logic using captured track variable + showLoading(`Downloading "${track.name}" as ${format.toUpperCase()}...`); + + try { + const query = `${track.name} ${track.artists}`; + const isrc = track.isrc || track.id; + + const filename = `${track.name} - ${track.artists}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); + + const response = await fetch(`/api/download/${isrc}?q=${encodeURIComponent(query)}&format=${format}&filename=${encodeURIComponent(filename)}`); + + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + + hideLoading(); + showToast('Download started!'); + + } catch (error) { + console.error('Download error:', error); + hideLoading(); + showError('Failed to download track.'); + } +}); + + + +function renderTrackCard(track) { + const year = track.release_date ? track.release_date.slice(0, 4) : ''; + // Use horizontal list item layout + return ` +
+ ${escapeHtml(track.name)} +
+
${escapeHtml(track.name)}
+
${escapeHtml(track.artists)}
+
+ ${track.duration_ms ? formatTime(track.duration_ms / 1000) : (track.duration && track.duration.toString().includes(':') ? track.duration : formatTime(track.duration))} + +
+ `; +} + + +// ... (keep renderAlbumCard and renderArtistCard as is) ... + +function renderAlbumCard(album) { + const year = (album.release_date && album.release_date.length >= 4) ? album.release_date.slice(0, 4) : ''; + const trackCount = album.total_tracks ? `${album.total_tracks} tracks` : ''; + // Check for HiRes quality (if available from API) + const isHiRes = album.audio_quality?.isHiRes || album.is_hires || false; + const hiResBadge = isHiRes ? 'HI-RES' : ''; + + return ` +
+
+ ${escapeHtml(album.name)} + ${hiResBadge} +
+
+

${escapeHtml(album.name)}

+

${escapeHtml(album.artists)}

+
+ ${trackCount} + ${year} +
+
+
+ `; +} + +function renderArtistCard(artist) { + const followers = artist.followers ? `${(artist.followers / 1000).toFixed(0)}K followers` : ''; + return ` +
+ Artist +
+

${escapeHtml(artist.name)}

+

${artist.genres?.slice(0, 2).join(', ') || 'Artist'}

+

${followers}

+
+
+ `; +} + +async function openAlbum(albumId) { + // Intercept setlists to open special modal + if (albumId.startsWith('setlist_')) { + openSetlistModal(albumId); + return; + } + + showLoading('Loading album...'); + try { + const response = await fetch(`/api/album/${albumId}`); + const album = await response.json(); + if (!response.ok) throw new Error(album.detail); + + hideLoading(); + console.log('Opening album modal for:', album.name, album); + showAlbumModal(album); + } catch (error) { + console.error('Failed to load album:', error); + showError('Failed to load album'); + } +} + +// Open podcast episodes in detail view (not album modal) +async function openPodcastEpisodes(podcastId) { + showLoading('Loading podcast episodes...'); + try { + const response = await fetch(`/api/album/${podcastId}`); + const podcast = await response.json(); + if (!response.ok) throw new Error(podcast.detail); + + hideLoading(); + console.log('Opening podcast episodes:', podcast.name, podcast); + + // Use detail view for podcasts (allows clicking episodes for info modal) + showDetailView(podcast, podcast.tracks || []); + } catch (error) { + console.error('Failed to load podcast:', error); + showError('Failed to load podcast'); + } +} + +// ========== SETLIST MODAL ========== +const setlistModal = $('#setlist-modal'); +const setlistCloseBtn = $('#setlist-close-btn'); +const setlistInfo = $('#setlist-info'); +const setlistTracks = $('#setlist-tracks'); +const setlistPlayBtn = $('#setlist-play-btn'); +let currentSetlist = null; + +if (setlistCloseBtn) { + setlistCloseBtn.addEventListener('click', () => { + setlistModal.classList.add('hidden'); + }); +} + +if (setlistPlayBtn) { + setlistPlayBtn.addEventListener('click', () => { + if (currentSetlist) { + setlistModal.classList.add('hidden'); + // Check if we have a direct audio source URL or need to search + if (currentSetlist.audio_url) { + // Direct import (Phish.in) - use performSearch which handles URLs + performSearch(currentSetlist.audio_url); + } else if (currentSetlist.audio_search) { + // Search Archive.org (Artist Date) + performSearch(currentSetlist.audio_search); + } else { + showError("No audio source found for this setlist."); + } + } + }); +} + +async function openSetlistModal(setlistId) { + // Get modal elements fresh each time + const modal = document.getElementById('setlist-modal'); + const infoEl = document.getElementById('setlist-info'); + const tracksEl = document.getElementById('setlist-tracks'); + const playBtn = document.getElementById('setlist-play-btn'); + + if (!modal) { + showError("Setlist modal not available"); + return; + } + + showLoading('Fetching setlist...'); + try { + // Use existing endpoint which returns formatted setlist + const response = await fetch(`/api/album/${setlistId}`); + const setlist = await response.json(); + + if (!response.ok) throw new Error(setlist.detail); + + currentSetlist = setlist; + hideLoading(); + + // Render Modal Content + infoEl.innerHTML = ` +
+

${escapeHtml(setlist.artists)}

+

${escapeHtml(setlist.venue)}

+

${setlist.date || setlist.release_date}

+

+ ${setlist.city} +

+
+ `; + + tracksEl.innerHTML = setlist.tracks.map((track, i) => ` +
+ ${i + 1} +
+
+ ${escapeHtml(track.name)} + ${track.set_name ? `${track.set_name}` : ''} +
+ ${track.info ? `

${escapeHtml(track.info)}

` : ''} + ${track.cover_info ? `

(Cover of ${escapeHtml(track.cover_info)})

` : ''} +
+
+ `).join(''); + + // Show audio source button label + if (setlist.audio_source === 'phish.in') { + playBtn.textContent = "🎧 Listen on Phish.in"; + } else { + playBtn.textContent = "🎧 Search on Archive.org"; + } + + modal.classList.remove('hidden'); + + } catch (error) { + console.error(error); + showError('Failed to load setlist'); + } +} + +// ========== ALBUM DETAILS MODAL ========== +const albumModal = $('#album-modal'); +const albumModalClose = $('#album-modal-close'); +const albumModalOverlay = albumModal?.querySelector('.album-modal-overlay'); +let currentAlbumData = null; + +// Close modal +if (albumModalClose) { + albumModalClose.addEventListener('click', () => { + albumModal.classList.add('hidden'); + }); +} + +// Close on overlay click +if (albumModalOverlay) { + albumModalOverlay.addEventListener('click', () => { + albumModal.classList.add('hidden'); + }); +} + +// Tab switching +const albumTabs = albumModal?.querySelectorAll('.album-tab'); +albumTabs?.forEach(tab => { + tab.addEventListener('click', () => { + albumTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + const tabName = tab.dataset.tab; + if (tabName === 'tracks') { + $('#album-modal-tracks')?.classList.remove('hidden'); + $('#album-modal-info-tab')?.classList.add('hidden'); + } else { + $('#album-modal-tracks')?.classList.add('hidden'); + $('#album-modal-info-tab')?.classList.remove('hidden'); + } + }); +}); + +// Action buttons +$('#album-play-btn')?.addEventListener('click', () => { + if (currentAlbumData?.tracks?.length) { + state.queue = [...currentAlbumData.tracks]; + state.currentIndex = 0; + updateQueueUI(); + loadTrack(state.queue[0]); + albumModal.classList.add('hidden'); + showToast(`Playing "${currentAlbumData.name}"`); + } +}); + +$('#album-queue-btn')?.addEventListener('click', () => { + if (currentAlbumData?.tracks?.length) { + state.queue.push(...currentAlbumData.tracks); + updateQueueUI(); + albumModal.classList.add('hidden'); + showToast(`Added ${currentAlbumData.tracks.length} tracks to queue`); + } +}); + +$('#album-download-btn')?.addEventListener('click', () => { + if (currentAlbumData) { + isBatchDownload = true; + trackToDownload = null; + state.detailTracks = currentAlbumData.tracks; + downloadTrackName.textContent = `${currentAlbumData.name} (${currentAlbumData.tracks?.length || 0} tracks)`; + downloadModal.classList.remove('hidden'); + albumModal.classList.add('hidden'); + } +}); + +$('#album-playlist-btn')?.addEventListener('click', () => { + if (currentAlbumData?.tracks?.length) { + // Open add to playlist modal with all album tracks + if (typeof openAddToPlaylistModal === 'function') { + openAddToPlaylistModal(currentAlbumData.tracks, currentAlbumData.name); + } else { + showToast('Playlist feature coming soon'); + } + } +}); + +function showAlbumModal(album) { + if (!albumModal) return; + + currentAlbumData = album; + const tracks = album.tracks || []; + + // Populate modal data + $('#album-modal-art').src = album.album_art || album.image || '/static/icon.svg'; + $('#album-modal-title').textContent = album.name || 'Unknown Album'; + $('#album-modal-artist').textContent = album.artists || 'Unknown Artist'; + + // Metadata pills + const date = album.release_date || ''; + const genre = album.genres?.[0] || album.genre || ''; + const trackCount = tracks.length || album.total_tracks || 0; + const totalDuration = tracks.reduce((sum, t) => sum + (parseDuration(t.duration) || 0), 0); + const durationMins = Math.round(totalDuration / 60); + + $('#album-modal-date').textContent = `📅 ${date || 'Unknown'}`; + // Genre removed + $('#album-modal-trackcount').textContent = `🎵 ${trackCount} tracks`; + $('#album-modal-duration').textContent = `⏱️ ${durationMins || '??'} min`; + + // Quality badge + const format = album.format || (state.hifiMode ? 'FLAC' : 'MP3'); + const bitDepth = album.audio_quality?.maximumBitDepth || 16; + const sampleRate = album.audio_quality?.maximumSamplingRate || 44.1; + $('#album-modal-quality').textContent = `🎵 ${format} • ${bitDepth}bit / ${sampleRate}kHz`; + + // Render track list + const tracksContainer = $('#album-modal-tracks'); + tracksContainer.innerHTML = tracks.map((track, i) => ` +
+ ${i + 1}. + +
+

${escapeHtml(track.name)}

+
+ + ${track.duration || '--:--'} +
+ + +
+
+ `).join(''); + + // Track click handlers + tracksContainer.querySelectorAll('.album-track-play').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + state.queue = [...tracks]; + state.currentIndex = idx; + updateQueueUI(); + loadTrack(tracks[idx]); + albumModal.classList.add('hidden'); + }); + }); + + tracksContainer.querySelectorAll('[data-action="queue"]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + state.queue.push(tracks[idx]); + updateQueueUI(); + showToast(`Added "${tracks[idx].name}" to queue`); + }); + }); + + // Playlist button handler + tracksContainer.querySelectorAll('.album-track-playlist').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + const track = tracks[idx]; + // Open add to playlist modal + if (typeof openAddToPlaylistModal === 'function') { + openAddToPlaylistModal(track); + } else { + showToast('Playlist feature coming soon'); + } + }); + }); + + tracksContainer.querySelectorAll('[data-action="download"]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + // Hide album modal first so download modal appears on top + albumModal.classList.add('hidden'); + // Store album data so we can reopen after download closes + state.pendingAlbumReopen = { album, tracks }; + openDownloadModal(encodeURIComponent(JSON.stringify(tracks[idx]))); + }); + }); + + // Album Info tab + $('#album-modal-description').textContent = album.description || + `${album.name} by ${album.artists}. Released ${date}. ${trackCount} tracks.`; + + // Reset to tracks tab + albumTabs?.forEach(t => t.classList.remove('active')); + albumModal.querySelector('[data-tab="tracks"]')?.classList.add('active'); + $('#album-modal-tracks')?.classList.remove('hidden'); + $('#album-modal-info-tab')?.classList.add('hidden'); + + // Show modal + albumModal.classList.remove('hidden'); +} + +// Helper to parse duration string to seconds +function parseDuration(dur) { + if (!dur) return 0; + const parts = dur.split(':').map(Number); + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return 0; +} + +async function openArtist(artistId) { + showLoading('Loading artist...'); + try { + const response = await fetch(`/api/artist/${artistId}`); + const artist = await response.json(); + if (!response.ok) throw new Error(artist.detail); + + hideLoading(); + showDetailView(artist, artist.tracks); + } catch (error) { + showError('Failed to load artist'); + } +} + +// Updated showDetailView to handle downloads +function showDetailView(item, tracks) { + state.detailTracks = tracks || []; + const isUserPlaylist = item.is_user_playlist || false; + + // Render info section + const isArtist = item.type === 'artist'; + const image = item.album_art || item.image || '/static/icon.svg'; + const subtitle = item.artists || item.owner || (item.genres?.slice(0, 2).join(', ')) || ''; + const stats = item.total_tracks ? `${item.total_tracks} tracks` : + item.followers ? `${(item.followers / 1000).toFixed(0)}K followers` : ''; + + detailInfo.innerHTML = ` + Cover +
+

${escapeHtml(item.name)}

+

${escapeHtml(subtitle)}

+

${stats}

+
+ `; + + // Render tracks with download button (and delete for user playlists) + detailTracks.innerHTML = tracks.map((t, i) => ` +
+ Art +
+

${escapeHtml(t.name)}

+

${escapeHtml(t.artists)}

+
+ +
+ ${renderDJBadgeForTrack(t)} + ${t.duration} + ${t.source === 'podcast' ? ` + + ` : ''} + + ${isUserPlaylist ? ` + + ` : ''} +
+
+ `).join(''); + + // Show detail view + detailView.classList.remove('hidden'); + resultsSection.classList.add('hidden'); + + if (state.djMode && tracks.length > 0) { + fetchAudioFeaturesForTracks(tracks); + } +} + +// ========== DOWNLOAD LOGIC ========== + +window.openDownloadModal = function(trackJson) { + const track = JSON.parse(decodeURIComponent(trackJson)); + trackToDownload = track; + + downloadTrackName.textContent = `${track.name} - ${track.artists}`; + downloadModal.classList.remove('hidden'); +}; + +function closeDownloadModal() { + downloadModal.classList.add('hidden'); + trackToDownload = null; +} + +downloadCancelBtn.addEventListener('click', closeDownloadModal); + +downloadConfirmBtn.addEventListener('click', async () => { + if (!trackToDownload) return; + + const format = downloadFormat.value; + const track = trackToDownload; + + closeDownloadModal(); + + // Show non-blocking notification or toast could be better, but we'll use loading for now + // or just let it happen in background. Let's show a loading indicator. + showLoading(`Downloading "${track.name}" as ${format.toUpperCase()}...`); + + try { + const query = `${track.name} ${track.artists}`; + const isrc = track.isrc || track.id; // Fallback to ID if ISRC missing + + // Construct filename + const filename = `${track.artists} - ${track.name}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); + + const response = await fetch(`/api/download/${isrc}?q=${encodeURIComponent(query)}&format=${format}&filename=${encodeURIComponent(filename)}`); + + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + + hideLoading(); + + } catch (error) { + console.error('Download error:', error); + showError('Failed to download track. Please try again.'); + } +}); + +// Configure Save to Drive button +$('#download-drive-btn').addEventListener('click', async () => { + if (!trackToDownload) return; + + // Ensure signed in logic + if (!googleAccessToken) { + await signInWithGoogle(); + if (!googleAccessToken) return; // User cancelled or failed + } + + const format = downloadFormat.value; + const track = trackToDownload; + + closeDownloadModal(); + + showLoading(`Saving "${track.name}" to Google Drive (as ${format.toUpperCase()})...`); + + try { + // 1. Get Folder ID + const folderId = await findOrCreateFreedifyFolder(); + if (!folderId) throw new Error('Could not find or create "Freedify" folder'); + + // 2. Call Backend to Upload + const query = `${track.name} ${track.artists}`; + const isrc = track.isrc || track.id; + + // Construct filename + const filename = `${track.artists} - ${track.name}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); + + const response = await fetch('/api/drive/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + isrc: isrc, + access_token: googleAccessToken, + format: format, + folder_id: folderId, + filename: filename, + q: query + }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Upload failed'); + } + + const result = await response.json(); + hideLoading(); + showToast(`✅ Saved to Drive: ${result.name}`); + + } catch (error) { + console.error('Drive save error:', error); + hideLoading(); + showError(`Failed to save to Drive: ${error.message}`); + } +}); + +// Close modal on outside click +downloadModal.addEventListener('click', (e) => { + if (e.target === downloadModal) closeDownloadModal(); +}); + +// ========== KEYBOARD SHORTCUTS ========== +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + switch (e.code) { + case 'Space': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowRight': + if (e.shiftKey) getActivePlayer().currentTime += 10; + else playNext(); + break; + case 'ArrowLeft': + if (e.shiftKey) getActivePlayer().currentTime -= 10; + else playPrevious(); + break; + case 'Escape': + if (!downloadModal.classList.contains('hidden')) { + closeDownloadModal(); + } else if (!detailView.classList.contains('hidden')) { + hideDetailView(); + } + break; + } +}); + +// ========== SERVICE WORKER ========== +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(console.error); +} + +// Initial state +showEmptyState(); + + +// Duplicate render functions removed - using definitions at line 753 + +// ========== ALBUM / ARTIST / PLAYLIST DETAIL VIEW ========== +// Note: openAlbum is defined earlier with setlist modal support + +async function openArtist(artistId) { + showLoading('Loading artist...'); + try { + const response = await fetch(`/api/artist/${artistId}`); + const artist = await response.json(); + if (!response.ok) throw new Error(artist.detail); + + hideLoading(); + showDetailView(artist, artist.tracks); + } catch (error) { + showError('Failed to load artist'); + } +} + +function showDetailView(item, tracks) { + state.detailTracks = tracks || []; + + // Render info section + const isArtist = item.type === 'artist'; + const image = item.album_art || item.image || '/static/icon.svg'; + const subtitle = item.artists || item.owner || (item.genres?.slice(0, 2).join(', ')) || ''; + const stats = item.total_tracks ? `${item.total_tracks} tracks` : + item.followers ? `${(item.followers / 1000).toFixed(0)}K followers` : ''; + + // Check if this is already a saved playlist (to avoid re-saving) + const isSavedPlaylist = item.id && item.id.startsWith('playlist_'); + + detailInfo.innerHTML = ` + Cover +
+

${escapeHtml(item.name)}

+

${escapeHtml(subtitle)}

+

${stats}

+ ${!isSavedPlaylist && tracks.length > 0 ? `` : ''} +
+ `; + + // Add save playlist handler + const saveBtn = $('#save-playlist-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + const name = prompt('Enter playlist name:', item.name || 'My Playlist'); + if (name) { + createPlaylist(name, state.detailTracks); + } + }); + } + + // Render tracks + detailTracks.innerHTML = tracks.map((t, i) => ` +
+ Art +
+

${escapeHtml(t.name)}

+

${escapeHtml(t.artists)}

+
+ ${t.duration} +
+ `).join(''); + + // Add click handlers + $$('#detail-tracks .track-item').forEach((el, i) => { + el.addEventListener('click', () => { + const track = tracks[i]; + // Show podcast modal for podcast episodes, otherwise play directly + if (track.source === 'podcast' && track.description) { + showPodcastModal(track); + } else { + playTrack(track); + } + }); + }); + + // Show detail view + detailView.classList.remove('hidden'); + resultsSection.classList.add('hidden'); +} + +function hideDetailView() { + detailView.classList.add('hidden'); + resultsSection.classList.remove('hidden'); +} + +backBtn.addEventListener('click', hideDetailView); + +queueAllBtn.addEventListener('click', () => { + if (state.detailTracks.length === 0) return; + + // Add all tracks to queue + state.detailTracks.forEach(track => { + if (!state.queue.find(t => t.id === track.id)) { + state.queue.push(track); + } + }); + + updateQueueUI(); + + // Start playing first if nothing is playing + if (state.currentIndex === -1 && state.queue.length > 0) { + state.currentIndex = 0; + loadTrack(state.queue[0]); + } + + hideDetailView(); +}); + +// Shuffle & Play button +shuffleBtn.addEventListener('click', () => { + if (state.detailTracks.length === 0) return; + + // Copy and shuffle tracks using Fisher-Yates + const shuffled = [...state.detailTracks]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + // Clear queue and add shuffled tracks + state.queue = []; + shuffled.forEach(track => state.queue.push(track)); + + // Start playing first shuffled track + state.currentIndex = 0; + updateQueueUI(); + loadTrack(state.queue[0]); + + hideDetailView(); +}); + +// ========== PLAYBACK ========== +function playTrack(track) { + if (!track || !track.id) { + console.error("playTrack called with invalid track:", track); + return; + } + // Add to queue if not already there + const existingIndex = state.queue.findIndex(t => t && t.id === track.id); + if (existingIndex === -1) { + state.queue.push(track); + state.currentIndex = state.queue.length - 1; + } else { + state.currentIndex = existingIndex; + } + + updateQueueUI(); + + // Check if this track is already preloaded and ready - use it directly! + if (preloadedTrackId === track.id && preloadedReady && preloadedPlayer) { + console.log('Using preloaded track:', track.name); + + // Reset preload state + preloadedTrackId = null; + preloadedReady = false; + + // Update all UI + updatePlayerUI(); + updateFullscreenUI(track); + + // Switch to preloaded player + performGaplessSwitch(); + + // Update format badge + updateFormatBadge(getActivePlayer().src); + + // Preload the next one + setTimeout(preloadNextTrack, 500); + return; + } + + loadTrack(track); +} + +// ========== ALBUM ART COLOR EXTRACTION ========== +function extractDominantColor(imageUrl) { + // Create an image to load the album art + const img = new Image(); + img.crossOrigin = 'anonymous'; // Allow cross-origin images + + img.onload = () => { + try { + // Create a small canvas for sampling + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 10; // Sample at 10x10 for performance + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Draw the scaled-down image + ctx.drawImage(img, 0, 0, sampleSize, sampleSize); + + // Get pixel data + const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize); + const pixels = imageData.data; + + // Calculate average color (excluding very dark/light pixels) + let r = 0, g = 0, b = 0, count = 0; + for (let i = 0; i < pixels.length; i += 4) { + const pr = pixels[i], pg = pixels[i + 1], pb = pixels[i + 2]; + const brightness = (pr + pg + pb) / 3; + + // Skip very dark or very light pixels + if (brightness > 30 && brightness < 220) { + r += pr; + g += pg; + b += pb; + count++; + } + } + + if (count > 0) { + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + + // Apply to player section as a subtle gradient + const playerSection = $('.player-section'); + if (playerSection) { + playerSection.style.background = `linear-gradient(180deg, rgba(${r}, ${g}, ${b}, 0.15) 0%, var(--bg-primary) 100%)`; + } + } + } catch (e) { + // Canvas tainted or other error - silently ignore + console.log('Could not extract color from album art'); + } + }; + + img.onerror = () => { + // Reset to default if image fails + const playerSection = $('.player-section'); + if (playerSection) { + playerSection.style.background = ''; + } + }; + + img.src = imageUrl; +} + +function updatePlayerUI() { + if (state.currentIndex < 0 || !state.queue[state.currentIndex]) return; + const track = state.queue[state.currentIndex]; + + // Basic Info + playerBar.classList.remove('hidden'); + playerTitle.textContent = track.name; + playerArtist.textContent = track.artists || '-'; + + // Update visualizer info if active (Immersive Mode support) + if (visualizerActive && typeof showVisualizerInfoBriefly === 'function') { + showVisualizerInfoBriefly(); + } + + // Album name (clickable to open album) + if (playerAlbum) { + playerAlbum.textContent = track.album || '-'; + playerAlbum.dataset.albumId = track.album_id || ''; + playerAlbum.dataset.albumName = track.album || ''; + } + + // Release year only (extract from YYYY-MM-DD) + if (playerYear) { + const year = track.release_date ? track.release_date.slice(0, 4) : ''; + playerYear.textContent = year ? `(${year})` : ''; + } + + playerArt.src = track.album_art || '/static/icon.svg'; + + // Extract dominant color for player background + if (track.album_art) { + extractDominantColor(track.album_art); + } + + // DJ Mode Info + const playerDJInfo = $('#player-dj-info'); + if (state.djMode && playerDJInfo) { + // Use embedded audio_features for local tracks, cache for others + const isLocal = track.id?.startsWith('local_'); + const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; + + if (feat) { + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + playerDJInfo.innerHTML = ` +
+ ${feat.bpm} BPM + ${feat.camelot} +
+ `; + playerDJInfo.classList.remove('hidden'); + } else { + // If active track is missing features, fetch them (debounce/check logic needed to avoid loop?) + // fetchAudioFeaturesForTracks([track]); // Avoiding loop, fetch should handle it + playerDJInfo.innerHTML = '
'; + playerDJInfo.classList.remove('hidden'); + } + } else if (playerDJInfo) { + playerDJInfo.classList.add('hidden'); + } + + // Update Mini Player + if (pipWindow) updateMiniPlayer(); +} + +// Update audio format badge (FLAC/MP3) +async function updateFormatBadge(audioSrc) { + const badge = document.getElementById('audio-format-badge'); + if (!badge) return; + + // For local files, show nothing + if (!audioSrc || audioSrc.startsWith('blob:') || audioSrc.startsWith('file:')) { + badge.classList.add('hidden'); + return; + } + + // Get current track source to determine actual quality + const currentTrack = state.queue[state.currentIndex]; + const source = currentTrack?.source || ''; + + // Determine format based on source + const isHiResSource = source === 'dab' || source === 'qobuz'; + const isHiFiSource = source === 'deezer' || source === 'jamendo'; + const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import'; + + badge.classList.remove('hidden', 'mp3', 'flac', 'hi-res'); + + if (isHiResSource && state.hiResMode) { + // Hi-Res 24-bit (Dab/Qobuz with Hi-Res mode) + badge.classList.add('flac', 'hi-res'); + badge.textContent = 'Hi-Res'; + } else if (isHiResSource || isHiFiSource) { + // HiFi 16-bit FLAC (Deezer, Jamendo, or Dab without Hi-Res mode) + badge.classList.add('flac'); + badge.textContent = 'FLAC'; + } else if (isLossySource) { + // Lossy MP3/AAC (YouTube, podcasts, imports) + badge.classList.add('mp3'); + badge.textContent = 'MP3'; + } else { + // Unknown source - default based on preference + badge.classList.add('flac'); + if (state.hiResMode) { + badge.classList.add('hi-res'); + } + badge.textContent = 'FLAC'; + } + + // Also update the HiFi button in header + if (typeof updateHifiButtonUI === 'function') { + updateHifiButtonUI(); + } +} + +// Player artist/album click handlers for discovery +if (playerArtist) { + playerArtist.addEventListener('click', () => { + const artistName = playerArtist.textContent; + if (artistName && artistName !== '-') { + state.searchType = 'artist'; + document.querySelectorAll('.type-btn, .type-btn-menu').forEach(b => b.classList.remove('active')); + const artistBtn = document.querySelector('[data-type="artist"]'); + if (artistBtn) artistBtn.classList.add('active'); + searchInput.value = artistName; + performSearch(artistName); + } + }); +} + +if (playerAlbum) { + playerAlbum.addEventListener('click', () => { + const albumId = playerAlbum.dataset.albumId; + if (albumId) { + openAlbum(albumId); + } + }); +} + +// Track load state to prevent duplicates +let loadInProgress = false; +let loadTimeoutId = null; + +async function loadTrack(track) { + // Prevent duplicate loads + if (loadInProgress) { + console.log('Load already in progress, skipping duplicate load for:', track.name); + return; + } + + loadInProgress = true; + showLoading(`Loading "${track.name}"...`); + state.scrobbledCurrent = false; // Reset scrobble status + playerBar.classList.remove('hidden'); + + // Reset preload state on direct track load + preloadedTrackId = null; + preloadedPlayer = null; + if (crossfadeTimeout) { + clearTimeout(crossfadeTimeout); + crossfadeTimeout = null; + } + + // Clear any existing load timeout + if (loadTimeoutId) { + clearTimeout(loadTimeoutId); + loadTimeoutId = null; + } + + updatePlayerUI(); + updateQueueUI(); + updateFullscreenUI(track); // Sync FS + + // Get the active player + const player = getActivePlayer(); + const playerGain = activePlayer === 1 ? gainNode1 : gainNode2; + + // Make sure active player gain is at 1 + if (playerGain) playerGain.gain.value = 1; + + // For ListenBrainz tracks, try to enrich with album art from search + if (track.source === 'listenbrainz' && track.album_art === '/static/icon.svg') { + try { + const searchQuery = track.artists + ' ' + track.name; + const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=1`); + const searchData = await searchRes.json(); + if (searchData.results && searchData.results.length > 0) { + const foundTrack = searchData.results[0]; + if (foundTrack.album_art && foundTrack.album_art !== '/static/icon.svg') { + track.album_art = foundTrack.album_art; + console.log('Enriched LB track with album art:', foundTrack.album_art); + updatePlayerUI(); // Refresh the player bar with new art + updateFullscreenUI(track); + } + } + } catch (e) { + console.log('Could not enrich LB track art:', e); + } + } + + // Play + if (track.is_local && track.src) { + player.src = track.src; + } else { + const hiresParam = state.hiResMode ? '&hires=true' : '&hires=false'; + player.src = `/api/stream/${track.isrc || track.id}?q=${encodeURIComponent(track.name + ' ' + track.artists)}${hiresParam}`; + } + + try { + player.load(); + + await new Promise((resolve, reject) => { + const cleanup = () => { + player.oncanplay = null; + player.onerror = null; + if (loadTimeoutId) { + clearTimeout(loadTimeoutId); + loadTimeoutId = null; + } + }; + + player.oncanplay = () => { + cleanup(); + resolve(); + }; + player.onerror = () => { + cleanup(); + reject(new Error('Failed to load audio')); + }; + loadTimeoutId = setTimeout(() => { + cleanup(); + reject(new Error('Timeout loading audio')); + }, 120000); + }); + + hideLoading(); + player.play(); + state.isPlaying = true; + updatePlayButton(); + updateMediaSession(track); + + // Detect audio format and update badge + updateFormatBadge(player.src); + + } catch (error) { + console.error('Playback error:', error); + showError('Failed to load track. Please try again.'); + } finally { + loadInProgress = false; + } +} + +// Player controls +playBtn.addEventListener('click', togglePlay); +prevBtn.addEventListener('click', playPrevious); +if (miniPlayerBtn) miniPlayerBtn.addEventListener('click', toggleMiniPlayer); +nextBtn.addEventListener('click', playNext); + +// Shuffle current queue +shuffleQueueBtn.addEventListener('click', () => { + if (state.queue.length <= 1) return; + + // Get currently playing track + const currentTrack = state.queue[state.currentIndex]; + + // Remove current track from queue temporarily + const otherTracks = state.queue.filter((_, i) => i !== state.currentIndex); + + // Shuffle the other tracks using Fisher-Yates + for (let i = otherTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [otherTracks[i], otherTracks[j]] = [otherTracks[j], otherTracks[i]]; + } + + // Put current track at front, add shuffled tracks after + state.queue = [currentTrack, ...otherTracks]; + state.currentIndex = 0; + + updateQueueUI(); + + // Visual feedback + shuffleQueueBtn.style.transform = 'scale(1.2)'; + setTimeout(() => shuffleQueueBtn.style.transform = '', 200); +}); + +function togglePlay() { + const player = getActivePlayer(); + + // Check if player has source logic (for refresh case) + if (!player.src && state.queue.length > 0 && state.currentIndex >= 0) { + // Queue exists but nothing loaded yet (refresh case) + loadTrack(state.queue[state.currentIndex]); + return; + } + + if (player.paused) { + player.play().catch(e => { + console.warn('Play failed, trying to reload:', e); + if (state.queue[state.currentIndex]) { + loadTrack(state.queue[state.currentIndex]); + } + }); + } else { + player.pause(); + } +} + +function playNext() { + const currentTrack = state.queue[state.currentIndex]; + const player = getActivePlayer(); + // Podcast: seek +15s instead of next track + if (currentTrack && currentTrack.source === 'podcast') { + player.currentTime = Math.min(player.duration || 0, player.currentTime + 15); + return; + } + if (state.currentIndex < state.queue.length - 1) { + state.currentIndex++; + + // Try to use preloaded player (no loading screen) - ONLY if ready + if (preloadedReady && preloadedPlayer && preloadedTrackId === state.queue[state.currentIndex]?.id) { + console.log('playNext: Using preloaded player for:', state.queue[state.currentIndex].name); + preloadedTrackId = null; + preloadedReady = false; + updatePlayerUI(); + updateQueueUI(); + updateFullscreenUI(state.queue[state.currentIndex]); + performGaplessSwitch(); + updateFormatBadge(getActivePlayer().src); + updateMediaSession(state.queue[state.currentIndex]); + setTimeout(preloadNextTrack, 500); + } else { + loadTrack(state.queue[state.currentIndex]); + } + } +} + +function playPrevious() { + const currentTrack = state.queue[state.currentIndex]; + const player = getActivePlayer(); + // Podcast: seek -15s instead of prev track + if (currentTrack && currentTrack.source === 'podcast') { + player.currentTime = Math.max(0, player.currentTime - 15); + return; + } + if (player.currentTime > 3) { + player.currentTime = 0; + } else if (state.currentIndex > 0) { + state.currentIndex--; + loadTrack(state.queue[state.currentIndex]); + } +} + +// Shared event handlers for both audio players +function handlePlay() { + state.isPlaying = true; + updatePlayButton(); + const track = state.queue[state.currentIndex]; + if (track) submitNowPlaying(track); +} + +function handlePause() { + // Only update if the active player paused + if (this === getActivePlayer()) { + state.isPlaying = false; + updatePlayButton(); + } +} + +function handleProgress() { + const player = getActivePlayer(); + if (player.duration > 0 && player.buffered.length > 0) { + // Check if we have buffered enough to start next download + const bufferedEnd = player.buffered.end(player.buffered.length - 1); + if (bufferedEnd >= player.duration - 60) { // 60 seconds before end (for long songs) + preloadNextTrack(); + } + } +} + +// Bind events to both players +audioPlayer.addEventListener('play', handlePlay); +audioPlayer2.addEventListener('play', handlePlay); +audioPlayer.addEventListener('pause', handlePause); +audioPlayer2.addEventListener('pause', handlePause); +audioPlayer.addEventListener('progress', handleProgress); +audioPlayer2.addEventListener('progress', handleProgress); + +// Ended fallback for audioPlayer2 only (audioPlayer uses playNextWithRepeat instead) +// Note: audioPlayer's ended handler is added later as playNextWithRepeat +audioPlayer2.addEventListener('ended', () => { + // Skip if gapless already handled this transition + if (crossfadeTimeout) return; + if (getActivePlayer() === audioPlayer2) playNext(); +}); + +audioPlayer.addEventListener('timeupdate', handleTimeUpdate); +audioPlayer2.addEventListener('timeupdate', handleTimeUpdate); + +function handleTimeUpdate() { + // Update Mini Player Time + if (pipWindow) updateMiniPlayer(); + + const player = getActivePlayer(); + if (player.duration) { + currentTime.textContent = formatTime(player.currentTime); + duration.textContent = formatTime(player.duration); + progressBar.value = (player.currentTime / player.duration) * 100; + + // Sync FS Progress + fsCurrentTime.textContent = currentTime.textContent; + fsDuration.textContent = duration.textContent; + fsProgressBar.value = progressBar.value; + + // Update CSS variable for gradient fill + progressBar.style.setProperty('--value', progressBar.value + '%'); + fsProgressBar.style.setProperty('--value', progressBar.value + '%'); + + // Scrobble Check (50% or 4 mins) + if (!state.scrobbledCurrent && state.queue[state.currentIndex]) { + if (player.currentTime > 240 || player.currentTime > player.duration / 2) { + submitScrobble(state.queue[state.currentIndex]); + } + } + + // Time-based preload trigger (1 minute before end - better for long songs) + const timeRemaining = player.duration - player.currentTime; + if (timeRemaining <= 60 && timeRemaining > 0 && !preloadedTrackId) { + preloadNextTrack(); + } + + // Crossfade/Gapless trigger: start transition before track ends + // Using 200ms buffer - try to use preloaded player even if not flagged "ready" + const crossfadeTime = crossfadeEnabled ? CROSSFADE_DURATION / 1000 : 0.2; + + // Trigger if preloaded player exists (don't strictly require preloadedReady - streaming can start anyway) + if (timeRemaining <= crossfadeTime && timeRemaining > 0 && preloadedPlayer && !crossfadeTimeout) { + if (state.currentIndex < state.queue.length - 1) { + // Mark that we're handling crossfade + crossfadeTimeout = setTimeout(() => { + crossfadeTimeout = null; + }, crossfadeTime * 1000 + 500); + + // Advance to next track + state.currentIndex++; + state.scrobbledCurrent = false; + preloadedTrackId = null; + preloadedReady = false; // Reset ready flag + + // Update UI for new track + updatePlayerUI(); + updateQueueUI(); + updateFullscreenUI(state.queue[state.currentIndex]); // Sync fullscreen UI + + // Perform crossfade or gapless switch + if (crossfadeEnabled) { + performCrossfade(); + } else { + performGaplessSwitch(); + } + + // Update format badge + updateFormatBadge(getActivePlayer().src); + + // Preload the next one + setTimeout(preloadNextTrack, 500); + } + } + } +} + +progressBar.addEventListener('input', (e) => { + const player = getActivePlayer(); + if (player.duration && Number.isFinite(player.duration)) { + player.currentTime = (e.target.value / 100) * player.duration; + e.target.style.setProperty('--value', e.target.value + '%'); + if (typeof fsProgressBar !== 'undefined') fsProgressBar.style.setProperty('--value', e.target.value + '%'); + } +}); + +function updatePlayButton() { + playBtn.textContent = state.isPlaying ? '⏸' : '▶'; + if (typeof updateFSPlayBtn === 'function') updateFSPlayBtn(); +} + +// ========== QUEUE ========== +// queueClose and queueClear are defined at top level + +// ... + +queueBtn.addEventListener('click', () => { + queueSection.classList.toggle('hidden'); +}); + +queueClose.addEventListener('click', () => { + queueSection.classList.add('hidden'); +}); + +queueClear.addEventListener('click', () => { + state.queue = []; + state.currentIndex = -1; + updateQueueUI(); +}); + +// Delegated click handler for queue items (handles play, remove, and add-to-playlist) +queueContainer.addEventListener('click', (e) => { + // Check if clicked on remove button + const removeBtn = e.target.closest('.queue-remove-btn'); + if (removeBtn) { + e.stopPropagation(); + const index = parseInt(removeBtn.dataset.index, 10); + window.removeFromQueue(index); + return; + } + + // Check if clicked on heart button (add to playlist) + const heartBtn = e.target.closest('.queue-heart-btn'); + if (heartBtn) { + e.stopPropagation(); + const index = parseInt(heartBtn.dataset.index, 10); + const track = state.queue[index]; + if (track && window.openAddToPlaylistModal) { + window.openAddToPlaylistModal(track); + } + return; + } + + // Check if clicked on queue item (to play) + const queueItem = e.target.closest('.queue-item'); + if (queueItem) { + const index = parseInt(queueItem.dataset.index, 10); + state.currentIndex = index; + loadTrack(state.queue[index]); + } +}); + +// ========== QUEUE PERSISTENCE ========== +function saveQueueToStorage() { + try { + // Save queue and current index + const queueData = { + queue: state.queue, + currentIndex: state.currentIndex + }; + localStorage.setItem('freedify_queue', JSON.stringify(queueData)); + } catch (e) { + console.warn('Could not save queue to storage:', e); + } +} + +function loadQueueFromStorage() { + try { + const saved = localStorage.getItem('freedify_queue'); + if (saved) { + const queueData = JSON.parse(saved); + if (queueData.queue && Array.isArray(queueData.queue) && queueData.queue.length > 0) { + state.queue = queueData.queue; + state.currentIndex = queueData.currentIndex || 0; + updateQueueUI(); + // Load the track but don't auto-play + if (state.queue[state.currentIndex]) { + updatePlayerUI(); + } + console.log(`Restored queue: ${state.queue.length} tracks`); + } + } + } catch (e) { + console.warn('Could not load queue from storage:', e); + } +} + +function updateQueueUI() { + queueCount.textContent = `(${state.queue.length})`; + + // Persist queue to localStorage + saveQueueToStorage(); + + if (state.queue.length === 0) { + queueContainer.innerHTML = '

Queue is empty

'; + return; + } + + queueContainer.innerHTML = state.queue.filter(t => t).map((track, i) => ` +
+ Art +
+

${escapeHtml(track.name || 'Unknown')}

+

${escapeHtml(track.artists || '')}

+
+ + +
+ `).join(''); + + // Mark currently playing and scroll into view + const currentEl = queueContainer.querySelector(`[data-index="${state.currentIndex}"]`); + if (currentEl) { + currentEl.classList.add('playing'); + currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +// ========== PRELOADING ========== +let preloadedTrackId = null; + +function preloadNextTrack() { + if (state.currentIndex === -1 || state.currentIndex >= state.queue.length - 1) return; + + const nextTrack = state.queue[state.currentIndex + 1]; + if (!nextTrack || nextTrack.id === preloadedTrackId) return; + + preloadedTrackId = nextTrack.id; + preloadedReady = false; // Reset ready flag + console.log('Preloading next track into inactive player:', nextTrack.name); + + const query = `${nextTrack.name} ${nextTrack.artists}`; + const hiresParam = state.hiResMode ? '&hires=true' : '&hires=false'; + const streamUrl = `/api/stream/${nextTrack.isrc || nextTrack.id}?q=${encodeURIComponent(query)}${hiresParam}`; + + // Load into the inactive player for gapless transition + const inactivePlayer = activePlayer === 1 ? audioPlayer2 : audioPlayer; + + // Set up canplay listener for faster ready detection + // canplay fires when enough data is available to start (faster than canplaythrough) + const onReady = () => { + preloadedReady = true; + console.log('Preloaded track ready (canplay):', nextTrack.name); + inactivePlayer.removeEventListener('canplay', onReady); + }; + inactivePlayer.addEventListener('canplay', onReady); + + inactivePlayer.src = streamUrl; + inactivePlayer.load(); + preloadedPlayer = inactivePlayer; + + console.log('Next track loading into player', activePlayer === 1 ? 2 : 1); +} + +// Get the currently active audio player +function getActivePlayer() { + return activePlayer === 1 ? audioPlayer : audioPlayer2; +} + +// Get the inactive audio player (for preloading) +function getInactivePlayer() { + return activePlayer === 1 ? audioPlayer2 : audioPlayer; +} + +// Perform crossfade between players +function performCrossfade() { + const fadeOutGain = activePlayer === 1 ? gainNode1 : gainNode2; + const fadeInGain = activePlayer === 1 ? gainNode2 : gainNode1; + const newPlayer = getInactivePlayer(); + + if (!audioContext || !fadeOutGain || !fadeInGain) return; + + const now = audioContext.currentTime; + const fadeDuration = CROSSFADE_DURATION / 1000; + + // Start playing the new track + newPlayer.play().catch(e => console.error('Crossfade play error:', e)); + + // Crossfade: fade out current, fade in next + fadeOutGain.gain.setValueAtTime(1, now); + fadeOutGain.gain.linearRampToValueAtTime(0, now + fadeDuration); + + fadeInGain.gain.setValueAtTime(0, now); + fadeInGain.gain.linearRampToValueAtTime(1, now + fadeDuration); + + // Switch active player + activePlayer = activePlayer === 1 ? 2 : 1; + + // Pause old player after fade completes + setTimeout(() => { + const oldPlayer = getInactivePlayer(); + oldPlayer.pause(); + oldPlayer.currentTime = 0; + }, CROSSFADE_DURATION + 100); + + console.log('Crossfade to player', activePlayer); +} + +// Instant gapless switch (no crossfade) +function performGaplessSwitch() { + const newPlayer = getInactivePlayer(); + const oldPlayer = getActivePlayer(); + const fadeOutGain = activePlayer === 1 ? gainNode1 : gainNode2; + const fadeInGain = activePlayer === 1 ? gainNode2 : gainNode1; + + // Ensure gains are set correctly + if (fadeOutGain) fadeOutGain.gain.value = 0; + if (fadeInGain) fadeInGain.gain.value = 1; + + // Start new player + newPlayer.play().catch(e => console.error('Gapless play error:', e)); + + // Stop old player immediately + oldPlayer.pause(); + oldPlayer.currentTime = 0; + + // Switch active player + activePlayer = activePlayer === 1 ? 2 : 1; + + console.log('Gapless switch to player', activePlayer); +} + +// ========== MEDIA SESSION ========== +function updateMediaSession(track) { + // DJ Mode Info in Player + const playerDJInfo = $('#player-dj-info'); + if (state.djMode && playerDJInfo) { + const feat = state.audioFeaturesCache[track.id]; + if (feat) { + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + playerDJInfo.innerHTML = ` +
+ ${feat.bpm} BPM + ${feat.camelot} +
+ `; + playerDJInfo.classList.remove('hidden'); + } else { + // Try to fetch if missing + fetchAudioFeaturesForQueue(); + playerDJInfo.classList.add('hidden'); + } + } else if (playerDJInfo) { + playerDJInfo.classList.add('hidden'); + } + + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: track.name, + artist: track.artists, + album: track.album || '', + artwork: track.album_art ? [{ src: track.album_art, sizes: '512x512' }] : [] + }); + + navigator.mediaSession.setActionHandler('play', () => getActivePlayer().play()); + navigator.mediaSession.setActionHandler('pause', () => getActivePlayer().pause()); + navigator.mediaSession.setActionHandler('previoustrack', playPrevious); + navigator.mediaSession.setActionHandler('nexttrack', playNext); + } +} + +// ========== UI HELPERS ========== +function showLoading(text) { + loadingText.textContent = text || 'Loading...'; + loadingOverlay.classList.remove('hidden'); + errorMessage.classList.add('hidden'); +} + +function hideLoading() { + loadingOverlay.classList.add('hidden'); +} + +function showError(message) { + hideLoading(); + errorText.textContent = message; + errorMessage.classList.remove('hidden'); +} + +errorRetry.addEventListener('click', () => { + errorMessage.classList.add('hidden'); + const query = searchInput.value.trim(); + if (query) performSearch(query); +}); + +function showEmptyState() { + resultsContainer.innerHTML = ` +
+ 🔍 +

Search for your favorite music

+

Or paste a Spotify link to an album or playlist

+
+ `; +} + +function formatTime(seconds) { + if (!seconds || isNaN(seconds)) return '0:00'; + seconds = Math.floor(seconds); + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} + +// ========== KEYBOARD SHORTCUTS ========== +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + switch (e.code) { + case 'Space': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowRight': + if (e.shiftKey) getActivePlayer().currentTime += 10; + else playNext(); + break; + case 'ArrowLeft': + if (e.shiftKey) getActivePlayer().currentTime -= 10; + else playPrevious(); + break; + } +}); + +// ========== SERVICE WORKER ========== +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(console.error); +} + +// Initial state +showEmptyState(); + +// ========== FULLSCREEN PLAYER & EXTRAS ========== + +window.removeFromQueue = function(index) { + if (index === state.currentIndex) { + // Removing currently playing + state.queue.splice(index, 1); + if (state.queue.length === 0) { + getActivePlayer().pause(); + state.isPlaying = false; + updatePlayButton(); + state.currentIndex = -1; + // updatePlayerUI({ name: 'No track playing', artists: '-', album_art: '' }); // Reset UI + playerTitle.textContent = 'No track playing'; + playerArtist.textContent = '-'; + playerArt.src = ''; + // Reset FS + fsTitle.textContent = 'No track playing'; + fsArtist.textContent = 'Select music'; + } else { + // If we remove last item + if (index >= state.queue.length) { + state.currentIndex = 0; + loadTrack(state.queue[0]); + } else { + // Index stays same (next track shifted into it) + playTrack(state.queue[index]); + } + } + } else { + // Removing other track + state.queue.splice(index, 1); + if (index < state.currentIndex) { + state.currentIndex--; + } + updateQueueUI(); + } +}; + +function toggleFullScreen() { + fullscreenPlayer.classList.toggle('hidden'); + if (!fullscreenPlayer.classList.contains('hidden')) { + if (state.currentIndex >= 0) { + updateFullscreenUI(state.queue[state.currentIndex]); + } + } +} + +function updateFullscreenUI(track) { + if (!track) return; + fsTitle.textContent = track.name; + const year = track.release_date ? track.release_date.slice(0, 4) : ''; + fsArtist.textContent = year ? `${track.artists} • ${year}` : track.artists; + fsArt.src = track.album_art || '/static/icon.svg'; + + // Backdrop + const backdrop = document.querySelector('.fs-backdrop'); + if (backdrop) backdrop.style.backgroundImage = `url('${track.album_art || '/static/icon.svg'}')`; + + // DJ Mode Info for Fullscreen + const fsDJInfo = $('#fs-dj-info'); + if (state.djMode && fsDJInfo) { + const isLocal = track.id?.startsWith('local_'); + const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; + + if (feat) { + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + fsDJInfo.innerHTML = ` +
+ ${feat.bpm} BPM + ${feat.camelot} +
+ `; + fsDJInfo.classList.remove('hidden'); + } else { + fsDJInfo.classList.add('hidden'); + } + } else if (fsDJInfo) { + fsDJInfo.classList.add('hidden'); + } + + updateFSPlayBtn(); +} + +function updateFSPlayBtn() { + if (!fsPlayBtn) return; + fsPlayBtn.textContent = state.isPlaying ? '⏸' : '▶'; +} + +// FS Controls +if (fsToggleBtn) fsToggleBtn.addEventListener('click', toggleFullScreen); +if (fsCloseBtn) fsCloseBtn.addEventListener('click', toggleFullScreen); +if (fsPlayBtn) fsPlayBtn.addEventListener('click', () => playBtn.click()); + +// FS Prev/Next - seek ±15s for podcasts, otherwise prev/next track +const fsHeartBtn = $('#fs-heart-btn'); +if (fsPrevBtn) { + fsPrevBtn.addEventListener('click', () => { + const currentTrack = state.queue[state.currentIndex]; + const player = getActivePlayer(); + if (currentTrack && currentTrack.source === 'podcast') { + player.currentTime = Math.max(0, player.currentTime - 15); + } else { + prevBtn.click(); + } + }); +} +if (fsNextBtn) { + fsNextBtn.addEventListener('click', () => { + const currentTrack = state.queue[state.currentIndex]; + const player = getActivePlayer(); + if (currentTrack && currentTrack.source === 'podcast') { + player.currentTime = Math.min(player.duration, player.currentTime + 15); + } else { + nextBtn.click(); + } + }); +} + +// FS Heart button - add current track to playlist +if (fsHeartBtn) { + fsHeartBtn.addEventListener('click', () => { + const currentTrack = state.queue[state.currentIndex]; + if (currentTrack && window.openAddToPlaylistModal) { + window.openAddToPlaylistModal(currentTrack); + } else { + showToast('No track playing'); + } + }); +} + +// More Menu Controls +const moreControlsBtn = $('#more-controls-btn'); +const playerMoreMenu = $('#player-more-menu'); + +if (moreControlsBtn && playerMoreMenu) { + moreControlsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + playerMoreMenu.classList.toggle('hidden'); + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!playerMoreMenu.classList.contains('hidden') && + !playerMoreMenu.contains(e.target) && + e.target !== moreControlsBtn) { + playerMoreMenu.classList.add('hidden'); + } + }); +} +if (fsNextBtn) fsNextBtn.addEventListener('click', () => nextBtn.click()); + +if (fsProgressBar) { + fsProgressBar.addEventListener('input', (e) => { + const player = getActivePlayer(); + if (player.duration) { + player.currentTime = (e.target.value / 100) * player.duration; + } + }); +} + +// Navigation Links +playerTitle.classList.add('clickable-link'); +playerArtist.classList.add('clickable-link'); + +playerTitle.addEventListener('click', () => { + if (state.currentIndex >= 0 && !fullscreenPlayer.classList.contains('hidden')) toggleFullScreen(); // Close FS if open? Or works anyway. + if (state.currentIndex >= 0) { + const track = state.queue[state.currentIndex]; + performSearch(track.name + " " + track.artists); + } +}); + +playerArtist.addEventListener('click', () => { + if (state.currentIndex >= 0) { + performSearch(state.queue[state.currentIndex].artists); + } +}); + +// ========== TOAST NOTIFICATIONS ========== +function showToast(message) { + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + toastContainer.appendChild(toast); + + // Remove after animation + setTimeout(() => toast.remove(), 3000); +} + +// ========== VOLUME CONTROL ========== +// Shared volume update function +function updateVolume(vol) { + if (vol < 0) vol = 0; + if (vol > 1) vol = 1; + + state.volume = vol; + audioPlayer.volume = vol; + audioPlayer2.volume = vol; // Apply to both players + state.muted = vol === 0; + + // Persist volume to localStorage + localStorage.setItem('freedify_volume', vol.toString()); + + // Update main slider UI if needed + const sliderVal = Math.round(vol * 100); + if (volumeSlider.value != sliderVal) volumeSlider.value = sliderVal; + + // Update PiP slider if exists + if (pipWindow) { + const waVol = pipWindow.document.getElementById('wa-vol'); + if (waVol && waVol.value != sliderVal) waVol.value = sliderVal; + } + + updateMuteIcon(); +} + +volumeSlider.addEventListener('input', (e) => { + updateVolume(e.target.value / 100); +}); + +muteBtn.addEventListener('click', () => { + state.muted = !state.muted; + if (state.muted) { + audioPlayer.volume = 0; + volumeSlider.value = 0; + } else { + audioPlayer.volume = state.volume || 1; + volumeSlider.value = (state.volume || 1) * 100; + } + updateMuteIcon(); +}); + +function updateMuteIcon() { + if (state.muted || audioPlayer.volume === 0) { + muteBtn.textContent = '🔇'; + } else if (audioPlayer.volume < 0.5) { + muteBtn.textContent = '🔉'; + } else { + muteBtn.textContent = '🔊'; + } +} + +// ========== REPEAT MODE ========== +repeatBtn.addEventListener('click', () => { + // Cycle: none -> all -> one -> none + if (state.repeatMode === 'none') { + state.repeatMode = 'all'; + repeatBtn.classList.add('repeat-active'); + repeatBtn.title = 'Repeat: All'; + showToast('Repeat: All'); + } else if (state.repeatMode === 'all') { + state.repeatMode = 'one'; + repeatBtn.classList.add('repeat-one'); + repeatBtn.title = 'Repeat: One'; + showToast('Repeat: One'); + } else { + state.repeatMode = 'none'; + repeatBtn.classList.remove('repeat-active', 'repeat-one'); + repeatBtn.title = 'Repeat: Off'; + showToast('Repeat: Off'); + } +}); + +// Override playNext for repeat handling +const originalPlayNext = playNext; +window.playNextWithRepeat = function() { + // Skip if gapless already handled this transition + if (crossfadeTimeout) return; + + const player = getActivePlayer(); + if (state.repeatMode === 'one') { + player.currentTime = 0; + player.play(); + return; + } + + if (state.currentIndex < state.queue.length - 1) { + state.currentIndex++; + + // Try to use preloaded player (no loading screen) - ONLY if ready + if (preloadedReady && preloadedPlayer && preloadedTrackId === state.queue[state.currentIndex]?.id) { + console.log('playNextWithRepeat: Using preloaded player for:', state.queue[state.currentIndex].name); + preloadedTrackId = null; + preloadedReady = false; + updatePlayerUI(); + updateQueueUI(); + updateFullscreenUI(state.queue[state.currentIndex]); + performGaplessSwitch(); + updateFormatBadge(getActivePlayer().src); + updateMediaSession(state.queue[state.currentIndex]); + setTimeout(preloadNextTrack, 500); + } else { + loadTrack(state.queue[state.currentIndex]); + } + } else if (state.repeatMode === 'all' && state.queue.length > 0) { + state.currentIndex = 0; + loadTrack(state.queue[0]); + } +}; + +// Replace ended handler with repeat-aware version +audioPlayer.removeEventListener('ended', playNext); +audioPlayer.addEventListener('ended', window.playNextWithRepeat); + +// ========== KEYBOARD SHORTCUTS ========== +shortcutsClose.addEventListener('click', () => { + shortcutsHelp.classList.add('hidden'); +}); + +document.addEventListener('keydown', (e) => { + // Skip if typing in input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + switch (e.key) { + case ' ': + e.preventDefault(); + togglePlay(); + break; + case 'ArrowRight': + if (e.shiftKey) { + const player = getActivePlayer(); + player.currentTime = Math.min(player.duration, player.currentTime + 10); + } else { + playNext(); + } + break; + case 'ArrowLeft': + if (e.shiftKey) { + const player = getActivePlayer(); + player.currentTime = Math.max(0, player.currentTime - 10); + } else { + playPrevious(); + } + break; + case 'ArrowUp': + e.preventDefault(); + updateVolume(Math.min(1, state.volume + 0.1)); + showToast(`Volume: ${Math.round(state.volume * 100)}%`); + break; + case 'ArrowDown': + e.preventDefault(); + updateVolume(Math.max(0, state.volume - 0.1)); + showToast(`Volume: ${Math.round(state.volume * 100)}%`); + break; + case 'm': + case 'M': + muteBtn.click(); + break; + case 's': + case 'S': + shuffleQueueBtn.click(); + showToast('Queue Shuffled'); + break; + case 'r': + case 'R': + repeatBtn.click(); + break; + case 'f': + case 'F': + toggleFullScreen(); + break; + case 'q': + case 'Q': + queueSection.classList.toggle('hidden'); + break; + case '?': + shortcutsHelp.classList.toggle('hidden'); + break; + } +}); + +// ========== QUEUE DRAG & DROP ========== +let draggedItem = null; +let draggedIndex = -1; + +function initQueueDragDrop() { + const items = queueContainer.querySelectorAll('.queue-item'); + + items.forEach((item, index) => { + item.setAttribute('draggable', 'true'); + + item.addEventListener('dragstart', (e) => { + draggedItem = item; + draggedIndex = index; + item.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + item.addEventListener('dragend', () => { + item.classList.remove('dragging'); + draggedItem = null; + draggedIndex = -1; + items.forEach(i => i.classList.remove('drag-over')); + }); + + item.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (item !== draggedItem) { + item.classList.add('drag-over'); + } + }); + + item.addEventListener('dragleave', () => { + item.classList.remove('drag-over'); + }); + + item.addEventListener('drop', (e) => { + e.preventDefault(); + if (item === draggedItem) return; + + const targetIndex = index; + + // Reorder queue + const [movedTrack] = state.queue.splice(draggedIndex, 1); + state.queue.splice(targetIndex, 0, movedTrack); + + // Update current index if needed + if (state.currentIndex === draggedIndex) { + state.currentIndex = targetIndex; + } else if (draggedIndex < state.currentIndex && targetIndex >= state.currentIndex) { + state.currentIndex--; + } else if (draggedIndex > state.currentIndex && targetIndex <= state.currentIndex) { + state.currentIndex++; + } + + updateQueueUI(); + showToast('Queue reordered'); + }); + +// Add track to queue +function addToQueue(track) { + if (!track) return; + state.queue.push(track); + updateQueueUI(); + showToast(`Added "${track.name}" to queue`); +} + }); +} + +// Patch updateQueueUI to init drag-drop +const originalUpdateQueueUI = updateQueueUI; +window.updateQueueUIPatched = function() { + originalUpdateQueueUI(); + initQueueDragDrop(); +}; + +// Override the function (need to call it after original) +const _originalUpdateQueueUI = updateQueueUI; +updateQueueUI = function() { + _originalUpdateQueueUI.apply(this, arguments); + setTimeout(initQueueDragDrop, 0); +}; + +// ========== EQUALIZER (Web Audio API) ========== +const eqPanel = $('#eq-panel'); +const eqToggleBtn = $('#eq-toggle-btn'); +const eqCloseBtn = $('#eq-close-btn'); +const eqPresets = $$('.eq-preset'); +const bassBoostSlider = $('#bass-boost'); +const bassBoostVal = $('#bass-boost-val'); +const volumeBoostSlider = $('#volume-boost'); +const volumeBoostVal = $('#volume-boost-val'); + +// Audio context and nodes (created lazily) +let audioContext = null; +let sourceNode = null; +let sourceNode2 = null; +let gainNode1 = null; // Gain for player 1 (for crossfade) +let gainNode2 = null; // Gain for player 2 (for crossfade) +let eqFilters = []; +let bassBoostFilter = null; +let volumeBoostGain = null; +let eqConnected = false; + +// EQ frequency bands +const EQ_BANDS = [ + { id: 'eq-60', freq: 60, type: 'lowshelf' }, + { id: 'eq-230', freq: 230, type: 'peaking' }, + { id: 'eq-910', freq: 910, type: 'peaking' }, + { id: 'eq-3600', freq: 3600, type: 'peaking' }, + { id: 'eq-7500', freq: 7500, type: 'highshelf' } +]; + +// Presets (dB values for each band) +const EQ_PRESETS = { + flat: [0, 0, 0, 0, 0], + bass: [6, 4, 0, 0, 0], + treble: [0, 0, 0, 3, 6], + vocal: [-2, 0, 4, 2, -1] +}; + +function initEqualizer() { + if (audioContext) return; // Already initialized + + try { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + + // Create source nodes for both audio players + sourceNode = audioContext.createMediaElementSource(audioPlayer); + sourceNode2 = audioContext.createMediaElementSource(audioPlayer2); + + // Create gain nodes for crossfade control + gainNode1 = audioContext.createGain(); + gainNode2 = audioContext.createGain(); + gainNode1.gain.value = 1; // Player 1 starts active + gainNode2.gain.value = 0; // Player 2 starts silent + + // Create EQ filter nodes + eqFilters = EQ_BANDS.map(band => { + const filter = audioContext.createBiquadFilter(); + filter.type = band.type; + filter.frequency.value = band.freq; + filter.gain.value = 0; + if (band.type === 'peaking') filter.Q.value = 1; + return filter; + }); + + // Create bass boost filter (low shelf) + bassBoostFilter = audioContext.createBiquadFilter(); + bassBoostFilter.type = 'lowshelf'; + bassBoostFilter.frequency.value = 100; + bassBoostFilter.gain.value = 0; + + // Create volume boost gain node + volumeBoostGain = audioContext.createGain(); + volumeBoostGain.gain.value = 1; + + // Connect chains: + // Player 1: source -> gain1 -> first EQ filter + // Player 2: source2 -> gain2 -> first EQ filter + // Then: EQ chain -> bass boost -> volume boost -> destination + sourceNode.connect(gainNode1); + sourceNode2.connect(gainNode2); + + // Both gains merge into first EQ filter + const firstFilter = eqFilters[0]; + gainNode1.connect(firstFilter); + gainNode2.connect(firstFilter); + + // Connect EQ filter chain + let lastNode = firstFilter; + for (let i = 1; i < eqFilters.length; i++) { + lastNode.connect(eqFilters[i]); + lastNode = eqFilters[i]; + } + lastNode.connect(bassBoostFilter); + bassBoostFilter.connect(volumeBoostGain); + volumeBoostGain.connect(audioContext.destination); + + eqConnected = true; + + // Load saved settings + loadEqSettings(); + + console.log('Equalizer initialized with crossfade support'); + } catch (e) { + console.error('Failed to initialize equalizer:', e); + } +} + +function loadEqSettings() { + const saved = localStorage.getItem('freedify_eq'); + if (saved) { + try { + const settings = JSON.parse(saved); + EQ_BANDS.forEach((band, i) => { + const slider = $(`#${band.id}`); + if (slider && settings.bands[i] !== undefined) { + slider.value = settings.bands[i]; + if (eqFilters[i]) eqFilters[i].gain.value = settings.bands[i]; + } + }); + if (settings.bass !== undefined) { + bassBoostSlider.value = settings.bass; + if (bassBoostFilter) bassBoostFilter.gain.value = settings.bass; + bassBoostVal.textContent = `${settings.bass}dB`; + } + if (settings.volume !== undefined) { + volumeBoostSlider.value = settings.volume; + if (volumeBoostGain) volumeBoostGain.gain.value = Math.pow(10, settings.volume / 20); + volumeBoostVal.textContent = `${settings.volume}dB`; + } + } catch (e) { console.error('Error loading EQ settings:', e); } + } +} + +function saveEqSettings() { + const settings = { + bands: EQ_BANDS.map(band => parseFloat($(`#${band.id}`).value)), + bass: parseFloat(bassBoostSlider.value), + volume: parseFloat(volumeBoostSlider.value) + }; + localStorage.setItem('freedify_eq', JSON.stringify(settings)); +} + +function applyPreset(preset) { + const values = EQ_PRESETS[preset]; + if (!values) return; + + EQ_BANDS.forEach((band, i) => { + const slider = $(`#${band.id}`); + slider.value = values[i]; + if (eqFilters[i]) eqFilters[i].gain.value = values[i]; + }); + + eqPresets.forEach(btn => btn.classList.remove('active')); + document.querySelector(`[data-preset="${preset}"]`)?.classList.add('active'); + + saveEqSettings(); +} + +// Toggle EQ panel +eqToggleBtn?.addEventListener('click', () => { + if (!audioContext) initEqualizer(); + eqPanel.classList.toggle('hidden'); + eqToggleBtn.classList.toggle('active'); +}); + +eqCloseBtn?.addEventListener('click', () => { + eqPanel.classList.add('hidden'); + eqToggleBtn.classList.remove('active'); +}); + +// Preset buttons +eqPresets.forEach(btn => { + btn.addEventListener('click', () => applyPreset(btn.dataset.preset)); +}); + +// EQ band sliders +EQ_BANDS.forEach((band, i) => { + const slider = $(`#${band.id}`); + slider?.addEventListener('input', () => { + if (eqFilters[i]) eqFilters[i].gain.value = parseFloat(slider.value); + saveEqSettings(); + // Clear preset selection when manually adjusting + eqPresets.forEach(btn => btn.classList.remove('active')); + }); +}); + +// Bass boost slider +bassBoostSlider?.addEventListener('input', () => { + const val = parseFloat(bassBoostSlider.value); + if (bassBoostFilter) bassBoostFilter.gain.value = val; + bassBoostVal.textContent = `${val}dB`; + saveEqSettings(); +}); + +// Volume boost slider +volumeBoostSlider?.addEventListener('input', () => { + const val = parseFloat(volumeBoostSlider.value); + // Convert dB to gain multiplier: gain = 10^(dB/20) + if (volumeBoostGain) volumeBoostGain.gain.value = Math.pow(10, val / 20); + volumeBoostVal.textContent = `${val}dB`; + saveEqSettings(); +}); + +// Initialize EQ when audio starts playing (to resume AudioContext) +audioPlayer.addEventListener('play', () => { + if (!audioContext) { + initEqualizer(); + } else if (audioContext.state === 'suspended') { + audioContext.resume(); + } +}); + +// ========== THEME PICKER ========== +const themeBtn = $('#theme-btn'); +const themePicker = $('#theme-picker'); +const themeOptions = $$('.theme-option'); + +// Load saved theme on startup +(function loadSavedTheme() { + const savedTheme = localStorage.getItem('freedify_theme') || ''; + if (savedTheme) { + document.body.classList.add(savedTheme); + } + // Mark active option + themeOptions.forEach(opt => { + if (opt.dataset.theme === savedTheme) { + opt.classList.add('active'); + } + }); + + // Sync meta theme-color on load + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor && savedTheme) { + setTimeout(() => { + const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); + if (accentColor) metaThemeColor.content = accentColor; + }, 50); + } +})(); + +// Toggle theme picker +themeBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + themePicker.classList.toggle('hidden'); +}); + +// Theme selection +themeOptions.forEach(opt => { + opt.addEventListener('click', () => { + const newTheme = opt.dataset.theme; + + // Remove all theme classes + document.body.classList.remove('theme-purple', 'theme-blue', 'theme-green', 'theme-pink', 'theme-orange'); + + // Add new theme + if (newTheme) { + document.body.classList.add(newTheme); + } + + // Save to localStorage + localStorage.setItem('freedify_theme', newTheme); + + // Update active state + themeOptions.forEach(o => o.classList.remove('active')); + opt.classList.add('active'); + + // Close picker + themePicker.classList.add('hidden'); + + showToast(`Theme changed to ${opt.textContent}`); + + // Update meta theme-color for mobile browser UI + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + // Get computed color for current theme + // Wait a tick for class change to apply + setTimeout(() => { + const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); + if (accentColor) metaThemeColor.content = accentColor; + }, 50); + } + }); +}); + +// Close theme picker when clicking outside +document.addEventListener('click', (e) => { + if (!themePicker.contains(e.target) && e.target !== themeBtn) { + themePicker.classList.add('hidden'); + } +}); + +// ========== MEDIA SESSION API (Lock Screen Controls) ========== +function updateMediaSession(track) { + if (!('mediaSession' in navigator)) return; + + navigator.mediaSession.metadata = new MediaMetadata({ + title: track.name || 'Unknown Track', + artist: track.artists || 'Unknown Artist', + album: track.album || '', + artwork: [ + { src: track.album_art || '/static/icon.svg', sizes: '512x512', type: 'image/png' } + ] + }); +} + +// Set up Media Session action handlers +if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', () => { + getActivePlayer().play(); + }); + + navigator.mediaSession.setActionHandler('pause', () => { + getActivePlayer().pause(); + }); + + navigator.mediaSession.setActionHandler('previoustrack', () => { + playPrevious(); + }); + + navigator.mediaSession.setActionHandler('nexttrack', () => { + playNext(); + }); + + navigator.mediaSession.setActionHandler('seekbackward', (details) => { + const player = getActivePlayer(); + player.currentTime = Math.max(player.currentTime - (details.seekOffset || 10), 0); + }); + + navigator.mediaSession.setActionHandler('seekforward', (details) => { + const player = getActivePlayer(); + player.currentTime = Math.min(player.currentTime + (details.seekOffset || 10), player.duration); + }); + + navigator.mediaSession.setActionHandler('seekto', (details) => { + const player = getActivePlayer(); + if (details.fastSeek && 'fastSeek' in player) { + player.fastSeek(details.seekTime); + } else { + player.currentTime = details.seekTime; + } + }); +} + +// Update position state periodically +audioPlayer.addEventListener('timeupdate', () => { + if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) { + try { + if (audioPlayer.duration && !isNaN(audioPlayer.duration)) { + navigator.mediaSession.setPositionState({ + duration: audioPlayer.duration, + playbackRate: audioPlayer.playbackRate, + position: audioPlayer.currentTime + }); + } + } catch (e) { /* Ignore errors */ } + } +}); + +// ========== GOOGLE DRIVE SYNC ========== +// Client ID: fetched from server env var, or from localStorage, or prompted +let GOOGLE_CLIENT_ID = localStorage.getItem('freedify_google_client_id') || ''; +// Expanded scope: appdata for favorites sync + drive.file for saving audio files +const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/drive.file'; +const SYNC_FILENAME = 'freedify_playlists.json'; +const FREEDIFY_FOLDER_NAME = 'Freedify'; + +let googleAccessToken = null; + +// Fetch server-side config (Google Client ID from env vars) +(async function loadServerConfig() { + try { + const res = await fetch('/api/config'); + if (res.ok) { + const config = await res.json(); + if (config.google_client_id) { + GOOGLE_CLIENT_ID = config.google_client_id; + console.log('Google Client ID loaded from server config'); + } + } + } catch (e) { + console.log('Could not load server config:', e.message); + } +})(); +const syncBtn = $('#sync-btn'); + +// Initialize Google API +// Initialize Google API +window.initGoogleApi = function() { + return new Promise((resolve) => { + if (typeof gapi === 'undefined') { + console.log('Google API not loaded yet'); + resolve(false); + return; + } + gapi.load('client', async () => { + try { + await gapi.client.init({ + discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'] + }); + console.log("Google Drive API initialized"); + resolve(true); + } catch (e) { + console.error('Failed to init Google API:', e); + resolve(false); + } + }); + }); +}; + +// If gapi is already loaded (race condition), init immediately +if (typeof gapi !== 'undefined') { + window.initGoogleApi(); +} + +// Google Sign-In +async function signInWithGoogle() { + if (!GOOGLE_CLIENT_ID) { + const clientId = prompt( + 'Enter your Google OAuth Client ID:\n\n' + + 'To get one:\n' + + '1. Go to console.cloud.google.com\n' + + '2. Create a project\n' + + '3. Enable Drive API\n' + + '4. Create OAuth credentials (Web application)\n' + + '5. Add your domain to authorized origins' + ); + if (clientId) { + localStorage.setItem('freedify_google_client_id', clientId); + location.reload(); + } + return null; + } + + return new Promise((resolve) => { + const client = google.accounts.oauth2.initTokenClient({ + client_id: GOOGLE_CLIENT_ID, + scope: GOOGLE_SCOPES, + callback: (response) => { + if (response.access_token) { + googleAccessToken = response.access_token; + gapi.client.setToken({ access_token: googleAccessToken }); + syncBtn.classList.add('synced'); + showToast('Signed in to Google Drive'); + resolve(response.access_token); + } else { + resolve(null); + } + }, + error_callback: (error) => { + console.error('Google sign-in error:', error); + showToast('Sign-in failed'); + resolve(null); + } + }); + client.requestAccessToken(); + }); +} + +// Find sync file in Drive +async function findSyncFile() { + try { + const response = await gapi.client.drive.files.list({ + spaces: 'appDataFolder', + q: `name='${SYNC_FILENAME}'`, + fields: 'files(id, name, modifiedTime)' + }); + return response.result.files?.[0] || null; + } catch (e) { + console.error('Error finding sync file:', e); + return null; + } +} + +// Find or create "Freedify" folder in Drive root +async function findOrCreateFreedifyFolder() { + try { + // Search for existing folder + const response = await gapi.client.drive.files.list({ + q: `name='${FREEDIFY_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`, + fields: 'files(id, name)' + }); + + if (response.result.files && response.result.files.length > 0) { + return response.result.files[0].id; + } + + // Create folder if not found + const createResponse = await gapi.client.drive.files.create({ + resource: { + name: FREEDIFY_FOLDER_NAME, + mimeType: 'application/vnd.google-apps.folder' + }, + fields: 'id' + }); + + return createResponse.result.id; + } catch (e) { + console.error('Error finding/creating folder:', e); + return null; + } +} + +// Upload to Drive with Granular Support +// syncType: 'all', 'playlists', 'queue' +async function uploadToDrive(syncType = 'all') { + if (!googleAccessToken) { + await signInWithGoogle(); + if (!googleAccessToken) return; // User cancelled auth + } + + const loadingText = syncType === 'all' ? 'Syncing all to Drive...' : + syncType === 'playlists' ? 'Syncing playlists...' : 'Syncing queue...'; + showLoading(loadingText); + + try { + // 1. Fetch EXISTING file first to preserve data we aren't updating + const existingFile = await findSyncFile(); + let currentRemoteData = {}; + + if (existingFile) { + try { + const response = await fetch( + `https://www.googleapis.com/drive/v3/files/${existingFile.id}?alt=media`, + { headers: { 'Authorization': `Bearer ${googleAccessToken}` } } + ); + if (response.ok) { + const json = await response.json(); + // Handle legacy array format + if (Array.isArray(json)) { + currentRemoteData = { playlists: json, queue: [] }; + } else { + currentRemoteData = json; + } + } + } catch (err) { + console.warn('Failed to read existing sync data, starting fresh', err); + } + } + + // 2. Prepare NEW data by merging state into remote data + const syncData = { + playlists: currentRemoteData.playlists || [], + queue: currentRemoteData.queue || [], + currentIndex: currentRemoteData.currentIndex || 0, + volume: currentRemoteData.volume || 1, + syncedAt: new Date().toISOString() + }; + + if (syncType === 'all' || syncType === 'playlists') { + syncData.playlists = state.playlists; + } + + if (syncType === 'all' || syncType === 'queue') { + syncData.queue = state.queue; + syncData.currentIndex = state.currentIndex; + syncData.volume = state.volume; + } + + // 3. Upload + const fileContent = JSON.stringify(syncData, null, 2); + + const metadata = { + name: SYNC_FILENAME, + mimeType: 'application/json' + }; + + if (!existingFile) { + metadata.parents = ['appDataFolder']; + } + + const form = new FormData(); + form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); + form.append('file', new Blob([fileContent], { type: 'application/json' })); + + const url = existingFile + ? `https://www.googleapis.com/upload/drive/v3/files/${existingFile.id}?uploadType=multipart` + : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; + + const response = await fetch(url, { + method: existingFile ? 'PATCH' : 'POST', + headers: { 'Authorization': `Bearer ${googleAccessToken}` }, + body: form + }); + + if (response.ok) { + hideLoading(); + // Close modal if open + $('#drive-sync-modal').classList.add('hidden'); + + let msg = 'Sync Complete!'; + if (syncType === 'playlists') msg = `Synced ${state.playlists.length} playlists to Drive`; + if (syncType === 'queue') msg = `Synced queue (${state.queue.length} tracks)`; + if (syncType === 'all') msg = `Synced Match & Queue to Drive`; + + showToast(msg); + localStorage.setItem('freedify_last_sync', new Date().toISOString()); + } else { + throw new Error('Upload failed'); + } + } catch (e) { + console.error('Upload error:', e); + hideLoading(); + showError('Failed to sync to Google Drive'); + } +} + +// Download from Drive with Granular Support +async function downloadFromDrive(syncType = 'all') { + if (!googleAccessToken) { + await signInWithGoogle(); + if (!googleAccessToken) return; + } + + showLoading('Loading from Google Drive...'); + + try { + const file = await findSyncFile(); + + if (!file) { + hideLoading(); + showToast('No saved data found in Drive'); + return; + } + + const response = await fetch( + `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`, + { headers: { 'Authorization': `Bearer ${googleAccessToken}` } } + ); + + if (response.ok) { + const syncData = await response.json(); + + // Normalize data (handle legacy) + const remotePlaylists = Array.isArray(syncData) ? syncData : (syncData.playlists || []); + const remoteQueue = Array.isArray(syncData) ? [] : (syncData.queue || []); + const remoteIndex = Array.isArray(syncData) ? 0 : (syncData.currentIndex || 0); + + let restoredCount = 0; + + // Apply updates + if (syncType === 'all' || syncType === 'playlists') { + state.playlists = remotePlaylists; + savePlaylists(); + restoredCount = remotePlaylists.length; + // If favorites view is active, refresh it + if (state.searchType === 'favorites') renderPlaylistsView(); + } + + if (syncType === 'all' || syncType === 'queue') { + if (remoteQueue.length > 0) { + state.queue = remoteQueue; + state.currentIndex = remoteIndex; + // Use remote volume only if 'all' to avoid startling volume jumps on just queue sync? + // Let's stick to syncing volume on queue sync. + if (syncData.volume) { + state.volume = syncData.volume; + if (audioPlayer) audioPlayer.volume = state.volume; + if (audioPlayer2) audioPlayer2.volume = state.volume; + if (volumeSlider) volumeSlider.value = Math.round(state.volume * 100); + } + updateQueueUI(); + updatePlayerUI(); + } + } + + hideLoading(); + // Close modal + $('#drive-sync-modal').classList.add('hidden'); + + if (syncType === 'playlists') showToast(`Loaded ${restoredCount} playlists`); + else if (syncType === 'queue') showToast(`Loaded queue (${remoteQueue.length} tracks)`); + else showToast(`Loaded Library & Session`); + + } else { + throw new Error('Download failed'); + } + } catch (e) { + console.error('Download error:', e); + hideLoading(); + showError('Failed to load from Google Drive'); + } +} + +// --- Drive Sync Modal UI & Events --- + +function updateDriveModalUI() { + const authSection = $('#drive-auth-section'); + const optionsSection = $('#drive-options-section'); + const userEmailSpan = $('#drive-user-email'); + + // Check if we have a valid access token (GIS flow stores it in googleAccessToken) + if (googleAccessToken) { + authSection.classList.add('hidden'); + optionsSection.classList.remove('hidden'); + if (userEmailSpan) userEmailSpan.textContent = 'Connected to Google Drive'; + } else { + authSection.classList.remove('hidden'); + optionsSection.classList.add('hidden'); + } +} + +async function showDriveModal() { + const modal = $('#drive-sync-modal'); + modal.classList.remove('hidden'); + + // Ensure API is ready + if (typeof gapi !== 'undefined' && (!gapi.auth2 || !gapi.client.drive)) { + $('#drive-loading').classList.remove('hidden'); + await initGoogleApi(); + $('#drive-loading').classList.add('hidden'); + } + updateDriveModalUI(); +} + +// Open Modal +syncBtn?.addEventListener('click', () => { + showDriveModal(); +}); + +// Close Modal +$('#drive-modal-close')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden')); +$('#drive-modal-close-top')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden')); + +// Auth Buttons +$('#drive-signin-btn')?.addEventListener('click', async () => { + await signInWithGoogle(); + updateDriveModalUI(); +}); + +$('#drive-signout-btn')?.addEventListener('click', () => { + googleAccessToken = null; + gapi.client.setToken(null); + syncBtn?.classList.remove('synced'); + updateDriveModalUI(); + showToast('Signed out from Google Drive'); +}); + +// Granular Action Bindings +$('#drive-up-all')?.addEventListener('click', () => uploadToDrive('all')); +$('#drive-up-playlists')?.addEventListener('click', () => uploadToDrive('playlists')); +$('#drive-up-queue')?.addEventListener('click', () => uploadToDrive('queue')); + +$('#drive-down-all')?.addEventListener('click', () => downloadFromDrive('all')); +$('#drive-down-playlists')?.addEventListener('click', () => downloadFromDrive('playlists')); +$('#drive-down-queue')?.addEventListener('click', () => downloadFromDrive('queue')); + +// ========== PODCAST EPISODE DETAILS MODAL ========== +const podcastModal = $('#podcast-modal'); +const podcastModalClose = $('#podcast-modal-close'); +const podcastModalArt = $('#podcast-modal-art'); +const podcastModalTitle = $('#podcast-modal-title'); +const podcastModalDate = $('#podcast-modal-date'); +const podcastModalDuration = $('#podcast-modal-duration'); +const podcastModalDescription = $('#podcast-modal-description'); +const podcastModalPlay = $('#podcast-modal-play'); + +let currentPodcastEpisode = null; + +function showPodcastModal(track) { + if (!track || track.source !== 'podcast') return; + + currentPodcastEpisode = track; + + podcastModalArt.src = track.album_art || '/static/icon.svg'; + podcastModalTitle.textContent = track.name; + podcastModalDate.textContent = track.datePublished || ''; + podcastModalDuration.textContent = `Duration: ${track.duration}`; + + // Strip HTML tags from description and decode entities + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = track.description || 'No description available.'; + podcastModalDescription.textContent = tempDiv.textContent || tempDiv.innerText; + + podcastModal.classList.remove('hidden'); +} +window.showPodcastModal = showPodcastModal; + +function hidePodcastModal() { + podcastModal.classList.add('hidden'); + currentPodcastEpisode = null; +} + +// Close modal +podcastModalClose?.addEventListener('click', hidePodcastModal); + +// Close on backdrop click +podcastModal?.addEventListener('click', (e) => { + if (e.target === podcastModal) hidePodcastModal(); +}); + +// Play button +podcastModalPlay?.addEventListener('click', () => { + if (currentPodcastEpisode) { + playTrack(currentPodcastEpisode); + hidePodcastModal(); + } +}); + +// Close on Escape key +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !podcastModal.classList.contains('hidden')) { + hidePodcastModal(); + } +}); +// ========== HiFi MODE ========== +const hifiBtn = $('#hifi-btn'); + +// Initialize HiFi button state - reflects actual playing quality, not just preference +// Initialize HiFi button state - reflects user preference and source limits +function updateHifiButtonUI() { + if (hifiBtn) { + const currentTrack = state.queue[state.currentIndex]; + const source = currentTrack?.source || ''; + + // Determine constraint based on source + const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import'; + + if (isLossySource) { + // Force Lossy display if source is known bad + hifiBtn.classList.remove('hi-res'); + hifiBtn.classList.add('active', 'lossy'); + hifiBtn.title = "Playing: Compressed Audio (MP3/AAC)"; + hifiBtn.textContent = "MP3"; + } else { + // For all other sources (HiFi, Hi-Res, Unknown), reflect the MODE setting + hifiBtn.classList.add('active'); + hifiBtn.classList.remove('lossy'); + + // Toggle Hi-Res vs HiFi based on state + // If state.hiResMode is true -> Add class 'hi-res' -> CSS makes it Cyan/Pulse + // If state.hiResMode is false -> Remove class 'hi-res' -> CSS makes it Green + hifiBtn.classList.toggle('hi-res', state.hiResMode); + + hifiBtn.title = state.hiResMode ? "Hi-Res Mode ON (24-bit)" : "HiFi Mode ON (16-bit)"; + hifiBtn.textContent = state.hiResMode ? "Hi-Res" : "HiFi"; + } + } +} + +// Toggle HiFi mode +if (hifiBtn) { + hifiBtn.addEventListener('click', () => { + state.hiResMode = !state.hiResMode; + localStorage.setItem('freedify_hires', state.hiResMode); + updateHifiButtonUI(); + + // Show toast notification + showToast(state.hiResMode ? + '💎 Hi-Res Mode ON - 24-bit Audio' : + '🎵 HiFi Mode ON - 16-bit Audio', 3000); + }); + + // Initialize UI on load + updateHifiButtonUI(); +} + +// ========== DJ MODE ========== +const djModeBtn = $('#dj-mode-btn'); +const djSetlistModal = $('#dj-setlist-modal'); +const djModalClose = $('#dj-modal-close'); +const djStyleSelect = $('#dj-style-select'); +const djSetlistLoading = $('#dj-setlist-loading'); +const djSetlistResults = $('#dj-setlist-results'); +const djOrderedTracks = $('#dj-ordered-tracks'); +const djGenerateBtn = $('#dj-generate-btn'); +const djApplyBtn = $('#dj-apply-btn'); + +// Musical Key to Camelot Wheel conversion +function musicalKeyToCamelot(key) { + if (!key) return null; + + // Normalize key: uppercase, handle sharps/flats + const normalized = key.trim() + .replace(/major/i, '') + .replace(/minor/i, 'm') + .replace(/♯/g, '#') + .replace(/♭/g, 'b') + .trim(); + + // Mapping of musical keys to Camelot notation + // Minor keys (A column) + const minorKeys = { + 'Abm': '1A', 'G#m': '1A', + 'Ebm': '2A', 'D#m': '2A', + 'Bbm': '3A', 'A#m': '3A', + 'Fm': '4A', + 'Cm': '5A', + 'Gm': '6A', + 'Dm': '7A', + 'Am': '8A', + 'Em': '9A', + 'Bm': '10A', + 'F#m': '11A', 'Gbm': '11A', + 'Dbm': '12A', 'C#m': '12A' + }; + + // Major keys (B column) + const majorKeys = { + 'B': '1B', + 'Gb': '2B', 'F#': '2B', + 'Db': '3B', 'C#': '3B', + 'Ab': '4B', 'G#': '4B', + 'Eb': '5B', 'D#': '5B', + 'Bb': '6B', 'A#': '6B', + 'F': '7B', + 'C': '8B', + 'G': '9B', + 'D': '10B', + 'A': '11B', + 'E': '12B' + }; + + // Check minor first, then major + if (minorKeys[normalized]) return minorKeys[normalized]; + if (majorKeys[normalized]) return majorKeys[normalized]; + + // Try case-insensitive match + for (const [k, v] of Object.entries(minorKeys)) { + if (k.toLowerCase() === normalized.toLowerCase()) return v; + } + for (const [k, v] of Object.entries(majorKeys)) { + if (k.toLowerCase() === normalized.toLowerCase()) return v; + } + + // If already in Camelot format, return as-is + if (/^[1-9][0-2]?[AB]$/i.test(normalized)) { + return normalized.toUpperCase(); + } + + return key; // Return original if no match +} + +// DJ Mode state +state.djMode = localStorage.getItem('freedify_dj_mode') === 'true'; +state.audioFeaturesCache = {}; // Cache audio features by track ID +state.lastSetlistResult = null; + +// Initialize DJ mode on load +if (state.djMode) { + document.body.classList.add('dj-mode-active'); +} + +// Toggle DJ mode +djModeBtn?.addEventListener('click', () => { + state.djMode = !state.djMode; + localStorage.setItem('freedify_dj_mode', state.djMode); + document.body.classList.toggle('dj-mode-active', state.djMode); + + if (state.djMode) { + showToast('🎧 DJ Mode activated'); + // Fetch audio features for current queue + if (state.queue.length > 0) { + fetchAudioFeaturesForQueue(); + } + } else { + showToast('DJ Mode deactivated'); + } +}); + +// Helper to render DJ Badge +function renderDJBadgeForTrack(track) { + if (!state.djMode) return ''; + + // For local tracks, use embedded audio_features directly (trust Serato) + const isLocal = track.id?.startsWith('local_'); + const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; + + if (!feat) return '
'; + + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + return ` +
+ ${feat.bpm} BPM + ${feat.camelot} +
+ `; +} + +// Generic fetch features for any list of tracks +async function fetchAudioFeaturesForTracks(tracks) { + if (!state.djMode || !tracks || tracks.length === 0) return; + + // Filter out already cached AND local files (trust local metadata) + const tracksToFetch = tracks + .filter(t => t.id && !t.id.startsWith('LINK:') && !t.id.startsWith('pod_') && !t.id.startsWith('local_')) + .filter(t => !state.audioFeaturesCache[t.id]) + .map(t => ({ + id: t.id, + isrc: t.isrc || null, + name: t.name || null, + artists: t.artists || null + })); + + // De-duplicate by ID + const uniqueTracks = []; + const seenIds = new Set(); + tracksToFetch.forEach(t => { + if (!seenIds.has(t.id)) { + seenIds.add(t.id); + uniqueTracks.push(t); + } + }); + + if (uniqueTracks.length === 0) return; + + try { + const response = await fetch('/api/audio-features/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: uniqueTracks }) + }); + + if (response.ok) { + const data = await response.json(); + data.features.forEach((feat, i) => { + if (feat) { + state.audioFeaturesCache[uniqueTracks[i].id] = feat; + } + }); + // Trigger UI updates + updateDJBadgesInUI(); + updatePlayerUI(); + } + } catch (err) { + console.warn('Failed to fetch audio features:', err); + } +} + +// Update all badges in DOM +function updateDJBadgesInUI() { + // Update placeholders + $$('.dj-badge-placeholder').forEach(el => { + const id = el.dataset.id; + const feat = state.audioFeaturesCache[id]; + if (feat) { + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + el.outerHTML = ` +
+ ${feat.bpm} BPM + ${feat.camelot} +
+ `; + } + }); + + // Update Player + if (state.currentIndex >= 0 && state.queue[state.currentIndex]) { + updatePlayerUI(); + } +} + +// Fetch audio features for tracks in queue +async function fetchAudioFeaturesForQueue() { + await fetchAudioFeaturesForTracks(state.queue); + addDJBadgesToQueue(); +} + +// Open DJ setlist modal +function openDJSetlistModal() { + if (state.queue.length < 3) { + showToast('Add at least 3 tracks to queue for setlist generation'); + return; + } + + djSetlistModal?.classList.remove('hidden'); + djSetlistLoading?.classList.add('hidden'); + djSetlistResults?.classList.add('hidden'); + djApplyBtn?.classList.add('hidden'); + state.lastSetlistResult = null; +} + +function closeDJSetlistModal() { + djSetlistModal?.classList.add('hidden'); +} + +djModalClose?.addEventListener('click', closeDJSetlistModal); +djSetlistModal?.addEventListener('click', (e) => { + if (e.target === djSetlistModal) closeDJSetlistModal(); +}); + +// Generate setlist +djGenerateBtn?.addEventListener('click', async () => { + // Ensure we have audio features + await fetchAudioFeaturesForQueue(); + + // Build tracks data - use embedded audio_features for local, cache for others + const tracksData = state.queue.map(t => { + const isLocal = t.id.startsWith('local_'); + const feat = isLocal ? t.audio_features : state.audioFeaturesCache[t.id]; + return { + id: t.id, + name: t.name, + artists: t.artists, + bpm: feat?.bpm || 0, + camelot: feat?.camelot || '?', + energy: feat?.energy || 0.5 + }; + }); + + djSetlistLoading?.classList.remove('hidden'); + djSetlistResults?.classList.add('hidden'); + + try { + const response = await fetch('/api/dj/generate-setlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tracks: tracksData, + style: djStyleSelect?.value || 'progressive' + }) + }); + + if (!response.ok) throw new Error('Generation failed'); + + const result = await response.json(); + state.lastSetlistResult = result; + + // Render results + renderSetlistResults(result, tracksData); + + } catch (err) { + console.error('Setlist generation error:', err); + showToast('Failed to generate setlist'); + } finally { + djSetlistLoading?.classList.add('hidden'); + } +}); + +function renderSetlistResults(result, tracksData) { + if (!djOrderedTracks) return; + + const trackMap = {}; + tracksData.forEach(t => trackMap[t.id] = t); + state.queue.forEach(t => trackMap[t.id] = { ...trackMap[t.id], ...t }); + + let html = ''; + result.ordered_ids.forEach((id, i) => { + const track = trackMap[id]; + if (!track) return; + + // Use embedded audio_features for local tracks, cache for others + const isLocal = id.startsWith('local_'); + const feat = isLocal ? (track.audio_features || {}) : (state.audioFeaturesCache[id] || {}); + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + + html += ` +
+
${i + 1}
+
+
${escapeHtml(track.name)}
+
${escapeHtml(track.artists)}
+
+
+ ${feat.bpm || '?'} BPM + ${feat.camelot || '?'} +
+
+ `; + + // Add transition tip if available + if (i < result.suggestions?.length) { + const sug = result.suggestions[i]; + const tipClass = sug.harmonic_match ? 'harmonic' : (sug.bpm_diff > 8 ? 'caution' : ''); + const technique = sug.technique ? `${escapeHtml(sug.technique)}` : ''; + const timing = sug.timing ? `${escapeHtml(sug.timing)}` : ''; + const tipText = sug.tip ? escapeHtml(sug.tip) : ''; + + html += ` +
+
+ 💡 ${technique} ${timing} +
+
${tipText}
+
+ `; + } + }); + + djOrderedTracks.innerHTML = html; + djSetlistResults?.classList.remove('hidden'); + djApplyBtn?.classList.remove('hidden'); + + // Show method used + const methodText = result.method === 'ai-gemini-2.0-flash' ? '✨ AI Generated' : '📊 Algorithm'; + showToast(`${methodText} setlist ready!`); +} + +// Apply setlist to queue +djApplyBtn?.addEventListener('click', () => { + if (!state.lastSetlistResult?.ordered_ids) return; + + const trackMap = {}; + state.queue.forEach(t => trackMap[t.id] = t); + + const newQueue = []; + state.lastSetlistResult.ordered_ids.forEach(id => { + if (trackMap[id]) newQueue.push(trackMap[id]); + }); + + // Add any tracks not in the result (shouldn't happen but safety) + state.queue.forEach(t => { + if (!newQueue.find(q => q.id === t.id)) { + newQueue.push(t); + } + }); + + state.queue = newQueue; + state.currentIndex = 0; + updateQueueUI(); + + closeDJSetlistModal(); + showToast('Queue reordered! Ready to mix 🎧'); +}); + +// Add "Generate DJ Set" button to queue header +const queueHeader = $('.queue-header'); +if (queueHeader) { + const djBtn = document.createElement('button'); + djBtn.className = 'dj-generate-set-btn'; + djBtn.innerHTML = '✨ Generate Set'; + djBtn.addEventListener('click', openDJSetlistModal); + queueHeader.querySelector('.queue-controls')?.prepend(djBtn); +} + +// Modify renderQueueItem to show DJ badges (override/extend existing) +const originalUpdateQueue = typeof updateQueueUI !== 'undefined' ? updateQueueUI : null; +function updateQueueWithDJ() { + originalUpdateQueue?.(); + if (state.djMode && state.queue.length > 0) { + fetchAudioFeaturesForQueue().then(() => { + addDJBadgesToQueue(); + // Also update player UI if needed + if (state.currentIndex >= 0) { + updatePlayerUI(); + } + }); + } +} + +function addDJBadgesToQueue() { + if (!state.djMode) return; + + const queueItems = $$('#queue-container .queue-item'); + queueItems.forEach((item, i) => { + if (i >= state.queue.length) return; + const track = state.queue[i]; + const feat = state.audioFeaturesCache[track.id]; + + // Remove existing badges + const existing = item.querySelector('.dj-badge-container'); + if (existing) existing.remove(); + + if (feat) { + const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; + const badgeContainer = document.createElement('div'); + badgeContainer.className = 'dj-badge-container'; + badgeContainer.innerHTML = ` + ${feat.bpm} BPM + ${feat.camelot} +
+ `; + item.querySelector('.queue-info')?.appendChild(badgeContainer); + } + }); +} + +// Escape key closes DJ modal +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !djSetlistModal?.classList.contains('hidden')) { + closeDJSetlistModal(); + } +}); + +// ========== LOCAL FILE HANDLING ========== +function initLocalFiles() { + initDragAndDrop(); + initManualUpload(); +} + +function initDragAndDrop() { + // Attach to window to catch drops anywhere + const dropZone = window; + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropZone.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + // Highlight drop zone (using body class) + window.addEventListener('dragenter', () => document.body.classList.add('dragging'), false); + window.addEventListener('dragleave', (e) => { + // Only remove if leaving the window + if (e.clientX === 0 && e.clientY === 0) { + document.body.classList.remove('dragging'); + } + }, false); + + window.addEventListener('drop', (e) => { + document.body.classList.remove('dragging'); + handleDrop(e); + }, false); + + console.log("Drag & Drop initialized on window"); +} + +function initManualUpload() { + // const addLocalBtn = document.getElementById('add-local-btn'); // Replaced by Label + const fileInput = document.getElementById('file-input'); + + if (fileInput) { + console.log("Initializing Manual Upload via Label"); + // No click listener needed for Label + + fileInput.addEventListener('change', (e) => { + console.log("File Input Changed", e.target.files); + if (e.target.files && e.target.files.length > 0) { + handleFiles(e.target.files); + } + }); + } else { + console.error("Could not find add-local-btn or file-input"); + } +} + +function handleDrop(e) { + const dt = e.dataTransfer; + const files = dt.files; + handleFiles(files); +} + +async function handleFiles(files) { + // alert(`DEBUG: handleFiles called with ${files.length} files`); + console.log("HandleFiles Entry:", files.length); + + const validExtensions = ['.mp3', '.flac', '.wav', '.aiff', '.aac', '.ogg', '.m4a', '.wma']; + + const audioFiles = Array.from(files).filter(file => { + const isAudio = file.type.startsWith('audio/') || + // Fallback: check extension if type is empty or generic + validExtensions.some(ext => file.name.toLowerCase().endsWith(ext)); + return isAudio; + }); + + // DEBUG: Log files + // console.log("All files:", files); + + if (audioFiles.length === 0) { + if (files.length > 0) showToast('No supported audio files found', 'error'); + return; + } + + showLoading(`Processing ${audioFiles.length} local files...`); + + let processedCount = 0; + for (const file of audioFiles) { + try { + const metadata = await extractMetadata(file); + if (metadata) { + addLocalTrackToQueue(file, metadata); + processedCount++; + } + } catch (err) { + console.error('Error processing file:', file.name, err); + showToast(`Error reading ${file.name}`, 'error'); + } + } + + hideLoading(); + + if (processedCount > 0) { + showToast(`Added ${processedCount} local tracks to queue!`, 'success'); + updateQueueUI(); + if (!state.isPlaying && state.queue.length === processedCount) { + playTrack(0); // Auto play if queue was empty + } + } +} + +function extractMetadata(file) { + return new Promise((resolve) => { + if (!window.jsmediatags) { + console.warn("jsmediatags not loaded"); + resolve({ title: file.name, artist: 'Local File' }); + return; + } + + window.jsmediatags.read(file, { + onSuccess: (tag) => { + const tags = tag.tags; + console.log("Raw tags from jsmediatags:", Object.keys(tags)); // DEBUG: Show all tag names + + let picture = null; + if (tags.picture) { + const { data, format } = tags.picture; + let base64String = ""; + for (let i = 0; i < data.length; i++) { + base64String += String.fromCharCode(data[i]); + } + picture = `data:${format};base64,${window.btoa(base64String)}`; + } + + // Helper to extract tag value (handles both direct and .data formats) + const getTagValue = (tagName) => { + const tag = tags[tagName]; + if (!tag) return null; + if (typeof tag === 'string' || typeof tag === 'number') return tag; + if (tag.data !== undefined) return tag.data; + return null; + }; + + // Dynamic search: find any tag containing "bpm" in name + let bpm = null; + let key = null; + + for (const tagName of Object.keys(tags)) { + const lowerName = tagName.toLowerCase(); + const val = getTagValue(tagName); + + if (!bpm && (lowerName.includes('bpm') || lowerName.includes('beats'))) { + bpm = val; + console.log(`Found BPM in tag "${tagName}":`, val); + } + if (!key && (lowerName.includes('key') || lowerName === 'tkey')) { + key = val; + console.log(`Found Key in tag "${tagName}":`, val); + } + } + + // Parse BPM as integer + if (bpm) bpm = parseInt(String(bpm).replace(/\D/g, ''), 10) || null; + + console.log("Final Extracted BPM:", bpm, "Key:", key); // DEBUG + + resolve({ + title: tags.title || file.name, + artist: tags.artist || 'Local Artist', + album: tags.album || 'Local Album', + bpm: bpm, + key: key, + picture: picture + }); + }, + onError: (error) => { + console.warn('Metadata read error:', error); + resolve({ title: file.name, artist: 'Local File' }); + } + }); + }); +} + +function addLocalTrackToQueue(file, metadata) { + const blobUrl = URL.createObjectURL(file); + const safeId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Convert musical key to Camelot notation for color coding + const camelotKey = musicalKeyToCamelot(metadata.key); + + const track = { + id: safeId, + name: metadata.title, + artists: metadata.artist, + album: metadata.album, + album_art: metadata.picture || '/static/icon.svg', + duration: 'Unknown', + isrc: safeId, + audio_features: { + bpm: metadata.bpm || 0, + camelot: camelotKey || (metadata.bpm ? '?' : null), + energy: 0.5, + key: -1, + mode: 1 + }, + src: blobUrl, + is_local: true + }; + + state.queue.push(track); +} + +// Initialize +// Initialize +document.addEventListener('DOMContentLoaded', () => { + initLocalFiles(); +}); + +// Expose for inline HTML handlers +window.handleFiles = handleFiles; +window.extractMetadata = extractMetadata; + +// CSS for drag highlight +const style = document.createElement('style'); +style.textContent = ` + body.dragging { + border: 4px dashed #1db954; + opacity: 0.8; + } +`; +document.head.appendChild(style); + +// ========== AI RADIO ========== +state.aiRadioActive = false; +state.aiRadioFetching = false; +state.aiRadioSeedTrack = null; // Store original seed track to prevent genre drift +const aiRadioBtn = $('#ai-radio-btn'); +let aiRadioStatusEl = null; +let aiRadioInterval = null; + +// Toggle AI Radio +if (aiRadioBtn) { + aiRadioBtn.addEventListener('click', () => { + console.log('AI Radio button clicked!'); + state.aiRadioActive = !state.aiRadioActive; + aiRadioBtn.classList.toggle('active', state.aiRadioActive); + + if (state.aiRadioActive) { + // Store the original seed track when AI Radio starts + const currentTrack = state.queue[Math.max(0, state.currentIndex)]; + state.aiRadioSeedTrack = currentTrack ? { + name: currentTrack.name, + artists: currentTrack.artists, + bpm: currentTrack.audio_features?.bpm, + camelot: currentTrack.audio_features?.camelot + } : null; + console.log('AI Radio seed track:', state.aiRadioSeedTrack); + + showAIRadioStatus('AI Radio Active'); + showToast('📻 AI Radio started! Will auto-add similar tracks.'); + checkAndAddTracks(); // Start immediately + + // Set up periodic check every 2 minutes (120 seconds) + aiRadioInterval = setInterval(() => { + console.log('AI Radio periodic check...'); + checkAndAddTracks(); + }, 120000); + } else { + hideAIRadioStatus(); + showToast('📻 AI Radio stopped'); + state.aiRadioSeedTrack = null; // Clear seed track + + // Clear the interval + if (aiRadioInterval) { + clearInterval(aiRadioInterval); + aiRadioInterval = null; + } + } + }); +} else { + console.error('AI Radio button not found! #ai-radio-btn'); +} + +function showAIRadioStatus(message) { + if (!aiRadioStatusEl) { + aiRadioStatusEl = document.createElement('div'); + aiRadioStatusEl.className = 'ai-radio-status'; + document.body.appendChild(aiRadioStatusEl); + } + aiRadioStatusEl.innerHTML = ` + + ${message} + `; + aiRadioStatusEl.style.display = 'flex'; +} + +function hideAIRadioStatus() { + if (aiRadioStatusEl) { + aiRadioStatusEl.style.display = 'none'; + } +} + +async function checkAndAddTracks() { + if (!state.aiRadioActive || state.aiRadioFetching) return; + + const remainingTracks = state.queue.length - Math.max(0, state.currentIndex) - 1; + console.log('AI Radio check:', { queueLen: state.queue.length, currentIndex: state.currentIndex, remaining: remainingTracks }); + + // Add more tracks if we have less than 3 remaining + if (remainingTracks < 3) { + state.aiRadioFetching = true; + showAIRadioStatus('Finding similar tracks...'); + + try { + // Use the ORIGINAL seed track stored when AI Radio started (prevents genre drift) + const seed = state.aiRadioSeedTrack; + + // Get current queue for exclusion + const queueTracks = state.queue.map(t => ({ + name: t.name, + artists: t.artists + })); + + // If no seed, use a default mood + const requestBody = { + seed_track: seed, + mood: seed ? null : "popular music hits", + current_queue: queueTracks, + count: 5 + }; + + console.log('AI Radio request:', requestBody); + + const response = await fetch('/api/ai-radio/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (response.ok) { + const data = await response.json(); + console.log('AI Radio response:', data); + const searchTerms = data.search_terms || []; + + // Search and add tracks + let addedCount = 0; + for (const term of searchTerms) { + if (addedCount >= 3) break; // Limit adds per batch + + try { + const searchRes = await fetch(`/api/search?q=${encodeURIComponent(term)}&type=track`); + if (searchRes.ok) { + const searchData = await searchRes.json(); + const results = searchData.results || []; + + // Add first non-duplicate result + for (const track of results.slice(0, 3)) { + const isDupe = state.queue.some(q => + q.id === track.id || + (q.name?.toLowerCase() === track.name?.toLowerCase() && + q.artists?.toLowerCase() === track.artists?.toLowerCase()) + ); + if (!isDupe) { + state.queue.push(track); + addedCount++; + break; + } + } + } + } catch (e) { + console.warn('AI Radio search error:', e); + } + } + + if (addedCount > 0) { + updateQueueUI(); + showToast(`📻 Added ${addedCount} tracks to queue`); + } + } + } catch (err) { + console.error('AI Radio error:', err); + } + + state.aiRadioFetching = false; + if (state.aiRadioActive) { + showAIRadioStatus('AI Radio Active'); + } + } +} + +// Check when current track ends +const originalPlayTrack = window.playTrack || playTrack; +const aiRadioWrappedPlayTrack = async function(index) { + await originalPlayTrack(index); + if (state.aiRadioActive) { + setTimeout(checkAndAddTracks, 1000); + } +}; + +// Hook into track end +audioPlayer?.addEventListener('ended', () => { + if (state.aiRadioActive) { + setTimeout(checkAndAddTracks, 500); + } +}); + +// alert("DEBUG: App.js initialization COMPLETE. If you see this, script is good."); +console.log("App.js initialization COMPLETE"); + +// ========== ADD TO PLAYLIST MODAL ========== +const playlistModal = $('#playlist-modal'); +const playlistList = $('#playlist-list'); +const newPlaylistInput = $('#new-playlist-input'); +const createPlaylistBtn = $('#create-playlist-btn'); +const playlistModalClose = $('#playlist-modal-close'); +const addToPlaylistBtn = $('#add-to-playlist-btn'); + +let pendingTrackForPlaylist = null; + +function openAddToPlaylistModal(track) { + if (!track) { + showToast('No track selected'); + return; + } + pendingTrackForPlaylist = track; + + // Render playlist list + if (state.playlists.length === 0) { + playlistList.innerHTML = '

No playlists yet. Create one below!

'; + } else { + playlistList.innerHTML = state.playlists.map(p => ` +
+ ${escapeHtml(p.name)} (${p.tracks.length}) +
+ `).join(''); + + // Click handler for each playlist item + playlistList.querySelectorAll('.playlist-list-item').forEach(el => { + el.addEventListener('click', () => { + addToPlaylist(el.dataset.playlistId, pendingTrackForPlaylist); + closeAddToPlaylistModal(); + }); + }); + } + + playlistModal.classList.remove('hidden'); +} + +function closeAddToPlaylistModal() { + playlistModal.classList.add('hidden'); + pendingTrackForPlaylist = null; + newPlaylistInput.value = ''; +} + +// Create new playlist from modal +createPlaylistBtn?.addEventListener('click', () => { + const name = newPlaylistInput.value.trim(); + if (!name) { + showToast('Enter a playlist name'); + return; + } + + let tracks = []; + if (pendingTrackForPlaylist) { + tracks = Array.isArray(pendingTrackForPlaylist) ? pendingTrackForPlaylist : [pendingTrackForPlaylist]; + } + + const newPlaylist = createPlaylist(name, tracks); + closeAddToPlaylistModal(); +}); + +// Close modal +playlistModalClose?.addEventListener('click', closeAddToPlaylistModal); +playlistModal?.addEventListener('click', (e) => { + if (e.target === playlistModal) closeAddToPlaylistModal(); +}); + +// Heart button in More Menu -> opens modal for current track +addToPlaylistBtn?.addEventListener('click', () => { + const currentTrack = state.queue[state.currentIndex]; + if (currentTrack) { + openAddToPlaylistModal(currentTrack); + // Close More menu + $('#player-more-menu')?.classList.add('hidden'); + } else { + showToast('No track playing'); + } +}); + +// Expose for queue item hearts (will be wired in updateQueueUI) +window.openAddToPlaylistModal = openAddToPlaylistModal; + +// ========== LOAD MORE RESULTS ========== +const loadMoreBtn = $('#load-more-btn'); +if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', () => { + if (state.lastSearchQuery) { + performSearch(state.lastSearchQuery, true); + } + }); +} + +// ========== LISTENBRAINZ LOGIC ========== +// Scrobble Logic +async function submitNowPlaying(track) { + if (!state.listenBrainzConfig.valid) return; + try { + await fetch('/api/listenbrainz/now-playing', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(track) + }); + } catch (e) { console.error('Now playing error:', e); } +} + +async function submitScrobble(track) { + if (!state.listenBrainzConfig.valid || state.scrobbledCurrent) return; + try { + state.scrobbledCurrent = true; // Prevent double scrobble + await fetch('/api/listenbrainz/scrobble', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(track) + }); + console.log('Scrobbled:', track.name); + } catch (e) { console.error('Scrobble error:', e); } +} + +// Check initial LB status +fetch('/api/listenbrainz/validate') + .then(res => res.json()) + .then(data => { + state.listenBrainzConfig = data; + if (data.valid) console.log('ListenBrainz connected:', data.username); + }) + .catch(console.error); + +async function renderRecommendations() { + resultsSection.classList.remove('hidden'); + detailView.classList.add('hidden'); + queueSection.classList.add('hidden'); + + let html = ''; + + // ========== SPOTIFY "MADE FOR YOU" SECTION ========== + // DISABLED: Spotify's sp_dc cookie auth doesn't provide personalized search results. + // Re-enable when Spotify Developer API access is available with proper OAuth scopes. + /* + try { + const spotifyRes = await fetch('/api/spotify/made-for-you'); + if (spotifyRes.ok) { + const spotifyPlaylists = await spotifyRes.json(); + + if (spotifyPlaylists && spotifyPlaylists.length > 0) { + html += ` +
+

Spotify For You

+ ${spotifyPlaylists.length} playlists +
+
+ `; + + for (const playlist of spotifyPlaylists) { + html += ` +
+
+ ${escapeHtml(playlist.name)} + Spotify +
+
+

${escapeHtml(playlist.name)}

+

${escapeHtml(playlist.owner || 'Spotify')}

+
+
+ `; + } + + html += '
'; + } + } + } catch (e) { + console.warn('Could not load Spotify Made For You:', e); + } + */ + + // ========== LISTENBRAINZ SECTION ========== + if (!state.listenBrainzConfig.valid) { + if (!html) { + // No Spotify AND no ListenBrainz - show empty state + resultsContainer.innerHTML = ` +
+
+

Connect Spotify or ListenBrainz to see personalized recommendations.

+

Set SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.

+
+ `; + } else { + resultsContainer.innerHTML = html; + attachSpotifyMFYHandlers(); + } + return; + } + + showLoading('Loading your ListenBrainz playlists...'); + try { + // Fetch playlists first + const playlistsRes = await fetch(`/api/listenbrainz/playlists/${state.listenBrainzConfig.username}`); + const playlistsData = await playlistsRes.json(); + + hideLoading(); + + // Fetch and display stats panel + try { + const statsRes = await fetch(`/api/listenbrainz/stats/${state.listenBrainzConfig.username}`); + if (statsRes.ok) { + const stats = await statsRes.json(); + if (stats.listen_count > 0) { + html += ` +
+
+ ${stats.listen_count.toLocaleString()} + Total Scrobbles +
+ ${stats.top_artists.length > 0 ? ` +
+ Top This Week +
+ ${stats.top_artists.slice(0, 3).map(a => `${escapeHtml(a.name)}`).join('')} +
+
+ ` : ''} +
+ `; + } + } + } catch (e) { + console.warn('Could not load LB stats:', e); + } + + // Show playlists section + if (playlistsData.playlists && playlistsData.playlists.length > 0) { + html += ` +
+

ListenBrainz Playlists

+ ${playlistsData.count} playlists +
+
+ `; + + for (const playlist of playlistsData.playlists) { + html += ` +
+
+ ${escapeHtml(playlist.name)} + LB +
+
+

${escapeHtml(playlist.name)}

+

${escapeHtml(playlist.artists)}

+
+ ${playlist.total_tracks || '?'} tracks +
+
+
+ `; + } + + html += '
'; + } else { + html += ` +
+

ListenBrainz Playlists

+
+
+
📋
+

No playlists found. Weekly Exploration playlists are generated on Mondays!

+
+ `; + } + + resultsContainer.innerHTML = html; + + // Attach click handlers for playlist cards + resultsContainer.querySelectorAll('.lb-playlist-card').forEach(card => { + card.addEventListener('click', async () => { + const playlistId = card.dataset.id; + await openLBPlaylist(playlistId); + }); + }); + + attachSpotifyMFYHandlers(); + + } catch (e) { + console.error(e); + showError('Failed to load ListenBrainz data'); + } +} + +// Attach click handlers for Spotify "Made For You" cards +function attachSpotifyMFYHandlers() { + resultsContainer.querySelectorAll('.spotify-mfy-card').forEach(card => { + card.addEventListener('click', async () => { + const playlistId = card.dataset.id; + await openSpotifyPlaylist(playlistId); + }); + }); +} + +// Open a Spotify playlist in detail view +async function openSpotifyPlaylist(playlistId) { + showLoading('Loading playlist tracks...'); + try { + const res = await fetch(`/api/content/playlist/${playlistId}?source=spotify`); + const playlist = await res.json(); + + if (!res.ok) throw new Error(playlist.detail); + + hideLoading(); + console.log('Opening Spotify playlist:', playlist.name, playlist); + + // Show in detail view + showDetailView(playlist, playlist.tracks || []); + } catch (e) { + console.error('Failed to load Spotify playlist:', e); + showError('Failed to load playlist'); + } +} + +// Open a ListenBrainz playlist in detail view +async function openLBPlaylist(playlistId) { + showLoading('Loading playlist tracks...'); + try { + const res = await fetch(`/api/listenbrainz/playlist/${playlistId}`); + const playlist = await res.json(); + + if (!res.ok) throw new Error(playlist.detail); + + hideLoading(); + console.log('Opening LB playlist:', playlist.name, playlist); + + // Show in detail view + showDetailView(playlist, playlist.tracks || []); + } catch (e) { + console.error('Failed to load LB playlist:', e); + showError('Failed to load playlist'); + } +} + +function showToast(message, duration = 3000) { + let toast = document.createElement('div'); + toast.className = 'toast-notification'; + toast.textContent = message; + Object.assign(toast.style, { + position: 'fixed', + bottom: '80px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: 'var(--accent)', + color: 'white', + padding: '10px 20px', + borderRadius: '20px', + boxShadow: '0 4px 12px rgba(0,0,0,0.3)', + zIndex: '10000', + opacity: '0', + transition: 'opacity 0.3s', + pointerEvents: 'none' + }); + document.body.appendChild(toast); + + // Animate in + requestAnimationFrame(() => toast.style.opacity = '1'); + + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// ==================== WINAMP MINI PLAYER ==================== +async function toggleMiniPlayer() { + if (!('documentPictureInPicture' in window)) { + showError('Mini Player not supported in this browser (Chrome/Edge 116+ required)'); + return; + } + + if (pipWindow) { + pipWindow.close(); + pipWindow = null; + return; + } + + try { + pipWindow = await documentPictureInPicture.requestWindow({ + width: 320, + height: 160, + }); + + // Copy Styles + [...document.styleSheets].forEach((styleSheet) => { + try { + const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join(''); + const style = document.createElement('style'); + style.textContent = cssRules; + pipWindow.document.head.appendChild(style); + } catch (e) { + // Ignore CORS errors for external sheets + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = styleSheet.type; + link.media = styleSheet.media; + link.href = styleSheet.href; + pipWindow.document.head.appendChild(link); + } + }); + + // Render Winamp HTML + updateMiniPlayerDOM(); + + // Bind Controls + const doc = pipWindow.document; + doc.getElementById('wa-prev').onclick = () => playPrevious(); + doc.getElementById('wa-play').onclick = () => { + const p = getActivePlayer(); + if (p.paused) p.play(); else p.pause(); + updateMiniPlayer(); // Immediate update + }; + doc.getElementById('wa-pause').onclick = () => getActivePlayer().pause(); + doc.getElementById('wa-next').onclick = () => playNext(); + doc.getElementById('wa-vol').oninput = (e) => { + const val = e.target.value / 100; + updateVolume(val); // Syncs main slider too + }; + + // Force initial update + updateMiniPlayer(); + + // Handle Close + pipWindow.addEventListener('pagehide', () => { + pipWindow = null; + }); + + } catch (err) { + console.error('Failed to open Mini Player:', err); + } +} + +function updateMiniPlayerDOM() { + if (!pipWindow) return; + const doc = pipWindow.document; + + // If body is empty, inject structure + if (!doc.body.children.length) { + doc.body.className = 'winamp-body'; + doc.body.innerHTML = ` +
+
+
+ FREEDIFY + +
+
+
+
+ +
+
+
+
00:00
+
Ready to Llama...
+
+ MP3 + STOP +
+
+
+
+
+
+
+
VOL
+ +
+
+
+
+ `; + } +} + +function updateMiniPlayer() { + if (!pipWindow) return; + const doc = pipWindow.document; + const player = getActivePlayer(); + + // Time + const cur = player.currentTime || 0; + const mins = Math.floor(cur / 60); + const secs = Math.floor(cur % 60); + const timeStr = `${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`; + const timeEl = doc.getElementById('wa-time'); + if (timeEl && timeEl.textContent !== timeStr) timeEl.textContent = timeStr; + + // Metadata (Title - Artist) + const title = $('#player-title').textContent; + const artist = $('#player-artist').textContent; + const text = `${artist} - ${title}`; + const marquee = doc.getElementById('wa-marquee'); + if (marquee) { + // Update span content or create one if missing + let span = marquee.querySelector('span'); + if (!span) { + span = doc.createElement('span'); + marquee.appendChild(span); + } + if (span.textContent !== text) span.textContent = text; + } + + // State (Play/Pause/Stop) + const stateEl = doc.getElementById('wa-state'); + const newState = player.paused ? 'PAUSE' : 'PLAY'; + if (stateEl && stateEl.textContent !== newState) stateEl.textContent = newState; + + // Art + const mainArt = $('#player-art'); + const waArt = doc.getElementById('wa-art'); + if (waArt && mainArt && waArt.src !== mainArt.src) waArt.src = mainArt.src; + + // Format + const badge = $('#audio-format-badge'); + const waFormat = doc.getElementById('wa-format'); + if (waFormat && badge) { + waFormat.textContent = badge.textContent || 'MP3'; + waFormat.style.color = (badge.textContent === 'HiFi' || badge.textContent === 'FLAC') ? '#00e000' : '#c0c000'; + } +} + +// ==================== AI ASSISTANT MODAL ==================== + +const aiModal = document.getElementById('ai-modal'); +const aiModalClose = document.getElementById('ai-modal-close'); +const aiModalOverlay = aiModal?.querySelector('.ai-modal-overlay'); +const aiMenuBtn = document.getElementById('ai-menu-btn'); + +// Playlist Generator elements +const aiPlaylistInput = document.getElementById('ai-playlist-input'); +const aiPlaylistGenBtn = document.getElementById('ai-playlist-gen-btn'); +const aiPlaylistResults = document.getElementById('ai-playlist-results'); +const aiDurationSlider = document.getElementById('ai-duration-slider'); +const aiDurationLabel = document.getElementById('ai-duration-label'); + +// Open/Close Modal +function openAIModal() { + if (aiModal) { + aiModal.classList.remove('hidden'); + aiPlaylistInput?.focus(); + // Hide menu if open + document.getElementById('search-more-menu')?.classList.add('hidden'); + } +} + +function closeAIModal() { + if (aiModal) { + aiModal.classList.add('hidden'); + } +} + +// Event listeners +aiMenuBtn?.addEventListener('click', openAIModal); +aiModalClose?.addEventListener('click', closeAIModal); +aiModalOverlay?.addEventListener('click', closeAIModal); + +// Duration slider +aiDurationSlider?.addEventListener('input', () => { + if (aiDurationLabel) { + aiDurationLabel.textContent = `${aiDurationSlider.value} min`; + } +}); + +// Playlist Generator +aiPlaylistGenBtn?.addEventListener('click', async () => { + const description = aiPlaylistInput?.value?.trim(); + if (!description) return; + + const duration = parseInt(aiDurationSlider?.value) || 60; + + aiPlaylistGenBtn.disabled = true; + aiPlaylistResults.innerHTML = '
Generating playlist
'; + + try { + const res = await fetch('/api/ai/generate-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description, duration_mins: duration }) + }); + const data = await res.json(); + + if (data.tracks && data.tracks.length > 0) { + let html = ` +
+ 🎵 ${data.playlist_name || 'Generated Playlist'} + ${data.tracks.length} tracks +
+ `; + + data.tracks.forEach((track, i) => { + html += ` +
+ ${i + 1} +
+
${escapeHtml(track.title)}
+
${escapeHtml(track.artist)}
+
+
+ `; + }); + + html += ''; + + aiPlaylistResults.innerHTML = html; + + // Click handler for individual tracks + aiPlaylistResults.querySelectorAll('.ai-track-item').forEach(item => { + item.addEventListener('click', async () => { + const searchQuery = `${item.dataset.artist} ${item.dataset.title}`; + closeAIModal(); + searchInput.value = searchQuery; + await performSearch(searchQuery); + }); + }); + + // Add all button + document.getElementById('ai-add-all')?.addEventListener('click', async () => { + closeAIModal(); + const wasEmpty = state.queue.length === 0; + // Auto-play if queue was empty OR nothing is currently loaded/playing + const shouldAutoPlay = wasEmpty || !state.currentTrack; + + const tracks = data.tracks; + if (tracks.length === 0) return; + + // Helper to search for a track + const searchTrack = async (track) => { + try { + const searchRes = await fetch(`/api/search?q=${encodeURIComponent(track.artist + ' ' + track.title)}&type=track&limit=1`); + const searchData = await searchRes.json(); + if (searchData.results && searchData.results.length > 0) { + return searchData.results[0]; + } + } catch (e) { + console.error('Failed to find track:', track.title); + } + return null; + }; + + let hasStartedPlaying = false; + + // 1. Process FIRST track immediately for instant playback + const firstTrack = tracks[0]; + const firstResult = await searchTrack(firstTrack); + + if (firstResult) { + addToQueue(firstResult); + if (shouldAutoPlay) { + playTrack(firstResult); + hasStartedPlaying = true; + } + } + + // 2. Process REST in parallel to preserve order but run fast + if (tracks.length > 1) { + const restTracks = tracks.slice(1); + // Fetch all in parallel + // Note: If list is huge (e.g. 50+), we might want to batch this. + // But for typical AI playlists (10-20), Promise.all is fine. + const results = await Promise.all(restTracks.map(t => searchTrack(t))); + + // Add valid results to queue in order + let addedCount = 0; + results.forEach(result => { + if (result) { + addToQueue(result); + addedCount++; + + // Fallback: If first track failed to play/find, play the first valid one we found here + if (shouldAutoPlay && !hasStartedPlaying) { + playTrack(result); + hasStartedPlaying = true; + } + } + }); + + if (addedCount > 0) { + showToast(`Added ${addedCount + (firstResult ? 1 : 0)} tracks to queue`); + } + } + }); + } else { + aiPlaylistResults.innerHTML = '

Could not generate playlist. Try a different description.

'; + } + } catch (e) { + console.error('Playlist generation error:', e); + aiPlaylistResults.innerHTML = '

Playlist generation failed. Please try again.

'; + } finally { + aiPlaylistGenBtn.disabled = false; + } +}); + +// Close on Escape +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && aiModal && !aiModal.classList.contains('hidden')) { + closeAIModal(); + } +}); + +console.log('AI Assistant module loaded'); + +// ========== KEYBOARD SHORTCUTS ========== +// Use existing shortcuts modal +const shortcutsModal = $('#shortcuts-help'); +const shortcutsCloseBtn = $('#shortcuts-close'); + +function openShortcutsModal() { + if (shortcutsModal) shortcutsModal.classList.remove('hidden'); +} + +function closeShortcutsModal() { + if (shortcutsModal) shortcutsModal.classList.add('hidden'); +} + +if (shortcutsCloseBtn) { + shortcutsCloseBtn.addEventListener('click', closeShortcutsModal); +} + +// Close on backdrop click +if (shortcutsModal) { + shortcutsModal.addEventListener('click', (e) => { + if (e.target === shortcutsModal) closeShortcutsModal(); + }); +} + +// Global keyboard shortcuts +document.addEventListener('keydown', (e) => { + // Ignore if typing in input + const target = e.target; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + // Check for modals first + if (e.key === 'Escape') { + if (shortcutsModal && !shortcutsModal.classList.contains('hidden')) { + closeShortcutsModal(); + return; + } + // Other escape handlers exist in their own listeners + } + + // ? - Show shortcuts help + if (e.key === '?' || (e.shiftKey && e.key === '/')) { + e.preventDefault(); + openShortcutsModal(); + return; + } + + // / - Focus search + if (e.key === '/' && !e.shiftKey) { + e.preventDefault(); + if (searchInput) searchInput.focus(); + return; + } + + // Space - Play/Pause + if (e.key === ' ') { + e.preventDefault(); + togglePlay(); + return; + } + + // Arrow Left - Previous track + if (e.key === 'ArrowLeft' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + playPrevious(); + return; + } + + // Arrow Right - Next track + if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + playNext(); + return; + } + + // Arrow Up - Volume up + if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + const newVol = Math.min(1, state.volume + 0.1); + setVolume(newVol); + return; + } + + // Arrow Down - Volume down + if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + const newVol = Math.max(0, state.volume - 0.1); + setVolume(newVol); + return; + } + + // M - Mute/Unmute + if (e.key.toLowerCase() === 'm') { + toggleMute(); + return; + } + + // R - Toggle repeat + if (e.key.toLowerCase() === 'r') { + cycleRepeatMode(); + return; + } + + // S - Shuffle (if in queue view) (no Shift) + if (e.key.toLowerCase() === 's' && !e.shiftKey) { + shuffleQueue(); + return; + } + + // Shift + S - Sync to Drive + if (e.key.toLowerCase() === 's' && e.shiftKey) { + e.preventDefault(); + syncBtn?.click(); + return; + } + + // F - Toggle fullscreen player + if (e.key.toLowerCase() === 'f') { + toggleFullscreenPlayer(); + return; + } + + // Q - Toggle queue + if (e.key.toLowerCase() === 'q') { + toggleQueue(); + return; + } + + // E - Toggle EQ panel + if (e.key.toLowerCase() === 'e') { + eqToggleBtn?.click(); + return; + } + + // P - Add current track to Playlist + if (e.key.toLowerCase() === 'p') { + // Yield to visualizer controls + if (visualizerActive && visualizerMode === 'milkdrop') return; + + const currentTrack = state.queue[state.currentIndex]; + if (currentTrack && window.openAddToPlaylistModal) { + window.openAddToPlaylistModal(currentTrack); + } else { + showToast('No track playing'); + } + return; + } + + // H - Toggle HiFi / Hi-Res mode + if (e.key.toLowerCase() === 'h') { + hifiBtn?.click(); + return; + } + + // D - Download current track + if (e.key.toLowerCase() === 'd') { + downloadCurrentTrack(); + return; + } + + // A - Toggle AI Radio + if (e.key.toLowerCase() === 'a') { + aiRadioBtn?.click(); + return; + } +}); + +console.log('Keyboard shortcuts loaded'); + +// ========== INIT: Load persisted state ========== +// Load saved queue on startup +setTimeout(() => { + loadQueueFromStorage(); + // Apply saved volume to audio players AND slider UI + audioPlayer.volume = state.volume; + audioPlayer2.volume = state.volume; + if (volumeSlider) { + volumeSlider.value = Math.round(state.volume * 100); + } + console.log(`Volume restored: ${Math.round(state.volume * 100)}%`); +}, 100); + +// ========== LYRICS MODAL ========== +const lyricsBtn = $('#lyrics-btn'); +const fsLyricsBtn = $('#fs-lyrics-btn'); +const lyricsModal = $('#lyrics-modal'); +const lyricsModalClose = $('#lyrics-modal-close'); +const lyricsModalArt = $('#lyrics-modal-art'); +const lyricsModalTitle = $('#lyrics-modal-title'); +const lyricsModalArtist = $('#lyrics-modal-artist'); +const lyricsModalAlbum = $('#lyrics-modal-album'); +const lyricsLoading = $('#lyrics-loading'); +const lyricsText = $('#lyrics-text'); +const lyricsNotFound = $('#lyrics-not-found'); +const lyricsSearchLink = $('#lyrics-search-link'); +const aboutDescription = $('#about-description'); +const aboutRelease = $('#about-release'); +const aboutWriters = $('#about-writers'); +const aboutProducers = $('#about-producers'); +const geniusLink = $('#genius-link'); +const annotationsList = $('#annotations-list'); +const annotationsEmpty = $('#annotations-empty'); +const lyricsTabs = document.querySelectorAll('.lyrics-tab'); +const lyricsPanels = document.querySelectorAll('.lyrics-panel'); + +let currentLyricsData = null; + +async function openLyricsModal() { + const track = state.queue[state.currentIndex]; + if (!track) { + showToast('No track playing'); + return; + } + + // Show modal and loading state + lyricsModal.classList.remove('hidden'); + lyricsLoading.classList.remove('hidden'); + lyricsText.textContent = ''; + lyricsNotFound.classList.add('hidden'); + aboutDescription.textContent = ''; + aboutRelease.textContent = ''; + aboutWriters.textContent = ''; + aboutProducers.textContent = ''; + + // Set header info + lyricsModalArt.src = track.album_art || '/static/icon.svg'; + lyricsModalTitle.textContent = track.name || 'Unknown'; + lyricsModalArtist.textContent = track.artists || 'Unknown Artist'; + lyricsModalAlbum.textContent = track.album || ''; + + // Reset to lyrics tab + lyricsTabs.forEach(t => t.classList.remove('active')); + lyricsPanels.forEach(p => p.classList.remove('active')); + document.querySelector('[data-tab="lyrics"]')?.classList.add('active'); + document.getElementById('lyrics-panel')?.classList.add('active'); + + // Fetch lyrics + try { + const artist = track.artists || ''; + const title = track.name || ''; + const response = await fetch(`/api/lyrics?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); + const data = await response.json(); + + currentLyricsData = data; + lyricsLoading.classList.add('hidden'); + + if (data.found && data.lyrics) { + lyricsText.textContent = data.lyrics; + lyricsNotFound.classList.add('hidden'); + } else { + lyricsText.textContent = ''; + lyricsNotFound.classList.remove('hidden'); + lyricsSearchLink.href = `https://genius.com/search?q=${encodeURIComponent(artist + ' ' + title)}`; + } + + // Populate About tab + if (data.about) { + aboutDescription.textContent = data.about; + } else { + aboutDescription.textContent = 'No description available for this track.'; + } + + if (data.release_date) { + aboutRelease.innerHTML = `Released: ${data.release_date}`; + } + if (data.writers && data.writers.length > 0) { + aboutWriters.innerHTML = `Written by: ${data.writers.join(', ')}`; + } + if (data.producers && data.producers.length > 0) { + aboutProducers.innerHTML = `Produced by: ${data.producers.join(', ')}`; + } + if (data.genius_url) { + geniusLink.href = data.genius_url; + geniusLink.classList.remove('hidden'); + } else { + geniusLink.classList.add('hidden'); + } + + // Populate Annotations tab + if (data.annotations && data.annotations.length > 0) { + annotationsList.innerHTML = data.annotations.map(ann => ` +
+
"${ann.fragment}"
+
${ann.text}
+
+ `).join(''); + annotationsEmpty.classList.add('hidden'); + } else { + annotationsList.innerHTML = ''; + annotationsEmpty.classList.remove('hidden'); + } + + } catch (error) { + console.error('Lyrics fetch error:', error); + lyricsLoading.classList.add('hidden'); + lyricsText.textContent = ''; + lyricsNotFound.classList.remove('hidden'); + const artist = track.artists || ''; + const title = track.name || ''; + lyricsSearchLink.href = `https://genius.com/search?q=${encodeURIComponent(artist + ' ' + title)}`; + } +} + +function closeLyricsModal() { + lyricsModal.classList.add('hidden'); + currentLyricsData = null; +} + +// Button handlers +if (lyricsBtn) { + lyricsBtn.addEventListener('click', openLyricsModal); +} +if (fsLyricsBtn) { + fsLyricsBtn.addEventListener('click', openLyricsModal); +} +if (lyricsModalClose) { + lyricsModalClose.addEventListener('click', closeLyricsModal); +} + +// Close on backdrop click +lyricsModal?.addEventListener('click', (e) => { + if (e.target === lyricsModal) { + closeLyricsModal(); + } +}); + +// Tab switching +lyricsTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + + lyricsTabs.forEach(t => t.classList.remove('active')); + lyricsPanels.forEach(p => p.classList.remove('active')); + + tab.classList.add('active'); + document.getElementById(`${tabName}-panel`)?.classList.add('active'); + }); +}); + +// Add L keyboard shortcut for lyrics +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key.toLowerCase() === 'l' && !e.ctrlKey && !e.metaKey) { + openLyricsModal(); + } +}); + +console.log('Lyrics modal loaded'); + +// ========== MUSIC VIDEO ========== +const fsVideoBtn = $('#fs-video-btn'); + +function openMusicVideo() { + const track = state.queue[state.currentIndex]; + if (!track) { + showToast('No track playing'); + return; + } + + const artist = track.artists || ''; + const title = track.name || ''; + const query = `${artist} ${title} official music video`; + + // Open YouTube search in new tab + const youtubeUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + window.open(youtubeUrl, '_blank'); + + showToast('🎬 Opening YouTube...'); +} + +// Button handlers +if (fsVideoBtn) { + fsVideoBtn.addEventListener('click', openMusicVideo); +} + +// Also add to more menu video button +const videoBtn = $('#video-btn'); +if (videoBtn) { + videoBtn.addEventListener('click', openMusicVideo); +} + +// V keyboard shortcut for video +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key.toLowerCase() === 'v' && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.shiftKey) { + e.preventDefault(); + openVisualizer(); + } else { + openMusicVideo(); + } + } +}); + +console.log('Music video feature loaded'); + +// Concerts feature removed per user request + +// ========== AUDIO VISUALIZER ========== +const visualizerBtn = $('#fs-visualizer-btn'); +const visualizerOverlay = $('#visualizer-overlay'); +const visualizerCanvas = $('#visualizer-canvas'); +const visualizerCanvasWebgl = $('#visualizer-canvas-webgl'); +const visualizerClose = $('#visualizer-close'); +const vizTrackName = $('#viz-track-name'); +const vizTrackArtist = $('#viz-track-artist'); +const vizModeBtns = document.querySelectorAll('.viz-mode-btn'); + +let visualizerActive = false; +let visualizerMode = 'bars'; +let vizAnalyser = null; +let animationId = null; +let particles = []; + +// Butterchurn (MilkDrop) variables +let butterchurnVisualizer = null; +let butterchurnPresets = []; +let butterchurnPresetNames = []; +let currentPresetIndex = 0; + +function initButterchurn() { + const bc = window.butterchurn?.default || window.butterchurn; + if (butterchurnVisualizer || !bc) { + if (!bc) console.error('Butterchurn library not found on window object'); + return null; + } + + try { + const canvas = visualizerCanvasWebgl || visualizerCanvas; // Fallback if element missing + butterchurnVisualizer = bc.createVisualizer( + audioContext, + canvas, + { + width: canvas.width, + height: canvas.height, + pixelRatio: window.devicePixelRatio || 1, + textureRatio: 1 + } + ); + + // Load presets + let presets = window.butterchurnPresets?.default || window.butterchurnPresets; + if (presets) { + // Check if it's a module with getPresets + if (typeof presets.getPresets === 'function') { + presets = presets.getPresets(); + } + + butterchurnPresets = presets; + butterchurnPresetNames = Object.keys(butterchurnPresets); + console.log(`Loaded ${butterchurnPresetNames.length} MilkDrop presets`); + + // Load a random preset to start + if (butterchurnPresetNames.length > 0) { + currentPresetIndex = Math.floor(Math.random() * butterchurnPresetNames.length); + loadButterchurnPreset(currentPresetIndex); + } + } + + // Connect to audio + butterchurnVisualizer.connectAudio(vizAnalyser || volumeBoostGain); + + console.log('Butterchurn initialized'); + return butterchurnVisualizer; + } catch (e) { + console.error('Failed to init Butterchurn:', e); + return null; + } +} + +function loadButterchurnPreset(index) { + if (!butterchurnVisualizer || butterchurnPresetNames.length === 0) return; + + // Ensure index is valid + if (index < 0) index = butterchurnPresetNames.length - 1; + if (index >= butterchurnPresetNames.length) index = 0; + currentPresetIndex = index; + + const presetName = butterchurnPresetNames[index]; + const preset = butterchurnPresets[presetName]; + + console.log(`Loading preset [${index}]: ${presetName}`, preset ? 'found' : 'missing'); + + if (preset) { + try { + butterchurnVisualizer.loadPreset(preset, 1.0); // 1.0 = blend time + showToast(`🎆 ${presetName}`); + } catch (err) { + console.error('Error loading preset:', err); + } + } +} + +function nextButterchurnPreset() { + console.log('Next preset clicked'); + loadButterchurnPreset(currentPresetIndex + 1); +} + +function prevButterchurnPreset() { + console.log('Prev preset clicked'); + loadButterchurnPreset(currentPresetIndex - 1); +} + +function randomButterchurnPreset() { + currentPresetIndex = Math.floor(Math.random() * butterchurnPresetNames.length); + loadButterchurnPreset(currentPresetIndex); +} + +function initVisualizerAnalyser() { + if (vizAnalyser) return; + + // We need to use the existing audioContext from the equalizer + // First ensure EQ is initialized (which creates the audioContext) + if (!audioContext) { + initEqualizer(); + } + + if (!audioContext) { + console.error('No audio context available for visualizer'); + return; + } + + try { + // Create analyser and connect it to the audio chain + vizAnalyser = audioContext.createAnalyser(); + vizAnalyser.fftSize = 256; + vizAnalyser.smoothingTimeConstant = 0.8; + + // Connect the volumeBoostGain to the analyser, then analyser to destination + // We need to disconnect volumeBoostGain from destination first + // Actually, let's just connect analyser in parallel to monitor the output + if (volumeBoostGain) { + volumeBoostGain.connect(vizAnalyser); + } else { + // If no EQ chain, try direct connection (fallback) + console.warn('No volumeBoostGain, visualizer may not work well'); + } + + console.log('Visualizer analyser connected to audio chain'); + } catch (e) { + console.error('Failed to init visualizer analyser:', e); + } +} + +function drawBars(ctx, dataArray, width, height) { + const barCount = 64; + const barWidth = width / barCount - 2; + const gradient = ctx.createLinearGradient(0, height, 0, 0); + gradient.addColorStop(0, '#ec4899'); + gradient.addColorStop(0.5, '#f59e0b'); + gradient.addColorStop(1, '#10b981'); + + for (let i = 0; i < barCount; i++) { + const barHeight = (dataArray[i] / 255) * height * 0.8; + const x = i * (barWidth + 2); + + ctx.fillStyle = gradient; + ctx.fillRect(x, height - barHeight, barWidth, barHeight); + + // Mirror reflection + ctx.globalAlpha = 0.3; + ctx.fillRect(x, height, barWidth, barHeight * 0.3); + ctx.globalAlpha = 1; + } +} + +function drawWave(ctx, dataArray, width, height) { + ctx.beginPath(); + ctx.strokeStyle = '#ec4899'; + ctx.lineWidth = 3; + + const sliceWidth = width / dataArray.length; + let x = 0; + + for (let i = 0; i < dataArray.length; i++) { + const v = dataArray[i] / 255; + const y = v * height; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + x += sliceWidth; + } + + ctx.stroke(); + + // Draw mirrored wave + ctx.beginPath(); + ctx.strokeStyle = '#f59e0b'; + ctx.globalAlpha = 0.5; + x = 0; + for (let i = 0; i < dataArray.length; i++) { + const v = dataArray[i] / 255; + const y = height - (v * height); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + x += sliceWidth; + } + ctx.stroke(); + ctx.globalAlpha = 1; +} + + + +function drawParticles(ctx, dataArray, width, height) { + // Spawn new particles based on audio intensity + const avgIntensity = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; + + if (avgIntensity > 100 && particles.length < 200) { + for (let i = 0; i < 3; i++) { + particles.push({ + x: Math.random() * width, + y: height + 10, + vx: (Math.random() - 0.5) * 4, + vy: -(Math.random() * 5 + 2), + size: Math.random() * 6 + 2, + color: `hsl(${Math.random() * 60 + 300}, 100%, 60%)`, + life: 1 + }); + } + } + + // Update and draw particles + particles = particles.filter(p => p.life > 0); + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.02; // Gravity + p.life -= 0.01; + + ctx.beginPath(); + const radius = Math.max(0, p.size * p.life); + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fillStyle = p.color; + ctx.globalAlpha = p.life; + ctx.fill(); + } + ctx.globalAlpha = 1; +} + +function renderVisualizer() { + if (!visualizerActive || !vizAnalyser) return; + + const canvas = visualizerCanvas; + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + + // Get frequency data + const bufferLength = vizAnalyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + vizAnalyser.getByteFrequencyData(dataArray); + + // Clear canvas with fade effect + ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.fillRect(0, 0, width, height); + + // Draw based on mode + switch (visualizerMode) { + case 'milkdrop': + // Butterchurn handles its own rendering + if (butterchurnVisualizer) { + butterchurnVisualizer.render(); + } + break; + case 'bars': + drawBars(ctx, dataArray, width, height); + break; + case 'wave': + drawWave(ctx, dataArray, width, height); + break; + + case 'particles': + drawParticles(ctx, dataArray, width, height); + break; + } + + animationId = requestAnimationFrame(renderVisualizer); +} + +// Visualizer Idle State +let visualizerIdleTimer = null; +let vizInfoBriefTimer = null; +let visualizerListenersAttached = false; + +function resetVisualizerIdleTimer() { + if (!visualizerActive) return; + + // Remove idle class (show UI) + visualizerOverlay.classList.remove('user-idle'); + + // Clear existing timer + if (visualizerIdleTimer) clearTimeout(visualizerIdleTimer); + + // Set new timer (10s) + visualizerIdleTimer = setTimeout(() => { + if (visualizerActive) { + visualizerOverlay.classList.add('user-idle'); + } + }, 10000); +} + +function showVisualizerInfoBriefly() { + if (!visualizerActive) return; + + // Ensure info is updated + const track = state.queue[state.currentIndex]; + if (track) { + vizTrackName.textContent = track.name || 'Unknown Track'; + vizTrackArtist.textContent = track.artists || ''; + } + + // Add temp-visible class + const info = document.querySelector('.visualizer-track-info'); + if (info) { + info.classList.add('temp-visible'); + + if (vizInfoBriefTimer) clearTimeout(vizInfoBriefTimer); + + vizInfoBriefTimer = setTimeout(() => { + info.classList.remove('temp-visible'); + }, 15000); // 15s + } +} + +function initVisualizerIdleState() { + if (visualizerListenersAttached) return; + + const events = ['mousemove', 'mousedown', 'click', 'keydown', 'touchstart']; + events.forEach(event => { + document.addEventListener(event, resetVisualizerIdleTimer); + }); + + visualizerListenersAttached = true; + resetVisualizerIdleTimer(); +} + +function openVisualizer() { + const track = state.queue[state.currentIndex]; + if (!track) { + showToast('Play a track first'); + return; + } + + // Initialize visualizer analyser (uses existing audioContext from EQ) + initVisualizerAnalyser(); + if (audioContext?.state === 'suspended') { + audioContext.resume(); + } + + // Init idle state + initVisualizerIdleState(); + visualizerOverlay.classList.remove('user-idle'); + + // Update track info + vizTrackName.textContent = track.name || 'Unknown Track'; + vizTrackArtist.textContent = track.artists || ''; + + // Set canvas size + visualizerCanvas.width = window.innerWidth; + visualizerCanvas.height = window.innerHeight; + if (visualizerCanvasWebgl) { + visualizerCanvasWebgl.width = window.innerWidth; + visualizerCanvasWebgl.height = window.innerHeight; + } + + // Initial visibility + if (visualizerMode === 'milkdrop') { + if (!butterchurnVisualizer) initButterchurn(); + visualizerCanvasWebgl?.classList.remove('hidden'); + visualizerCanvas?.classList.add('hidden'); + } else { + visualizerCanvasWebgl?.classList.add('hidden'); + visualizerCanvas?.classList.remove('hidden'); + } + + // Show overlay + visualizerOverlay.classList.remove('hidden'); + visualizerActive = true; + + // Start rendering + renderVisualizer(); +} + +function closeVisualizer() { + visualizerActive = false; + visualizerOverlay.classList.add('hidden'); + + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } +} + +// Button handlers +if (visualizerBtn) { + visualizerBtn.addEventListener('click', openVisualizer); +} +// Also add to more menu visualizer button +const menuVisualizerBtn = $('#menu-visualizer-btn'); +if (menuVisualizerBtn) { + menuVisualizerBtn.addEventListener('click', openVisualizer); +} + +if (visualizerClose) { + visualizerClose.addEventListener('click', closeVisualizer); +} + +// Close on ESC +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && visualizerActive) { + closeVisualizer(); + } + + // N for Next Preset (MilkDrop) + if ((e.key === 'n' || e.key === 'N') && visualizerActive && visualizerMode === 'milkdrop') { + nextButterchurnPreset(); + } + + // P for Prev Preset (MilkDrop) + if ((e.key === 'p' || e.key === 'P') && visualizerActive && visualizerMode === 'milkdrop') { + prevButterchurnPreset(); + } +}); + +// Mode switching +vizModeBtns.forEach(btn => { + btn.addEventListener('click', () => { + // Toggle preset button visibility + const isMilkDrop = btn.dataset.mode === 'milkdrop'; + const nextPresetBtn = document.getElementById('viz-next-preset'); + const prevPresetBtn = document.getElementById('viz-prev-preset'); + + if (nextPresetBtn) nextPresetBtn.style.display = isMilkDrop ? 'block' : 'none'; + if (prevPresetBtn) prevPresetBtn.style.display = isMilkDrop ? 'block' : 'none'; + + // Handle normal mode switching + if (!btn.id || (btn.id !== 'viz-next-preset' && btn.id !== 'viz-prev-preset')) { + vizModeBtns.forEach(b => { + if (b.id !== 'viz-next-preset' && b.id !== 'viz-prev-preset') b.classList.remove('active'); + }); + btn.classList.add('active'); + visualizerMode = btn.dataset.mode; + particles = []; // Clear particles when switching modes + + // Init Butterchurn if needed + if (visualizerMode === 'milkdrop') { + if (!butterchurnVisualizer) initButterchurn(); + // Toggle canvases + if (visualizerCanvasWebgl) { + visualizerCanvasWebgl.classList.remove('hidden'); + visualizerCanvas.classList.add('hidden'); + } + } else { + // Toggle canvases + if (visualizerCanvasWebgl) { + visualizerCanvasWebgl.classList.add('hidden'); + visualizerCanvas.classList.remove('hidden'); + } + } + } + }); +}); + +const vizNextPresetBtn = document.getElementById('viz-next-preset'); +if (vizNextPresetBtn) { + vizNextPresetBtn.addEventListener('click', (e) => { + e.stopPropagation(); + nextButterchurnPreset(); + }); +} +const vizPrevPresetBtn = document.getElementById('viz-prev-preset'); +if (vizPrevPresetBtn) { + vizPrevPresetBtn.addEventListener('click', (e) => { + e.stopPropagation(); + prevButterchurnPreset(); + }); +} + +// Handle window resize +window.addEventListener('resize', () => { + if (visualizerActive) { + visualizerCanvas.width = window.innerWidth; + visualizerCanvas.height = window.innerHeight; + + if (visualizerCanvasWebgl) { + visualizerCanvasWebgl.width = window.innerWidth; + visualizerCanvasWebgl.height = window.innerHeight; + } + + if (butterchurnVisualizer) { + butterchurnVisualizer.setRendererSize(window.innerWidth, window.innerHeight); + } + } +}); + +console.log('Audio visualizer loaded'); + +// ========== CONCERT ALERTS ========== + +const concertModal = $('#concert-modal'); +const concertModalClose = $('#concert-modal-close'); +const concertMenuBtn = $('#concert-search-menu-btn'); +const concertResults = $('#concert-results'); +const concertLoading = $('#concert-loading'); +const concertEmpty = $('#concert-empty'); +const concertArtistSearch = $('#concert-artist-search'); +const concertSearchBtn = $('#concert-search-btn'); +const concertTabs = $$('.concert-tab'); +const concertRecentSection = $('#concert-recent-section'); +const concertSearchSection = $('#concert-search-section'); + +// Concert State +const concertState = { + currentTab: 'recent' +}; + +// Open Concert Modal (optionally with artist pre-filled from main search) +function openConcertModal(artistQuery = null) { + concertModal?.classList.remove('hidden'); + + // If artist query provided, switch to search tab and auto-search + if (artistQuery && artistQuery.trim()) { + concertState.currentTab = 'search'; + concertTabs.forEach(t => t.classList.remove('active')); + concertTabs.forEach(t => { if (t.dataset.tab === 'search') t.classList.add('active'); }); + concertRecentSection?.classList.add('hidden'); + concertSearchSection?.classList.remove('hidden'); + + if (concertArtistSearch) { + concertArtistSearch.value = artistQuery.trim(); + } + searchConcerts(artistQuery.trim()); + } else if (concertState.currentTab === 'recent') { + // Load concerts for recent artists by default + loadConcertsForRecentArtists(); + } +} + +// Close Concert Modal +function closeConcertModal() { + concertModal?.classList.add('hidden'); +} + +// Get unique artists from recent listen history +function getRecentArtists() { + const artistSet = new Set(); + const artists = []; + + // Get from current queue + state.queue.forEach(track => { + if (track.artists && !artistSet.has(track.artists)) { + artistSet.add(track.artists); + artists.push(track.artists.split(',')[0].trim()); // Take first artist + } + }); + + // Limit to 10 unique artists + return artists.slice(0, 10); +} + +// Load concerts for recent artists +async function loadConcertsForRecentArtists() { + const artists = getRecentArtists(); + + if (artists.length === 0) { + concertResults.innerHTML = ''; + concertEmpty.classList.remove('hidden'); + concertEmpty.querySelector('p').textContent = 'Listen to some music first to see concert recommendations!'; + return; + } + + concertLoading.classList.remove('hidden'); + concertEmpty.classList.add('hidden'); + concertResults.innerHTML = ''; + + try { + const response = await fetch(`/api/concerts/for-artists?artists=${encodeURIComponent(artists.join(','))}`); + const data = await response.json(); + + concertLoading.classList.add('hidden'); + + if (data.events && data.events.length > 0) { + renderConcertCards(data.events); + } else { + concertEmpty.classList.remove('hidden'); + concertEmpty.querySelector('p').textContent = 'No upcoming concerts found for your recent artists'; + } + } catch (error) { + console.error('Concert fetch error:', error); + concertLoading.classList.add('hidden'); + concertEmpty.classList.remove('hidden'); + concertEmpty.querySelector('p').textContent = 'Failed to load concerts. Check API keys.'; + } +} + +// Search concerts for a specific artist +async function searchConcerts(artist) { + if (!artist.trim()) return; + + concertLoading.classList.remove('hidden'); + concertEmpty.classList.add('hidden'); + concertResults.innerHTML = ''; + + try { + const response = await fetch(`/api/concerts/search?artist=${encodeURIComponent(artist)}`); + const data = await response.json(); + + concertLoading.classList.add('hidden'); + + if (data.events && data.events.length > 0) { + renderConcertCards(data.events); + } else { + concertEmpty.classList.remove('hidden'); + concertEmpty.querySelector('p').textContent = `No upcoming concerts found for "${artist}"`; + } + } catch (error) { + console.error('Concert search error:', error); + concertLoading.classList.add('hidden'); + concertEmpty.classList.remove('hidden'); + concertEmpty.querySelector('p').textContent = 'Search failed. Check API keys.'; + } +} + +// Render concert cards +function renderConcertCards(events) { + concertResults.innerHTML = events.map(event => { + const date = event.date ? new Date(event.date + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric' + }) : 'TBA'; + + const time = event.time ? formatConcertTime(event.time) : ''; + const location = [event.city, event.state, event.country].filter(Boolean).join(', '); + const priceRange = event.price_min && event.price_max + ? `$${Math.round(event.price_min)} - $${Math.round(event.price_max)}` + : event.price_min + ? `From $${Math.round(event.price_min)}` + : ''; + + return ` +
+ ${event.image + ? `${event.artist}` + : '
🎵
' + } +
+
+ ${event.artist || event.name} + ${event.source} +
+
📍 ${event.venue}${location ? `, ${location}` : ''}
+
📅 ${date}${time ? ` • ${time}` : ''}
+ ${priceRange ? `
💰 ${priceRange}
` : ''} +
+
+ ${event.ticket_url + ? `🎫 Tickets` + : '' + } +
+
+ `; + }).join(''); +} + +// Format time from HH:MM:SS to readable +function formatConcertTime(timeStr) { + if (!timeStr) return ''; + const [hours, minutes] = timeStr.split(':'); + const h = parseInt(hours); + const ampm = h >= 12 ? 'PM' : 'AM'; + const hour12 = h % 12 || 12; + return `${hour12}:${minutes} ${ampm}`; +} + +// Event Listeners +concertMenuBtn?.addEventListener('click', () => { + // Get text from main search input if any + const mainSearchInput = $('#search-input'); + const artistQuery = mainSearchInput?.value || ''; + openConcertModal(artistQuery); + // Close the more menu + $('#search-more-menu')?.classList.add('hidden'); +}); +concertModalClose?.addEventListener('click', closeConcertModal); +concertModal?.addEventListener('click', (e) => { + if (e.target === concertModal) closeConcertModal(); +}); + +// Tab switching +concertTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + concertState.currentTab = tabName; + + // Update active tab + concertTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + // Show/hide sections + if (tabName === 'recent') { + concertRecentSection.classList.remove('hidden'); + concertSearchSection.classList.add('hidden'); + loadConcertsForRecentArtists(); + } else { + concertRecentSection.classList.add('hidden'); + concertSearchSection.classList.remove('hidden'); + concertResults.innerHTML = ''; + concertEmpty.classList.add('hidden'); + } + }); +}); + +// Search button +concertSearchBtn?.addEventListener('click', () => { + searchConcerts(concertArtistSearch?.value || ''); +}); + +// Search on Enter +concertArtistSearch?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + searchConcerts(concertArtistSearch.value); + } +}); + +console.log('Concert alerts loaded'); diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..5407e6e Binary files /dev/null and b/static/icon.png differ diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..ec7d85a --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..999bfc6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,778 @@ + + + + + + + + + Freedify + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+

Freedify

+
+ + + + + +
+
+ + + + + +
+
+ + +
+ + +
+ + + + + +
+ + + +
+ + + + + + + + + + + +
+
+ +
+ 🔍 +

Search for your favorite music

+

Or paste a Spotify link to an album or playlist

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/jsmediatags.min.js b/static/jsmediatags.min.js new file mode 100644 index 0000000..55e2186 --- /dev/null +++ b/static/jsmediatags.min.js @@ -0,0 +1,95 @@ +var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(v,h,p){v!=Array.prototype&&v!=Object.prototype&&(v[h]=p.value)};$jscomp.getGlobal=function(v){return"undefined"!=typeof window&&window===v?v:"undefined"!=typeof global&&null!=global?global:v};$jscomp.global=$jscomp.getGlobal(this); +$jscomp.polyfill=function(v,h,p,n){if(h){p=$jscomp.global;v=v.split(".");for(n=0;n=this._array.length)throw Error("Offset "+a+" hasn't been loaded yet.");return this._array[a]}}],[{key:"canReadFile",value:function(a){return Array.isArray(a)|| +"function"===typeof Buffer&&Buffer.isBuffer(a)}}]);return a}(h("./MediaFileReader"));p.exports=h},{"./MediaFileReader":11}],4:[function(h,p,n){function m(a){m="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"===typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a};return m(a)}function q(a,e){for(var d=0;dk.offset&&(f=this._sliceData(k.data,0,f-k.offset),a.data=this._concatData(f,c));b&&(f=this._sliceData(a.data,0,r.offset-a.offset),a.data=this._concatData(f,r.data));this._fileData.splice(g.startIx,g.endIx-g.startIx+1,a)}}},{key:"_concatData",value:function(f,c){if("undefined"!==typeof ArrayBuffer&&ArrayBuffer.isView&&ArrayBuffer.isView(f)){var b= +new f.constructor(f.length+c.length);b.set(f,0);b.set(c,f.length);return b}return f.concat(c)}},{key:"_sliceData",value:function(f,c,b){return f.slice?f.slice(c,b):f.subarray(c,b)}},{key:"_getChunkRange",value:function(f,c){for(var b,g,k=-1,r=-1,a=0,e=0;e=g-1){k=e;break}}if(-1===k)return{startIx:-1,endIx:-1,insertIx:a};for(e=k;e=g-1&&(r=e),c<=b+1);e++);-1===r&&(r=k);return{startIx:k,endIx:r}}},{key:"hasDataRange",value:function(f,c){for(var b=0;b=g.offset&&c=g&&f<=k){c=this._fileData[b];break}}if(c)return c.data[f-c.offset]; +throw Error("Offset "+f+" hasn't been loaded yet.");}}]);return h}();p.exports=h},{}],6:[function(h,p,n){function m(a){m="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"===typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a};return m(a)}function q(a,e){for(var d=0;da?g[a]:""}};e&&(a.tags.track=e);return a}}],[{key:"getTagIdentifierByteRange",value:function(){return{offset:-128,length:128}}},{key:"canReadTagFormat",value:function(a){return"TAG"===String.fromCharCode.apply(String,a.slice(0,3))}}]);return k}(n);var g="Blues;Classic Rock;Country;Dance;Disco;Funk;Grunge;Hip-Hop;Jazz;Metal;New Age;Oldies;Other;Pop;R&B;Rap;Reggae;Rock;Techno;Industrial;Alternative;Ska;Death Metal;Pranks;Soundtrack;Euro-Techno;Ambient;Trip-Hop;Vocal;Jazz+Funk;Fusion;Trance;Classical;Instrumental;Acid;House;Game;Sound Clip;Gospel;Noise;AlternRock;Bass;Soul;Punk;Space;Meditative;Instrumental Pop;Instrumental Rock;Ethnic;Gothic;Darkwave;Techno-Industrial;Electronic;Pop-Folk;Eurodance;Dream;Southern Rock;Comedy;Cult;Gangsta;Top 40;Christian Rap;Pop/Funk;Jungle;Native American;Cabaret;New Wave;Psychadelic;Rave;Showtunes;Trailer;Lo-Fi;Tribal;Acid Punk;Acid Jazz;Polka;Retro;Musical;Rock & Roll;Hard Rock;Folk;Folk-Rock;National Folk;Swing;Fast Fusion;Bebob;Latin;Revival;Celtic;Bluegrass;Avantgarde;Gothic Rock;Progressive Rock;Psychedelic Rock;Symphonic Rock;Slow Rock;Big Band;Chorus;Easy Listening;Acoustic;Humour;Speech;Chanson;Opera;Chamber Music;Sonata;Symphony;Booty Bass;Primus;Porn Groove;Satire;Slow Jam;Club;Tango;Samba;Folklore;Ballad;Power Ballad;Rhythmic Soul;Freestyle;Duet;Punk Rock;Drum Solo;Acapella;Euro-House;Dance Hall".split(";"); +p.exports=h},{"./MediaFileReader":11,"./MediaTagReader":12}],8:[function(h,p,n){function m(a,e){for(var d=0;d2.4",tags:{}};var f=a.getByteAt(e+4),h=a.isBitSetAt(e+5,7),r=a.isBitSetAt(e+5,6),m=a.isBitSetAt(e+5,5),p=a.getSynchsafeInteger32At(e+6);e+=10;if(r)if(4===c){var n=a.getSynchsafeInteger32At(e);e+=n}else n=a.getLongAt(e,!0),e+=n+4;n={type:"ID3",version:"2."+c+"."+f,major:c,revision:f,flags:{unsynchronisation:h,extended_header:r,experimental_indicator:m,footer_present:!1},size:p, +tags:{}};b&&(d=this._expandShortcutTags(b));b=p+10;n.flags.unsynchronisation&&(a=g.getUnsyncFileReader(a,e,p),e=0,b=a.getSize());a=g.readFrames(e,b,a,n,d);for(var q in k)k.hasOwnProperty(q)&&(d=this._getFrameData(a,k[q]))&&(n.tags[q]=d);for(var t in a)a.hasOwnProperty(t)&&(n.tags[t]=a[t]);return n}},{key:"_getFrameData",value:function(a,b){for(var d=0,e;e=b[d];d++)if(e in a)return a=a[e]instanceof Array?a[e][0]:a[e],a.data}},{key:"getShortcuts",value:function(){return k}}],[{key:"getTagIdentifierByteRange", +value:function(){return{offset:0,length:10}}},{key:"canReadTagFormat",value:function(a){return"ID3"===String.fromCharCode.apply(String,a.slice(0,3))}}]);return a}(n);var k={title:["TIT2","TT2"],artist:["TPE1","TP1"],album:["TALB","TAL"],year:["TYER","TYE"],comment:["COMM","COM"],track:["TRCK","TRK"],genre:["TCON","TCO"],picture:["APIC","PIC"],lyrics:["USLT","ULT"]};p.exports=h},{"./ID3v2FrameReader":8,"./MediaFileReader":11,"./MediaTagReader":12}],10:[function(h,p,n){function m(a){m="function"=== +typeof Symbol&&"symbol"===typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"===typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a};return m(a)}function q(a,b){for(var d=0;d=a.getSize())c.onSuccess();else{var d=this,l=a.getLongAt(b,!0);if(0==l||isNaN(l))c.onSuccess();else{var g=a.getStringAt(b+ +4,4);if(this._isContainerAtom(g)){"meta"==g&&(b+=4);var k=(e?e+".":"")+g;"moov.udta.meta.ilst"===k?a.loadRange([b,b+l],c):a.loadRange([b+8,b+8+8],{onSuccess:function(){d._loadAtom(a,b+8,k,c)},onError:c.onError})}else a.loadRange([b+l,b+l+8],{onSuccess:function(){d._loadAtom(a,b+l,e,c)},onError:c.onError})}}}},{key:"_isContainerAtom",value:function(a){return 0<=["moov","udta","meta","ilst"].indexOf(a)}},{key:"_canReadAtom",value:function(a){return"----"!==a}},{key:"_parseData",value:function(a,b){var d= +{};b=this._expandShortcutTags(b);this._readAtom(d,a,0,a.getSize(),b);for(var e in r)r.hasOwnProperty(e)&&(b=d[r[e]])&&(d[e]="track"===e?b.data.track:b.data);return{type:"MP4",ftyp:a.getStringAt(8,4),version:a.getLongAt(12,!0),tags:d}}},{key:"_readAtom",value:function(a,b,e,c,g,k,f){f=void 0===f?"":f+" ";for(var d=e;db&&(b+=65536);return b}},{key:"getSShortAt",value:function(b,c){b=this.getShortAt(b,c);return 32767c&&(c+=4294967296);return c}},{key:"getSLongAt",value:function(b,c){b=this.getLongAt(b,c);return 2147483647c&&(c+=16777216);return c}},{key:"getStringAt",value:function(b,c){for(var g=[],f=b,a=0;fd||224<=d?b[e]=String.fromCharCode(l):(d=(c[k+h]<<8)+c[k+a],k+=2,b[e]=String.fromCharCode(l,d))}return new f(b.join(""),k)},readUTF8String:function(c,b){var g=0;b=Math.min(b||c.length,c.length);239==c[0]&&187==c[1]&&191==c[2]&&(g=3);for(var h=[],m=0;ga)h[m]=String.fromCharCode(a);else if(194<=a&&224>a){var e= +c[g++];h[m]=String.fromCharCode(((a&31)<<6)+(e&63))}else if(224<=a&&240>a){e=c[g++];var d=c[g++];h[m]=String.fromCharCode(((a&255)<<12)+((e&63)<<6)+(d&63))}else if(240<=a&&245>a){e=c[g++];d=c[g++];var l=c[g++];d=((a&7)<<18)+((e&63)<<12)+((d&63)<<6)+(l&63)-65536;h[m]=String.fromCharCode((d>>10)+55296,(d&1023)+56320)}}return new f(h.join(""),g)},readNullTerminatedString:function(c,b){var g=[];b=b||c.length;for(var h=0;he._config.disallowedXhrHeaders.indexOf(b.toLowerCase())&&a.setRequestHeader(b,c)}},{key:"_hasResponseHeader",value:function(a,b){a=a.getAllResponseHeaders();if(!a)return!1;a=a.split("\r\n");for(var d=[],c=0;ca.offset&&(-a.offset>b||0=b||c)}h("./MediaFileReader");var b=h("./XhrFileReader"),g=h("./BlobFileReader"),k=h("./ArrayFileReader");h("./MediaTagReader");var r=h("./ID3v1TagReader"),a=h("./ID3v2TagReader"),e=h("./MP4TagReader"),d=h("./FLACTagReader"),l=[],u=[],w=function(){function a(b){m(this,a);f(this,"_file",void 0);f(this,"_tagsToRead",void 0);f(this,"_fileReader",void 0);f(this,"_tagReader",void 0);this._file= +b}t(a,[{key:"setTagsToRead",value:function(a){this._tagsToRead=a;return this}},{key:"setFileReader",value:function(a){this._fileReader=a;return this}},{key:"setTagReader",value:function(a){this._tagReader=a;return this}},{key:"read",value:function(a){var b=new (this._getFileReader())(this._file),c=this;b.init({onSuccess:function(){c._getTagReader(b,{onSuccess:function(d){(new d(b)).setTagsToRead(c._tagsToRead).read(a)},onError:a.onError})},onError:a.onError})}},{key:"_getFileReader",value:function(){return this._fileReader? +this._fileReader:this._findFileReader()}},{key:"_findFileReader",value:function(){for(var a=0;ah.offset&&h.offset<-f/2?d.push(u[g]):e.push(u[g]))}var k=!1;g={onSuccess:function(){if(k){for(var d=0;d { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +// Activate - clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch - network first, fallback to cache +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip API and audio streaming requests + if (url.pathname.startsWith('/api/')) { + return; + } + + event.respondWith( + fetch(request) + .then((response) => { + // Cache successful responses for static assets + if (response.ok && STATIC_ASSETS.includes(url.pathname)) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }) + .catch(() => { + // Fallback to cache + return caches.match(request); + }) + ); +});