Initial commit
393
.github/workflows/build.yml
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# App package
|
||||
273
app/ai_radio_service.py
Normal 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
133
app/cache.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
DJ Service for Freedify.
|
||||
AI-powered setlist generation using Gemini 2.0 Flash.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Camelot wheel compatibility chart
|
||||
# Compatible keys: same key, +1/-1 on wheel, switch A/B (relative major/minor)
|
||||
CAMELOT_COMPAT = {
|
||||
"1A": ["1A", "1B", "12A", "2A"],
|
||||
"1B": ["1B", "1A", "12B", "2B"],
|
||||
"2A": ["2A", "2B", "1A", "3A"],
|
||||
"2B": ["2B", "2A", "1B", "3B"],
|
||||
"3A": ["3A", "3B", "2A", "4A"],
|
||||
"3B": ["3B", "3A", "2B", "4B"],
|
||||
"4A": ["4A", "4B", "3A", "5A"],
|
||||
"4B": ["4B", "4A", "3B", "5B"],
|
||||
"5A": ["5A", "5B", "4A", "6A"],
|
||||
"5B": ["5B", "5A", "4B", "6B"],
|
||||
"6A": ["6A", "6B", "5A", "7A"],
|
||||
"6B": ["6B", "6A", "5B", "7B"],
|
||||
"7A": ["7A", "7B", "6A", "8A"],
|
||||
"7B": ["7B", "7A", "6B", "8B"],
|
||||
"8A": ["8A", "8B", "7A", "9A"],
|
||||
"8B": ["8B", "8A", "7B", "9B"],
|
||||
"9A": ["9A", "9B", "8A", "10A"],
|
||||
"9B": ["9B", "9A", "8B", "10B"],
|
||||
"10A": ["10A", "10B", "9A", "11A"],
|
||||
"10B": ["10B", "10A", "9B", "11B"],
|
||||
"11A": ["11A", "11B", "10A", "12A"],
|
||||
"11B": ["11B", "11A", "10B", "12B"],
|
||||
"12A": ["12A", "12B", "11A", "1A"],
|
||||
"12B": ["12B", "12A", "11B", "1B"],
|
||||
}
|
||||
|
||||
|
||||
class DJService:
|
||||
"""AI-powered DJ setlist generator using Gemini 2.0 Flash."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.environ.get("GEMINI_API_KEY")
|
||||
self._genai = None
|
||||
self._model = None
|
||||
|
||||
def _init_genai(self):
|
||||
"""Lazy initialization of Gemini client."""
|
||||
if self._genai is None:
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
if not self.api_key:
|
||||
logger.warning("GEMINI_API_KEY not set - AI features will use rule-based fallback")
|
||||
return False
|
||||
genai.configure(api_key=self.api_key)
|
||||
self._genai = genai
|
||||
self._model = genai.GenerativeModel('gemini-2.0-flash')
|
||||
logger.info("Gemini 2.0 Flash initialized successfully")
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("google-generativeai not installed - using rule-based fallback")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Gemini: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_harmonically_compatible(self, camelot1: str, camelot2: str) -> bool:
|
||||
"""Check if two Camelot keys are harmonically compatible."""
|
||||
if camelot1 == "?" or camelot2 == "?":
|
||||
return False
|
||||
return camelot2 in CAMELOT_COMPAT.get(camelot1, [])
|
||||
|
||||
def _rule_based_setlist(self, tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate setlist using rule-based algorithm when AI is unavailable.
|
||||
Strategy: Sort by energy, then optimize for harmonic compatibility.
|
||||
"""
|
||||
if len(tracks) <= 2:
|
||||
return sorted(tracks, key=lambda t: t.get("energy", 0.5))
|
||||
|
||||
# Start with lowest energy track
|
||||
sorted_tracks = sorted(tracks, key=lambda t: t.get("energy", 0.5))
|
||||
setlist = [sorted_tracks.pop(0)]
|
||||
|
||||
while sorted_tracks:
|
||||
last = setlist[-1]
|
||||
last_camelot = last.get("camelot", "?")
|
||||
last_bpm = last.get("bpm", 120)
|
||||
|
||||
# Score remaining tracks by compatibility
|
||||
def score_track(t):
|
||||
score = 0
|
||||
# Harmonic compatibility (+10 points)
|
||||
if self.is_harmonically_compatible(last_camelot, t.get("camelot", "?")):
|
||||
score += 10
|
||||
# BPM proximity (+5 for within 5 BPM, +3 for within 10)
|
||||
bpm_diff = abs(t.get("bpm", 120) - last_bpm)
|
||||
if bpm_diff <= 5:
|
||||
score += 5
|
||||
elif bpm_diff <= 10:
|
||||
score += 3
|
||||
# Slight energy increase preferred (+2)
|
||||
energy_diff = t.get("energy", 0.5) - last.get("energy", 0.5)
|
||||
if 0 < energy_diff < 0.15:
|
||||
score += 2
|
||||
return score
|
||||
|
||||
# Pick best scoring track
|
||||
sorted_tracks.sort(key=score_track, reverse=True)
|
||||
setlist.append(sorted_tracks.pop(0))
|
||||
|
||||
return setlist
|
||||
|
||||
async def generate_setlist(
|
||||
self,
|
||||
tracks: List[Dict[str, Any]],
|
||||
style: str = "progressive" # or "peak-time", "chill", "journey"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate an AI-optimized DJ setlist.
|
||||
|
||||
Args:
|
||||
tracks: List of tracks with bpm, camelot, energy, name, artists
|
||||
style: Setlist style preference
|
||||
|
||||
Returns:
|
||||
Dict with ordered track IDs and mixing suggestions
|
||||
"""
|
||||
if len(tracks) < 2:
|
||||
return {
|
||||
"ordered_ids": [t.get("id") for t in tracks],
|
||||
"suggestions": [],
|
||||
"method": "passthrough"
|
||||
}
|
||||
|
||||
# Try AI generation first
|
||||
if self._init_genai() and self._model:
|
||||
try:
|
||||
result = await self._ai_generate_setlist(tracks, style)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"AI setlist generation failed: {e}")
|
||||
|
||||
# Fallback to rule-based
|
||||
logger.info("Using rule-based setlist generation")
|
||||
ordered = self._rule_based_setlist(tracks.copy())
|
||||
|
||||
# Generate basic suggestions
|
||||
suggestions = []
|
||||
for i in range(len(ordered) - 1):
|
||||
t1, t2 = ordered[i], ordered[i+1]
|
||||
bpm_diff = abs(t2.get("bpm", 0) - t1.get("bpm", 0))
|
||||
compatible = self.is_harmonically_compatible(
|
||||
t1.get("camelot", "?"), t2.get("camelot", "?")
|
||||
)
|
||||
|
||||
suggestion = {
|
||||
"from_id": t1.get("id"),
|
||||
"to_id": t2.get("id"),
|
||||
"harmonic_match": compatible,
|
||||
"bpm_diff": bpm_diff,
|
||||
}
|
||||
|
||||
if compatible and bpm_diff <= 5:
|
||||
suggestion["tip"] = "Perfect mix - smooth harmonic transition"
|
||||
elif compatible:
|
||||
suggestion["tip"] = f"Harmonically compatible, adjust BPM by {bpm_diff}"
|
||||
elif bpm_diff <= 3:
|
||||
suggestion["tip"] = "BPM locked, consider EQ mixing"
|
||||
else:
|
||||
suggestion["tip"] = "Energy transition - use effects or beat drop"
|
||||
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return {
|
||||
"ordered_ids": [t.get("id") for t in ordered],
|
||||
"suggestions": suggestions,
|
||||
"method": "rule-based"
|
||||
}
|
||||
|
||||
async def _ai_generate_setlist(
|
||||
self,
|
||||
tracks: List[Dict[str, Any]],
|
||||
style: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Generate setlist using Gemini AI."""
|
||||
import json
|
||||
|
||||
# Build track summary for the prompt
|
||||
track_summary = []
|
||||
for i, t in enumerate(tracks):
|
||||
track_summary.append(
|
||||
f"{i+1}. \"{t.get('name', 'Unknown')}\" by {t.get('artists', 'Unknown')} | "
|
||||
f"BPM: {t.get('bpm', '?')} | Key: {t.get('camelot', '?')} | Energy: {t.get('energy', '?')}"
|
||||
)
|
||||
|
||||
style_desc = {
|
||||
"progressive": "gradually build energy from low to high, creating a journey",
|
||||
"peak-time": "maintain high energy throughout with dramatic moments",
|
||||
"chill": "keep energy low to medium, prioritizing smooth vibes",
|
||||
"journey": "create a wave pattern - build up, peak, come down, build again"
|
||||
}.get(style, "gradually build energy")
|
||||
|
||||
prompt = f"""You are an expert DJ creating an optimal setlist. Analyze these tracks and order them for the best flow.
|
||||
|
||||
TRACKS:
|
||||
{chr(10).join(track_summary)}
|
||||
|
||||
GOAL: {style_desc}
|
||||
|
||||
MIXING RULES:
|
||||
1. Harmonically compatible keys mix best (same Camelot number, or ±1, or A↔B switch)
|
||||
2. Keep BPM changes within ±8 BPM between tracks for smooth mixing
|
||||
3. Energy should follow the style pattern
|
||||
4. Consider musical "story" - intro, build, peak, outro
|
||||
|
||||
DJ TECHNIQUES TO SUGGEST:
|
||||
- "Long Blend" - 16-32 bar crossfade with EQ swapping
|
||||
- "Filter Sweep" - Use low-pass or high-pass filter on outgoing track
|
||||
- "Echo Out" - Apply echo/delay while fading out
|
||||
- "Hard Cut" - Quick switch on phrase start (for genre changes or impact)
|
||||
- "Beat Drop" - Drop incoming track on a breakdown/drop
|
||||
- "EQ Swap" - Gradually swap bass/mids/highs between tracks
|
||||
- "Loop & Build" - Loop outgoing track while bringing in new one
|
||||
- "Acapella Blend" - If vocal track, layer over instrumental
|
||||
|
||||
Respond ONLY with valid JSON in this exact format:
|
||||
{{
|
||||
"order": [1, 3, 2, ...],
|
||||
"tips": [
|
||||
{{
|
||||
"from": 1,
|
||||
"to": 3,
|
||||
"technique": "Filter Sweep",
|
||||
"timing": "16 bars",
|
||||
"tip": "Filter out the bass of track 1, bring in track 3 on the drop"
|
||||
}},
|
||||
...
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON from response
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
order = data.get("order", [])
|
||||
tips = data.get("tips", [])
|
||||
|
||||
# Map back to track IDs
|
||||
ordered_ids = []
|
||||
for idx in order:
|
||||
if 1 <= idx <= len(tracks):
|
||||
ordered_ids.append(tracks[idx - 1].get("id"))
|
||||
|
||||
# Map tips to track IDs
|
||||
suggestions = []
|
||||
for tip in tips:
|
||||
from_idx = tip.get("from", 0)
|
||||
to_idx = tip.get("to", 0)
|
||||
if 1 <= from_idx <= len(tracks) and 1 <= to_idx <= len(tracks):
|
||||
t1 = tracks[from_idx - 1]
|
||||
t2 = tracks[to_idx - 1]
|
||||
suggestions.append({
|
||||
"from_id": t1.get("id"),
|
||||
"to_id": t2.get("id"),
|
||||
"harmonic_match": self.is_harmonically_compatible(
|
||||
t1.get("camelot", "?"), t2.get("camelot", "?")
|
||||
),
|
||||
"bpm_diff": abs(t2.get("bpm", 0) - t1.get("bpm", 0)),
|
||||
"technique": tip.get("technique", ""),
|
||||
"timing": tip.get("timing", ""),
|
||||
"tip": tip.get("tip", "")
|
||||
})
|
||||
|
||||
logger.info(f"AI generated setlist with {len(ordered_ids)} tracks")
|
||||
return {
|
||||
"ordered_ids": ordered_ids,
|
||||
"suggestions": suggestions,
|
||||
"method": "ai-gemini-2.0-flash"
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse AI response as JSON: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"AI generation error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
return None
|
||||
|
||||
async def get_audio_features_ai(self, name: str, artist: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Estimate audio features using AI when Spotify data is unavailable.
|
||||
"""
|
||||
if not self._init_genai() or not self._model:
|
||||
return None
|
||||
|
||||
prompt = f"""Act as an expert musicologist and DJ.
|
||||
Provide the OFFICIAL studio audio analysis for the track:
|
||||
Title: "{name}"
|
||||
Artist: "{artist}"
|
||||
|
||||
Analyze the genre and style. (e.g. Dubstep/Mid-tempo is usually 90-110 BPM, House is 120-130).
|
||||
Provide the most accurate:
|
||||
1. BPM (Integer) - Check for half-time/double-time ambiguities.
|
||||
2. Key (Camelot Notation, e.g. 5A, 11B)
|
||||
3. Energy (0.0 to 1.0)
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"bpm": 100,
|
||||
"camelot": "5A",
|
||||
"energy": 0.8
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
import json
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
# Validate
|
||||
return {
|
||||
"track_id": f"ai_{abs(hash(name + artist))}", # Dummy ID
|
||||
"bpm": int(data.get("bpm", 120)),
|
||||
"camelot": data.get("camelot", "?"),
|
||||
"energy": float(data.get("energy", 0.5)),
|
||||
"key": -1, # Unknown
|
||||
"mode": 0,
|
||||
"danceability": 0.5,
|
||||
"valence": 0.5,
|
||||
"source": "ai_estimate"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AI audio features estimation failed (using fallback): {e}")
|
||||
return None
|
||||
|
||||
async def interpret_mood_query(self, query: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Interpret a natural language mood query using AI.
|
||||
Returns structured search terms and mood metadata.
|
||||
"""
|
||||
if not self._init_genai() or not self._model:
|
||||
return None
|
||||
|
||||
prompt = f"""You are a music discovery AI. The user wants to find music based on a mood or vibe description.
|
||||
|
||||
USER QUERY: "{query}"
|
||||
|
||||
Interpret this mood/vibe and provide:
|
||||
1. 3-5 search terms that would find matching songs (artist names, genres, song characteristics)
|
||||
2. Mood keywords that describe this vibe
|
||||
3. Suggested BPM range
|
||||
4. Energy level (low, medium, high)
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{{
|
||||
"search_terms": ["term1", "term2", "term3"],
|
||||
"moods": ["chill", "relaxed"],
|
||||
"bpm_range": {{"min": 70, "max": 100}},
|
||||
"energy": "low",
|
||||
"description": "Brief 1-sentence description of the vibe"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
import json
|
||||
response = await self._model.generate_content_async(prompt)
|
||||
text = response.text.strip()
|
||||
|
||||
# Extract JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
logger.info(f"AI interpreted mood query: {query} -> {data.get('search_terms', [])}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AI mood interpretation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
dj_service = DJService()
|
||||
241
app/genius_service.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
194
app/musicbrainz_service.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
5
icons/circle-x.svg
Normal 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
|
After Width: | Height: | Size: 6.4 KiB |
5
icons/download.svg
Normal 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
|
After Width: | Height: | Size: 169 KiB |
48
icons/icon.svg
Normal 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
|
After Width: | Height: | Size: 6.1 KiB |
3
icons/tool.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
BIN
screenshots/album-details.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
screenshots/album-search.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
screenshots/download-formats.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
screenshots/equalizer.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
screenshots/fullscreen-player.png
Normal file
|
After Width: | Height: | Size: 778 KiB |
BIN
screenshots/genius-annotations.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
screenshots/genius-lyrics.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
screenshots/milkdrop-visualizer-2.png
Normal file
|
After Width: | Height: | Size: 941 KiB |
BIN
screenshots/milkdrop-visualizer.png
Normal file
|
After Width: | Height: | Size: 919 KiB |
BIN
screenshots/podcast-episode.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
6536
static/app.js
Normal file
BIN
static/icon.png
Normal file
|
After Width: | Height: | Size: 476 KiB |
10
static/icon.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
66
static/sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||