Initial commit

This commit is contained in:
Percy 2026-01-13 22:26:48 +00:00
commit c803de020e
52 changed files with 20439 additions and 0 deletions

393
.github/workflows/build.yml vendored Normal file
View file

@ -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
<details>
<summary><b>Linux Requirements</b></summary>
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
```
</details>
files: |
artifacts/windows-portable/*.exe
artifacts/macos-portable/*.dmg
artifacts/linux-portable/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

46
.gitignore vendored Normal file
View file

@ -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

20
Dockerfile Normal file
View file

@ -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}"]

21
LICENSE Normal file
View file

@ -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.

401
README.md Normal file
View file

@ -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
<p align="center">
<img src="screenshots/album-search.png" alt="Album Search" width="700">
<br><em>Search albums with Hi-Res badges — stream in 24-bit lossless quality from Qobuz</em>
</p>
<p align="center">
<img src="screenshots/album-details.png" alt="Album Details" width="500">
<br><em>Album view with format info, track listing, and one-click download as ZIP</em>
</p>
<p align="center">
<img src="screenshots/fullscreen-player.png" alt="Fullscreen Player" width="500">
<br><em>Immersive fullscreen mode with album art, playback controls, and visualizer toggle</em>
</p>
<p align="center">
<img src="screenshots/download-formats.png" alt="Download Formats" width="400">
<br><em>Smart format selection — options adapt based on source quality (lossy, 16-bit, or 24-bit Hi-Res)</em>
</p>
<p align="center">
<img src="screenshots/equalizer.png" alt="Equalizer" width="400">
<br><em>5-band EQ with presets (Flat, Bass Boost, Treble, Vocal) plus bass and volume boost</em>
</p>
<p align="center">
<img src="screenshots/genius-lyrics.png" alt="Genius Lyrics" width="500">
<br><em>Full lyrics with verse/chorus sections synced from Genius</em>
</p>
<p align="center">
<img src="screenshots/genius-annotations.png" alt="Genius Annotations" width="500">
<br><em>Genius annotations explaining song meanings and references</em>
</p>
<p align="center">
<img src="screenshots/podcast-episode.png" alt="Podcast Episode" width="400">
<br><em>Podcast support with episode details, show notes, and streaming playback</em>
</p>
<p align="center">
<img src="screenshots/milkdrop-visualizer.png" alt="MilkDrop Visualizer" width="700">
<br><em>MilkDrop visualizer powered by Butterchurn — hundreds of audio-reactive presets</em>
</p>
<p align="center">
<img src="screenshots/milkdrop-visualizer-2.png" alt="MilkDrop Visualizer 2" width="700">
<br><em>Switch between MilkDrop, Bars, Wave, and Particles modes with keyboard shortcuts</em>
</p>
---
## 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.

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
# App package

273
app/ai_radio_service.py Normal file
View file

@ -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()

1036
app/audio_service.py Normal file

File diff suppressed because it is too large Load diff

133
app/cache.py Normal file
View file

@ -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()

324
app/concert_service.py Normal file
View file

@ -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()

258
app/dab_service.py Normal file
View file

@ -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()

160
app/deezer_service.py Normal file
View file

@ -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()

406
app/dj_service.py Normal file
View file

@ -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 AB 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()

241
app/genius_service.py Normal file
View file

@ -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()

254
app/jamendo_service.py Normal file
View file

@ -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()

409
app/listenbrainz_service.py Normal file
View file

@ -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()

206
app/live_show_service.py Normal file
View file

@ -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()

1220
app/main.py Normal file

File diff suppressed because it is too large Load diff

194
app/musicbrainz_service.py Normal file
View file

@ -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()

165
app/podcast_service.py Normal file
View file

@ -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()

14
app/requirements.txt Normal file
View file

@ -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

310
app/setlist_service.py Normal file
View file

@ -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()

496
app/spotify_service.py Normal file
View file

@ -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()

158
app/ytmusic_service.py Normal file
View file

@ -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()

0
check.json Normal file
View file

5
icons/circle-x.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

BIN
icons/deezer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

5
icons/download.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

BIN
icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

48
icons/icon.svg Normal file
View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FF0000;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFF00;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#2AA125;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
</style>
<g id="SVGRepo_bgCarrier">
</g>
<g id="SVGRepo_tracerCarrier">
</g>
<g id="Layer_x0020_1">
<g id="_1818452274576">
<g id="SVGRepo_bgCarrier_00000044893734704698182460000014884511992085247122_">
</g>
<g id="SVGRepo_tracerCarrier_00000067939915892718314930000001743019108017086612_">
</g>
<g id="SVGRepo_iconCarrier_00000176000695080737548300000008661679408724292005_">
<path d="M407.1,227.2c-54.7-27.6-119.3-43.8-187.6-43.8c-38.9,0-76.5,5.2-112.3,15l3-0.7c-2.1,0.7-4.5,1.1-7,1.1
c-13,0-23.5-10.5-23.5-23.5c0-10.5,6.8-19.3,16.3-22.4l0.2-0.1c90.9-26.9,240.6-21.8,335.4,34.6c7.3,4.4,12.1,12.3,12.1,21.3
c0,4.4-1.2,8.6-3.2,12.2l0.1-0.1c-5,5.9-12.3,9.6-20.6,9.6c-4.7,0-9-1.2-12.8-3.3L407.1,227.2L407.1,227.2z M404.5,298.9
c-3.4,5.7-9.6,9.4-16.6,9.4c-3.8,0-7.4-1.1-10.4-3l0.1,0.1c-46.8-26.8-102.8-42.5-162.5-42.5c-32.9,0-64.6,4.8-94.6,13.7l2.3-0.6
c-1.7,0.5-3.7,0.9-5.8,0.9c-10.7,0-19.4-8.7-19.4-19.4c0-8.7,5.7-16,13.5-18.5l0.1,0c30.8-9.1,66.2-14.4,102.8-14.4
c68.1,0,132,18.2,187,49.9l-1.8-1c5.1,3.3,8.4,8.9,8.4,15.3C407.7,292.5,406.5,296,404.5,298.9L404.5,298.9L404.5,298.9
L404.5,298.9z M373.8,369.3c-2.7,4.6-7.6,7.7-13.3,7.7c-3.2,0-6.1-1-8.6-2.6l0.1,0c-40.9-23-89.7-36.5-141.7-36.5
c-29.8,0-58.6,4.5-85.7,12.7l2.1-0.5c-1.1,0.3-2.5,0.5-3.8,0.5c-8.7,0-15.8-7.1-15.8-15.8c0-7.4,5.1-13.6,11.9-15.3l0.1,0
c27-8,58-12.6,90.1-12.6c58.1,0,112.6,15.1,159.9,41.7l-1.7-0.9c5.2,2.6,8.6,7.8,8.6,13.8C376,364.3,375.2,367.1,373.8,369.3
L373.8,369.3L373.8,369.3L373.8,369.3z M256-0.6L256-0.6C114.6-0.6,0,114,0,255.4s114.6,256,256,256s256-114.6,256-256l0,0
C511.6,114.2,397.2-0.2,256-0.6L256-0.6L256-0.6L256-0.6z"/>
</g>
</g>
<path class="st0" d="M406.9,227.2c0,0,0.1,0,0.1,0.1L406.9,227.2z M107.1,198.5c35.8-9.8,73.4-15,112.3-15
c68.3,0,132.8,16.1,187.5,43.7c3.8,2.1,8.2,3.3,12.8,3.3c8.2,0,15.6-3.7,20.5-9.6c0,0,0,0,0,0c2-3.6,3.1-7.7,3.1-12
c0-9-4.8-16.8-12.1-21.3c-94.7-56.4-244.5-61.5-335.4-34.6l-0.2,0.1c-9.4,3.1-16.3,11.9-16.3,22.4c0,13,10.5,23.5,23.5,23.5
c2.5,0,4.9-0.4,7-1.1L107.1,198.5L107.1,198.5z"/>
<path class="st1" d="M401.2,274.3c-55-31.8-118.9-49.9-187-49.9c-36.6,0-72,5.3-102.8,14.4l-0.1,0c-7.9,2.5-13.5,9.9-13.5,18.5
c0,10.7,8.7,19.4,19.4,19.4c1.3,0,2.5-0.1,3.6-0.3c29.9-8.8,61.5-13.6,94.3-13.6c59.7,0,115.7,15.7,162.4,42.5l0.1,0.1
c3,1.9,6.5,3,10.3,3c7,0,13.1-3.7,16.6-9.4l0,0c2-2.9,3.2-6.5,3.2-10.3c0-6.4-3.3-12-8.4-15.3L401.2,274.3L401.2,274.3z"/>
<path class="st2" d="M352,374.4C352,374.4,352,374.4,352,374.4L352,374.4z M373.8,369.3C373.8,369.3,373.7,369.4,373.8,369.3
L373.8,369.3L373.8,369.3z M367.8,347.8l-0.4-0.2C367.5,347.6,367.6,347.7,367.8,347.8z M369,348.4
c-47.3-26.5-101.8-41.7-159.9-41.7c-32.1,0-63.1,4.6-90.1,12.6l-0.1,0c-6.8,1.8-11.9,8-11.9,15.3c0,8.7,7.1,15.8,15.8,15.8
c1,0,1.9-0.1,2.8-0.3c26.8-8.1,55.2-12.4,84.6-12.4c52,0,100.8,13.5,141.7,36.5l0,0c2.4,1.6,5.4,2.6,8.5,2.6
c5.7,0,10.6-3.1,13.3-7.7h0c1.4-2.3,2.2-5,2.2-7.9c0-5.9-3.3-11-8.2-13.6L369,348.4L369,348.4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
icons/tidal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

3
icons/tool.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

6
icons/trash.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

12
render.yaml Normal file
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
screenshots/equalizer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

6536
static/app.js Normal file

File diff suppressed because it is too large Load diff

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

10
static/icon.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#818cf8"/>
<stop offset="100%" style="stop-color:#6366f1"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#gradient)"/>
<path d="M35 25 L35 75 L75 50 Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

778
static/index.html Normal file
View file

@ -0,0 +1,778 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Freedify</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/static/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/static/icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@400;700;900&family=Permanent+Marker&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css?t=1736053100">
<!-- Google API for Drive sync -->
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script src="https://apis.google.com/js/api.js" async defer></script>
<!-- Butterchurn (MilkDrop WebGL visualizer) -->
<script src="https://cdn.jsdelivr.net/npm/butterchurn@2.6.7/lib/butterchurn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/butterchurn-presets@2.4.7/lib/butterchurnPresets.min.js"></script>
</head>
<body>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Keyboard Shortcuts Help -->
<div id="shortcuts-help" class="shortcuts-help hidden">
<div class="shortcuts-content">
<div class="shortcuts-header">
<h3>⌨️ Keyboard Shortcuts</h3>
<button id="shortcuts-close" class="shortcuts-close">×</button>
</div>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>Space</kbd><span>Play/Pause</span></div>
<div class="shortcut"><kbd></kbd><span>Next Track</span></div>
<div class="shortcut"><kbd></kbd><span>Previous Track</span></div>
<div class="shortcut"><kbd>Shift + →</kbd><span>Seek +10s</span></div>
<div class="shortcut"><kbd>Shift + ←</kbd><span>Seek -10s</span></div>
<div class="shortcut"><kbd></kbd><span>Volume Up</span></div>
<div class="shortcut"><kbd></kbd><span>Volume Down</span></div>
<div class="shortcut"><kbd>M</kbd><span>Mute/Unmute</span></div>
<div class="shortcut"><kbd>S</kbd><span>Shuffle Queue</span></div>
<div class="shortcut"><kbd>R</kbd><span>Toggle Repeat</span></div>
<div class="shortcut"><kbd>F</kbd><span>Fullscreen</span></div>
<div class="shortcut"><kbd>Q</kbd><span>Toggle Queue</span></div>
<div class="shortcut"><kbd>E</kbd><span>Toggle EQ</span></div>
<div class="shortcut"><kbd>P</kbd><span>Add to Playlist</span></div>
<div class="shortcut"><kbd>H</kbd><span>HiFi/Hi-Res</span></div>
<div class="shortcut"><kbd>D</kbd><span>Download Track</span></div>
<div class="shortcut"><kbd>A</kbd><span>AI Radio</span></div>
<div class="shortcut"><kbd>L</kbd><span>Lyrics</span></div>
<div class="shortcut"><kbd>V</kbd><span>Music Video</span></div>
<div class="shortcut"><kbd>Shift + S</kbd><span>Sync to Drive</span></div>
<div class="shortcut"><kbd>?</kbd><span>This Help</span></div>
</div>
</div>
</div>
<div class="app-container">
<!-- Header -->
<header class="header">
<h1 class="logo"><span class="brand-text">Freedify</span></h1>
<div class="header-actions">
<button id="hifi-btn" class="header-btn" title="HiFi Mode - Stream lossless FLAC (faster startup, more data)">HiFi</button>
<label for="file-input" class="header-btn" title="Add Local Songs (MP3/FLAC)" style="cursor: pointer; display: inline-flex; align-items: center; justify-content: center;">📂</label>
<button id="dj-mode-btn" class="header-btn" title="DJ Mode">🎧</button>
<button id="sync-btn" class="header-btn" title="Sync with Google Drive">☁️</button>
<button id="theme-btn" class="theme-btn" title="Change Theme">🎨</button>
</div>
</header>
<!-- Theme Picker Dropdown -->
<div id="theme-picker" class="theme-picker hidden">
<div class="theme-option" data-theme="">🌙 Default</div>
<div class="theme-option" data-theme="theme-purple">💜 Purple</div>
<div class="theme-option" data-theme="theme-blue">💙 Blue</div>
<div class="theme-option" data-theme="theme-green">💚 Green</div>
<div class="theme-option" data-theme="theme-pink">💕 Pink</div>
<div class="theme-option" data-theme="theme-orange">🧡 Orange</div>
</div>
<!-- Search Section -->
<section class="search-section">
<div class="search-container">
<input
type="text"
id="search-input"
class="search-input"
placeholder="Search music or paste ANY link (Spotify, Bandcamp, SoundCloud...)"
autocomplete="off"
title="Search or Import"
>
<button id="search-clear" class="search-clear" title="Clear search">×</button>
</div>
<!-- Search Type Selector -->
<div class="search-type-selector">
<button class="type-btn active" data-type="track">Songs</button>
<button class="type-btn" data-type="album">Albums</button>
<button class="type-btn" data-type="artist">Artists</button>
<button class="type-btn" data-type="podcast">Podcasts</button>
<button id="search-more-btn" class="type-btn more-btn">⋮ More</button>
</div>
<!-- Search More Menu -->
<div id="search-more-menu" class="search-more-menu hidden">
<button class="menu-item" id="ai-menu-btn">Smart Playlist</button>
<button class="menu-item" id="concert-search-menu-btn">Concert Search</button>
<div class="menu-divider"></div>
<button class="menu-item type-btn-menu" data-type="ytmusic">YT Music</button>
<button class="menu-item type-btn-menu" data-type="setlist">Setlists</button>
<button class="menu-item type-btn-menu" data-type="rec">For You</button>
<button class="menu-item type-btn-menu" data-type="favorites">Playlists</button>
</div>
</section>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loading-content">
<div class="spinner"></div>
<p id="loading-text">Loading...</p>
</div>
</div>
<!-- Error Message -->
<div id="error-message" class="error-message hidden">
<span class="error-icon">😕</span>
<p id="error-text">Something went wrong</p>
<button id="error-retry" class="btn-retry">Try Again</button>
</div>
<!-- Download Modal -->
<div id="download-modal" class="modal hidden">
<div class="modal-content">
<h3>Download Track</h3>
<p id="download-track-name">Track Name</p>
<p id="download-source-hint" class="download-hint hidden"></p>
<div class="format-selector">
<label>Select Format:</label>
<select id="download-format">
<optgroup label="Lossy">
<option value="mp3" data-min-quality="lossy">MP3 (320kbps)</option>
</optgroup>
<optgroup label="Lossless (16-bit)">
<option value="flac" data-min-quality="lossless">FLAC (16-bit)</option>
<option value="aiff" data-min-quality="lossless">AIFF (16-bit)</option>
<option value="wav" data-min-quality="lossless">WAV (16-bit)</option>
<option value="alac" data-min-quality="lossless">ALAC (Apple Lossless)</option>
</optgroup>
<optgroup id="hires-formats" label="Hi-Res (24-bit)">
<option value="flac_24" data-min-quality="hires">FLAC (24-bit Hi-Res)</option>
<option value="aiff_24" data-min-quality="hires">AIFF (24-bit)</option>
<option value="wav_24" data-min-quality="hires">WAV (24-bit)</option>
</optgroup>
</select>
</div>
<div class="modal-actions">
<button id="download-cancel-btn" class="btn-secondary">Cancel</button>
<button id="download-drive-btn" class="btn-secondary" title="Save to Google Drive">☁️ Save to Drive</button>
<button id="download-confirm-btn" class="btn-primary">Download</button>
</div>
</div>
</div>
<!-- Results Section -->
<section id="results-section" class="results-section">
<div id="results-container" class="results-container">
<!-- Search results will be inserted here -->
<div class="empty-state">
<span class="empty-icon">🔍</span>
<p>Search for your favorite music</p>
<p class="hint">Or paste a Spotify link to an album or playlist</p>
</div>
</div>
<button id="load-more-btn" class="load-more-btn hidden">Load More Results</button>
</section>
<!-- Album/Playlist Detail View -->
<section id="detail-view" class="detail-view hidden">
<div class="detail-header">
<button id="back-btn" class="back-btn">← Back</button>
<div class="detail-actions">
<button id="shuffle-btn" class="shuffle-btn" title="Shuffle & Play">Shuffle</button>
<button id="queue-all-btn" class="queue-all-btn">Add All to Queue</button>
<button id="download-all-btn" class="download-all-btn">Download ZIP</button>
</div>
</div>
<div id="detail-info" class="detail-info">
<!-- Album/playlist info will be inserted here -->
</div>
<div id="detail-tracks" class="detail-tracks">
<!-- Tracks will be inserted here -->
</div>
</section>
<!-- Queue Section -->
<section id="queue-section" class="queue-section hidden">
<div class="queue-header">
<h3>Queue <span id="queue-count">(0)</span></h3>
<div class="queue-controls">
<button id="queue-clear" class="queue-clear">Clear</button>
<button id="queue-close" class="queue-close-btn" title="Close Queue">×</button>
</div>
</div>
<!-- Crossfade Toggle -->
<div class="crossfade-toggle">
<span class="crossfade-icon"></span>
<div class="crossfade-info">
<span class="crossfade-title">1s Crossfade</span>
<span class="crossfade-desc">Smooth transition between tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="crossfade-checkbox">
<span class="toggle-slider"></span>
</label>
</div>
<div id="queue-container" class="queue-container">
<!-- Queue items will be inserted here -->
</div>
</section>
<!-- Album Details Modal -->
<div id="album-modal" class="album-modal hidden">
<div class="album-modal-overlay"></div>
<div class="album-modal-content">
<div class="album-modal-header">
<button id="album-modal-close" class="album-modal-close" title="Close">×</button>
<h2>Album Details</h2>
</div>
<div class="album-modal-body">
<div class="album-modal-info">
<img id="album-modal-art" class="album-modal-art" src="" alt="Album Art">
<div class="album-modal-meta">
<h3 id="album-modal-title" class="album-modal-title">Album Title</h3>
<p id="album-modal-artist" class="album-modal-artist">Artist Name</p>
<!-- Metadata Pills -->
<div class="album-meta-pills">
<span id="album-modal-date" class="meta-pill">📅 2024-01-01</span>
<span id="album-modal-trackcount" class="meta-pill">🎵 12 tracks</span>
<span id="album-modal-duration" class="meta-pill">⏱️ 45 min</span>
</div>
<!-- Action Buttons -->
<div class="album-action-buttons">
<button id="album-play-btn" class="album-action-btn primary">▶ Play Album</button>
<button id="album-queue-btn" class="album-action-btn">+ Add to Queue</button>
<button id="album-download-btn" class="album-action-btn">⬇ Download Album</button>
<button id="album-playlist-btn" class="album-action-btn">♡ Add to Playlist</button>
</div>
<!-- Quality Badge -->
<div id="album-modal-quality" class="album-quality-badge">
🎵 FLAC • 16bit / 44.1kHz
</div>
</div>
</div>
<!-- Tabs -->
<div class="album-tabs">
<button class="album-tab active" data-tab="tracks">Tracks</button>
<button class="album-tab" data-tab="info">Album Info</button>
</div>
<!-- Track List -->
<div id="album-modal-tracks" class="album-modal-tracks">
<!-- Tracks will be inserted here -->
</div>
<!-- Album Info (hidden by default) -->
<div id="album-modal-info-tab" class="album-modal-info-tab hidden">
<p id="album-modal-description">Album description and additional info...</p>
</div>
</div>
</div>
</div>
<!-- AI Assistant Modal -->
<div id="ai-modal" class="ai-modal hidden">
<div class="ai-modal-overlay"></div>
<div class="ai-modal-content">
<div class="ai-modal-header">
<h2>🧠 Smart Playlist</h2>
<button id="ai-modal-close" class="ai-modal-close" title="Close">×</button>
</div>
<!-- Playlist Generator Content -->
<div class="ai-tab-content active">
<p class="ai-tab-desc">Describe your perfect playlist and I'll create it...</p>
<textarea id="ai-playlist-input" class="ai-input" placeholder="e.g., 'A 30-minute morning coffee playlist with jazz and bossa nova'" rows="3"></textarea>
<div class="ai-duration-row">
<label>Duration:</label>
<input type="range" id="ai-duration-slider" min="15" max="120" value="60" step="15">
<span id="ai-duration-label">60 min</span>
</div>
<button id="ai-playlist-gen-btn" class="ai-action-btn">🎵 Generate Playlist</button>
<div id="ai-playlist-results" class="ai-results"></div>
</div>
</div>
</div>
<!-- Bottom Player -->
<div id="player-bar" class="player-bar hidden">
<!-- Main visible row (Art + Info + Primary Controls) -->
<div class="player-main-row">
<div class="player-info">
<img id="player-art" class="player-art" src="" alt="Album art">
<div class="player-details">
<p id="player-title" class="player-title">No track playing</p>
<div class="player-meta-row">
<span id="player-artist" class="player-artist clickable" title="Search artist">-</span>
<span class="player-separator"></span>
<span id="player-album" class="player-album clickable" title="View album">-</span>
<span id="player-year" class="player-year"></span>
<span id="audio-format-badge" class="audio-format-badge hidden">MP3</span>
</div>
</div>
</div>
<div class="player-controls-primary">
<button id="prev-btn" class="control-btn" title="Previous"></button>
<button id="play-btn" class="control-btn play-btn" title="Play/Pause"></button>
<button id="next-btn" class="control-btn" title="Next"></button>
<button id="fs-toggle-btn" class="control-btn" title="Full Screen"></button>
<button id="more-controls-btn" class="control-btn" title="More Options"></button>
</div>
</div>
<!-- Progress Bar -->
<div class="player-progress">
<span id="current-time" class="time">0:00</span>
<input type="range" id="progress-bar" class="progress-bar" min="0" max="100" value="0" title="Seek">
<span id="duration" class="time">0:00</span>
</div>
<!-- More Menu (Popup) - 2x5 Grid -->
<div id="player-more-menu" class="player-more-menu hidden">
<div class="more-menu-grid four-col">
<!-- Row 1 -->
<button id="shuffle-queue-btn" class="control-btn" title="Shuffle Queue"></button>
<button id="repeat-btn" class="control-btn" title="Repeat: Off"></button>
<button id="download-current-btn" class="control-btn" title="Download"></button>
<button id="queue-btn" class="control-btn queue-toggle" title="Queue"></button>
<!-- Row 2 -->
<button id="mute-btn" class="control-btn volume-btn" title="Mute">🔊</button>
<button id="eq-toggle-btn" class="control-btn eq-btn" title="Equalizer">🎛️</button>
<button id="ai-radio-btn" class="control-btn ai-radio-btn" title="AI Radio">📻</button>
<button id="mini-player-btn" class="control-btn" title="Popout Winamp Player"></button>
<!-- Row 3 -->
<button id="add-to-playlist-btn" class="control-btn" title="Add to Playlist">🩷</button>
<button id="lyrics-btn" class="control-btn lyrics-btn" title="Lyrics">📝</button>
<button id="video-btn" class="control-btn" title="Music Video">🎬</button>
<button id="menu-visualizer-btn" class="control-btn" title="Visualizer">🌈</button>
</div>
<!-- Volume slider inside menu for mobile compactness -->
<div class="more-menu-volume">
<input type="range" id="volume-slider" class="volume-slider" min="0" max="100" value="100" title="Volume">
</div>
</div>
</div>
<!-- Equalizer Panel -->
<div id="eq-panel" class="eq-panel hidden">
<div class="eq-header">
<h3>🎛️ Equalizer</h3>
<button id="eq-close-btn" class="eq-close-btn">×</button>
</div>
<div class="eq-presets">
<button class="eq-preset active" data-preset="flat">Flat</button>
<button class="eq-preset" data-preset="bass">Bass Boost</button>
<button class="eq-preset" data-preset="treble">Treble</button>
<button class="eq-preset" data-preset="vocal">Vocal</button>
</div>
<div class="eq-sliders">
<div class="eq-band">
<input type="range" id="eq-60" class="eq-slider" min="-12" max="12" value="0" orient="vertical">
<span class="eq-label">60Hz</span>
</div>
<div class="eq-band">
<input type="range" id="eq-230" class="eq-slider" min="-12" max="12" value="0">
<span class="eq-label">230Hz</span>
</div>
<div class="eq-band">
<input type="range" id="eq-910" class="eq-slider" min="-12" max="12" value="0">
<span class="eq-label">910Hz</span>
</div>
<div class="eq-band">
<input type="range" id="eq-3600" class="eq-slider" min="-12" max="12" value="0">
<span class="eq-label">3.6kHz</span>
</div>
<div class="eq-band">
<input type="range" id="eq-7500" class="eq-slider" min="-12" max="12" value="0">
<span class="eq-label">7.5kHz</span>
</div>
</div>
<div class="eq-extras">
<div class="eq-extra">
<label for="bass-boost">Bass Boost</label>
<input type="range" id="bass-boost" class="eq-boost-slider" min="0" max="12" value="0">
<span id="bass-boost-val">0dB</span>
</div>
<div class="eq-extra">
<label for="volume-boost">Volume Boost</label>
<input type="range" id="volume-boost" class="eq-boost-slider" min="0" max="6" value="0">
<span id="volume-boost-val">0dB</span>
</div>
</div>
</div>
<!-- Audio Elements (dual for gapless/crossfade) -->
<audio id="audio-player" preload="auto"></audio>
<audio id="audio-player-2" preload="auto"></audio>
</div>
<!-- Fullscreen Player Overlay (outside .app for proper fixed positioning) -->
<div id="fullscreen-player" class="fullscreen-player hidden">
<div class="fs-backdrop"></div>
<div class="fs-content">
<div class="fs-header">
<button id="fs-close-btn" class="fs-close-btn">×</button>
</div>
<div class="fs-art-container">
<img id="fs-art" src="/static/icon.svg" alt="Album Art">
<button id="fs-lyrics-btn" class="fs-art-lyrics-btn" title="Lyrics">📝</button>
</div>
<div class="fs-info">
<h2 id="fs-title">No Track Playing</h2>
<p id="fs-artist">Select music to play</p>
<div id="fs-dj-info" class="fs-dj-info hidden"></div>
</div>
<div class="fs-progress-container">
<span id="fs-current-time">0:00</span>
<input type="range" id="fs-progress-bar" class="progress-bar" min="0" max="100" value="0" title="Seek">
<span id="fs-duration">0:00</span>
</div>
<div class="fs-controls">
<button id="fs-heart-btn" class="fs-control-btn" title="Add to Playlist">🩷</button>
<button id="fs-prev-btn" class="fs-control-btn"></button>
<button id="fs-play-btn" class="fs-control-btn play-btn"></button>
<button id="fs-next-btn" class="fs-control-btn"></button>
<button id="fs-download-btn" class="fs-control-btn" title="Download"></button>
<button id="fs-visualizer-btn" class="fs-control-btn" title="Visualizer">🌈</button>
</div>
</div>
</div>
<!-- Visualizer Overlay -->
<div id="visualizer-overlay" class="visualizer-overlay hidden">
<canvas id="visualizer-canvas"></canvas>
<canvas id="visualizer-canvas-webgl" class="hidden"></canvas>
<div class="visualizer-controls">
<div class="visualizer-track-info">
<span id="viz-track-name">No Track</span>
<span id="viz-track-artist"></span>
</div>
<div class="visualizer-mode-selector">
<button id="viz-prev-preset" class="viz-action-btn" title="Previous Preset (P)" style="display: none;">⏮ Prev</button>
<button class="viz-mode-btn" data-mode="milkdrop">MilkDrop</button>
<button id="viz-next-preset" class="viz-action-btn" title="Next Preset (N)" style="display: none;">Next ⏭</button>
<button class="viz-mode-btn active" data-mode="bars">Bars</button>
<button class="viz-mode-btn" data-mode="wave">Wave</button>
<button class="viz-mode-btn" data-mode="particles">Particles</button>
</div>
<button id="visualizer-close" class="visualizer-close-btn">✕ Exit</button>
</div>
<div class="visualizer-hint">Press ESC or click to exit</div>
</div>
<!-- Podcast Episode Details Modal -->
<div id="podcast-modal" class="podcast-modal hidden">
<div class="podcast-modal-content">
<button id="podcast-modal-close" class="podcast-modal-close">×</button>
<img id="podcast-modal-art" class="podcast-modal-art" src="" alt="Episode Art">
<h2 id="podcast-modal-title" class="podcast-modal-title"></h2>
<p id="podcast-modal-date" class="podcast-modal-date"></p>
<p id="podcast-modal-duration" class="podcast-modal-duration"></p>
<div id="podcast-modal-description" class="podcast-modal-description"></div>
<div class="podcast-modal-actions">
<button id="podcast-modal-play" class="podcast-modal-play">▶ Play Episode</button>
</div>
</div>
</div>
<!-- Lyrics Modal -->
<div id="lyrics-modal" class="lyrics-modal hidden">
<div class="lyrics-modal-content">
<button id="lyrics-modal-close" class="lyrics-modal-close">×</button>
<div class="lyrics-modal-header">
<img id="lyrics-modal-art" class="lyrics-modal-art" src="" alt="Album Art">
<div class="lyrics-modal-info">
<h2 id="lyrics-modal-title">Song Title</h2>
<p id="lyrics-modal-artist">Artist</p>
<p id="lyrics-modal-album" class="lyrics-modal-album"></p>
</div>
</div>
<div class="lyrics-tabs">
<button class="lyrics-tab active" data-tab="lyrics">Lyrics</button>
<button class="lyrics-tab" data-tab="about">About</button>
<button class="lyrics-tab" data-tab="annotations">Annotations</button>
</div>
<div class="lyrics-tab-content">
<div id="lyrics-panel" class="lyrics-panel active">
<div id="lyrics-loading" class="lyrics-loading hidden">
<div class="lyrics-spinner"></div>
<p>Fetching lyrics...</p>
</div>
<div id="lyrics-text" class="lyrics-text"></div>
<div id="lyrics-not-found" class="lyrics-not-found hidden">
<p>😢 Lyrics not found for this track</p>
<a id="lyrics-search-link" href="#" target="_blank" class="lyrics-search-link">Search on Genius →</a>
</div>
</div>
<div id="about-panel" class="lyrics-panel">
<div id="about-content" class="about-content">
<div id="about-description" class="about-description"></div>
<div id="about-credits" class="about-credits">
<p id="about-release"></p>
<p id="about-writers"></p>
<p id="about-producers"></p>
</div>
<a id="genius-link" href="#" target="_blank" class="genius-link">View on Genius →</a>
</div>
</div>
<div id="annotations-panel" class="lyrics-panel">
<div id="annotations-loading" class="lyrics-loading hidden">
<div class="lyrics-spinner"></div>
<p>Loading annotations...</p>
</div>
<div id="annotations-list" class="annotations-list"></div>
<div id="annotations-empty" class="lyrics-not-found hidden">
<p>No annotations available for this track</p>
</div>
</div>
</div>
</div>
</div>
<!-- Concerts Modal -->
<div id="concerts-modal" class="concerts-modal hidden">
<div class="concerts-modal-content">
<button id="concerts-modal-close" class="concerts-modal-close">×</button>
<div class="concerts-modal-header">
<h2>🎤 Upcoming Concerts</h2>
</div>
<div class="concerts-settings">
<label>My Cities (comma-separated):</label>
<input type="text" id="concerts-cities" class="concerts-cities-input" placeholder="San Francisco, Los Angeles, Seattle...">
<button id="concerts-save-cities" class="concerts-save-btn">Save</button>
</div>
<div class="concerts-tabs">
<button class="concerts-tab active" data-source="queue">From Queue</button>
<button class="concerts-tab" data-source="search">Search Artist</button>
</div>
<div id="concerts-search-section" class="concerts-search-section hidden">
<input type="text" id="concerts-artist-search" class="concerts-artist-input" placeholder="Search artist...">
<button id="concerts-search-btn" class="concerts-search-btn">🔍</button>
</div>
<div id="concerts-loading" class="lyrics-loading hidden">
<div class="lyrics-spinner"></div>
<p>Finding concerts...</p>
</div>
<div id="concerts-list" class="concerts-list"></div>
<div id="concerts-empty" class="concerts-empty hidden">
<p>No upcoming concerts found</p>
<p class="concerts-empty-hint">Try adding more artists to your queue or adjusting your cities</p>
</div>
</div>
</div>
<!-- DJ Setlist Modal -->
<div id="dj-setlist-modal" class="dj-modal hidden">
<div class="dj-modal-content">
<div class="dj-modal-header">
<h2>🎧 AI DJ Setlist</h2>
<button id="dj-modal-close" class="dj-modal-close">×</button>
</div>
<div class="dj-modal-body">
<div class="dj-style-selector">
<label>Set Style:</label>
<select id="dj-style-select">
<option value="progressive">Progressive (Build Energy)</option>
<option value="peak-time">Peak Time (High Energy)</option>
<option value="chill">Chill (Low-Med Energy)</option>
<option value="journey">Journey (Wave Pattern)</option>
</select>
</div>
<div id="dj-setlist-loading" class="dj-loading hidden">
<div class="spinner"></div>
<p>Analyzing tracks and generating setlist...</p>
</div>
<div id="dj-setlist-results" class="dj-results hidden">
<div id="dj-ordered-tracks" class="dj-ordered-tracks"></div>
</div>
</div>
<div class="dj-modal-actions">
<button id="dj-generate-btn" class="btn-primary">✨ Generate Setlist</button>
<button id="dj-apply-btn" class="btn-secondary hidden">Apply to Queue</button>
</div>
</div>
</div>
<!-- Hidden File Input (Moved to body end for reliability) -->
<input
type="file"
id="file-input"
multiple
accept="audio/*,.flac,.mp3,.wav,.aiff,.aac,.ogg,.m4a"
style="position: fixed; top: -100px; left: -100px; opacity: 0; pointer-events: none;"
onclick="this.value=null"
onchange="if(window.handleFiles) { window.handleFiles(this.files); } else { alert('Error: handleFiles not loaded'); }"
>
<!-- Playlist Selection Modal -->
<div id="playlist-modal" class="modal hidden">
<div class="modal-content playlist-modal-content">
<h3>Add to Playlist</h3>
<div id="playlist-list" class="playlist-list"></div>
<div class="create-playlist-row">
<input type="text" id="new-playlist-input" placeholder="New playlist name..." class="new-playlist-input">
<button id="create-playlist-btn" class="btn-primary">+</button>
</div>
<button id="playlist-modal-close" class="btn-secondary" style="width:100%; margin-top:12px;">Cancel</button>
</div>
</div>
<!-- Setlist Detail Modal -->
<div id="setlist-modal" class="modal hidden">
<div class="modal-content setlist-modal-content" style="max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>Setlist</h3>
<button id="setlist-close-btn" class="modal-close-btn">×</button>
</div>
<div id="setlist-info" class="setlist-header-info">
<!-- Artist at Venue - Date inserted here -->
</div>
<div id="setlist-tracks" class="setlist-tracks-list">
<!-- Tracks inserted here -->
</div>
<div class="modal-actions" style="margin-top: 16px;">
<button id="setlist-play-btn" class="btn-primary" style="width: 100%;">
🎧 Listen to Show
</button>
</div>
</div>
</div>
<!-- Drive Sync Modal -->
<div id="drive-sync-modal" class="modal hidden">
<div class="modal-content drive-modal-content">
<div class="modal-header">
<h2>Google Drive Sync</h2>
<button id="drive-modal-close-top" class="modal-close-btn">×</button>
</div>
<!-- Auth Section -->
<div id="drive-auth-section">
<p style="margin-bottom: 20px; color: var(--text-secondary);">Sign in to sync your library across devices.</p>
<div class="drive-signin-container">
<button id="drive-signin-btn" class="drive-auth-btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27 3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10 5.35 0 9.25-3.67 9.25-9.09 0-1.15-.15-1.81-.15-1.81z"/></svg>
Sign in with Google
</button>
<div id="drive-loading" class="hidden">Connecting...</div>
</div>
</div>
<!-- Options Section -->
<div id="drive-options-section" class="hidden">
<div class="drive-user-info">
<span id="drive-user-email">Connected</span>
<button id="drive-signout-btn" class="text-link">Sign Out</button>
</div>
<div class="drive-actions-grid">
<!-- Upload Group -->
<div class="sync-group upload-group">
<h3>☁️ Upload (Save)</h3>
<p class="sync-desc">Save your current library to the cloud.</p>
<div class="action-buttons">
<button id="drive-up-all" class="action-btn upload">
<span class="btn-icon">⬆️</span>
<span class="btn-text">Everything</span>
</button>
<button id="drive-up-playlists" class="action-btn upload secondary">
<span class="btn-text">Playlists Only</span>
</button>
<button id="drive-up-queue" class="action-btn upload secondary">
<span class="btn-text">Queue Only</span>
</button>
</div>
</div>
<!-- Download Group -->
<div class="sync-group download-group">
<h3>⬇️ Download (Load)</h3>
<p class="sync-desc">Restore your library from the cloud.</p>
<div class="action-buttons">
<button id="drive-down-all" class="action-btn download">
<span class="btn-icon">⬇️</span>
<span class="btn-text">Everything</span>
</button>
<button id="drive-down-playlists" class="action-btn download secondary">
<span class="btn-text">Playlists Only</span>
</button>
<button id="drive-down-queue" class="action-btn download secondary">
<span class="btn-text">Queue Only</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Concert Alerts Modal -->
<div id="concert-modal" class="modal hidden">
<div class="modal-content concert-modal-content">
<div class="modal-header">
<h3>🎫 Concert Alerts</h3>
<button class="modal-close" id="concert-modal-close">×</button>
</div>
<!-- Tabs: Recent Artists | Search -->
<div class="concert-tabs">
<button class="concert-tab active" data-tab="recent">Recent Artists</button>
<button class="concert-tab" data-tab="search">Search Artist</button>
</div>
<!-- Recent Artists Tab (default) -->
<div id="concert-recent-section" class="concert-tab-content">
<p class="concert-hint">Showing concerts for artists you've listened to recently</p>
</div>
<!-- Search Tab -->
<div id="concert-search-section" class="concert-tab-content hidden">
<div class="concert-search-wrapper">
<input type="text" id="concert-artist-search" placeholder="Search for an artist...">
<button id="concert-search-btn" class="btn-primary">Search</button>
</div>
</div>
<!-- Results -->
<div id="concert-results" class="concert-results"></div>
<div id="concert-loading" class="concert-loading hidden">
<span class="loading-spinner"></span> Finding concerts...
</div>
<div id="concert-empty" class="concert-empty hidden">
<span>🎵</span>
<p>No upcoming concerts found</p>
</div>
</div>
</div>
<script src="static/jsmediatags.min.js"></script>
<script src="https://apis.google.com/js/api.js"></script>
<script src="https://accounts.google.com/gsi/client"></script>
<script src="/static/app.js?t=1736053100"></script></script>
</body>
</html>

95
static/jsmediatags.min.js vendored Normal file
View file

@ -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<v.length-1;n++){var m=v[n];m in p||(p[m]={});p=p[m]}v=v[v.length-1];n=p[v];h=h(n);h!=n&&null!=h&&$jscomp.defineProperty(p,v,{configurable:!0,writable:!0,value:h})}};$jscomp.polyfill("Object.setPrototypeOf",function(v){return v?v:"object"!=typeof"".__proto__?null:function(h,p){h.__proto__=p;if(h.__proto__!==p)throw new TypeError(h+" is not extensible");return h}},"es6","es5");
(function(v){"object"===typeof exports&&"undefined"!==typeof module?module.exports=v():"function"===typeof define&&define.amd?define([],v):("undefined"!==typeof window?window:"undefined"!==typeof global?global:"undefined"!==typeof self?self:this).jsmediatags=v()})(function(){return function h(p,n,m){function q(c,b){if(!n[c]){if(!p[c]){var g="function"==typeof require&&require;if(!b&&g)return g(c,!0);if(t)return t(c,!0);b=Error("Cannot find module '"+c+"'");throw b.code="MODULE_NOT_FOUND",b;}b=n[c]=
{exports:{}};p[c][0].call(b.exports,function(b){var k=p[c][1][b];return q(k?k:b)},b,b.exports,h,p,n,m)}return n[c].exports}for(var t="function"==typeof require&&require,f=0;f<m.length;f++)q(m[f]);return q}({1:[function(h,p,n){},{}],2:[function(h,p,n){p.exports=XMLHttpRequest},{}],3:[function(h,p,n){function m(b){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(b)}function q(b,a){for(var e=0;e<a.length;e++){var d=a[e];d.enumerable=d.enumerable||!1;d.configurable=!0;"value"in d&&(d.writable=!0);Object.defineProperty(b,d.key,d)}}function t(b,a,e){a&&q(b.prototype,a);e&&q(b,e);return b}function f(b){f=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return f(b)}function c(b){if(void 0===b)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return b}
function b(b,a){if("function"!==typeof a&&null!==a)throw new TypeError("Super expression must either be null or a function");b.prototype=Object.create(a&&a.prototype,{constructor:{value:b,writable:!0,configurable:!0}});a&&g(b,a)}function g(b,a){g=Object.setPrototypeOf||function(a,d){a.__proto__=d;return a};return g(b,a)}function k(b,a,e){a in b?Object.defineProperty(b,a,{value:e,enumerable:!0,configurable:!0,writable:!0}):b[a]=e;return b}h=function(g){function a(e){if(!(this instanceof a))throw new TypeError("Cannot call a class as a function");
var d=f(a).call(this);d=!d||"object"!==m(d)&&"function"!==typeof d?c(this):d;k(c(d),"_array",void 0);k(c(d),"_size",void 0);d._array=e;d._size=e.length;d._isInitialized=!0;return d}b(a,g);t(a,[{key:"init",value:function(a){setTimeout(a.onSuccess,0)}},{key:"loadRange",value:function(a,d){setTimeout(d.onSuccess,0)}},{key:"getByteAt",value:function(a){if(a>=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;d<e.length;d++){var l=e[d];l.enumerable=l.enumerable||!1;l.configurable=!0;"value"in l&&(l.writable=
!0);Object.defineProperty(a,l.key,l)}}function t(a,e,d){e&&q(a.prototype,e);d&&q(a,d);return a}function f(a){f=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return f(a)}function c(a){if(void 0===a)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return a}function b(a,e){if("function"!==typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");a.prototype=Object.create(e&&
e.prototype,{constructor:{value:a,writable:!0,configurable:!0}});e&&g(a,e)}function g(a,e){g=Object.setPrototypeOf||function(d,a){d.__proto__=a;return d};return g(a,e)}function k(a,e,d){e in a?Object.defineProperty(a,e,{value:d,enumerable:!0,configurable:!0,writable:!0}):a[e]=d;return a}var r=h("./ChunkedFileData");h=function(a){function e(d){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function");var a=f(e).call(this);a=!a||"object"!==m(a)&&"function"!==typeof a?c(this):
a;k(c(a),"_blob",void 0);k(c(a),"_fileData",void 0);a._blob=d;a._fileData=new r;return a}b(e,a);t(e,[{key:"_init",value:function(d){this._size=this._blob.size;setTimeout(d.onSuccess,1)}},{key:"loadRange",value:function(d,a){var e=this,l=(this._blob.slice||this._blob.mozSlice||this._blob.webkitSlice).call(this._blob,d[0],d[1]+1),b=new FileReader;b.onloadend=function(l){l=new Uint8Array(b.result);e._fileData.addData(d[0],l);a.onSuccess()};b.onerror=b.onabort=function(d){if(a.onError)a.onError({type:"blob",
info:b.error})};b.readAsArrayBuffer(l)}},{key:"getByteAt",value:function(a){return this._fileData.getByteAt(a)}}],[{key:"canReadFile",value:function(a){return"undefined"!==typeof Blob&&a instanceof Blob||"undefined"!==typeof File&&a instanceof File}}]);return e}(h("./MediaFileReader"));p.exports=h},{"./ChunkedFileData":5,"./MediaFileReader":11}],5:[function(h,p,n){function m(h,f){for(var c=0;c<f.length;c++){var b=f[c];b.enumerable=b.enumerable||!1;b.configurable=!0;"value"in b&&(b.writable=!0);Object.defineProperty(h,
b.key,b)}}function q(h,f,c){f&&m(h.prototype,f);c&&m(h,c);return h}h=function(){function h(){if(!(this instanceof h))throw new TypeError("Cannot call a class as a function");"_fileData"in this?Object.defineProperty(this,"_fileData",{value:void 0,enumerable:!0,configurable:!0,writable:!0}):this._fileData=void 0;this._fileData=[]}q(h,null,[{key:"NOT_FOUND",get:function(){return-1}}]);q(h,[{key:"addData",value:function(f,c){var b=f+c.length-1,g=this._getChunkRange(f,b);if(-1===g.startIx)this._fileData.splice(g.insertIx||
0,0,{offset:f,data:c});else{var k=this._fileData[g.startIx],r=this._fileData[g.endIx];b=b<r.offset+r.data.length-1;var a={offset:Math.min(f,k.offset),data:c};f>k.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<this._fileData.length;e++,a=e){g=this._fileData[e].offset;b=g+this._fileData[e].data.length;if(c<g-1)break;if(f<=b+1&&c>=g-1){k=e;break}}if(-1===k)return{startIx:-1,endIx:-1,insertIx:a};for(e=k;e<this._fileData.length&&!(g=this._fileData[e].offset,
b=g+this._fileData[e].data.length,c>=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<this._fileData.length;b++){var g=this._fileData[b];if(c<g.offset)break;if(f>=g.offset&&c<g.offset+g.data.length)return!0}return!1}},{key:"getByteAt",value:function(f){for(var c,b=0;b<this._fileData.length;b++){var g=this._fileData[b].offset,k=g+this._fileData[b].data.length-1;if(f>=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;d<e.length;d++){var b=e[d];b.enumerable=b.enumerable||!1;b.configurable=!0;"value"in b&&(b.writable=!0);Object.defineProperty(a,b.key,b)}}
function t(a,e,b){e&&q(a.prototype,e);b&&q(a,b);return a}function f(a){f=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return f(a)}function c(a){if(void 0===a)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return a}function b(a,e){if("function"!==typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");a.prototype=Object.create(e&&e.prototype,{constructor:{value:a,writable:!0,
configurable:!0}});e&&g(a,e)}function g(a,e){g=Object.setPrototypeOf||function(a,d){a.__proto__=d;return a};return g(a,e)}function k(a,e,b){e in a?Object.defineProperty(a,e,{value:b,enumerable:!0,configurable:!0,writable:!0}):a[e]=b;return a}var r=[4,132],a=[6,134],e="Other;32x32 pixels 'file icon' (PNG only);Other file icon;Cover (front);Cover (back);Leaflet page;Media (e.g. label side of CD);Lead artist/lead performer/soloist;Artist/performer;Conductor;Band/Orchestra;Composer;Lyricist/text writer;Recording Location;During recording;During performance;Movie/video screen capture;A bright coloured fish;Illustration;Band/artist logotype;Publisher/Studio logotype".split(";");
h=function(d){function l(){var a;if(!(this instanceof l))throw new TypeError("Cannot call a class as a function");for(var d=arguments.length,e=Array(d),b=0;b<d;b++)e[b]=arguments[b];d=(a=f(l)).call.apply(a,[this].concat(e));a=!d||"object"!==m(d)&&"function"!==typeof d?c(this):d;k(c(a),"_commentOffset",void 0);k(c(a),"_pictureOffset",void 0);return a}b(l,d);t(l,[{key:"_loadData",value:function(a,d){var e=this;a.loadRange([4,7],{onSuccess:function(){e._loadBlock(a,4,d)}})}},{key:"_loadBlock",value:function(d,
e,b){var l=this,c=d.getByteAt(e),k=d.getInteger24At(e+1,!0);if(-1!==r.indexOf(c)){var g=e+4;d.loadRange([g,g+k],{onSuccess:function(){l._commentOffset=g;l._nextBlock(d,e,c,k,b)}})}else-1!==a.indexOf(c)?(g=e+4,d.loadRange([g,g+k],{onSuccess:function(){l._pictureOffset=g;l._nextBlock(d,e,c,k,b)}})):l._nextBlock(d,e,c,k,b)}},{key:"_nextBlock",value:function(a,d,e,b,l){var c=this;if(127<e)if(c._commentOffset)l.onSuccess();else l.onError({type:"loadData",info:"Comment block could not be found."});else a.loadRange([d+
4+b,d+4+4+b],{onSuccess:function(){c._loadBlock(a,d+4+b,l)}})}},{key:"_parseData",value:function(a,d){var b=a.getLongAt(this._commentOffset,!1)+(this._commentOffset+4);d=a.getLongAt(b,!1);b+=4;for(var l,c,k,g,u,r,f=0;f<d;f++){var w=a.getLongAt(b,!1),h=a.getStringWithCharsetAt(b+4,w,"utf-8").toString(),m=h.indexOf("=");h=[h.slice(0,m),h.slice(m+1)];switch(h[0].toUpperCase()){case "TITLE":l=h[1];break;case "ARTIST":c=h[1];break;case "ALBUM":k=h[1];break;case "TRACKNUMBER":g=h[1];break;case "GENRE":u=
h[1]}b+=4+w}this._pictureOffset&&(r=a.getLongAt(this._pictureOffset,!0),d=this._pictureOffset+4,b=a.getLongAt(d,!0),f=d+4,d=a.getStringAt(f,b),b=f+b,f=a.getLongAt(b,!0),w=b+4,b=a.getStringWithCharsetAt(w,f,"utf-8").toString(),f=w+f+16,w=a.getLongAt(f,!0),a=a.getBytesAt(f+4,w,!0),r={format:d,type:e[r],description:b,data:a});return{type:"FLAC",version:"1",tags:{title:l,artist:c,album:k,track:g,genre:u,picture:r}}}}],[{key:"getTagIdentifierByteRange",value:function(){return{offset:0,length:4}}},{key:"canReadTagFormat",
value:function(a){return"fLaC"===String.fromCharCode.apply(String,a.slice(0,4))}}]);return l}(h("./MediaTagReader"));p.exports=h},{"./MediaTagReader":12}],7:[function(h,p,n){function m(b){m="function"===typeof Symbol&&"symbol"===typeof Symbol.iterator?function(b){return typeof b}:function(b){return b&&"function"===typeof Symbol&&b.constructor===Symbol&&b!==Symbol.prototype?"symbol":typeof b};return m(b)}function q(b,c){for(var a=0;a<c.length;a++){var e=c[a];e.enumerable=e.enumerable||!1;e.configurable=
!0;"value"in e&&(e.writable=!0);Object.defineProperty(b,e.key,e)}}function t(b,c,a){c&&q(b.prototype,c);a&&q(b,a);return b}function f(b){f=Object.setPrototypeOf?Object.getPrototypeOf:function(b){return b.__proto__||Object.getPrototypeOf(b)};return f(b)}function c(c,g){if("function"!==typeof g&&null!==g)throw new TypeError("Super expression must either be null or a function");c.prototype=Object.create(g&&g.prototype,{constructor:{value:c,writable:!0,configurable:!0}});g&&b(c,g)}function b(c,g){b=Object.setPrototypeOf||
function(a,e){a.__proto__=e;return a};return b(c,g)}n=h("./MediaTagReader");h("./MediaFileReader");h=function(b){function k(){if(!(this instanceof k))throw new TypeError("Cannot call a class as a function");var a=f(k).apply(this,arguments);if(!a||"object"!==m(a)&&"function"!==typeof a){if(void 0===this)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");a=this}return a}c(k,b);t(k,[{key:"_loadData",value:function(a,e){var d=a.getSize();a.loadRange([d-128,d-1],e)}},
{key:"_parseData",value:function(a,e){var d=a.getSize()-128,b=a.getStringWithCharsetAt(d+3,30).toString(),c=a.getStringWithCharsetAt(d+33,30).toString(),k=a.getStringWithCharsetAt(d+63,30).toString(),f=a.getStringWithCharsetAt(d+93,4).toString();var h=a.getByteAt(d+97+28);e=a.getByteAt(d+97+29);if(0==h&&0!=e){var r="1.1";h=a.getStringWithCharsetAt(d+97,28).toString()}else r="1.0",h=a.getStringWithCharsetAt(d+97,30).toString(),e=0;a=a.getByteAt(d+97+30);a={type:"ID3",version:r,tags:{title:b,artist:c,
album:k,year:f,comment:h,genre:255>a?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;d<e.length;d++){var b=e[d];b.enumerable=b.enumerable||!1;b.configurable=!0;"value"in b&&(b.writable=!0);Object.defineProperty(a,b.key,b)}}function q(a,e,d){e&&m(a.prototype,e);d&&m(a,d);return a}function t(a){switch(a){case 0:a="iso-8859-1";break;case 1:a="utf-16";break;case 2:a="utf-16be";break;case 3:a="utf-8";break;default:a="iso-8859-1"}return a}function f(a,e,d,b){var l=d.getStringWithCharsetAt(a+
1,e-1,b);a=d.getStringWithCharsetAt(a+1+l.bytesReadCount,e-1-l.bytesReadCount,b);return{user_description:l.toString(),data:a.toString()}}h("./MediaFileReader");var c=h("./StringUtils"),b=h("./ArrayFileReader"),g={BUF:"Recommended buffer size",CNT:"Play counter",COM:"Comments",CRA:"Audio encryption",CRM:"Encrypted meta frame",ETC:"Event timing codes",EQU:"Equalization",GEO:"General encapsulated object",IPL:"Involved people list",LNK:"Linked information",MCI:"Music CD Identifier",MLL:"MPEG location lookup table",
PIC:"Attached picture",POP:"Popularimeter",REV:"Reverb",RVA:"Relative volume adjustment",SLT:"Synchronized lyric/text",STC:"Synced tempo codes",TAL:"Album/Movie/Show title",TBP:"BPM (Beats Per Minute)",TCM:"Composer",TCO:"Content type",TCR:"Copyright message",TDA:"Date",TDY:"Playlist delay",TEN:"Encoded by",TFT:"File type",TIM:"Time",TKE:"Initial key",TLA:"Language(s)",TLE:"Length",TMT:"Media type",TOA:"Original artist(s)/performer(s)",TOF:"Original filename",TOL:"Original Lyricist(s)/text writer(s)",
TOR:"Original release year",TOT:"Original album/Movie/Show title",TP1:"Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group",TP2:"Band/Orchestra/Accompaniment",TP3:"Conductor/Performer refinement",TP4:"Interpreted, remixed, or otherwise modified by",TPA:"Part of a set",TPB:"Publisher",TRC:"ISRC (International Standard Recording Code)",TRD:"Recording dates",TRK:"Track number/Position in set",TSI:"Size",TSS:"Software/hardware and settings used for encoding",TT1:"Content group description",TT2:"Title/Songname/Content description",
TT3:"Subtitle/Description refinement",TXT:"Lyricist/text writer",TXX:"User defined text information frame",TYE:"Year",UFI:"Unique file identifier",ULT:"Unsychronized lyric/text transcription",WAF:"Official audio file webpage",WAR:"Official artist/performer webpage",WAS:"Official audio source webpage",WCM:"Commercial information",WCP:"Copyright/Legal information",WPB:"Publishers official webpage",WXX:"User defined URL link frame",AENC:"Audio encryption",APIC:"Attached picture",ASPI:"Audio seek point index",
CHAP:"Chapter",CTOC:"Table of contents",COMM:"Comments",COMR:"Commercial frame",ENCR:"Encryption method registration",EQU2:"Equalisation (2)",EQUA:"Equalization",ETCO:"Event timing codes",GEOB:"General encapsulated object",GRID:"Group identification registration",IPLS:"Involved people list",LINK:"Linked information",MCDI:"Music CD identifier",MLLT:"MPEG location lookup table",OWNE:"Ownership frame",PRIV:"Private frame",PCNT:"Play counter",POPM:"Popularimeter",POSS:"Position synchronisation frame",
RBUF:"Recommended buffer size",RVA2:"Relative volume adjustment (2)",RVAD:"Relative volume adjustment",RVRB:"Reverb",SEEK:"Seek frame",SYLT:"Synchronized lyric/text",SYTC:"Synchronized tempo codes",TALB:"Album/Movie/Show title",TBPM:"BPM (beats per minute)",TCOM:"Composer",TCON:"Content type",TCOP:"Copyright message",TDAT:"Date",TDLY:"Playlist delay",TDRC:"Recording time",TDRL:"Release time",TDTG:"Tagging time",TENC:"Encoded by",TEXT:"Lyricist/Text writer",TFLT:"File type",TIME:"Time",TIPL:"Involved people list",
TIT1:"Content group description",TIT2:"Title/songname/content description",TIT3:"Subtitle/Description refinement",TKEY:"Initial key",TLAN:"Language(s)",TLEN:"Length",TMCL:"Musician credits list",TMED:"Media type",TMOO:"Mood",TOAL:"Original album/movie/show title",TOFN:"Original filename",TOLY:"Original lyricist(s)/text writer(s)",TOPE:"Original artist(s)/performer(s)",TORY:"Original release year",TOWN:"File owner/licensee",TPE1:"Lead performer(s)/Soloist(s)",TPE2:"Band/orchestra/accompaniment",TPE3:"Conductor/performer refinement",
TPE4:"Interpreted, remixed, or otherwise modified by",TPOS:"Part of a set",TPRO:"Produced notice",TPUB:"Publisher",TRCK:"Track number/Position in set",TRDA:"Recording dates",TRSN:"Internet radio station name",TRSO:"Internet radio station owner",TSOA:"Album sort order",TSOP:"Performer sort order",TSOT:"Title sort order",TSIZ:"Size",TSRC:"ISRC (international standard recording code)",TSSE:"Software/Hardware and settings used for encoding",TSST:"Set subtitle",TYER:"Year",TXXX:"User defined text information frame",
UFID:"Unique file identifier",USER:"Terms of use",USLT:"Unsychronized lyric/text transcription",WCOM:"Commercial information",WCOP:"Copyright/Legal information",WOAF:"Official audio file webpage",WOAR:"Official artist/performer webpage",WOAS:"Official audio source webpage",WORS:"Official internet radio station homepage",WPAY:"Payment",WPUB:"Publishers official webpage",WXXX:"User defined URL link frame"};h=function(){function a(){if(!(this instanceof a))throw new TypeError("Cannot call a class as a function");
}q(a,null,[{key:"getFrameReaderFunction",value:function(a){return a in k?k[a]:"T"===a[0]?k["T*"]:"W"===a[0]?k["W*"]:null}},{key:"readFrames",value:function(e,d,b,c,g){for(var l={},k=this._getFrameHeaderSize(c);e<d-k;){var f=this._readFrameHeader(b,e,c),u=f.id;if(!u)break;var h=f.flags,w=f.size,r=e+f.headerSize,m=b;e+=f.headerSize+f.size;if(!g||-1!==g.indexOf(u)){if("MP3e"===u||"\x00MP3"===u||"\x00\x00MP"===u||" MP3"===u)break;h&&h.format.unsynchronisation&&!c.flags.unsynchronisation&&(m=this.getUnsyncFileReader(m,
r,w),r=0,w=m.getSize());h&&h.format.data_length_indicator&&(r+=4,w-=4);h=(f=a.getFrameReaderFunction(u))?f.apply(this,[r,w,m,h,c]):null;r=this._getFrameDescription(u);w={id:u,size:w,description:r,data:h};u in l?(l[u].id&&(l[u]=[l[u]]),l[u].push(w)):l[u]=w}}return l}},{key:"_getFrameHeaderSize",value:function(a){a=a.major;return 2==a?6:3==a||4==a?10:0}},{key:"_readFrameHeader",value:function(a,d,b){var e=b.major,l=null;b=this._getFrameHeaderSize(b);switch(e){case 2:var c=a.getStringAt(d,3);var g=a.getInteger24At(d+
3,!0);break;case 3:c=a.getStringAt(d,4);g=a.getLongAt(d+4,!0);break;case 4:c=a.getStringAt(d,4),g=a.getSynchsafeInteger32At(d+4)}if(c==String.fromCharCode(0,0,0)||c==String.fromCharCode(0,0,0,0))c="";c&&2<e&&(l=this._readFrameFlags(a,d+8));return{id:c||"",size:g||0,headerSize:b||0,flags:l}}},{key:"_readFrameFlags",value:function(a,d){return{message:{tag_alter_preservation:a.isBitSetAt(d,6),file_alter_preservation:a.isBitSetAt(d,5),read_only:a.isBitSetAt(d,4)},format:{grouping_identity:a.isBitSetAt(d+
1,7),compression:a.isBitSetAt(d+1,3),encryption:a.isBitSetAt(d+1,2),unsynchronisation:a.isBitSetAt(d+1,1),data_length_indicator:a.isBitSetAt(d+1,0)}}}},{key:"_getFrameDescription",value:function(a){return a in g?g[a]:"Unknown"}},{key:"getUnsyncFileReader",value:function(a,d,l){a=a.getBytesAt(d,l);for(d=0;d<a.length-1;d++)255===a[d]&&0===a[d+1]&&a.splice(d+1,1);return new b(a)}}]);return a}();var k={APIC:function(a,b,d,l,c){l=a;var e=t(d.getByteAt(a));switch(c&&c.major){case 2:c=d.getStringAt(a+1,
3);a+=4;break;case 3:case 4:c=d.getStringWithCharsetAt(a+1,b-1);a+=1+c.bytesReadCount;break;default:throw Error("Couldn't read ID3v2 major version.");}var g=d.getByteAt(a);g=r[g];e=d.getStringWithCharsetAt(a+1,b-(a-l)-1,e);a+=1+e.bytesReadCount;return{format:c.toString(),type:g,description:e.toString(),data:d.getBytesAt(a,l+b-a)}},CHAP:function(a,b,d,l,g){l=a;var e={},k=c.readNullTerminatedString(d.getBytesAt(a,b));e.id=k.toString();a+=k.bytesReadCount;e.startTime=d.getLongAt(a,!0);a+=4;e.endTime=
d.getLongAt(a,!0);a+=4;e.startOffset=d.getLongAt(a,!0);a+=4;e.endOffset=d.getLongAt(a,!0);a+=4;e.subFrames=this.readFrames(a,a+(b-(a-l)),d,g);return e},CTOC:function(a,b,d,l,g){l=a;var e={childElementIds:[],id:void 0,topLevel:void 0,ordered:void 0,entryCount:void 0,subFrames:void 0},k=c.readNullTerminatedString(d.getBytesAt(a,b));e.id=k.toString();a+=k.bytesReadCount;e.topLevel=d.isBitSetAt(a,1);e.ordered=d.isBitSetAt(a,0);a++;e.entryCount=d.getByteAt(a);a++;for(k=0;k<e.entryCount;k++){var f=c.readNullTerminatedString(d.getBytesAt(a,
b-(a-l)));e.childElementIds.push(f.toString());a+=f.bytesReadCount}e.subFrames=this.readFrames(a,a+(b-(a-l)),d,g);return e},COMM:function(a,b,d,l,c){var e=a,g=t(d.getByteAt(a));l=d.getStringAt(a+1,3);c=d.getStringWithCharsetAt(a+4,b-4,g);a+=4+c.bytesReadCount;a=d.getStringWithCharsetAt(a,e+b-a,g);return{language:l,short_description:c.toString(),text:a.toString()}}};k.COM=k.COMM;k.PIC=function(a,b,d,l,c){return k.APIC(a,b,d,l,c)};k.PCNT=function(a,b,d,l,c){return d.getLongAt(a,!1)};k.CNT=k.PCNT;k["T*"]=
function(a,b,d,l,c){l=t(d.getByteAt(a));return d.getStringWithCharsetAt(a+1,b-1,l).toString()};k.TXXX=function(a,b,d,l,c){l=t(d.getByteAt(a));return f(a,b,d,l)};k.WXXX=function(a,b,d,l,c){if(0===b)return null;l=t(d.getByteAt(a));return f(a,b,d,l)};k["W*"]=function(a,b,d,l,c){return 0===b?null:d.getStringWithCharsetAt(a,b,"iso-8859-1").toString()};k.TCON=function(a,b,d,l){return k["T*"].apply(this,arguments).replace(/^\(\d+\)/,"")};k.TCO=k.TCON;k.USLT=function(a,b,d,l,c){var e=a,g=t(d.getByteAt(a));
l=d.getStringAt(a+1,3);c=d.getStringWithCharsetAt(a+4,b-4,g);a+=4+c.bytesReadCount;a=d.getStringWithCharsetAt(a,e+b-a,g);return{language:l,descriptor:c.toString(),lyrics:a.toString()}};k.ULT=k.USLT;k.UFID=function(a,b,d,l,g){l=c.readNullTerminatedString(d.getBytesAt(a,b));a+=l.bytesReadCount;a=d.getBytesAt(a,b-l.bytesReadCount);return{ownerIdentifier:l.toString(),identifier:a}};var r="Other;32x32 pixels 'file icon' (PNG only);Other file icon;Cover (front);Cover (back);Leaflet page;Media (e.g. label side of CD);Lead artist/lead performer/soloist;Artist/performer;Conductor;Band/Orchestra;Composer;Lyricist/text writer;Recording Location;During recording;During performance;Movie/video screen capture;A bright coloured fish;Illustration;Band/artist logotype;Publisher/Studio logotype".split(";");
p.exports=h},{"./ArrayFileReader":3,"./MediaFileReader":11,"./StringUtils":13}],9:[function(h,p,n){function m(b){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(b)}function q(b,a){for(var e=0;e<a.length;e++){var d=a[e];d.enumerable=d.enumerable||!1;d.configurable=!0;"value"in d&&(d.writable=!0);Object.defineProperty(b,d.key,d)}}function t(b,
a,e){a&&q(b.prototype,a);e&&q(b,e);return b}function f(b){f=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return f(b)}function c(c,a){if("function"!==typeof a&&null!==a)throw new TypeError("Super expression must either be null or a function");c.prototype=Object.create(a&&a.prototype,{constructor:{value:c,writable:!0,configurable:!0}});a&&b(c,a)}function b(c,a){b=Object.setPrototypeOf||function(a,d){a.__proto__=d;return a};return b(c,a)}n=h("./MediaTagReader");
h("./MediaFileReader");var g=h("./ID3v2FrameReader");h=function(b){function a(){if(!(this instanceof a))throw new TypeError("Cannot call a class as a function");var b=f(a).apply(this,arguments);if(!b||"object"!==m(b)&&"function"!==typeof b){if(void 0===this)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");b=this}return b}c(a,b);t(a,[{key:"_loadData",value:function(a,b){a.loadRange([6,9],{onSuccess:function(){a.loadRange([0,10+a.getSynchsafeInteger32At(6)-1],b)},
onError:b.onError})}},{key:"_parseData",value:function(a,b){var d,e=0,c=a.getByteAt(e+3);if(4<c)return{type:"ID3",version:">2.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<b.length;d++){var e=b[d];e.enumerable=e.enumerable||!1;e.configurable=!0;"value"in e&&(e.writable=!0);Object.defineProperty(a,e.key,e)}}function t(a,b,d){b&&q(a.prototype,b);d&&q(a,d);return a}function f(a){f=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||
Object.getPrototypeOf(a)};return f(a)}function c(a,e){if("function"!==typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");a.prototype=Object.create(e&&e.prototype,{constructor:{value:a,writable:!0,configurable:!0}});e&&b(a,e)}function b(a,e){b=Object.setPrototypeOf||function(a,b){a.__proto__=b;return a};return b(a,e)}n=h("./MediaTagReader");h("./MediaFileReader");h=function(a){function b(){if(!(this instanceof b))throw new TypeError("Cannot call a class as a function");
var a=f(b).apply(this,arguments);if(!a||"object"!==m(a)&&"function"!==typeof a){if(void 0===this)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");a=this}return a}c(b,a);t(b,[{key:"_loadData",value:function(a,b){var d=this;a.loadRange([0,16],{onSuccess:function(){d._loadAtom(a,0,"",b)},onError:b.onError})}},{key:"_loadAtom",value:function(a,b,e,c){if(b>=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;d<e+c;){var l=b.getLongAt(d,!0);if(0==l)break;var h=b.getStringAt(d+4,4);if(this._isContainerAtom(h)){"meta"==h&&(d+=4);this._readAtom(a,b,d+8,l-8,g,(k?k+".":"")+h,f);break}(!g||0<=g.indexOf(h))&&
"moov.udta.meta.ilst"===k&&this._canReadAtom(h)&&(a[h]=this._readMetadataAtom(b,d));d+=l}}},{key:"_readMetadataAtom",value:function(a,b){var d=a.getLongAt(b,!0),e=a.getStringAt(b+4,4),c=a.getInteger24At(b+16+1,!0);c=g[c];if("trkn"==e)var l={track:a.getByteAt(b+16+11),total:a.getByteAt(b+16+13)};else if("disk"==e)l={disk:a.getByteAt(b+16+11),total:a.getByteAt(b+16+13)};else{b+=24;var f=d-24;"covr"===e&&"uint8"===c&&(c="jpeg");switch(c){case "text":l=a.getStringWithCharsetAt(b,f,"utf-8").toString();
break;case "uint8":l=a.getShortAt(b,!1);break;case "int":case "uint":l=("int"==c?1==f?a.getSByteAt:2==f?a.getSShortAt:4==f?a.getSLongAt:a.getLongAt:1==f?a.getByteAt:2==f?a.getShortAt:a.getLongAt).call(a,b+(8==f?4:0),!0);break;case "jpeg":case "png":l={format:"image/"+c,data:a.getBytesAt(b,f)}}}return{id:e,size:d,description:k[e]||"Unknown",data:l}}},{key:"getShortcuts",value:function(){return r}}],[{key:"getTagIdentifierByteRange",value:function(){return{offset:0,length:16}}},{key:"canReadTagFormat",
value:function(a){return"ftyp"===String.fromCharCode.apply(String,a.slice(4,8))}}]);return b}(n);var g={0:"uint8",1:"text",13:"jpeg",14:"png",21:"int",22:"uint"},k={"\u00a9alb":"Album","\u00a9ART":"Artist",aART:"Album Artist","\u00a9day":"Release Date","\u00a9nam":"Title","\u00a9gen":"Genre",gnre:"Genre",trkn:"Track Number","\u00a9wrt":"Composer","\u00a9too":"Encoding Tool","\u00a9enc":"Encoded By",cprt:"Copyright",covr:"Cover Art","\u00a9grp":"Grouping",keyw:"Keywords","\u00a9lyr":"Lyrics","\u00a9cmt":"Comment",
tmpo:"Tempo",cpil:"Compilation",disk:"Disc Number",tvsh:"TV Show Name",tven:"TV Episode ID",tvsn:"TV Season",tves:"TV Episode",tvnn:"TV Network",desc:"Description",ldes:"Long Description",sonm:"Sort Name",soar:"Sort Artist",soaa:"Sort Album",soco:"Sort Composer",sosn:"Sort Show",purd:"Purchase Date",pcst:"Podcast",purl:"Podcast URL",catg:"Category",hdvd:"HD Video",stik:"Media Type",rtng:"Content Rating",pgap:"Gapless Playback",apID:"Purchase Account",sfID:"Country Code",atID:"Artist ID",cnID:"Catalog ID",
plID:"Collection ID",geID:"Genre ID","xid ":"Vendor Information",flvr:"Codec Flavor"},r={title:"\u00a9nam",artist:"\u00a9ART",album:"\u00a9alb",year:"\u00a9day",comment:"\u00a9cmt",track:"trkn",genre:"\u00a9gen",picture:"covr",lyrics:"\u00a9lyr"};p.exports=h},{"./MediaFileReader":11,"./MediaTagReader":12}],11:[function(h,p,n){function m(c,b){for(var g=0;g<b.length;g++){var k=b[g];k.enumerable=k.enumerable||!1;k.configurable=!0;"value"in k&&(k.writable=!0);Object.defineProperty(c,k.key,k)}}function q(c,
b,g){b&&m(c.prototype,b);g&&m(c,g);return c}function t(c,b,g){b in c?Object.defineProperty(c,b,{value:g,enumerable:!0,configurable:!0,writable:!0}):c[b]=g;return c}var f=h("./StringUtils");h=function(){function c(b){if(!(this instanceof c))throw new TypeError("Cannot call a class as a function");t(this,"_isInitialized",void 0);t(this,"_size",void 0);this._isInitialized=!1;this._size=0}q(c,[{key:"init",value:function(b){var c=this;if(this._isInitialized)setTimeout(b.onSuccess,1);else return this._init({onSuccess:function(){c._isInitialized=
!0;b.onSuccess()},onError:b.onError})}},{key:"_init",value:function(b){throw Error("Must implement init function");}},{key:"loadRange",value:function(b,c){throw Error("Must implement loadRange function");}},{key:"getSize",value:function(){if(!this._isInitialized)throw Error("init() must be called first.");return this._size}},{key:"getByteAt",value:function(b){throw Error("Must implement getByteAt function");}},{key:"getBytesAt",value:function(b,c){for(var g=Array(c),f=0;f<c;f++)g[f]=this.getByteAt(b+
f);return g}},{key:"isBitSetAt",value:function(b,c){return 0!=(this.getByteAt(b)&1<<c)}},{key:"getSByteAt",value:function(b){b=this.getByteAt(b);return 127<b?b-256:b}},{key:"getShortAt",value:function(b,c){b=c?(this.getByteAt(b)<<8)+this.getByteAt(b+1):(this.getByteAt(b+1)<<8)+this.getByteAt(b);0>b&&(b+=65536);return b}},{key:"getSShortAt",value:function(b,c){b=this.getShortAt(b,c);return 32767<b?b-65536:b}},{key:"getLongAt",value:function(b,c){var g=this.getByteAt(b),f=this.getByteAt(b+1),a=this.getByteAt(b+
2);b=this.getByteAt(b+3);c=c?(((g<<8)+f<<8)+a<<8)+b:(((b<<8)+a<<8)+f<<8)+g;0>c&&(c+=4294967296);return c}},{key:"getSLongAt",value:function(b,c){b=this.getLongAt(b,c);return 2147483647<b?b-4294967296:b}},{key:"getInteger24At",value:function(b,c){var g=this.getByteAt(b),f=this.getByteAt(b+1);b=this.getByteAt(b+2);c=c?((g<<8)+f<<8)+b:((b<<8)+f<<8)+g;0>c&&(c+=16777216);return c}},{key:"getStringAt",value:function(b,c){for(var g=[],f=b,a=0;f<b+c;f++,a++)g[a]=String.fromCharCode(this.getByteAt(f));return g.join("")}},
{key:"getStringWithCharsetAt",value:function(b,c,k){b=this.getBytesAt(b,c);switch((k||"").toLowerCase()){case "utf-16":case "utf-16le":case "utf-16be":k=f.readUTF16String(b,"utf-16be"===k);break;case "utf-8":k=f.readUTF8String(b);break;default:k=f.readNullTerminatedString(b)}return k}},{key:"getCharAt",value:function(b){return String.fromCharCode(this.getByteAt(b))}},{key:"getSynchsafeInteger32At",value:function(b){var c=this.getByteAt(b),f=this.getByteAt(b+1),h=this.getByteAt(b+2);return this.getByteAt(b+
3)&127|(h&127)<<7|(f&127)<<14|(c&127)<<21}}],[{key:"canReadFile",value:function(b){throw Error("Must implement canReadFile function");}}]);return c}();p.exports=h},{"./StringUtils":13}],12:[function(h,p,n){function m(f,c){for(var b=0;b<c.length;b++){var g=c[b];g.enumerable=g.enumerable||!1;g.configurable=!0;"value"in g&&(g.writable=!0);Object.defineProperty(f,g.key,g)}}function q(f,c,b){c&&m(f.prototype,c);b&&m(f,b);return f}function t(f,c,b){c in f?Object.defineProperty(f,c,{value:b,enumerable:!0,
configurable:!0,writable:!0}):f[c]=b;return f}h("./MediaFileReader");h=function(){function f(c){if(!(this instanceof f))throw new TypeError("Cannot call a class as a function");t(this,"_mediaFileReader",void 0);t(this,"_tags",void 0);this._mediaFileReader=c;this._tags=null}q(f,[{key:"setTagsToRead",value:function(c){this._tags=c;return this}},{key:"read",value:function(c){var b=this;this._mediaFileReader.init({onSuccess:function(){b._loadData(b._mediaFileReader,{onSuccess:function(){try{var g=b._parseData(b._mediaFileReader,
b._tags)}catch(k){if(c.onError){c.onError({type:"parseData",info:k.message});return}}c.onSuccess(g)},onError:c.onError})},onError:c.onError})}},{key:"getShortcuts",value:function(){return{}}},{key:"_loadData",value:function(c,b){throw Error("Must implement _loadData function");}},{key:"_parseData",value:function(c,b){throw Error("Must implement _parseData function");}},{key:"_expandShortcutTags",value:function(c){if(!c)return null;for(var b=[],g=this.getShortcuts(),f=0,h;h=c[f];f++)b=b.concat(g[h]||
[h]);return b}}],[{key:"getTagIdentifierByteRange",value:function(){throw Error("Must implement");}},{key:"canReadTagFormat",value:function(c){throw Error("Must implement");}}]);return f}();p.exports=h},{"./MediaFileReader":11}],13:[function(h,p,n){function m(c,b){for(var g=0;g<b.length;g++){var f=b[g];f.enumerable=f.enumerable||!1;f.configurable=!0;"value"in f&&(f.writable=!0);Object.defineProperty(c,f.key,f)}}function q(c,b,f){b&&m(c.prototype,b);f&&m(c,f);return c}function t(c,b,f){b in c?Object.defineProperty(c,
b,{value:f,enumerable:!0,configurable:!0,writable:!0}):c[b]=f;return c}var f=function(){function c(b,f){if(!(this instanceof c))throw new TypeError("Cannot call a class as a function");t(this,"_value",void 0);t(this,"bytesReadCount",void 0);t(this,"length",void 0);this._value=b;this.bytesReadCount=f;this.length=b.length}q(c,[{key:"toString",value:function(){return this._value}}]);return c}();p.exports={readUTF16String:function(c,b,g){var k=0,h=1,a=0;g=Math.min(g||c.length,c.length);254==c[0]&&255==
c[1]?(b=!0,k=2):255==c[0]&&254==c[1]&&(b=!1,k=2);b&&(h=0,a=1);b=[];for(var e=0;k<g;e++){var d=c[k+h],l=(d<<8)+c[k+a];k+=2;if(0==l)break;else 216>d||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;g<b;m++){var a=c[g++];if(0==a)break;else if(128>a)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;h<b;){var m=c[h++];if(0==m)break;g[h-1]=String.fromCharCode(m)}return new f(g.join(""),
h)}}},{}],14:[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<b.length;d++){var c=b[d];c.enumerable=c.enumerable||!1;c.configurable=!0;"value"in c&&(c.writable=!0);Object.defineProperty(a,c.key,c)}}function t(a,b,d){b&&q(a.prototype,b);d&&q(a,d);return a}function f(a){f=
Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return f(a)}function c(a){if(void 0===a)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return a}function b(a,b){if("function"!==typeof b&&null!==b)throw new TypeError("Super expression must either be null or a function");a.prototype=Object.create(b&&b.prototype,{constructor:{value:a,writable:!0,configurable:!0}});b&&g(a,b)}function g(a,b){g=Object.setPrototypeOf||
function(a,b){a.__proto__=b;return a};return g(a,b)}function k(a,b,d){b in a?Object.defineProperty(a,b,{value:d,enumerable:!0,configurable:!0,writable:!0}):a[b]=d;return a}var r=h("./ChunkedFileData");n=function(a){function e(a){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function");var b=f(e).call(this);b=!b||"object"!==m(b)&&"function"!==typeof b?c(this):b;k(c(b),"_url",void 0);k(c(b),"_fileData",void 0);b._url=a;b._fileData=new r;return b}b(e,a);t(e,[{key:"_init",value:function(a){e._config.avoidHeadRequests?
this._fetchSizeWithGetRequest(a):this._fetchSizeWithHeadRequest(a)}},{key:"_fetchSizeWithHeadRequest",value:function(a){var b=this;this._makeXHRRequest("HEAD",null,{onSuccess:function(d){(d=b._parseContentLength(d))?(b._size=d,a.onSuccess()):b._fetchSizeWithGetRequest(a)},onError:a.onError})}},{key:"_fetchSizeWithGetRequest",value:function(a){var b=this,d=this._roundRangeToChunkMultiple([0,0]);this._makeXHRRequest("GET",d,{onSuccess:function(d){var c=b._parseContentRange(d);d=b._getXhrResponseContent(d);
if(c){if(null==c.instanceLength){b._fetchEntireFile(a);return}b._size=c.instanceLength}else b._size=d.length;b._fileData.addData(0,d);a.onSuccess()},onError:a.onError})}},{key:"_fetchEntireFile",value:function(a){var b=this;this._makeXHRRequest("GET",null,{onSuccess:function(d){d=b._getXhrResponseContent(d);b._size=d.length;b._fileData.addData(0,d);a.onSuccess()},onError:a.onError})}},{key:"_getXhrResponseContent",value:function(a){return a.responseBody||a.responseText||""}},{key:"_parseContentLength",
value:function(a){a=this._getResponseHeader(a,"Content-Length");return null==a?a:parseInt(a,10)}},{key:"_parseContentRange",value:function(a){if(a=this._getResponseHeader(a,"Content-Range")){var b=a.match(/bytes (\d+)-(\d+)\/(?:(\d+)|\*)/i);if(!b)throw Error("FIXME: Unknown Content-Range syntax: "+a);return{firstBytePosition:parseInt(b[1],10),lastBytePosition:parseInt(b[2],10),instanceLength:b[3]?parseInt(b[3],10):null}}return null}},{key:"loadRange",value:function(a,b){var d=this;d._fileData.hasDataRange(a[0],
Math.min(d._size,a[1]))?setTimeout(b.onSuccess,1):(a=this._roundRangeToChunkMultiple(a),a[1]=Math.min(d._size,a[1]),this._makeXHRRequest("GET",a,{onSuccess:function(c){c=d._getXhrResponseContent(c);d._fileData.addData(a[0],c);b.onSuccess()},onError:b.onError}))}},{key:"_roundRangeToChunkMultiple",value:function(a){return[a[0],a[0]+1024*Math.ceil((a[1]-a[0]+1)/1024)-1]}},{key:"_makeXHRRequest",value:function(a,b,c){var d=this._createXHRObject();d.open(a,this._url);var f=function(){if(200===d.status||
206===d.status)c.onSuccess(d);else if(c.onError)c.onError({type:"xhr",info:"Unexpected HTTP status "+d.status+".",xhr:d});d=null};"undefined"!==typeof d.onload?(d.onload=f,d.onerror=function(){if(c.onError)c.onError({type:"xhr",info:"Generic XHR error, check xhr object.",xhr:d})}):d.onreadystatechange=function(){4===d.readyState&&f()};e._config.timeoutInSec&&(d.timeout=1E3*e._config.timeoutInSec,d.ontimeout=function(){if(c.onError)c.onError({type:"xhr",info:"Timeout after "+d.timeout/1E3+"s. Use jsmediatags.Config.setXhrTimeout to override.",
xhr:d})});d.overrideMimeType("text/plain; charset=x-user-defined");b&&this._setRequestHeader(d,"Range","bytes="+b[0]+"-"+b[1]);this._setRequestHeader(d,"If-Modified-Since","Sat, 01 Jan 1970 00:00:00 GMT");d.send(null)}},{key:"_setRequestHeader",value:function(a,b,c){0>e._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;c<a.length;c++)d[c]=a[c].split(":")[0].toLowerCase();
return 0<=d.indexOf(b.toLowerCase())}},{key:"_getResponseHeader",value:function(a,b){return this._hasResponseHeader(a,b)?a.getResponseHeader(b):null}},{key:"getByteAt",value:function(a){return this._fileData.getByteAt(a).charCodeAt(0)&255}},{key:"_isWebWorker",value:function(){return"undefined"!==typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope}},{key:"_createXHRObject",value:function(){if("undefined"===typeof window&&!this._isWebWorker())return new (h("xhr2").XMLHttpRequest);if("undefined"!==
typeof XMLHttpRequest)return new XMLHttpRequest;throw Error("XMLHttpRequest is not supported");}}],[{key:"canReadFile",value:function(a){return"string"===typeof a&&/^[a-z]+:\/\//i.test(a)}},{key:"setConfig",value:function(a){for(var b in a)a.hasOwnProperty(b)&&(this._config[b]=a[b]);a=this._config.disallowedXhrHeaders;for(b=0;b<a.length;b++)a[b]=a[b].toLowerCase()}}]);return e}(h("./MediaFileReader"));k(n,"_config",void 0);n._config={avoidHeadRequests:!1,disallowedXhrHeaders:[],timeoutInSec:30};p.exports=
n},{"./ChunkedFileData":5,"./MediaFileReader":11,xhr2:2}],15:[function(h,p,n){function m(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function");}function q(a,b){for(var c=0;c<b.length;c++){var d=b[c];d.enumerable=d.enumerable||!1;d.configurable=!0;"value"in d&&(d.writable=!0);Object.defineProperty(a,d.key,d)}}function t(a,b,c){b&&q(a.prototype,b);c&&q(a,c);return a}function f(a,b,c){b in a?Object.defineProperty(a,b,{value:c,enumerable:!0,configurable:!0,writable:!0}):a[b]=
c;return a}function c(a,b){var c=0>a.offset&&(-a.offset>b||0<a.offset+a.length);return!(0<=a.offset&&a.offset+a.length>=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;a<l.length;a++)if(l[a].canReadFile(this._file))return l[a];throw Error("No suitable file reader found for "+this._file);}},{key:"_getTagReader",value:function(a,b){if(this._tagReader){var c=this._tagReader;setTimeout(function(){b.onSuccess(c)},1)}else this._findTagReader(a,b)}},{key:"_findTagReader",value:function(a,b){for(var d=[],e=[],f=a.getSize(),g=0;g<u.length;g++){var h=u[g].getTagIdentifierByteRange();
c(h,f)&&(0<=h.offset&&h.offset<f/2||0>h.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<u.length;d++){var e=u[d].getTagIdentifierByteRange();if(c(e,f)){try{var g=a.getBytesAt(0<=e.offset?e.offset:e.offset+f,e.length)}catch(x){if(b.onError)b.onError({type:"fileReader",info:x.message});return}if(u[d].canReadTagFormat(g)){b.onSuccess(u[d]);return}}}if(b.onError)b.onError({type:"tagFormat",info:"No suitable tag reader found"})}else k=!0},onError:b.onError};
this._loadTagIdentifierRanges(a,d,g);this._loadTagIdentifierRanges(a,e,g)}},{key:"_loadTagIdentifierRanges",value:function(a,b,c){if(0===b.length)setTimeout(c.onSuccess,1);else{for(var d=[Number.MAX_VALUE,0],e=a.getSize(),f=0;f<b.length;f++){var g=b[f].getTagIdentifierByteRange(),h=0<=g.offset?g.offset:g.offset+e;g=h+g.length-1;d[0]=Math.min(h,d[0]);d[1]=Math.max(g,d[1])}a.loadRange(d,c)}}}]);return a}();n=function(){function a(){m(this,a)}t(a,null,[{key:"addFileReader",value:function(b){l.push(b);
return a}},{key:"addTagReader",value:function(b){u.push(b);return a}},{key:"removeTagReader",value:function(b){b=u.indexOf(b);0<=b&&u.splice(b,1);return a}},{key:"EXPERIMENTAL_avoidHeadRequests",value:function(){b.setConfig({avoidHeadRequests:!0})}},{key:"setDisallowedXhrHeaders",value:function(a){b.setConfig({disallowedXhrHeaders:a})}},{key:"setXhrTimeoutInSec",value:function(a){b.setConfig({timeoutInSec:a})}}]);return a}();n.addFileReader(b).addFileReader(g).addFileReader(k).addTagReader(a).addTagReader(r).addTagReader(e).addTagReader(d);
"undefined"===typeof process||process.browser||(h="undefined"!==typeof navigator&&"ReactNative"===navigator.product?h("./ReactNativeFileReader"):h("./NodeFileReader"),n.addFileReader(h));p.exports={read:function(a,b){(new w(a)).read(b)},Reader:w,Config:n}},{"./ArrayFileReader":3,"./BlobFileReader":4,"./FLACTagReader":6,"./ID3v1TagReader":7,"./ID3v2TagReader":9,"./MP4TagReader":10,"./MediaFileReader":11,"./MediaTagReader":12,"./NodeFileReader":1,"./ReactNativeFileReader":1,"./XhrFileReader":14}]},
{},[15])(15)});

18
static/manifest.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "Freedify",
"short_name": "Freedify",
"description": "Stream music from anywhere",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"orientation": "portrait",
"icons": [
{
"src": "/static/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

5718
static/styles.css Normal file

File diff suppressed because it is too large Load diff

66
static/sw.js Normal file
View file

@ -0,0 +1,66 @@
/**
* SpotiFLAC Service Worker
* Caches app shell for offline access
*/
const CACHE_NAME = 'spotiflac-v6';
const STATIC_ASSETS = [
'/',
'/static/styles.css',
'/static/app.js',
'/static/manifest.json',
'/static/icon.svg'
];
// Install - cache static assets
self.addEventListener('install', (event) => {
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);
})
);
});