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

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +--- + +## Credits +Inspired by and built off of [Spotiflac](https://github.com/afkarxyz/Spotiflac) by afkarxyz. +**Hi-Res Audio Source** provided by [Dab Music](https://dabmusic.xyz). + +--- + +Made with 💖 by a music lover, for music lovers. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..edabda9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# App package diff --git a/app/ai_radio_service.py b/app/ai_radio_service.py new file mode 100644 index 0000000..a125848 --- /dev/null +++ b/app/ai_radio_service.py @@ -0,0 +1,273 @@ +""" +AI Radio Service for Freedify. +Generates continuous playlist recommendations based on a seed track or mood. +""" +import os +import logging +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class AIRadioService: + """AI-powered radio that generates track recommendations.""" + + def __init__(self): + # Helper for key fallback logic if needed, but primary is env var + self.api_key = os.environ.get("GEMINI_API_KEY") + self._genai = None + self._model = None + + def _init_genai(self): + """Lazy initialization of Gemini client.""" + if self._genai is None: + try: + import google.generativeai as genai + if not self.api_key: + logger.warning("GEMINI_API_KEY not set - AI Radio will use basic mode") + return False + genai.configure(api_key=self.api_key) + self._genai = genai + self._model = genai.GenerativeModel('gemini-2.0-flash') + logger.info("AI Radio: Gemini initialized") + return True + except ImportError: + logger.warning("google-generativeai not installed") + return False + except Exception as e: + logger.error(f"Failed to initialize Gemini for AI Radio: {e}") + return False + return True + + async def generate_recommendations( + self, + seed_track: Optional[Dict[str, Any]] = None, + mood: Optional[str] = None, + current_queue: List[Dict[str, Any]] = None, + count: int = 5 + ) -> Dict[str, Any]: + """ + Generate track recommendations for AI Radio. + + Args: + seed_track: A track to base recommendations on (name, artist, bpm, key) + mood: A mood/vibe description if no seed track + current_queue: Current queue to avoid duplicates + count: Number of recommendations to generate + + Returns: + Dict with search_terms to find recommended tracks + """ + current_queue = current_queue or [] + + # Build context + if seed_track: + context = f"""Based on this seed track: +Title: "{seed_track.get('name', 'Unknown')}" +Artist: {seed_track.get('artists', 'Unknown')} +BPM: {seed_track.get('bpm', 'Unknown')} +Key: {seed_track.get('camelot', 'Unknown')}""" + elif mood: + context = f'Based on this mood/vibe: "{mood}"' + else: + context = "Generate a diverse mix of popular tracks" + + # Exclude current queue tracks + exclude_list = [] + for t in current_queue[:10]: # Limit to last 10 + exclude_list.append(f"- {t.get('name', '')} by {t.get('artists', '')}") + + exclude_str = "\n".join(exclude_list) if exclude_list else "None" + + # Try AI generation + if self._init_genai() and self._model: + try: + return await self._ai_generate_recommendations( + context, exclude_str, count, seed_track + ) + except Exception as e: + logger.error(f"AI recommendation failed: {e}") + + # Fallback: return genre-based search terms + return self._fallback_recommendations(seed_track, mood, count) + + async def _ai_generate_recommendations( + self, + context: str, + exclude_str: str, + count: int, + seed_track: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Generate recommendations using Gemini AI.""" + import json + + prompt = f"""{context} + +TASK: Recommend {count} songs that would flow well in a DJ mix or playlist. + +RULES: +1. Match the energy, tempo, and vibe of the seed track or mood +2. Consider harmonic compatibility (Camelot wheel) +3. Mix well-known tracks with hidden gems +4. Vary artists but keep genre/style consistent + +EXCLUDE these tracks already in queue: +{exclude_str} + +Respond ONLY with valid JSON: +{{ + "recommendations": [ + {{"artist": "Artist Name", "title": "Song Title", "reason": "Why it fits"}}, + ... + ], + "suggested_searches": ["search term 1", "search term 2", ...], + "vibe_description": "Brief description of the vibe" +}}""" + + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + + # Build search terms from recommendations + search_terms = [] + for rec in data.get("recommendations", [])[:count]: + artist = rec.get("artist", "") + title = rec.get("title", "") + if artist and title: + search_terms.append(f"{artist} {title}") + + # Add suggested searches as fallback + search_terms.extend(data.get("suggested_searches", [])[:3]) + + logger.info(f"AI Radio generated {len(search_terms)} recommendations") + + return { + "search_terms": search_terms, + "recommendations": data.get("recommendations", []), + "vibe_description": data.get("vibe_description", ""), + "method": "ai" + } + + def _fallback_recommendations( + self, + seed_track: Optional[Dict[str, Any]], + mood: Optional[str], + count: int + ) -> Dict[str, Any]: + """Fallback when AI is unavailable.""" + search_terms = [] + + if seed_track: + # Search for similar based on artist + artist = seed_track.get("artists", "").split(",")[0].strip() + if artist: + search_terms.append(f"{artist}") + search_terms.append(f"{artist} remix") + + if mood: + search_terms.append(mood) + + # Generic fallback + if not search_terms: + search_terms = ["popular electronic", "chill beats", "dance hits"] + + return { + "search_terms": search_terms[:count], + "recommendations": [], + "vibe_description": "Based on your selection", + "method": "fallback" + } + + + + async def generate_playlist( + self, + description: str, + duration_mins: int = 60, + track_count: int = 15 + ) -> Dict[str, Any]: + """ + Generate a playlist from a natural language description. + + Args: + description: Playlist description like "morning coffee jazz" or "high energy workout" + duration_mins: Target duration in minutes + track_count: Number of tracks to generate + + Returns: + Dict with tracks (artist + title pairs), playlist name, description + """ + if not self._init_genai() or not self._model: + return { + "tracks": [], + "playlist_name": "Generated Playlist", + "description": description, + "method": "fallback", + "error": "AI not available" + } + + try: + import json + + # Estimate tracks based on duration (avg 3.5 min per track) + estimated_tracks = min(max(duration_mins // 4, 5), track_count) + + prompt = f"""You are a music curator. Create a playlist based on this description. + +DESCRIPTION: "{description}" +TARGET DURATION: ~{duration_mins} minutes ({estimated_tracks} tracks) + +TASK: Generate a cohesive playlist that matches the vibe and purpose. + +RULES: +1. Mix popular tracks with quality deep cuts +2. Consider flow and energy progression +3. Vary artists while maintaining style consistency +4. Include specific, real songs (not made-up titles) + +Respond ONLY with valid JSON: +{{ + "playlist_name": "Creative name for this playlist", + "description": "Brief description of the vibe", + "tracks": [ + {{"artist": "Artist Name", "title": "Song Title"}}, + ... + ] +}}""" + + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + data["method"] = "ai" + data["requested_duration"] = duration_mins + + logger.info(f"Generated playlist '{data.get('playlist_name')}' with {len(data.get('tracks', []))} tracks") + return data + + except Exception as e: + logger.error(f"Playlist generation error: {e}") + return { + "tracks": [], + "playlist_name": "Generated Playlist", + "description": description, + "method": "fallback", + "error": str(e) + } + + +# Singleton instance +ai_radio_service = AIRadioService() diff --git a/app/audio_service.py b/app/audio_service.py new file mode 100644 index 0000000..e296461 --- /dev/null +++ b/app/audio_service.py @@ -0,0 +1,1036 @@ +""" +Audio service for fetching and transcoding music. +Fetches FLAC from Tidal/Deezer and transcodes to MP3 using FFmpeg. +Uses multiple API endpoints with fallback for reliability. +""" +import os +import subprocess +import asyncio +import httpx +import base64 +from typing import Optional, Dict, Any, List, Union +import logging +import json +import tempfile +from mutagen.flac import FLAC, Picture +from mutagen.id3 import ID3, TIT2, TPE1, TALB, TRCK, TDRC, TCON, APIC, COMM +from mutagen.mp3 import MP3, EasyMP3 +from mutagen.mp4 import MP4, MP4Cover + +import re +from app.cache import is_cached, get_cached_file, cache_file, get_cache_path + +logger = logging.getLogger(__name__) + +# Configuration +BITRATE = os.environ.get("MP3_BITRATE", "320k") +DEEZER_API_URL = os.environ.get("DEEZER_API_URL", "https://api.deezmate.com") + +# FFmpeg path - check common locations on Windows +FFMPEG_PATH = os.environ.get("FFMPEG_PATH", "ffmpeg") +if os.name == 'nt' and FFMPEG_PATH == "ffmpeg": + # Try common Windows locations + winget_path = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages") + if os.path.exists(winget_path): + for root, dirs, files in os.walk(winget_path): + if "ffmpeg.exe" in files: + FFMPEG_PATH = os.path.join(root, "ffmpeg.exe") + break + +# List of Tidal API endpoints with fallback (fastest/most reliable first) +TIDAL_APIS = [ + "https://triton.squid.wtf", # From Monochrome - fast primary + "https://hifi.401658.xyz", + "https://tidal.kinoplus.online", + "https://tidal-api.binimum.org", + "https://wolf.qqdl.site", + "https://maus.qqdl.site", + "https://vogel.qqdl.site", + "https://katze.qqdl.site", + "https://hund.qqdl.site", +] + + + +class AudioService: + """Service for fetching and transcoding audio.""" + + # Tidal credentials (same as SpotiFLAC) + TIDAL_CLIENT_ID = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode() + TIDAL_CLIENT_SECRET = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode() + + async def import_url(self, url: str) -> Optional[Dict[str, Any]]: + """Import track or playlist from URL using yt-dlp.""" + try: + # Phish.in Custom Handler (Fast Path) + if "phish.in" in url: + logger.info("Detected Phish.in URL, using custom API handler") + phish_data = await self._import_phish_in(url) + if phish_data: return phish_data + + loop = asyncio.get_event_loop() + info = await loop.run_in_executor(None, lambda: self._extract_info_safe(url)) + + if not info: + return None + + # Check if it's a playlist/album + if 'entries' in info and info['entries']: + logger.info(f"Detected playlist: {info.get('title')}") + tracks = [] + for entry in info['entries']: + if not entry: continue + + # Determine playback URL (webpage_url for recalculation, or direct url) + # For stability, we prefer the webpage_url if it's a separate page, + # OR we use the original URL with an index? + # Ideally, entry has 'webpage_url' or 'url'. + # For yt-dlp, 'url' might be the stream url (which expires). 'webpage_url' is persistent. + play_url = entry.get('webpage_url') or entry.get('url') + if not play_url: continue + + safe_t_id = f"LINK:{base64.urlsafe_b64encode(play_url.encode()).decode()}" + duration_s = entry.get('duration', 0) + + tracks.append({ + 'id': safe_t_id, + 'name': entry.get('title', 'Unknown Title'), + 'artists': entry.get('uploader', entry.get('artist', 'Unknown Artist')), + 'album_art': entry.get('thumbnail', info.get('thumbnail', '/static/icon.svg')), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'album': info.get('title', 'Imported Playlist'), + 'isrc': safe_t_id # Use ID as ISRC for internal logic + }) + + if not tracks: return None + + return { + 'type': 'album', + 'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}", + 'name': info.get('title', 'Imported Playlist'), + 'artists': info.get('uploader', 'Various'), + 'image': info.get('thumbnail', '/static/icon.svg'), # Use album art + 'release_date': info.get('upload_date', ''), + 'tracks': tracks, + 'total_tracks': len(tracks), + 'is_custom': True + } + + # Single Track Logic + safe_id = f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}" + duration_s = info.get('duration', 0) + + track = { + 'id': safe_id, + 'name': info.get('title', 'Unknown Title'), + 'artists': info.get('uploader', info.get('artist', 'Unknown Artist')), + 'album_art': info.get('thumbnail', '/static/icon.svg'), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'album': info.get('extractor_key', 'Imported'), + 'isrc': safe_id + } + return track + except Exception as e: + logger.error(f"Import error: {e}") + return None + + def _extract_info_safe(self, url): + try: + import yt_dlp + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'format': 'bestaudio/best', + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + return ydl.extract_info(url, download=False) + except Exception as e: + logger.error(f"yt-dlp error: {e}") + return None + + async def _import_phish_in(self, url: str) -> Optional[Dict[str, Any]]: + """Import show from Phish.in API.""" + try: + # Extract date YYYY-MM-DD + match = re.search(r'(\d{4}-\d{2}-\d{2})', url) + if not match: + logger.warning("Could not extract date from Phish.in URL") + return None + + date = match.group(1) + api_url = f"https://phish.in/api/v2/shows/{date}" + + logger.info(f"Fetching Phish.in API: {api_url}") + async with httpx.AsyncClient() as client: + response = await client.get(api_url, timeout=15.0) + if response.status_code != 200: + return None + + data = response.json() + + tracks_list = [] + show_meta = {} + + # Handle v2 (List of tracks? or Object with tracks?) + # Swagger says implementation differs. Based on curl, likely a List. + if isinstance(data, list): + tracks_list = data + if tracks_list: + show_meta = tracks_list[0] + elif isinstance(data, dict): + if 'data' in data: data = data['data'] + if 'tracks' in data: + tracks_list = data['tracks'] + show_meta = data + else: + # Maybe data IS the track list? + pass + + if not tracks_list: return None + + tracks = [] + # extracting metadata + venue = show_meta.get('venue_name', show_meta.get('venue', {}).get('name', 'Unknown Venue')) + show_date = show_meta.get('show_date', show_meta.get('date', date)) + + album_name = f"{show_date} - {venue}" + + for t in tracks_list: + # mp3 url is usually http, ensure https if possible or leave as is + mp3_url = t.get('mp3_url') or t.get('mp3') + if not mp3_url: continue + + safe_id = f"LINK:{base64.urlsafe_b64encode(mp3_url.encode()).decode()}" + duration_s = t.get('duration', 0) / 1000.0 if t.get('duration', 0) > 10000 else t.get('duration', 0) + # v2 duration seems to be ms? curl say 666600 (666s = 11m). So ms. + + tracks.append({ + 'id': safe_id, + 'name': t.get('title', 'Unknown'), + 'artists': 'Phish', + 'album': album_name, + 'album_art': t.get('show_album_cover_url', '/static/icon.svg'), + 'duration': f"{int(duration_s // 60)}:{int(duration_s % 60):02d}", + 'isrc': safe_id + }) + + if not tracks: return None + + return { + 'type': 'album', + 'id': f"LINK:{base64.urlsafe_b64encode(url.encode()).decode()}", + 'name': album_name, + 'artists': 'Phish', + 'image': tracks[0]['album_art'], + 'release_date': show_date, + 'tracks': tracks, + 'total_tracks': len(tracks), + 'is_custom': True + } + except Exception as e: + logger.error(f"Phish.in import error: {e}") + return None + + def _get_stream_url(self, url: str) -> Optional[str]: + """Get the actual stream URL from a page URL using yt-dlp. + For direct audio files (.mp3, .m4a, etc.), return as-is. + """ + # Check if URL is already a direct audio file + from urllib.parse import urlparse + parsed = urlparse(url) + path_lower = parsed.path.lower() + audio_extensions = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.flac', '.opus') + if any(path_lower.endswith(ext) for ext in audio_extensions): + logger.info(f"Direct audio URL detected, bypassing yt-dlp: {url[:60]}...") + return url + + + # Check cache + import time + now = time.time() + if url in self._stream_url_cache: + cached_url, expiry = self._stream_url_cache[url] + if now < expiry: + logger.info("Stream URL cache hit") + return cached_url + else: + del self._stream_url_cache[url] + + # Use yt-dlp for page URLs (YouTube, Bandcamp, etc.) + info = self._extract_info_safe(url) + if not info: return None + if 'entries' in info: info = info['entries'][0] + + stream_url = info.get('url') + if stream_url: + # Cache for 1 hour (Google URLs usually expire in ~4-6 hours) + self._stream_url_cache[url] = (stream_url, now + 3600) + + return stream_url + + + + + + # Simple in-memory cache for resolved stream URLs (to speed up seeking) + _stream_url_cache = {} # {url: (stream_url, expire_time)} + + def __init__(self): + # Enable redirect following and increase timeout + # Using a shared client with a connection pool to avoid socket exhaustion + limits = httpx.Limits(max_keepalive_connections=50, max_connections=100) + self.client = httpx.AsyncClient(timeout=60.0, follow_redirects=True, limits=limits) + + self.tidal_token: Optional[str] = None + self.working_api: Optional[str] = None # Cache the last working API + + async def get_tidal_token(self) -> str: + """Get Tidal access token.""" + if self.tidal_token: + return self.tidal_token + + response = await self.client.post( + "https://auth.tidal.com/v1/oauth2/token", + data={ + "client_id": self.TIDAL_CLIENT_ID, + "grant_type": "client_credentials" + }, + auth=(self.TIDAL_CLIENT_ID, self.TIDAL_CLIENT_SECRET) + ) + response.raise_for_status() + self.tidal_token = response.json()["access_token"] + return self.tidal_token + + async def search_tidal_by_isrc(self, isrc: str, query: str = "") -> Optional[Dict[str, Any]]: + """Search Tidal for a track by ISRC.""" + try: + token = await self.get_tidal_token() + search_query = query or isrc + + response = await self.client.get( + "https://api.tidal.com/v1/search/tracks", + headers={"Authorization": f"Bearer {token}"}, + params={ + "query": search_query, + "limit": 25, + "offset": 0, + "countryCode": "US" + } + ) + response.raise_for_status() + + data = response.json() + items = data.get("items", []) + + # Find by ISRC match + for item in items: + if item.get("isrc") == isrc: + return item + + # Fall back to first result + return items[0] if items else None + + except Exception as e: + logger.error(f"Tidal search error: {e}") + return None + + async def get_tidal_download_url_from_api(self, api_url: str, track_id: int, quality: str = "LOSSLESS") -> Optional[str]: + """Get download URL from a specific Tidal API.""" + import base64 + import json as json_module + + try: + full_url = f"{api_url}/track/?id={track_id}&quality={quality}" + logger.info(f"Trying API: {api_url}") + + response = await self.client.get(full_url, timeout=30.0) + + if response.status_code != 200: + logger.warning(f"API {api_url} returned {response.status_code}") + return None + + # Check if we got HTML instead of JSON + content_type = response.headers.get("content-type", "") + if "html" in content_type.lower(): + logger.warning(f"API {api_url} returned HTML instead of JSON") + return None + + try: + data = response.json() + except Exception: + logger.warning(f"API {api_url} returned invalid JSON") + return None + + # Handle API v2.0 format with manifest + if isinstance(data, dict) and "version" in data and "data" in data: + inner_data = data.get("data", {}) + manifest_b64 = inner_data.get("manifest") + + if manifest_b64: + try: + manifest_json = base64.b64decode(manifest_b64).decode('utf-8') + manifest = json_module.loads(manifest_json) + urls = manifest.get("urls", []) + + if urls: + download_url = urls[0] + logger.info(f"Got download URL from {api_url} (v2.0 manifest)") + self.working_api = api_url + return download_url + except Exception as e: + logger.warning(f"Failed to decode manifest from {api_url}: {e}") + + # Handle legacy format (list with OriginalTrackUrl) + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and "OriginalTrackUrl" in item: + logger.info(f"Got download URL from {api_url} (legacy format)") + self.working_api = api_url + return item["OriginalTrackUrl"] + + # Handle other dict formats + elif isinstance(data, dict): + if "OriginalTrackUrl" in data: + self.working_api = api_url + return data["OriginalTrackUrl"] + if "url" in data: + self.working_api = api_url + return data["url"] + + logger.warning(f"API {api_url} returned unexpected format") + return None + + except httpx.TimeoutException: + logger.warning(f"API {api_url} timed out") + return None + except Exception as e: + logger.warning(f"API {api_url} error: {e}") + return None + + async def update_tidal_apis(self): + """Update available Tidal APIs from status server.""" + try: + # Only update once per session to avoid delay + if hasattr(self, '_apis_updated') and self._apis_updated: + return + + logger.info("Updating Tidal API list...") + async with httpx.AsyncClient(timeout=5.0) as client: + async with client.stream("GET", "https://status.monochrome.tf/api/stream") as response: + async for line in response.aiter_lines(): + if line.startswith("data: "): + data = json.loads(line[6:]) + + api_instances = [ + inst for inst in data.get('instances', []) + if inst.get('instance_type') == 'api' and inst.get('last_check', {}).get('success') + ] + + # Sort by avg_response_time + api_instances.sort(key=lambda x: x.get('avg_response_time', 9999)) + + new_apis = [api['url'] for api in api_instances if api.get('url')] + + if new_apis: + global TIDAL_APIS + TIDAL_APIS = new_apis + self._apis_updated = True + logger.info(f"Updated Tidal API list with {len(new_apis)} servers") + break # Found data, done + except Exception as e: + logger.warning(f"Failed to update Tidal APIs: {e}") + + def embed_metadata(self, audio_data: bytes, format: str, metadata: Dict) -> bytes: + """Embed metadata into audio file (MP3/FLAC).""" + if not metadata: return audio_data + + logger.info(f"Embedding metadata for {format}: {metadata.get('title')} - {metadata.get('year')}") + + try: + suffix = ".flac" if format == "flac" else ".mp3" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(audio_data) + tmp_path = tmp.name + + if format == "flac": + audio = FLAC(tmp_path) + audio.clear_pictures() + + if metadata.get("title"): audio["TITLE"] = metadata["title"] + if metadata.get("artists"): audio["ARTIST"] = metadata["artists"] + if metadata.get("album"): audio["ALBUM"] = metadata["album"] + if metadata.get("year"): audio["DATE"] = str(metadata["year"])[:4] + if metadata.get("track_number"): audio["TRACKNUMBER"] = str(metadata["track_number"]) + + if metadata.get("album_art_data"): + picture = Picture() + picture.data = metadata["album_art_data"] + picture.type = PictureType.COVER_FRONT + picture.mime = "image/jpeg" + audio.add_picture(picture) + audio.save() + + elif format in ["mp3", "mp3_128"]: + try: + audio = MP3(tmp_path, ID3=ID3) + except: + audio = MP3(tmp_path) + audio.add_tags() + + # ID3 Tags + if metadata.get("title"): audio.tags.add(TIT2(encoding=3, text=metadata["title"])) + if metadata.get("artists"): audio.tags.add(TPE1(encoding=3, text=metadata["artists"])) + if metadata.get("album"): audio.tags.add(TALB(encoding=3, text=metadata["album"])) + if metadata.get("year"): audio.tags.add(TDRC(encoding=3, text=str(metadata["year"])[:4])) + if metadata.get("track_number"): audio.tags.add(TRCK(encoding=3, text=str(metadata["track_number"]))) + + if metadata.get("album_art_data"): + audio.tags.add( + APIC( + encoding=3, + mime='image/jpeg', + type=3, + desc='Cover', + data=metadata["album_art_data"] + ) + ) + audio.save() + + with open(tmp_path, 'rb') as f: + tagged_data = f.read() + + os.remove(tmp_path) + return tagged_data + + except Exception as e: + logger.error(f"Metadata tagging error: {e}") + if os.path.exists(tmp_path): os.remove(tmp_path) + return audio_data + + async def get_tidal_download_url(self, track_id: int, quality: str = "LOSSLESS") -> Optional[str]: + """Get download URL from Tidal APIs with fallback.""" + + # Update APIs first + await self.update_tidal_apis() + + # Build API list with the last working API first + apis_to_try = list(TIDAL_APIS) + if self.working_api and self.working_api in apis_to_try: + apis_to_try.remove(self.working_api) + apis_to_try.insert(0, self.working_api) + + # Try each API until one works + for api_url in apis_to_try: + download_url = await self.get_tidal_download_url_from_api(api_url, track_id, quality) + if download_url: + return download_url + + logger.error("All Tidal APIs failed") + return None + + async def _fetch_tidal_cover(self, cover_uuid: str) -> Optional[bytes]: + """Fetch Tidal album art.""" + try: + url = f"https://resources.tidal.com/images/{cover_uuid.replace('-', '/')}/1280x1280.jpg" + response = await self.client.get(url) + if response.status_code == 200: + return response.content + except Exception: + pass + return None + + async def get_deezer_track_info(self, isrc: str) -> Optional[Dict]: + """Get Deezer track info from ISRC.""" + try: + response = await self.client.get( + f"https://api.deezer.com/2.0/track/isrc:{isrc}" + ) + if response.status_code == 200: + data = response.json() + if "error" not in data: + return data + return None + except Exception as e: + logger.error(f"Deezer lookup error: {e}") + return None + + async def get_deezer_download_url(self, track_id: int) -> Optional[str]: + """Get FLAC download URL from Deezer API.""" + try: + response = await self.client.get( + f"{DEEZER_API_URL}/dl/{track_id}", + timeout=30.0 + ) + + if response.status_code != 200: + logger.warning(f"Deezer API returned {response.status_code}") + return None + + data = response.json() + if data.get("success"): + return data.get("links", {}).get("flac") + + return None + + except Exception as e: + logger.error(f"Deezer download URL error: {e}") + return None + + async def fetch_tidal_metadata(self, track: Dict) -> Dict: + """Extract metadata from Tidal track object.""" + try: + album = track.get("album", {}) + artist = track.get("artist", {}) + if not artist and track.get("artists"): + artist = track.get("artists")[0] + + cover_uuid = album.get("cover") + album_art_data = None + if cover_uuid: + album_art_data = await self._fetch_tidal_cover(cover_uuid) + + return { + "title": track.get("title"), + "artist": artist.get("name"), + "artists": artist.get("name"), # For embed_metadata + "album": album.get("title"), + "year": track.get("releaseDate", "")[:4], + "track_number": track.get("trackNumber"), + "album_art_data": album_art_data, + "album_art_url": None + } + except Exception as e: + logger.error(f"Metadata extraction error: {e}") + return {} + + async def fetch_flac(self, isrc: str, query: str = "", hires: bool = True) -> Optional[Union[tuple[bytes, Dict], tuple[str, Dict]]]: + """Fetch FLAC audio and metadata from Tidal or Deezer (with fallback).""" + + deezer_info = None # Cache for potential metadata use + + # Handle Deezer track IDs (dz_XXXXX format) - extract ISRC first + if isrc.startswith("dz_"): + deezer_track_id = isrc.replace("dz_", "") + logger.info(f"Deezer track ID detected: {deezer_track_id}") + + try: + # Fetch track info from Deezer public API to get ISRC + response = await self.client.get( + f"https://api.deezer.com/track/{deezer_track_id}" + ) + if response.status_code == 200: + deezer_info = response.json() + if "error" not in deezer_info: + extracted_isrc = deezer_info.get("isrc") + if extracted_isrc: + logger.info(f"Extracted ISRC from Deezer: {extracted_isrc}") + isrc = extracted_isrc # Use real ISRC for Tidal lookup + query = query or f"{deezer_info.get('title', '')} {deezer_info.get('artist', {}).get('name', '')}" + else: + logger.warning("No ISRC in Deezer track - will try Deezer download directly") + except Exception as e: + logger.error(f"Deezer track info fetch error: {e}") + + # Handle query: prefixed IDs (from ListenBrainz playlists) + # These are searchable by artist + title, no ISRC available + if isrc.startswith("query:"): + query = isrc.replace("query:", "") + isrc = "" # Clear ISRC, will search by query only + logger.info(f"ListenBrainz track - searching by query: {query}") + + + # 0. Try Dab Music (Qobuz Hi-Res Proxy) - New! Priority #1 + try: + from app.dab_service import dab_service + + dab_id = None + dab_track = None # Initialize to avoid UnboundLocalError + + if isrc.startswith("dab_"): + dab_id = isrc + else: + # Search prioritization: + # Prefer standard query (Artist + Title) because ISRC support on Dab is flaky. + dab_query = query + if not dab_query and isrc and not isrc.startswith("dz_"): + dab_query = f"isrc:{isrc}" + + if dab_query: + dab_tracks = await dab_service.search_tracks(dab_query, limit=1) + if dab_tracks: + dab_track = dab_tracks[0] + dab_id = dab_track.get('id') + + # Optional: Verify title match if using query? + # For now assume top result is correct as Dab/Qobuz search is usually okay for metadata. + + if dab_id: + # Select quality + quality = "27" if hires else "7" + stream_url = await dab_service.get_stream_url(dab_id, quality=quality) + + if stream_url: + logger.info(f"Dab Stream URL found: {stream_url[:40]}...") + + # Metadata gathering + # Use Dab metadata if available, otherwise fallback to query string + if dab_track: + metadata = { + "title": dab_track.get("title"), + "artist": dab_track.get("artist"), + "album": dab_track.get("albumTitle"), + "year": dab_track.get("releaseDate", "")[:4] if dab_track.get("releaseDate") else "", + "album_art_url": dab_track.get("albumCover"), + "album_art_data": None + } + # Ensure artist/album are strings + if isinstance(metadata["artist"], dict): metadata["artist"] = metadata["artist"].get("name") + if isinstance(metadata["album"], dict): metadata["album"] = metadata["album"].get("title") + else: + # Fallback: extract from query string (format: "Title Artist") + parts = query.split(" ") if query else [] + metadata = { + "title": query or "Unknown", + "artist": "", + "album": "", + "year": "", + "album_art_url": None, + "album_art_data": None + } + + metadata["is_hi_res"] = True + return (stream_url, metadata) + except Exception as e: + logger.error(f"Dab Music fetch error: {e}") + + # 1. Try Tidal (Primary Source) + if not isrc.startswith("dz_"): # Only if we have a real ISRC + logger.info(f"Trying Tidal APIs for ISRC: {isrc}") + tidal_track = await self.search_tidal_by_isrc(isrc, query) + + if tidal_track: + track_id = tidal_track.get("id") + download_url = await self.get_tidal_download_url(track_id) + + if download_url: + logger.info(f"Downloading from Tidal: {download_url[:80]}...") + try: + response = await self.client.get(download_url, timeout=180.0) + if response.status_code == 200: + size_mb = len(response.content) / 1024 / 1024 + logger.info(f"Downloaded {size_mb:.2f} MB from Tidal") + + meta = { + "title": tidal_track.get("title"), + "artists": [a["name"] for a in tidal_track.get("artists", [])], + "album": tidal_track.get("album", {}).get("title"), + "year": tidal_track.get("album", {}).get("releaseDate"), + "track_number": tidal_track.get("trackNumber"), + } + cover_uuid = tidal_track.get("album", {}).get("cover") + if cover_uuid: + meta["album_art_data"] = await self._fetch_tidal_cover(cover_uuid) + + return (response.content, meta) + except Exception as e: + logger.error(f"Tidal download error: {e}") + else: + logger.warning(f"Tidal search returned no results for: {isrc}") + + # Fallback to Deezer FLAC download (deezmate API) + logger.info(f"Falling back to Deezer for: {isrc or query}") + + # If we have cached deezer_info from above, use it; otherwise fetch + if not deezer_info and isrc.startswith("dz_"): + deezer_track_id = isrc.replace("dz_", "") + try: + response = await self.client.get(f"https://api.deezer.com/track/{deezer_track_id}") + if response.status_code == 200: + deezer_info = response.json() + except: + pass + elif not deezer_info and isrc: + # Lookup by ISRC + deezer_info = await self.get_deezer_track_info(isrc) + elif not deezer_info and query: + # No ISRC - search by query (for ListenBrainz tracks) + try: + response = await self.client.get( + "https://api.deezer.com/search/track", + params={"q": query, "limit": 1} + ) + if response.status_code == 200: + data = response.json() + tracks = data.get("data", []) + if tracks: + deezer_info = tracks[0] + logger.info(f"Deezer search found: {deezer_info.get('title')} by {deezer_info.get('artist', {}).get('name')}") + except Exception as e: + logger.error(f"Deezer search error: {e}") + + if deezer_info and "error" not in deezer_info: + deezer_id = deezer_info.get("id") + download_url = await self.get_deezer_download_url(deezer_id) + + if download_url: + logger.info(f"Downloading from Deezer (deezmate)...") + try: + response = await self.client.get(download_url, timeout=180.0) + if response.status_code == 200: + logger.info(f"Downloaded {len(response.content) / 1024 / 1024:.2f} MB from Deezer") + + meta = { + "title": deezer_info.get("title"), + "artists": [a["name"] for a in deezer_info.get("contributors", [])] or [deezer_info.get("artist", {}).get("name")], + "album": deezer_info.get("album", {}).get("title"), + "year": deezer_info.get("release_date"), + "track_number": deezer_info.get("track_position"), + } + cover_url = deezer_info.get("album", {}).get("cover_xl") + if cover_url: + try: + cover_resp = await self.client.get(cover_url) + if cover_resp.status_code == 200: + meta["album_art_data"] = cover_resp.content + except: pass + + return (response.content, meta) + except Exception as e: + logger.error(f"Deezer download error: {e}") + + logger.error(f"Could not fetch audio for: {isrc}") + return None + + def transcode_to_mp3(self, flac_data: bytes, bitrate: str = BITRATE) -> Optional[bytes]: + """Transcode FLAC to MP3 using FFmpeg.""" + try: + # Use FFmpeg with stdin/stdout for streaming + process = subprocess.Popen( + [ + FFMPEG_PATH, + "-i", "pipe:0", # Read from stdin + "-vn", # No video + "-acodec", "libmp3lame", # MP3 encoder + "-b:a", bitrate, # Bitrate + "-f", "mp3", # Output format + "pipe:1" # Write to stdout + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + mp3_data, stderr = process.communicate(input=flac_data) + + if process.returncode != 0: + logger.error(f"FFmpeg error: {stderr.decode()[:500]}") + return None + + logger.info(f"Transcoded to MP3: {len(mp3_data) / 1024 / 1024:.2f} MB") + return mp3_data + + except FileNotFoundError: + logger.error("FFmpeg not found! Please install FFmpeg.") + return None + except Exception as e: + logger.error(f"Transcode error: {e}") + return None + + async def get_audio_stream(self, isrc: str, query: str = "") -> Optional[bytes]: + """Get transcoded MP3 audio, using cache if available.""" + + # Check cache first + if is_cached(isrc, "mp3"): + logger.info(f"Cache hit for {isrc}") + cached_data = await get_cached_file(isrc, "mp3") + if cached_data: + return cached_data + + + + # Fetch and transcode + logger.info(f"Cache miss for {isrc}, fetching...") + result = await self.fetch_flac(isrc, query) + + if not result: + return None + + flac_data, metadata = result + + # Transcode (run in executor to not block) + loop = asyncio.get_event_loop() + mp3_data = await loop.run_in_executor(None, self.transcode_to_mp3, flac_data) + + if mp3_data: + # Cache the result + await cache_file(isrc, mp3_data, "mp3") + + return mp3_data + + + + # Format configurations for FFmpeg + FORMAT_CONFIG = { + "mp3": { + "ext": ".mp3", + "mime": "audio/mpeg", + "args": ["-acodec", "libmp3lame", "-b:a", "320k", "-f", "mp3"] + }, + "mp3_128": { + "ext": ".mp3", + "mime": "audio/mpeg", + "args": ["-acodec", "libmp3lame", "-b:a", "128k", "-f", "mp3"] + }, + "flac": { + "ext": ".flac", + "mime": "audio/flac", + "args": ["-acodec", "flac", "-sample_fmt", "s16", "-f", "flac"] # Force 16-bit + }, + "flac_24": { + "ext": ".flac", + "mime": "audio/flac", + "args": ["-acodec", "flac", "-sample_fmt", "s32", "-f", "flac"] # 24-bit preserved + }, + "aiff": { + "ext": ".aiff", + "mime": "audio/aiff", + "args": ["-acodec", "pcm_s16be", "-f", "aiff"] + }, + "wav": { + "ext": ".wav", + "mime": "audio/wav", + "args": ["-acodec", "pcm_s16le", "-f", "wav"] + }, + "wav_24": { + "ext": ".wav", + "mime": "audio/wav", + "args": ["-acodec", "pcm_s24le", "-f", "wav"] + }, + "alac": { + "ext": ".m4a", + "mime": "audio/mp4", + "args": ["-acodec", "alac", "-f", "ipod"] + }, + "aiff_24": { + "ext": ".aiff", + "mime": "audio/aiff", + "args": ["-acodec", "pcm_s24be", "-f", "aiff"] + } + } + + def transcode_to_format(self, flac_data: bytes, format: str = "mp3") -> Optional[bytes]: + """Transcode FLAC to specified format using FFmpeg.""" + config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"]) + + try: + logger.info(f"Transcoding to {format} using FFmpeg at: {FFMPEG_PATH}") + # Note: flac_24 goes through FFmpeg to ensure proper sample format + + cmd = [ + FFMPEG_PATH, + "-i", "pipe:0", # Read from stdin + "-vn", # No video + ] + config["args"] + [ + "pipe:1" # Write to stdout + ] + + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + output_data, stderr = process.communicate(input=flac_data) + + if process.returncode != 0: + logger.error(f"FFmpeg error: {stderr.decode()[:500]}") + return None + + logger.info(f"Transcoded to {format}: {len(output_data) / 1024 / 1024:.2f} MB") + return output_data + + except FileNotFoundError: + logger.error("FFmpeg not found!") + return None + except Exception as e: + logger.error(f"Transcode error: {e}") + return None + + async def get_download_audio(self, isrc: str, query: str, format: str = "mp3") -> Optional[tuple]: + """Get audio in specified format for download. Returns (data, extension, mime_type).""" + + config = self.FORMAT_CONFIG.get(format, self.FORMAT_CONFIG["mp3"]) + cache_ext = format if format != "mp3_128" else "mp3_128" + + # Skip cache for downloads to ensure we get fresh metadata + # if is_cached(isrc, cache_ext): + # ... + + # Fetch FLAC + logger.info(f"Fetching audio for download (skipping cache to ensure tags): {isrc}") + + # Handle Imported Links + if isrc.startswith("LINK:"): + # ... (existing link handling) + return None # Todo: handle link tagging similarly if possible + + result = await self.fetch_flac(isrc, query) + + if not result: + return None + + flac_data, metadata = result + + # Enrich metadata with MusicBrainz (release year, label, better cover art) + try: + from app.musicbrainz_service import musicbrainz_service + mb_data = await musicbrainz_service.lookup_by_isrc(isrc) + if mb_data: + # Fill in missing fields from MusicBrainz + if not metadata.get("year") and mb_data.get("release_date"): + metadata["year"] = mb_data["release_date"] + if mb_data.get("label"): + metadata["label"] = mb_data["label"] + # Use MusicBrainz cover art if we don't have one + if not metadata.get("album_art_data") and mb_data.get("cover_art_url"): + try: + cover_resp = await self.client.get(mb_data["cover_art_url"]) + if cover_resp.status_code == 200: + metadata["album_art_data"] = cover_resp.content + logger.info("Using cover art from Cover Art Archive") + except: pass + except Exception as e: + logger.debug(f"MusicBrainz enrichment skipped: {e}") + + # Transcode/Passthrough + loop = asyncio.get_event_loop() + output_data = await loop.run_in_executor( + None, self.transcode_to_format, flac_data, format + ) + + if output_data: + # Embed Metadata + output_data = await loop.run_in_executor( + None, self.embed_metadata, output_data, format, metadata + ) + + # Cache it? Maybe not since it has user-specific tags? + # Actually standard tags are fine. + # await cache_file(isrc, output_data, cache_ext) + return (output_data, config["ext"], config["mime"]) + + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + # Close Dab Service + try: + from app.dab_service import dab_service + await dab_service.close() + except: pass + + +# Singleton instance +audio_service = AudioService() diff --git a/app/cache.py b/app/cache.py new file mode 100644 index 0000000..3a19118 --- /dev/null +++ b/app/cache.py @@ -0,0 +1,133 @@ +""" +Cache service for storing transcoded audio files. +Implements auto-cleanup to stay within storage limits. +""" +import os +import time +import asyncio +import aiofiles +from pathlib import Path +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + +# Cache configuration +CACHE_DIR = Path(os.environ.get("CACHE_DIR", "/tmp/spotiflac_cache")) +MAX_CACHE_SIZE_MB = int(os.environ.get("MAX_CACHE_SIZE_MB", "500")) +CACHE_TTL_HOURS = int(os.environ.get("CACHE_TTL_HOURS", "24")) + + +def ensure_cache_dir(): + """Ensure cache directory exists.""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + return CACHE_DIR + + +def get_cache_path(isrc: str, format: str = "mp3") -> Path: + """Get the cache file path for a given ISRC. + + For LINK: prefixed IDs (which can be very long base64 strings), + we hash the ID to create a shorter, valid filename. + """ + import hashlib + ensure_cache_dir() + + # Hash long IDs to prevent "filename too long" errors + if len(isrc) > 100 or isrc.startswith("LINK:"): + safe_name = hashlib.md5(isrc.encode()).hexdigest() + else: + # Sanitize the ISRC for use as filename + safe_name = isrc.replace("/", "_").replace(":", "_") + + return CACHE_DIR / f"{safe_name}.{format}" + + +def is_cached(isrc: str, format: str = "mp3") -> bool: + """Check if a track is cached.""" + cache_path = get_cache_path(isrc, format) + return cache_path.exists() and cache_path.stat().st_size > 0 + + +async def get_cached_file(isrc: str, format: str = "mp3") -> Optional[bytes]: + """Retrieve a cached file if it exists.""" + cache_path = get_cache_path(isrc, format) + if cache_path.exists(): + try: + # Update access time + cache_path.touch() + async with aiofiles.open(cache_path, 'rb') as f: + return await f.read() + except Exception as e: + logger.error(f"Error reading cache for {isrc}: {e}") + return None + + +async def cache_file(isrc: str, data: bytes, format: str = "mp3") -> bool: + """Cache a transcoded file.""" + try: + cache_path = get_cache_path(isrc, format) + async with aiofiles.open(cache_path, 'wb') as f: + await f.write(data) + logger.info(f"Cached {isrc}.{format} ({len(data) / 1024 / 1024:.2f} MB)") + return True + except Exception as e: + logger.error(f"Error caching {isrc}: {e}") + return False + + +def get_cache_size_mb() -> float: + """Get total cache size in MB.""" + ensure_cache_dir() + total = sum(f.stat().st_size for f in CACHE_DIR.iterdir() if f.is_file()) + return total / 1024 / 1024 + + +async def cleanup_cache(): + """Remove old files to stay within cache limits.""" + ensure_cache_dir() + now = time.time() + ttl_seconds = CACHE_TTL_HOURS * 3600 + max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024 + + files = [] + for f in CACHE_DIR.iterdir(): + if f.is_file(): + stat = f.stat() + files.append({ + 'path': f, + 'size': stat.st_size, + 'atime': stat.st_atime + }) + + # Remove files older than TTL + for file_info in files[:]: + if now - file_info['atime'] > ttl_seconds: + try: + file_info['path'].unlink() + files.remove(file_info) + logger.info(f"Removed expired cache file: {file_info['path'].name}") + except Exception as e: + logger.error(f"Error removing {file_info['path']}: {e}") + + # If still over limit, remove oldest files + files.sort(key=lambda x: x['atime']) + total_size = sum(f['size'] for f in files) + + while total_size > max_bytes and files: + oldest = files.pop(0) + try: + oldest['path'].unlink() + total_size -= oldest['size'] + logger.info(f"Removed cache file to free space: {oldest['path'].name}") + except Exception as e: + logger.error(f"Error removing {oldest['path']}: {e}") + + logger.info(f"Cache size after cleanup: {total_size / 1024 / 1024:.2f} MB") + + +async def periodic_cleanup(interval_minutes: int = 30): + """Run cache cleanup periodically.""" + while True: + await asyncio.sleep(interval_minutes * 60) + await cleanup_cache() diff --git a/app/concert_service.py b/app/concert_service.py new file mode 100644 index 0000000..e26ce9c --- /dev/null +++ b/app/concert_service.py @@ -0,0 +1,324 @@ +""" +Concert Service - Ticketmaster Discovery API + SeatGeek fallback +Provides upcoming concert search for artists +""" + +import os +import httpx +import logging +from typing import List, Dict, Optional, Any +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# API Configuration +TICKETMASTER_API_KEY = os.getenv("TICKETMASTER_API_KEY", "") +SEATGEEK_CLIENT_ID = os.getenv("SEATGEEK_CLIENT_ID", "") + +TICKETMASTER_BASE = "https://app.ticketmaster.com/discovery/v2" +SEATGEEK_BASE = "https://api.seatgeek.com/2" + + +class ConcertService: + """Service for fetching upcoming concerts from Ticketmaster and SeatGeek.""" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + + async def search_ticketmaster( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search Ticketmaster Discovery API for events. + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + if not TICKETMASTER_API_KEY: + logger.warning("TICKETMASTER_API_KEY not set") + return [] + + try: + params = { + "apikey": TICKETMASTER_API_KEY, + "keyword": artist, + "classificationName": "music", + "size": limit, + "sort": "date,asc" + } + + # Normalize city name (remove "City" suffix, common variants) + if city: + normalized_city = city.replace(" City", "").replace(" city", "").strip() + params["city"] = normalized_city + + response = await self.client.get( + f"{TICKETMASTER_BASE}/events.json", + params=params + ) + + if response.status_code != 200: + logger.error(f"Ticketmaster API error: {response.status_code}") + return [] + + data = response.json() + events = data.get("_embedded", {}).get("events", []) + + logger.info(f"Ticketmaster returned {len(events)} events for '{artist}'") + + # If no events found with city filter, try without + if not events and city: + logger.info(f"No events with city filter, trying without...") + del params["city"] + response = await self.client.get( + f"{TICKETMASTER_BASE}/events.json", + params=params + ) + if response.status_code == 200: + data = response.json() + events = data.get("_embedded", {}).get("events", []) + logger.info(f"Ticketmaster (no city) returned {len(events)} events") + + return [self._normalize_ticketmaster_event(e) for e in events] + + except Exception as e: + logger.error(f"Ticketmaster search error: {e}") + return [] + + def _normalize_ticketmaster_event(self, event: Dict) -> Dict[str, Any]: + """Convert Ticketmaster event to normalized format.""" + # Get venue info + venues = event.get("_embedded", {}).get("venues", []) + venue = venues[0] if venues else {} + + # Get date/time + dates = event.get("dates", {}) + start = dates.get("start", {}) + + # Get price range + price_ranges = event.get("priceRanges", []) + price = price_ranges[0] if price_ranges else {} + + # Get image + images = event.get("images", []) + image = next((img["url"] for img in images if img.get("ratio") == "16_9"), None) + if not image and images: + image = images[0].get("url") + + # Get artist name from attractions + attractions = event.get("_embedded", {}).get("attractions", []) + artist_name = attractions[0].get("name") if attractions else event.get("name", "") + + return { + "id": event.get("id", ""), + "name": event.get("name", ""), + "artist": artist_name, + "venue": venue.get("name", "Unknown Venue"), + "city": venue.get("city", {}).get("name", ""), + "state": venue.get("state", {}).get("stateCode", ""), + "country": venue.get("country", {}).get("countryCode", ""), + "date": start.get("localDate", ""), + "time": start.get("localTime", ""), + "ticket_url": event.get("url", ""), + "price_min": price.get("min"), + "price_max": price.get("max"), + "currency": price.get("currency", "USD"), + "image": image, + "source": "ticketmaster" + } + + async def search_seatgeek( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search SeatGeek API for events (fallback). + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + if not SEATGEEK_CLIENT_ID: + logger.warning("SEATGEEK_CLIENT_ID not set") + return [] + + try: + # Use performers.slug for better matching (slugify artist name) + artist_slug = artist.lower().replace(" ", "-").replace("'", "") + + params = { + "client_id": SEATGEEK_CLIENT_ID, + "performers.slug": artist_slug, + "per_page": limit, + "sort": "datetime_utc.asc" + } + + response = await self.client.get( + f"{SEATGEEK_BASE}/events", + params=params + ) + + if response.status_code != 200: + logger.error(f"SeatGeek API error: {response.status_code}") + return [] + + data = response.json() + events = data.get("events", []) + + logger.info(f"SeatGeek returned {len(events)} events for '{artist}'") + + # If no results with slug, try keyword search + if not events: + logger.info(f"SeatGeek slug search failed, trying q=") + params = { + "client_id": SEATGEEK_CLIENT_ID, + "q": artist, + "type": "concert", + "per_page": limit, + "sort": "datetime_utc.asc" + } + response = await self.client.get( + f"{SEATGEEK_BASE}/events", + params=params + ) + if response.status_code == 200: + data = response.json() + events = data.get("events", []) + logger.info(f"SeatGeek (q=) returned {len(events)} events") + + return [self._normalize_seatgeek_event(e) for e in events] + + except Exception as e: + logger.error(f"SeatGeek search error: {e}") + return [] + + def _normalize_seatgeek_event(self, event: Dict) -> Dict[str, Any]: + """Convert SeatGeek event to normalized format.""" + venue = event.get("venue", {}) + performers = event.get("performers", []) + performer = performers[0] if performers else {} + + # Parse datetime + datetime_utc = event.get("datetime_utc", "") + date_str = "" + time_str = "" + if datetime_utc: + try: + dt = datetime.fromisoformat(datetime_utc.replace("Z", "+00:00")) + date_str = dt.strftime("%Y-%m-%d") + time_str = dt.strftime("%H:%M:%S") + except: + pass + + # Get stats for pricing + stats = event.get("stats", {}) + + return { + "id": str(event.get("id", "")), + "name": event.get("title", ""), + "artist": performer.get("name", event.get("title", "")), + "venue": venue.get("name", "Unknown Venue"), + "city": venue.get("city", ""), + "state": venue.get("state", ""), + "country": venue.get("country", ""), + "date": date_str, + "time": time_str, + "ticket_url": event.get("url", ""), + "price_min": stats.get("lowest_price"), + "price_max": stats.get("highest_price"), + "currency": "USD", + "image": performer.get("image"), + "source": "seatgeek" + } + + async def search_events( + self, + artist: str, + city: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Search for events with Ticketmaster primary, SeatGeek fallback. + + Args: + artist: Artist name to search + city: Optional city to filter by + limit: Max results to return + + Returns: + List of normalized event objects + """ + # Try Ticketmaster first + events = await self.search_ticketmaster(artist, city, limit) + + # If no results or Ticketmaster unavailable, try SeatGeek + if not events: + logger.info(f"Falling back to SeatGeek for: {artist}") + events = await self.search_seatgeek(artist, city, limit) + + return events + + async def get_events_for_artists( + self, + artists: List[str], + cities: Optional[List[str]] = None, + limit_per_artist: int = 5 + ) -> List[Dict[str, Any]]: + """ + Get upcoming events for multiple artists. + + Args: + artists: List of artist names + cities: Optional list of cities to filter by + limit_per_artist: Max events per artist + + Returns: + List of all events, sorted by date + """ + all_events = [] + + for artist in artists[:10]: # Limit to 10 artists to avoid rate limits + if cities: + # Search each city + for city in cities[:3]: # Limit to 3 cities + events = await self.search_events(artist, city, limit_per_artist) + all_events.extend(events) + else: + events = await self.search_events(artist, None, limit_per_artist) + all_events.extend(events) + + # Deduplicate by event ID + seen_ids = set() + unique_events = [] + for event in all_events: + event_id = f"{event['source']}_{event['id']}" + if event_id not in seen_ids: + seen_ids.add(event_id) + unique_events.append(event) + + # Sort by date + unique_events.sort(key=lambda e: e.get("date", "9999-99-99")) + + return unique_events + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +concert_service = ConcertService() diff --git a/app/dab_service.py b/app/dab_service.py new file mode 100644 index 0000000..4b84b29 --- /dev/null +++ b/app/dab_service.py @@ -0,0 +1,258 @@ +""" +Dab Music Service +Retrieves Hi-Res audio from Dab Music API. +""" +import httpx +import logging +from typing import Optional, List, Dict, Any +import os + +logger = logging.getLogger(__name__) + +class DabService: + BASE_URL = "https://dabmusic.xyz/api" + + def __init__(self): + self._initialized = False + self.client = None + self.session_token = "" + self.visitor_id = "" + + def _ensure_initialized(self): + """Lazy initialization - loads credentials on first use, not import time.""" + if self._initialized: + return + + # Load credentials at runtime (not import time) for cloud deployment compatibility + self.session_token = os.getenv("DAB_SESSION", "") + self.visitor_id = os.getenv("DAB_VISITOR_ID", "") + + # Debug: Log if credentials are present (not the actual values) + if self.session_token: + logger.info(f"Dab credentials loaded: session={len(self.session_token)} chars, visitor={len(self.visitor_id)} chars") + else: + logger.warning("Dab credentials not found - Hi-Res streaming will be unavailable") + + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://dabmusic.xyz/", + "Origin": "https://dabmusic.xyz" + } + self.cookies = { + "session": self.session_token + } + + self.client = httpx.AsyncClient( + headers=self.headers, + cookies=self.cookies, + timeout=15.0, + follow_redirects=True + ) + self._initialized = True + + async def search_tracks(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + """Search for tracks on Dab Music.""" + self._ensure_initialized() + try: + resp = await self.client.get( + f"{self.BASE_URL}/search", + params={"q": query, "type": "track", "limit": limit} + ) + if resp.status_code == 200: + data = resp.json() + tracks = data.get("tracks", []) + + # Debug logging + if tracks and len(tracks) > 0: + logger.info(f"Dab Search Track 0: {type(tracks[0])} - {str(tracks[0])[:50]}...") + + return [self._format_track(t) for t in tracks if isinstance(t, dict)] + elif resp.status_code == 401: + logger.error("Dab API Unauthorized - Token may have expired") + else: + logger.warning(f"Dab search failed: {resp.status_code}") + return [] + except Exception as e: + logger.error(f"Dab search error: {e}") + return [] + + async def search_albums(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: + """Search for albums on Dab Music.""" + self._ensure_initialized() + try: + resp = await self.client.get( + f"{self.BASE_URL}/search", + params={"q": query, "type": "album", "limit": limit} + ) + if resp.status_code == 200: + data = resp.json() + albums = data.get("albums", []) + + # Debug logging + if albums and len(albums) > 0: + logger.info(f"Dab Search Album 0: {type(albums[0])} - {albums[0]}") + + return [self._format_album(a) for a in albums if isinstance(a, dict)] + return [] + except Exception as e: + logger.error(f"Dab album search error: {e}") + return [] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album details with tracks.""" + self._ensure_initialized() + try: + clean_id = album_id.replace("dab_", "") + # Try getAlbum endpoint first (based on test results, /getAlbum works usually if /album fails) + # Actually test showed 404 for all... Wait, I need to be careful. + # But earlier test showed /album and /getAlbum failing with 400/404. + # BUT web interface uses: /api/get?id=... (from observation usually) + # Or /api/album?id=... + # I will try /getAlbum based on typical Qobuz proxies, or fallback to search if needed? + # actually if the test failed, I might need to rely on what I saw in other code or assume /getAlbum or /album. + # Let's try /getAlbum with 'albumId' param as that is specific to Dab often. + + resp = await self.client.get(f"{self.BASE_URL}/getAlbum", params={"albumId": clean_id}) + if resp.status_code != 200: + resp = await self.client.get(f"{self.BASE_URL}/album", params={"albumId": clean_id}) + + if resp.status_code == 200: + data = resp.json() + + # Check for nested 'album' key which is common in getAlbum/album endpoints + album_data = data.get("album", data) + + album = self._format_album(album_data) + + tracks = [] + # Tracks are usually inside the album object or 'tracks' key + raw_tracks = album_data.get("tracks", []) + # Sometimes tracks are wrapped in 'items' + if isinstance(raw_tracks, dict) and "items" in raw_tracks: + raw_tracks = raw_tracks["items"] + elif not isinstance(raw_tracks, list): + raw_tracks = [] + + tracks = [self._format_track(t, album_info=album_data) for t in raw_tracks] + album["tracks"] = tracks + return album + + logger.warning(f"Dab get_album failed: {resp.status_code}") + return None + except Exception as e: + logger.error(f"Dab get_album error: {e}") + return None + + def _format_track(self, item: dict, album_info: dict = None) -> dict: + """Format Dab track to frontend schema.""" + # Clean ID + track_id = str(item.get("id")) + + # Album info might come from item or parent + alb_title = item.get("albumTitle") or item.get("album", {}).get("title") + if album_info: alb_title = alb_title or album_info.get("title") + + alb_cover = item.get("albumCover") or item.get("album", {}).get("cover") + if not alb_cover and album_info: + alb_cover = album_info.get("image", {}).get("large") or album_info.get("cover") + + # Artist + artist_obj = item.get("artist") + if isinstance(artist_obj, dict): + artist_name = artist_obj.get("name") + else: + artist_name = artist_obj + + if not artist_name and album_info: + artist_val = album_info.get("artist") + if isinstance(artist_val, dict): + artist_name = artist_val.get("name") + else: + artist_name = artist_val + + return { + "id": f"dab_{track_id}", + "type": "track", + "name": item.get("title", "Unknown"), + "artists": artist_name, + "artist_names": [artist_name], + "album": alb_title, + "album_id": f"dab_{item.get('albumId') or (album_info['id'] if album_info else '')}", + "album_art": alb_cover, + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), # Dab often provides isrc + "release_date": item.get("releaseDate", ""), + "source": "dab", + "is_hi_res": item.get("audioQuality", {}).get("isHiRes", False) + } + + def _format_album(self, item: dict) -> dict: + """Format Dab album to frontend schema.""" + # Extract images + images = item.get("images", {}) + cover = None + if isinstance(images, dict): + cover = images.get("large") or images.get("medium") + + if not cover: cover = item.get("cover") # Fallback to top level + + if isinstance(cover, dict): cover = cover.get("large") # Handle nested cases + + # Handle Artist + artist_obj = item.get("artist") + if isinstance(artist_obj, dict): + artist_name = artist_obj.get("name") + else: + artist_name = artist_obj + + # Extract audio quality info + audio_quality = item.get("audioQuality", {}) + + return { + "id": f"dab_{item.get('id')}", + "type": "album", + "name": item.get("title", ""), + "artists": artist_name, + "album_art": cover, + "release_date": item.get("releaseDate", ""), + "total_tracks": item.get("trackCount", 0), + "source": "dab", + "is_hi_res": audio_quality.get("isHiRes", False), + "audio_quality": { + "maximumBitDepth": audio_quality.get("maximumBitDepth", 16), + "maximumSamplingRate": audio_quality.get("maximumSamplingRate", 44.1), + "isHiRes": audio_quality.get("isHiRes", False) + }, + "format": "FLAC" if audio_quality.get("isHiRes", False) else "FLAC" + } + + def _format_duration(self, ms: int) -> str: + seconds = int(ms // 1000) + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def get_stream_url(self, track_id: str, quality: str = "27") -> Optional[str]: + """Get stream URL for a track. Quality 27=Hi-Res, 7=Lossless.""" + self._ensure_initialized() + try: + clean_id = str(track_id).replace("dab_", "") + resp = await self.client.get( + f"{self.BASE_URL}/stream", + params={"trackId": clean_id, "quality": quality} + ) + if resp.status_code == 200: + data = resp.json() + return data.get("url") + logger.warning(f"Dab stream fetch failed: {resp.status_code} - {resp.text}") + return None + except Exception as e: + logger.error(f"Dab stream error: {e}") + return None + + async def close(self): + await self.client.aclose() + +# Singleton +dab_service = DabService() diff --git a/app/deezer_service.py b/app/deezer_service.py new file mode 100644 index 0000000..a95473e --- /dev/null +++ b/app/deezer_service.py @@ -0,0 +1,160 @@ +""" +Deezer service for Freedify. +Provides search (tracks, albums, artists) as fallback when Spotify is rate limited. +Deezer API is free and doesn't require authentication for basic searches. +""" +import httpx +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class DeezerService: + """Service for searching and fetching metadata from Deezer.""" + + API_BASE = "https://api.deezer.com" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make API request to Deezer.""" + response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params) + response.raise_for_status() + return response.json() + + # ========== TRACK METHODS ========== + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for tracks.""" + data = await self._api_request("/search/track", {"q": query, "limit": limit, "index": offset}) + return [self._format_track(item) for item in data.get("data", [])] + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend (matching Spotify format).""" + album = item.get("album", {}) + artist = item.get("artist", {}) + return { + "id": f"dz_{item['id']}", + "type": "track", + "name": item.get("title", ""), + "artists": artist.get("name", ""), + "artist_names": [artist.get("name", "")], + "album": album.get("title", ""), + "album_id": f"dz_{album.get('id', '')}", + "album_art": album.get("cover_xl") or album.get("cover_big") or album.get("cover_medium"), + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), + "preview_url": item.get("preview"), + "release_date": album.get("release_date", ""), + "source": "deezer", + } + + # ========== ALBUM METHODS ========== + + async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for albums.""" + data = await self._api_request("/search/album", {"q": query, "limit": limit, "index": offset}) + return [self._format_album(item) for item in data.get("data", [])] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + # Remove dz_ prefix if present + clean_id = album_id.replace("dz_", "") + data = await self._api_request(f"/album/{clean_id}") + album = self._format_album(data) + + # Format tracks + tracks = [] + for item in data.get("tracks", {}).get("data", []): + track = { + "id": f"dz_{item['id']}", + "type": "track", + "name": item.get("title", ""), + "artists": data.get("artist", {}).get("name", ""), + "artist_names": [data.get("artist", {}).get("name", "")], + "album": data.get("title", ""), + "album_id": f"dz_{clean_id}", + "album_art": album["album_art"], + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "isrc": item.get("isrc"), + "preview_url": item.get("preview"), + "release_date": data.get("release_date", ""), + "source": "deezer", + } + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Deezer album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + artist = item.get("artist", {}) + return { + "id": f"dz_{item['id']}", + "type": "album", + "name": item.get("title", ""), + "artists": artist.get("name", ""), + "album_art": item.get("cover_xl") or item.get("cover_big") or item.get("cover_medium"), + "release_date": item.get("release_date", ""), + "total_tracks": item.get("nb_tracks", 0), + "source": "deezer", + } + + # ========== ARTIST METHODS ========== + + async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for artists.""" + data = await self._api_request("/search/artist", {"q": query, "limit": limit, "index": offset}) + return [self._format_artist(item) for item in data.get("data", [])] + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + clean_id = artist_id.replace("dz_", "") + data = await self._api_request(f"/artist/{clean_id}") + artist = self._format_artist(data) + + # Get top tracks + top_tracks = await self._api_request(f"/artist/{clean_id}/top", {"limit": 10}) + artist["tracks"] = [self._format_track(t) for t in top_tracks.get("data", [])] + + return artist + except Exception as e: + logger.error(f"Error fetching Deezer artist {artist_id}: {e}") + return None + + def _format_artist(self, item: dict) -> dict: + """Format artist data for frontend.""" + return { + "id": f"dz_{item['id']}", + "type": "artist", + "name": item.get("name", ""), + "image": item.get("picture_xl") or item.get("picture_big") or item.get("picture_medium"), + "fans": item.get("nb_fan", 0), + "source": "deezer", + } + + # ========== UTILITIES ========== + + def _format_duration(self, ms: int) -> str: + """Format duration from ms to MM:SS.""" + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +deezer_service = DeezerService() diff --git a/app/dj_service.py b/app/dj_service.py new file mode 100644 index 0000000..ed1a38b --- /dev/null +++ b/app/dj_service.py @@ -0,0 +1,406 @@ +""" +DJ Service for Freedify. +AI-powered setlist generation using Gemini 2.0 Flash. +""" +import os +import logging +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# Camelot wheel compatibility chart +# Compatible keys: same key, +1/-1 on wheel, switch A/B (relative major/minor) +CAMELOT_COMPAT = { + "1A": ["1A", "1B", "12A", "2A"], + "1B": ["1B", "1A", "12B", "2B"], + "2A": ["2A", "2B", "1A", "3A"], + "2B": ["2B", "2A", "1B", "3B"], + "3A": ["3A", "3B", "2A", "4A"], + "3B": ["3B", "3A", "2B", "4B"], + "4A": ["4A", "4B", "3A", "5A"], + "4B": ["4B", "4A", "3B", "5B"], + "5A": ["5A", "5B", "4A", "6A"], + "5B": ["5B", "5A", "4B", "6B"], + "6A": ["6A", "6B", "5A", "7A"], + "6B": ["6B", "6A", "5B", "7B"], + "7A": ["7A", "7B", "6A", "8A"], + "7B": ["7B", "7A", "6B", "8B"], + "8A": ["8A", "8B", "7A", "9A"], + "8B": ["8B", "8A", "7B", "9B"], + "9A": ["9A", "9B", "8A", "10A"], + "9B": ["9B", "9A", "8B", "10B"], + "10A": ["10A", "10B", "9A", "11A"], + "10B": ["10B", "10A", "9B", "11B"], + "11A": ["11A", "11B", "10A", "12A"], + "11B": ["11B", "11A", "10B", "12B"], + "12A": ["12A", "12B", "11A", "1A"], + "12B": ["12B", "12A", "11B", "1B"], +} + + +class DJService: + """AI-powered DJ setlist generator using Gemini 2.0 Flash.""" + + def __init__(self): + self.api_key = os.environ.get("GEMINI_API_KEY") + self._genai = None + self._model = None + + def _init_genai(self): + """Lazy initialization of Gemini client.""" + if self._genai is None: + try: + import google.generativeai as genai + if not self.api_key: + logger.warning("GEMINI_API_KEY not set - AI features will use rule-based fallback") + return False + genai.configure(api_key=self.api_key) + self._genai = genai + self._model = genai.GenerativeModel('gemini-2.0-flash') + logger.info("Gemini 2.0 Flash initialized successfully") + return True + except ImportError: + logger.warning("google-generativeai not installed - using rule-based fallback") + return False + except Exception as e: + logger.error(f"Failed to initialize Gemini: {e}") + return False + return True + + def is_harmonically_compatible(self, camelot1: str, camelot2: str) -> bool: + """Check if two Camelot keys are harmonically compatible.""" + if camelot1 == "?" or camelot2 == "?": + return False + return camelot2 in CAMELOT_COMPAT.get(camelot1, []) + + def _rule_based_setlist(self, tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Generate setlist using rule-based algorithm when AI is unavailable. + Strategy: Sort by energy, then optimize for harmonic compatibility. + """ + if len(tracks) <= 2: + return sorted(tracks, key=lambda t: t.get("energy", 0.5)) + + # Start with lowest energy track + sorted_tracks = sorted(tracks, key=lambda t: t.get("energy", 0.5)) + setlist = [sorted_tracks.pop(0)] + + while sorted_tracks: + last = setlist[-1] + last_camelot = last.get("camelot", "?") + last_bpm = last.get("bpm", 120) + + # Score remaining tracks by compatibility + def score_track(t): + score = 0 + # Harmonic compatibility (+10 points) + if self.is_harmonically_compatible(last_camelot, t.get("camelot", "?")): + score += 10 + # BPM proximity (+5 for within 5 BPM, +3 for within 10) + bpm_diff = abs(t.get("bpm", 120) - last_bpm) + if bpm_diff <= 5: + score += 5 + elif bpm_diff <= 10: + score += 3 + # Slight energy increase preferred (+2) + energy_diff = t.get("energy", 0.5) - last.get("energy", 0.5) + if 0 < energy_diff < 0.15: + score += 2 + return score + + # Pick best scoring track + sorted_tracks.sort(key=score_track, reverse=True) + setlist.append(sorted_tracks.pop(0)) + + return setlist + + async def generate_setlist( + self, + tracks: List[Dict[str, Any]], + style: str = "progressive" # or "peak-time", "chill", "journey" + ) -> Dict[str, Any]: + """ + Generate an AI-optimized DJ setlist. + + Args: + tracks: List of tracks with bpm, camelot, energy, name, artists + style: Setlist style preference + + Returns: + Dict with ordered track IDs and mixing suggestions + """ + if len(tracks) < 2: + return { + "ordered_ids": [t.get("id") for t in tracks], + "suggestions": [], + "method": "passthrough" + } + + # Try AI generation first + if self._init_genai() and self._model: + try: + result = await self._ai_generate_setlist(tracks, style) + if result: + return result + except Exception as e: + logger.error(f"AI setlist generation failed: {e}") + + # Fallback to rule-based + logger.info("Using rule-based setlist generation") + ordered = self._rule_based_setlist(tracks.copy()) + + # Generate basic suggestions + suggestions = [] + for i in range(len(ordered) - 1): + t1, t2 = ordered[i], ordered[i+1] + bpm_diff = abs(t2.get("bpm", 0) - t1.get("bpm", 0)) + compatible = self.is_harmonically_compatible( + t1.get("camelot", "?"), t2.get("camelot", "?") + ) + + suggestion = { + "from_id": t1.get("id"), + "to_id": t2.get("id"), + "harmonic_match": compatible, + "bpm_diff": bpm_diff, + } + + if compatible and bpm_diff <= 5: + suggestion["tip"] = "Perfect mix - smooth harmonic transition" + elif compatible: + suggestion["tip"] = f"Harmonically compatible, adjust BPM by {bpm_diff}" + elif bpm_diff <= 3: + suggestion["tip"] = "BPM locked, consider EQ mixing" + else: + suggestion["tip"] = "Energy transition - use effects or beat drop" + + suggestions.append(suggestion) + + return { + "ordered_ids": [t.get("id") for t in ordered], + "suggestions": suggestions, + "method": "rule-based" + } + + async def _ai_generate_setlist( + self, + tracks: List[Dict[str, Any]], + style: str + ) -> Optional[Dict[str, Any]]: + """Generate setlist using Gemini AI.""" + import json + + # Build track summary for the prompt + track_summary = [] + for i, t in enumerate(tracks): + track_summary.append( + f"{i+1}. \"{t.get('name', 'Unknown')}\" by {t.get('artists', 'Unknown')} | " + f"BPM: {t.get('bpm', '?')} | Key: {t.get('camelot', '?')} | Energy: {t.get('energy', '?')}" + ) + + style_desc = { + "progressive": "gradually build energy from low to high, creating a journey", + "peak-time": "maintain high energy throughout with dramatic moments", + "chill": "keep energy low to medium, prioritizing smooth vibes", + "journey": "create a wave pattern - build up, peak, come down, build again" + }.get(style, "gradually build energy") + + prompt = f"""You are an expert DJ creating an optimal setlist. Analyze these tracks and order them for the best flow. + +TRACKS: +{chr(10).join(track_summary)} + +GOAL: {style_desc} + +MIXING RULES: +1. Harmonically compatible keys mix best (same Camelot number, or ±1, or A↔B switch) +2. Keep BPM changes within ±8 BPM between tracks for smooth mixing +3. Energy should follow the style pattern +4. Consider musical "story" - intro, build, peak, outro + +DJ TECHNIQUES TO SUGGEST: +- "Long Blend" - 16-32 bar crossfade with EQ swapping +- "Filter Sweep" - Use low-pass or high-pass filter on outgoing track +- "Echo Out" - Apply echo/delay while fading out +- "Hard Cut" - Quick switch on phrase start (for genre changes or impact) +- "Beat Drop" - Drop incoming track on a breakdown/drop +- "EQ Swap" - Gradually swap bass/mids/highs between tracks +- "Loop & Build" - Loop outgoing track while bringing in new one +- "Acapella Blend" - If vocal track, layer over instrumental + +Respond ONLY with valid JSON in this exact format: +{{ + "order": [1, 3, 2, ...], + "tips": [ + {{ + "from": 1, + "to": 3, + "technique": "Filter Sweep", + "timing": "16 bars", + "tip": "Filter out the bass of track 1, bring in track 3 on the drop" + }}, + ... + ] +}}""" + + try: + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON from response + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + order = data.get("order", []) + tips = data.get("tips", []) + + # Map back to track IDs + ordered_ids = [] + for idx in order: + if 1 <= idx <= len(tracks): + ordered_ids.append(tracks[idx - 1].get("id")) + + # Map tips to track IDs + suggestions = [] + for tip in tips: + from_idx = tip.get("from", 0) + to_idx = tip.get("to", 0) + if 1 <= from_idx <= len(tracks) and 1 <= to_idx <= len(tracks): + t1 = tracks[from_idx - 1] + t2 = tracks[to_idx - 1] + suggestions.append({ + "from_id": t1.get("id"), + "to_id": t2.get("id"), + "harmonic_match": self.is_harmonically_compatible( + t1.get("camelot", "?"), t2.get("camelot", "?") + ), + "bpm_diff": abs(t2.get("bpm", 0) - t1.get("bpm", 0)), + "technique": tip.get("technique", ""), + "timing": tip.get("timing", ""), + "tip": tip.get("tip", "") + }) + + logger.info(f"AI generated setlist with {len(ordered_ids)} tracks") + return { + "ordered_ids": ordered_ids, + "suggestions": suggestions, + "method": "ai-gemini-2.0-flash" + } + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse AI response as JSON: {e}") + return None + except Exception as e: + logger.error(f"AI generation error: {e}") + return None + + + return None + + async def get_audio_features_ai(self, name: str, artist: str) -> Optional[Dict[str, Any]]: + """ + Estimate audio features using AI when Spotify data is unavailable. + """ + if not self._init_genai() or not self._model: + return None + + prompt = f"""Act as an expert musicologist and DJ. +Provide the OFFICIAL studio audio analysis for the track: +Title: "{name}" +Artist: "{artist}" + +Analyze the genre and style. (e.g. Dubstep/Mid-tempo is usually 90-110 BPM, House is 120-130). +Provide the most accurate: +1. BPM (Integer) - Check for half-time/double-time ambiguities. +2. Key (Camelot Notation, e.g. 5A, 11B) +3. Energy (0.0 to 1.0) + +Respond ONLY with valid JSON: +{{ + "bpm": 100, + "camelot": "5A", + "energy": 0.8 +}}""" + + try: + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + import json + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + + # Validate + return { + "track_id": f"ai_{abs(hash(name + artist))}", # Dummy ID + "bpm": int(data.get("bpm", 120)), + "camelot": data.get("camelot", "?"), + "energy": float(data.get("energy", 0.5)), + "key": -1, # Unknown + "mode": 0, + "danceability": 0.5, + "valence": 0.5, + "source": "ai_estimate" + } + + except Exception as e: + logger.warning(f"AI audio features estimation failed (using fallback): {e}") + return None + + async def interpret_mood_query(self, query: str) -> Optional[Dict[str, Any]]: + """ + Interpret a natural language mood query using AI. + Returns structured search terms and mood metadata. + """ + if not self._init_genai() or not self._model: + return None + + prompt = f"""You are a music discovery AI. The user wants to find music based on a mood or vibe description. + +USER QUERY: "{query}" + +Interpret this mood/vibe and provide: +1. 3-5 search terms that would find matching songs (artist names, genres, song characteristics) +2. Mood keywords that describe this vibe +3. Suggested BPM range +4. Energy level (low, medium, high) + +Respond ONLY with valid JSON: +{{ + "search_terms": ["term1", "term2", "term3"], + "moods": ["chill", "relaxed"], + "bpm_range": {{"min": 70, "max": 100}}, + "energy": "low", + "description": "Brief 1-sentence description of the vibe" +}}""" + + try: + import json + response = await self._model.generate_content_async(prompt) + text = response.text.strip() + + # Extract JSON + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + data = json.loads(text) + logger.info(f"AI interpreted mood query: {query} -> {data.get('search_terms', [])}") + return data + + except Exception as e: + logger.warning(f"AI mood interpretation failed: {e}") + return None + + +# Singleton instance +dj_service = DJService() diff --git a/app/genius_service.py b/app/genius_service.py new file mode 100644 index 0000000..8eaa134 --- /dev/null +++ b/app/genius_service.py @@ -0,0 +1,241 @@ +""" +Genius service for Freedify. +Provides lyrics, annotations, and song information from Genius. +API docs: https://docs.genius.com/ +""" +import os +import re +import httpx +from typing import Optional, Dict, Any +import logging +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + + +class GeniusService: + """Service for fetching lyrics and annotations from Genius.""" + + API_BASE = "https://api.genius.com" + + def __init__(self): + # Access token: use env var (required for production) + self.access_token = os.environ.get("GENIUS_ACCESS_TOKEN", "") + if not self.access_token: + logger.warning("GENIUS_ACCESS_TOKEN not set - lyrics will not work") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make authenticated API request to Genius.""" + headers = {"Authorization": f"Bearer {self.access_token}"} + if params is None: + params = {} + + response = await self.client.get( + f"{self.API_BASE}{endpoint}", + headers=headers, + params=params + ) + response.raise_for_status() + return response.json() + + async def search_song(self, query: str) -> Optional[Dict[str, Any]]: + """Search for a song on Genius. Returns the best match.""" + try: + data = await self._api_request("/search", {"q": query}) + hits = data.get("response", {}).get("hits", []) + + # Find first song result + for hit in hits: + if hit.get("type") == "song": + song = hit.get("result", {}) + return { + "id": song.get("id"), + "title": song.get("title"), + "artist": song.get("primary_artist", {}).get("name"), + "url": song.get("url"), + "thumbnail": song.get("song_art_image_thumbnail_url"), + "full_title": song.get("full_title"), + } + return None + except Exception as e: + logger.error(f"Genius search error: {e}") + return None + + async def get_song_details(self, song_id: int) -> Optional[Dict[str, Any]]: + """Get detailed song information including annotations.""" + try: + data = await self._api_request(f"/songs/{song_id}") + song = data.get("response", {}).get("song", {}) + + # Extract useful info + description = song.get("description", {}) + if isinstance(description, dict): + description_text = description.get("plain", "") + else: + description_text = str(description) if description else "" + + return { + "id": song.get("id"), + "title": song.get("title"), + "artist": song.get("primary_artist", {}).get("name"), + "album": song.get("album", {}).get("name") if song.get("album") else None, + "release_date": song.get("release_date_for_display"), + "url": song.get("url"), + "thumbnail": song.get("song_art_image_url"), + "description": description_text, + "apple_music_id": song.get("apple_music_id"), + "recording_location": song.get("recording_location"), + "producer_artists": [p.get("name") for p in song.get("producer_artists", [])], + "writer_artists": [w.get("name") for w in song.get("writer_artists", [])], + "featured_artists": [f.get("name") for f in song.get("featured_artists", [])], + } + except Exception as e: + logger.error(f"Genius song details error: {e}") + return None + + async def scrape_lyrics(self, genius_url: str) -> Optional[str]: + """Scrape lyrics from a Genius song page.""" + try: + # Fetch the page + response = await self.client.get(genius_url, follow_redirects=True) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # Genius uses data-lyrics-container for lyrics sections + lyrics_containers = soup.find_all("div", {"data-lyrics-container": "true"}) + + if lyrics_containers: + lyrics_parts = [] + for container in lyrics_containers: + # Get text, preserving line breaks + for br in container.find_all("br"): + br.replace_with("\n") + lyrics_parts.append(container.get_text()) + + lyrics = "\n".join(lyrics_parts) + # Clean up extra whitespace + lyrics = re.sub(r'\n{3,}', '\n\n', lyrics) + return lyrics.strip() + + # Fallback: try older format + lyrics_div = soup.find("div", class_="lyrics") + if lyrics_div: + return lyrics_div.get_text().strip() + + logger.warning(f"Could not find lyrics on page: {genius_url}") + return None + + except Exception as e: + logger.error(f"Genius lyrics scrape error: {e}") + return None + + async def get_song_referents(self, song_id: int) -> list: + """Get annotations for a song using the Genius API referents endpoint.""" + annotations = [] + try: + # Use API to get referents (annotated sections) + data = await self._api_request(f"/referents", { + "song_id": song_id, + "text_format": "plain", + "per_page": 20 + }) + + referents = data.get("response", {}).get("referents", []) + + for ref in referents[:15]: # Limit to 15 annotations + fragment = ref.get("fragment", "") + annotation_list = ref.get("annotations", []) + + for ann in annotation_list: + # Get the annotation body + body = ann.get("body", {}) + if isinstance(body, dict): + plain_text = body.get("plain", "") + else: + plain_text = str(body) if body else "" + + # Also get the annotation state/votes for quality filtering + votes_total = ann.get("votes_total", 0) + + if plain_text and len(plain_text) > 10: + annotations.append({ + "fragment": fragment[:150] + "..." if len(fragment) > 150 else fragment, + "text": plain_text, + "votes": votes_total + }) + + # Sort by votes (most upvoted first) + annotations.sort(key=lambda x: x.get("votes", 0), reverse=True) + + return annotations + + except Exception as e: + logger.error(f"Genius referents API error: {e}") + return [] + + async def get_lyrics_and_info(self, artist: str, title: str) -> Dict[str, Any]: + """ + Main method: Search for a song, get lyrics and details. + Returns a dict with lyrics, about info, annotations, and metadata. + """ + result = { + "found": False, + "lyrics": None, + "title": title, + "artist": artist, + "about": None, + "album": None, + "release_date": None, + "producers": [], + "writers": [], + "annotations": [], + "genius_url": None, + "thumbnail": None, + } + + # Search for the song + query = f"{artist} {title}" + song = await self.search_song(query) + + if not song: + logger.info(f"No Genius match for: {query}") + return result + + result["found"] = True + result["genius_url"] = song.get("url") + result["thumbnail"] = song.get("thumbnail") + result["title"] = song.get("title", title) + result["artist"] = song.get("artist", artist) + + # Get detailed info + song_id = song.get("id") + if song_id: + details = await self.get_song_details(song_id) + if details: + result["about"] = details.get("description") + result["album"] = details.get("album") + result["release_date"] = details.get("release_date") + result["producers"] = details.get("producer_artists", []) + result["writers"] = details.get("writer_artists", []) + + # Scrape lyrics + if song.get("url"): + lyrics = await self.scrape_lyrics(song["url"]) + result["lyrics"] = lyrics + + # Get annotations via API (requires song_id) + if song_id: + annotations = await self.get_song_referents(song_id) + result["annotations"] = annotations + + return result + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +genius_service = GeniusService() diff --git a/app/jamendo_service.py b/app/jamendo_service.py new file mode 100644 index 0000000..143e75e --- /dev/null +++ b/app/jamendo_service.py @@ -0,0 +1,254 @@ +""" +Jamendo service for Freedify. +Provides access to 600,000+ independent and Creative Commons licensed tracks. +Jamendo API docs: https://developer.jamendo.com/v3.0 +""" +import os +import httpx +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class JamendoService: + """Service for searching and streaming from Jamendo.""" + + API_BASE = "https://api.jamendo.com/v3.0" + + def __init__(self): + # Client ID: use env var or fallback for local testing + self.client_id = os.environ.get("JAMENDO_CLIENT_ID", "90aefcef") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make API request to Jamendo.""" + if params is None: + params = {} + params["client_id"] = self.client_id + params["format"] = "json" + + response = await self.client.get(f"{self.API_BASE}{endpoint}", params=params) + response.raise_for_status() + return response.json() + + # ========== TRACK METHODS ========== + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for tracks.""" + data = await self._api_request("/tracks/", { + "search": query, + "limit": limit, + "offset": offset, + "include": "musicinfo licenses", + "audioformat": "flac", # Request FLAC URLs + }) + return [self._format_track(item) for item in data.get("results", [])] + + async def get_track(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get single track details.""" + try: + clean_id = track_id.replace("jm_", "") + data = await self._api_request("/tracks/", { + "id": clean_id, + "include": "musicinfo licenses", + "audioformat": "flac", + }) + results = data.get("results", []) + if results: + return self._format_track(results[0]) + return None + except Exception as e: + logger.error(f"Error fetching Jamendo track {track_id}: {e}") + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend (matching Spotify/Deezer format).""" + # Get best quality audio URL (prefer FLAC, fallback to MP3) + audio_url = item.get("audiodownload") or item.get("audio") or "" + + # Jamendo returns audio URL with format parameter + # We'll use the direct audio field which respects audioformat param + return { + "id": f"jm_{item['id']}", + "type": "track", + "name": item.get("name", ""), + "artists": item.get("artist_name", ""), + "artist_names": [item.get("artist_name", "")], + "artist_id": f"jm_artist_{item.get('artist_id', '')}", + "album": item.get("album_name", ""), + "album_id": f"jm_{item.get('album_id', '')}", + "album_art": item.get("album_image") or item.get("image") or "", + "duration_ms": item.get("duration", 0) * 1000, + "duration": self._format_duration(item.get("duration", 0) * 1000), + "audio_url": audio_url, # Direct stream URL + "license": item.get("license_ccurl", ""), + "release_date": item.get("releasedate", ""), + "source": "jamendo", + "format": "flac" if "flac" in audio_url.lower() else "mp3", + } + + # ========== ALBUM METHODS ========== + + async def search_albums(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for albums.""" + data = await self._api_request("/albums/", { + "search": query, + "limit": limit, + "offset": offset, + }) + return [self._format_album(item) for item in data.get("results", [])] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + clean_id = album_id.replace("jm_", "") + + # Get album info + album_data = await self._api_request("/albums/", {"id": clean_id}) + albums = album_data.get("results", []) + if not albums: + return None + + album = self._format_album(albums[0]) + + # Get album tracks + tracks_data = await self._api_request("/albums/tracks/", { + "id": clean_id, + "audioformat": "flac", + }) + + tracks = [] + for item in tracks_data.get("results", []): + for track in item.get("tracks", []): + track["album_name"] = album["name"] + track["album_image"] = album["album_art"] + track["album_id"] = clean_id + track["artist_name"] = album["artists"] + track["artist_id"] = albums[0].get("artist_id", "") + tracks.append(self._format_track(track)) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Jamendo album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + return { + "id": f"jm_{item['id']}", + "type": "album", + "name": item.get("name", ""), + "artists": item.get("artist_name", ""), + "artist_id": f"jm_artist_{item.get('artist_id', '')}", + "album_art": item.get("image") or "", + "release_date": item.get("releasedate", ""), + "total_tracks": 0, # Not always provided + "source": "jamendo", + } + + # ========== ARTIST METHODS ========== + + async def search_artists(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for artists.""" + data = await self._api_request("/artists/", { + "search": query, + "limit": limit, + "offset": offset, + }) + return [self._format_artist(item) for item in data.get("results", [])] + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + clean_id = artist_id.replace("jm_artist_", "").replace("jm_", "") + + # Get artist info + artist_data = await self._api_request("/artists/", {"id": clean_id}) + artists = artist_data.get("results", []) + if not artists: + return None + + artist = self._format_artist(artists[0]) + + # Get artist's tracks + tracks_data = await self._api_request("/artists/tracks/", { + "id": clean_id, + "limit": 20, + "audioformat": "flac", + }) + + tracks = [] + for item in tracks_data.get("results", []): + for track in item.get("tracks", []): + track["artist_name"] = artist["name"] + track["artist_id"] = clean_id + tracks.append(self._format_track(track)) + + artist["tracks"] = tracks + return artist + except Exception as e: + logger.error(f"Error fetching Jamendo artist {artist_id}: {e}") + return None + + def _format_artist(self, item: dict) -> dict: + """Format artist data for frontend.""" + return { + "id": f"jm_artist_{item['id']}", + "type": "artist", + "name": item.get("name", ""), + "image": item.get("image") or "", + "website": item.get("website", ""), + "source": "jamendo", + } + + # ========== STREAM URL ========== + + async def get_stream_url(self, track_id: str, prefer_flac: bool = True) -> Optional[str]: + """Get direct stream URL for a track. Tries FLAC first, falls back to MP3.""" + try: + clean_id = track_id.replace("jm_", "") + + # Try FLAC first + if prefer_flac: + data = await self._api_request("/tracks/", { + "id": clean_id, + "audioformat": "flac", + }) + results = data.get("results", []) + if results: + url = results[0].get("audiodownload") or results[0].get("audio") + if url: + return url + + # Fallback to MP3 (mp32 = VBR good quality) + data = await self._api_request("/tracks/", { + "id": clean_id, + "audioformat": "mp32", + }) + results = data.get("results", []) + if results: + return results[0].get("audiodownload") or results[0].get("audio") + + return None + except Exception as e: + logger.error(f"Error getting Jamendo stream URL for {track_id}: {e}") + return None + + # ========== UTILITIES ========== + + def _format_duration(self, ms: int) -> str: + """Format duration from ms to MM:SS.""" + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +jamendo_service = JamendoService() diff --git a/app/listenbrainz_service.py b/app/listenbrainz_service.py new file mode 100644 index 0000000..24f28d8 --- /dev/null +++ b/app/listenbrainz_service.py @@ -0,0 +1,409 @@ +""" +ListenBrainz service for Freedify. +Handles scrobbling (listening history) and personalized recommendations. +""" +import os +import time +import httpx +from typing import Optional, Dict, List, Any +import logging +from app.musicbrainz_service import musicbrainz_service + +logger = logging.getLogger(__name__) + +# User token from environment (can also be set via frontend settings) +LISTENBRAINZ_TOKEN = os.getenv("LISTENBRAINZ_TOKEN") + + +class ListenBrainzService: + """Service for ListenBrainz scrobbling and recommendations.""" + + API_BASE = "https://api.listenbrainz.org" + + def __init__(self): + self.token = LISTENBRAINZ_TOKEN + self.client = httpx.AsyncClient(timeout=15.0) + + def set_token(self, token: str): + """Set user token (from settings UI).""" + self.token = token + + def is_configured(self) -> bool: + """Check if ListenBrainz token is configured.""" + return bool(self.token) + + def _get_headers(self) -> dict: + """Get headers with authorization.""" + return { + "Authorization": f"Token {self.token}", + "Content-Type": "application/json" + } + + async def submit_now_playing(self, track: Dict[str, Any]) -> bool: + """Submit 'now playing' status when a track starts. + + Args: + track: Track info with name, artists, album, duration_ms + """ + if not self.is_configured(): + logger.debug("ListenBrainz not configured, skipping now playing") + return False + + try: + payload = { + "listen_type": "playing_now", + "payload": [self._format_track_payload(track)] + } + + response = await self.client.post( + f"{self.API_BASE}/1/submit-listens", + headers=self._get_headers(), + json=payload + ) + + if response.status_code == 200: + logger.info(f"ListenBrainz now playing: {track.get('name')}") + return True + else: + logger.warning(f"ListenBrainz now playing failed: {response.status_code}") + return False + + except Exception as e: + logger.error(f"ListenBrainz now playing error: {e}") + return False + + async def submit_listen(self, track: Dict[str, Any], listened_at: Optional[int] = None) -> bool: + """Submit a completed listen (scrobble). + + Should be called after user listens to 50% of track or 4 minutes, whichever is shorter. + + Args: + track: Track info with name, artists, album, duration_ms + listened_at: Unix timestamp when listening started (defaults to now) + """ + if not self.is_configured(): + logger.debug("ListenBrainz not configured, skipping scrobble") + return False + + try: + track_payload = self._format_track_payload(track) + track_payload["listened_at"] = listened_at or int(time.time()) + + payload = { + "listen_type": "single", + "payload": [track_payload] + } + + response = await self.client.post( + f"{self.API_BASE}/1/submit-listens", + headers=self._get_headers(), + json=payload + ) + + if response.status_code == 200: + logger.info(f"ListenBrainz scrobbled: {track.get('name')}") + return True + else: + logger.warning(f"ListenBrainz scrobble failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"ListenBrainz scrobble error: {e}") + return False + + def _format_track_payload(self, track: Dict[str, Any]) -> dict: + """Format track data for ListenBrainz API.""" + # Get artist name (handle both string and list formats) + artist = track.get("artists", "") + if isinstance(artist, list): + artist = ", ".join(artist) + + additional_info = {} + + # Add duration if available + duration_ms = track.get("duration_ms") + if duration_ms: + additional_info["duration_ms"] = duration_ms + + # Add release name (album) + if track.get("album"): + additional_info["release_name"] = track["album"] + + # Add ISRC if available (helps with MusicBrainz matching) + if track.get("isrc") and not track["isrc"].startswith(("dz_", "ytm_", "LINK:", "pod_")): + additional_info["isrc"] = track["isrc"] + + # Add track number if available + if track.get("track_number"): + additional_info["tracknumber"] = track["track_number"] + + return { + "track_metadata": { + "artist_name": artist, + "track_name": track.get("name", "Unknown"), + "additional_info": additional_info if additional_info else None + } + } + + async def get_recommendations(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get personalized recommendations for a user. + + Note: Recommendations are generated weekly by ListenBrainz based on listening history. + + Args: + username: ListenBrainz username + count: Number of recommendations to fetch + """ + try: + response = await self.client.get( + f"{self.API_BASE}/1/cf/recommendation/recording/{username}", + params={"count": count} + ) + + if response.status_code != 200: + logger.warning(f"ListenBrainz recommendations failed: {response.status_code}") + return [] + + data = response.json() + payload = data.get("payload", {}) + + recommendations = [] + mbids = [rec.get("recording_mbid") for rec in payload.get("mbids", [])[:15]] # Limit to 15 for performance + + for mbid in mbids: + if not mbid: continue + # Lookup metadata from MusicBrainz + track_data = await musicbrainz_service.lookup_recording(mbid) + if track_data: + track_data["type"] = "recommendation" + track_data["source"] = "listenbrainz" + recommendations.append(track_data) + + return recommendations + + except Exception as e: + logger.error(f"ListenBrainz recommendations error: {e}") + return [] + + async def get_user_listens(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get recent listens for a user.""" + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/listens", + params={"count": count} + ) + + if response.status_code != 200: + return [] + + data = response.json() + listens = data.get("payload", {}).get("listens", []) + + return [{ + "track_name": l.get("track_metadata", {}).get("track_name"), + "artist_name": l.get("track_metadata", {}).get("artist_name"), + "listened_at": l.get("listened_at"), + "source": "listenbrainz" + } for l in listens] + + except Exception as e: + logger.error(f"ListenBrainz get listens error: {e}") + return [] + + async def validate_token(self) -> Optional[str]: + """Validate token and return username if valid.""" + if not self.is_configured(): + return None + + try: + response = await self.client.get( + f"{self.API_BASE}/1/validate-token", + headers=self._get_headers() + ) + + if response.status_code == 200: + data = response.json() + if data.get("valid"): + return data.get("user_name") + + return None + + except Exception as e: + logger.error(f"ListenBrainz token validation error: {e}") + return None + + async def get_user_playlists(self, username: str, count: int = 25) -> List[Dict[str, Any]]: + """Get user's playlists from ListenBrainz (includes Weekly Exploration).""" + formatted = [] + + # Fetch user-created playlists + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/playlists", + params={"count": count} + ) + + if response.status_code == 200: + data = response.json() + playlists = data.get("playlists", []) + formatted.extend(self._format_playlists(playlists, username)) + except Exception as e: + logger.error(f"ListenBrainz user playlists error: {e}") + + # Fetch "created-for" playlists (Weekly Exploration, Daily Jam, etc.) + try: + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/playlists/createdfor", + params={"count": count} + ) + + if response.status_code == 200: + data = response.json() + playlists = data.get("playlists", []) + # Add these at the beginning since they're the most interesting + formatted = self._format_playlists(playlists, username, is_generated=True) + formatted + except Exception as e: + logger.error(f"ListenBrainz created-for playlists error: {e}") + + return formatted + + async def get_user_stats(self, username: str) -> Dict[str, Any]: + """Get user's listening statistics from ListenBrainz.""" + stats = { + "listen_count": 0, + "top_artists": [], + "top_releases": [], + "username": username + } + + try: + # Get total listen count + response = await self.client.get( + f"{self.API_BASE}/1/user/{username}/listen-count" + ) + if response.status_code == 200: + data = response.json() + stats["listen_count"] = data.get("payload", {}).get("count", 0) + except Exception as e: + logger.warning(f"ListenBrainz listen count error: {e}") + + try: + # Get top artists - try this_week first, fall back to all_time + for time_range in ["this_week", "all_time"]: + response = await self.client.get( + f"{self.API_BASE}/1/stats/user/{username}/artists", + params={"count": 5, "range": time_range} + ) + logger.info(f"LB stats for {username} ({time_range}): {response.status_code}") + + if response.status_code == 200: + data = response.json() + artists = data.get("payload", {}).get("artists", []) + if artists: + stats["top_artists"] = [ + { + "name": a.get("artist_name", "Unknown"), + "count": a.get("listen_count", 0) + } + for a in artists[:5] + ] + break # Found artists, stop trying + elif response.status_code == 204: + # No content = no stats available yet + logger.info(f"No {time_range} stats available for {username}") + except Exception as e: + logger.warning(f"ListenBrainz top artists error: {e}") + + return stats + + def _format_playlists(self, playlists: list, username: str, is_generated: bool = False) -> List[Dict[str, Any]]: + """Format playlist data from ListenBrainz API response.""" + formatted = [] + for p in playlists: + playlist = p.get("playlist", {}) + # Extract playlist MBID from identifier URL + identifier = playlist.get("identifier", "") + playlist_id = identifier.split("/")[-1] if identifier else "" + + name = playlist.get("title", "Untitled Playlist") + + formatted.append({ + "id": f"lb_{playlist_id}", + "type": "album", # Treat as album for UI compatibility + "name": name, + "artists": playlist.get("creator", username), + "description": playlist.get("annotation", "")[:150] if playlist.get("annotation") else "", + "album_art": "/static/icon.svg", # LB playlists don't have artwork + "total_tracks": len(playlist.get("track", [])), + "source": "listenbrainz", + "is_playlist": True, + "is_generated": is_generated # True for Weekly Exploration, Daily Jam, etc. + }) + + return formatted + + async def get_playlist_tracks(self, playlist_id: str) -> Optional[Dict[str, Any]]: + """Get tracks from a ListenBrainz playlist.""" + try: + # Remove lb_ prefix if present + clean_id = playlist_id.replace("lb_", "") + + response = await self.client.get( + f"{self.API_BASE}/1/playlist/{clean_id}" + ) + + if response.status_code != 200: + logger.warning(f"ListenBrainz playlist fetch failed: {response.status_code}") + return None + + data = response.json() + playlist = data.get("playlist", {}) + + # Parse JSPF tracks + jspf_tracks = playlist.get("track", []) + tracks = [] + + for i, t in enumerate(jspf_tracks): + # Extract artist and title from JSPF + artist = t.get("creator", "Unknown Artist") + title = t.get("title", "Unknown Track") + + # Build search query for audio lookup + search_query = f"{artist} - {title}" + + # Create a searchable track object + # Use the search query as the track "isrc" so the audio service can find it + tracks.append({ + "id": f"query:{search_query}", # This will trigger a search + "type": "track", + "name": title, + "artists": artist, + "album": playlist.get("title", "ListenBrainz Playlist"), + "album_art": "/static/icon.svg", + "duration": "0:00", # Duration not in JSPF + "isrc": f"query:{search_query}", # Audio service will search by name + "source": "listenbrainz" + }) + + return { + "id": f"lb_{clean_id}", + "type": "album", + "name": playlist.get("title", "ListenBrainz Playlist"), + "artists": playlist.get("creator", "ListenBrainz"), + "album_art": "/static/icon.svg", + "tracks": tracks, + "total_tracks": len(tracks), + "source": "listenbrainz" + } + + except Exception as e: + logger.error(f"ListenBrainz playlist tracks error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +listenbrainz_service = ListenBrainzService() diff --git a/app/live_show_service.py b/app/live_show_service.py new file mode 100644 index 0000000..f05c521 --- /dev/null +++ b/app/live_show_service.py @@ -0,0 +1,206 @@ +""" +Live Show Search Service for Freedify. +Searches Phish.in for Phish shows and Archive.org for other jam bands. +""" +import httpx +import re +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +# Bands that have shows on Archive.org Live Music Archive +ARCHIVE_BANDS = { + "grateful dead": "GratefulDead", + "dead": "GratefulDead", + "gd": "GratefulDead", + "billy strings": "BillyStrings", + "ween": "Ween", + "king gizzard": "KingGizzardAndTheLizardWizard", + "king gizzard & the lizard wizard": "KingGizzardAndTheLizardWizard", + "king gizzard and the lizard wizard": "KingGizzardAndTheLizardWizard", + "kglw": "KingGizzardAndTheLizardWizard", +} + + +class LiveShowService: + """Service for searching live show archives.""" + + PHISH_API = "https://phish.in/api/v2" + ARCHIVE_API = "https://archive.org/advancedsearch.php" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=30.0) + + def detect_live_search(self, query: str) -> Optional[Dict[str, Any]]: + """ + Detect if a search query is looking for live shows. + Returns dict with band, year, month if found, else None. + + Examples: + - "Phish 2025" -> {"band": "phish", "year": "2025", "month": None} + - "Phish 2024/12" -> {"band": "phish", "year": "2024", "month": "12"} + - "Grateful Dead 1977" -> {"band": "grateful dead", "year": "1977", "month": None} + """ + query_lower = query.lower().strip() + + # Pattern: band name + year or year/month + # e.g., "Phish 2025", "Grateful Dead 1977/05", "Billy Strings 2023-08" + pattern = r'^(phish|grateful dead|dead|gd|billy strings|ween|king gizzard.*?|kglw)\s+(\d{4})(?:[/-](\d{1,2}))?$' + + match = re.match(pattern, query_lower) + if match: + band = match.group(1) + year = match.group(2) + month = match.group(3) + return { + "band": band, + "year": year, + "month": month.zfill(2) if month else None + } + return None + + async def search_phish_shows(self, year: str, month: str = None) -> List[Dict[str, Any]]: + """Search Phish.in for shows by year/month.""" + try: + # Phish.in API endpoint for shows by year + url = f"{self.PHISH_API}/shows" + params = {"year": year} + + response = await self.client.get(url, params=params) + if response.status_code != 200: + logger.warning(f"Phish.in API returned {response.status_code}") + return [] + + data = response.json() + # API v2 returns {'data': [...]} or {'shows': [...]} (observed 'shows' in testing) + shows = data.get('data', []) or data.get('shows', []) + + # Filter by month if specified + if month: + shows = [s for s in shows if s.get("date", "").startswith(f"{year}-{month}")] + + # Format as albums for the UI + results = [] + for show in shows[:20]: # Limit to 20 + date = show.get("date", "") + venue = show.get("venue", {}) + venue_name = venue.get("name", "") if isinstance(venue, dict) else str(venue) + location = venue.get("location", "") if isinstance(venue, dict) else "" + + results.append({ + "id": f"phish_{date}", + "type": "album", + "name": f"Phish - {date}", + "artists": "Phish", + "album_art": "/static/icon.svg", # phish.in logo 404s, use local icon + "release_date": date, + "description": f"{venue_name}, {location}" if location else venue_name, + "total_tracks": show.get("tracks_count", 0), + "source": "phish.in", + "import_url": f"https://phish.in/{date}", + }) + + return results + except Exception as e: + logger.error(f"Phish.in search error: {e}") + return [] + + async def search_archive_shows(self, band: str, year: str, month: str = None) -> List[Dict[str, Any]]: + """Search Archive.org Live Music Archive for shows.""" + try: + # Get the Archive.org collection name + band_lower = band.lower() + collection = None + for key, val in ARCHIVE_BANDS.items(): + if key in band_lower or band_lower in key: + collection = val + break + + if not collection: + return [] + + # Build Archive.org search query + date_query = f"{year}-{month}" if month else year + query = f'collection:{collection} AND date:{date_query}* AND mediatype:etree' + + params = { + "q": query, + "fl[]": ["identifier", "title", "date", "venue", "coverage", "description"], + "sort[]": "date asc", + "rows": 20, + "output": "json", + } + + response = await self.client.get(self.ARCHIVE_API, params=params) + if response.status_code != 200: + logger.warning(f"Archive.org API returned {response.status_code}") + return [] + + data = response.json() + docs = data.get("response", {}).get("docs", []) + + # Map band collection to display name + band_names = { + "GratefulDead": "Grateful Dead", + "BillyStrings": "Billy Strings", + "Ween": "Ween", + "KingGizzardAndTheLizardWizard": "King Gizzard & The Lizard Wizard", + } + display_name = band_names.get(collection, collection) + + results = [] + for doc in docs: + identifier = doc.get("identifier", "") + date = doc.get("date", "")[:10] if doc.get("date") else "" + title = doc.get("title", f"{display_name} - {date}") + venue = doc.get("venue", "") + location = doc.get("coverage", "") + + results.append({ + "id": f"archive_{identifier}", + "type": "album", + "name": title if title else f"{display_name} - {date}", + "artists": display_name, + "album_art": f"https://archive.org/services/img/{identifier}", + "release_date": date, + "description": f"{venue}, {location}" if venue and location else (venue or location or ""), + "source": "archive.org", + "import_url": f"https://archive.org/details/{identifier}", + }) + + return results + except Exception as e: + logger.error(f"Archive.org search error: {e}") + return [] + + async def search_live_shows(self, query: str) -> Optional[List[Dict[str, Any]]]: + """ + Main entry point - detect if query is for live shows and search appropriate source. + Returns None if not a live show query. + """ + detected = self.detect_live_search(query) + if not detected: + return None + + band = detected["band"] + year = detected["year"] + month = detected["month"] + + # Phish -> use phish.in + if band == "phish": + logger.info(f"Searching Phish.in for {year}" + (f"/{month}" if month else "")) + return await self.search_phish_shows(year, month) + + # Other bands -> use Archive.org + logger.info(f"Searching Archive.org for {band} {year}" + (f"/{month}" if month else "")) + return await self.search_archive_shows(band, year, month) + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +live_show_service = LiveShowService() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..5dc9ffb --- /dev/null +++ b/app/main.py @@ -0,0 +1,1220 @@ +""" +Freedify Streaming Server +A FastAPI server for streaming music with FFmpeg transcoding. +""" +import os +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, HTTPException, Query, Response, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import zipfile +import io +from typing import List +import httpx + + +from app.deezer_service import deezer_service +from app.live_show_service import live_show_service +from app.spotify_service import spotify_service +from app.audio_service import audio_service +from app.podcast_service import podcast_service +from app.dj_service import dj_service +from app.ai_radio_service import ai_radio_service +from app.ytmusic_service import ytmusic_service +from app.setlist_service import setlist_service +from app.listenbrainz_service import listenbrainz_service +from app.jamendo_service import jamendo_service +from app.genius_service import genius_service +from app.concert_service import concert_service + +from app.cache import cleanup_cache, periodic_cleanup, is_cached, get_cache_path + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager.""" + logger.info("Starting Freedify Streaming Server...") + + # Initial cache cleanup + await cleanup_cache() + + # Start periodic cleanup task + cleanup_task = asyncio.create_task(periodic_cleanup(30)) + + yield + + # Cleanup on shutdown + cleanup_task.cancel() + await deezer_service.close() + await live_show_service.close() + await spotify_service.close() + await audio_service.close() + await podcast_service.close() + logger.info("Server shutdown complete.") + + +app = FastAPI( + title="Freedify Streaming", + description="Stream music from Deezer, Spotify URLs, and Live Archives", + lifespan=lifespan +) + +# CORS for mobile access +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Middleware to set COOP header for Google OAuth popups +@app.middleware("http") +async def add_security_headers(request, call_next): + response = await call_next(request) + # Allow popups (like Google Sign-In) to communicate with window + response.headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups" + return response + + +# ========== MODELS ========== + +class ParseUrlRequest(BaseModel): + url: str + +class ImportRequest(BaseModel): + url: str + + +# ========== API ENDPOINTS ========== + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "service": "freedify-streaming"} + + +@app.get("/api/config") +async def get_config(): + """Get public configuration for the frontend (like Google Client ID).""" + return { + "google_client_id": os.environ.get("GOOGLE_CLIENT_ID", ""), + } + + +@app.get("/api/spotify/made-for-you") +async def get_spotify_made_for_you(): + """Get Spotify 'Made For You' playlists (Daily Mix, Discover Weekly).""" + return await spotify_service.get_made_for_you_playlists() + +@app.get("/api/search") +async def search( + q: str = Query(..., min_length=1, description="Search query"), + type: str = Query("track", description="Search type: track, album, artist, or podcast"), + offset: int = Query(0, description="Offset for pagination") +): + """Search for tracks, albums, artists, or podcasts.""" + try: + # Check for Spotify URL (uses Spotify API - may be rate limited) + if spotify_service.is_spotify_url(q): + parsed = spotify_service.parse_spotify_url(q) + if parsed: + url_type, item_id = parsed + logger.info(f"Detected Spotify URL: {url_type}/{item_id}") + try: + return await get_spotify_content(url_type, item_id) + except HTTPException as e: + # If Spotify fails (rate limited), return error with info + raise HTTPException( + status_code=503, + detail=str(e.detail) + ) + + # Check for other URLs (Bandcamp, Soundcloud, Phish.in, Archive.org, etc.) + if q.startswith("http://") or q.startswith("https://"): + logger.info(f"Detected URL: {q}") + item = await audio_service.import_url(q) + if item: + # Check if it's an album/playlist + if item.get('type') == 'album': + return { + "results": [item], + "type": "album", + "is_url": True, + "source": "import", + "tracks": item.get('tracks', []) + } + # Single track + return {"results": [item], "type": "track", "is_url": True, "source": "import"} + # Podcast Search + if type == "podcast": + results = await podcast_service.search_podcasts(q) + return {"results": results, "query": q, "type": "podcast", "source": "podcast", "offset": offset} + + # YouTube Music Search + if type == "ytmusic": + results = await ytmusic_service.search_tracks(q, limit=20, offset=offset) + return {"results": results, "query": q, "type": "track", "source": "ytmusic", "offset": offset} + + # Setlist.fm Search + if type == "setlist": + results = await setlist_service.search_setlists(q) + return {"results": results, "query": q, "type": "album", "source": "setlist.fm", "offset": offset} + + # Check for live show searches FIRST if no type specified or type is album + # But only if NOT one of the special types above (which returned already) + live_results = await live_show_service.search_live_shows(q) + if live_results is not None: + return {"results": live_results, "query": q, "type": "album", "source": "live_shows"} + + # Regular search - Use Dab Music (Priority) then Deezer + logger.info(f"Searching: {q} (type: {type}, offset: {offset})") + + results = [] + source = "deezer" + + # 1. Try Dab Music (unless offset > 0, as Dab paging is limited/untested or we want fast fallback) + # Actually Dab search wrapper I wrote doesn't support offset yet (defaults limit 10). + # We'll use Dab for generic queries. + if type in ["album", "track"] and offset == 0: + try: + from app.dab_service import dab_service + if type == "album": + dab_results = await dab_service.search_albums(q, limit=10) + else: + dab_results = await dab_service.search_tracks(q, limit=10) + + if dab_results: + logger.info(f"Found {len(dab_results)} results on Dab Music") + results = dab_results + source = "dab" + except Exception as e: + logger.error(f"Dab search error: {e}") + + # 2. Fallback to Deezer if no Dab results + if not results: + logger.info(f"Falling back to Deezer search...") + if type == "album": + results = await deezer_service.search_albums(q, limit=20, offset=offset) + elif type == "artist": + results = await deezer_service.search_artists(q, limit=20, offset=offset) + else: + results = await deezer_service.search_tracks(q, limit=20, offset=offset) + if results: + source = "deezer" + + # 3. Final fallback to Jamendo (independent/CC music) if still no results + if not results and type in ["track", "album", "artist"]: + logger.info(f"Falling back to Jamendo search...") + try: + if type == "album": + results = await jamendo_service.search_albums(q, limit=20, offset=offset) + elif type == "artist": + results = await jamendo_service.search_artists(q, limit=20, offset=offset) + else: + results = await jamendo_service.search_tracks(q, limit=20, offset=offset) + if results: + source = "jamendo" + logger.info(f"Found {len(results)} results on Jamendo") + except Exception as e: + logger.error(f"Jamendo search error: {e}") + + return {"results": results, "query": q, "type": type, "source": source, "offset": offset} + except Exception as e: + logger.error(f"Search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def get_content_by_type(content_type: str, item_id: str): + """Helper to get content by type and ID (uses Deezer, Dab, or Jamendo).""" + + # Handle Dab Music IDs + if item_id.startswith("dab_"): + from app.dab_service import dab_service + if content_type == "album": + album = await dab_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])} + # Dab doesn't really have "get_track" singular metadata endpoint exposed yet, but we can search or use stream. + # But for UI "open track", usually it plays directly. + pass + + # Handle Jamendo IDs (jm_ prefix) + if item_id.startswith("jm_"): + if content_type == "album": + album = await jamendo_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "jamendo"} + elif content_type == "artist" or item_id.startswith("jm_artist_"): + artist = await jamendo_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "jamendo"} + elif content_type == "track": + track = await jamendo_service.get_track(item_id) + if track: + return {"results": [track], "type": "track", "is_url": True, "source": "jamendo"} + raise HTTPException(status_code=404, detail=f"Jamendo {content_type} not found") + + if content_type == "track": + results = await deezer_service.search_tracks(item_id, limit=1) + if results: + return {"results": results, "type": "track", "is_url": True} + elif content_type == "album": + album = await deezer_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", [])} + elif content_type == "artist": + artist = await deezer_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", [])} + + raise HTTPException(status_code=404, detail=f"{content_type.title()} not found") + + +async def get_spotify_content(content_type: str, item_id: str): + """Helper to get content from Spotify by type and ID.""" + if content_type == "track": + track = await spotify_service.get_track_by_id(item_id) + if track: + return {"results": [track], "type": "track", "is_url": True, "source": "spotify"} + elif content_type == "album": + album = await spotify_service.get_album(item_id) + if album: + return {"results": [album], "type": "album", "is_url": True, "tracks": album.get("tracks", []), "source": "spotify"} + elif content_type == "playlist": + playlist = await spotify_service.get_playlist(item_id) + if playlist: + return {"results": [playlist], "type": "playlist", "is_url": True, "tracks": playlist.get("tracks", []), "source": "spotify"} + elif content_type == "artist": + artist = await spotify_service.get_artist(item_id) + if artist: + return {"results": [artist], "type": "artist", "is_url": True, "tracks": artist.get("tracks", []), "source": "spotify"} + + raise HTTPException(status_code=404, detail=f"Spotify {content_type.title()} not found") + + +@app.post("/api/import") +async def import_url_endpoint(request: ImportRequest): + """Import a track from a URL (Bandcamp, Soundcloud, etc.).""" + try: + track = await audio_service.import_url(request.url) + if not track: + raise HTTPException(status_code=400, detail="Could not import URL") + return track + except Exception as e: + logger.error(f"Import endpoint error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/track/{track_id}") +async def get_track(track_id: str): + """Get track details by Spotify ID.""" + try: + track = await spotify_service.get_track_by_id(track_id) + if not track: + raise HTTPException(status_code=404, detail="Track not found") + return track + except HTTPException: + raise + except Exception as e: + logger.error(f"Track fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/album/{album_id}") +async def get_album(album_id: str): + # Support Dab Albums + if album_id.startswith("dab_"): + from app.dab_service import dab_service + album = await dab_service.get_album(album_id) + if album: return album + raise HTTPException(status_code=404, detail="Dab album not found") + + # Support Deezer Albums (fallback logic handled in dedicated service or here) + if album_id.startswith("dz_"): + album = await deezer_service.get_album(album_id) + if album: return album + raise HTTPException(status_code=404, detail="Deezer album not found") + + """Get album details with all tracks.""" + try: + # Handle different sources based on ID prefix + if album_id.startswith("dz_"): + # Deezer album + album = await deezer_service.get_album(album_id) + elif album_id.startswith("archive_"): + # Archive.org show - import via URL + identifier = album_id.replace("archive_", "") + url = f"https://archive.org/details/{identifier}" + logger.info(f"Importing Archive.org show: {url}") + album = await audio_service.import_url(url) + elif album_id.startswith("phish_"): + # Phish.in show - import via URL + date = album_id.replace("phish_", "") + url = f"https://phish.in/{date}" + logger.info(f"Importing Phish.in show: {url}") + album = await audio_service.import_url(url) + elif album_id.startswith("pod_"): + # Podcast Import (PodcastIndex) + feed_id = album_id.replace("pod_", "") + album = await podcast_service.get_podcast_episodes(feed_id) + elif album_id.startswith("setlist_"): + # Setlist.fm - get full setlist with tracks + setlist_id = album_id.replace("setlist_", "") + album = await setlist_service.get_setlist(setlist_id) + if album and album.get("audio_source") == "phish.in": + # Phish show - fetch audio from phish.in + album["audio_available"] = True + elif album and album.get("audio_source") == "archive.org": + # Other artist - find best Archive.org version + archive_url = await setlist_service.find_best_archive_show( + album.get("artists", ""), + album.get("iso_date", "") + ) + if archive_url: + album["audio_url"] = archive_url + album["audio_available"] = True + else: + # Fallback to search if no direct match + album["audio_available"] = True + else: + # Unknown source - try Deezer + album = await deezer_service.get_album(album_id) + + if not album: + raise HTTPException(status_code=404, detail="Album not found") + return album + except HTTPException: + raise + except Exception as e: + logger.error(f"Album fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/playlist/{playlist_id}") +async def get_playlist(playlist_id: str): + """Get playlist details with all tracks.""" + try: + playlist = await spotify_service.get_playlist(playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + return playlist + except HTTPException: + raise + except Exception as e: + logger.error(f"Playlist fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/artist/{artist_id}") +async def get_artist(artist_id: str): + """Get artist details with top tracks.""" + try: + # Use Deezer for dz_ prefixed IDs + if artist_id.startswith("dz_"): + artist = await deezer_service.get_artist(artist_id) + else: + artist = await spotify_service.get_artist(artist_id) + if not artist: + raise HTTPException(status_code=404, detail="Artist not found") + return artist + except HTTPException: + raise + except Exception as e: + logger.error(f"Artist fetch error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.api_route("/api/stream/{isrc}", methods=["GET", "HEAD"]) +async def stream_audio( + request: Request, + isrc: str, + q: Optional[str] = Query(None, description="Search query hint"), + hires: bool = Query(True, description="Prefer Hi-Res 24-bit audio") +): + """Stream audio for a track by ISRC.""" + try: + logger.info(f"Stream request for ISRC: {isrc} (hires={hires})") + + target_stream_url = None + + # 1. Resolve Target Stream URL (Direct or via yt-dlp) + + # Handle Imported Links (LINK:) + if isrc.startswith("LINK:"): + import base64 + from urllib.parse import urlparse + try: + encoded_url = isrc.replace("LINK:", "") + original_url = base64.urlsafe_b64decode(encoded_url).decode() + + # Check for direct file extension first (fast path) + parsed = urlparse(original_url) + audio_exts = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.opus', '.flac') + if any(parsed.path.lower().endswith(ext) for ext in audio_exts): + target_stream_url = original_url + else: + # Try to extract stream via yt-dlp (for YouTube/SoundCloud links) + # Run in executor to avoid blocking + loop = asyncio.get_event_loop() + target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, original_url) + + except Exception as e: + logger.warning(f"Failed to parse/extract LINK: {e}") + + # Handle YouTube Music (ytm_) + elif isrc.startswith("ytm_"): + video_id = isrc.replace("ytm_", "") + youtube_url = f"https://music.youtube.com/watch?v={video_id}" + loop = asyncio.get_event_loop() + target_stream_url = await loop.run_in_executor(None, audio_service._get_stream_url, youtube_url) + + # Handle Jamendo (jm_) - Direct stream/download URLs + elif isrc.startswith("jm_"): + track_id = isrc.replace("jm_", "") + target_stream_url = await jamendo_service.get_stream_url(track_id, prefer_flac=hires) + + # 2. Proxy the Target Stream (if found) + if target_stream_url: + logger.info(f"Proxying direct stream: {target_stream_url[:60]}...") + + # Forward Range header to support seeking + req_headers = {} + if request.headers.get("Range"): + req_headers["Range"] = request.headers.get("Range") + logger.info(f"Forwarding Range header: {req_headers['Range']}") + + + + try: + # Use a dedicated client per request, managed by a generator with context manager. + # This guarantees cleanup when the generator is closed (e.g. client disconnect). + # This avoids "Shared Client" pool exhaustion/deadlocks. + + async def stream_generator(): + try: + # 60s timeout, follow redirects + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + req = client.build_request("GET", target_stream_url, headers=req_headers) + async with client.stream(req.method, req.url, headers=req.headers) as r: + # We've started the stream. Now we need to yield data. + # But wait, we need to return status_code and headers to FastAPI *before* we yield data + # if we use standard StreamingResponse... + + # Actually, StreamingResponse takes a generator for content. + # But it needs status and headers passed in the constructor. + # Be we can't get them until we make the request! + + # This is the "Streaming Problem". + # Solution: use a separate setup request (HEAD) or... + # Accept that we make the request *outside* the generator for headers, + # but risking the cleanup issue? + + # NO. The cleanup issue is paramount. + # If we can't get headers cleanly without risking leaks, we should assume defaults + # or use a dummy request? + + # ALTERNATIVE: Use the shared client again, BUT with a much more aggressive timeout? + # OR use the pattern where we don't return StreamingResponse until we have headers, + # ensuring we use a try/finally block that closes the client. + + # Let's stick to the pattern I implemented before but simpler: + # 1. Create client + # 2. Make request + # 3. Return StreamingResponse with a generator that CLOSES the client. + # 4. BUT ensure `client` is a LOCALLY created instance, not shared. + pass + + except Exception as e: + logger.error(f"Generator error: {e}") + + # Real Implementation: + # Create a local client instance (not shared). + client = httpx.AsyncClient(follow_redirects=True, timeout=60.0) + req = client.build_request("GET", target_stream_url, headers=req_headers) + r = await client.send(req, stream=True) + + # Prepare headers + resp_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*" + } + for key in ["Content-Range", "Content-Length", "Content-Type", "Last-Modified", "ETag"]: + if r.headers.get(key): + resp_headers[key] = r.headers[key] + + # Custom iterator that closes the LOCAL client + async def response_iterator(): + try: + async for chunk in r.aiter_bytes(chunk_size=65536): + yield chunk + except Exception as e: + logger.error(f"Stream iteration error: {e}") + finally: + # Close the response AND the client + await r.aclose() + await client.aclose() + + return StreamingResponse( + response_iterator(), + status_code=r.status_code, + media_type=r.headers.get("Content-Type", "audio/mpeg"), + headers=resp_headers + ) + except Exception as e: + logger.error(f"Proxying stream failed: {e}") + # Fall through to standard playback if proxy fails + + # 3. Standard / HiFi Playback (Fallback or standard sources) + + # Force FLAC/Hi-Res path (MP3 option removed) + cache_ext = "flac" + mime_type = "audio/flac" + + # Check cache + if is_cached(isrc, cache_ext): + cache_path = get_cache_path(isrc, cache_ext) + logger.info(f"Serving from cache ({cache_ext}): {cache_path}") + return FileResponse( + cache_path, + media_type=mime_type, + headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"} + ) + + + # 4. Standard / HiFi Playback (Uses fetch_flac with internal priorities: Dab -> Tidal -> Deezer) + + # Standard: Fetch FLAC directly (Hifi/Hi-Res) - Skip MP3 transcoding + # The user requested to remove non-hifi options for efficiency. + result = await audio_service.fetch_flac(isrc, q or "", hires=hires) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio") + + # Check if result is URL (tuple[str, dict]) or Bytes (tuple[bytes, dict]) + if isinstance(result[0], str): + # It's a URL! Stream it via proxy + target_stream_url = result[0] + metadata = result[1] + logger.info(f"Streaming via proxy from URL: {target_stream_url[:50]}...") + + # Proxy streaming logic for fetched URL + # Need to handle Range requests properly for seeking + + req_headers = {} + if request.headers.get("Range"): + req_headers["Range"] = request.headers.get("Range") + logger.info(f"Forwarding Range header: {req_headers['Range']}") + + # Make initial request to get status/headers + client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) + try: + upstream_req = client.build_request("GET", target_stream_url, headers=req_headers) + upstream_resp = await client.send(upstream_req, stream=True) + + # Build response headers + resp_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*" + } + + # Forward important headers from upstream + for key in ["Content-Range", "Content-Length", "Content-Type"]: + if upstream_resp.headers.get(key): + resp_headers[key] = upstream_resp.headers[key] + + if metadata and metadata.get("is_hi_res"): + resp_headers["X-Audio-Quality"] = "Hi-Res" + resp_headers["X-Audio-Format"] = "FLAC" + + # Iterator that closes client when done + async def response_iterator(): + try: + async for chunk in upstream_resp.aiter_bytes(chunk_size=65536): + yield chunk + except Exception as e: + logger.error(f"Stream iteration error: {e}") + finally: + await upstream_resp.aclose() + await client.aclose() + + return StreamingResponse( + response_iterator(), + status_code=upstream_resp.status_code, # 200 or 206 + media_type=upstream_resp.headers.get("Content-Type", "audio/flac"), + headers=resp_headers + ) + except Exception as e: + await client.aclose() + raise + + else: + # It's bytes! Serve directly. + flac_data, metadata = result + + headers = { + "Accept-Ranges": "bytes", + "Content-Length": str(len(flac_data)), + "Cache-Control": "public, max-age=86400", + "X-Audio-Format": "FLAC" + } + + if metadata and metadata.get("is_hi_res"): + headers["X-Audio-Quality"] = "Hi-Res" + + return Response( + content=flac_data, + media_type="audio/flac", + headers=headers + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Stream error for {isrc}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/download/{isrc}") +async def download_audio( + isrc: str, + q: Optional[str] = Query(None, description="Search query hint"), + format: str = Query("mp3", description="Audio format: mp3, flac, aiff, wav, alac"), + filename: Optional[str] = Query(None, description="Filename") +): + """Download audio in specified format.""" + try: + logger.info(f"Download request for {isrc} in {format}") + + result = await audio_service.get_download_audio(isrc, q or "", format) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio for download") + + data, ext, mime = result + download_name = filename if filename else f"{isrc}{ext}" + if not download_name.endswith(ext): + download_name += ext + + return Response( + content=data, + media_type=mime, + headers={ + "Content-Disposition": f'attachment; filename="{download_name}"', + "Content-Length": str(len(data)) + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Download error for {isrc}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== DJ MODE ENDPOINTS ========== + +class TrackForFeatures(BaseModel): + id: str + isrc: Optional[str] = None + name: Optional[str] = None + artists: Optional[str] = None + + +class AudioFeaturesBatchRequest(BaseModel): + tracks: List[TrackForFeatures] + + +class TrackForSetlist(BaseModel): + id: str + name: str + artists: str + bpm: int + camelot: str + energy: float + + +class SetlistRequest(BaseModel): + tracks: List[TrackForSetlist] + style: str = "progressive" # progressive, peak-time, chill, journey + + +@app.get("/api/audio-features/{track_id}") +async def get_audio_features( + track_id: str, + isrc: Optional[str] = Query(None), + name: Optional[str] = Query(None), + artist: Optional[str] = Query(None) +): + """Get audio features (BPM, key, energy) for a track.""" + try: + features = await spotify_service.get_audio_features(track_id, isrc, name, artist) + if not features: + raise HTTPException(status_code=404, detail="Audio features not found") + return features + except HTTPException: + raise + except Exception as e: + logger.error(f"Audio features error for {track_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/audio-features/batch") +async def get_audio_features_batch(request: AudioFeaturesBatchRequest): + """Get audio features for multiple tracks.""" + try: + if not request.tracks: + return {"features": []} + + # Process each track, handling Deezer tracks with ISRC/name lookup + features = [] + for track in request.tracks: + feat = await spotify_service.get_audio_features( + track.id, + track.isrc, + track.name, + track.artists + ) + + # Fallback to AI estimation if Spotify fails + if not feat and track.name and track.artists: + feat = await dj_service.get_audio_features_ai(track.name, track.artists) + if feat: + feat['track_id'] = track.id # Match requested ID for frontend cache + + features.append(feat) + + return {"features": features} + except Exception as e: + logger.error(f"Batch audio features error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + logger.error(f"Batch audio features error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/dj/generate-setlist") +async def generate_setlist(request: SetlistRequest): + """Generate AI-optimized DJ setlist ordering.""" + try: + tracks = [t.model_dump() for t in request.tracks] + result = await dj_service.generate_setlist(tracks, request.style) + return result + except Exception as e: + logger.error(f"Setlist generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class MoodSearchRequest(BaseModel): + query: str + + +@app.post("/api/search/mood") +async def search_by_mood(request: MoodSearchRequest): + """Interpret a natural language mood query using AI and return search terms.""" + try: + result = await dj_service.interpret_mood_query(request.query) + if not result: + # Fallback: just return the query as a search term + return { + "search_terms": [request.query], + "moods": [], + "bpm_range": None, + "energy": "medium", + "description": f"Searching for: {request.query}" + } + return result + except Exception as e: + logger.error(f"Mood search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SeedTrack(BaseModel): + name: str + artists: str + bpm: Optional[int] = None + camelot: Optional[str] = None + + +class QueueTrack(BaseModel): + name: str + artists: str + + +class AIRadioRequest(BaseModel): + seed_track: Optional[SeedTrack] = None + mood: Optional[str] = None + current_queue: Optional[List[QueueTrack]] = None + count: int = 5 + + +@app.post("/api/ai-radio/generate") +async def generate_ai_radio_recommendations(request: AIRadioRequest): + """Generate AI Radio recommendations based on seed track or mood.""" + try: + seed = request.seed_track.model_dump() if request.seed_track else None + queue = [t.model_dump() for t in request.current_queue] if request.current_queue else [] + + result = await ai_radio_service.generate_recommendations( + seed_track=seed, + mood=request.mood, + current_queue=queue, + count=request.count + ) + return result + except Exception as e: + logger.error(f"AI Radio error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== AI ASSISTANT ENDPOINTS ==================== + +class GeneratePlaylistRequest(BaseModel): + description: str + duration_mins: int = 60 + +@app.post("/api/ai/generate-playlist") +async def ai_generate_playlist(request: GeneratePlaylistRequest): + """Generate a playlist from a natural language description.""" + try: + result = await ai_radio_service.generate_playlist( + description=request.description, + duration_mins=request.duration_mins + ) + return result + except Exception as e: + logger.error(f"Playlist generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class BatchDownloadRequest(BaseModel): + tracks: List[str] # List of ISRCs or IDs + names: List[str] # List of track names for filenames + artists: List[str] # List of artist names + album_name: str + format: str = "mp3" + + +@app.post("/api/download-batch") +async def download_batch(request: BatchDownloadRequest): + """Download multiple tracks as a ZIP file.""" + try: + logger.info(f"Batch download request: {len(request.tracks)} tracks from {request.album_name}") + + # In-memory ZIP buffer + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + used_names = set() + + # Process sequentially for better reliability + for i, isrc in enumerate(request.tracks): + try: + query = f"{request.names[i]} {request.artists[i]}" + result = await audio_service.get_download_audio(isrc, query, request.format) + + if result: + data, ext, _ = result + # Clean filename + safe_name = f"{request.artists[i]} - {request.names[i]}".replace("/", "_").replace("\\", "_").replace(":", "_").replace("*", "").replace("?", "").replace('"', "").replace("<", "").replace(">", "").replace("|", "") + filename = f"{safe_name}{ext}" + + # Handle duplicates + count = 1 + base_filename = filename + while filename in used_names: + filename = f"{safe_name} ({count}){ext}" + count += 1 + used_names.add(filename) + + zip_file.writestr(filename, data) + except Exception as e: + logger.error(f"Failed to download track {isrc}: {e}") + # Continue with other tracks + + zip_buffer.seek(0) + safe_album = request.album_name.replace("/", "_").replace("\\", "_").replace(":", "_") + filename = f"{safe_album}.zip" + + return Response( + content=zip_buffer.getvalue(), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + } + ) + + except Exception as e: + logger.error(f"Batch download error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== GOOGLE DRIVE ========== + +class UploadToDriveRequest(BaseModel): + isrc: str + access_token: str + format: str = "aiff" + folder_id: Optional[str] = None + filename: Optional[str] = None + q: Optional[str] = None + + +@app.post("/api/drive/upload") +async def upload_to_drive(request: UploadToDriveRequest): + """Download audio, transcode, and upload to Google Drive.""" + try: + logger.info(f"Drive upload request for {request.isrc} in {request.format}") + + # 1. Get Audio Data (reuse existing logic) + result = await audio_service.get_download_audio(request.isrc, request.q or "", request.format) + + if not result: + raise HTTPException(status_code=404, detail="Could not fetch audio") + + data, ext, mime = result + filename = request.filename if request.filename else f"{request.isrc}{ext}" + if not filename.endswith(ext): + filename += ext + + # 2. Upload to Drive (Multipart upload for metadata + media) + metadata = { + 'name': filename, + 'mimeType': mime + } + if request.folder_id: + metadata['parents'] = [request.folder_id] + + import httpx + import json + + async with httpx.AsyncClient() as client: + # Multipart upload + files_param = { + 'metadata': (None, json.dumps(metadata), 'application/json; charset=UTF-8'), + 'file': (filename, data, mime) + } + + drive_response = await client.post( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', + headers={'Authorization': f'Bearer {request.access_token}'}, + files=files_param, + timeout=300.0 # 5 minutes for upload + ) + + if drive_response.status_code != 200: + logger.error(f"Drive upload failed: {drive_response.text}") + raise HTTPException(status_code=500, detail=f"Drive upload failed: {drive_response.text}") + + file_data = drive_response.json() + return {"file_id": file_data.get('id'), "name": file_data.get('name')} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Drive upload error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== STATIC FILES ========== + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC_DIR = os.path.join(BASE_DIR, "static") + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/") +async def index(): + """Serve the main page.""" + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + return {"message": "Freedify Streaming Server", "docs": "/docs"} + + +@app.get("/manifest.json") +async def manifest(): + """Serve PWA manifest.""" + manifest_path = os.path.join(STATIC_DIR, "manifest.json") + if os.path.exists(manifest_path): + return FileResponse(manifest_path, media_type="application/json") + raise HTTPException(status_code=404) + + +@app.get("/sw.js") +async def service_worker(): + """Serve service worker.""" + sw_path = os.path.join(STATIC_DIR, "sw.js") + if os.path.exists(sw_path): + return FileResponse(sw_path, media_type="application/javascript") + raise HTTPException(status_code=404) + + +# ==================== LISTENBRAINZ ENDPOINTS ==================== + +@app.post("/api/listenbrainz/now-playing") +async def listenbrainz_now_playing(track: dict): + """Submit 'now playing' status to ListenBrainz.""" + success = await listenbrainz_service.submit_now_playing(track) + return {"success": success} + + +@app.post("/api/listenbrainz/scrobble") +async def listenbrainz_scrobble(track: dict, listened_at: Optional[int] = None): + """Submit a completed listen to ListenBrainz.""" + success = await listenbrainz_service.submit_listen(track, listened_at) + return {"success": success} + + +@app.get("/api/listenbrainz/validate") +async def listenbrainz_validate(): + """Validate ListenBrainz token and return username.""" + username = await listenbrainz_service.validate_token() + return {"valid": username is not None, "username": username} + + +@app.get("/api/listenbrainz/recommendations/{username}") +async def listenbrainz_recommendations(username: str, count: int = 25): + """Get personalized recommendations for a user.""" + recommendations = await listenbrainz_service.get_recommendations(username, count) + return {"recommendations": recommendations, "count": len(recommendations)} + + +@app.get("/api/listenbrainz/listens/{username}") +async def listenbrainz_listens(username: str, count: int = 25): + """Get recent listens for a user.""" + listens = await listenbrainz_service.get_user_listens(username, count) + return {"listens": listens, "count": len(listens)} + + +@app.post("/api/listenbrainz/set-token") +async def listenbrainz_set_token(token: str): + """Set ListenBrainz user token (from settings UI).""" + listenbrainz_service.set_token(token) + username = await listenbrainz_service.validate_token() + return {"valid": username is not None, "username": username} + +@app.get("/api/listenbrainz/playlists/{username}") +async def listenbrainz_playlists(username: str, count: int = 25): + """Get user's ListenBrainz playlists (includes Weekly Exploration).""" + playlists = await listenbrainz_service.get_user_playlists(username, count) + return {"playlists": playlists, "count": len(playlists)} + +@app.get("/api/listenbrainz/playlist/{playlist_id}") +async def listenbrainz_playlist_tracks(playlist_id: str): + """Get tracks from a ListenBrainz playlist.""" + playlist = await listenbrainz_service.get_playlist_tracks(playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + return playlist + +@app.get("/api/listenbrainz/stats/{username}") +async def listenbrainz_stats(username: str): + """Get user's ListenBrainz listening statistics.""" + stats = await listenbrainz_service.get_user_stats(username) + return stats + + +# ========== GENIUS LYRICS ========== + +@app.get("/api/lyrics") +async def get_lyrics(artist: str, title: str): + """Get lyrics and song info from Genius.""" + result = await genius_service.get_lyrics_and_info(artist, title) + return result + + +@app.get("/api/proxy_image") +async def proxy_image(url: str): + """Proxy image requests to avoid 429 errors/CORS issues.""" + if not url: + raise HTTPException(status_code=400, detail="No URL provided") + + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, follow_redirects=True) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail="Failed to fetch image") + + return Response( + content=resp.content, + media_type=resp.headers.get("Content-Type", "image/jpeg"), + headers={ + "Cache-Control": "public, max-age=86400" + } + ) + except Exception as e: + logger.error(f"Image proxy error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ========== CONCERT ALERTS ENDPOINTS ========== + +@app.get("/api/concerts/search") +async def search_concerts( + artist: str = Query(..., description="Artist name to search"), + city: Optional[str] = Query(None, description="City to filter events") +): + """ + Search for upcoming concerts by artist name. + Uses Ticketmaster with SeatGeek fallback. + """ + try: + logger.info(f"Concert search for: {artist} (city: {city})") + events = await concert_service.search_events(artist, city, limit=20) + return {"events": events, "artist": artist, "city": city} + except Exception as e: + logger.error(f"Concert search error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/concerts/for-artists") +async def get_concerts_for_artists( + artists: str = Query(..., description="Comma-separated list of artist names"), + cities: Optional[str] = Query(None, description="Comma-separated list of cities") +): + """ + Get upcoming concerts for multiple artists. + Useful for showing concerts from recently listened artists. + """ + try: + artist_list = [a.strip() for a in artists.split(",") if a.strip()] + city_list = [c.strip() for c in cities.split(",")] if cities else None + + if not artist_list: + return {"events": [], "artists": [], "cities": city_list} + + logger.info(f"Concert search for {len(artist_list)} artists, cities: {city_list}") + events = await concert_service.get_events_for_artists(artist_list, city_list) + + return {"events": events, "artists": artist_list, "cities": city_list} + except Exception as e: + logger.error(f"Concerts for artists error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=int(os.environ.get("PORT", 8000)), + reload=True + ) diff --git a/app/musicbrainz_service.py b/app/musicbrainz_service.py new file mode 100644 index 0000000..dc23ccf --- /dev/null +++ b/app/musicbrainz_service.py @@ -0,0 +1,194 @@ +""" +MusicBrainz service for Freedify. +Provides metadata enrichment: release year, label, and cover art from Cover Art Archive. +""" +import httpx +from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger(__name__) + + +class MusicBrainzService: + """Service for enriching track metadata from MusicBrainz.""" + + MB_API = "https://musicbrainz.org/ws/2" + CAA_API = "https://coverartarchive.org" + USER_AGENT = "Freedify/1.0 (https://github.com/freedify)" + + def __init__(self): + self.client = httpx.AsyncClient( + timeout=15.0, + headers={"User-Agent": self.USER_AGENT} + ) + + async def lookup_recording(self, mbid: str) -> Optional[Dict[str, Any]]: + """Look up a recording by MBID. + + Returns: + { + 'id': '...', + 'name': 'Track Name', + 'artists': 'Artist Name', + 'album': 'Album Name', + 'album_art': '...', + 'release_date': '...', + 'duration': '3:45' + } + """ + try: + response = await self.client.get( + f"{self.MB_API}/recording/{mbid}", + params={"fmt": "json", "inc": "releases+artist-credits+release-groups+genres"} + ) + + if response.status_code != 200: + logger.debug(f"MBID lookup failed: {mbid}") + return None + + data = response.json() + + # Helper to get artist name + artist_credit = data.get("artist-credit", []) + artist_name = ", ".join([ac.get("name", "") for ac in artist_credit]) if artist_credit else "Unknown Artist" + + result = { + "id": mbid, + "name": data.get("title", "Unknown Track"), + "artists": artist_name, + "duration": data.get("length", 0) // 1000 if data.get("length") else 0 + } + + # Get release info + releases = data.get("releases", []) + if releases: + release = releases[0] + result["album"] = release.get("title", "") + result["release_date"] = release.get("date", "") + + # Cover Art + release_id = release.get("id") + if release_id: + cover_url = await self._get_cover_art(release_id) + if cover_url: + result["album_art"] = cover_url + + return result + + except Exception as e: + logger.error(f"MusicBrainz recording lookup error: {e}") + return None + + async def lookup_by_isrc(self, isrc: str) -> Optional[Dict[str, Any]]: + """Look up a recording by ISRC and return enriched metadata. + + Returns: + { + 'release_date': '2020-01-15', + 'label': 'Atlantic Records', + 'cover_art_url': 'https://...', + 'genres': ['pop', 'electronic'], + 'release_id': '...' # for further lookups + } + """ + try: + # Skip non-standard ISRCs (like dz_ or ytm_ prefixed IDs) + if not isrc or isrc.startswith(('dz_', 'ytm_', 'LINK:')): + return None + + logger.info(f"Looking up ISRC on MusicBrainz: {isrc}") + + # Search for recording by ISRC + response = await self.client.get( + f"{self.MB_API}/isrc/{isrc}", + params={"fmt": "json", "inc": "releases+release-groups+labels+genres"} + ) + + if response.status_code != 200: + logger.debug(f"No MusicBrainz result for ISRC: {isrc}") + return None + + data = response.json() + recordings = data.get("recordings", []) + + if not recordings: + return None + + # Get the first recording's release info + recording = recordings[0] + releases = recording.get("releases", []) + + if not releases: + return None + + # Use the first release (typically the original) + release = releases[0] + release_id = release.get("id", "") + + result = { + "release_date": release.get("date", ""), + "release_id": release_id, + "label": "", + "cover_art_url": "", + "genres": [] + } + + # Get label from label-info + label_info = release.get("label-info", []) + if label_info and label_info[0].get("label"): + result["label"] = label_info[0]["label"].get("name", "") + + # Get genres from recording + genres = recording.get("genres", []) + result["genres"] = [g.get("name", "") for g in genres[:5]] + + # Try to get cover art from Cover Art Archive + if release_id: + cover_url = await self._get_cover_art(release_id) + if cover_url: + result["cover_art_url"] = cover_url + + logger.info(f"MusicBrainz enrichment found: year={result['release_date']}, label={result['label']}") + return result + + except Exception as e: + logger.debug(f"MusicBrainz lookup error for {isrc}: {e}") + return None + + async def _get_cover_art(self, release_id: str) -> Optional[str]: + """Get cover art URL from Cover Art Archive.""" + try: + response = await self.client.get( + f"{self.CAA_API}/release/{release_id}", + follow_redirects=True + ) + + if response.status_code != 200: + return None + + data = response.json() + images = data.get("images", []) + + # Get front cover, prefer large size + for img in images: + if img.get("front"): + # Prefer 500px version for quality/speed balance + thumbnails = img.get("thumbnails", {}) + return thumbnails.get("500") or thumbnails.get("large") or img.get("image") + + # Fallback to first image + if images: + return images[0].get("image") + + return None + except Exception as e: + logger.debug(f"Cover Art Archive error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +musicbrainz_service = MusicBrainzService() diff --git a/app/podcast_service.py b/app/podcast_service.py new file mode 100644 index 0000000..948162f --- /dev/null +++ b/app/podcast_service.py @@ -0,0 +1,165 @@ +""" +Podcast service using PodcastIndex API. +https://podcastindex-org.github.io/docs-api/ +""" +import httpx +import logging +import hashlib +import time +import base64 +import os +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# API Keys - MUST be set via environment variables +PODCASTINDEX_KEY = os.getenv("PODCASTINDEX_KEY", "") +PODCASTINDEX_SECRET = os.getenv("PODCASTINDEX_SECRET", "") + +class PodcastService: + """Service for searching podcasts via PodcastIndex API.""" + + BASE_URL = "https://api.podcastindex.org/api/1.0" + + def __init__(self): + self.client = httpx.AsyncClient(timeout=15.0) + self.api_key = PODCASTINDEX_KEY + self.api_secret = PODCASTINDEX_SECRET + + def _get_auth_headers(self) -> Dict[str, str]: + """Generate authentication headers for PodcastIndex API.""" + if not self.api_key or not self.api_secret: + logger.warning("PodcastIndex API keys are missing!") + return {} + + epoch_time = int(time.time()) + data_to_hash = self.api_key + self.api_secret + str(epoch_time) + sha1_hash = hashlib.sha1(data_to_hash.encode('utf-8')).hexdigest() + + return { + "X-Auth-Key": self.api_key, + "X-Auth-Date": str(epoch_time), + "Authorization": sha1_hash, + "User-Agent": "Freedify/1.0" + } + + async def search_podcasts(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search for podcasts by term.""" + try: + if not self.api_key: + logger.error("Cannot search podcasts: Missing API Key") + return [] + + params = {"q": query, "max": limit} + response = await self.client.get( + f"{self.BASE_URL}/search/byterm", + params=params, + headers=self._get_auth_headers() + ) + + if response.status_code != 200: + logger.error(f"PodcastIndex search failed: {response.status_code}") + return [] + + data = response.json() + feeds = data.get("feeds", []) + + return [self._format_podcast(feed) for feed in feeds[:limit]] + + except Exception as e: + logger.error(f"Podcast search error: {e}") + return [] + + async def get_podcast_episodes(self, feed_id: str, limit: int = 50) -> Optional[Dict[str, Any]]: + """Get episodes for a podcast by feed ID.""" + try: + if not self.api_key: + return None + + # First get feed info + feed_response = await self.client.get( + f"{self.BASE_URL}/podcasts/byfeedid", + params={"id": feed_id}, + headers=self._get_auth_headers() + ) + + if feed_response.status_code != 200: + logger.error(f"Failed to get feed info: {feed_response.status_code}") + return None + + feed_data = feed_response.json().get("feed", {}) + + # Get episodes + episodes_response = await self.client.get( + f"{self.BASE_URL}/episodes/byfeedid", + params={"id": feed_id, "max": limit}, + headers=self._get_auth_headers() + ) + + if episodes_response.status_code != 200: + logger.error(f"Failed to get episodes: {episodes_response.status_code}") + return None + + episodes_data = episodes_response.json().get("items", []) + + # Format episodes as tracks + tracks = [] + for ep in episodes_data: + audio_url = ep.get("enclosureUrl") + if not audio_url: + continue + + # Create ID that audio_service can decode (LINK:base64) + safe_id = f"LINK:{base64.urlsafe_b64encode(audio_url.encode()).decode()}" + + duration_s = ep.get("duration", 0) + duration_str = f"{int(duration_s // 60)}:{int(duration_s % 60):02d}" if duration_s else "0:00" + + tracks.append({ + "id": safe_id, + "type": "track", + "name": ep.get("title", "Unknown Episode"), + "artists": feed_data.get("author") or feed_data.get("title", "Unknown"), + "album": feed_data.get("title", "Podcast"), + "album_art": ep.get("image") or feed_data.get("image") or "/static/icon.svg", + "duration": duration_str, + "isrc": safe_id, + "source": "podcast", + # Metadata for Info Modal + "description": ep.get("description", ""), + "datePublished": ep.get("datePublishedPretty", "") + }) + + return { + "id": f"pod_{feed_id}", + "type": "album", + "name": feed_data.get("title", "Unknown Podcast"), + "artists": feed_data.get("author") or "Podcast", + "image": feed_data.get("image") or "/static/icon.svg", + "album_art": feed_data.get("image") or "/static/icon.svg", + "tracks": tracks, + "total_tracks": len(tracks), + "source": "podcast" + } + + except Exception as e: + logger.error(f"Error fetching episodes for feed {feed_id}: {e}") + return None + + def _format_podcast(self, feed: dict) -> dict: + """Format PodcastIndex feed to app format.""" + return { + "id": f"pod_{feed.get('id')}", + "type": "album", + "is_podcast": True, + "name": feed.get("title", "Unknown Podcast"), + "artists": feed.get("author") or feed.get("ownerName", "Unknown"), + "album_art": feed.get("image") or feed.get("artwork") or "/static/icon.svg", + "description": feed.get("description", "")[:150], + "source": "podcast" + } + + async def close(self): + await self.client.aclose() + +podcast_service = PodcastService() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..c75bf4c --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +httpx[socks]>=0.25.0 +aiofiles>=23.2.0 +ffmpeg-python>=0.2.0 +mutagen>=1.47.0 +pyotp>=2.9.0 +requests>=2.31.0 +google-generativeai>=0.8.0 +python-dotenv>=1.0.0 +yt-dlp>=2024.1.0 +ytmusicapi>=1.8.0 +packaging>=23.0 +beautifulsoup4>=4.12.0 diff --git a/app/setlist_service.py b/app/setlist_service.py new file mode 100644 index 0000000..cc44b23 --- /dev/null +++ b/app/setlist_service.py @@ -0,0 +1,310 @@ +""" +Setlist.fm service for Freedify. +Searches for concert setlists and matches them to audio sources (Phish.in, Archive.org). +""" +import os +import httpx +from typing import Optional, Dict, List, Any +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +# API key from environment +SETLIST_FM_API_KEY = os.getenv("SETLIST_FM_API_KEY", "") + + +class SetlistService: + """Service for searching and retrieving concert setlists from Setlist.fm.""" + + API_BASE = "https://api.setlist.fm/rest/1.0" + + def __init__(self): + self.client = httpx.AsyncClient( + timeout=15.0, + headers={ + "Accept": "application/json", + "x-api-key": SETLIST_FM_API_KEY + } + ) + + async def search_setlists(self, query: str, page: int = 1) -> List[Dict[str, Any]]: + """Search for setlists by artist name or date. + + Examples: + "Grateful Dead" - search by artist + "Phish 2023" - artist + year + "Pearl Jam 1991-09-20" - specific date + """ + if not SETLIST_FM_API_KEY: + logger.warning("Setlist.fm API key not configured") + return [] + + try: + # Parse query for artist and potential date + # Parse query for artist and potential date + params = {"p": page} + + # Helper to strip date parts from query to get artist name + def clean_query(q, match_str): + return q.replace(match_str, "").strip() + + import re + + # Pattern 1: YYYY-MM-DD + date_match_iso = re.search(r'(\d{4})-(\d{2})-(\d{2})', query) + + # Pattern 2: DD-MM-YYYY (what user tried) + date_match_eu = re.search(r'(\d{2})-(\d{2})-(\d{4})', query) + + # Pattern 3: Month name and day + month_match = re.search(r'(?i)\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:st|nd|rd|th)?(?:,)?\s*(\d{4})?', query) + + # Pattern 4: Simple year + year_match = re.search(r'\b(19|20)\d{2}\b', query) + + if date_match_iso: + # YYYY-MM-DD -> API needs dd-MM-yyyy + dt = datetime.strptime(date_match_iso.group(0), "%Y-%m-%d") + params["date"] = dt.strftime("%d-%m-%Y") + params["artistName"] = clean_query(query, date_match_iso.group(0)) + + elif date_match_eu: + # DD-MM-YYYY -> API needs dd-MM-yyyy (pass as is, or reformat to ensure validity) + # User typed: 31-12-2025 + try: + dt = datetime.strptime(date_match_eu.group(0), "%d-%m-%Y") + params["date"] = dt.strftime("%d-%m-%Y") + params["artistName"] = clean_query(query, date_match_eu.group(0)) + except ValueError: + # Invalid date (e.g. 99-99-2025), fallback to year or artist + logger.warning(f"Invalid date in query: {date_match_eu.group(0)}") + if year_match: + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + else: + params["artistName"] = query + + elif month_match: + # Month Day [Year] + try: + month_str = month_match.group(1) + day_str = month_match.group(2) + year_str = month_match.group(3) + + # Convert month name to number + dt_str = f"{month_str} {day_str} {year_str if year_str else '2000'}" # Dummy year if missing + dt = datetime.strptime(dt_str, "%b %d %Y") + + if year_str: + # Full date found + params["date"] = dt.strftime("%d-%m-%Y") + else: + # Recursive search or just month param? Setlist API only supports full date or year + # If year is missing in "Phish December 31", we might need to guess current year or search by year + # For now, let's assume current year if user says "Phish December 31" + current_year = datetime.now().year + params["date"] = dt.strftime(f"%d-%m-{current_year}") + + params["artistName"] = clean_query(query, month_match.group(0)) + except Exception as e: + logger.warning(f"Date parse error: {e}") + # Fallback to year only if possible + if year_match: + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + else: + params["artistName"] = query + + elif year_match: + # Year only + params["year"] = year_match.group(0) + params["artistName"] = clean_query(query, year_match.group(0)) + + else: + # Just artist name + params["artistName"] = query + + logger.info(f"Searching Setlist.fm: {params}") + response = await self.client.get(f"{self.API_BASE}/search/setlists", params=params) + + if response.status_code == 404: + return [] + + response.raise_for_status() + data = response.json() + + setlists = data.get("setlist", []) + return [self._format_setlist(s) for s in setlists[:20]] + + except Exception as e: + logger.error(f"Setlist.fm search error: {e}") + return [] + + async def get_setlist(self, setlist_id: str) -> Optional[Dict[str, Any]]: + """Get full setlist details by ID.""" + if not SETLIST_FM_API_KEY: + return None + + try: + response = await self.client.get(f"{self.API_BASE}/setlist/{setlist_id}") + + if response.status_code != 200: + return None + + data = response.json() + return self._format_setlist_detail(data) + + except Exception as e: + logger.error(f"Setlist.fm get_setlist error: {e}") + return None + + def _format_setlist(self, item: dict) -> dict: + """Format setlist data for search results.""" + artist = item.get("artist", {}) + venue = item.get("venue", {}) + city = venue.get("city", {}) + + # Parse date (format: DD-MM-YYYY) + event_date = item.get("eventDate", "") + formatted_date = "" + iso_date = "" + if event_date: + try: + dt = datetime.strptime(event_date, "%d-%m-%Y") + formatted_date = dt.strftime("%B %d, %Y") + iso_date = dt.strftime("%Y-%m-%d") + except: + formatted_date = event_date + + # Count songs + song_count = 0 + for setlist_set in item.get("sets", {}).get("set", []): + song_count += len(setlist_set.get("song", [])) + + return { + "id": f"setlist_{item.get('id', '')}", + "type": "setlist", + "name": f"{artist.get('name', 'Unknown')} at {venue.get('name', 'Unknown Venue')}", + "artists": artist.get("name", ""), + "artist_mbid": artist.get("mbid", ""), + "venue": venue.get("name", ""), + "city": f"{city.get('name', '')}, {city.get('stateCode', '')} {city.get('country', {}).get('code', '')}".strip(", "), + "date": formatted_date, + "iso_date": iso_date, + "song_count": song_count, + "setlist_id": item.get("id", ""), + "url": item.get("url", ""), + "source": "setlist.fm", + # For display + "album_art": "/static/icon.svg", # Use default icon + "total_tracks": song_count, + "release_date": iso_date, + } + + def _format_setlist_detail(self, item: dict) -> dict: + """Format full setlist with all songs.""" + base = self._format_setlist(item) + + # Extract all songs from all sets + tracks = [] + set_idx = 0 + for setlist_set in item.get("sets", {}).get("set", []): + set_name = setlist_set.get("name") or f"Set {set_idx + 1}" + if setlist_set.get("encore"): + set_name = "Encore" + + for song in setlist_set.get("song", []): + song_name = song.get("name", "Unknown") + + # Build track info + track = { + "id": f"setlist_song_{base['setlist_id']}_{len(tracks)}", + "name": song_name, + "artists": base["artists"], + "set_name": set_name, + "with_info": song.get("with", {}).get("name"), # Guest artist + "cover_info": song.get("cover", {}).get("name"), # Original artist if cover + "info": song.get("info", ""), # Additional notes + "duration": "", # Setlist.fm doesn't have duration + "type": "track", + "source": "setlist.fm", + } + tracks.append(track) + + set_idx += 1 + + base["tracks"] = tracks + base["type"] = "album" # Treat as album for detail view + + # Determine audio source + artist_lower = base["artists"].lower() + if "phish" in artist_lower: + base["audio_source"] = "phish.in" + base["audio_url"] = f"https://phish.in/{base['iso_date']}" + else: + base["audio_source"] = "archive.org" + # We'll set audio_url after searching for the best version + base["audio_search"] = f"{base['artists']} {base['iso_date']}" + + return base + + async def find_best_archive_show(self, artist: str, iso_date: str) -> Optional[str]: + """Search Archive.org for the best (most downloaded) version of a show.""" + try: + # Map common artist names to Archive.org collections + artist_lower = artist.lower() + collection = None + + collection_map = { + "grateful dead": "GratefulDead", + "dead": "GratefulDead", + "billy strings": "BillyStrings", + "ween": "Ween", + "king gizzard": "KingGizzardAndTheLizardWizard", + "kglw": "KingGizzardAndTheLizardWizard", + } + + for key, val in collection_map.items(): + if key in artist_lower: + collection = val + break + + if not collection: + # Fallback: search by creator + query = f'creator:"{artist}" AND date:{iso_date}* AND mediatype:etree' + else: + query = f'collection:{collection} AND date:{iso_date}* AND mediatype:etree' + + params = { + "q": query, + "fl[]": ["identifier", "downloads"], + "sort[]": "downloads desc", # Sort by most downloads + "rows": 1, # Just get the top one + "output": "json", + } + + response = await self.client.get("https://archive.org/advancedsearch.php", params=params) + if response.status_code != 200: + return None + + data = response.json() + docs = data.get("response", {}).get("docs", []) + + if docs: + identifier = docs[0].get("identifier") + return f"https://archive.org/details/{identifier}" + + return None + + except Exception as e: + logger.error(f"Archive.org search error: {e}") + return None + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + +# Singleton instance +setlist_service = SetlistService() diff --git a/app/spotify_service.py b/app/spotify_service.py new file mode 100644 index 0000000..8e8df89 --- /dev/null +++ b/app/spotify_service.py @@ -0,0 +1,496 @@ +""" +Spotify service for Freedify. +Provides playlist/album fetching and URL parsing. +ONLY used when a Spotify URL is pasted - not for search (to avoid rate limits). +""" +import httpx +import re +from typing import Optional, Dict, List, Any, Tuple +import logging +from random import randrange + +logger = logging.getLogger(__name__) + + +def get_random_user_agent(): + return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}" + + +class SpotifyService: + """Service for fetching metadata from Spotify URLs (not for search).""" + + TOKEN_URL = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player" + AUTH_URL = "https://accounts.spotify.com/api/token" + API_BASE = "https://api.spotify.com/v1" + + # Regex patterns for Spotify URLs + URL_PATTERNS = { + 'track': re.compile(r'(?:spotify\.com/track/|spotify:track:)([a-zA-Z0-9]+)'), + 'album': re.compile(r'(?:spotify\.com/album/|spotify:album:)([a-zA-Z0-9]+)'), + 'playlist': re.compile(r'(?:spotify\.com/playlist/|spotify:playlist:)([a-zA-Z0-9]+)'), + 'artist': re.compile(r'(?:spotify\.com/artist/|spotify:artist:)([a-zA-Z0-9]+)'), + } + + def __init__(self): + import os + self.access_token: Optional[str] = None + self.client_id = os.environ.get("SPOTIFY_CLIENT_ID") + self.client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET") + self.sp_dc = os.environ.get("SPOTIFY_SP_DC") + self.client = httpx.AsyncClient(timeout=30.0) + + async def _get_access_token(self) -> str: + """Get access token (Client Creds > Cookie > Web Player > Embed).""" + if self.access_token: + return self.access_token + + # 1. Try Client Credentials Flow + if self.client_id and self.client_secret: + try: + import base64 + auth_str = f"{self.client_id}:{self.client_secret}" + b64_auth = base64.b64encode(auth_str.encode()).decode() + + headers = { + "Authorization": f"Basic {b64_auth}", + "Content-Type": "application/x-www-form-urlencoded" + } + data = {"grant_type": "client_credentials"} + + response = await self.client.post(self.AUTH_URL, headers=headers, data=data) + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data.get("access_token") + logger.info("Got Spotify token via Client Credentials") + return self.access_token + except Exception as e: + logger.error(f"Client Credentials auth failed: {e}") + + # 2. Try Cookie Auth (sp_dc) - Mimics logged-in Web Player + # This is the best fallback if Developer App creation is blocked + cookies = None + if self.sp_dc: + cookies = {"sp_dc": self.sp_dc} + logger.info("Using provided sp_dc cookie for authentication") + + # 3. Web Player Token (Anonymous or Authenticated via Cookie) + headers = { + "User-Agent": get_random_user_agent(), + "Accept": "application/json", + "Referer": "https://open.spotify.com/", + } + + try: + # If cookies are passed, this request becomes authenticated! + response = await self.client.get(self.TOKEN_URL, headers=headers, cookies=cookies) + if response.status_code == 200: + data = response.json() + self.access_token = data.get("accessToken") + if self.access_token: + logger.info(f"Got Spotify token via Web Player ({'Authenticated' if cookies else 'Anonymous'})") + return self.access_token + except Exception as e: + logger.warning(f"Web Player token fetch failed: {e}") + + # 4. Fallback: Embed Page + if response.status_code == 200: + data = response.json() + self.access_token = data.get("accessToken") + if self.access_token: + logger.info("Got Spotify token via direct method") + return self.access_token + except Exception as e: + logger.warning(f"Direct token fetch failed: {e}") + + # 3. Fallback: Embed Page + try: + embed_url = "https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT" + response = await self.client.get(embed_url, headers={"User-Agent": get_random_user_agent()}) + if response.status_code == 200: + token_match = re.search(r'"accessToken":"([^"]+)"', response.text) + if token_match: + self.access_token = token_match.group(1) + logger.info("Got Spotify token via embed page") + return self.access_token + except Exception as e: + logger.warning(f"Embed token fetch failed: {e}") + + raise Exception("Failed to get Spotify access token") + + async def _api_request(self, endpoint: str, params: dict = None) -> dict: + """Make authenticated API request with rate limit handling.""" + import asyncio + + max_retries = 3 + retry_delay = 2 + + for attempt in range(max_retries): + token = await self._get_access_token() + headers = { + "Authorization": f"Bearer {token}", + "User-Agent": get_random_user_agent(), + "Accept": "application/json", + } + response = await self.client.get(f"{self.API_BASE}{endpoint}", headers=headers, params=params) + + if response.status_code == 401: + logger.warning("Got 401, refreshing Spotify token...") + self.access_token = None + continue + + if response.status_code == 429: + retry_after = min(int(response.headers.get("Retry-After", retry_delay)), 10) + logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry {attempt + 1}/{max_retries}") + await asyncio.sleep(retry_after) + retry_delay *= 2 + continue + + response.raise_for_status() + return response.json() + + response.raise_for_status() + return response.json() + + def parse_spotify_url(self, url: str) -> Optional[Tuple[str, str]]: + """Parse Spotify URL and return (type, id) or None.""" + for url_type, pattern in self.URL_PATTERNS.items(): + match = pattern.search(url) + if match: + return (url_type, match.group(1)) + return None + + def is_spotify_url(self, url: str) -> bool: + """Check if a URL is a Spotify URL.""" + return 'spotify.com/' in url or 'spotify:' in url + + # ========== TRACK METHODS ========== + + async def get_track_by_id(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get a single track by ID.""" + try: + data = await self._api_request(f"/tracks/{track_id}", {"market": "US"}) + return self._format_track(data) + except: + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend.""" + return { + "id": item["id"], + "type": "track", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item["artists"]), + "artist_names": [a["name"] for a in item["artists"]], + "album": item["album"]["name"], + "album_id": item["album"]["id"], + "album_art": self._get_best_image(item["album"]["images"]), + "duration_ms": item["duration_ms"], + "duration": self._format_duration(item["duration_ms"]), + "isrc": item.get("external_ids", {}).get("isrc"), + "source": "spotify", + } + + # ========== ALBUM METHODS ========== + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album with all tracks.""" + try: + data = await self._api_request(f"/albums/{album_id}", {"market": "US"}) + album = self._format_album(data) + + tracks = [] + for item in data.get("tracks", {}).get("items", []): + track = { + "id": item["id"], + "type": "track", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item["artists"]), + "artist_names": [a["name"] for a in item["artists"]], + "album": data["name"], + "album_id": album_id, + "album_art": album["album_art"], + "duration_ms": item["duration_ms"], + "duration": self._format_duration(item["duration_ms"]), + "isrc": None, + "source": "spotify", + } + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"Error fetching Spotify album {album_id}: {e}") + return None + + def _format_album(self, item: dict) -> dict: + return { + "id": item["id"], + "type": "album", + "name": item["name"], + "artists": ", ".join(a["name"] for a in item.get("artists", [])), + "album_art": self._get_best_image(item.get("images", [])), + "release_date": item.get("release_date", ""), + "total_tracks": item.get("total_tracks", 0), + "source": "spotify", + } + + # ========== PLAYLIST METHODS ========== + + async def get_playlist(self, playlist_id: str) -> Optional[Dict[str, Any]]: + """Get playlist with all tracks.""" + try: + data = await self._api_request(f"/playlists/{playlist_id}", {"market": "US"}) + + playlist = { + "id": data["id"], + "type": "playlist", + "name": data["name"], + "description": data.get("description", ""), + "album_art": self._get_best_image(data.get("images", [])), + "owner": data.get("owner", {}).get("display_name", ""), + "total_tracks": data.get("tracks", {}).get("total", 0), + "source": "spotify", + } + + tracks = [] + for item in data.get("tracks", {}).get("items", []): + track_data = item.get("track") + if track_data and track_data.get("id"): + tracks.append(self._format_track(track_data)) + + playlist["tracks"] = tracks + return playlist + except Exception as e: + logger.error(f"Error fetching Spotify playlist {playlist_id}: {e}") + return None + + # ========== ARTIST METHODS ========== + + async def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get artist info with top tracks.""" + try: + artist_data = await self._api_request(f"/artists/{artist_id}") + artist = { + "id": artist_data["id"], + "type": "artist", + "name": artist_data["name"], + "image": self._get_best_image(artist_data.get("images", [])), + "genres": artist_data.get("genres", []), + "followers": artist_data.get("followers", {}).get("total", 0), + "source": "spotify", + } + + top_tracks = await self._api_request(f"/artists/{artist_id}/top-tracks", {"market": "US"}) + artist["tracks"] = [self._format_track(t) for t in top_tracks.get("tracks", [])] + + return artist + except Exception as e: + logger.error(f"Error fetching Spotify artist {artist_id}: {e}") + return None + + # ========== AUDIO FEATURES & CAMELOT ========== + + # Camelot Wheel: Maps (pitch_class, mode) to Camelot notation + # pitch_class: 0=C, 1=C#, 2=D, ..., 11=B + # mode: 1=Major (B), 0=Minor (A) + CAMELOT_MAP = { + (0, 1): "8B", (0, 0): "5A", # C Major / C Minor + (1, 1): "3B", (1, 0): "12A", # C# Major / C# Minor + (2, 1): "10B", (2, 0): "7A", # D Major / D Minor + (3, 1): "5B", (3, 0): "2A", # D# Major / D# Minor + (4, 1): "12B", (4, 0): "9A", # E Major / E Minor + (5, 1): "7B", (5, 0): "4A", # F Major / F Minor + (6, 1): "2B", (6, 0): "11A", # F# Major / F# Minor + (7, 1): "9B", (7, 0): "6A", # G Major / G Minor + (8, 1): "4B", (8, 0): "1A", # G# Major / G# Minor + (9, 1): "11B", (9, 0): "8A", # A Major / A Minor + (10, 1): "6B", (10, 0): "3A", # A# Major / A# Minor + (11, 1): "1B", (11, 0): "10A", # B Major / B Minor + } + + def _to_camelot(self, key: int, mode: int) -> str: + """Convert Spotify key/mode to Camelot notation.""" + return self.CAMELOT_MAP.get((key, mode), "?") + + async def search_track_by_isrc(self, isrc: str) -> Optional[str]: + """Search for a track by ISRC and return Spotify track ID.""" + try: + data = await self._api_request("/search", {"q": f"isrc:{isrc}", "type": "track", "limit": 1}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + except Exception as e: + logger.warning(f"ISRC search failed for {isrc}: {e}") + return None + + async def search_track_by_name(self, name: str, artist: str) -> Optional[str]: + """Search for a track by name and artist, return Spotify track ID.""" + try: + # 1. Try strict search first + query = f"track:{name} artist:{artist}" + data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + + # 2. Fallback to loose search (just string matching) + # Remove special chars and extra artists for better matching + clean_name = name.split('(')[0].split('-')[0].strip() + clean_artist = artist.split(',')[0].strip() + query = f"{clean_name} {clean_artist}" + data = await self._api_request("/search", {"q": query, "type": "track", "limit": 1, "market": "US"}) + tracks = data.get("tracks", {}).get("items", []) + if tracks: + return tracks[0].get("id") + + except Exception as e: + logger.warning(f"Name search failed for {name} by {artist}: {e}") + return None + + async def get_audio_features(self, track_id: str, isrc: str = None, name: str = None, artist: str = None) -> Optional[Dict[str, Any]]: + """Get audio features (BPM, key, energy) for a single track. + + If track_id starts with 'dz_' (Deezer), will try ISRC or name/artist lookup first. + """ + spotify_id = track_id + + # Handle Deezer tracks - need to find Spotify equivalent + if track_id.startswith("dz_"): + spotify_id = None + # Try ISRC first + if isrc: + spotify_id = await self.search_track_by_isrc(isrc) + # Fallback to name/artist search + if not spotify_id and name and artist: + spotify_id = await self.search_track_by_name(name, artist) + + if not spotify_id: + logger.warning(f"Could not find Spotify ID for Deezer track {track_id}") + return None + + try: + data = await self._api_request(f"/audio-features/{spotify_id}") + return self._format_audio_features(data) + except Exception as e: + # 403 Forbidden likely means token lacks permission (scraper token) or ID is invalid + if "403" in str(e): + logger.warning(f"Spotify 403 Forbidden for {spotify_id} (token permissions?)") + else: + logger.error(f"Error fetching audio features for {spotify_id}: {e}") + return None + + + async def get_audio_features_batch(self, track_ids: List[str]) -> List[Optional[Dict[str, Any]]]: + """Get audio features for multiple tracks (max 100 per request).""" + if not track_ids: + return [] + + # Spotify API limit is 100 tracks per request + results = [] + for i in range(0, len(track_ids), 100): + batch = track_ids[i:i+100] + try: + data = await self._api_request("/audio-features", {"ids": ",".join(batch)}) + for features in data.get("audio_features", []): + if features: + results.append(self._format_audio_features(features)) + else: + results.append(None) + except Exception as e: + logger.error(f"Error fetching batch audio features: {e}") + results.extend([None] * len(batch)) + + return results + + def _format_audio_features(self, data: dict) -> dict: + """Format audio features for frontend.""" + key = data.get("key", -1) + mode = data.get("mode", 0) + return { + "track_id": data.get("id"), + "bpm": round(data.get("tempo", 0)), + "key": key, + "mode": mode, + "camelot": self._to_camelot(key, mode) if key >= 0 else "?", + "energy": round(data.get("energy", 0), 2), + "danceability": round(data.get("danceability", 0), 2), + "valence": round(data.get("valence", 0), 2), # "happiness" + } + + # ========== UTILITIES ========== + + def _get_best_image(self, images: List[Dict]) -> Optional[str]: + if not images: + return None + sorted_images = sorted(images, key=lambda x: x.get("width", 0), reverse=True) + return sorted_images[0]["url"] if sorted_images else None + + def _format_duration(self, ms: int) -> str: + seconds = ms // 1000 + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + async def get_made_for_you_playlists(self) -> List[Dict[str, Any]]: + """ + Get 'Made For You' playlists (Daily Mix, Discover Weekly, etc.). + Uses search API with strict filtering for Spotify-owned playlists. + Requires authenticated token (sp_dc cookie). + """ + try: + # Check if we have a valid token (will try cookie auth) + token = await self._get_access_token() + if not token: + logger.warning("No Spotify token available for Made For You") + return [] + + mixes = [] + queries = ["Daily Mix", "Discover Weekly", "Release Radar", "On Repeat", "Repeat Rewind"] + + for q in queries: + try: + data = await self._api_request("/search", {"q": q, "type": "playlist", "limit": 10}) + if not data: + continue + + items = data.get("playlists", {}).get("items", []) + for item in items: + if not item: + continue + + owner_id = item.get("owner", {}).get("id", "") + name = item.get("name", "") + + # Filter: owned by "spotify" OR name starts with one of our keywords + # (Daily Mix 1, Daily Mix 2, etc. are personalized) + is_spotify_owned = owner_id == "spotify" + name_matches = any(name.startswith(kw) or name == kw for kw in queries) + + if is_spotify_owned or name_matches: + mixes.append({ + "id": item["id"], + "name": name, + "description": item.get("description", ""), + "image": self._get_best_image(item.get("images", [])), + "owner": "Spotify", + "type": "playlist", + "source": "spotify" + }) + except Exception as e: + logger.warning(f"Failed to fetch mix '{q}': {e}") + + # Deduplicate by ID + unique_mixes = {m['id']: m for m in mixes}.values() + logger.info(f"Found {len(list(unique_mixes))} Made For You playlists") + return list(unique_mixes) + + except Exception as e: + logger.error(f"Error fetching Made For You playlists: {e}") + return [] + + async def close(self): + await self.client.aclose() + + +# Singleton instance +spotify_service = SpotifyService() diff --git a/app/ytmusic_service.py b/app/ytmusic_service.py new file mode 100644 index 0000000..e9bffc6 --- /dev/null +++ b/app/ytmusic_service.py @@ -0,0 +1,158 @@ +""" +YouTube Music service for Freedify. +Uses ytmusicapi for searching YouTube Music catalog. +Streaming is handled by existing audio_service (yt-dlp). +""" +from ytmusicapi import YTMusic +from typing import Optional, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + + +class YTMusicService: + """Service for searching YouTube Music.""" + + def __init__(self): + # Initialize without auth (works for search) + self.ytm = YTMusic() + + async def search_tracks(self, query: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]: + """Search for songs on YouTube Music.""" + try: + # YTMusic doesn't have native offset, so we fetch more and slice + total_needed = offset + limit + results = self.ytm.search(query, filter="songs", limit=total_needed) + # Slice to get the offset range + sliced = results[offset:offset + limit] if offset > 0 else results[:limit] + return [self._format_track(item) for item in sliced if item.get("videoId")] + except Exception as e: + logger.error(f"YTMusic search error: {e}") + return [] + + async def search_albums(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search for albums on YouTube Music.""" + try: + results = self.ytm.search(query, filter="albums", limit=limit) + return [self._format_album(item) for item in results if item.get("browseId")] + except Exception as e: + logger.error(f"YTMusic album search error: {e}") + return [] + + async def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: + """Get album details with tracks.""" + try: + # Remove ytm_ prefix if present + clean_id = album_id.replace("ytm_", "") + data = self.ytm.get_album(clean_id) + + album = { + "id": f"ytm_{clean_id}", + "type": "album", + "name": data.get("title", ""), + "artists": ", ".join([a.get("name", "") for a in data.get("artists", [])]), + "album_art": self._get_thumbnail(data.get("thumbnails")), + "total_tracks": data.get("trackCount", 0), + "release_date": data.get("year", ""), + "source": "ytmusic", + } + + tracks = [] + for item in data.get("tracks", []): + if not item.get("videoId"): + continue + track = self._format_track(item) + track["album"] = album["name"] + track["album_art"] = album["album_art"] + tracks.append(track) + + album["tracks"] = tracks + return album + except Exception as e: + logger.error(f"YTMusic get_album error: {e}") + return None + + def _format_track(self, item: dict) -> dict: + """Format track data for frontend.""" + artists = item.get("artists", []) + artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else "" + + # Get album info if available + album = item.get("album", {}) or {} + + # Duration can be in seconds or as string "3:45" + duration_str = item.get("duration", "0:00") + duration_ms = self._parse_duration(duration_str) + + return { + "id": f"ytm_{item.get('videoId', '')}", + "type": "track", + "name": item.get("title", ""), + "artists": artist_str, + "artist_names": [a.get("name", "") for a in artists], + "album": album.get("name", "") if isinstance(album, dict) else str(album), + "album_id": f"ytm_{album.get('id', '')}" if isinstance(album, dict) else "", + "album_art": self._get_thumbnail(item.get("thumbnails")), + "duration_ms": duration_ms, + "duration": duration_str if isinstance(duration_str, str) else self._format_duration(duration_ms), + "isrc": f"ytm_{item.get('videoId', '')}", # Use prefixed videoId for streaming + "source": "ytmusic", + "video_id": item.get("videoId", ""), # Keep for reference + } + + def _format_album(self, item: dict) -> dict: + """Format album data for frontend.""" + artists = item.get("artists", []) + artist_str = ", ".join([a.get("name", "") for a in artists]) if artists else "" + + return { + "id": f"ytm_{item.get('browseId', '')}", + "type": "album", + "name": item.get("title", ""), + "artists": artist_str, + "album_art": self._get_thumbnail(item.get("thumbnails")), + "release_date": item.get("year", ""), + "source": "ytmusic", + } + + def _get_thumbnail(self, thumbnails: list) -> str: + """Get highest quality thumbnail.""" + if not thumbnails: + return "/static/icon.svg" + # Sort by width descending and get the largest + sorted_thumbs = sorted(thumbnails, key=lambda x: x.get("width", 0), reverse=True) + url = sorted_thumbs[0].get("url", "/static/icon.svg") + + # Proxy googleusercontent images to avoid 429 + if "googleusercontent.com" in url or "ggpht.com" in url: + import urllib.parse + return f"/api/proxy_image?url={urllib.parse.quote(url)}" + + return url + + def _parse_duration(self, duration) -> int: + """Parse duration string to milliseconds.""" + if isinstance(duration, int): + return duration * 1000 + if not duration or not isinstance(duration, str): + return 0 + try: + parts = duration.split(":") + if len(parts) == 2: + return (int(parts[0]) * 60 + int(parts[1])) * 1000 + elif len(parts) == 3: + return (int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])) * 1000 + return 0 + except: + return 0 + + def _format_duration(self, ms: int) -> str: + """Format milliseconds to mm:ss.""" + seconds = ms // 1000 + mins = seconds // 60 + secs = seconds % 60 + return f"{mins}:{secs:02d}" + + +# Singleton instance +ytmusic_service = YTMusicService() diff --git a/check.json b/check.json new file mode 100644 index 0000000..e69de29 diff --git a/icons/circle-x.svg b/icons/circle-x.svg new file mode 100644 index 0000000..3625f95 --- /dev/null +++ b/icons/circle-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/deezer.png b/icons/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..db9cdd1c94bd95713362734551d223e0e0cfa50f GIT binary patch literal 6504 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 literal 0 HcmV?d00001 diff --git a/icons/download.svg b/icons/download.svg new file mode 100644 index 0000000..c77f62c --- /dev/null +++ b/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8686c1648ed35babc7dedd6c8dada32adee2e183 GIT binary patch literal 172823 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
    9Y2IE^?qrk*&*asY5seKT!}V~13Cbl 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 D+yqex literal 0 HcmV?d00001 diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..455ce78 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/tidal.png b/icons/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..8d37f2b6de5fa8ab12d0d31181b8493b1964c08b GIT binary patch literal 6252 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 A0RR91 literal 0 HcmV?d00001 diff --git a/icons/tool.svg b/icons/tool.svg new file mode 100644 index 0000000..29229f9 --- /dev/null +++ b/icons/tool.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/trash.svg b/icons/trash.svg new file mode 100644 index 0000000..20fb5cb --- /dev/null +++ b/icons/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..dbc0c26 --- /dev/null +++ b/render.yaml @@ -0,0 +1,12 @@ +# Render deployment configuration for Freedify + +services: + - type: web + name: freedify + runtime: docker + plan: free + envVars: + - key: MP3_BITRATE + value: "320k" + healthCheckPath: /api/health + autoDeploy: true diff --git a/screenshots/album-details.png b/screenshots/album-details.png new file mode 100644 index 0000000000000000000000000000000000000000..9e60bc6d825dc4f0989824e27ab83c709f3935c8 GIT binary patch literal 180003 zcmeFZbx>Se`zE@(f#B|L0RlmTOP~pw5Hth`uEB!4HtrfhfcIl=M?=0RSEFTv}4yU4J(X)lYqF3Z9WXCCjBo^vU?*RSsDvfk~wC zZl+%Z8KjehpMqZ&ALK zL&(3bMf5N6N=RGI82Rc1KTMg_@<-V^M=Zd8WLJGDHIfx?Iq1Ov_WkR6TdLH5?Sx@5 z-D0pGUqJy|*}Ex*kDoZ+Fez87{nfX#vrkoR_}P6_=W?Oxd#aEB>gtF58N*+_0OMyO zmyE}in!5T&{%I3IEg-CW#bUwNhlq$sVA@m&@86q<9tV-nZ1ff;VNVLIT*WL}RkZ^_ z8sWv-RdN+?i<4(s-XD4h|DoHc3|(?j+jlce{-=g!L4Ka-01AqX#Y{DC6Wpa3XMyG4 zlHo7QtfEm(gh>i72u7lGTxAD7e&mxY)S!-&C*oTbZw&dvq~r~m(A6~(OqSXVR9?S+ za}e)H07OK*7_8o_k86a|{9*ke;KEplnHdTR`z87h)9?Bp@?HUt*sMaTZA9s5lt--% z(_j8;&k(y-(be6H{oyqEA&*mmH3%0IQ!$ZBQ~-+! zTRkFDK?A4s7+(5+Pa8ylhQ|Kp3GJ}~r(7`@f>_kSZ;IWzU5mnh9=$(=?US_S)^2J9 zc$^Jz-Ylb^xhoFoT;X{B?{l3gj@r= zDyrq)!MyqOK!-7p^Zu*}c9MARzr`5wpEyhqD_X5#>%bWy@R{E_T);+T?TPrnAE&5= zgeK47SLXyp48FicfS&>AxFp5*L;Zu`#6jSATD6Co>;}z3ZlFw)?h^Tj7{?pv!ms|R zef~=yscUE$&NtFJ5ds~Dq@e%R6Y($C5)Z5tk$U7o7E|A;S0(-|8dq|VY0wKD*gLrp zV6bGa{t>5bI#jub{KG$0+h5~09LlcKs(zWN<{Y%nIjEIIVosj>=}&IT|^IUyoVu4Qiuk^S~{ zs6u#^W~e(IbW$g1>o5uR;qEApU4-;+t{vYoxWaD-%HiXo4>o9ct=LP*s$1ajHNu+A z1J((X*6H%~HyOq!)NbutQaALR#`0B+dauKenIaG8CTLf-P%~O5em*`4>FAQv5BNb! z8-mk^dAQO-3saJdadol_J+aI1YoRHNhr=g1NEb&HnAiH89J2qRY0g;er_6E&a|a}A zH?tpeJ1jF+;gs#yr#b8tn+#=1d8K-8PhB3i6Hk3Q%jn*&H!dasu5WixW+!t+Q}1H! z(px}Kv2$h&KzO%A;`Av6p)FOoGzo}q<=B*IKE;tM#6Xb7!26E3z>!RJ;}t=$#e##L zvz2B*T?RCmn}%@%-zs(TU_F=Td{FW1%z2At(mJL6=o4HPl)0qt!StIpS|#&)`0<`e=%7*6{rHEu%Ii;-WA7?e-DX`TI3}G(qR;4CZjY_0ybpmU=7rcQ zd0-MrS(!F;Z#szN-Pr9GS*;6gGKZ9(iQIO^G5(d5qWUITB7U%nqgNZDmdls&Czkh4a7H(DFHe*$#yp;OsK(TA?nL|EU*{)m z);yF!Ap_+37!v^$WyNW1!7eg#P>JPe=I_$Ci77sFU-3J?zr+X}l?E0*tJbDhSNho%LL{o#fzW1ksAxdCBQC5AGAf>0c z?MQCX9VB};TYI8CTEjn1S3cCW&m3$V;-sz_;k8E2%%m4Ps~1&W6AS)yXywac$xD-Pk5BZnp~ zD{eXZWM!`e!@_l&4MmtGZr(62zAY}_X!bH`B3XW3+JRas7Q?kZ$r>KOVSl(ZlbKBP zL<^3|EC#30wy8yNo$*zuw9)r-W(yCN@QJROhm&El?S6KtnMQA_o&-1|mLUZgUn`os z3hkf~#|5(ruVr5WR?>j_42>u@MR;hbkoFGBm%?6dV+(`Xr!H@+Dli6(J`@jH7t&aa z%*tS?v$wlt_=LGNClL$RP~wmcV%iYEEOR(z1o4*(UK~wIlva&Ko099Ni4aTlxx`_< zDEpx?V-!h~HdOE?KKg@_@l>0^(NZOJ1&UdyXtJWmojLKKn&fMS;qi$+Mu8}pPIV9@ zx;sv&Ct4*d`HbXYw7LxWy0-+9UGqJ0frc*M$AyM}?3Ohhi02R$O9(4RHZ63#SP@zN z>u^{@#OB%>aT1^96VB$#7wme|XzBg@VXfNqz&nTSCinFW(HtR-;8@$eKqSue@K-eM z)a@6$d9pLs?E@XKqXE;_^aj9w_#=4MY0sd{w~(PDjp5@94dd2}1LK~>k;8@L$o3;t z-QeVj3rTdp;Q@HCjc06Mqg!tD5sdIdnfZjqvQ+mbV|90};O@uew=Blfrxj%-lhsb& zvL@mOjN86A4K+z&K6IA)7GG2ZtMuZ6#gmE$OW;t&=x~Y?*hFCJp+G%aurefkLFb|? z`KdM9KFEIV4QbqKX-AXQbxC8-8y&~D$oP&rqrCRqkE?2TqUJ3|8hiRE<#S)dgle(+ixPq1kyZd{Ji#SiFBMFNg6SL zYE~g4!B-1n{t`6qoa=H4V&;Ql@xR$NdBZ9~JQHY!&v{$4pg!Rjq|4`uNrz=5Xu6b+ z@WMcSOk_XX+O24op&B{fF9~-+BNtnt-SCUYgD2kJx0iTDuK@P+BV^av8>-RD2}eS} z_uBRW!Opv=7^dg!T9sH^(mc~OHv3xNcrMyQc@|-q-0dSa>>$G>T=$wHUe|Xz!NW|% z7W3cuUts9Q21cN`A2v3W-E6iP-MuFw;nWRqKN+`}`;}{{zG#qp(YhAv8?CzxY%>CY zjL?04&EdqJ3=Hsf-xAvuyHxeMeqX%_J%3$$+3|FU4M&|*C#>`vGyrAOj{{nRGEEHH z>NQKUp9&W;pM?UuF{0_>q?Dh49h*v4?a0G;-I#;sYy7qBj!%8vb>eVbZPya@2xiHGzVNfxPT5^F{FhkQ4|7GeUTN*+p98O9&7*BM)T?J$=sAn zU()4$-zNK3oiS2wK@g3yqUl`c=<^h5cH}!^Pm6EJgpdP%*)3+L(skvygY} zAP5`9?KO(ht|-~7mNeX=7SeR@ZRXo%C#_EsO_$cUCxb}#_gI-%o+*p|-L%h2v43&j zZ8aE9Q%FmkMfBh7STM;zx3`MuD( zp_ou#62Em7ZTigs**k_KIHpAVV)4!LSM}PXog&bn8{a(bkyn(c4V|rzld8SZ$vCvD z!p^{M#hAP+ZIUo2w>H$)4;Z|wH$Pm*Psz*VHlNStm)#Sgq@i_g{rpqM(a)8~WNi(tcv3zzh3ZGdJVYMQWF! z`C9Xba7Q@u8P!i7`%$S{Pc7xUtC{|a}rC0*Y zpwt6zgVu6oz|)BUhuR)gxd6#Q2h7~xbu3-Z=oz%;rd)_GJ(+bdlIUxZtH*HDbElG> zJxlP)M6RasrA(JFbG!4IH@`6MM&gq6z~O(q{TNs*VBy}ec-5N?hM}+AN8{hP$~Ic@ zFsD0{^?RN}mr&XC!#%4uk5ECrM5chSPj2&b!U3QNP$xUELNCUGyt^V?iM=Fey(0WfBq zyDrT$TGQvWL0dAl`_q8@A*)=%2X^E>U8{%edyR1+?op1VM)9JGe_Q z6WPVYf|uiZHyII@CQ6}`%aPF3kmgVBrXzoERkxV$* zd*T!B-8j9&Tb6jjbLtqbZd_T+aIed2$kyL2Io`?<2ct`x%)#R<6+g8V*1sV1R)JF+L&Z*IPVz(7nBHLu)(KS5vhqjuET z==MXN4~KKR!CI9?V!0n9K3%IH8~+|d`@epcX+44Ma4}bdoPKZmBs@367iS}t7Yw}~ zp{d<2hz$G;M3Qo~3U8#wnx}iikWukf%^?VyWvpAoS`xWx<# z6uwgZnI^78J0>OO=>yHi-L})S)LCRdu8dj~ALTk4&I&rJdt7L1)N@h@#zY}tzlrC~ zId)6=7;Mq~P$AU_6WJxmC=25s^od~Z6sv&@eP(aRF@-7%;}nVk&j$&)5W!be51jOPDFD z1pH!z>}J!1&R`q?Lm#GF7a1FGSCMD=_JvLDQdKW_Pq6;6IAcnWw}N}|ad z22t0cG@e9>dal^(0|tHp0bnZkMc>-p@9~lkVc9Z~Jf5d!NkR^LVHo`;piD9;=EeX8 zw?vga-55S!Z_9_S6kvazpoj~J$*QV%0L;a&KVdIxrf^Z zrD-AABjEY+VU6tR?xR`wZ7w+&Qs5~3b|5*@wBQ_!d?4XPsnn#!fV!&{52%b<^3FcJ zlH8rjsZz)9xX6Hp#EPfHkY{*DxQSn4HDFUjj>K-3tQ0i@bUBsXOHc{Y_Uqk%Xn|c* z4w5;ZyDc5=#nJL41AJD)xSu4e|4cd+216oAIJj+Ru_sMp=jd#J!h(XsU%Ug>Rre^E zPrSlueJ`_WZ;w^utXD6$a)pJiH&?h#x-<*T_M<&UZixYbr@)9lS)i>pe=|l4{PuBg z;dgRV9_QIi%nqYe{c|Rz=~KFw=xFQAfcMGhp3v!+LRIbPTbynbJsb^qbnuIB6m`(S*a@Hs%{c_&rt4pCP0$xFJSzut2tf;VVA zGsc1ar#bq6(OQD@6eH>Nygl4MSAXwIqUKP z9=DZ=PVt&7;r^!mBUuu_^czp5Z40Nstc3}c_iVDzRh*4V=Fr1P?xRW{hw~5dpYfuv z@>jL~q3)xVGIeT7DBvwqZ&M)JcY5AOpYU*@XHpEHFEWc&6Z#?ReJM+`PFx*do`0b9 z5ic}>5n~{d#$xshuKTmpN40i5H6q_Wdki#%M7Q1LJlKwmZ1y4yJtL18fEEjvx=Ye` zwVczyN9QQ|TDZRScd|H%U2wdcQdimhob#7h+6IEq z3{Mum6`v8Oqov2)SteL{w`)kdQzv0%9CP;vN`odfCP%AH_tH1U!opBwzxNtxLpPNQ z_Giz%GR?s9n(VMIOL8j*xFIhllkzjN@7HA@j_BzWuIx%)aH&<5Ko#-_U`Qu>)P6Z> zmrO#%MoG=eQ0N7O=l$iPf!A{8&o7e!)Zy!M_A{6t%AvSBzGdI>fj;e;XKUG=c^b!{ zz!GlfZ99VM45tWKKusQ$daxhNolo@AKo%pFcBl_OwVza{W7p=2^^0+C6u z82QG(U$@bk+HEkROawyY<#y5g%K0^buug~hj(j1P8qQv~>yOsu7K1=|?g9$Wo0&UX=&bL}r%8`Mg0swxKwz-Q#f$36 zB@xzcjYRca9ukILRntUwG;DAPmtH#{b0SlRXDOmdl|if5x6_6Z9;3i=i#kN|S(M7* z$+e3&;e8{kcC6&IfNy|LeK_0u4$%pk=;nzgT5sQHARzDZoR!LR@y(?cql*CSjoW|j3$1_+hr^wMLpLm)?8`E2keqms7qkEDin8%(1pk`T5=e+M&5H&@Mu08-+c=+7-aGW-%e# z%TwZw3`74g-`XQl**RQSad;L*e(7n154YR7Z%@#h*|{`xlIVWph1IcwnQ+_?^m_o~ zhkP6Y(Z>MhWR<#7Sl-!mofTP5)=l8umNBfNG^_-3?ZY&zDm0hVc2Khp_-PU7hByD3 z>dVJl;jK6C)b}`FUv6*BaBqH0IW&3wRwpdLR@8X;%h9A6QvOF;;er(8u!X=k$1Qm(Q=hj8v4QYo~Gs*1nu5&KreV`i~1P?JdhrrL^p!pc=)$l_!Q zJmpB^qUmO((~DEmX<?nU3|SIC2y zz#DS*v|v>h+z#qj(B3eso)l#sJmDR@|h%F63iAK;+m(oMroL} z8$7vL6P_CFnY+37%ON)%H;u$G_czTJTqJA91`cD2s#+Q zIV8(QreU}(CAiXbKgEp_ENVQt1%*6L-d?7)ALB^gV-~UbP8_qHCJ6G4&qq@CTm4i!VY*)QYyOxopVD*V>>P=qG!nh9QeKQw8UQtjD4aUdS}qtx4S zWa=Dp>(iq6nomn$1AEr_z{~b?UzuD6XwtI6_4?-2O~37<=GPDYRMh1ISzbnk(IPoo zBsx1UHH_zR7Z$6`^;>Hcc&|Cf>n_~Ezx&ewdJ#JEu?nC?SmuE953}{xpK%5VKzDV* z)1mqgje~RuP=O*Snl%33a0tW~KNE;E*y)`50}>1gL_@$}cmn#;zl+X)!Ae&$eqe}i zF{!!29~!Y4AfQaiNq78zP6LtpTqOlz09RG|sT_Z35({2mys$oyd*|8JZmrlfY= zf4BhuznJjKWHBV5wG zg{gNHHmWKrkavGLNC+%F4stMmW4H)oq`1SmSb%cb)*8J;0HP@ZX?U^Sls;gpxPO|- zzc5m|hxW_i-7CwHT<@G8PUyKk?SFcqi@-FL5|I9+K9K%W9{`{Jvp;BvD_Mm4Xf7t( z_@nxuL#PkXpgZ~>G{h?9U+SaUIF;)UjeLH8sSn5s(;sFXXNT;F4LkIT&InG znVD*v@|76TY?qz!$KL9$pq`pE*ww!$QZ)8g$8;#%<7I~U)5Wr*F3OcSDV!_>@#X+a zoa$f0fN9HQa)K5|lqEV9xSE2TMX1Hx*h$*=8xCUjY8IL`NTL9(!fJHde-k6<7SIvl z%_9zu&URdhD6^jlWA4A&+Mb>6OyKU6G}vk&69LCQ=JxP^&(50IpBNp8m4FL1-<{8B zXSfAo3qbDScdS6sYtaB)sekQ2nFc`*Gaz5vLUiY~5-qsxy{_NnX@m%pCBxX8X@z`9*S!NpSLnHGM_O9>zvFnfR?%MM@oO_r6K{ILI!@^ z+#hw*I?>2)15_@fstBZ;x$93z_o5&(04 zfB%IfhyZADB~~J&@?L@YgG;}x<9*Gn<4+0>&>N&oC1)}JKS;B$D7eZ6B>z=SSUKAh z1gVPyL;5e>8UD4M2;1uker$zQ`@}65D0_11{ztp@1R<5tSRdQ}U}IsQ<^900&$Vo+ zgnxKA-~VY(zM7@sy0oqec{(n%j$`A5)Q7*lh0g_H{WY#|GRs?f@lPHg5S0k9^C!?#sD(xiXwMM2x6JVP&S2_G}@%ggkJgCxQ@A56$vI#*tMx#`q?Pum+X2V zTmad)@Yh=dGo*U~LwC~Ol0Q92;3{+=!}}Or-}lPwi>6^xmod+7^^EPC9MCueDAOoG z82egm@-B6mjb+fj6)_q%1FHGI6L2zr{Fi*c`HqZ`Z@=3&6RNtpQ6QuTnNp!R1a15YOq zh__&SMwf5@O(4sANHMY_Das>cd&=Kr2G;x6!JPFvXi5^a)9;t6v6&%3yt~g0hH9_%<#X49!1XkHn{G74F5TYA8)@9V+M*}eKgebHf53} zFt$M(O$AMhW76aAq#S?dXLKom{oUEb#Ld=#*vT(%VnE~FZK-d2i>*WUd|@Z1e5Mk( zaAHQk2CvL>Lz6{3{dZ!KHJv{*JHQvSw9sU&`L6zmdD8IiY^X%hnuwSLTux?OF65U4 zmoT=JPYR;8>`7ZGCsV_J%z_^&*%?xq`gr398PC(10risjYOF11;^k{!Djsr5$P4`; z%sscJ51L%Y=T7XL2UBDsu9>#dQTgg>2C*yz2rNuM-M zGUP=f?=DE+cF8ZeC9P3Gdm=8aU2GTz@KSSk zNPWF7PtIly7beA>GW{y|SS0lA>$nB7>tC7TZi)QVC(y5Nc~=6z-p=@FZy=K{mBAX~ zuIuVwMp(b3&(2i}3qXnF*~vBFKAEBAzR5LkycYR_7e~mP{=0WBjBk+*hO|dRdF<=m zb|Mz+YB;Lz=Ugo&s+-7NjX!jn;Z6YiO3#M#pE>h05Ssd#h;orKJ<9zL z38y=k`u2!0;_-2{m|NFUwAY;Gd}@vVtr*x}mh_k2p7ghmzn*ts=#hHN|GInJ7S&>M zrX0D;M3@_uk&l9J^aCB8Vcfm-2aN#L)`Z8&(NQk%;g8c&9_#Y~w$xEq734f(?1kU* zUH+c<76D+$UWlyT5p5F^b2ERNV@%)@GI zP;KCG+;`eJd~J&?&^!J8Md~xo6bH6>Oa7<)gD@;*{?+d?z@_Ns-LFGIlUKeklQ$-5 zjc0{dbl100(30gWI>MKd6oEL8Fb50SCar2?_H8jjB_M9&s4E?VMM>f)9vx%wDsK#p zSh5X8t)p*J10*d?j8EZw&HU_=V{yNelVN3+UEz@a1O3f&EONayG-KAgrlx zGES-7wY2pvI{~IoqO${#@f5N%Gm*=s>4)>5q&XQ$mh0@08UIL%d|O7GD5nLH{=sgk8HlvmNDg9k@wJ0g?L3Bm}fca`i9C-MQ{VTTD9 zKKA$Y`qkimHMOj*ST88~-IH@%SWS6IaU7m-@eDFXSEHJ^AW(j1{eocGnkkjG(SLpI5xupP2k|_{&n!y z=$FlhvOMGDZ)!=vAnja3Y3T+rF%jJ-rR=gYGW09q*ZBj4atv5&%GLxS=vZv}Nl6S~ zKh94K{4VWYo1Hth7&4o8q>N%C5S%MCaB{2+o;wKE#mxoPX1|0Os(@f~j9Q;n2zXY+ zvK^;x_dGF~&|d+K_SqNZ(M#1KbM3=otMpRD3XD`>)gR+J!TEWci|czM@*kx|io5O~ z-}|E4HQ&(}6tMF%&Pd{CZr!eRu>3tRvG4{f$^#ZjM_fJ1!bW7iHD}}Gr-W96A_pxe zWuMDen|8AjN1~t^BL~5LpJ^1%-WP_B%3xg%x=OsI1CmtpnX}$xG7Dbfd7JkLpUiHf z2&GI@`r$hbKbsC54d^i`;ITUypS3l2pZ2-E?Xx(Azq9+b!z;@5J!Z)YHA$7svr*;& z&qoGbq98%#=c9gW3n|2VCsXZp1&c3opQj}#G!d76yyKj|5ssj9|%Ht|PGHtl>DClNWA7~g>FYzO`H zQ@NR(usJ-U7srQ%%W+aX8=+UHD(gXCVtQMag)zeZjB%d)ra!RM=_m?Z+8{Ws=YuXM zs2$sO9y?Ze$a{Lz6@;2%N2iuIbbta}@fsy_y|=~3C%qkTEd8P|g7$@Jj*GLXou&u< zsipVf5vjZ3Fz@5rZ#ig#mr#6r>AyKY{@)}s!_k1L===I-a2-Co7!%Zc6rKBpgjQQ_ z+?E@OGux1^8uu4{3GW*nycqea8z%&bfVPWSvgHz28nwsY)*ZJMt+`8UQ=IwuVaol_ z*7~e%WmA)#^?|VUq@ULFNkk))n1G~nAjQY^rK-x{Upe)_;sd9UWB&2SO#Q;2YI&bS+LYACLZk#cZ|Pv^Q&en{}>(W5e=z*=-7iZ-01Q&CkG8-9}2 zS-L6)6b_Qj;p^A=95%Z@-vE-(qNKQH;LJnd9qlF$tSd!ZMZ0bbkAUO zkHf~%vPs^BP6i|aal;68OIo8iA4I!SbZ9EGj3G7_AD&*0}TpO3pK;%a}&M7s_htf~8s>p|}27d}6 zXpro0n$g_mPae%*ol;`k*-aH82ja}o&e&W)DW0q7s^<+b)7(qzw!wWSkRns!wya4} z&rBO^agRT^x37ik#ZLjByYJCezxRHX_3|>}& zH__Vz4nq%Jqh}Jyv(>dKRnn!;BXB1s%Du0jnJ#!doVWf_{VqE#z>J_9Y@#eQ394b3QCo3?)#0i50SZe0BqxFkb95CPlAw~-f3_eMTP zxlb>gz1rVMan{VWHT7|y>zkjQio}*R6PCYhtH0epqkAY`SzRcHq)yw8wXO0iZ)pYV z+a~5kF#QS1OIy}6jYh8eHOAnDRFy@k7R*K)%4kP>*OXYYu**4ow|qbYZE!NLxP%|M zv0LH=<4F$7hJ2+z*{3ac40KCHbB*%gLuLJ?<48i ztZPiC9K|l$9w*)2&J0TfdLmVw`Q5nBy8CU;=7T+BYh9oDd0saG4~y}!L%y-(W!aT> z8V`5V#W=HAaD_EWH%7N4y1LLCJPrwm0PC7E1ExsxDTKkN@@1nRM8e0z{bMoUE9u%y*D?q(SzaKPy^Or0Q zQL~7;w7M4eZk)z$Ge`?t-1X#*U>LhDBwcfgaemxPT4$iOCc!sbx$GU}7DBfe(eUHO z>i$6}razkY+#7mv(k9yzr*wB~LYC4={`IeJ{P<5SOka+D-V&bpjs{p2Y-<^-=c*&) zJ!tg}j|Ad+9>rLUxXhtdPG$!eDX9C8v4xo&eK)%t5T#x0M<2*$dmUU{v+HjZ7z;7V znk@%{Da;lbg>MJemo)9Fxwaq4(53CnS%Kv*fW(s$nnQ~vu@82Qc!o}u=N=Tn_|k8+ zIH0A&m-U#*w6XRExzYe$g-)$B=`i{3^P}FK=(`iseX5LXJewQRuJlXG%NX1zn6!m- zZ82gMUnMqQ34!R?giLocqoOr9z_1LmFOFZzOt;i@efQkib@Z#->`-MvMen(kasCeA zl#viL2DrJv??&xHEle6(F~KA)@QWZE#0$< z@D*pJUgPYNuqqDQ3xp$tK7fmil1RHRih+!4elb#y5KF_uTO-ld%qQLtGsMEq6S?80 z!N=Gbd*MSnB;9HNL+6?QIK)lH3s<4kP$o8c>6{uCfF3$TKyQPyMbIiy`z5K>ys9Q*qpKgDhBCLra z4|DT;v$$F1Wcv6+?t?}befzA_eJXrl^quBF{f_Qp!Wawr@vIaMi$JG$ogszssx#o>(AYVRy$}Si`C|Z6x(h& z(I#7e!EFk@6lLThsJ1BxAf~UZ4dvFASZAA=nOv5snF@ctjXD0tT0(IemqtE^eKeCq zx!M_-JH9h&(VjrtSCSE}&E2h`r4n!^-cIAu)=-T}Uhs}BuMv-O77HPa-HY7UYtUZ1 z4_dtowIo^|OT78fsP;TkI&SbXKw8!CU5c!!Q@mxqWC_?0H(nFH)tL5S8a=i8TeM-! z>8BObLEMZHZw6HA!M@|IXSr$(9iry$WKJxw{nfHtc{@+gVB zC3+5c2}<;X3l}_wpPyc6)*bqu zM5A_nwWcH;+4=RMM44o((vbf_Du9n{)3aNYoDMg5Qatb`S!p?x_N~b-2VtBv77MK) zKrLDl*c*YtXFdON2Lm}EFWJI3pfS$QlEs8*l9iqMcoAe&=QKqMNlB=(N`r+ye}eR$CBP5Evo zw<1hV;F9V-QvhO0w60{bJlF(~b^M z{2tBa9pS&7(s2WLDB6iY<}ht?DDM#CgK8*8V?#?M`3#BUzSz8^j-X%+Znv9BO4GX6 zFHKCAvVaH8#;NPWxr8LLTo-W+-b?sZ>OPNZ3W0-X^GHaur1wruYd#ae#w!7-xu-SO zW{WQe)Z?Bl&Cq);)SLcbQHP;n4!n^Aoj6-xL+HTLK?FZb1Oxd#RT}$k@%h5dIN>fc z((L*g;VtR*52vf_a^eYxkChpKRqvxOKXabTg?&LID-VDfaz0{r=)?|GyOLZC!2L2% z)VY`qnCkXj#jcOtP;b6?Au^gZ#BtM(DeOzF%Oi)4Tu$I8nIf;q?*R7Gfh;7`ai6C7 z(>M#EWwSD*-^<4<@GScT-$TL%y{`EW@$rW0Q3W);VqSvU+gN6+B#DEp8v2OIQbp}( zIGe6rFW#O8zQ1dTjt6daVt>n2e<#cY44EU~2VImlQBDKGI?!=rCx50T&ot4RqF5lW z*bzE9i{5qkpj4r##+ots>dCm=vwokQroqJ@Ju?&8Ud@GmYx_4=yZy1&#*%zs*v!qo zhYv`KcHO3KalMsm@Y*>Gz~g{e5HIz)M_LnmJ8(qO?~6{QOmSx2tZVkaGsuev`ZJ>` zLdFFK!zs6y1XhNSaWM~KyO@IXJ;QC|E_7{>9C{l(1Da3MWo5pwarDuDYP&_jw7+@! zfyb-=S*qhQzD0|YPX0w{JT51yq|@>UhTi^Xh@@nBAqY=SOqI+M#eyESI=cj;Ss`1q zUHCArq0R4ejltYz4XGNZn)KsK(Y4+Zw;`kwk(LF{CrWtuSuwuh@41iKC>N?}n|L?} z2=eD?qXoiuTg%?Jpi9GP$j?xn>%5I$jKX4_pA1^hHR%arwAlLkYBbYRfEK4Ff0?0| z(1_Ah_+AUM;QN7r`}*G~HU7->fO=$Z2}ykhwWNFBQYdwd#KrtXl<>wI4IbErofmKK z#-d!n`!RLZ0qae4>_}vhmcnX`N@t+rq^L(HPUV0(@AXMww#8CWpUU@zCZp02=TmI+ z%g<+|>7>ZWm#(%$+{aPlwoh{3fgvCDEr=qF*vr=#Ao9&V&ei>+m9yT7w6=BELjvJL zsXhmNv7R4%#-P?#iFt45g0BvY=%u_4?e)GQ`QLKVAFPiln;gAos=X_DetEQkLgu== zv^kld6T_j3%ZeVID0c1s4BLggt}Iha6gh7*-kz1Z87{e+EDfmqVBG5@b&h5!wF)BfzV3iRw*nCx;|duuAjO*GFm3H?Lb1w3?I^4rOb|!`>ff4&iD? zaT`a8zK7TG>QGjT*s1)73!rV|HyDFuOn&NX43T979-1){0jXt~54_Z{jS3s+9<)Th zwCBM2@cQ|k+}a&sM#rxM->&k&n@p(x{&ju9tI4f1GjaHz$?uvC;vm;+p~aU6TEjuJ&V#7oVYSmeFJ$pMXAydS0{>`%ZGYBRG%_3gX@U6AyE9D$_J`+v*cuw` zOp5a#fJreCrp*D2c3&oPaG%%j3?g{B@;ZX%qxo6HNlRG^1{{u_s%tZ({Q6_11h z3uC;F+byk@g!E$!O0_iedo42W#q#rj=#$Avx0a$tDrF@Pa#~*zLaC5bxo#J2>m#Y+ z;f(j5NDeo88B)VkMKYN-C=KysCH3159t)bVSZS_2KfE?g-ia~dxP5T5vF~LrxQahf z9i=AiNd%nL(Liaa@ve0mRTXup14E+pv)7Lra;%8gFZkrj+;k^f3J>s_)3407USSsj z*~IBYoc@<dBYs&AZ zJpP}*I^kc0JgyDO+;4;OOCjx3333j<=QMtCbL-<$`$EgzLMs&PHyPA#>@B9Q%)H)_GA61slFIpBMwt|wb16!O&jhqUFC}7U{W}QDWYt-^r)i~=CYG> zLlp3o&HS zOv*P=nH5fUY}kbQp~AK`p)`r`ib7k7L&`|gE4{d0HXx7Fq^p61@zjQl(d2SDaGGIo z@2OWWcOR^Sc12qRN-t~R;FQ2sPE2{q&j`|p1>*K*G&}t0r9zha1atDSTjmzjzl$?5 zF(I}|+QXv`p7@hK_=&=c2z~}LM7fG$7hMiwGu=kzfIMFp85xuiT-xBHm4ly?QUl^Q zK_T~=68i2-Ojs1o?C*c%sib-&?EcKoHtv};Gc&7uXP*8)&u0t_QDr(>3EA1XOZ?Qb zQtgl&QWtWZ!m}2A4zq9!(H>2qja)T}q~9nl$MfPfL&AsM8J_Sdf1hU=el=f2Z=f|G zhpCkYvg4jkD#;dEd^Qf?S$pgPRA&Q@PKZNYNoL@XXl+)?*I4G}19)$;Uu1PX*-gXNz zu>!;W+)Iw*9GZ;W!>=qqYJOZfAzxVf!OCUSGYGO6%dR`-l7Gn8Es^{&A!r@HX8V3U zsX7deH;-u-R>OpqP3Ua%(lXxOmo#p1sKMUdW9}>0CD^}YGozQdBt}Y$UHL7i>I3nO zhgKc3$PCYURk#`lw#xW1v7rW83v#TJ7(poHPhn*iIgb|>=gWG6fC`0pRR_6O`nq?4 z^3oB?praeHdPP1dX%K|gdKVaA>n-aqHw9rQ3L+}M=6txhFZ>J`TGqGy=T5PRtL>_Mq29q_2@yN9zC6~ zSQ6x@ae_e#KrK%7xVvd%R*tdO1M%Av%QJQxi=Ctf-Ai&*VcBijbgQ}(jJEN39*9W%}P30u*Ao!QHXtR{xWwK&}M;oO}u93meRm7K6omK+lYpJ{C1AM z`uz=Uk-dwg?0#{VE8n-a{&VYh`KaPqnQwa`XI#7|aIaQ|isP0l5ohb|dSO$2yNTpH zqbh;MLm~{?@n|XE49$8VhpR<+$2{w^RiZ=67(IZNXaG$sG=3>53GVyMFLkosXHCZE zoBb-cxgfny_vzu|lf>RKqweMGNZHK1yhmi;3fRK&8D)!AfFv;whV#jlSn?Y9ddh)4 z!qln)7H#~16(R6&cEM|V?M5d1rB$IDqw2%E8(b{uvh%$eqv0>8hYbrh#o)3iexGYn zq4PX9H&uk@ zKXsY{e{J6!H;}w=-f%ali6tnTs^e*_5tnBvYx*{SX5kaQ$r<)8JcdwxABp{;pnSaW5n6~2EZ8n$h|TVoT$Hw?yv=K6$U;X#JY@I#_|q` zQP0>mD2{XZBFHu-qj?H}7xm6>a8qomE#uOUJ4tyDe{!{q*SfZ23&JSv8lw_8bkqQ? zF#{T^jQ@&E4U6d!<-I_%tb5tgU4zki@QkqVd7Dq4 z=8@$-=95n#^X6iMy_2&-1$iZ?7Fo?_Z}B5 zmk1r2`Q|zAW$$G#M?8c*TVbk&q>> zwkf;r3ckhA<-+%jt_K(GBC)u5U|V^|_xp{hERiW1`t>)La&DUDuuNr{GBiKcM32Di z=}@}w3f4ZkA>xBVmH>h{$LcI#2%~Gfn1}Ly<8?nAs$GsyP*qn?I=bd+`PE4uN)=Pu zd|h9?B{;oAX9f7D#+ZSZsh8Xc3E_RhiFdREzd0D^zS|xG)KC%Z#p^LIheyCD zB{|oB$P0WPAUZ3}IiC%8dE2`G#?@DcMfq-BKQnZ93k)C#BAp^JAOeB{0wN94(%mtH zgmg$rhaxRq14Dx#jUbJ5ch~ovbAIRjuJ`?K{+hYwdG@{c+H0@9ZZ8z8#hkZu2J83v z&9HCtx{V{bi1~_N2bp3#jku6O4~-7)ziotQ<@%2iGwFP#o%=Bg$pO7UAe}#8OFou<#V>?B)eFZwC_o=Tl-rq3CpJSiqLlcY)D=94Y6Z}5 z#mS7Tub3m^oA!gr__}QsQ(eh+GAZd{Cf&Zm$1u-$$xyn0nsHNvl13hye>|&z14z>MfmgLkLIXiu?{kWzK$_x|Ud_oAFeZ zY+V@qb#r#e990W($pF+I15>^}Kagn)+BqYPrP4XJ*UYYTuY%&7$>L(~dz)(tbNz{N zPzQ1NEecspRd|RWDW7(7gRATC&st}su6#zEFj_=S9F(YvNXM%jI4B4c$srFsKctK` zeU*iPsB6lxho1MtsITRmn1G(=?~*@OH$LZc_g1%@-(9ygMeiDZGyGxn-mSqBbJ@s_BXLUn{P}sN|Itd%;Z7A# z$%$c~MY`kTyOVE7!Bv^F^l`2F^LZ>7!{!E4$a3E!qOoR24%+o0is&J!d$r#(&IA;| zif4?JAGX`{2drwlIl0!Dl?K-I+m>sF&Kdhw^>yd+szI@fenU*-x;eKO#hRspHJ#m zGQI8O9UUFJOY;TbXa*55j+tgDGh?Ks?3!Vb*tDA^^zdx~)F_(jRyJ z@g{#mZ?T2&iPt*v1Y;uo-K6K2+Ta)ch%x@@tMYElAzrAy za&6>BsV0F`UAv>X_w~U6b^Kpn_aEEFoy8-Jhw~BA-wkv{C~2BR-^8D4HDLsExIlG--b}yQi0pI3tlrLE z>i3g?9|i(mvaA6L4-pb-8u%6L1Q`h}ANGsy774Rv81<;eTPpC>XdxuZFVPWrJTzqE zTD|fRxXzb3moaKVYfVyz*meJ_&2@*PzHh~RhPR;S&1+tpDOLOj9;V%{6GLRu+h9M~ z^Y)#7#5==V_wE_v73EZZZO*%OClDhob&{!c4d(Ha-~GZM5+6qMsGlAg4`j8%mUqGJ>+P}`?S0nv zDDt9yHwC}z;_{ZVu3gE?l`7hAC9Qr`58P*oDTwo5*xH8rWX(DDc%-|}hxJ-K3CW9* zB(=zJ4RQL;g=NT1%=dROh^R+J>Q|px1p4~;J7CH<;uq}gU2%lb{q@56l;5?!%dEO` zoZ#o<(>j57D9gFz==2Rm7urgHUw#$d*)yY0Hi$jG?w%`q67z#+!Wp zNJ3A;<72eoznxPr5Wou54dG}0Zq&{-T|Z`ekf z{lg~@LV3@J!mGo0{_?quJfx9P(~9IFCmt;%t(Sbk!lKjpdv2fAzcmsYgU3!w`}re} z{J2Q1UA{MHdVC6@y>5w|n3htZLXY$DP+<|EfYAzd^`c1r<;J zhwk4}F#;{Kw!YjmX4+E@UNrT{>zu!6zMS7F>@s0oIguxLKrllXH33_Zc%QS@@->Ud zIfh$b?yz|yg_*ao=ULijR>Q{p^5gQNILeHwcRYG~?plewrO=PR`s9VeTy5owyl#<) z)C%Zs3xY+^Hs))oBV_oKjz_-=$GlS(VQgh0e{wUnxuguNNTyCxUGaB>wxKZNfJiz= zqw9@8c>3WRT!VTAs}_H4ZPIdOU$hy!^JmpIbF-^+&pV2{5)3u8N}9O^Qk*0iS;Z~(+SI9Cthdn-UlTz^%TV8MPIVUn zOt6$=)oT5Kt3OW%7WZu7HjGK!TETeoB6@&B%$r?0r`2r$Jb3x{7Z-=;%b|>Z!^nrW z$7i$7?h4lu{@9`REtiz{H|y96REVum4LGCJotw+{@0$6AEvI|`8xv6m52IXzCNXs2 z&~L$ch(2v!f8n{zSWomVRawK@Rix^T4HO|O8TQT)|GF`nRVm5xO~cmG`kykgpbG@r zm8nOkGX)^#wv!#=U90CFZUcI=jBf;OEhOLH?qg94T3V{^?A;s>b;R-Qoy*-;y_4b_K3f&=k&n8#TgL$co1LVo+Bh;0j=5!0ZB=1}bWh_uH z08}TH*RKB&#Z)+6-;sP2H^@B7+ zf4$C@PJh$~3xyoxGy<{&tqN0qG^I^kjmOA|r_DVDu8-YLC=;n-N*pQW8Q^)>3( z!jxRS*W1cn^noYbDE)2QJs>c}Z?M-;S~2Le(yI_5Y`tpfDFqeTFoz;@1)X&SBDv$M z?NMjR+m0D@k|=x!M;yxcMxPKbu!E6%rSOd4gSiv1pV(Vr@55`2f`zIh2TM@9UC#Re0eGAguaQoux$wuWbto zcgd7g1a&WOAgC7-^((j(*lS!`VR3oLcTIOBz*%a^omqnwI-;>9z6)puSD6L%o~4#> zti1$Hg2KUqL6Y?^m=rXYUBEq?L0SeLE|O3}2@(*N86yO~0E-LcL1({27UZ=5I1!MP8&F6# zvN6+!{gCiJv-$OXC@HPN$CKvaiqFqPi$EnXvUqD! zit{@ksm6bab#c8gL=gN1OurJxtPC=4-Y?sD-RVz0T*~ZR1>E zBfwUV5DkkV&aspid9}6e2)2LQ+iFTOcMyl2A3;An4*$MofDD%}ysuvhv984BrPAZY z1vhM(1|y(w{RNoMf0{!Cx@I&yfdVL^yAM2^_uhM()_@1pyjvd%!wvh5AEv}!D`1rI zUWcVwATQUq9`zWIWd^M9@M^@Yv}I!=u@MT&zSA;ir+-+t4Uv1A^pa1MEz2*5_lI5h z%f0mFVSomwAT?3*_^{RO9X4379{s_e8m@XGAQV#<4yg4G7wuxj5pYMr1V8!eKIP*` zVCN}DdGGs0GSIoo_b6wy*M{-+N!N+rZoCg5Gk^8PYh{fs=Waf;>;=FZgQOcOjX7_= zE~pg3>$u3%=-7j9_1ArK)2EXuZyosE32;PHf5J3h>c3zHHpH63a592o*un~5Yx45W z%Ktf2SBo&iB%lfmLtEAmfWpK=UPy^T(~^u$KBxuPC=6S~S$sZTh>%THO29Vk0Q@Iq^ZmszSM&sv*hprM-AeVu^g!={ffW&5jCiX-Zr4c))-0 zbaG*Me#0p&z~lIriO{&XSD5O*yZ{$Lyrs^f&Wy8i{df2|Q6P2U#0+5II^pVZwP`U& zY@9!&*)bbxW$1tVR%q`K{Lba-vAbw+N`qAH#5{Z$v3RhNM`nFuo_x;Ju%bBYxqe{; z3fJ97YlCmEPD__4{C0jka`?&NuO8Fz0Nk!AC^)Y9+f3e0)ukn8OXu=Z1l$-hZGLCy3;AN-@}b*9ZPo8!@qLPve@hFu!L<@M zO~EOBZX8hFd?a>t`V5y8!i+ytXP?ZcT)TDU)LuJtM+h1g;|B-yB`~%#c$@yVkEO-) zy~0OFV@;vt;SYwl*2bb%H=pU&HFtSTN=C_(E$yYF^+=74jH)a(# z1fDA)dl7p(3+}1Sx-ZZRso{b)e{SH%;KeuQztTQ-MQoqjoGzi9{{eWgEbNsZK z;U_mz&XM5U9HzE76iFsjnSnJ)srmD-=W+)P-5|S4Lv8?f%Uq;Y9|e_2_1b$2&FUDt zL@j2urG?e0cMA%CQ_nHug43=&P09NsJls~V@acp{r+>Zcpi=RW(IFSx?!6jzK9Alqgat8-0o*p zGi5kBRB4_iwj?hGD~2Of$A_y*lL9@EEth-#Qx(?k)t2TMwc=QO*-u7cY6ONd`$F{a z`ssNbbu!aZu5kks(s8;ykUlF!MnC$_^pneY1FO68Eze1a)3{M|AJX@kyzLiDOr+Oo zf@UWlORx+JCiCuPy2oFM(-yiHg6*B_hclUVq5#!QK#FW+7{cfyZTkrp6-L~_@oz!2 zcaKmtwfK@|+@Es3io%Xx6=mnWD;_ipM0_IqF1VJI^tV022Nzzkyu}JkU77oAXTkor&0F@NLz*ESy6J^%r+n9v{_$HvjG>X-u}r(H>go3 zxn>W~ZDPO_pKrP=MR0k7N*h0miCRjF=01~evL+!7_(A^U+gt%uVasp0aTp!I>AJOA z6JY}c?Z1bVdMtPPloln{hNf9&uNRG^Rh|kgOaAOIFI&0~+rAC}7Qb4Qr{>tXLiqFO zcN+LH5F1x%Wy`NVEM&A(jU2x{%P=Z9j?TvyrEmW==W5qWT1}$=jOPUX^sx)asKVB0 zup^Tsfz0n;``_~SsP6xS%R)DVaU-xXz2zjIaggLzyX1W0Y`_K1JWKCh_6p$|P2>Y% zj;EX7#^PS3B^wapOzw}GAxpXrB(HazANZaShqZC`MDQrLoz0vNc(C)$6G~M3%f;1i z;P4<%1ij8?xtG&CkJpwKgRZ4zG5v2Sp{UZjd2GJmyq?hlB|ak*pU3s?{rJUnyaoa! zQD5ef<%W9XvGV9O()GrHuiWR%B>HKY96d%uYtCvw_J6ei11w%)Va!YWAZ6g$z5Q&z znT)~rq>Wae^y@26${;L8#`G@{0GP-pt$IQ>m_DAm&FrcK#FF^#NAO4nj^iJMx(LOJ z0gV1P9AA}vg{=v~8l2ac7ViDj?-S0#|A^1DtLHv0Vr*CPP;Bf3n_tWit#$l8Ke2() zK!?t-5s#CR9s%ax>_ExDrvsgT76uBxfcu`mH3hwvVOkC%(OoBYx8rGC+*BSwp6Ys?76DzPY~9vqYlE%1#q&a>$g|Jd;^g zK`^eM%O^3^frn8m1sZcR3!ztAW<~(Lj{(uP9JzWV;Wqw<7gjQ%I09@XVJOwlC6#t$ zYR>J}7+%ULKXW*M79-;tO~1VW7oH1VKVLbj&rC!~Dlmb+%jj8^iw!iiEI|N7dcyKb???Sre<(bkn*{4)Y87I zBL*@({*vs}eyMRe4yE?K-gU3|if#Jrp@o4OS+2accEeZdusF`1Pt$h%r4lbDEn)J*k9ySIKNu7?f2(|apF$3aNI#?I1t3n`Ma@K9xM^Hki+cusrh(L zag#Cm$6_G#a=VPQb#P{llKLX;o7ML{$?@5SVIjR|T)TJ8hXvi=_Z!!{Fz(@H14ysq zsBdz`*WI4;x;saIex*dD-B89Y##B?ENhivg+6uM9qCI4MM|27dv*fjPMIaIEv6fh# zU&$YH{)FAG&eZI~z7@L*zk(bH9qHjooi56cL2^S%1_m>Gb*?ZtcEoI>Xt9FIdKivQoMjqERv_Eeqa&u#BSBw+7uFD2 zqVLYUJ{Rn>jy-c%PLkLFC}Q{{u+2FVz@;%KJijI81zj9 z%`cq~`W|I%)yF@Q^0|&`rpNRP8)S~VZ`bmnyWRQ%au-J{OB zS~QD@aAM(w#DW>?(!h$ZLDi5~--;`Y?wG4>{@@1}Cn3W7b z?OU7bt~)$$kiGA(IUoEErcKQ&`xfCt3^BUswJ!2)TFnAr^Y1zKG*yl0+3py<4WW-L z-kSM06*WG4`K{_v2@d38?_>6`K)hV$pY?oy>!Sqhf^h8+yoJmB(f2fUZ$=#HddwbR zP-tVAQmLA{LrigWW4TO&Y%*Nj-`@JKbMMdX7Z)i%dYD7Dx6Zp+c9nF#=dC}tKI=SG zgH|(ly~m0AK@V{N&CAD%4R7l{*1FtznKs3pp7s3vT)a_J?u&-T^DI*lx){b{knrv8}rxZP`;NDgOwXouIvB zxkrzVvdkr4mqllWk_3doWsN#mDn60|EM}+uZ5u9j+uPr6S1c}#v1FCZ4=x{IaAT&Y z{mxWr6SOear*DYEfqG25p5N1C2KZq|Hi(y7UAzPvuSwU^=D1_=jAfInR(D<{J8jCq z*j1JJw@U0{-+ymN51glj!iV?}&zK>xisUPUa=^jC#AJ5F4DrwNE-4o!bi@%C#;(Y- zzJ5By*zXC%cHbYE>NDy>+<@)FFRnV3rW~)+MZ=3~GTXbCo=85tpXFCAGlK$-LDJc2 zJ2*@Tbl=af$r?C}0l(q0(bx#H@8Xs{J1aeqkzT+q)r6KeD;oWyO>0ZJ4h(?>cEqQotQ-p1ZMWX6e&C8e&jjk%(iW=gdpmmf-?nv3Anyp3cV+o^DY-fUgv2R zlSC3N+^mUE8iZn@mO@*_JgIGP&`~^ohw5TG6;xDfkkq@n)?4I=&7(V-#*4rG8^3k! z!Rr$w4`M}6hN(ueBoF+Av2EnhVR?k@olOhNY-8V4zWJX0iO8<>m_N>(sU=_>oFN`& zKt~lO2`PWV{wESpDWBlPfZbEStFc+(LK(HHEQesTMCV($pOSP8ZX<<1ww9o43#72U zmGGn(yA`7;|CnYS4{n|t(1-O4!JjyKW`}cQ%4=_XNbT|=WPXvPLuHM0$3TW$fxdn| zq2MIe%Nm@cILW>?U0KE8u}(4Su;Neb)Q~Mn44$S;bmuwrsfde`;le6XfN$gPq(Ddw zWdOfhR3t)&d%Ks^@6GPad07dZg7pC;Ij3hf|2Dg;6CW$%2*#l{dnUUr!w|i;u-bNs zPLH;a!NMEg_(Dl^H09uh@M!`+E*HkZDz%qC(c0xKX&oCnb|Q-;pDAx@thOxk1B`WF7PyN#3vcT zc&VCCM)5p|uF+_g@}j-S+5`uqe@O#AYXbK&J@n`E?OGurAsb^^cXhRJkM>|3m%B@g zQIJ7buQwOe2aM9(r{2pCYaId?N6Gg=cT0@(npz8>40ud;{)o_RVSyT}5$l2Xj`!w4 zhv9Vm#C3W1hfNtC5QC?7qxiz=FNH=W{zCU~LFs{3 zUv|xMVCd^NgZy5YclgE_to`BwMoEW)-}#h>B*EBjN;(rb=r>6g#UZnTa{NvZ&#n9n{0~nXq!8@Ic4HK8efT6F7 z1z#6+;m_a^icdtF?6Wm~|5>?ge5{I%V0Ex%F4%zM0GQ)fHC-x`cx9KPADPIod>GAQ zZZcm#M0eM)aq0<9EPV&(VRB*Dn+!G_(+mtx93};F_|x43dMVU4mFDI5oRz}aImk$G zKY72ukT+p1VUf1lXP1rcQr8^Iv~YK%=S-7TqI72iOnH)}RC=@sjgpbJKXaGepVn7Ta$3~Rj5BE*&Pn1i>6I}e%-n!m5L}j9zv7p>rj(1P~4Ed#X zM{RS7-+``FW}|54uA;T*7$|E4=ZC)Ay5DMD$VInHoc|0Hx8Tz)r%3wJW_a)MY^DyS zzpI~1ZaJa2I``jy1uUBGyl}zsxZO5XuctI1VIR}VGASViS}!pCG9_n6ER1~DsMkTc z-#^8~Is&CB4S1A{HNv}GR(jD$ej?_{!1;rS6D;U2DLY3trRn#X zdWou>FNi9zGIafY@u$8`*$juA_j#GEJ5^t8Co2hnS_n)?pfMt=^t6`fwNUaFMw^Pf zriI3{GG)!+D#Ez>IX#_-ft1{FH_nMRnuRzVpdtD9!M4ZLfXdwRM{wGRd^%Qc?xUud zEek0I6yvOfHv%yHS?ixdC5FF`V4XPfkMu1%anp|ur4j=lLQ%g|NH;PE_?Ts+kqY!KsWwTC!T5g# z-p54{S2BIY*Gy!8c?iBOqo@Ad^1P=nmew^wIethBs=CGPM_4@3ua1;FOLarZosY2N z0MTn+=}l!sH*Tx|P!j8i=dhb=TbtS^vG;!Kw{l|?r9aT9>5shLJI3Z(|4HLZ{OX;a zBSnT|cWN-Dzpt;a%SH~5@urW8%8%gHIC+Kwk>;geE6?@W_P)@n^o=&lLjB@)@z& zBHeb7=b0y`<^CVf=G^z&sr&Vbe%G8I-l@L^K>MXLmx;H@qlp{|cpWb;@unrX_3C4r z?6Oapd<6ggC+Yng1|c>+C<2LsKO4!E?>A`(9~4Tb(Jt_nEJs!X0tuf4F8Epji5?)P z{-z3Kb4Zlq_$%~186dEEf?ULl(^BzN?cN(L2yf~`_{rJ+I%m0 zjpMu;je^q6R4BaJI2icyTqhKuDf6n>_2DV#B+KgkpBhK_11JKSfH1L2JO zrh3+u%~B!bheqzYv>?{7tbSG16?k6@MF;|Vg}XFv$2*1y4+YHobkObM>mT`Ax#EZgL`CzUD_sCVU!8PTcq}u!xdR-s86sHnZ>q zb4%nVY*_@mRp{FO6U18g8>0VfaAD2>3gDnS(cTOii!RPW4#Z3xLiI4NX-D7ocno|C zj3+<~ti;qK%ywOo!%hijyaVh6w@)@$Vumkp$Sl?FHMEE15<*7oNW3ECGH6|=uFO2e z6#z%>SY!KBOBbp|$KWY);Yh(P-8 zexlqjNJRu{Bsduui;!x2w&3}yjmKDUQgQ+v?(QCSx4R(OA@wMOkQmtan)D6=sDA}^ zKmB*^fiWLchoLl;0dj%c?T3IL=sXV3e?MX_EnHStCOYaQ~@% zEHq7zGIPD8NVm4Cr-SJ<=F3y zwg@yy3nN-d0Ec(`UxwwEyH8zV02~gx=2^ia2QC=l9%bL{Re)@3K)$GUR_^5%j}V&`^7eyd_U>$`^|Q3!La%MZYVeFcBev66^;<*}x)s70UeX-kg0M{$+t2GXmcfmo)DF(fvwEwcdKmP_)}0QLf~@ zV|t(4nt-Fk7;`dl$QI6Ceyt;BqJTrym0P;L5KTj0o2y#VZg-s?jVcgKvtjY=I^8VP zR3h%t8eZ>_4?zCiCHaRrm9&enf*$~{AKidkw{eY{KVg>(dW@nnk$%grVLdUfJWL^1 zfeY)i$&oC?-Jq=eDh&hJBKPy=9my^XE=)$q8w(xJG65Us9#X%&#}8}8nWeABSFU_1 zZnizokfJBWPQesxjoRY1!f&LRH9xtYrz*xxrKF)IT^BvM%s-c9zzi)1Z!lcp!goq^ ziphH944zM39%e5O42|!R%+qDR_&ubCXX5hUWv=xQ7gdfypOk(?@)KPZDC`zi>l0!> zK#HJhz4(X^mvc6ff|TS?dfwf5>r~!l>;>rTf_Ro!?5LN;a=v-a^5(*^1Q$NQ6wnGv z12)s0{kq_LNxF9QpATtJr0I+Ar?8$sCAJjhZ2<=o5z3O6WO~o`h|-UX-XQ~(AZLFp z?2~eqQOykgH;!V*1I`Yu&_O@%(&7f&`g$$h?2m}k(_0wl1VK6;DBKS?Bc0rSQJ z?{>4-YO(w3zv_WHD8si}8gm?ZkxaU>e}|Zn(7?L`@1MO{*WEmu*=KK-w4K%fJN@T4 zA)oI4rFY$^YeljlPvVoC>${R6CLejw)(j;prYUaMRrWhYhY-p=5hv%LA! z`f5xEkv|P`>#h7Sm`n9!GQ`V-WN24##UYF8j++I^htBUxb%fHmW}ODppH^*p4m~R@ zbYjW9-qS@%lF~V$(9jrg%|_7;u+JCn;WN6-wf6qkGaDcx*srF`7l#u&9G!}c zJ^Q70{FJ+Ymu%kr?&`wcu=OtAb7u$FKL%iEnm*E}}Jj8Q67!fSUa zT*+!38d^+qbq%P~YaU-;PYQ%K+j@ZVJGOM)iw{XNleaUQ4~W48sLwuN zNZmO2Gf;#LK#Zb4*}PL-Pn*UeyDHR4kSe3`L80Q>T%Xg?DcE$`11Qd9zXMOUW2{2E z>}vSXBxUf$Z~o0n164@HqJbG@S4xT%ivOO)cdLSw#XjZLR3g}`Qs_0mPL1t9&Ul%l zlHJh{pVUz#8=!0d{r0ZrHmwh$BKb7wxk>5-mL}D1c*%{>UY+SCzt}2SmXcMd@vG_2 zz?6!d^@%VWq+;S$E)3_zf19pG+=pe0&$EAFwgx`&TFO)4^ugGZOy&yyz|A} zmHMuy{V=@$l4siC3a58@$I5|B;zPF9gSXIgf=x0*Hed=+ouQqcJ)j-9 zi#&-vfZ>^%7U;(~Qc)G*Pvo5`jTsEOCVsnDQ{%MKCx0bph-(q5N)&8k!WbM@z!as;m(IDm z#;SPW7+cjTCmV1`vqsC(42+Gx#!rbG{2l4v-q%oS$kf!QZAWo>?BXPj>j(8?mNdl( zWS(65mgJ+?k=X~NZ1MGSyV4LNzQdy0@+OLutlRbQ?1iqGhja15&yCtk&!cHOHL_`vj|qJ_3@O(L9CM8&xeTc=dU<& zjgSV(ZXqw<{{0CZovPMhA$h6e6cCrK6i8nilIcL2prnqU@-!Ra7tH3Wws^~|NT?q6 z@c3n^{Xz;lKt}M9ao#PzUG6$EithgeqAYs%EA&7>L1`r4Fx|221gU~DoL{MRYvGre z*woE(L%wXE#@xWgxW%=sd52)ZK4EH@^rj<&2Ms|D&|zqNHAy(4FD2(Eb68V-z3jdh zWWUbgE_dcY_r}j(bGvB-1Q5<|9svXECFSBO;BW?-qMd$8;R0iJk%ZEO7T|Z#@E_G* zv>f?BR+#0nv_gr1^3QIju}*}X7{FihQ+UCrT3>=QHh7e!iVBa?b!fvHBLe`9Y?v_N zKTMd`eFF{Kk*aHOcD<#}f%P#Mbi@RmW>U#HvQViCqVjQZP|pAi(cu{ zS04X~c5|ZgyaFyjV^8aa^)|p?swMirMC^Yf)Q0|FC;-r@df9rh`n1e#_UX@vFue+L z7Ck;C>rz(ACvVEG>%OpAOCxy$*vix=U-4CDM*|g4iu7_#7OP##vh3duJaaH)#(71U zRdrAhRYGHsH24S#N6-y1VtV23X}fW&@;sf32>}#rcT%4?Xq#3kuYCBV2plKH0~l#~ z$I(-G>_Qa-tLUGLQc^e5wYl2Z5cNdA$$-|u^+Qok*`S~xjbhiT?Yl;7KwJgjeYGX1 zKJ*wH?IH~Q22I0w|1qo2KD6QaydmXeGpQUuJ2SU!HU4PCM^b2^kn&m;JCPC&jndqI zwE*5&1$dgscG-~amwP=U;lM#t7Y=~i?VM{9C$WSBG}{FSm3~OWYh7o{UaoZ_sfLJO zE@y2oe%+%W_Y2MtxRwTN=vz_7l>bv${?puVQw6|4qji9}Rs2}|4M4r9k%+qVzZFx; zOSqE^Qg_ZI+pzitsQqgFb4uZhPF}(BI4RBMTZent;9?|D%nX4*nb1UCXGIQrO3%aV859=9Q&%le_=FXb z9+R}=kEV}hDtSS)n3U1%n)$?dczrs3+g0Jdi?>3vbac8++Et@m4JkR`yiN*W_K+r+ zUL|`?hv3>_WB1-k(Ux*()A9SY2rDI zcupNe@WCye({3L|c^I#z3oCT9`s|P5ewl&jbLpaq<}X=55D#@#DugMS73dQ#jMzr% z3rm=lXM`fms~RY0NJyTXx*r|L%2j9>w$aW0O_0;0KUX?7VGZbxfA7G|j}Ctn2sQVo zX}y&zbr;U&$*B$H3) zX!Nh*A%0(x6Rf58opi9KS|#t+-?WYb1g2fkpY-Wa6d9n3nNkPP-86|gmwgINTnHnd zz*r190HIAl(^D%>IaUCkG)~u{QJkY}cr~1dp54?GsyaVJ>d(gakdlQI$Pb3}kY=z5 zcGztX@WmVK`WQz3$bOc5Q;W_|cTZ!v=8^lPGykUSLNZl$TELd5>K;)f#cRPLu=A!d zUrv~J#p3z%(}doL$9KTR0wys4Gk7(~lOxzAefCYYIZMtC9kDC(2hZFU0yW~uAV)_f zTolGAvHXWe{{J;vRvHk6F1}(1s4r-@)Eu&OppRqity>~a59r=%YY$lE{`cX+%N2otMi(J$>Xir`Yfd?1@*_JN!HNJpR_A?dX3qp5HjK& zs@T^Lv|SPE2ZniVxQIO+5g}5oA*0U{m;)(15Qj*ue(1OlaizZpTbPD{3=b;~X7Cby zT-3eO+fY*8K1)7lsLlqg0K*{iy_N6zrdga&gJe&eCw|z2g|paL!k?1|iVB=kI5>o! zuWiKz`beJG+fNb#% zMO>6p{13zQPyBh1f>i|0LKLZDgKl33Geo7*!|>{)%RV^RIxgwdb@O0KgJ*A z%jSvp=z@da2E-`%ufwSlN6RC%umn{MS(}F_5Hb9^PUasNJ`RPsm5v zKl@xlHNdzH(FB#GiAET*k%-biVZ~rxo{SVCqmnCB*zz@n!q+f{4Zo8lJOvJ5n6y#c zMMObBY;zR}5I_3vpmg_Mjv2t8`T00H5a3Nk7CpN0nyh&h@cF9c1v56qTPg>spodz0 zpf`>*hy7Om)uNa`A2m5Ca|8K9CafV;4OX@FSgYd6fsguvtf%p%9v`ENRa~wne9)pM z=)0CvF$xBB&+bQ4tNt&<9fqQ$1}ic-FPe9{?;r-368qvp4J5y;1UfwJ5;XfH$erJt z1&wr3?K!+#gO#iem-st_@r^|`&*W}$0P?RPQW2%g@PXlNlkay=Cng|r22Mz-K&(dCtW?NWuEKhJP8lPTNM)sV|#&?fChCDfW#6UcRl1T zrjYI8<%a-)c{-)TDAeOE`pFJH28OZ-93y&LAhgRh6DObqFv^u3y~sANgl6MpA#$NW z6~#Aic7j*h0ScZN@WH|71`Db+A3fRgeNix-CMb17z5z5Gyi}KMi!95R@p4QdBuN5r z`V$uWShMea5v8QQz@4X5u_8Rc8%+*(;L0*JARf)lj|~_fYddR70(uW}6neeeh5vy5 z-7=60jJv>aCi(x}=|z@e+jODBttrKeQ~LMAp^H1t+&0?u0{cNH;th2Ia!#-3cOSpj ziNHrS+$w#d%XE+>__%>T)hVc2xzd0RvoMb6;7G)0*D_Yv4a0t&Qv^bZ(GKU5bFn?E zY(4(|(n?#;O7oEZrL7`Pm@w?yW1OjrpK{w-?uijia zE92iA&_dt5>Z|qzW&-qsA1~RfDV=a&O-A)uxtc4D<);dX@+Y!X7Pqq;usCiVuYkJS zwCIzOczAtM6?Q=GYl<3lguuneQQ||s#f;cqe8@WKj1TsE)8Rq#O#{U# z>=9L)SoW2WW^{O?f~ehVID zUh3a6fGF{hQ490!-)uP!)tKK;M?vCE73qo zR-$YQ7Git=)b#{Jwm?nxZx)EPk&E--B=x5PZiHEPz~66+L_uBr^4{u@r+bDwV?Dbh&_{ayE|K**u_d-2F2Qvsu8DeG&ClAw5kiO0tInUKf5A`VZ$38_`?(DM`}P#0F{PTcN4rCUXbm6#Egw`&*5S#&Un-E$~5fZOjQv|kO9O+ zFNgq5boRr<^O(W6FUf^{SFp^Ws4{`dY&ft;f?>zWsS*fyT;1@0O8q}2!sZGa9kPgx zs2W5n2pn_A4H*prQ8d#uIwLwC^uIkRcz0M?1ksWs6xAZ3@hfw#!x*H!b25KxJ8hcC zy=;FvzDfAP5s_=X%?>k#f8_5&h*?8w|I`mx*5l;=fWm^Bs_A{tY9aEzXti?G3TIg_ z_LJ!4IVH=^dqnv^7z^#$L|Ya?%TlN`dg)09h=?nF{E$p)e!>|_+!%eYh%RM75_kQIX!{V8eWqso z%dBi?epW;r=BQ8Z%MGZ*-@Ytds1fI#8M%7I|7H}XPX5IwkO1rH?%`mqn338#dXvWm zOD6i|cbwdUj`*R8DRz^2nnkrQrNjzmm9&RT4Q8{wxPUqvLbmsGpE;F>B3AeA|B?09 zVNtbR-|#uZ&>%=i2@D9RNF!YXNT;AkgVNnygGh-;cd95zONjKKA|TxY64KqB-}bqm z_j|AFe*fbi4i5G{_qo>k)k&9KDhSLf^K}( zrsPZ94R1DO686W19!GtB7~76rzgcK-RlTxqo(|Z$q8p)Ivx+eIsE{fzZ0iK zS<6pbedjH+Es>vWl&JDQA=nH<>e5{|mV%@7sS9gOS~QUmVx9FV6c&S)k(Sl`DfdJZ=dOLs*53P13#+3bkbW#61 z4wq9ZAn`yjD-mDs$uO%e!)^39CeVK3IaCTetXB zie~^BufuS%;gyKUmLe(9xZ#4ww)5{%{^C8O_>0PS0oUCZbw}JRe?M<=J;A9lPju1y zpyy9lSGYPoW4@jFY9v0i+HM4|rDf-%X0<3+?M2kP#vZv~4#3H5unqcb@XMYHh7Bs+Nx5)Y`vPtAZXWy3BG3qZbckGXKEX@St9$a&0O^CpVO){;T58AS zIr#N5h^BSuziVs&RAV)T3H{wRPg2+o$xum{Qj0A$!%2qdqMaORE`c-h7hHNzy%-Jj zZ-FjdHDKt-i#H7nGlsFMR^E;+9%fo-_3y_j*Pt#N5;9awqW!); zj23&9nHomxY$OEhdy)2z!H}=~8ibjZB?kTSG^NGH@pm{(FrQVB26twt*c8=Br9omR zVqD@un+XySI9^&R%@E((3)lMeS*pgN%NAb7(O*H9v`?qR{96esq?hj`|NBglUPltZ za~}ZRFmh_~k125?o?(F-+s)NA{59U6&-lgsJlqX!!fjRr%iHD%2OIFMY4!F(vrgJGHQR!-%TKKi7M>q$w`y{*a(c2BDleXt$3yhP5?hime zv0beKN5GPZ&B&DU15HGW$D!d35E>4*wz|%ALE;{u%oc+T{aN9c{qmH%{ikBrce7vt z@(w>tvZ5OOz}RP-G=HtF7x0g!lBE96k{J8PC#56k4~6<%aqGFGa1#r7v?W8&IiM?C0jnOr4VYW|tp)Ewu3vZo z0chVadLpR-bg4j3Pm1k|c9>BIYsaz5?-Dn3k3PohsrDD0K(0K9|N24kY(w>MX2(?h zvULG7NH-3Xp0y9ZPSKFg{-e>JSLw^}TK1tX*Cy#-$i_+;Jx zOjHsNoxitLnCkam&r1F;I7d(i4a5z-;wWMAx)Gk4TBZcwsN(-U<1A@Wj<5 zLFWN*}!Gs378?aem}xH#g+@fcR}8h+>`qh6hIYSMFxuTO}B!d zZZezF`j7(IZ%8=17A+|6MWF3go-l*-bsDr^62?^zmOJLQq0(0fsDnWP>iV0RFYgD@ zjuFp3NRfk9N3v2vR3x~Q48w!<&8#;R(y0cU{=BXLDBAM&{c5LC9PGZ?)3^nOAhYT7w++g`M0rV=C3dt&Q5=X%D3^h0g=Df$)=4 z77tGZ2vNrlP$gnH2p;}E!7ulrj6$~X$P&||WZscz(Eq#-!S&dPXS#FD*t{o{8r-Kc z6Y|fYz{>n^fI$D&QzRMFVz0SxpF_wJGFOD0(Bj4cy>;g0&t;UKxwo*6)Ol%lpyL(1 zbx486+O#(B$$tUA(G-8%(^HIc+x1&H_{-2h3O<^sfmo9^m2V*T_s_o3VXAgqA#9A%Jy^MpD@Q_2 zd@#$^XcU=sf^Q%$cPQvP-aRh)V1SPv3Pv)iicEJ+YjHeM&W@lF{9?D*gQpBAvK`n$ zZ7bT-tc>N&Yo%51+iZ*rJSy_kE*X(M5FIhZN3)3oeS9!+yWxYf;eEjyFjE$SEgHRv z-%PO?T&0Y#nVK}prxc2?u>^+YiKB?5VDDSR4P-1(+#fbXd2r@S_TsU!rR1je32^<^ zet%UrOpeb6e~AQw14{wF+5T&Sv|)grj4}d^4x+~3FCYEzBkFu4GkqvglLkCd0BK;q zxn9D8?rraEU2u!NLr)3I?NuLh_Uuhjb&*{6F_ypt!1nGEjMG%zrL|K4dG(^29}m6o zF{K&6^i31p)CVT;CBKmjP-|y(lG8%%p)0MBBmCg-wN=N}DH-@`WzT|*DZmFxiDfD< z&0|9>z2WTq_(`W#tOHV1@w$KpI37KgNj<5JzaEscTl<#zp{#72`WL1`W|WFG(LUiQ z%WCka0*(U8ygxOZJVcj{pSyp@A;0!K9o@4`byqUHH|Onwb-UNv=X;DfSpsX z!n-QvhtGczGF;Hr-k!7mN};EDhM;>h>XE^;E8pSBhz&=Ll?m{09PM;HL8J4CzE=ik zR85iV)eKj}REecMDt}t*>5qd)I1gLh(Q84mCC-4Q;DqI2i#aCniI6b^&zVTJ>ie*u^ z@X#_?Vf_$?^?aBlH#XDCDfj>hQgbo7qU6A4Qn{#35`n%Q_tSLR>a0=b19E5S*WH6W z$18bu3juVjc(HGA9jZlYv(mdmOZ4(sgGG;snkYxdfK49Mf(sWr3{7kyH z3?*y4gK~ZQ3Lh7VuxVniH=SkQ4?Ooi9Z0z}=}Z;H#`Qap z|EB2-RG^W>8G$L|I#v7m2&Spb+4$T~&Zd1xg-%{=9yd?lSiFreUhmVqiZ=mvr_x<{$$Y7Xyl_Vnnvj>6Bxddh$ zDX?SvvXVEkv_4Yp5>+grCO5Wwy>Pd!LKGeD;loXLv`*W17#R}~nDY~_{;Tue8m(6s zBa$=drGb8h=A^p%`V~mOpPO?&kDIaK)%L|@ZaLLV5(Q-;-U`_Ao6bAg+u{)SGb0BH zw>92CAwFrRXSWBxhQ?Gpx{AEb;3PH;-G12a6Am>4y9*oB)ArIYasQd~%ptf|@W+Sk+!)KjBgyOQ$}lQ75L5EKk4cNIIYqK zQmsD2dnd$&<%!b-#E%FyHTA4;ub$JqsZS~LY#$2>$V`R(G%V+OOHEpSXBH>JYPrh86bHzh^ zx7%pi5Zbx!-bxd?isztLK%@a7xT{!0F<%8gb{i`K^wkRghr#tRw`J(F%Y9s=Q+)ut zGqOS0w{)fYlv%bbi}0rO(@$}I`r(LZH$Hkz&8c zYuLU79P^_om$}lkOvt&wsKrzIKJ(D$&xMBq&U7G#wmuF|#6V0ndXdLNpeS;=AQ2CZ z*r(W55<#T_v{=wXIgCyaLMdUkC2hWvRFKE(nBlFgpkX!&Js_4!RkZT0eV+9QEE zS#j+VWXhDRF&F1$Sa{g-SHhlr*=v$7*m_gt9}5yBt~WVzy@rz9#UtMXhx^1c-c_VP zQ~wT{^E!~j7k48bknELwAg=^wE*B2w;yjeHp#FH6;6Nk&0l#HeE-s^gEY9dP_6Vw6 zt|hTAIn#xk44X3J@|-$0?a2Q8C<@*RXmXo87ZZo_iMfWPwv>% zE*hGM;6BvOhTi?dK3Vs0rpDGY`CCX0Dl3}~B(}=$#S<9>2c&%Vv||Ft@}-H;o@k5T zG9XdAW({H$JD3PFui-$8r6)tAV7IKJpX8VN4#Q-p-{+=wsKqkk`{kCzlg=B}Rgm+(FEMS7TJ zVsP}s^4dj0-cN53K%v%@<8I3i0wib*6NG&K^VD5JvkZlHuIYUViO3N%UTB>Ny|=v& zu<67t;V7L6T|(Aut&idCPZL4;$nz=fTL~ZwAABH00SpAC;ednzMe`EZL?mwxQa_aCY7E=;{wivx-x| zn$!TakfL4wrODN4$`?CKzU0ByqY|BE$aW6rG^%^y6x1ipo%=2(ko*juOEK3cYa%bU zDV(u{=41Rmmu^@3cKYhFCX_6?@o+rln#BJ5;@Fi<9DI~&-QN`#`<*72ugaot{j+t) zjQnMA5kGh>FArFGc7Mj(&bWClS*^1<%l$s(l)Y;@Ql8WD$Zh}V;8*-X@8vP`85Ky1 zf>W%!O$dfG?3QIFxVG+Ak9MpXsA|pxaTwfL)FVV1@)uKMiIsT$A(_IdpXt2%`DAT9 ze#qbtV-dpM5Kdj`iivq*aL*PaEK)`@i%?8>XsQI8nV+K+*yXQ~KEHV2^3*tZb`t5e z@$6&p4hNrQ`&EwaC1TRK+had-iT;6hzMAEjq%!zP00eRGM~V?$9sA9v(kQiUVfeCwQI-)pcXZ2Cv3 zjJJwX_l!WI)dgVlC?f7IN;xMY`65Lg$LH!N!@ZA2x~ke-FjM(fW-hOka45Q5W%(Q9 zm@7VTCpyj#5`C?<)pXn%G)LrLt^cm3VPT_cIvi<~xU-wcuVUAN_Wt@*`ZYnTFjr`=KhiWoL2*w`#~a>5{M+DRx+nmA&@ZgM=BjXQWn-sWvKPIyLW>Vy(GgGr?xnjJ zt^*G83P5HfJXz0C(YdcI(JZ9h`K?-&D{uX7=gS%xno^M!?`{%NAzt~2%Uc(~w~&VQ z%6F{E39EgJ5C&5U5RAlXufg)TJhi768n65GqM66bfxF$|;?+9o%#y``=csaupM1(? zTxlWEJH>4$+alfT&rr5QGz;2|!Gh*dE15Z2a%DlC8P=kBAfj(gKYuHm(+3shV@$;a zsw|uO#-vAdr`%xo-#%|dBtGg>V&y}JTVeDLa~yf8o{Z>jC%v6*N4$!;SE^!+ryrqp zx!>74T!G$yj&_4>5RZ-e61ekf#XmEqhWAA|5O{>qI!!QUUwa1cYQWaDG3DKPp>@bI zLxv`Cs6}8hMa23cW872yN=vllQLP4~XLyWRGxoY4vJRDd#&SE!YvyGnqRXVd7i%$# zU6OoQU z)0bg6Ay_Y3>!%Kkr({h|eo`%eYGc?q?j)Z(oxzn-uC(laJE(ERJLx^=5$bkZEnP-| z@xqer-Z_+&I69yvWYo(|149QKY=0vefIQFSf@GO|7VIeq^boZY3@vhgI@|ng@fd zNo@=oK%jBot(qX`QmA9F;kjeQgEM{lzVm;=SGsbbyC6K z-qEuM;1%I39^D`~a)QD)8pY-`6NkQ{{C=x$N0baG+oSHb2wbWvvv$jke~w+;`&kIH z+}YE7%=bGDwfLCZTiHAbop_@Pk}(INDFLIXMHD9t+EG%w-*glZp;N3YopW{GT%k0F zfBT$YY*BW06DBc>=YhGK?;p>mp80)aH+GC+1?YwsUqFF{DWKTpRWd*wp5HS$@eg(m z`8jj3b;Z-U5_CGq`00MrAk5-xxTHJTncB<=&gzDL6|*?B1EY3|0XG7id;JMr>W8+S zw3k8es8>3c3HqgO&KSB#fM%Y}m_BDOVml8`esw!@n|F0BLvi|os{wzJ|1R1Umy{Ao zE3?^5dCp3%{SV~Obm*_(3`s-|s0{s+7M4WZvJKXh#93X86r-ZLL|3{G#JjD2t|ae_ zbcxPitT@xSHpN+Bh)GwEJg;7wPc2j#!lC=POw@U7+8*6D>*d4eMy>a@n|ntM?5j=z z8lOX=8IH2df_H7TIL|Vmg*FRK3rtP(s8>@gYX;(vU>GDR0iI7_3tLack$qvoV&J-# zlLJ=ed=55Ay!fbA1hdirHN&%5=PQY$ffK7tS%rqn>Z&D4KieoN^P2vkDNy!+gPe;% zyT!u#hB3y;SX)Yh=B2KZ>xI;h%ty?kDmwaKiU>>=U7tVuwY#Sq_cq-8j~p)3V%+%i z{zF4053P9bd5(sctgD*9`5x+l^oX22%oShN*YgcUOQ3u?_iCa4PS&m6-pD%=q+1*p zHHTFYbU%X^j3TUaFi#s+YJ6ND63CH~`d zpA6=FaWL9PW0g7enex{BWrWmJI3qd$UUA{DM=L%HeX_KS{^9n?=x`S?F}lvHMa%JxJ6pLn5BZX`zPys$CjBoHIXzM z#d4Y~khIS+j$k|Q48)o;0r}=;MbDz7d<3y3S)`8;KoK`|URhF6MI76!fCd+aws%UG zx5GzA*B^%@;zLPiRy6he$e~i4b;sTv(p!>i+FQdn(m>xc!nPXlYD5|o)8tJ!H70RM z9Le~$J}mnXklmOA|4mRMomi`6zgFB*Gu$CmRP@R09!&Gl2=x&}U{6}Do*}v0*ElmH za1@_5Y~E+AzPTXdh27kX(A$$+mxznF@(>P2#tX`ge4PLP+GN&!%d|;VyodhYF2x#@o+jC&<8^?qHd>le}cG?Xzi-~*G{8UBgl`{jq8uR-j=of|fgL-$c2rtnNuV9i6n_8IF1 z6mq_@8pZv7wE1Amddk>Ov-;(sl>r`Xrc<=x)YJ@$2q$f4iAvb?gXgMxRGq~lR#;YJ zRo|9C8>F6LX&29&b3$Y1Z+bp=>Is-Nmh&xx*L6O;*yrmJ%_hZ-jCFVzA#iZ)T&OSx z+Q~>^-XwYl`7gmQx88td`YmsgGJ&7?P&Q%QYq#;=2n?>2H?!4+bi=V$n10ER2R|V@ z`s?hy%q6~u*jUn;RFc48Ek~TU>>o_P|G-MvDaKi+?L{Ry>!esSww{YjR(g*oWkC5| z6G4Z2O=SUFYKNPSIt6<|u|<~l`p8(v)|1DH@lRZj(8;ARf09zlSQ;;8V5qFG3>wy_ zyJlDnh_F#&aHNC*mpTIoGvE(QI_mD~aVLczHr|jV2ZolIEv8EQdrTExpWDoi#QZD+ zvzA_|x&@?Wd!OUi?ToK2C$Bl>$r?i&jJ9ff-(+vPsDg-7lZqvdh<2TmQVeX&MPOwv z)_YQxciMp!O!-S$^>jaMzQB!p0_OQmiIx*@+RC@@?O7)AJ)LYC5bvj8_L&pLMr#vo zzyHNaj6O274Ib4)zGaIXu#tIfZ(K0Fwx;Qk=lNX8-t;Hs1g*Yp?wRm)P#3h_3!V5H zMRL?oWAkCPQ*OvL(Nv?u=DjQ@XGTB8Stt(63_*HY;uH{Vuo>-+egJI;(Z&l4x;ij1 zc`ApNg3w#6%;7-Dx7U6iBXRhx3JR~+%8!TBZGAujD9pBZe-3h$w>C1qzrlFg7kNJB zz53B)k?fTxF5dH?HK&-<#z3E=8Ylr1kivUBsyT}5q;{lPS88kis*sFy$ayLN+m#B z;!xT(CefYoXtvh@UOgps9WDc$_t%>UzAotDpwxOey%t7Z8s;Of9l{rfyQeJDI}vD) zhnO$@<0NBGHShVTSG@MzDz>lj#dn{fTU?Sl-Tvl2xmEh+O;S;T-z-A(yjo%4cnZIG z(-e+5p?3~w?uij#;<)H_aeOOa$~2F}-Q{Cp#7{cUNRjzgh)=Cb#d`6~N7Hr0YZCb@ zksD^eBHwqY$%vqHaxvMlt=goj$u77G$47RZQO~@~$~loVrC0ImJ`5J3;)qpOAAY8C zd(?E&UW%$7pwSo+6-{_~Xc`!?eeC-;aqMifUJA-5eyJ{T5LM;vl6(fe%C^2KKdC5# z{=dMX>gIus!+{%O`d!aoBX;+qk8@?s2-=$j$9F4W3wO-CmmD-U&V8Ar_lI9D1vRO8 zO|nkH)=?s`-zP%y_+T?~BqVmiEN9Z6pXgBimNxhCl1$0=bE*dKcgY}E1@kdKF!%;n zVsfM~IF%N>>AxNI7ae|ob-CrVyT*e+;Lmlae5Z7vebWV6D!61m?2#BXkpBz*u>Z?_ z8t25}qrWoFzdyLIiEYXfo-0+P!pI5JwEkmh+N+OcJJOU&K}DLnb~!9w#_WJIsH-Xf z$@|QYpYVJk@6+3VXCMU!S}uVV9~(tjApHeMlF{4_RZp71M;G7Wxh4DYp4Hb|akOM- zd@y7g0ono^v&|!M-Y2^sPUrO2&+&q=d&+~0-et%RvMfs$x(`z~I1#{66_==siczvf zMpl?9uM7t*Jq6%o6o>@Nc_^0$VK%Z=7R=Cafxdg2BEn6)F|$RYN&*(!LD1l23kq;w zEn}#%>cB4%B#=CA31Ubv0-E1~HmaK~s$X>c>~Fe8b8>m9WWRgVX_BqJw=}I6LH~QF zpT=}`$mVidqsM69w1&WU$65VR3Tn@(FToOHiurJ|phZ-Lu%d7o-Dd&>Y z*J3?SSER;|x>I>8Q2>Ce$?3+VYr-==?ocRRpTOvOGXHPy8xaxFYG8(ceXLg!ZG2QG zR8hsif~`;Xwa*D!?Y91u2g%J zfjxWK3&A29;j^Nz2hX8bSsm(%-Lra+1~RzOi%mTgW*;s5V_dEE)Te0|x7H)~WZdqM zy3lQC*X%qEp!};db3fX(n)MY|9G0{l0W8AJ#+=RCJ@vYkRXgh*$NE0I>2rBO&5*g% zFlZYe4#li!4x zciFC$Y^74Wze(UpD^c-7%NYpkq#A;4`7cSih)EYq;iW=9uBpQ9?ctb1WW_RsQB0HL zT|?Vl(M0#snaim&tfHA!O4a$1BeLv`uennxv z{gz3m{7a;rYDuWepE0TPKVAgv&ks(kDQ^EuRsReA7kxPI=$^;OrJR>yvqZj{=Ii}n zPqilk%RTY!P{xYA_vPWX1smD|=(M}mlw1j&!Q!S>z8W-tzTuSVm~!-h&56uoEI`tL zFhL^0(4a_Q;@wI^e6dvXmf?wZhDb*t-TA^vK{)z4)yJFDkBC_NMM8X%h>m*@cQbz5 zYbDWOA!u#k*iFTXM`o^44j(V*(N>aH-rf=PgDK*lU-XDv5cxjad6wSTsqQ{B-5hz! zcBoxaAHM3LVNJ1cGSJ2l(!mQ|j3Ib$_zH^d*4lq&?rX6WX-*8=ZlQ60jJ0i;_{Efn zvp?ZO_>as)WwDWyKU#re4xr>b7$%A?iDQ?+aZY$KpnIOZ_6UxA zO|f{F$%uYmf%ioH_zZ*C4q+6xQH2inci7KP1lz12Px(s*pZV+hP3Mswr4-`_-D#El zR^8Znqc#gaAFtDBdgg6y<*15nRFNO-@9+A65(tHQlFn~+J6$q>&8m4PUFw5+mU^XC zFH<55(Y0#>5=UM6!ND*cB51{D3MIh?%a(I{j<^rOwA;!6>sn^G6pVK)@QmIDTDnM7 zb&_BGa7hbqXmoC=k1=*(>+{6s@3(v0LABKzm4{9*~4SPB>?+uqNR=%Wi!5w>n?h2J0^<5@r_3Qpt(ZLN5RLm{}%^~$$k z1iX8`cw;rEX32QiQ~japRGYN-zBzBj$iYqRE?djrcXgA4ta0FY*DccJU-I-{H~*u$ zOEEk=%Y0f>TxDsQa9>XiPLueqktW|)VAQU3*?z`y0j86X(}`#QlpHow*~hd+6u^!$$W?~Thjrl#ZuBPk+Ge5d%}+*A(0X(;M{fW*na8O{F?QT^haFA zQj&)nUNt)HRJJ#V6w32J-$A@9UPr>xHMis}oo6^NaSthDt%!+-_4ETaoJ!|!pxY5W zzXJ_Kcb7*8yBsVZmW+JcycFT*v>uZW9U$uXS)&aU=M>k&^MGay{CZ@2T_kO;B2Wxy z-ed}3n7;S;N6o?NAhzpI^w$&z^DFESoH>#B&1QG0j>Cz-KV`g{mrq8h5hyITmY_nS z*qU*2;F*;s3GH)SGI&Za43*yqQ}_&)nF_32L^5}d<`&c*pnoy_YwjK4hB&mpESDeW zU=LEAuOo@T8UTSOUQVXBMJ|`H?^qglQ}@+>mK`W9yZ5Mu%hTp&CNItxh4|(>SWErO zZrHK}?Oj$Dxc;6-XuqbSBOzevQLcKYzzThrT8mIfo5@%-UC zY^n6>z3E0GX}{e!bkZ!T&w#yu0(crm>$a6!4ze9iDPO!)fL;4z%m>4`J&mq&zX!G% zZ{GZ15hJB8bag3Ow>U7}_r*oxzFED)%43Dbu+KS-5*{9pTXE5&Hw8vqdx;E7X0hVh zL}wcc#T7%igoQ^ZWDkk3BZl`42r*Sc@wjMWDRR85z{IHdv-O;Z+a8Jww--50QBvGQ zqx*K}zb11iS?hwn6G^K9>~k}TAzP*-HdBt;lBW!xHu!5eTKD#?*o~eh4B+OirGbLM z+198PthQ)Q67<1YL3E{%97M;Zo(fad(#3V@gKom zDe!>~sdIDV(?X%hLQ1rCrb+S2v?d%`!esUf8a=-~a=KqopC7*YWlp{Ba&@}j#PR&r z!9jGs%|zGRl;+c<`+i)udG#N)K0YM>kO=h%;GAZ8v`ScZsnB%#hbcr%!5na+IX(%D zcOlTdJ$tk(!rmXhY-Dn##mjxX_w~nZ&>+ehqr$=rx!1d!Jq4$JL;tY%r&nVCV9B@l z|HnA2neiYC58%i-_mwSj0l9p?(UB4iuD9CfyMV*2(`Nf2JBa;A>En~;#Dfj2T1DOw zBA3#;_B_Z z-~+}Zj}#k2c8YPi9dnl)liyYbtpQT0I+dmSUcc28f_w7Ne&zls^>E&>-t0yK`I>VVoqtILAT-1DJA~|& zr{RMi(n_AhxrL4!46JpmYl^@fmam-gg&@CK?Rlxd&gq6V9suH41FibLIZ55Q>J_WR z$(!F=rMg#?$iRzabfm7H5X|(`CsDV{sh302l=ta|3Px-Fu{L^da2h{2nhYSh*c>Rb zX^n5Q+06@FUE6whaZM!l(C6^-cbJ+YWwBByEisdBJT6KBjpbOr8+#7Mt z0zIFXj|SEgWU*~;_h#@r#RXr0av%Lqs$yRO0_{SNWGL6!MX~BX2N5Pl|2iRm2ZHFo zl0>4#T;dr^O~7-^Cjdg|1#H&JtFrLXTsW7%BXhNs$h@)6*WQ8cn9oZ-7N{tA?n5gK z8%hhEVZOP>b<0Et;AOtx2G)djN!AV6+5H71E+&p5^0FZH2%Sp!($GW=e>kn_A>-oZ z-164N@>hja@a6L7nsxUsY&C=D^$(5Z_daJ^a=ig9OhePt8jq`3%+2J?kJBt@uwl6b zpk%MNDOL`Sp4?T#j}r%iWpHHDt2COeGZ$7sAMZJp4Fl?184*y$eZ|@3N$T z?gC5Fk@}zG?O%EC_J8Uq!%i+~c5ZC7f4~bx!GAr8ZdjO15-5&(|zf3>53} zqyFY)aeg^7t~-1=-QVY&#ddVi|6-@HYMSucTOYqoY3&bc@08WX?|44$OG8oIupv-3 zG$jW}m58D#bS`vp+agx(V2W582`!p>^5ZT@J%Ubtk_Cp{B_-<^2;AC{>~lm6IBFh+ zRmmv&-H=4+q$B+lM5)_#`aXy21UVtlaoEdnJh{*d4qZExA>rG+yk~b)QWB+nD>cw= zGSGH6dks@HDo*_eZS-^uU}x>#Ke3{}ed4zH-$4Syi-SUf_}U!#Vg0rD$R!*cP8SVK zd-Gl%6#Uj(vA+4E^x!6vA#sh5TtqMtkoFat)?vXgNjs<6=XRs1W4U)={<1PfC&}zS zoJ$HLjD^7-q+oxe2+GTmLal4wc<+kWXg zmVLqmaB$bPM{86`pJ6;Tvo`nrr^m3q0-=J}TG+`~B#@mOKw+?r?rYIOd1sEz89!n& zP!e;#)`l9^7Z`xoUKFB$wx^c7hU|Nt+|6)P# zl^h(CfUi^8GNGEmgkqjXjfRyHqSp>O&=`i~?tFs$Q zUi#MYTcXa(VT+)5{?i{ynCEVee~i6Q3T^pYgUm%@`;<0^>R+K8Mc46I$ehBe)PS`O zCmt$|b#mZqw=&MWA?!#VkNSZSI-)^%{PI&l_QIa?a`;M)2YEXy&|=Wkm?YB@sEfzm zzD_D2nVmmlU_dW8gq5ooe%`Du`z@r(vm2D?e~MqbDr?$zrSJ&Y^ z%2TSEjv{$GCf55tk_Y~P4$|4g0_+uuQKRiCYj+zuR8)qCoF>7GwUwvLsfwiCi*!D3 z=A%ggZ3vgAWS`iLtTQqEs}jd%RY_LXC|~vMbk|6PBC$X=hr_FeO;-km&oI}IQRPI*4V*fg(CWn$JSL9#d>dpE!ut`(<)R^aJ-Vpf3h_L|HZ>Q;GA~hmB{?n zYjJNa1UlD4#|3X~t^q!dbwof%^^zGjhso}j41TK%XmYnw!UZefsKAl+8HxAv*GM=i zz}nG6YO8J<*jQCKM-37dmn6mbjP?;QxjewYcgzLn+hUg1gS<-QtEU*o zU8Z`fdfg7+DP3F)P4cUc!@gpn6OIRH&?FnsI$QKAuI4Y2F@z?XXEw~kWf9FFT$zua zG&pD5m!{vR%X(62q+?>7a1|f$+~bWR1KA?^G9K#-g9DKXE_5VC#`3Q`*22jm@Y^@?# zH#`pc4%t9F(PZ=seBkrpHsHw6SF~YQtoj^mp(zM~;(BL8%-4=qQ2>wNR-q1OQ4#M$ z%_pB;eR136hL?*sLt1pBf(a8`7&vLH+M1Jh@1WLz>^UFZ3?v0>`Qhj^l2D9eF*Y2v z+js6yN+Dxw9@~z2)JdrO!MM#W%-Y$Rn7EWauT>>wph8Xt{Pr95Pv?0PQ+YW#%syNw zPl1H5E>UIqJp68ah* zdD8^}m=~47IF+-#Ff2CAx~mKFn&{@&CBj(-qDEnUH!LG=gfg!@+PWSF$M6>DN1A%krg|-I< z6o6pX!5tc%$Aj!n!d07faz`t1L3XPs%=3!z!=E?RhF4NN)yBRg)M5WZW@B7=v`&o*@S^P8M~?C*{D$qmMgZ+!z&*RFP^A;r6T zXO8#tWXZZ%$J$Fv{c2Z%6o5JyGYAdTGwKaLi|I|Y)5j;ZktD2^j6~MW@cQg@j`hN?>R3~^*Wj?anJo+AX>TJ zKnx{-wr5vPYFo|3yd>l`ABxU3!cyJbq74cdQpTXweCJFc<2B%k$X(uT$eb1yU^gBk}% z>*~qc*uGNMTd|S7B{n_3{C~bt`vyF5Rw$e$9v`hF4f2M~N4-q>alR6)-uyu}h@u`O z^fpH>Q@~tr%}2MY5?|?Md^Q&@&{%r_yfHsWC&4^~=2GX%%As8>knAs?ub)pBPc$ef zfbyG0kAeN|UpFn`VB;$G!UU2*a2i*n;^>rpFmSAkV`y4}rAs=nHHZH?(COdQXh%;) zUfj1!$+Dhe@axO#Nces$7grMK#uk46Jx*JQi-B?)?cU!GT^nGIb#cTtvGRC9sMHTd za@EE0*LiMqUD}gFU4i6Z2T3E+|DMtxqQ~S`=6bVVzjXQJY-w4vq z?a{7%`(`!2y-oU8jjmIoFWs3UB^?rC*LGTdvIl`nd}^7$H0Ijo_>02pkH3E^#WQVO ztMc5Sg(e)ir_&Z&jS-qX}u6xzW(YDp>As4nz z+-h0%SQ`}+;^`uG@>*c1u08=56p?tJ^!dQJaE=QLH%`0OH5R$&U%|!d=&OJkY<3b_hw6^ zT&=CFBwhUEO)MpFvG=)7+VYyz0Vv&(@Lhg`Yu)nP4`OyGA6dz`+1~^X5EU#dwslUF zF6t4s*&OEWlebB4W>##lcBj~|Ol#$t3=Pt^Bff2U%5TXng*H~nYym}eGd9s}X;XQ^ zNy;Edits6SN606>flm_G&L(i|qI)AC#d0pBAjp7dB_`u7b@sYX5=GQT&~RNY@Zc{i zFLx5^d6G2xMZhR1Oe}3mZ%yd%1y*0Br?2;;voqQ+<~9B%WSgtAZfjC^@AAM=>NV5t zufYuMs`&e7BT^x~2e*K;6fALU(&*>4_$Bm5oZr7f+Y7X*LKw{Bhr7zQQ(j9UX)jz^T8&I-eui2_D8W6uNeDHPCirkdn$!rn;2<;6N?6X=koL zL|j#(+R^c7bYk%Tqv@@qqWZr7;XA{Cba$t8caMrRA`MDON_W>_B9hXLAc%B#41zR* zq|!Zfcg^p;ogO=t!dM%O0lx&lQQr z`3DTR_g`@MZuWX0@kg!Wr230ScNR2B1RwJ~08`zy`Q0Rb`{rLd3X#K%7_&J42 zoZ}&w2L|EL7&^&pyP;GyFb0;P%&aAuQXu%rE9NBhCd_g^$94HWzr`f;|I8vJ ztib;HIYKND3iqlt965kAyDcXpn8a?EP@k9nN*4g zH-R8EN)e{5kTLaCg~u>*P`|R&pHkuW`oS~Nn0Q_A_ z#K@MKAg<)Br9?xJgj~eQgwNuqe^8mk1;okEI#t;JsdW%_05Q`eshkc z%7BZqADHNc#L0sj_}l z>GDCG_aUH6tl`gFA^}U*ATLHx3zZjg5h9U57Aya7iCoE+o=y=83iss6y10HcqVHR_ zvV-<#SO*?SgtD*1fP+({9!2=~cR@97Tf&57$#ang$l(5 zh_%un>xf`M)&M;bBnHwEoiTS`@d&ha$@~G|uv(9#sP(lJpFcv)zP{f(3CPh(|EogQ zbK!}H#2uu01f1&DO+8tCcrkvO<@Zh6%QsI=@A}90@9K4ql73L+4e{OEjxDGjM01u{ z@xw!XfWF$fh;xsn#Q6)?I>7UzVQ(#o=F?$SIh1eayZYA__)ToYvrwSE7gsHmF`J9Z zIp?P3nlCgNRTK4oke8Y2nMt4_0_g`)dhdO2hQ|2`)|B~}nD%+3*9(`Bd@;iTP{JSUwq&w_o z6!7I%RK#YVm0~ztpQfFrbjG7+~*^)bpR~$9e00e^&6HDrlN!hrtoS?<;M+L=w*bpY;6j z3xx*o4mfm=Xqnnc`&YzgZ~Pv=Qt=oQ?^?nccOLR4POW~%*{1%?vhVwcP=-fY`7Ea- zG^uo!GnSw?Qwz&rR$(?apu_3!AgKcC^h}x$qA@Ug9v8;$x+W0-M@&F|_@$}D@&E<7 z>C6c(3^DMhAS*kLObG2nsG)VXw8UIy-1H_{{$7fp^o7=0ozrHaQ^og>lyXXS`;6&Q zUsD2GTGFtoO+mq%uNaH=G?!<@QcVj7LL!RIPXY+){RfQI@Njq=2+E%!v_o^<$b%% zvw7YK)^sIubx-dMCe-)ywlZ9o%X#vYnWmI_!3p zV3&#|cOX^Djsl?ZWJ#D+2e3VsM9b`a9~OpPXFpVVb(5(MmajD0IEcRIz$sRT&yRGn$fS4&_slPs(VvB&V9~`4ZyRI`t==!(Ks@4Qh#T! zU}1qadS|Qfs4WlrdLkoAGAM`>NfC#GaHz5S6I0WWf3zC(3%_!v>tC>hMJelfs!tX~ zAw%AG^7ye7*zE3&DG*tRI(*Gy3{Z`9 zAOmiKST1Q7f}AzJAWnGlyIVJ!v>qF}WHKUyu$=}Ul@?eOKeg`ufl<_NSb^!gy9-4` z7SgS8+0V4Nwu%UW#vqhR6}^9+?Hp{OunL9LyC{zu$) ziU$m*j}4LYpnC2RrOPRH?EMF>JH!j@?I$dddztIct3&^5%KkSMN4y0`QvjmH*kYas zm4x3k^4GCGk*u%}t3)07i=8j`)(68HS!0w_I4Y@uckkrF0yN$a#I9TPpm?)l*d?iOf zfE)Lq??GQy^|-H?Plict7ux_>@390o_stDm%XM=2VUn* zKD@dSk7ZN@^n4C*Zc1won(TjzPn)uR%&#whf!}V7h4d*}$Te$Iza}7IW6t4+s=0G9 zEhS;7RzT3rxE-$wBiu$gFK75daS-K>i`eIS?)FdZVepoV*5vs6P>hoq4puqNuC zzo;5kCw`E%HBx-cX#L0b+{h>v6l8$^O88r4?Ue&Py}o{A-01!T5jJrDB%z)+OhPAJ zJ3y*GpMY*EqRPgCf9@hvdOE+}7pQkz9Q%H=Un1O2aq)!I^7azP9}N2}(1SO=Bf(}P zR$kJ5x^zqoe0rC1Q(n$EaUci4j8de&1rZMiw4Ji>s*-MUN2aAG{Ig%Y^1RuOw2TW$ z(+U9`5se1)boq3vE0{;7c6I_BIWe4?QL^2<2&0Dk zFV=KFI<`1D%>ZvysDz0Ip=PPlZ?}l3JeP!?P3}vhe;qPtCyBJ*Xv~1P-|Jp|wqC3u zw&xuMb(mTNRF$YJ7pcHap}Z<~(tdW`eksx_t}mac$9^RK7=Q5Ph`jn2)bRt6^88^Z zwS+U*2SSEYeyFv_78bKQ@e@E*4Fe+HS(+RA1cnW<#AY?3S^717$*8)8NWcBGw%7tF zNEwNAnp>MSkbCvY0skKtz}p;aBUe~;O@`sj;arA1wP{OTRUK7PUNVoj>+x-#=@UY( z;?sfU0WIUPcM$=zFRCuV$M)z`t)q+P`s_>-+5o4-EjMs+||to^f`G=y?$u$^t87xmi=Vayjf zS(39^b2OTp_zXQF-=Hvbdtst47^-qh_;5?lLXlWo?&$E>fzaF&>Tn_GE0|0KTovGg zIZb07THyegh_zU#<@oNW%&Z`e6eYLjVh%VlXqN;4?1H~jI zSdvvWv}{>vAMubp8LB-jE-qD;NTK@j0bb|%oB=C}_6^J1k5-q5B&y%Za_JMypsYIe zP_g}Ms9k<8Z9a2aXr7O6@Mtccf6POuIBoHakaDs(# zP|+z!hSCG&_hESaT}yQ}mie+U0EfIKBC=}}$D8~EtobK4&@8p+-sRcf+I~8=?`oRm zp41QsGkz%IIwx-25h_kgD^M;9gRnX^nkcq9jMuD$lM1+%W9u6lT6MnpCFQ*d`0mYw z{@Et{opg2L7I$O7C4P|OWqW`7l#AT+Z)5!W;~u`|FhX&9ga012(QB9h9wg4295A?3 zhg2@KMC~0VKnvh2tzyf24<{LLBzM}|Vt_AYM&uN7%J8r~?eB0yvk=S)rO^S1b9*9< zNtw)n|Iy1b%t&f>>oWETy9zDkMcnpiC=&Wod&!044s&W5^^0SX^O>ekWBx;840!w= zFY$RanE@tq`Ju$B*{Ehm`X0>>X45{miZE(ou95`ar-CGc&`( z0;Gn2dX7W9ZgPA~g9$|6uMI`N%7RR92ab#ZL1qT~0zC+cUVeT|_F@GgU!aMffH)5} zlPuWiOWdyE2OWPi%Gf}@(8B%>RqYnz`aPXPy8Cpb7NhpyN5^{>Ky{-}rd|xW-#0}3 z1M^Ux_1muku{WdDWG{JgIc;0yQaAls5k*63_r)Mz;hIC_qlC)~CYd{nuNH4otpC>7 znQ>m^gGW@;iDhmgi|RE>ZuwVCSMC8`%9d+=Iv`aGH#_v&?X^=&>QpcZs4U+*HdA{t z;J%eZk_)w5Xnp^;H2@=q9_J3YMj7C$P$(}A#DEn;t@;#E;~A|D)OM5>Algpw4%EvG zcwGk|^!BGQ7fb3tqdzl$b+1KGi*|}lie^J`3CkukSHD@wcbk(|7b@cVwF>j3uc-Ji2DYd^nXC{tS zu6-y;`zu$N?YU$l<6A7k?zqk;aheV^h(0t&%d_vHJ#ifwV?g}rORkDE*9?9mT_p`B zW0~Ba{$DXPu@;i?VWP+7k_fyr4S&u9bwA@1i^4LSp!XX6RUr~c@0IcVK$@`MtB;jl z2&UUb<$Z{gUg5%%DSawHsW1O3ia*JJEFe!0nHYI$O@x48U|AilWIR6fu?>4*p8ThV z9AVO4_EOR;apxlMspNpY=WIQH%DUXox9uJzmfUcKoL@cBBd6E7yUM}=$8}?7hyu^$ zu`o6vpKb4>u+71-*!!UR<-VWT?@0Xf+1yRA5&B~nKvC(oA|r5@1#l^Ag#-)Y04_@Q zLQU}#I~MCWz`!9#$!^&yDb~4P!mxaz2g$G&fpMLc_%lXD&N}qa==fwuF>Cltq*yWUctlmG z#fvkC;b4{=m3Cuft`6ICMJp~xCnsU&zUTcDRdk4CP{CJ`=IpYi3Pr|2DK|8xS9KF;|pt~;S#VhLyx6v#j{D?D>a%1kXRZ4q;GeoE4C(=J`yqr zl%q>m_ch)&?&Ck~@hmFxRbIXm%y3HbEOl@4CZU#c7uxup6>FbkHd#^*R2k!g*jY+? z7>}~RFH$d=haTvF==gDi=F4jKQ_#AIu*KP zwIP+_b^OfF4(u$;#$_bytUMKW+{C91F>^lzQMO`D*Obk0ZGErGA;r!%YK#A>kv*G8 z5pkP?{?yXi|6#D{+7UW{6hCsN-zbm`-vW@DHGSP|)kOVMRG<4cL|!|(DqCBN&b*lY zMvhoQ|NX;ee0E7ojyrVBIXNN(6-LuN&w6a;L!m2Nf1sBjD_yu{WFh~Jv8y*$*}$NV z3)tS?j#KS^gtFPg${h;C_=Zb$5w#=rLRqW|%suQnTR3xoWu zuGRP~LO-UKXUPW@2GRLc8fx81MP=U@=*OgocOzw#N~|Kfqa9Ueo~3(zRz@n5SHa>` zsYnDRRu5-7ta>rfF{T?%Txh<1DH49Y1itcIGnJ|MPTrc>s4Q<&*5~ z@2rF*2HbNHDmzd^Jh0i{L3B)WmwBO9a1 z{kdO{pffWfd9c?`4v#eS$m-qZKbXm0uu^I>Cp}a1Rm{+fGkKZx*vx=}RCmM;X)X4~ zSnT0Vv_XOU@F{muSxG|9qqkUUk_elh!~h`+RI@~!Ok*Bhoj*(Rj@*I54KNv5Lp&u)IA&~=N%fb+DN>ztDF z+$CT2zXiGqjxR}#zW-vMm(ssk8|{pkE+ElTCRVHF!;I+uA*18fuNuX{6)3F+`&Jx2 z;JCDO(D61p?AqKSg!A1^00tXU1W{wJ{R;J)D$rZMp3|&;my%w_oA?0{)TkhxsAXXni zCt^cwSP|OV`&~ZhTuxtK-+E^X71-+HC!wlT@;rn3pp5pj^7DA-9w(H=*6$6Y2^j+e z18SjM^Y#>DU;iJJ#lfo3(w7a#c?3@cC#k#cK7enOyRGtnZH&VcfIOTx9N4@nAOLNW z2y>Am70Z54iEzERN!D)KUuDJ-eE^t|?~AZrU#5^b$m0NZt)6Lov>6$HCdT;2(9RFv z)=wEapf4s?wO4P`@Ko9Lbh}10>*BDQTDeM{9E(XBvS>7Cu`wEL!%c*}!lI$fy7~_u>~JiT00Y)Uaey7m{=!yBnoAtw;_;2|A%q0;bH}+PRGOtMnE>Z(sW{OIz`gKG`AB=p|88 zCgy;^Uws0dc^|yD)$T5b8ekywzY-ALjzA~DUQ;p6jni^Q`bKKTfU4gr^7oIIH zsG%l8P$)xz>?M3qVkrOq!-wOLzKZrh@?^dy5->-dio1i`%)b;g!LuGp6^1g>(f(fj z#CiV#{EV-(T{--u=ILiw;_=_$->=pOI6baUx!qnWM0B9!!vyWJ_B{`moQ$hh{CbTulF2pH`=b5?w6lXi#w8s9w#t)UtVnvYII45j%zh69rZFL zAGGcZWG*%=-n)9-D+XU$`bq9A2kPh>Osy6cXcUM!4&uE$Ivx~cHQJPaB;&cJ%4h2T zw)WTR#W3iLb9g$gUCSG=d~wR(Fc(=;PRl*;i$T`c@?=9RMU%!0m34sp!ZCOVj?4#9 zXkGqR*TIxU-#_LoDUQL*pUtpe4C@nmRwlXOYC|b@*x}A;I zw=;YOSvnYzX>m$J$h6V@XmH(Y+h}v0xd>Qs_cDbB$$p4PN+Hv)u0=>0&kU2m)9byi z@}{=QiW+P^+P^}Yf(gsY`1|Z;N9X=VCG=`9rA>~oru5?>sJ_{V48gX7i#X>rdxBF! z--N}K@n<}h$mu-8+pt<|Bq=H`ex#*Eu)VVruU<@(OAORcW+!?YQy@qIT|%TMw99Gp zEZ+~|@Rsv(p{C_TDl!gz3QHZdPISI4AcyBDn*P5p6gG*C%T_#+SjCW70M1GHlNcfyb3Y&f19 zP<7@gh&2s*%mtv#be{})jH?A7F0oL{cvIi+bTA^P$}((&pyht8tMjSSUWDdBE**%$ zqt#xG+pw#_pkI~=c#rN5myKRspVEM1oA@}E#ryM))!Qq}mn|$$KCC=px-JSPjHUld z-Wc(!>%%H&zw|ETMkba4GWCT+qbm4n)_rr7|c`FY+^wk_X1ZA*P;K(0tZ5{BYVD3_lK8jwNGDE_w6|K~CN5mk< zI9QIZB!=Hcp^i58AFNNO>O`H=!e-OWxDCK;REkpGj=c3Hk$F^2%IO z&E;>udp5Wg@!@cWq=mYZRE3lZxO+r(+^%c?tzN$(6tovf`}siOkcFkxu++b_WH9v4 zfdwHH@z%WdHMe%#3&qzwrur_dU;)4x$|%rkOCD zvbfJ)n zL2SXKU`k1OGcFj}u88{=C6CCKH+4JNKpk%86Iqbj_Iy)Iu#HhT>R!#eZ%o7+X8I>k zD{0G=;%U0v{=X8f{$FVn}HG*Gj3}k=^Dc+n*&lE39?jNS%m5C;c z=`#(CJYA8cdYV)D2NO;}#9GJ`I4SVu?)!vXUIpIa5Nhp_Y({_H*D5u}-&b*cLt#!D zpD!V;wL2WUbM<-MzQA^33#HIP2Q7#2`s#(5NZcbfvGBVz1U;q7xhu?d{;e3H3Va}8IY98?I8RLW{ ze#1?Sfk}(o!$M2oe$7EAWjQ3EPBB@a&7`{B{`Y0t=0Y$<>)wXnG8xmoT(RR03CZID zPgO(du`Ba@4`wrpUoMivd&h^MFuZG_E$C_ezzC^rDQHMWwdVIm$Q*Q1>0Ktvxn!Rz zH{&-6yP4@#yw?VYov5QeHlhI!NjgR z|FD_$eD%gGrg8=SaX@+aL8a65v#VgTwMCC!GcZvbP0HN4-n_H%HmySueFfQ_Z&8UE zzJY{uPLj8oI6693xi6DC&oxqVKBM0`7x>#NpE78DvClBSP{_%94TIB}|P6jFX0<(3brAefKKHy&Evz_=4 z#D2be?9WNx?%FGE36*G!BnyUJ4(K?+b0dZ@}6t2 zM{B*iKG+A!8x1n-{(gDT?c;^A>=K>z2)=mrja1F%<%@M$7M*x#9rxrD-lKA&#Td5U zn5CDCTcJNlJ7WE{)NYSzdOA3tW%Aj~>uJjiTyVAbbf`qrMuRR>iXOL@!YqZ9{4b(j zy0tNMfYy?SV;Vs%4Bzh?R~kL05jl`#$UBYyn_^z`KvO;eszJ}0&4hCoxc~tQ2(fB& zGYkP-o|isG(v8}9agYqaJf%USL8as&LcGHtKqa%bXnFm54xC@ybO$75ngl0&+Su3f zwOC(L-sjg1E6*pLQeBY3@}jr8wqIYJv5Ol+TV;2keuBX zvX23Uqmv`YN&f>yh0~dGApO+nlcFGLcsnT&8k}bE%^}yUSp}d@LJ$KAfxZ-gIzT<5 z7)UFlAq|CVkThFW2P+~JH~^VeCo>ZmFkG9e^|HaWPA=1Y==gkg|EG}xdZehlIBDKp zAVG<(XzgruG4rPX-EY}}(5t418UfjIFIfYMUp}8Jb|!^fJMNxA>KCtmOLF9&gG(?& zd7CfRf0lY!j?WaY`bToXX}{w2AHVe)$yYy>gj%%SgJ)g*-f&srI)63;83(*#L z+xGr@b>%f(1)P=sN2L9Yi$Mq4t$P&SA;i|bF;xG=Yr0QRf=C9xR*e5#fC6p%`n>Wp z(O|Ju`N?xj-|yOYAdD>LKZ2e^7mr`ed5xr0IZX@5osQ-NQS5lq9ff*=M=h(Jsc88x zjfmnJKys5R;aKOF5)LL4DA1&{mn6X~>AcJ_fJ05nH8*ccoXJUA1X zrl(7?nWKswT)n9GwjTTTLnQfo&VKvAtDo=sQ>8p;vjgmGQ@UUJaZbEx!mGTzraK&% zGV)lyl*VV0g?gn7VT5Q%SIoZf`r96gXFltPU8K3P(ed(~p1t-`kmBVlKcYXb@}zd` z0aF!WSlWtriKek(S1IUl@O-jL6xKNMGAA+~yfeJN^)9@OLA%S<>(aZ&q8=;kP09Ml z__SiiQ`TF=D2M@BreHn(d)r6JwQIq&qIP`e`}6$Msmh&v1T!6I1CuatIvVjSFVB71 ziOPDZ*Gb-G24C*}@=06F%f?s#S9R+D+s_7Ya{$$X3SuZ6FgMbecJPNPe*Vk8!>>4$ zd2{270|@ITVCZ3$o*nXVxh+1;o!iupEC-Q!)sJ-rbT#`0>-g!zKFENN4-uVb&IAo5 z0P*IFQES!vnIcGQHHNkt(kL-1LOr^J2lIKQ{5l0a<*{_XzYYvO&hX|gS@Uxy9XffM zmd@if+R3=Zks&f-FeOkoOIY6v+p#{c1sKW?x2EBDot zxMEd}dhaDfppfiSztsqT$m^Hzq+30fJQ$)LXF?)iVTA@Wy=0LA6Cpe~j`O@726sG& zK2@MJ8h_N1$N^*uCrzDWk)6t%?~?*~-wxH8^bK^10c;-pxqPr3;URa-d?ucv*@4?` zRA$!Oq(J7NtlyrJ(@S2vkyL)7KP?ygKbuZa!XX3REV=aubK!-_%25omSVjai9)DoS zpoJ5K0POn>$qp8iE5VShoB~BD(X#!=RtRRaisE&)YU%v@|B4S##5f3If-DH#g1m_@ z&TiUNc4t?>{ro|wXr;Uc)VKnjgT7+pwr@P@Szmc{IP7*GCht3rN^QSC3+vJl-0K8a zT28kJzLVU*@acC0Z;nda2Qnnp)6c zC1`h{jSM_Y?$=Z`%2vbRa@pN%pcU>cubCW_`7tJZL<>l3+9QO(E6=vH!x&|Ki%7rK z)<&;x1TV>{7VyA~^?tZtpE{%kfn6Ef#)t)%4bU%OJ`OG+sK@MKyVhuSesk%Ifb!tI ziw-ou@4=LlrqD&XUz^IUkUyFgSerAhu@lesU#S8UK6Xyr+OG`d(6`Doys>9hv3L1J z`l|GY85zBo_ZJ-uhgF;XPi>U_Z!bUjuaShc`_|n}{16qg8xl8eyY4p+Fi4z5ui+#J zJM^(>b0iAW<2OZqSCz2nXoSaplwcT9*5;!^S9Hxx~Gxo+Z^&W&?lvD$^=}r&( z>;o_MsUGRR=SX8W-xB;eatr$6O2@e`)nIfCfzXAZ0;j1|j9dd z^b(8f2IBpc2Zv?91zS$%?tLr_e^(rbmRTB|DMDHX?)P5Z9Z_~RhsGb#bwHB*TXa2K zOU~}{sG0v>h@cB?kT&T4yz$M7g0!=H8XHgP0A{w!@1}|S=$)K@@62S)AG1=b$fUzX zC?Cm@P*nc*MSO}Bi>$)F!elc;adY*v!keEfv1Y@Cb&-i%3Bu0Z&-JG#SklVjK0cVK z;!96h(4Tw6X9f2FHC*BeSw`T|j)MAqRyoFp2^>=s^Akejhtsb*vm72vr1}Qv3ht*7 zZod$@P)B{t()1JxLguC*Oo{BP*fG=ygF;SR@l-}6^Q_bPjFod4Xm)SU9>!K$+-Ej; z9j*0`H=^+Fx-bx)nmhnw*zJmK54h3ox3&C9cIc`bFHb5(YgFt~9D76i?K=8{k$HegFl6q1ngO(w34Qqh+B{zX zBB>uO6yRj1LQj-|?h%4XjNrJ!PIIHOzQ&NJPietD(oZfyl`(~A(Qp1t;C6)y%}unZ zFFa}!hm2IGD;BAD{ugCXk}cq*z8TZj+)N8rPlwfTdJPyPZM_o*zMhG?y zMhRzs!(gID2CAM&1&ohG7wwq;xh8R(wN*cq_ zsQ>3{Cb9MXS@l|M*zH>zzb1TYAvktK$c=gY0g2rUX;dR44pe|WS?U63Wvc8Y+l~&y zV@p+0c_Rqirwe3d+*H|*xZbV{h9&wq@bm|CzwrJ7H7$C`{Ap_P_F(h)&xzIXn#1Le z1$uOkSI%XIFN#tAnI_wfXPUG>;|70r^p9yRWk|qKmZ*x%@!{MT`;0%Qdlt-LdtAG; z!j#&#)7F1O0|jS1=P#NqONOpaDpDj`PY1!)oJ+#l_#-MO>{%bh@1%<0^^UR6c zSPHsY%*+d4FmYF~PccWTELv57ORVs@j>vVBg9@yVQ>U>9@z`-kHT1dg%A6-oUNu1I z{WE~)7_CW{o{|1%;%~%9#z4~Em@LZiRxv9}*)t_Ro|R9=tT9SF;jdX)h{*r=L#vcD zV_opK{pBQ1oCfQx){5d&#jJ51x3T_%hMzvwwaL7F9Ag4S@Br3ORa-zF%lV;cU_$`j zB&26~@#MY!Cq*JHMab-N#avk-8RKeL`905!EU;P$#ZitUIMwSV^6`3iKaEVfZ( z3Krt>;>2zSlOc|3a_;ou4K0amz~!ihIAoB+JYb!p|EEr8mopDjHuqpbO_2Sdy<$*o z9M({BGhRa&N_H{&X8$JI#%KP$_&T^;2Z28CHI-p#1+eZee0e%Jkemqr>#g1@*Ll%} zay|&0SRE5;@WA`-VR&b=6)ihyM967=PFytx&%;2B&C7tMv#Ev)3rzSB7m%(G5eglm zAIi8QWFb_m@6O|1!=sU_J2%gi8?SqS2r83d1yVgg`#JQZMDC%!RzH6e?R@BZV)+LT|(Jn+tI(+K+j9q8RuW zKcZr@dbZFz=IlFT5n)GnQeK6SupoELDYt)7`TX;OFPHCD^>VJGxMWqIAHOKGK=Xm~ zm+l+W8qL>RV;S#Gtpf-@WfFxRvgt}{dw%DIBFs5?jTF}3U!LTkNwElM-+%!S8Rp+f zzl6@AnC=f{xdpjZfZ!FfoG*9o0)O8RkQYA|P(C8z0c7sdu@~_{7%?~Hf3;xn{!t`c z#9%sB4p0Yuq$;_y4m=Qd*P+@!M?PK%P=IPN8@m0xOej20h1~L_xBXLAyRGR}StJtr ztbd#5@Zvr615oMd^8)ysC{skEOgXsM{ z8WoERFf^#JS3a}YzV|w|)vjEe8jExm_#7I9rw9mDAy$z%-NK(JanLu8vkwsfw;9;zOD?McP&c~T77j7>jN5`(R;MExJ? zg+6+2WL%XBiFtoL5X41jtV5909f3;*It`lCiaHycP9F&)zysIjQBi3T@@)m6QjIgj zh{Z>nx<>&K0=e$}xitk-`LXu~Hr1xh;A?8jX_D)De?LY8K{~0Vm5M)L4_97&bU!>3 zYPX$g=0`<}FYD(DzO_KF_UL?k1MrBdrVnf&i9Aj>cUGL>OKSo{hdNoK;Kz^dH&qs? zOeC2$(6=bXl_r!}!eBt?$uK*XD|x@Wn?BI~0Qrz!W+Ei#-G5g-V#QB;@irtX7q zPYv*@WbxwqKu1usMg>`8@V8;9nqmENch@g~WlxHU*_#2+`3HOuWT|jX3#T??@7)F3 za7bY9F>BPA`!!=6z}(c+6e29XtUr#=C|CQD5zrIzSkW&vYthAr-B{7Q5XaG=8tUp* zKr39`(|SxA=!*C*FLBe~Eruxv8dH1-zB2RQJIjA9wTST1c19*^y*%<9-#2H-&Y`&G z9W{EV6vJP|%+m4t52^6J%@4*;QuP~b;iuO-6?m^3-bDpjmH?OIIiZ5e`MzIyBDld3NYil`dFXmIb22Z$gEm?^NbP+P1f8K=GN&D_ zg?S^)g}Rn%U!Blj?vWITSadeJbZ}a`1C7?$MGL@wP>&I^>O1e4@hH?^C!5Xy&_USz zt&?m^IZw8>k9w6owFq(>&bYW(b87g}Gi#l(fElub7zn7XmH1j$M^nbH1y0`D{?>a5 zl2%b4|9g|jM1CxGtp^`e4tuxE4$B);(B8RoDC&72;?W?nynMj^z_rzyxOptH*KL1noS?Zy&~Ca%_mFb9=}U9>FX`U92Z;Wpi1tB*l1Yq%gC z_9u_7yDA5n$qwnf0mFNsy;rk~j1;00Q^YldsWE5)_m-Re5p3WRJX&Gx$C^R7Acbm~ zX4tIQ`1gk&Z>pS(Mi{O@>>ac9v=$GHWzblg`ST!GkPC8Pk6i7*i(;Lzsna44L?Y#;Sra zxQfz$6(|ps{wY{^%f@;J;t90bL9Q4QuBL+Ts8Mr$@kV$M_^N{W>;uNekrSAE?*SQ* z@HD=s>37p(?xvPQuLl1kf>gz@_}YX5xm$39oM(yuGkIq7`B|ui%R*~=i9y>1*pt3B zsVVRJK;RU#o}jZ2?<#({+Eaihyqyl>FV5JmFWU7DgN{M)Va1 zFJl1`owvd4cqB##)#{^`Q%s1E@2TqPjX9t>Ng#QFllOdVDdiA7k!QJ*EsHSW7 znD#96`Snyn-t(XEzHonhyyT&tOC0Oxda}e@kszOzTz8ASn1?q`vp`b?5C_Pipp!x> zU*i$RDD%@Kw<-pVcR<`fX2lh?C70=Y$7DU=6m&Y`f%uK+=U>`|bjr1w8lt+^?|+Zs zH`#GCr^c+6E$q0Ff&*CjK!7-+_58)fdg;1Cz5oO%B`z-6-)g_-B}SUmA&oBKDS1~U z;|+PuuPnq&(HW=zRRxATn-(O+;IBWfDce);RkHV`UHGGuGl0=D%AMos46Ph{G35yZ z9uYwpZ5!u$_B9yh71_LULZMk|dE8lM?YESW>yJbw2Z(gMP&k~HV8L1NaO{M+|0{bZ zQOnR!*uIxV;$8DUTPQXUMQT~d>7S3^_H$4tVEzE>b6>db^8CSl&=K3F)_064@evRZ zD={pKoARGGD{rzB!QKYj`RREr88f$)M$AUvf#C?{70c)_5w$m>-2YY{7~Q(rc$N9j zei?~iMfgSQSCejeN0-jdOhr(XgdxW5gErodl0hwdqS8J9msQ@1Tn*rZ%#B2Swzw@W zk~T)UP}OJInBe08=|rBcayPp#!kczc61~^mo)bYTRBm_@%%uxus@Himu_3NH0Z5)8jrM~kVnGP{)dkB<% z%~0*p!a=OwP{H13kpP9ya)in;z^&@Sd_xlC_X4j)Z+eiU{8YpUa8)Rsrose1A3Y_3 zH%UoD4G1ERa&BEr(RkAFXfkJ^6G4-(lLi~K1HiS9=6?);KnhW8*-V6z$ouO7btO-D zJ^yDL-Ed!ljHfKxlTS@!3ZDH5Aor3X^f^K4;W-_#MX2PhkO>70NNKkDLLDDW5D>|P@I%Qs#RFU`H4-fGZV4K(> zQ+?|z+jH)*65Ym^bJVHNNjlb(XTOD}VtI>2-OQod+1H7NsE=hTZC2hg-Bw=GS56?T zH@7^8#mRnUy8Mdl7`Q8H!Q&VhOrDQS;>{7-Kc1hjz6<f7N7kRIXI z)`~54Fy2^LLH)<`T6FXb1TRQQAvnbln;3KCc60~VEO%~=P7)j7??x}>o(sI@J+qfc zwhMBun=%=Mt6%5U4LCWFi0SFF_xO8*RSLovT3480!D=_=Hsdk5Rx`Z*#zt6a@N zKgm6B=6}$*8q)zni!I%)m4eAOd6fCvh09_06(iPblU;|~?RTjXA;ohUjfb+n!|6V$ z@?Kc!0<|l$i?`zDe_IF$hUa;Gmi>6uvOat`$zEQpeARVW6+6%WjnR7Oz3_%8y<$SE zofz)zrV3goiZblx|hJ8{})CEdIrUo5@Y|mHr_r0GrG@3YIJYaQjQV{ke1J-kbRK}bw zlJ_n?Pd7H6odqw5r1{{{5a2L-ZbeL))i@yp?u~uCVapF^D8py2T`-zmrWP>+s(IPE zrtgw%F4F3J9dGZN!=fW?E->euxDmlPpHtpAL5-E(iQhk*b!Pg@K4{U$k~54OF|t;D z48TD?RZc|$^=qf(2PJq^zpc+^`2XIIm8gExEh;H~Eca6c!N)VM#*OpZ#cmME3PRGV z1uG&wni`Z^!)2bGV@YZO?kps6f=Z(^b3E1z(vH-2rm_&9x7#`fCcMf@!9t&YSwqJf zwK5;Rg`rPjajtkgM=5~;DjR|<{FIZjaB_^9d!0z(QYiBhT`mX7d5slH$5O#(H2aP9 zrl!eiIwqDXBuGfFFz*Yd1%aiLQ4;camWwh{I(C#f+$tqE5*bG9rO32*K>A_MGx%Xl z&ZZ(bBNG&@wC;Tthwtn*pVgG(H+Muhcd{jzUvGyX#+|j8xL^0OAEvn2GHbEu{&#{B zvYx5ag&zJqcNxQSpKGk57WV$e0s!mHh8KOV$tD<9T^h7H z?;ZsG2@*sG55$0PtCF46IFL{QoViQnQ2RkChTZwdHJ!IF{nS<13YlMidAa%d@Tw=% zE-O6$anSEX$T)UsRh62}0;~{*-c1Fe>fh!hkHCp|_tlAgrS(Tay8YkQnAR=M|7P6} zmmpxHCVJ8~b~mk0&@(JKSGLGVVs}vz_pIj5#1tFxG5Z#aHsiOYSrXOmNS$F@8#Yr=jIKyvaYxlPS*A|&+V2rQ3Pv#%{oYUNuBs4`VjRy zi|Y8l;7>;tz?cIki9w&-MnpOub#n7=n~oGPI zshXGFB^CQ!U_XcT9|GALFg<8|sio@qcJRO4r%d^s8xn6+x);N>@u6s?D_R4H_eIC;S49_23;|U##5Yb4FV|6nsN4i z>cOY9j7FFvL%YCr%NMfXVT9h*I5B0pHloy87GrdHF=h304uau9&Eb@R`P@ClW&+Sx zZyWc0R&n_1l6DxVDaN&wMNZY*i8Yua|KxKXZCX3Ie{CX&=zXpRMbHEQ1{B=g`6T~{ z8op}y^JG0I>kcN-(&Fz!MHbPC!K-g*Sg0Z!-P<8mukpn(c)&V<;nT>#ChO`{3&ud2 z3VnvW&ZFla(Qw_>N?IH*|ETOE6nK#tH1!CA)ObK1Nv|U;|GA&#a*c zxb4-kY^BeR*Pdf&(6M;c>rSiSJzfiykUPsXw+e<}f;4BW>8KWEO@N3|;k72v8v(Ti z@zvMEwU(EauZk+@B3|>onKH#lUi8rT{?WE4_1~qxX^>uufIb#D3wU9^G56JhRs*66 z-&^dk*#0&z6Y3ZAhOu5Y|1rWu$8Q-!`Vlcxy)>t>qPCik$_}STF@I&}N+35Cx@Ibq_I1>I$mOVM@9~SCY z*c__-k3=9|1s*$&gh#Z)SuM};)pCMdLrkm+OG5r$m`sNau4sSy!F2HX$e6Us|HZAA zpjdX#_{ajUNSJ^TLDm~#HWYJKv64Va=Z~6-<#mjm_z^#%i6(2SPgsv6hv!W7T0sCx ztsj%XC;(5&Ya`M0kPuO#&zw*nPNqEvi<8-9y2+WEzm}nLGV3W|jnhobHWo zn7LQ;U@VO&7RCd@WJ6cqWxpN*Nj@^&2)y1$RuPDV3L+X2r3tPfxYx3R1G8!pt~?K= zpgF4z$-(>o$JSfMMHRJe-+P7`8X1rlaOjXO5s3lm5KxeY5s(f8q+!590qK$&Bt$^E zb3mjKq`RfNn|FKNPu|b_+n@Qsn!V3;u5%s7|8SCTf3^DQg4bhnPynaQWV?JdQz z%FxgGUtQo7mRGmy;8&FTjph^Kk*7-syC;1gwp)tQ-xRs;llU5gxxueqEOpeH6#GJ6=5a!66b(LNf=33Ezy0Okt}MY}kIS zmMHBdvecdU0ZduXQQ?r45E%H@8}mBIaYWYrk9t}|)%2X~e{3rIivQHd;vkv_q)Z4# z5?~$xqnXt`{pXK7<=UoiL2*N*k~Kk?IGmqZ+RH9(fDwv1uZw2`s){p!2)??bck#uy zX0_^Uh=994RRRDDYP7?l#(Fc;JW^pEmq)%#;S0rN`>#-^K+ zvabn(?cp52Nkr=hU=<(t1mo;1>9Ylskh^AmU0-FvensEPOF{e5K`K(wT|8@mqD(l$ zf*V`H%I|XJi{aF)f1a+yEV7PIt{_5B3q?lrs2hMPoR-JBp$xKgAXP_k6DJi8AgED% zoaDsRYO+FHh}dq~UAe9sqndqXm!jyjF}1L7`Rvz4Y=Mf30vg|MH&i%cYaTRtC+-2u z93w5g;zd+&jgAU+V3abdKeV0!>fI8m8|pdI4V+`3*oghl=`9}wT%SNO%dMft&YOVK z_~L8w)v@>otjuj5jk|uPGM3=s)bXtzul>-h+XUfOk^z6@=Xtq?YqH7X59dCwXP<9& zF$a#77K%Fe#CWc*DqLf{_Gw5UeK)T$FWcya-H2_pYI{x+RxX3AJM%tNptl>!1AAYv zrp9UJd6q32%fvp?Zo6GDQ%cyUZM8KC|K}8Zm?k?`8fG*_69R@o#f1>(+235FQxX085EZlVzVOZ$Qto4C zhr;gy=m2js&HBoYDjFuOGDku|^Yv6s)7&LEN`>E3)T*Q^`K7-HW_+vH+8y3*&)-lx zx{iL+hD}gfQGJOcS(Sh z<6pYW&ChQxn!J%~5cmt6*43QazT$i^!x z;8^gKSS>o6#D47YkeY27e%{wtf9>ura41Q3cnw>nGzb;BD#Gal`tW<-g3XS(5LT8U9HHwpsrfU%`_cAGCdC=t2ZsAAycw`x41@l0}=TO5w!EIHxE3-RG*5l7;sH zGXQI_@A~J~(^#nei_h_aRhG}MJ>auak0w6#esGhz0r7frq^;J__Iy;rKUbkB(fiky zvOF@OFUaQsVEE__D!EOtNHZ{L$L|3^xMC2kaOd1M5ol_>uH+#=LLwfY$~Ad8nvjS% z^V_*NG#_pH6uC^<*zd%DZsj;}78BG|rYOu;OwJDQio3lSsm`Tq^0ww zp_#3#n4R8^8esF27aU|g%tB4& zdjk=(x@{XJ{k|DM->T*CZth)6?$HWA=!M#Gf<)wIFlU;;qrJ8yvp{WK`%_E~wA+*_ zs420__+VS(q$gG+i+M}9GeGCpt>*mf{TfG%)=yPY4fkf_p^(sw;OKcsUn3#V8n!vg zO<+q?F(8sEuw|e8pu@AIt~29}Grg^DCrtL-Y+Cv!^_e`(=67;PZmxRdwMF+KTO=-D zz;QwF{oAN>#E|lVi0~S6`tBQnyO+r4u76lU>0Xqdyv#SPX4Pr!8+g9MQ0lfiS^a9X zJnxmL~k0TsRN~wQW$+BZR(avuo@qQ`5bP;7@K_9500g{_G`q*4S z&JEJ(R3&+7g1;5$a}VHpF0pR(Kt6imfC{KsGX6W|J7#11LmfvDRN-PVb!FeOM9J-6 zkxKrRSUe0LISWA}ze27%xu{^@R%yDomX%D=5Nw?w47k>R4C;`(Zv=}YMLU&2dge7W zU?BM>wu*%9D@7$Xr6JAllI9i5$!i_!(C8~P; zTWm0x9M#DUs6vY-kpgQ$pE}ibftD63omwcU;tGA!aHq%Mv8=cnU%#pL1=OvfYPSAh zE%#<)59a>tWW~v=rKGzCq;s^@gLvsx?>=dAJgmmSq(f^VmSuWs3qVBZq=#1Yi zjwNcS$EMuXtE$e66;&7nb`>ND-`!ud0-uB|_3+7=InO6bmz7o%5CWHiP(6a(`|b;^ zHqwsUD1b#sc6=5@YNFz{9?G>x+xy(ztPfo~9lqb}=)GQg$I~4wmMi zswgFqxf?7xh`RxVv!qKe{Km?yZ3z3d{5a(sx0hVjs+|*q7<<*P4rf`LK>f&K*yr== zI_n=9y6g0y+mU>9jPwUx>Gv-vwC`J#ndr4H1Gl=eh1n#v4g+>O7IO0G%~$Iqp6Xuu zR|;&0Tql+67&<6T=&l7#wG+Pc4}LpG$L7E{g$u=Pchj>t-gpX!yRm=L6T|O#s$|tp zv-cIhJ<2qF!@~MpIC8@vb0Ao$%ss;T@FtJ7@x-Def1)(QEdkXId1+|oyfEo)37Ce< zmp6fLKCr4S2W;I*Rc1}4iUrmWDR*H}JPp4~@Wd}eh=|ZAUt!{=gC|$oS-_xbZxrOo zmf#hUUw7m)(}~#?m3foK|C5C@6Po4{nsP$1;}2keJhdv>o0|aBFO-P^>s+*U-S~ZgjgocA4&|8e0HD$<;}me$=6a8fi?k4*n8hQ1>uwB;OvF zLbZYd*cRgT8lZkbqYA=~s7Cb*euP`kr709ui??zV^^00m?#}Y?2q=_ZUy+SvN7JN& zIaGHzAiSr?C07+kjp*d-EPnK@abuGg>2uPlQ$^JGj~G$1x8?IA9w-oExT7M22e)t9 z?Mt32P*htoQXA#SqCSKw#zqQ*sARe^{ymcDS+j~LEPFL9zTs}x94Hty>< zcgFZj4HS^$jT7L2y;(iMG6Qv~GAw{;A*MeHGBDqJuVh;WJ$SvPQvg6Q)8vo8{cdTG z%qV`fOfoOaEbtVj&W5U%4IBY2BSp>=`!Z{9`}DzbL}VS)<`E0zehQf=X3Rq%%7lkU3WBH2DP zNBj?bN|X}V&#{*gyBYj2UmmKi<+PXK0@QiA%-V!)I*e|+G-0dD1R$th;C38{SL6Qn z?IntI49xs_$@==0@a==+XGDO^v&YBInA$j^oq+LzUidZfRtgOU%tfpFy;1aixpEph zu7=zC83lN;Ks{abNQ>gqaB`Mfz&pvErbZ1YB2#0%(TV0qPY-l3oJr>M2WFFZt(y37 zbF3*O(id`|qpifI+F}`83d2RVFMCc}%KNX>^ls+MJz!VHYpIjER$-0m;gatCTP2`D zS&|^;N4zAM{P|O-1ACOdYW7W4&M~MEj}3^o)_tvoqt^bz{f0eO6jt10%NJ}M(F57;maoT0x*}M93I|+S+G|vKY=?lM zI(~ln&jaEmpu|_UmhBNuqgA_!=G9jo;tJmJe5O^J#&1f7>$0-4^0byzznjcAcUMvB$0d=qe#${|fAyqaPfVnZ^T{-f3Xgq8#fh{7L@h>>iJn zu%sxeFH)IA=eVe5`RrT4<&w4u6x$kLEa>9c1}{qd43UNC@HQSz?0tVbV}0^t+}PpB zsH@QGPj?~2R{2OUVB*N19UxpgtsF}$=xrpiTvW#VAO>RhXkV_a6&Akmp@cdKx4VPQ zxbZc07z8a4?j-ObfAHG2Z@mALW*6LPs)C@`dFl-`EYDJRvE&}rD=)=YUb_%g5Xlk7 z^2GYRS?PwfmM$u1l<_o&M>~SlPtU*1Dv)76648#u@a-YJgzVZ8pjXXuaBzq%F&Ff_ zp;!|mXfVZwkJhig%Lf07L{MTjHChI4NnUlfC z{?k==>YeN)@--PH4QxZpD<-j)cylxOf(=*2v&;sL7yE6CTYL_6Ox>TdfnUFkDx(A4 z!|V(guwP@2IPiY4K2#^djhyMzV){=+^OQeq>Snag&o*85hmUW%PG_8}RqZ9QLX(f# zR6a-LGpnVi+^;+`B0qWwVURlD zP{TEnJ(DsEOcX7=84Hoo{LD-y1DRg2Z;fjCc=H>^S*Fr;hOy$pCqoeBStkT_{X=NV z48<0-B0sRQ7GFJv7CH2kuDRUK$=F`%CPVU|!M%e!jvBJ>EQ$L`s}vu0Qso~^R8T0I8|ZwMFKvcx z&(Cjr!h>UGzzcn8l*s9$fwY@Y%CnOb)*e3$c@DL}qBF_EVk^|#G zt?&0e=T~iCthuqV7ALfbKDhnH=^^2JyM|yx;h;>+86n*(k&V}pXTEU+i(zW%?76Wl zMKGW@j{PlYW`02j;mzgUt@umw{4j??HG5#514Qev0F=Q>H^2VIvLMw6atR5EvlvlD z4b1Vxh+YDe9>hfhWkG=u>*a!AC%(&})H*P(a65IPZmHH;?L5*lj<;M0VJ1Q8c4u7W z@WO=W*m{8@e90YsrF{92lCw`?NAlGZ^9l#L>XWR$kiUdLO6j+UsawY|OsY9-p;Zf) zsW$L@t{INk{6%2ADlf?+{du`R7352$R)P@G8v^oQj)j{(KTG!X^@p$w@0@u(wkA1S z@P;1G^*6HNqDsIix7FI97z?p#Q9y_sDaE z&t>q{mf)d*Xo*(_O%V(OpQTYTf@7BUwARHV|C$Kh)&uNr$->Pl!m!1AqEP|+sk*>o z?Z}_Z==bJRb|JXfRJBDWtaAAHxmE>`kxjZZonRLE8dsdto1(G9WL!!u04e3%b zOzvA%3ZrV-gXh?fXwpB5(0v=t=6s{*2-QETMGB;ln^CI<9daM>Z7zO5uxHRBMb!G~ zyWk>r%ql4hRVepOJu9B@8X`)-6lF0E`Aj@GHy&76`|YT(Qd3R`*@QYFra~;wZDN>v25+M zk0AEtBNr|Z@BFOn{oRYbwML4Z{Cp}(yY`55XlBBpoZs2vEh%(Rhp<)a0w|Lw@$z2j zb4<@aA*+YV+#g-}vmoR!XeS(zb#F2K!TI?h+t>RUPw~GnQ?tWHAMFhp9CsYMR2}%i z-jmf5gJxH}j+!?F7Qet&jLd54`00&fx_^b7i=wKV7;~ir4O&AENpd}n#@5+YLxHXmrH*QF@}@C&ssxh= zW^o7LSE#HPR<#G?_YH<&28<`PvCEbFH|GN}k%|7ikkGfLrDqM_(|E}}8U4&ufzp(G z$BC=I<)p~AILv9#V5i8w_WNWx@K$4JHlD?kgb)c76(I3-%UA5~4#hUMN}#uP!3ORfK(K0X`FNrd-E7-N=F%<{cvF zgmYp6q^BX*K?VIp5svZS7fS$ViM*NXB#B6qn0IFOBLevq>Whxb%b`++M0T@ ze8+P?rEhPnudn|Z9OQn6?9Mj-{f_^k8kCe?M6=Z!JftT}xj(|;$Lf3WmhQQDBy`;y zzk6J~>6eB~GqdeqjWLa%`>CRO-WtsnKKD2N7+YPvT7n<6|LzCxojZBp+?Pi<&dI@n zlC@*HG>8c;u>Sn~u<2B$aO6HuFEKcYZU)`e9k!8~vkiWNa~op=muHO4P47T3#LiOa zKf05U>cvwc3^KKZdVyH$!hAJFo3PUAi$y)t&7^&b(G$`Yqx29uXn%PP6^dS&w2l3((Rt!@|tHxh(M~_{AYt-;xw;ufHOE z^TL-BMXvU-@iiYhTaGo0V2uJ-T*`;rhs|D%9%=iRwEumTeQCk`%I+d}U2+uB#pp^6n=5{>Pe(qe-rDmgq!{W=A8f0X9zrP(t1#X5V1! z>AClR#=+_O)vS+ORZw1#2MT|Z0O3Nowc-+))~O|``@`aaP?4`Z&*(7~e%3rq_y$kp z!mWK-t4q6Ff}r)q68JrXD8%Nki!0M(W*R0|{v9=;T!debINM+^bETZuCl26xyif*^ zs$tWiZ+;SefRFwL{HD$eEq*(hw7>qh$NvfYUd+ydZL;DL*g$h+wC#m!JUtjm3f?Q# z4zgV7d#a#ZEQT^Jl)}%iA9JnJ>Se=i({g-p|BZk>Vsbk>_^7c)_Gch6YB2Zv_nXv2 z6Ui@cMDophR6CE25BN1uk5p!c*Q-RpZ0;fIX;hovDR!E?c=C3cQ5hTXpW!@SJ^>J& zdif28wSwXViFK^%e$Rf!sh!N4Bhh1do0Nb9xoghBrNq()DG`NYXb6h<)+G8JbH zq|6araZD5{22tXvL)*Q(uWl)z2r;G)nLimqpQjf?$jFw99Hv13l^e7}5jqqbtIBm# z6`}gr8JQTMD)DYDPuFi^8U3c*DXZy*riYywk|uU~n0MCL*Hl;;`TaJ3zdF9s-5FK59|Q?oL7@A$*jE;YsHuNj1k`|#=!A*(J42ff#(L}P9? ztVTYjW@bNNHm2M6=3d&f;m!>ul5TR`3Y-NmojLNRXJ{#@^@Mb_6f8*M%BoYPF8waz z9ep3oarX-S_(~`haK&hBEKpdp@bVX{w97x`9XK+He+SU5Ab=H6E0(M8=!dj&T;=CH zE=a)}B*PsE=25GgwAQ48_RGgj5H_W2}lnE~qIRhwKA#6F0CFGp0 zX^N`{XU~}+>>-8U{X6;{gC;pBgHOdvZ?glT=NR{cAPqpE zT-Gl4jjZq!Y&p}&N1W}bzdPJv5KR8rfY-J=I&dpOP#GLQ>hwtX7E3g|v9wY!-rgj>OaS%{{SWb3)uu}z32kF6oEnSpu;OwK(*m9A%M4n z*Ae-f5TLzvitQltYI7g`vg#(U`zn0jm0kGqTA*A^hNA zIBvp|H27&+v-CZmiHs>a!dwwl=_PTd){*)AGiiLWO#vg%t)xefb?@xB+no0_v>b#7 zypplmD3u;bR<#wahKeN8lvRjlRG&hBr^L(X`H40GW`l{NtkfX{OY0dOz zmk0gcH24#D%Bt2Mxu%l#Yb`xBN~*mtDe;H;?kpoXz(2O<{_{A2Yt9V0XPtDRx9(C> z>x-)zWJaoIBfFPYV zbk~Rsv61=##lC`KeIVrO$FRb9(n{TAtk4JX#FQ%nQj*t+veA1Q4BPhD(Dl~b< zVkF2yAJA#-WD&Z*?F9rUrgkuZ|5X`8ti)gGt-}^qzdy}l2tnZRWeAwHw^uHM1i5;4 zL|4CV;!X$`OX#ilmCDM+K|wGPe^1zG>9M%$K@FkOCZr&&&z3iwQ^ty}{ z1DC+byP&G80Xyv$3JITwIxd|z622dk?zEhm8?Xwax!D3eG83~(r8B?hx_>XGKr~l!_d{lLAv`DIi92qmslek+mwRfS6 zpQO%{u)0paxz^q*<=MHBL6J%MLa*hfYFkF|P3ZxFa8xvBfZKr}72K5TEfNWYC++g4 zTgpHP`;|s;Qa!#8pPj;L+w&4F#DbhEFizt_yp*~;NH4Q^pVKcLT;71_8IS9Eovge#rN z!0Xm{MGy)K8#3+dyvX86e<<*60ponbNM!e!Nm_DA9HqM0$$kNoFnTG&mxj*^p)-^- z-%3dWVQnU5(E|>erer6T{>yOwKi41Bx|zsASs(}`@Z_+aw4*12cuN2x{@2Ffo&sie zMAYXd)nfbANAe!Z*m&pcaioCoW@(}qTh~$R95=9N(1y^-JU{ZQSvp_D^jw&z)mwLa zF|y4WoHlk$CT$de;4EgG3y__SLNz3Zi=Tm7P6a4roQj_Xay)TJI%RNtXH~;mi+$2yz6R7F=sK`5)tr# z$xx)3Cs5LBPpo0*SD{&?8Bp^{>BRrPul_$#5-+&^ePmEnbR6+R=-x%t8D8a4bw+nU z^~i+UkExnhTtBcUo} z-DbS6zdvZe_V;Y@x8a5M-O@)i=%&WxJGF17rM}ZC$9AVK%&NfCoFyRbwO6MlV_zDZ!VD3%{-O4CrcIgHN`UN6n z0ob*<0RTB?2g;bRiK?k17;Rv54=_tJ`1KfnMcF}&rar@r@m< z|0Y(X7{o=%4c>MBlJ_72Di1(dVn}rO7kuATpxLrs4HDB}-qlVf7<7~Dh3aa$jSbtj88+B1CK=u0Q%@;xu=ic$S|s^c>Npej@&=urN<- zMu$G?Mp-FJjS2!sQ*-+^!lrK^q&=muMZS%LgtBr9Lnji#7OEeR{9Y8djO;|0Z;v> zGz-8^`|!CIM_^s9%{z7OVEuGk8dXsR8a%{??LNHBrnLR95vvYLQ>s$w#Z>u%kWZ`i z;2~Jjd0Zpy)2=zToCXC@ZRy;Vq-k;WhrMPbz}XUZ_0< zn>O($LVJ}|6wk`P4U=R4>suy5^jICJA_gkU(W_Hhgh08=!RI@~z=STw|Hg0SVK&sV z)5x)U;-A+t3u;e^fJUb5>!(kNa5CWg?T-E_{=bXP|JnuIC_xpwj-kCY@Z>wG(RkzP zdX3%EsDS^ElG@K5SPgumq|#fxRhY*<$cb%$V;w7aIL$dw_~A%C;t<%eFrf7l7$!5v zRM1z3jyn}B|7AF(?$kk73)u&V_SiCi zNVZ36oM@511vNs1U;4Dco3%Y>;r+^7gPpV;6<(YqH+)p&GwNuD(m-i3Wuta#x|*=T zbmm;=Bn!fq6k(VXS6>nK&hkIs;X6$?5Uf{j$O~-0QH=+Ro&hhsA$@M)iyw7` zitsRQ#Ltb-jDmie^V=VC(|bNPa1Ao&R*bZ|3p=B1Z35@_6M*Pg^c}t@rBp&Ixf_aFEhB zvTcI69b#lbm_bsM9r*b!s_4jK`Frh3@rzSJ;PI!YbTB4k_8?H-GM6g&0ftkWT>q){ zPh#|s>i>TnfC_ zhV!9+SZl0Hx~>TZx#p`^2MX`(A33dEm4}Ysu64g0G0?QJSUd0=kc030T8Os8mYxtb zZ8VOoX-8e?K@wg9x+Hyz;pO|v^MbsqQDEfNt0Z4p67|heqv0#t zNPEZFihwrl^0b-l0WN zp$QpyGO)PVy9Pba&4NGvYY6>+{T{Rw|H=$q4l(9i+DnDiq`XsPyQL!=EqT?NM)bXJ z_iId8Hn)dkO<$2|Fu-sTc%j^;$GC`91d`L|lR25<0E0(>%ylyK_XayrsH605p2@na zH=H%COI_LAbPZ0(J8Ik0pig8-Uv~!YlZ<#P|J!4D`F9vSQoeEH4cZtZ8Vjuv1SiA^KkY3xa}3 z+W@<@aVBl}w2>oLS z6@HB;1Rk&V1(DNq(3;K5(7Tu)rOUp1(m=9Akch}F0&_2u_}lu;Y`Hi5v$rg4-^K2# zuvLg5S01OI)FZRlF9f%F@YIRVoVgmKl0F{-h>}hODRZ^uRh$cn&29|&0T%+=5hD?^ zF|udH^#D4{>astV{r_ybPhnzUZ-acwtpZd%ZsEd%ieGS;ff-VkPQw+^-nONV3O+W^ zo!`&ksifxOsxl_d++6d&xL1Y@pVhYQo8(p}`q?kEWhfE0YJkbj^nwh}3vvOgHLhI0 z;X}u;M!L(ym%#9pBbkgEa4BQijrZ%Kmxs2mCg1^npJE>Z7~`V`jLIs?u*H9+Z!k)I z`fy#r%umltfKd?GhM0d2Net+@I!_GfoLL|ezZ&RnY#oAXg~hH$p??qABysQ#pY%zt zhLk+`J!G!^V8`dm_J6*Z>DjpR9o@z3`z+*lDB-Am(D|X1 zaf};b#K;eZpZcXaA(_gPEP&={%1Tm|m-#KQH}|LiH&Fe*ipxJj`@e$CoP(bV0?h=l z5}Surx$wci0*KOPiD(z9rJ67CPj0wmp?Uforp6jUO4RJ8pgIa>@pxyO`vxDPv{t9S z(WY?dsZI)%UDQ<-5=)ifBzrxiSziSibpe*>Xby6zrV&8@Q(OcrKfW>xDzPuv8gLKZ z4ez-M>5>h*erY6p5qpI!Xo*5hyg9qLm(fGcnd%k`- z1epdlLB|bi>YbU+>Wb$Y!s8EJr28}l4&~GOgs$zy*{^{%- z4++;S;()z0@)cFPZ@)?B+(^XY;|mW8bHEO^u;GV7f96y1NHLKd=>7Vl8%y0V-dy|0 z#xC@@<1yH#+&aucF#pFV@PAg2*`9w!J0ZdpMhlVg*A_H=w+5t4)_e8Cv@tTD#ScoSh!soB^dbZf z&n62yLQ6TPys=SbsCcOR;#t!DiUlWs_LuV)PUDn9 zpHxHLPl+#-;Xs^RMRf(BSyfT)58k|izgt3rFzePAkQAOE+uWV5{qPi>+AI+t`kXm?= zmQH{gsZ4$z_J6MS|JXm7l>do4#E4I%I043PJCs#*aGV~DuIQ$zXwhfewQqLqTgD=1 z&j^847Qt=$=Ua0k1`zby?bo(9Ajp1SyunNxi(tHgVeMutrc3JJ6=BKegt^4u0Q4%1 z3%K)(!$;#QD|pW&e0g&ogru}`p-2yjjaY7so(aZxe2wU0iFQ_UbS%9{mh`(3Ug z|3dSz0rIu}k*4zcF6*P; zSo-te7(7ELj`C(ns_qNYoQDfb9Iu4W)*@BE4S*&Mg-5~i>1B-8tI!UBUmwASx@tSN z%qGEL-1Hd?i}(V3tOGlb-S0ncbM2%lOI9q**qRYO-ya10iBKpWoqjTuP0U#9LR@~w zSE3k`8*EH;d=TuMc*uGZvOBKhBEFFe1X2!LM}woq#xvJ{H6-!>r-oDo6TStN^F)B$ zEn@}`T7=h5n{f1FrzMx|3xQGdrX2A6Zn9NyJGQ&DflFta1-LlmLq3z3v3XW5F>#*s zydgo{bB2c;{UQ+Xci*Z+nu}anu6>)r%dD8Oxk$WCli0`tQjai6ELdhK#VexhBf`=R|bwHrbO$B7VjDrGk z#9KYWx6{J)be6eIqphu@bhJ+5UAz;=*K#u++ua)u9PB2|j#WRk4aZZeUo9uq_9ySB zZ2MN^VZz6hbejo-jgiZ}T!QNGsbT{t_AVSlclBKb_?XdLL_kX0L!>xlx#jctaR?jm zdX~2S{-6@>zdaa==P(oosB%rWfTCH#G0E7mMC1v%L@SKmbK-P}Epkt?{fkr~_+xy+m4 zBY{kA_M#tzJ^Eu(eo6K;_v+x+OEY$2p`hQ#&X5dRO+h7kYs+>JFH;rEfV-JOF#ok} z#j6S?qVP#I_RVUjPR^rQ|HE=>$+f=IgdzfcKs+Z0%YvMMM!|I87@K9SS~(Hgzzx~J zI*d~WtVlUfYU+ z=c{OJ!b|&1k2pINO9fe?7gmR8^o6iIS1xZZ@~Bz4TblX?hPeabCJsCQ^WPR5r-c5u z-WZssXwxx&-3Woy*-P?k*-o5ak)1=omY^IslX!dB+EY&;A^0LBv^e>Np^7S`dJuCf z1LY-;`8GU1ac}=wN_cGOx$-+$-uye)l1X)D!;VJREn0RU$jIl#3GK)GM8F}w&R2z| z23ve?T*7RcMNCIDW8eO$6nLf_+Y5Kb7u5r-MO4{qBGuW8v$Eji{NBWOMx5l0MXuFl zW1uk-0yLzw5r8FBRS=V-{iW|k+MaiiCZwHLE#A;j4kG7kKFL4!)N%b(SxUo(^=*FH zy|)IuPhE3<6OH<`GE3SuNe_+;sqvur7#@V0L0I)5hNq3Ww5l*e5ex@2J3;<|Y|d*T zX0Ml_yfDJ;Rgc*R8+M8&tS{MiDe~8k?~}kZ3Eq+RQR}T}rCTjNa~gQkSF7qQ$@5CA z&}b&`(ea(pGi_4x%_oY6)RchICsJ=fOnl+iw<<~uWux?@h5+=xKbM~|Yyj07p{lv( zwv72KY|!vk6?oCBGo63>lx-^hDIW{?rsJcDu;pl;*$C#CG!yAecBtseaJhvBq)Msf zo%zhZac$!MxbZ5QzoNPfwe5X7q3fi=%mS2)Wc#txhnCH7Gq21FVhxP}-r412hg;Yv z@EbtV2W$$V1Kl{2BvL3Lf30DBWf zUFDoA)!~pZqt{A9g4he`+`K$c*g&Jg&aiJx$Gbuv!h;Uxhkns&hQ6U&j0U=;JAK|7|)mefSj7Beu65nKOv zol6q84t$h93WA!FZ_igbV>Ybmi3JYMJ!&TY-}f9lLMREvVXbw6NgoeL!v5r_bGRpz zHkzVRWEE{q@@(nqKD@j?C?QnXc(nH=?N&yskGDU(M4yimmgH2qc4F83S`9TNPeS#$Gwt! z)<#cIV*yeht#dU9Fe=S$kNo+FVN(A9-77r;n+=UHv zAC*$0--;hb9gg@}whAJtJ_RSHC7k^!>!VwA)PJikYxgjrc%1yJ8Pr@@r<*P5=|?=p zChQMVQRUTiBe&EKB5!55!hfcp|Nd1J337#Vm8BmA8osN*v)=SqG=}yU>D-4A7SXmg zSDkBUH=-&mT1Z-DX52&x)lahOk+C<1pUJtR{DIMPUVuY6|1~ZO@>O|f5HP3E)DDN0 zmX#{A;7}@~9XO{r!^m^a9E)1T0(<&7iaS1d36y{>bmc0hny~7>JsUbO zMo8Zs2qM@qaK;=FcM$4_$Q<_|PzO{at78vHB=3$rf=rvw1vGUWGAb52P;rA2h}jv5kV2l$>>ium4>03i@Nf+F4|d!PY5`f^~?q8?U~n4d#jwrN56-R*8o zZS9}&8tQBR*Kl)BFaG}3#98w3)M zC}Eh$t~>UTv|m|OL16k6o9Lb@rzkT7k(^{b_mf4@>V=+x(r)UT=2 z(Syd#siyy4SGG^UR~$1ALjxc)_OhTh-XI##QwDnJ(!_` zb~daL%s$&T+7^_vWzW+-4G>6?=hRvah(`cSOiZ3$Lk!Y&C(qno+eT<*MF=mz(fdd0^5SpSAQABrABMY- zA=V;cZk^n>q+{Q&It%{Ae+oHIo=A;uUBF&0VPAMM&By8ow~bL_-eaW56Saf0NQTK# zwNOsG$JmjHD4!`B#a1abKIkZ--%?8GpA_uW(!_uG{ZjnfQD3a zq@27$r+lHaNBDtZd44QtC^06eS5LkJQZJyntYP)u{Bj@&$scy}C!* zwjP3P4&!+09)OA54?uuV(CA|$C8c+Q@O5dT{~2O&V0zbRzh)T!WWk|U7QSyeYvZ^D z=GCS$;5;D-ae=Ttb>=>yh_MUCm)&b2yB5T&Zp0t0)Hg!SbU`4G6d4K6aba*PDq0#66Vn+jBvicME@CrWr}Osa zo;l$dW7ZWru&0{kNoHU83%z?FS>ddu8eu^ZKD&dr{B&t>H|P8J2Ylu%voo*Zh*t`4 zDOgxg3kodadhZ3>jbeykf4}rB)8rqLQ?e=$tG40cO)9~4CLcw}-Lk;2Dkw>x;feU$ zH__U$t(fJ-#3b;iQA8=s`9x$Am64Ig^ulCl2QyG*)CzG_Rm9%6dGz_83z$@hEo2V- z_JH9&UsQOs)%>?&Qn$c1O9;Gcae&f+Ek{+u$TLgpV;XX=?7(;6)|l5_v*tKe`5~`` zu;(GJ>Zq%ic<@cfJh!|4(0P0C2so-)37@O3J0s8e zm|k&mGeS@uc)YN!ws2ai{#Om1v)(v-RhliExJCss4 z9k6r%=HjcS*RdsQZe#bvvT!;w z-J`R3BX?j_5(-kcRGj%Q-5tsXZfLz;ZCsF`g@8{WQz)j4NBdi(=Ie~SJd%`@iN4|C zpy6Q}HxG}^*_FSqs@Q-<^B7ium>x*#BWGU=DY=G4icLQ@T)0cJv2Jq<#ctysxc@iQ zo}uaPb?M<{>!pQ!B&0Sflf0}u`ko=AHqh|s66Gk9ep3;SW|$z+#U^@!P;wt2(azB1 z!ss1vOu`MckVbgkf1??1etNZl{;9dh(s{2u=&rJK!BWDUMa5Mt6o zeQ$6g*q9t}A2=?Zl7|B?jW~$E#*CL<$%f{{wk0`97OH8`%uel%m8{r>SMEp+GD(wIuqAuRB!=LUu^jeoV7#x%)8*vIM<p@% z_6_S-6pO3m=nK40Dc5h}O?Vf6AB%Ts;WWiDr{>)!Wqg>!3xzfWpXdYT8!XDW&GO^N ztnASl29O?NkcLq=+k3!ISYdatlLu*Q%CGb5b=At5_3z%1Vtg?x9m*sF9U20Ud^s^M zVPD|tM`?@jrrH?f6RMH`5s8g0yhx(EXL?}@gK(l%EE9qj#MR3dr+`%OESu}^Jj-V}E% z%)<5J6jfD^NNlbzC24ttNCAOgJ9hvHYmEIjm63oB<|Ng<+mPR4YtepXT=fXXQ^tPS zRci>2mt{o@QRmeFv2t}kkn+(0S}gOI$x927M=7Fs=iSpcf3B_T80iP9^1WA$Nd<&i zgfnH3Y9s?JDD#D0OBXtY-<}LB<;v_`+ldh3x%3f}lDK#K$G|3rC?yK<{#MzT@wo*WKJrv#!RnSktBgZ9q?tv@PH0`i;!-#eBh0 zWmC@xSbJxgs337L>D`vq(Hi%mhX$|+?lc)6Suog)tz-i9DbY++IQtPyk$HUo_hJ8K zfW$F_^bd07P`a?VIL^AAX3>?kwaLF!s-FSy96&zlF|s-Bz0bP?XQv0)MNZLvjW+Sl z*Pel3T!#6@;Kp&_#&?*ou@e4juixy(>)||kF#Ic(u)G{_pO`zVGa=7pRV2;rKhih&Wa2W z-H?G@ctV77$}4^1q2x;g!PhtWUm7^td8*&L^M%%=rI9hwh4s{nca@qb{mP+)E~1JG z;lK!2g8n{>3WnkaMsOzW6@KGv*?9VD-Rd`^4-5ojr>fehB-UkTyf{$(@5z^$`#SaR z3+OPodBEe9Ea$37_vNED#HdRvZwR}x028lKyrczus9hjEPxrnf=+NLUCqlNt?TDz2 zG`6E+Wdz!dkiNf&xWm(HW@sQisQx+(b!yuE>}b+>BInLF)h0XkC;1s=m^wY++{GIo zd89@j6qI3D@y`EZeraj(-yQpzj8M-1T3Sh<0!=tBz#TkrFW;#%*>+`P1N)4{aA>OK zL3Vi6j7NLGS(8>DRoc><>^WMcaSdHw8-am5|fLtMDf@FMT z3sm7V*)h(CN>m5c72vm zxTgPnpWg32jg-hmbk*M~vmcEmFSz^6+y3UJ0>61Uu@qhhdXcp>I5(mXNf@`j)@Mns z$T)5%&T2dH2T;5KcypAFZ3hOFkt40}SbgRkO>ers*J`BibW@mF5LY<$G_0f9tpIQU z;~HNt?Wk`Ey1oglN$TpmJf%a)U%T=w-c%=%d(+XWNhimgJm^%`TE;yr+P{m* zW3^eA5+Q7!n8*ZAKHVSCk+N2_!IVy`{?|YG-h}1lXXAP<546{N`@S~g|MN)EZ7YS6 zNz}PN&nk5FgNScWT~o$?E$oIqY{f(&Xsy7UwkmKn;C^VEq*p2cE`A-)gyka#j6`3^ z>bw481Xy1!XEM`6v49~aUei}0w@v;P&)9&>G6TaN7WJ5$GPBpTT9ET$~} z9yYLU;cmyo3c7dsV8QC%Cg}J!0W}^GbHZJ7l66JSo22dvKQ43NqEE}8+W0{1+)WeX zLTK(tPayt!RrDo{i&e9gxw2(O&@sy!B7KtW53`dP>b?BY<}uNgPpVR+aMxwfCtj;$ z&$a9LFp6G>{3YcP=QG8#L-uDK?hq8Z8jlR5N88c4Tqd_>M%?Opfl zv=pE(0JsJ%#D;{D<8JUgHo0U^d?`c!qTSy&e(d2xpAK=iOgID^{&_ZPwqv0OO@Cdl z;Xj;U<1s_G;V4RvPm{8|+%O^H?4Uyphx%q}W)|I%#FqGZw!)`MX+b;S@+0u_h$T_% za5Pm^K&V)`g}=;QE(i_HUSY*ip!gw=Mx|S2MB(aQ(eL?@5D!>Qrc3xB0?;U;xTLc{&j-u0c|dw;qY-WIH) zMQFjzsa8_-IV}1(A30a&b$zKKab5d4J=ueUtMz$f<6o!_J&OMy7l0I5FP(_5kD8KA z6Q9>}YM&XFfBc)`G)|iZA{UNhGmiI#7eygNSwrcf^95u2z0s&x>w`t znNXS69y#P+9N#1B`s}v5-A07cW;xt}io+zDrR~OVHm#Qjbn1_Zq@Cx+s$b zws7%F4e|cG^U%R%yVDQrf?@G0ZJGTs*Hdj+9&zjZ-kn@{Ps&~W3jkcxE+KP1D%vFa z-&vP6kFug2lt^|WxCw8~kg-wE_-%W@$jfql8vRc^ zpPMp&zH}YNu81hPU~>R6{?d7bBRme>I)@ctB4~X^9p{glg=}=;We$uuk#i3mTQ}bG z=2Hh+tdJL%_P}aPfW%w!OeqQ4tl4XWBM0lP)LXap6^mbpjE)N}OBAU4P%z%~l+@5Q z&nUO=Z9rr{96_L1{T0oks5ZB)S`ol25>)o4Q!Ut66=0TX$KCv!)J^g(q2Be$S*17& zz2#y9Ju}v)9XW&%JY^dVkEwr^rQB^Z0Zq$|7+Fd>xb?6~QQ|^nlNj34c#)rl;*iJp zChmg>l8(enUD1pzqYzI|$&oVqG$fp;psXAe`u;jnk=S1LI9-e)YL?FmHC<%mG3Wq~ zhIUVo577&=lu2M2$)JNb#1Vb3zwLBh<<nt zDaGLHLyNgns+B)S*46^wb2MdaL`*YRjxQiK225+(Kqy)hbY8<3dQqax-W`2&O0Z82 zZK-hc{O*6Tx&6ON0IZtRD7?mY_*tdTSQTB9Nh+*uH zket`pBksV#zc#IpS?)!Jtr!;9tK9ovUJ02EdpCxc#J@qnt2m%2+KZzg*PTUp=`bpTfkoi=0uevx9m+RuUvVeC7;9|%|L1t7^&4)+wLw;~=yyAmkpNbe?g z-t{E*nBOeNfU5=X;FM|H)k}t!fLBJB7n~A!LFRSnW(fSc`F@zv<1}0U{pY9)QK_gX zwUIJkWeJG4qI>j7J_u-0qHx|-yqBKIdM`C>Q~UTj(^x_V(R8*|!q`thOzH)L{3(_5 z$Zx+Kcz}X#3U6HA1ZT-=5gyL@My;^tNblwiGgn_`*hdyenZl3ZNe}iIFU1m!ld@^0=i=AG%4Dg3y-%Vscj0QnuImpN4!a^VJk9F0A^% zaU>S(EoKdLYs#1cnU^7^w^bNGAHx*T`Fip$?BCZ+6hpUj&kL&0UgIyH1bcvGNV_&U zPqbS>7~}dVM%jJ7Df+)33=W3rf-2!p=(M0oZ18yFeC3Ue)l3bH5Fjii;oP=g`PLWFja{J)Flp`876}T4Z`#VW#nAl+wUMPxE(*b^%0Vj zC)R}>!4-XRaw&mAb7V}=ABSGJXuj;uU}O1EW*Q8D*k~2mBF!K!R$0=A#QJHp7@$zV z2c#JmWpuyK_$Qwb9Q5YP7al{0dr58%cjAnc1-s0h99v1w&V&!hMtb*#E$*9Zn+w{| zI#N=VY^Ld$TCj9L0$d$`oeUNG7V8BWzYHXK8Y{_=*6{}+^GP@X6#@;LKa$1G*5Whv zhkE$9vtg)UK2yI-K~imJSl9EQ)W@&5L61EpNWqWj?QxSQ_lnIgMJwm%YGR!u;^M40 z+5ez0dr$i1G_Lg(q>YM|15}o*ckX{vRQ^nb<$y~Wl7d3<%i!j<$(DrS@BZQc;36zT zw`)cyE_xuE2O1psZX$)QYvc9BMh!7*;s1YGG7PwyjaO>tF*?hjC@)IqZS#gqKLa2_ z0g!r1oVuyN?MS>|_fT!Wm7k*eLF%|#_`+{ z+DQKho?E!;AbBsWoWn$8pOMkyF-0s%XH1)#lP?;UFO5`;L#Rc*c-9X*ah#3flcK^M zKX+$$=Jb4oYtfW|9R@M?#oNO5V#Z_4!Q?AW4Ezz9k4+BB z=LY>@vDKhTY`uyInZIj<29>8Quyo2P~;tXCyYC?~8*gE7rjQLH`@RFDqLC6!?*X zumYIv9^u3Dtt@5DLzNI^qpaw`jSa&{i z?8-vdX%>P^C!J5TZ6?ca$``7~GwFd#M>R{2_qwUX3pb(O`H1|UqH~N3(c-2&Vf|L% zeY2#7fz2hE;mO?skK zlih`5!-g+Aj{MdfRK3p*P8vEN-xSr_aM9EaH(YVaA!NHsvx(1aP_4NsdSq6=7ePghH`*Z&K35eobyY-LToGCb)O5hsi=; zbK2Zv+JJ#8>r8KYMf39Qz=w~#YYjtOxsw9=vC*;1-=DFo`#lx*e_`YfOd;1t;{?YR ztWSzfm=j7>25dKYs^t(9_|n)IdB~MGTFWyx1xoK_JtFGW^SMM$a7eNf6gkUIy0q8LhUWY7UZDI@a@*|E`LZRuNK^!8O1|m?h^y(e$h-lAhcde;;f2Bh%Ec@`XC<2{{XJ z4PfUW?BreImh&YGp7mztjoq*}R} ziFglEA-szzani(tZl62LH@1#C)ao=#2vdS zVj?UO5m0_ue%NRq1}d#|FrT@<0rAx*Z8~25m3ih_4zJV^CeEFqm5EE~#%@NJffyq5 zixL^X9dF+)Z1tbYzqt=ehh7WcL|8$3$s?eJUe@RQM(;B{?0pw$w4sGLuoritAiKhl zukOW(^QiIRs`JL@?(W?2&kXV)*8D${qhdKCr*F5dOquusc|e{&kUH=ST2Jv?Ii{uS zN^0LaVei8<%qtw;vHDQZ{oa9ro&FVfCSboH#Xfote)1%>^hXYM{mAYeKvBg;mI-M!kHUOfot*+lL)sEuKdWm$~>SDAyg`Ab21XfMDUirXhCIOxj_ zcmDp=Z0^}+y}b$)Bz}GcRl=QrkB;`-z=LjPU#)Iml+3anfUa|}fTfL;pFeL!tlPQG z|G7PysS&*PyW|8zJg5Bh=~LDHj$YN{sr{judwHtQI_{sW-NwZB=QrS*)_qov@1E7n zpX@fzXIc>0KqM+LR6}NgOKcBR3v{9R2W$R9(s{xmw zkb%&YbN6zLimU}Mf&}iDbp3lejjfCM%>}Vt6%ld`%vHQ%2KvS5Y2+;z;s$HFj>1cw zkPz~T`rx@gO8HfxtG({)MlnI8SMc+l@-cql%@If8V3JlMc8u~IK&t$zo&l%X!U(aX zp7~BkwC%8*zoq7`wSRVdb3ijv=P+5$3oS1v~X%37|?3k^_f z2UtIsCTlH=fT;!3(c`@-p3L2_|5}zdy6}=g->?tChQg&34a7;gltSg;%M?f6;@AIn z^(y%GOA7-(I!O3quhV{^58v?Hn0#Q1*{pCT;Mr+a`QsCK4V?Z+1WS*9HuUnMcK#(C zJiYSeK}}d~{nF-!ER_d~~%1$*?&$FYt1k&Ss)ib+UZ)Bg}SgCF&lfp#$Hf0*zaHck*kj|L5+ zQ;_dcudZF#G#^=&-90r&()yiedcd^yp40tpz_e1srl&w(V2+mUNWQgWFW=Tf2i1ji zP`BDsUF)Ph?eB+d1NHYA!i^V4T&5NlPrwF-7L8wU8~XKYVqOEbf;=1EmJivwi{Hy; z`@0OsRLus2)$o{HU0qjQFB>+q`ZK&<)T|y2$NznJH*n~IyZ5GAr3%+%EAeL{_Isj* zoO9UP~!ApNkYaQP<}ai;g-f#PZ2@@T)Pl4jw|6JSS#;LrN}jGV8y>>+>B zBdxLRw4&;j`H`@5WaS|-e*NHU- z62%J++He*i;p$khV)aZ=kuQGHiw(Q zxcVpasF+An;)FPA+XBKb0VtU0^1Pjfz2_^utyg&uuqMU&1{&GF`B;nH<}XHPLWeQ9 z7?oJacBHsyGPz!#8!CXl#rLOhHM1db@-e&JDGyA7M*}~;lJIkuw-4zNd1z=9XIx9q zHv`njd6>Vs!R_J#ucl?+`!V-1pK+}E!)1!CZ5CM@`fu57mR9~DgfnB|J)1_kxaRF! zmncEcWouB7s2EsDlHK~6r=;GEI0$(Q%pA9=Y8|{WSbR(*4kWRD*vZBSKTdx1mGV!H zy2`WERE?h+7Bvr-(GE#N@4mVF{QUbiscG<6^@ThaHgme7*%7wYyglsxa1E(k3M!p} zEdbxiF@}%nx>UpF6osX=HO>uw&XB4)CkMfyPy5);HQ!(ehiR%uW6l~(IPb~&p4~Lf z`eFxXYGueH{4QVDhirm*L3q?|Qo9h)wwuEA=%<=nVRd!LI|r>}d%q68-A!NgXr*U} zdaI();NYN`D~e7t&}2LlP9p+&G-B)avSsMa-;_hGOSFG7Z@oVLqKp3)F8)W>HhX;= z9EyQr5wnjYu{FLYJF_iwY8zbd6t{)z+s0!es7jM#(Fuk{5Yn00fxNm=-qkFC!Y%+l zIgyr=YLe6W-6RFD;^5C0r>U9`H2_8qm-n82pEE}qr$q-=bAf05BhA zjj$(dModiP4b8N%*eUyVWDHb(U(dID?*|P0414})jA0(*PyA#0gJRH*tCgLXtd)9W zH~BfZ8lr>l6=1w!Z$(Z1^L6sg`L4pIkGk)DcSBk3e>Uh@WBQ&Uha1*31Y$MPc~Jrj zG~LTlE=v#Y4ohE)B!;pERG{ckFQ8;Q2{(0iqdvhL(kLrz;mr;`VCR_BX~ zcei;d8Gy4p*@8~L8%Qv`eZ^qC`*WZ-1}LtWq0!?x46(eOCVjkkmiihk0!UHJG%2Os zNcshhZ%Ck6Ndl_0Mbq0hs%6UTvZp;@U_VDu^vXOXktwnc+Kv~=4m>o4s6uf(=ez8g zR1QrCqoKPU4u|1oL+`rox5r%nSBbrAZ7rYe`ofbS3XQ05-1{+0k&ZkXUk2OC?d{RT z9B<6=%%uF|12J$c#R*EeXDW2AS7I1IsNEoKVW7&1Ms=_IxbU-IPRsFE&?GH<_WagO zQfxu^tuDg3EYL&gIyz1`2F<#y(3eoF!$p7dXYpjIaDwxmRPCd`7mfQr!yk3=ggEuG zmN#hOP^39(0r8h-9a_(#WP~#9c11ky>8pTvx~MTo>;7=Ko~5z+%7OIW1B)RJN(9 z>HUJh>s!6+&6~=s2|U3RYwPQawh^E_olWfuWo@1J=IBL`I*l<*AN2wNpYSqh+SBw# ztq0Hxf}!5Jwhx3%vGW6$RAUNwAk4o@{Ll_*zJ+N@7K*sub*2?F9HYjK?B-IzW%BvFDGa zD13pK;{7V1#CipWKm_>`a3_mU<=pL3&Xa-|;wv|9cb{h-p2ZSSqSCoH??mcgh1($= zj!44s9pQm^!`MlT9g~5~i1#g1mRi8|R+Mm@N5LPA~u$7^2TW0mE4Bsu_5oZlCewfx3<{K4WxRp(>1t zTTy(=!Q6QiUb2u!ziwWhYuW$#+zlL(P_|I&w9`d#OWv;76YUF_3Y~|8)2e+X zGe}`g4OPCf_t)Pg7MBcwne&-pOo_UPNz~!*CgBJWK|Ie-XpK z%guM%y$kYR9^em(;K1?Ufaj6AJuV83L_)$&U1cQ6=H{jd34SPzH$k$ppkn;qBD=m{d(R2}(EmTL-A$)g3 zre_ff3-yy*-e2p(N{rtssUS;|n9**S3#i#OLc=&9|-r`p1wCf?c3&3p3}Q`T;??54+;2=VqO4mAdvB46{PsOTzWlu=W|I3&p=BpndGPsLfKF) z1{}ej*l|RBUY)KuD=oCy<7=tl5dhffl(wj9cZb=`7?K>9GIw({BQ-b^MhidQ0g(|l z3gXkaZ&yx}1Q}CH9?p8w70(Yb@mn+8t^^=z(blM?a64s3L+C*heh;Pb66#d*(Q6uG zjGN5o;3om4vMNIIhv{*(p7xx3dZX%XPB+@C8x zl3f%at8GJr@~K0(v?Ku-(75iKp;8$@#|RuxXMe>I77f?v;2Nn!yh;5Qj8*p5d>V2? z<|dHL!PKdzWu9nTBlxuIa9{fx=#Hz#OFk5Mlqm~(P?#?C8nZ{0_8oq7doHvN{mup zbZ5B;eQW+`GGk?<&-v#ov9_~6YcZZncj3fS3GUFs5mMq&!?aDkR0@?$F-v9igI7(8>JM40?zR)vV z_2@5;qoh+q>mdU_aIsA!P(Sp%$*3!0XUyN`G0X)zx&I+aLhPwvzI^RPkh|I1*(G7{ zixT2oj;`mRV6zZ~2Unpbqz8wV|m&C z7fV9-cfyk>vU~>CWYW3FPb?I%%l-KYr~Av@uPWNkDpfBI0t{xJQpu>Y&7O3QE;dD- z_9p#v7>N&Bh$JZnKN?wc__?=VPd%4n}0JhUg{&l&?Tc z@lt=kap+&tdc^eUJXQx5_BaMMjDH#T5hjf=J%a}7@Zn#wITxd@fSHb@M92H` z=66+e04CxQH#UOAeRpo9`=VF)h^WVPk2gfli5J@;xZf6@azPJaQqdqHur4J=0@iW3lMhQbp-v|$ zy#J32fN~%a0pe+fX9I*C^c&Lwh9pM+34s(n;kUcpE(VgUWKOn?0%Y>gFLxP7?NuJ@f^p)@IBuQp3Aphq13k;ZwjxqdUGjzeQ!JG-MIPV$$5W{JWa!N zFbPNri@uRmz)XSx&o!7)E1pX!i)_OxrbLa#NXib2uA+c!j?!H+x}KndCqha^+39eAu3j>D~Y%c|ltK zAwT~vVzAAnCrR_`ZmENPGV!c({>BgFSIW!tS$00_p9cu%+X?wTXGLPnIjiu6^y7nk z0K8(p&oIEBOAZ%n#JP&*t#;n<s{Ovx_Kt_$rY{Bp84|+fa(d3Wak*;ARW*G zUY5Tt`%(P6!rHrU=q?fjEVU#=D10V47$_(qgX81TMd+yeZbpS&YCI5r0r^X)b>iNV zN=L?FekYQ2i%`EDCihMU;ao;SS>39<9F=FmS+tn!ZmDK1*`j^l)X*av8irOP!gg}06#~x(er>|zEI)FUxG=t+yb5}Y zS+`W-gIXHbg5E25*%ime_2cPu2!thVnKVsM5%0r8qTzE0Ny>FoGe=Cr12Sjb5hqM* zkz<3rkg0=(Wb)N!l31aC@~%2x{UC&gWcpU9?IXU#c!s3gm&6MVHArL>pi%XgEQ#_V~1$vNeKfcY~ z#pfU%AA=!AvSrE2YU;VJn`nKxqp(UOgm5CyEL2i8>!EyvM{yBGzO71$&07YKvq#w= z2-5cQ2GN~`43CYra2J3T2%GS?$U!efs6F5mDhRRm7>@}F%4HywQ+#)6o&0mMkjqW_* zdv$qKdt6K8xMMPojX;ei;@uY2PRnY3zLbB~0A>%HOe)xf6w^PL_v{9cE_}M~K;#d? zPft%b?snRx_qZ%_Y!|1oP&vZgefEp!(_5(-4`++NQdMk=7g#4yF$$Ta}{F&O_~ zRJi!QyeqOmt?+Bes{1MpgBQuzJEiyn5d;u=2K_miNo!+aAYoK|)&1f4GsQLwUKqdv zfYkilx%$0iEP?{K2SFAw;2k1^8uVwA8rmoT*Sed6gKQ{NF6QI;8We#wsnH6ibS#3p z9+g`V%WTa+-_<`)yQ1T$G6ds@XQQQ`k7vx}+3-CkR}INt@#(#w)Z>2hKpaU3Xy|(| zQA+`WESwx^oAlzn4bqh7f`Hh78ymn$;eY<)_NUJST4rB7izpAmC&gxot0u@qD%lpDJ&{l1I` z3?E(_0CLb%1wp{4_12@(t8h7)*?MsA=m>mR7UK?a2 zjTluVD0YS^w0UeUpBCji3q@~W3i2J+Al>F9vgdcpfGU1P*6X6LbwxkZctG|tH)}{x zBZN{xloF9rIAeNcZ9_M}j>)=W;_33eC?0#tj7^q#Z7Zo0d$NeHZ>6k{XpP=7kIDXY z*ERi~vYU;mGf8R+e;K z{vL|Xj}RM^)#Tq+ncdre7yCqlvG1~BBE84QH^Q-EM(+b5W51^2Yudv7Y{dXhu46_h zKV!+*%;ZKY*Bk|PEa2`tFh8Vc@k;~?otaxDr375dD8~*1KD-DDW_5qKm-xgShV^NV z)5ZXq-8%3h>hYo4l!KX72`2;K2>JP2ps)m8p0?~K%(#Ui-V&hjqQo}O zM#lbL+s|El74Y*Ln(+SN)OA`lictq*NKe)A2hh?CXeO;1nXQLJ41}j*(ZmQa{*-oz zpb4-M@!22Sr&GO0k+7J-5z?mKpoE?4$M-57BKZyoHecGL6Lv=Gw*CK-N~>0Awq@$v!(zHmSx}?DEuGu@ zc5_sNudKyey{Y~PD!^ybmR<%%^}lD5KK+g^0CctKE6YYcWb{^@3JG=&nslX#MLVL@ z#7pVllAUm!8MH!D47Al9R$k@i(M7a9iAQPAQnl|bJz-7_vsV<8*r79fkmSwa{$ATa zDo0hhq4w{A0%+~BUPJ+*Pb*b>KKVlydLaPhP&(90C8=*4Iev?8vx6QyfZD9BB~b!- zlk!X^IFoAJVz#2W1c)11Kjp+q0IDNqdL127>0N^@+k@BV_nMFQdc}#d-UYg97m1fY zMLYU(bu}K(F#ELG_LG9aUf|0ppH0iIoZUxVnOf&AfA zc_4%^OC+TE9sWTwImF(~5bgf_$}ap|SzZC(?<>RNKR@AF*){HlPEre4!hI@@5-Mkx zhG6|E^ME~Wm8nNe;Jwh`2tPY0VWc{fYuW8w;<=-^h1*O+XxtO92SzIFQ>;p}d_j^z zC`eKe&XJhJEZzZ?y9Z4+ynqZk|BJqcx|w6e3+=7|k|Sg5;TR+z+g_xDG5;FGWX5;$ z6bzms+^XU|ExolVx>yS`kY}7MM*H1GoRMW7&YNuzERbO!kZ&~%FNHcrrn?dvZZ2WU zy}9n$o?k6+IO4q1tRS)f^Ng;CbOsvFu~Ih^uFmT9sO)}C>n6JoA^{ftcn6_gbRUZF zjkD5YCU9xAVPJ^#i1d2oUpZac(yv-IADuRGoQg}!Zv+L!{Z6_ec2OknSPT8@J|#rk z==JWisyaM>AB=qp6HQ zz_PA(LKG*?KGZ3@`|s%lI13ZOs(_yz#CHLG`0yIE@pQW>DDbVQsf_0B$FP?3X}>R7 z>RbA(^rV@;nR14s(bFV0y4bLT8-Q>|*xj%P4Waap)R$jz%3l_ie>4-uWBvkbPCBw4 zfR?OSVO1izNs&eVC;*1duPn6>OA|4a`C9z|vwbX*%&kU7&izP?H+%!)H9ax0JwKIt zoe834XH=j;Nkel8RUKETY)A>h!}iN2-Ln5-qyeFGiC?Mr!IMYkI&vEEX#4@Doep_o z@yh>4qAEB%5MCdsfOw8pFj;8Tm(IIVq7K(b=fNM~DTd)@BV0?Go0>?enNIxIlK@jp zbpFB}kUO>z`k2xvSK&rCSsn;7J^U7LZ0;~1n}sESbxjvo3^k3e^Q!o$O!@a7G8VPf z&*GPk-vYSc5!>0X4Q)ZsG?M(leQ2-tO!17k9US40#6Mwvk42M(_=yBOUr zvq_YV+AZB{nw&YKu)IAwxAQIS`~Cnt#W;TibI2s-r>h6(n?7a)oaR@2GqPX2V@~jo z@wEV5(sxkO!!rn zt{bJnrcPbugEokZqq1OES5A&Reh|W_StA6HHM)m9k^y)H(q#6?2aVFr zJ?n?g``p@|b|j(6)-OPecc7PDn27>^lO7LtB^@lc?~3SLci^hLNLIY{Et-5wb`-mN z>eU}W-#E;Ka;}yH^AM7%zrX)vo)4M!jSg=4vnwk?D#`ISEC$@%+<65K!F}Z6Q{mgK zE)JauKN2pZ?G<(^xj|NoB=u3h;R;fq4nik_gbq-zmb)W8DZ{=z`3OA`tJ2(NDupdO&YZ+ z+p~o}h;H!^UwKK(UZi9t9Jrp5a`#nF?n*zZCqC@9j2Z|V z-)=p>KSMXNLf3gWT#|}Hg857khzY0~9nWrRuRoY?^IB#re|mo@)^99ePb}HJOwY<& zdhVqqRBR{h#yFW=s(Yh(ulWiUeNhiP-;cWGteo}5>S_#+Ll3Kr4e9A6%m;P{&x0p> zQPTA3nzb%idWsPW(?%G{S-;;A=M!lyThpoLJqXHEG$Dz=U zdkbmQoz?!%)Y|`Mfg?G?wuIfTVRI_@ptl~_i|m>={^39E@+~ zIM-66_ymI4PKMI1K7OsDtzvwb6YZw*WcDJJtTOI4J9iomEgBs-S9B@*v)X>2w?ANf zqMJtB`^DxKf)3I=#JyYH+Z5}9tE3*;t+^geipK|Zt-qN_%wuJBoHo&z$gj&gX6}2aIP)8}GIB zpmWPpyXvkytvHTA--AXd7v>g}_q%;8ST;+4eLXz9xqHr8o-_WVi&1H(v=;mr#AtIg z)*s=svaBJqQ^k(O<>k+pc?&YIueXmwhCSVx(|t0u`l@SO{<2|C5>Xe~4)@}~lryiO zm*u^7_kiQ&B8gN6E{M4+%~`J3ce&HAB-!!RM(2Kwm0;R(r@X36+5-)-?FK)Au&;Zt z&h&uAC}Bzc`j$TrCkUO#udDbjOhk?I;?K8b`4~GHfa<*~`%wWXAU$e9dD8pU8R-e< zpb)M*Qzm>bN|ID1XrKe}CBf*GEpoESr0C6fusqFgHIL2_3EBu>AST>Sf#20DIMrI5 z{~3U($jjwp1n`T3TSRcnx<^KwQvW04%G#EWX8`#<+ZxgVX(Xw45B0Srfof)m=^BaM zT6Ql;76fNA9|&Xm@e`SRV@+YCxohJHGzlY5pAX%L^)nc|1Qc746i~#Zby!bhWd^p> zTpx|OQ#v~zf0z8EGt-$08M3i_2{1qjV%P8_^oatuDpBsSJ2RpB3mQAy=qG+P-VhN6 zUxmHAm$G9qv>bFrf3wgJs-a3r@IU3svfCaQkvkq`u- zcQKbT9`=~hwA$p_-*$3~F(i>acve$tx$V?dsqiuJToTmcQPkP$8i^5xoLtPO6~#(b zaXG+uu&SLY^VjQqV;e{BS@N4nr+0odw&lopKp{*Y%GWrqRzn3$((FyBHqb@tPYV7L zigr!#X%)hugQ>6`{T?_ zjp^^fJ}_GK4gD)BzU|cy7OzKLg(5~`#UYKIw*edJDEjYIgZs&l)=W%HuyIaaeS}1> zEE!%|Xp@5|Fu%!kY2+~B_lk+DtK#`v>cNZC2hqOKP^=hPF|Fw-h7(u{5O{9!9KiXx z*eQNtelg9XdxZWzGEyEIwn?T@bD!oaqgrgNu$(4!vMnw@GU6FXoL{);Pp#X|76e)v zb8}$YjhdIoZ&kl{(eJr3eAre~YB=#@=ZxIB#^4FR+-NxSPL$XlbY;Ee!VIz#s%5k< z$?a-~(RldDc?@|ZoXj`*H2&%tq{`cd`*~;_J64X?i-F=gY*4ByW;^3TS(96OvTygp zZ^Rn$u-#tS0_x}jMEAY}P=S{tO+x zxxC|}@}J&HbrK>n@<|{NdN6bj(hBXy`+UVHQT7#7Sm{yx&t`e$QW?5bqr=HN?vo$O zidpZ`St3!WaZHQE&x^AZ4_{F@$o|hM!}J`jKlwB=B{`^~gA%7PmFw6QTduE0T2U60 zquS6fcLgaH#UVeQmgp;YDI+=Fo8Js9YD&Ka3Mjr~+VtMEsdLpVuy5miok(rsje3i@ z^+cK&V|y{PVAIaRTeiHt{iC7lz{y<6}@fU$k-Z_rrC5-ge9HX(lEj^&88pl&)}B0po2 zps4iP*SBO(b?2X2S@JV}Xm_AJ^kto5_J2g##3( zEeAa&)&`}B=5qV|m-XRZRx8SE(f)~8ke-oHUq53!|2Y3LQHv*Hr};h8(69;ouW-+z z|Mh8&f6VQ8F{nUaZVer{hnAwp*UGmVc$$8i3Yb2XLEuM__zJy|8SBj6fBIaq8;a%!66u_f11DH*`b7)hF&WfA;TxEM>dPSudJHezZd#B|8P&UwYf{m25IRk~oOR`6(p5`c1HSr%r(%Q@ zQ|e&D65jZghNpVKBidbecBU@sZ)KX7?{DnrT8?y@*;^&L-!+7cv7k62&)|dDw0n@< z(3iKQvl-BoyMWk3Dt1ei?z&44j}3t98Pjr7L-ub?3Zy`FLYkA z>5!?4>D--%w5v?8#HeT)jUq@ZZp32yEnzKkvsj?nXe7|2`DDwXC;<2IqRpc)$c?#( zP2Vdo*o*KoW?^D-sBr-dxnPolUUl#>rg)~~Xk!-S^pxMwbq%Y9o)t3eyQyl1)qWNi zuEc)Rh2o83^3_vN!29ox1?ud7ypZ`v)9MU=-5?mmX}c5FWY%$?&jhvnHg<*B{U4n^ z&pGb!jdSmpO|Ofkkj!R*T+lMetcK%sS>U{-Jn7d$tiZtr(5>qRgTAr< z!@UcQZbU|0_-iY~G8}wEkN-63!?8Kw23U4xnU4(6@BL;G{-BM`0M7hnz@P%QGR*wD z9{zY&#C8B-{oH6njccUJqrmb-e93$ejj?f<`^-7-K@*l=N8$6w2$X<1fdNiFuu&v0 ze^FobiyP8P@0GMPK0cn|G^HlCCr{k>RrcKkQSyg@)2tCs1;M;8C2g>hqO{z5Q_<&L z902>Fxv1NJwE+EFpD%`ghk1{BcKn&|NiWO5rg}-%nSYNo_9Z)Wky0=}p!njElGe-b zMi{kk-z17nZ9R8s`GC2+UV`=^1mc@~FuW!=0=-5bYbHX^l0JCoC5)_F|NMGg5uCw2 zeAY+gz=Wt~dx0I49i5t+h}n9H@e&{edu4Ye#GZ5r_q@XS1ZkGA@!czx>Hf#bncm1z z>NIZziXRCNr=~wTf}WmIWB|@mM%bRRukYnp=iX28It!zJ?6*N=UGf})Ca4CsKth1Ow&|HKI zflb2c(Za_qj)$*y>2y+BY z^hf#!Nl~!TaW9v z7b|wrxEABMnuuK8^P!X%Z0Y;@&tccq6bF$f3e!Y!x7v6gV7`U~)nZj!;Zw(=#%CBX2-=JsTlmHJ7zeXOd~l%;L@hLh2uk(m`~VOFzHvgUS50Eo zj-)nm7vq7v{Iu}YKS2ROKKvv?X(T0#tRHIEd$W&1IRR^vgJk1gslu)AURH`Fz#?5i zLC^9}4s=9P(mam!$2zTleNw$VpnL`&hWfygNC%R-2NzFwwwlf=5XSKzDV77pS`owMHe1Yufr@dYqw|rg{$TExdSmmpg zwPl+l9T3a?Q!xLK+cjO{7Jtgk{YIBP$ZcTtA!ybF`(z&s~t&<;)$bV(iBHc+^)F{N}-|3XBiAB#374T=apLA+5>D3(a2a8v5@-WMp%R;Y}15UQ! z&7GocyZ|>A-5>K04zF(tJ#|JZNNxt2!Q^e=EL}f+LR#JVOjZ<($%Rnif~Hynya>GH z`pnNy7F~~;KH%v?oD!c98yU^3fnCU)PikECBMZMHqMgN-HeYX-HrFvtN4e_NSR}gc zh~q#!;VCErSSFoKDK0>4y*|buY&xVcOdgVfLJ*B#v^mJNSd`ptFx%Pi zA!Lz;mgOgWP(QSZv)ok7ptZBxCXLm}t9va^kgxGYGyQW*HN&4zHQ0fv*~qVJk*;Y; zPq3*lx}13wu^cdnT5|4t4lyn9?ftS+hDIS-@(uU8J^ncl zz4|zY$4(f*vCy{KckC9OG`RiQf-g%HlMJzjT$NhX`~S~vQAuwO08LSgnw8bni*@TL z{)nA7^Pv$y?VnHT!wopFL~!tUv&o(Xd>Ctj@JfWyefdRjN8D6F4bY=&YTggjN?xUT zBu4YDIi5@eCVKm`3o~2`%hU*^9|UU$eZYBNjPL+0dRh{Qa2>&q7MMt)l4mBvj~Af! zn$jTIu*|8$O18>@X=bMWgVnYy#ODS1A4}IG6XdF#O~6KS{V9$fNgOnt_Jl@#{2gZC zZ2)Vha$SJbwWy$daNYurmJ$bB6w5ebTD!;mWW$VB+%BPT#YU+C8jjCu95|D7zaqFQ zD|ieY#_{H{ZM@C*Ypir6Jvvbbz&zp1zHRylJ;5s#L7P^Om~tCZ1`D^OxFecT>I4`>9#J=TI;%UIHnzI zalH0a@vVSl=|AhcRZ|)CMPL%&-*6EVcWfKg5PV*!(><$64m5m$B5XFU6mHT0d#f?5 zDa^+JPZy1BW-Suos|XFxNb=W^f#wCK?JI61?|hdlEuzAp{%KDGKrWw0I^TzUU=S)o zy|@}mtBZx~tGKp?bo-zOV~DknkE^ITJWt#6@1 zyFj!(Tyt$-d`%QYq{&rXiF%;dOwh*`y~d4S;msx3VB{W9ZHXuttz@Spf2Q#M=|IuN zphoeIvT=)>91tY=;X9?MHHyS)NPff}dunfX`#*Kw)FZN=S#{ocseVU=4}@=hDz?b(@j`(E%1oM&+UntFo_ z{RCVLdcMsd2iP;Vi*LL)uQ`93*k36+knpsoy3E(27>;FIn9^OvYKYNi{?dikO>$v< ztBIHEwl5}z%D#_3dnS71r*W>;j<5tRiqk#OqK~-1X4aO{{S+lnU8td<%)#=JZt0&m zqK(I4q-(-~1-#fOnXNMx!f%!zaLfpxLyI>%+hW%iYsn@K5Gg-BCayhsa%TtIwg_Eg z#d|4VrLZannx6Lg;JWOfJ~6#>c`qZY|oo>b@f} zSW=^Khyr+OC7PPgLl~(Qw#^|%b8Q!z)dBNNO`S+Y$=*4vawMmiG{uDo6 zTpM~DfW`m{&_YAQAW``F6Yw8&*B4*RwE;uKdkSug)m|#l?GR#wtq9hU2Pw=oPd^2{ z+k}{wdI@f4p}7#4Qj;i@wK38a{tDv+BOg#mE@^#r&e?c2do^urhhJH=V{%Sq+mi0@ z%Qs}kcw5Anrcfcq%HX~}q^99Jfu@*xybH&DzK@)r4&2+jLq2~O(kfW!MN}~9sKP$r zTIdicj}agVRfp;g8xoe|;0ODD4shNNt10n;$2`gmG_A?ZgnCqsPdV1OCbS@-N||P_ zj?Y`N{I@3_Qq;?Hl^2i$UGbLV03S|XKCVS~r&dD!wHhIdPVCGU{fqGz9P}jb$%Rw*ze`%bPDD&{!p1SWaYhCAts@_(W77t~0wm_eA45>2 za-t_ZC5-9c9T^5MNA_`)!414G)@XkoTJ5=eRNLL%B;>rX`4AiT$MSz5xnMRP*J$*2 zb$G~XAG&nZO4{ed2`ocWKMWS=mUPy8*!LPgZWs+(eYOKK4obDb1;6XMa@P9gu4pUu zpT4jB)2mTWkZ+&}yzV!g0P52B9)SBN`cx|L<45FmdfPda^ii|5W^Oqe#48eN+Fwx} zMB24}A=P7t{Q^wr(m(kX4Uk}sVL-3kY_mnBE#1bRu77;YJv==gh}m+Ve5%xzA=ThY zWJ26oS%Lw&6iaV3rFc7Zb+BT@yLpauXy4Kak?3rg4z|Sw%(d2nMy(dP?zy2>Z>))-2fi z80lfX%ah2XW?eF+eyDCJ(|o&9@b)egp>18)b+U<$ZaSBz8?kxaGHC&yYyrDCey8P>`%5#Cp^^^1V&ReMj9Wj*(V}?teuzNOyE9@5ypJ?m4fH)hk=62 zv~>m3pMW?z3lDLHXDOnu1qNtfs+nbLKXP|>Qe+l?O>AvE%6MS=ozcL-{FkscJu4^lero4^O)m3$7tzbCk!lH@4AHobbdce>YNB;i zu>Ozd(^rAsK;uh%#G*lQL2@w2YYhJNS>YZ(h6h0{BeDBZ!|lJXl-O)g21)tgmj%Ub zYXgxVf4LL4hrbiWL@$Vmi3QzO_Qa-z4-OK8XKwdb{^U+e^|-kIptfjdKwO|#XZS}` zUxlkLW0R7g0?k9O;cZpzZ2{D8auz%mKwrL_I(GIGJYaDs-k*f(Fwg3Uv*QQX8~iOQ zORwUXiM-hKM`yzN0T#WglE>zCO@GjM8(8P-WD@qF7FLDVEm2f9XjOB7bWP^-Beu`u z(yz<$+$(FcK+6~t6FRo(7!d;+a}StL7dSzken+Dtz7KD+`}P@>QG=qnHgwknIb}aJqU{M-oF%HO!@Qn+VClW z8h7ybECRT9Sa?*;T(Khhcu8N^p`0KH^B^LcNC^?>cl@YlLTm#8>t{zYR!zL@gl1&l zoGIDc+3h-;nyyJKx>?c+*6|o_pa{{!pFh1$wmjs0&Tfd8S7bOuL`G%=?ZOeb;Rrac z6j}6MmS}%Vr5MijxnSE&6}t5!hyAVFjE?L*`KyEuqznv^ot<~GbF5fJMG2^=R(bgOtDxVLB}-Jb zw6q#biWIOA4O<|5GoJ(C6P+(11FY62US-dyLH5}&;I->M?%Us9G8LQ5i z5%{g^`+|PG${W46byG!#S4DNpYc>rXXAkr?Ec(_mtG60Y?OaUX)+(_oPX+T%94Aa) zdSRdydXz8pCH`f)kOGOfbeQXg3q;3xA>?%SZB|}z3dV-C2ZO1&Ee>-Ws;TOGo*^(i zYqZPHqM#QAw{GS?&8@D1BAR9WYq{v>%$5Z1bSqn^mGHR_J$`6lhrdkznUszyFu_X& z>rSF>b9pAz?4{XSh-5>GGMPTthoy^?{$t__10aDf z1q1{#R48pc34RAF!!UzlfR0P`#}WKnJc5EaVI(Y-E!sAGyu3v9mlB>2QRG0X*NyAV zT5;B_*`(AZBLwlem@pkM4Q;S?ZoWSj;PcB{%Ht>@E5N{>QO4}#84loSCU=~qvyQJ< z)^y+4)6)`&zRZHJJMHSGoSPV3A|Ij(4Dm%8i!#dzizD!lQSmRoW_@Fa>C@rAar@}T zLxbaXbU0pLG*y0$5Wibk9s@~*2G90w@nfYIKR_nDF`oQ9xI24qr=?EP*W#K<1B$R# z!r0eVQ4J2!Ws?>lf9LC|Qqymlox{Zuvk3@~gljx0&qSE!zK?hw+`v9JIa&Cc8naLU zuptBXN&~J->a}6|%2*31cPg$YsX<{ZS8~k$CmIRD7TAL_`?CG1a5=;j@Y=5sJY2&$-l77M z%*P~O&vx(8-5O&m!T$G|p@4w$Yz^M|pwZHYsAs_Y#2T09dpxBRAnXpwmI?NO0^$8}2b$l;AIHOehuats#;)9|ey z@lV`71RSiYOh~Y^LjU*CB!R&30nhRGfJ@F|uUbAoRF7_Hh#hnvvQ(Xf{swIR+^jCi zkTOs>nhlDFmQi`-Q2};}+qQ+im74_;Cj)mxt{2FFIqVi?3@@jl$7qMm^-3(UeK=Kq z!oG=?-R|w%06?eJ+&y{6`qSzM&A*O5n+@wN~ zR>y;hu%Dg~L};5}@g-X*(ayVjrR@%<&Cm7}&?fU)bA(SxVq0cw1xr3M)Fw7?AsQ4B z;&_%)@TbbC$1kM2Tkx><=mEm>^|#+Szw_|(KGav1#y^TI6})DByF1jCe>z*buE};U zVSQiA`?s)PQ^=N#<(4SOW{|X$#nG;X?;1>R2{0}#ooI@}o5)vR#_j1mV2+&3FK@?MOmc5@UFg%Y|$@-*?h1nk}D2tXAs9l809{uF-x?GJvT#Ywb27RLv z8VVigyHFvlMR2;<1tN;vUU`ha1Px3wny+3(@f%hzT_f+~ZR_?cdw*}BZp(blZ~zzd z-^nEgxbQHyL^fpqw`#!^Je-4h!wvLz&ZmMSm6ECAsO(Pq<3bVk3_8KZ9)s&pId|W9j~Bl58+Xgj@7tKT{J2y@@U&J0aW zrI~^@d8rkJ{}#h^qlGG+NBNO0-J9d1GK=&u$@WRjNwCZ1?X&>3%)p5fEq#{3iS5%a`#v?NdAs$EzRq9iK-nhGfLp z+l@9A?#kmUUb{bxeRfyv+0zG49H7siKPO{ILzMq!4-SiCYpnmynuVL6&(6o$n#uX9 z5TgzMEQ&Z><a8Sw8Hxb{qN@4pYCB7GC!cyH~UJs2^m9hZNP=$_T;%6!?i-w#P=?q5}pV8 z`g9R}kF{Rt1u2HTDSLe_?=Y1b&4hFOl(aO-o+{4DOP?uFhc-21-$NO(hA^e@Y&5}1oPYly(2)aQbYaz59o$R}Aqd-Oq`R^xiB@mX5 ziO7lsszEG@-bzyyCP3fHfqmLd6rUEGhntIkpXuYX$QHYyW{VTunj<$SY|oE!>#+?^ zh*QYoknN2j3>H##QNY75nN`);ICeD=uS=O(o7dTR<6BN*zEbT!Re8(`ULvhF((|LO zV#s*=!&3Tn)QzcB#?A?EPz=xpRG!mAB@q+cPJoY;u_lHA+bZXBYE1AC`e=aJ+cCQeh>U#)G@1^5?Kos3k zlzNZP?EGr&df3s6k(y()vP|A2R zcjiYolv=XT7yS1pVvxie1qh&6GJyLJ(;KcDQ zu|m=pX1gi`9$MZpMAeZ}Pz64iuNW@z<$A77K^FfZ>V!CjADUkN?G(62Hb@UlP_Yue zG>coiwT?!-30|oX^h__U#m#ePV;6yPa&m@8xvLT$Ft14`E|4=Z8O_JB{(cVOS(AE5 zG>V6P{;Tr<#U!-f_w-_)!EO64O4t66WLE+5i=*OgMb2sD-&dLcd>G-FH(Y@1<(<95 zuh7=Br8YjGR`DXR@{7`(*R=P>i1_GLithDo0q?c0C*}bU<_0yMO<}_*gm%waC7J>? z)CflvHsVvoIUMRn^hUh-@|7~PG#gi0S$PVc2QXAKwSG;xE<@uI0OdLW=jW}=xAH1C zIGGzphPwUMQa(?f@R_e%xawFEi4URbK<^ULl+n)Tg>nkn0kJmHH zkzU=6L9iFXCM-bS)L@xuaxP|>&Be-@#qsI4h^D?y)Iq+vr!K-}?Y4mcufBmGIIB>E z5vT8YDZ2H+9j-fOi|G$^4xHy|%XH>jJogvA;#87a~DyHyNwHLn49M2V<+A|f4MZgB%7U+ZMt4;1d3FIu!)!* zjNz!A+jFF2MfUo1O&y39_u#HX75Ww429!?E0k(f`!~#4yP$ejbwV_;@>8 z=Gyn*OPubDnw{f#fK)bwe|EMh^D^SUOAcInl^ReKC9rqO1?b@;STKQ_5hJ9sO`bjC zJcZGsR8a-Lxc=hF$wrcgdk8!=N`))(TAx8-?LzbfxqE62GFwq8?C}0$$ym7l`_-HH zFO(UqEwpvsAybVjq&f~WA@8I^cna{t3hpPhl97LVd1*e3lPQ1@CFwaJJ}EMp{aswI z;O|l5tD125=TDuZKNj(}588i;aYGzu+3(LH<#ptHd`0wL;>IioMj{>E&!7ED4zDQl zAFKECSYIpWrn8RXEaLain5NF>Y{JMYaE9d1yk$3EMLDvWZsbl*ZG;6@mBg{O~@)VL1q z^4?P?rDBQ{3ZQib|b~c`36BRjE(Mv&Ng2 zu8G}tx}FALQK|fcvXWA^@{8IAF`K`85v&ha>SHjaVPEg#U6QoEUH^kRIw~qhErg%b z(RFvQ4bk6|(uNJfojK+01R5B}q!5N)8|gFi;D)cQA@|;Q?~oLbWgc*31sHj$Yl$AN z8+d`EB3ocEJR+(!z1hh1m-lF)c|G0c*FBTmayXXaaG#d(qm>p+s_!Qky6KgLxLsgD z(#zZVv60;Y^d0d^zP5lr`?#v@>+H5B->@=Wy#3?MiqV_CsK{1Lj8nF`u3gdpdVK9i zgKR|C<(ae{A7|#?{&tRpR|N5ZQM|M0>Ap_$n3M}UAd6<9V-96z`Yc>&LSvbon{F>j z-OUGm9Jz>JBCeO>JCN^JKPB?vA)O4?i?ZT0nJ+E(e&51rwnb|f%`4Rt2*P407vnss z&9;j%&h%YFWloW^>PDlp=;@C!(l2J4gj7^}($(XOH- zE%7iA4D}Vl8o3oQ3{?yxHk~ZqmGb&9|8izhO-)THUSNJCL(y6t?WQ1Ud(#kN|Lxw3n^_@Fo2@`Q2-!Clm`(uu2 z+Q8=(#T1BIkJSk6Q{OI=M$wN^Fo}L5W1!NqNKc+j!$S3fSG?_D&HPI|b!K5Lt#a3Q zsZWN8op*J56}O}kyYfMq&%dhXwrMjxU3@*B8tSQyg{U_P=f4aBvuqacs(6XQ7?L#n z-JJ7v7beAJ*wu|jIvL4gr0?+t(MPUL~C; zPnr~k8w^0`yLalz85qL;_V%)~r_r`*@n6eVh9!398yLL3r5{}PFU?hy-=A?4m6oQ$ z!ooT$l=Ztvmy=91=(fI#Y}ir>32o(^AO~-sK_bk{>yEj3CK(M)Y-cCfkI6A);**!W zESxK|U^v^Ijcjkf(UEsEC0W;nEE*38W~qg~yM76#F@l5Z{&VD)6RM#82cz7(A7$siyf`Vut}~yHOHbzrQnJ z6Ej*Ni>9Z2OMOn?P*PE06Y&J3o$Shx`d^*Y2%-5wl-BVYk*QSi?9}An>O8QO6f`gI z*Eji`Dz^l@<4dvIo)`da%~i_&H9boj5Yu{9A_Tp}1a|2-JK&{fvJEgc6y0XGi2>*G z%@foI%| zC==Qf1KWQ#L_-RfV8q_MZ(@!8XpYBJnMHvzPUY&x*pGrH%j1w_EqpLbP1rgj^lu$k zDGpI_JKGo`n6Ez-=~)aVvM;qB8hpEchcFM&O!>Xt8-A_^3AcNM59`B1Oi)8&Gro(YGAubG-r|;a#Fau3JQ|UFmCkPS|uT^O(wS=frZ%Fi&tC75~F$L zBnMn_8!65#R8(|#;|UQ{O;rS1oR7Nl#QZrEvzSc+RsKx92Zw~C0n$ma)1dd^HCWYF zvOxRGSVSzdskm;hw!eR+Ck2sknDppJkA8s}CL!J^@xQ%8)+sz8^Y?dC3zsVWF3JC_ zuVY<*aST9F0Xe>}hkK!|Wh(A{gr}j6Cn$2r)|=E5J0qbmA#QN(U%S6SGm2Y~q0JKAlRy zUG$glNw_FKob6jTICelFoU7>k-&5Pn9Xne!PY%#iP;UvO+XjV}NCI+bi=Jnds4?|; z0UI#;r@8p)?0W=mFfpXLzZ}M@Xh=6vZ#^W?!k?N7kH{(f)8a6lnSM`cqegkV_3s^H zf>3)ii`(&$lYat1VBgYXF%RMaq3s{JLjIvL(Dc38YF-Zc=H5%!*OB(%R7$fi0>aEs zFJ_f;%?<{$V~+JVmuWM5vQY8o$&nunEPCop)Mm`P45CMRYy%7exi`Dl$L6-%55@;m z8fM<572S&cy(8&z6sAvsYGktHzGw?>5;*)w5L&vU{xL}bUhOeI^3Ria_5>8V{Qys` z?zuV^eFIo&Oe;Pxez(yH(kgc^>(iFow)jVV@LlgJ`nbCRI`*u<+sw86B-zWX%DaJe z*u7(>W+H}Y^_C(^a0ClQ1NfY5yn|qbnvP@+x&HZ?@z%9V?9o3pMmV6I)Ez|^n~)$y zAu%0PBHlBcku_XqLDQdn{v#s0noEyK9sI@vxSieI=D)MGT@DrlBJJ=emP{a~q5KW0 zxBwa-uv(V1v-d@|qS+B{GGh1JOq-m=1j%g1E}U?+W@*vU1lR+0g+G35CWviX+GABX z&F8N%KD_?txz{8QfB(Mg%iUakegqY7vqO~+;2H>?H@h5R_mHqR-#p5s| z^wFccXU0E0+Xs>-CD%yGBu+!PzX^>r>ieEoH!=fm zuemkkv8JaR%IwCEbSS8TKBro7LfJ_pFt5&8qKuoamLmn_iF$ni2n8V94!ojsWxflH z6vaV@86mCL@&h!GxgLAjBUVZUd;_;Ua+{SjrN% z9#jRlZ|6O;%Cx`x8YVv^2dW8}4$5ysD#di+i>(>pG0eTze_1*{iXTLs$;AbJjpvvf zw4fw#MBBHh$gVURfeUyTkKghD7uuLMdVB`~W}MiVvqUAAn4z#yu=_`Ja%OsS{5qki z;B0@WdZC2=@5OX!Bu}42RgMm}ed0X+n9`Ix-Dla?Vmmrmx(99W+;=#0t<3u88_5Ko zoDhOIvjKeQ+S(fCxI0Kg8?yrhFf)MRQdfATDsu7*Ftok1p!yN=z+>NDr07ZdgpIS$ z1K7}Wwj$>n7Jh-siE^2@I}XOe@>8>lLz5E*H8C9EpOJ*AR~fqroXkmb<0Gm}ut}H$ zpQY4Kxm9Fmdqm=guDu+||2t=jgDELtay^r!yS%LX`f$8)`4FX_g2Y7VYnXMrdy>I) zAnx8@peGf;;(WsSz(p~#R^csr4HvQ?V6MGQeX*`Gtfkz_5V%&kU{C)mt1IX$E&8nR zFkeng^@EKc6}Wf6Ud9oqf9<#Tvv6el+t1$p1~V|g=6aG4+7;PSS~`(K41(Gd-{gCv zUJ6zHIH_y`t9Rc!!wsGaC!3-`8XP4Y5fPNjZ)%DOXdJVkVgMJX*fshIB8D>*1$1V` zu}C@+=7uNMbx`t^j6wP(YcxExBlS(MK1lg?W4!O4UPt=x&94M?8%tzTo7tj#Sbv4D zPg6Y~Kc?@FO^eV_@F(Hq?0ULjBJk zjI$$B^%(3ACxjuiwmAThNcPGFPr||4e~ld8D+WR3LP*1+_(gfWT`IwR)^z;D#I_9dc1B=Yi?Yq_rqi_|JZ_>vOz^X zJzDT5fGC*hIqV@hQyBb}@9t2m-9e)S$gE)DGpM}C^M9_b4N~DJ**G|uq^0XX<1&k) zlam0b?3Pq}^r*YuWBc%8MFYzr`u;~a?l2-(a=Yo=*!ejV5TG(7dn{XMGSoc9a3d zUtTr`|EaFSTy5QgqwmQ^3=8VuA&5|_n5xL#w7u&=ax)5YZ8%hk71sQ2o=JmEHRr}d z>gJo@Ot-(Q>}fzRxJP(oWYMIpo^MpCM2x}d>Cn7 z9i&(^TYlShb2~oRHbK5aqs~{k=y(HRV2>HuUZ@|dZ!g|FHIQ&P3;Q-P*d~ag6Wvo$ zQj!9_?*ccw&KiL^<-y7;*= z!Jks&N6kTRu#Ub(A`haSU7{CidWVL3_-p3gPa6ATyu%@B2hoo&I9$r74DDkAF>n^a zc9qh@he1WVAdDE%*@@|Qv4r0F24)rz3pDKH%BQ_K+4y*IoR$WwaWZlRpOIy7ICbi6 z;y5Mp&tbZ7(M6Mn`E+E1loaq9F`&oOL~On=S7zssZt=8Oz7Bo#UL ztdY&MU#xqtEHCdq7^GR0^L>j&ee3Ec0mlV8tKewJE3c*40Yma9S#o&5e&g}|XR@vi zM9tvPap`2VCD8x+Z1Uy_6ecupgi7hVIMn8=w>Y&!DroN{fhFYouM%Ae}DnYlGTI)lY@EXphg`B>>4PxVj+Tp{Jy!g(<-T` zm?E1oF0Sm6DWu>{C;}nA$xmK@O}wcvOpe@(g<7kD$*DRwLsJdoq3MT8fDJ!c`sPDe z^m{=D1s*4_r|m76e&+|1DPjAea6@{utf#rTNX`>!Ebvr#G<@oDx<7upKVrxZ3~Rf$ z<9!B8CpNH(T5dqfk?5M-Y7f6_KkR03UWwo>F4hNXHjyoYpFV$ns&zs8WL-vnK^pFe+gAij~2a}tZ% zv|X43kW^^%$4BcDsoedU`fJrhimh0{nOP8>wa9*7|mmu*ehs_;I)U z%}6FEwUf-7Kez;nIkg@3BV`t0TUD#3x`V7-=`{VwX=pG+|b2?rH&0GJV8$P(gEDZTI@HUY;M|C?|}rq*>8{qcA!?S#RMF> z^V>xHgigzYby(T)L^QB?j?)r$(Ey4` zrjBFY4S5Dwf0Rr+Zj5Q{zL7UgVV5UK3~KW@@C23GbS(ZS@4rvmUZepfePBs#2DZN* zj6yhrF#+SniEvC8CrY&b$~I#QpAS6}Noo)FynCtM%*ESP%0x2k4Hq^8VtdpQ&m&Tg?PA z)Nu~ilF=O?6X)RO7;P=6-EzCDNS!&fOFKBYaujk5p4Jhd9h%Z`C+_3$_{6&V7l0+&ANu0qXIik z5tj3hssXWsDf7^G*PJz-gTANZl4FfuOg!huYf)Eevwb|1FhSOiN484WpP&b`$iY@H z6sU9Uu#=+VU^LCxY0gITW)UZTaX6#n?Orz~qR6mjI>55s`@>Q}Npql~(`8802h@ft z&B{_3+q<<(^QAQvnR z?4ASUZopb&+>T<{#hQkdxuF^E?xVY0HW#0uLRS4YpzpWi%;8Vf zYWha2Tk}z`{o5iI_)V-1Z=0LYvc+(^R*`GLCT%q7qCX4*K4pqvR^lVggQH~NV2tlc z8X2WHu=U32DOi7=rIC_SdWLhcK?B&p_u#Zq8~c?sH?^lvYm6trgYlDpnSCX3Ab3b@ z==+?GqYXa7=#v$$MyT{^U4&7GvHBqFSbIR|Ro$B^x>B*_%{pt$w(s%Aw%uGNI|;nf zo_jQ4c`49Y>&m?dl5>guY*#(tCnMYktr#smU}`fxcu-e)d;Nu)-GFf$A`ry8kPMv? z<1nyZWd)0PV;gTi`XO*5*eF{exG(qM1jcW0^4+@|911%KmVqSPE{%XA(HAdXEQ66R zj&959f0nz{L5h)PvS5#q=RhevK6jcd6QB9S*W<{9#j?A4Ae_qo;u@J|`xR*2 zF7*eE*@dPrFo>v{Wkm}UN1%nw~B`U%4Uc;q5xSv#HMOskT2&MM#NyACgW3UD-=s7 z+8gCZoQsWcbMoo3jXIhZ@;ZP;B9@Pf*&vHGEh#Ysf;Y`_HJ5z3uzH7S-3u^sAq3Ph zq*Zan!77YewMS~=~aIo8tCv)o>-GOce1Ij&ad94{H0;npPf1~AK_ z{Q4n>5N6IZIie>^7e{?qfzoCi)_4yf2&FB!t@Gt|BVkZ_ZH4%IR*qrWcSwDu4lP`M znYY>xzJLF2ar8$U+-U_RJaiqAz&|TFC4(kS(1g$3ql2hsrUI=2zk27LVeGx?aa=wu zD&{ytd2_q3paa-#rl!D?tsOUi zzrL8L5oiTlPl-#9Fi>EeOvX#Y=GCtZJ0v)6j%j{JLp`_viQEb9-(3hS?=*zlman#* z%j$+lZ_itXMD`g>^57R8ef)*TpEVY$XzZx7eGa?g|Hc%Bai?Bng?vS=u%N?a_HU}Z zCj)L)STY;Gv0alagEFaxkWq!}9i;Hv9XyG&P)uMgsrhV%;B5Jc^DVgfMY5nT%y0HQ z&y#cCv*Qxk$8=)?_*BT-J>|9COqyL#X(_RAvRsjW6GCG={0A3k?l%Fe(ta1Ot4)QK zor zMq4iZ5&LlWvB;Zm=Td-yWBNZ%c{fIr0a`UNZKc}*TyQ4uoqI7svWV+2MlJj7H<1+4T7ho?h1Y#? zx~REKIvz&NbpNwyc=81-Ho)TVICjCAOsi0}bq^DHy`a2yX^oE=LJ$zIIkx2SoCs0v z&!`EcmsVQ-x~Wc4JDtX02TJkMdwA=4MY)>u^RIAFju=y<7Vro_wj>ylUfUu5UV>}* z&5CS76ujLM6VL`Oz8h+3!9%Vju#tPu_MIV%;HBuweni1X_$gG(!~hv^ZM32^bO@fn z;S|qkLqL7vO7`c$%D1{P@u8s{O)vXZts$~Lt9A1w0Uoh*^A3UMW7=@>EVq%&IxfKP zbR7S9+2!CHxa!`m$(a}PgIos{;J_MOtEnL+MIuGbY5oY-G3-agLuePyq6me?(uow2|!Id=qU7?&rwS zY%ewPDNRa(!iE%N2weQ1Y=ibr0{)Y@jD9hB9@KCFH5Ju+8e=a!@C?yzxPFDX5_}Cd z9OeD77w0`qj=g=}VG}9VI5izCg*g{^lwL8?02zx!CyiL{*o-F^Z1rVjK4@*yaKTD zkhU61wNS7g8wS&FOw*L{0WlY(*NSqaN`j;_TLAbt7Yg9wG!TJtib$Js05)`Kf)W{3 z!j=Eb^ECi<*+?J$YzDyU_^k0ZGQ>mG9}0AR24}E|HL1H#=&)M%3$N;d4|iK|Y^F@y z240FQzqku%POZweT+bi#zgY%*k;0HK;@B7SN*QrM5rRfQ!t-wX{z`FT6&Z)A(qF0L zuegtIQtCy?0fRGdyI%{G*$L1O@$v6o_ibrBu>B>SRPWr12|O#Dp>!bt#uaR2AP5SZ zDK%$rF_>b!BJ_c_vyLN66sCdG;$%&Jw>`u4MDMqc0Pk;_VYPYm#}r-Pm1@q+@n+x4%74Bt6z0zWZ}$ zDo*KM1I^69iXRC?O@XG;_9@lpLM0O>U5^q?r-`RsT?wc)?>uux{{C1XjV|D}wa?hE z;6bfImIMgtl~5vA-@{?otz06e@G1@C0w<*?UYV>i-e6cM)+C{WG#m+dc6Jgb5|s58 zG^Jn9o75C&Lgr15z?VGLQY+lftR>=qa&T~cC3SGn6I*h3j1?d9-yiEvOnu(dNwB{s zMlFQw90Mx)Lwjn-3BLN7jLyc_K7!`p*tY|8>t(x!;@8%xN1@&W{TH7zI5>f`2j6Qv zIH2d@3P^C+@rYi?wrc6PL%R0MRNToAnRT|f6A~4aNXvrYpnro&CUGve$%Z|2ElfY zN>NAOlEv>0+#M}Y@0})qBA~SEJ-LG)NT9{)wh}QCh+A?uD&VkR!k3)gX7TAD*0~`& z7Fh=bI)Kk%;7%e{xUL4?*eV+wHF7;9Z{3TCX!~g<)2G{hpmIpz)KDzdwc!5#{)Qlm zPX>>ogb-Joo{k`UDbvPoz)*doz{Kt~%8%daFd{+XLk=4n3<3=fi$ZL1C3bxb)WY`U zR_(_fb>>2FX|&1s3Bv{-0A{*x?CvE@SU)L20_O$1Y{*4k~G@cgrk8f4-G!puohJ1v~m$7hoK*_Q_f9pJg<+#gJ}Dd)oyFQ=*B5n!-LAwZfc-3 zOh@EER-FY$jAYo!sql}ca_U7O(z8-YEYC+mXrIe~35>%L>2h?)aBCln@LOT&nb3SV zRA%`E=3#%!5U$m=8``*$qgVg^q3`nE^8pBFI^FZT!R|H1#?BX)={|TDRnA%%MS=PP z?xQ_H%8)Zp;6qet4UPM8$3^1BkXl#w*Xb6@&BNvQ=t~`ksRyWXDRkA&;S&XfG-Iynr;Rfr)gHQaFn9il>atDR(0c>Nq(bF7&1A#Af&xJqXO94xx zPlnp3`~p;57n=M0>?if!Wbv|rmYjUn%a^gpE-Keod8Zos7t4*|73dE~(0GnNARK}l z-@N%Dcv*ld`PD8rdd8tZ_bWg;?KNb$UA~etQ1ZFsr0Ve2>?XY-E_gd4QoH8>lJ{b( zNnLAU!O`CuDZ)Y1dk$%8VWDIo&idT+8=MVmz8<849R1VzP8V895yV_X(BBKunCiyyk-BU+^Ro zX&$4+E?6hKe@t#Cfp&MYQhZu#-tYQZDdG&4U2$mTd<#kkp(iw`ry0+NjUT3r?T{HE z#UAj9!~8Z9ttaNPPHwl)Cpvzt&r`cUnj2UtE8VQg;!y!6=w%O{EW?_gg_iTL25Ci@%qW2`Knzrr=n>+* zl4ErUj8E&HnFTsjmmX3i9F&a(B`-Jn+i7*!CbG z&cs7U#cA^1M8nVLdeoWod8SK~F{94)CDS+W)hfs7J632lS5&~!5j3?zyd>`zweZHn zSqBX}eO1-S2tD~N$G>vre>iXC!7x_cv~6fS@%b@XYnn%UcXF&!z8KeSR|mhFDN+F> zg@soZ-&DQ=r7P-GEK=T`iC_Q+X^jJKaY_*H)7I`O!V7j6I>LLRA@qH3Zc3$$zDT)i zMx=$pSm_e8BnP__p1JM9Qbw(0RwUd$4Of9gHL&MSHJZ;8ri)7+Z600pUN@LK0##ay z-Ubd=4pU4B!R1lyKfpQAMXGJoO>KmR0B_$aJD1B>+Z_M_f*6|$cjmF8q}%hKf^b!u(&85!ie;d*=)DnN zr`t`@*SC-AUihP?mF1bWQtw8!KY5BlmXu9p7wPsexP!+|z zn8OXO0oM8&B0k^uZhY@}s|Ka@y)-L)ubcY?+zG75XTGIrD6I%@wBNPSk{A;jDs>2@ z==0qf-mxpS#A7o<9c;PI=xRorPeNSl0s$|0djN1*Y8)W{*5)yUb~ScsmM!1vP3(B5 zh3k8F(vX@2Ejeu8{pHiD9s!wsiL7+WhV?C;n%PKd{?-U<+Pi-Inbt?3+FW#5)!T)>b)t6m^-AJ(bgBlPxfsmIE{1o*gOi{DV!sxz2(#In$$KY1`{P4+ z@On|anO0-Q%`7JN89tC>xc9qB$$c*3v5kl0d&=~;&-z;grMOW_O$p2%9|S}-GJ1M` zUp*f&^l`C}v0oU}Wh{S8u*d*P|C}>GoMZLjK=2WTry59xg22c2vzeAAUgJB9p6C5S z_~lcD2&ntrPdz~Jl^f!$AAcmH7RLAb@=UBvJ>}+(rg+tUY{ayaN=_JfK4@q10f&$F zD40o57QGqc*lYwYpC>sv+~T~hdz@BL)cixQ>41UNXe*Z0u{u)}@fvqVjU*l$CwUyG zq_c1kft$Iy8(GjY!yXKI*q5)=;4Vc=jDQRr;dv(FgPt(i>2Thf)`2G5-8g74(_^*+ z-*wt=a+FMHe3lfV4ek--M~f!O1j>D~4I&1-5{uD5Qa`!cnJGoJb^wCKwi40{;brm-)vMgV%8ndHIQme7waxTE#BjczDo>zQbr$gXp0bv^U^;93qHY{ljl= zlhOg5vbBl&V8cyh-HVgP{@9dW_w$U>CF#}VI;$M3b}A#<{7vXyhgQSJT5vRjuTM4y zkMr>+@!IHSgR?VTNOTwLw^fxIY8N6uZ%ck@>(zKbYXkI-hb7Cm$$=C9j+E~IbaM~U}(X7Ti$0yjdYlroqHtjcys*^*}zHjROv=kB-H*DwnX9hU0 z@M*^zHwO(C;!*U)rldI9-6`iX`?2?6@r(<-pbas=PnQM+1mihr&)`mfpybxS^BNF4 z)@9>Tu7p8%)NY{$?@4W!O}pQ2k^c60VWiSCzl5gSh9;0qE$May74PxuX59IaYAWE3 zc^D<-pVfR|8;T|~r-DhcdRNai&sdi(r@d`#XGpa;*rqQi%{uQ_^8v{zb#Ul+Md#6A z7t_o}L=5u0pZ}_8li_8*-nH{N*l1d=Gf!p&`rZ+#?+p3j@QH<`y3U9;m%jGX<(q$} zmMVGitn+HZ=lR`;bz7TNuY`oT?V+_rrZERb+EvFD|A(^rmko@chRWN&K6f1X zCXmwZCjULtI$)yVNh#&o<0{V;(`^&<#OlGrDi_yb5?tD2R_FC*>syUyxqIh_-UIjSMakbx9+QzhVEr@5K&3i&58}PY%1j-=0;slsOKQ3u2oL zY*eRL0Qwt3DzLoVE6J^kM!$N0!Kz2)F1uSyU#Y zqq9iXj&IMucSw>}Kl>RZM_C?IlW(avk`{Ar;v6; zeU!3#utoQ%=05It(7BNtvcX+YWIdgh5-E&il?i$7xG9ER-?#Pk>$;0YuRNde*4?^A z@OD{(e*mTa76yX)y!I!#MJ)_w#UNjI#3)#&Q4Svtde|V7>L)Rc8 z84KqQPh}8ugShmBYopu&8Vkw_P?JF}E<+~bf-+3c@K%dHYl{#-XKDk0acD=-9zq(b zuFv&_q4dP4%#othLHuL1qfx#Y9A%b=dI+mE@3}t68My5*+lQNM_A_1X3D#hxdcOm= zS#`3hgvu!!vLjud5%?~si>w1WUSH}3T=n4iR|SAspl}CJb!5iM+0YLI;a6vKgxDxg;`k%0coot&I~y3w^Nu*D>4BGiV5DvimVT)dw5rO;{vr& z>b?oYXP=k;6)Ns#LA6!FisSGeq1RN>^zv)K1*N#eQ`BX$W35_0R~679C-GMIdfzB{ z`fYX&=02_e%7OY|!QM0ZrO>wWl+}11P!(4&RK+ouU~GqnHUL$ad96v<6!EwWiMNl( zbvyP);!m%NX~R&!1bRgBC?cXT&Y<-A{!c=RQi2aH`BOl6RMLERMyRjcdFZ{c1H6Q` zc9!z6QgR8mSti{a{jQ&HALu5=pS^$?$YmyEpzG=w2{?rWD7oW<9y~atJl_^j3AmAI z!)h?J!BYfuVq#2h0DA6>P`$QwesY=tOb3tlH5B?*UjpD09?CFra`xZQFLN5b{YBNt z7m%Zyz1~Odpx%H!fAEb*UTpXmT;#9?iq*xL`bR}o~k5%Bv zS--Qznk0BM@Pq_!9HFmpG8Vz-FtW zORKlYn(+*+V5==5!sz*;u35oZ4nQw6CNs|_*UAKR)o@U_0=Gm!0gk?RpUd_h7;)f* z=;uB7fN%!`4B8^9fv~W3WQyqw*i*!Tlw@f$spEUW!PwJZE^%!*Mf#r{2l1PuiT#eY zH0_7K>Ka^18TuLi77N3cdxq@G8|JtD{`g0gH3y^^5dbRj9xF1|+JaSCiw6lBjr8>TQ-}S{j<_w~HLO~2ej1o4s_w;OY zd%K+;YCsNpv^5J)!$Wzv*V`0=39ca z$)7?K4XY7xp@H++~Mi;2Q&2NPe&vk=z=_zk`|D6 z41M{3e*&jTqoEFCmjN}*r2X+TKmx;umS%6?&Hl4Z4CU7t?!XRd_u0OCr21e>yW=0t z{{4zoI?#f@^n0xT4fDkCBr#!DtHd^RFeaXw%;pkMUSr0pL-~*QgFhq9;G|IGsNMVL zKu`Yl;O}3MM4dn4bOUmsG5Dp|l}y5qraue^1~b30QrPMR%+J>IF3>9Y?oy ztFtF;le!lhNdmztYs6yy)d~s^e3KB82+hI+rJ+Sq{^JY9ut0#@En9YOdbYsAAe$h< zT&;hv3?RLr2_Z!o-jcZL`_pKde{{=+4DL?`S+&&xZm&e|qznYaI+idu_CFf}%Fq^7>~ppP-q0|B|{*yA6v28KL+`0 zkylwUb1#rx36(FLcvHgeAdp+NY|!r7{Cvm1yTzie!bA*54fcA{9iBPzv}EhO2~$U) z92NenWx(Aegf3!(isK7HJ)iz#LdK}!{uGc^>)UIL;m3M<$FWcv-G7NPTp1kzN2l_q z%Rz79*_FrlKq6Z}-gFoHkGJAQK+bN-Cx$ST3B>Xp8(H=W?EhI}V4hvS?T7Po;wcF| zMN~n7LeA)+>@3uw_`ls6h(Of>N_o+7#GE{+Vy)z+BM)6QfdJst|D_Le;v7mdUAjaG z%=<@CD%Y)i)G{u3%G@x%blZXyv>FQB3lD=sSy@;}zkCVyn#-L#PI&ak;xZuM%+OU8 zXowBsfG&8u!J@&CHG$21mDTy{Bv(vISO1yAf&#vB_U(QY~!;z3l5)u{!l!s7hxt_X`jZi~i&wvJBR^(nIU zbdl_QI7lAFqAE6rp=L1(#EH5})`3%t@AhYW1-16NBvHn+s;a6_q5=fBzCP0;p{IWW z;(BE6-HYj4@~#2L&n%$AYHD>a9SX9zx}3F`>%sY(R14w{+-AXqJ=wN;r+?<~VmXfRG7{irYe^u1QHA=8l6i~Py$FMfu-kP5R6us)c zCBR+fzuUz*b_6mU+iUCU3VRK`eaqoC*F|qqx&0Xc)4k5D`5NgB%*@RA8#4hRzz(#C zln$w)@=}T8HFIfQ(bDYV^IqrFFSRED#?Hv>esSR6x(K;SY9kFpCu1?en7o|MwkdHdUb8l}OFc@eI%;Z>LfuQR3C{SeEU@bL7)pb{Q0FkV z?Dop2@AuC(<(*?rVNi5TOfWuK=Ig4ex~eIV=u_0{gJ9-ei7sfU$eL<LgtJ>9X??7nwel%*Y>mL*uSflkBp1NQrC|UjT@=S z*SJ6qVVE*z0o5QGh2zG@`ylR#Sz0c>Qz1&|Ngr=bnrC_Obh)xg+FEKtKM9Bg`&@J1 zmp0i#NjUA+XzhtP2t;j}u2(<-ZjbDPo3W}__-_w?)G@8z1Zi_drWM;kDhm)}(QXYx zkzj2`cE{FQwuIo2sB_N(qupm`88`0@C@`Iu+KGP$z0j?W0F8ifuEA^9Vc_0%6OL3O z_K6$*n+xD5vjj}|Q?=eG3T%hQJ?!3fprYYwV@chPH>?b;C#UEej2N$l6<#R|?RQO* zTvBvf?xRL9YjZv}rAhofK1x{@$3JO+G2{mVF+SUH9zRdSPQA0!-y z?&KJik%CxI06TbXeC3RK-_Z~CyXNtp-Pf080C6r5D^t z4UJo|nh^E1D}K8+`DGwEB;5!11-XDB7O~9VdxeV_SOo;c?synYzItH{58B%k^;)Qx zS2y&q_rGrhaEqcWSmOXpFe6L#8inu9@`q@*vpy$k688$d(`LRMmEYW3J&Ws8d6%P0 z`kNrk%w?j2cZcrU{$~pWbeuGD!YUtW4gyJmszU1Pm-q0PlSr}apx}@l8=owy1irrE z>ToT`0%_qhUf13>_9pIVGPb>LeJUIvg2EeLT@yDql^1_}pm_eA!UKdpC;2!zGQ_dK zwfK5-?)8zpb>f>c-<-Y2!O8gv+|4l9tSL6iao7hneiBaERwM?~(RnV}1C4ou5_=r> ze3pSi#PQv=z~JxEuu&v}3Ku%lcQlkN$>DoEfHfk+b{ZpzIhR2=8c-is^TRO_XC0CF zSK&y`BdU%a=u zB83OYrC+R5tE0NQGzrzLKYn^8jhowH<|-G(a3639IRt9%Iu}9o8%uO_Tlf$;tE%Xf zGC*(7(Tlmc=sk0pY6?!bD$$5v*=56L_l_o>eL{!7|r(b@DqG8RaX$efnh& z8uDuWPk}&;rWrHCT$9QZfw8)}venZG>=n2SN>2OE9guj7^hV6^-)V0XMEfvPC)6#F09f;cO9Sc#USEdgrgc0%4K?eKP~lk(2qSe1&<;@rPA z3dmJj`~-?#?8ICmjw=eqvVr-B#@k#JhD+lpOB*U$Z!Cv~%GOa(MoNcz8WN#eHyPr6 zezJiqK*p;-W-okhe3M3?^P|#<)zpuR4-OF_Lq2u0E;$rLpRa`RIi5?s`0M;D2s z^40srqD2=Yc+7i_DY#zf`&_q3mxQ61XIq!Da6dZM96ci8wuy6$A&xKwgK}QlEGU9l zqA?e^dzjm>CRB z-(y?jA3~{nPVWT*HC8Jih`EQR;$>WLNB3tL+lAD6@1&Cw(^EJ#+~2NI@!F`DV&bZ1 zzZg~71Q?ZA{2FT@lZdpA4)!+vK$ysg@7qq-5;+9Y)>drxvNfJW{lPU{B6pvoIp&e_ zdzUq}1o&>%X)g{{^kk3YUMj8x^WjP_?(tyib&&;3dT)62G7p&$z-J0T1mR0^XnN17 zU(@Kx$lbn1V$V$64&ppsY*Y!cqT+o1tQtwE>;|x{LWt;kdwrK?zhr^Uc9;@W2i4DW z=71yy1`E<5KldRQk|8ck>zI&13Ccs}2t>DBye{&3Lj}W= zk*0y)x9Kz${9QFDK6p;Rs(iG4i1)70!ZWBNGtQVD1D>eBt1F`#$~9&ZnZK60e&D|e z%zar|OHq$y)5juGehB<~@8X4z8y|&*6|u8)BkJl{G@2c@^BQLeQyttCFH=B6AzNO2 zRDR!*&e>&8i?dI!G)5*r0;Tab_Vu)%C3wbtSpfDIu;Sf&6{A8JuKl2)p$Dlz{XxoH zVX>V|`wrj<-^W(gF;9L#M5k*+?OW@Q^=TUgV$nXFg{cxjrDS(t^;o#uMziAA7P)mr z0wl}CCrj{{u5H~x-}W?iy`SznlLrv*2nu2Qw@S$S2fj+Mx0SiTIVy*J=KR&6s-=k) z0BJ3w=@rZnr6xibY=JObn-{R5j^yuPc1E{A&@DK(S)ZM`>!+FYqtG(wF2sn}vQ`C_bM4>LV&FLE*n z)Hk55WH<%YP7S?JH`9Rmrkn|B%Oe0L^o>rs2;(7of((rq=@~y7x8ehOw4(Ze!cxCz z&DF1D-b9?ZK1bYYTe{sbotT2e?#qerihjiF+n&rd4LC%qAy28uC;RA9z7=?|+YfDc z2|n#lye2BzFR?8+QcXnL(|Tad<})2G9@Kb;!e0%Bx-};eDRravZrZx>W*(!jUmV4% zYwuhNSKoxNOakUdspD%BlGyYqwT?$vF6P)L zQ!P}!x$&bv+$UF>6FSe_X(fl8f_6yrv1gVeue>ch2;V`~*DU*29ZPtqev3&j=g$sW zu+)$b&2r5LSta_{yo|#b4n2p*Q=GDULeiq^Cin?H51L<+vy%_rtu3g>ilrd?w|kZ0E+xnKw{zTS>3Me+)4m1F*2C`jGC7OvJ#|>ELPP zDB_yY|3Ib!g;~Z#U}MefpU6>$wE;_T3Rs66l~LBek!3s+%t$0OAqjji+TjBguKyS< z>uKh-Z5HS)kvW}-O|tIakjU>NfvFgOR|$#-35LrLmMiVXvI&tkqt=E1x&Ms<|M~?< zN4A;>@wrZS!ciZg;7{a&mNO2i=`nY;Iyn8saz zrCrV|{suf2zd!!<3vn0XkPVXHcfCkU4rOP5(EIokmOl)a-v4N`_UCuO6CK&7Sq;|^ zAcf@U_tqYrG8_~>@KBKgvh-Hiy!n4TG8T;Kr@+p8SqPjF*~^3gSKYyA_dkDjQ!z`| z$QV!+Lk>Jo$F(2Dg8uvtcuJdWH47R`fREu1Jx_R{t)qp7eq$*A^Y-&9NH}Ww^nUmYu>Acm9t;i=j5GKy@_U6t=FCW0=pp*aoBx2} z5mXo;(zqJfKOb=g>HaO`#7r0=*Q?!suJ?cxx-r(!g~>wo2cY^O90hm^pyqfSPzVby zcK^&MeY#$+6fv1NQ;#><z~e8yV@&e+9tKyOtgb9e*;@ZlP8v}8v%3#oAa!&oKq?IlgJvm; zZO`MJY7kV?R(?Ash9B?TIj4q{0ogyFkx8p2OItHDH3j)EyF5zB@gYf59-p21dR74R zFss!@wH7D*xvuBi5!Co&l<=zqJ6!bm~ET0e@bJ0SDt3vN7ty5WjS;X(fW zfbvx#gMqmtBA%`>Zk82Z{MULq$b%fbU%x8mNFoc)0uO@c(7n(;%gO;IDu{B|q9$PC z%aJ~Doq^o}7lI^5Bx@Sr7+{8pIpE7IrB4Dzv27*AqWf^Fsx}?|%HEziE4*c4;xMikxz1q28hi3X@*7m-#CN-VV=m!6N@HQEevB=HEYLuh zQD9R%>_BKB$ceG9%YNwaU8xS*9I-=H^|}5GQ7Oc~*;Kk1P}9Dk^CMYwBk`t3`?VQ~ z#y2(kvs&i@PLPQkOzus!-)79;)^ZKk6=TB?#7J}sT#Qy#{>uku6a>d(LLt`?#`i3t zENp0CtFNsG4w;!r2`qN@W+n%u9I~%u&h&sULGWeXv55(5-OsnAAxtY!;3WP~`MtI` zy4cQ&AXLZVkxrXmA1y4pV<EI7EBBWi%pu%IPq;tEuElTLQY>+itXp;*VjjtX$ScNsD7>k|`! z9+5s?cS$CJ43~bi1C1hOGOtE>^4EaID&VH1#O9T9kuxuyr++mtWQ2a6`Ba=2+I1pD zae;5>O*xqB!3eIky=F>m7wgLeY3 z3UD#=215*Kpj(tKFfsY(p7)fX8i+Vu1ynl1_^zTT3`q({E%fDIogW#y`m68d#mF1C zDwITCHvC$!F&P*fqDqEGcf`@cA@<{9XPeV@<4Bnx=XWjFEq=WOiGolNn#fV><)dHA z><1oOCivBbr|d`w2}M_lhy1ljV=Qn4#N)zjgM$$X)F2))L=2U;wJA!IJmMmzq`U}n z%fztA5{!~z1UpkTnFkB!Ox28?)&jOckyXrGcUBVsL*Gb;^0)wy;`@Nm!F32m!VHZG zuL=gPPjM-N%(znnCGW4?s?)Dr+kcp~XGTQF2}fAH`Cq#2n6F%L1< zgILaIZlbaJE%(-gU1mF&y_YMjT=qBbhSDcuF^}Pc22-4$i^_*1kw_GX@HGW5wRjPB zyi`V0=rXnLg&ZGG$gt~MUSFxjwHzwXZyOb{->tIo0$v}_LdzDbIC(&nEEjYdQ3SES z98$+uIs2Ekc6Q=)a|aYhs`eu4zl2lW`Sifs<#6}Iot1DArs!S-9;6HrSLc~N(|h#D ztKzdzCSID7F%IoG4!owOW;#6Da${>N1|&6mIdz3j$o3M5w}{viCxUT z^YyXVcpz)_yzfYbb=yl7F*h%hZ>du3Gy+yn0Slxlk9J+Xz}L4L^;3)e0to0{#$^~9 zH}N_@1om_E=8zC!?k;uB0u10^r+P1MB7J)s5MrUHN! zDGij~myKk&c2Ol!_yrJ;n|a3P9+#Ds8Q1!o9Pe32Rvr6@RQPNewytfiT++$r0HK^x zoW{Ev8-~dXQqE(<2@-qq748c!eh@%zcry_+Qi^-)mTmq<#WvO>00FR^8$FS3PL)dC zTbXE7YkI3O+7!<}s&MDd1wC3A!X9h~XCtpLRBCD}O)n!kIXBo2&o$pDDz&{VvHTMD z=8CJ;qUieLC(!Oz-<)QK0>Ag}c-8dd-QL_p*%M`{1@1w+eD@eoCSF12GZT>i+pVd6 zxd+d*a}7;E#mbw)_WE|mRa#kjq~zorT3N>&TMJUGz=z5a6H|6)?I(7j^SA^)5mBsF zo8e2T+ItfrAbeEs1&@hed#6_^KoatdRp!5YYi24ZaGbmL%m|F1dp{w&d%=Hi%DTTc zApfq`(Y{)XMU>7HcRPUHt2IP1a+NvChV|ljEmt%=l?u4N;qALTB)T^dHF{^}8-1oO zLhOD@?R zofXq_@!>sbFRJ&xhI#N@|dLIL;KrD z>R0aIh3jCU8yk&4YC&d-OV5D!c)EGeY3C^!yQ*T#i#AI%>Rek~`c#WvGl(L~aG6qk zEsJnBbE@f|NHKdMv3SMxBVc^MAxDH735Q{Eyr5mLBJg#y#IgrCiGg?`Pl2rbKy)zS zj0FcNA+)(6D_)T(?C^AT)wwyf7L}k0JTeH;dq2zjh;98c@_k!0 zCqs#fiHJ^^Uv2HOEad+1`pGe)Law3ERBFJqVx>oZJg@S=2&Yy}Y$L;aj$Ff%IPVS0 z`1=%>4L?VacVt}wYa#Rs$OztNN+xJ5auoqP%DFpKo3=N`hfMU;i3J#N( z!Zu84sZVGh@fwvXShejBA9|-qoNH0yb?3Wt zgHp0siE@^S_yO?m#O#bLFc(=Ju{jJ>oo^|+5$y6*@_uxVS*=Fb8zpNvM6cnzyGrs- z``rVY?R>RkwG;`I#?}5A~_`sWqAUCgC%7O0J4?F4SndW}1x!+8c)CZf5lU*%EFd#3T5}09D zFL=%i+dGG0K}2+J0}tN31>k4D@|ZJ-;oVzrxrL7di3N|n%c5<{*5PU;An28am1N!s zr*xR=colTf*j$?FG9fYa83Adqn7+z^N55F{_}0dXcuETaABJAd@%}Q^MI~$F)XjyC zBYB2qJF&&7uh03_PTW({aG?E7{~raslY29CU$avEnL%C!m5v@wEaZ&*{6UFkQ7^;n z;7+8|-?7(7rDAwqogxUZFGT;T^CCqoq{O^17~ z7T>NfP*ff7(_O5hmh@A207Lb%bdZpN6W;(#BY6wcg$th%%me(i!rXPr^kBAf3B3G3 z!NJ*cUVn1|7*ZsyC?H-qs@Nyh)s*2v^mi!DsLT^^%NcLq*$fY(#LLmk&wCgg{Brzg zfJVw=G2YAfApU_rE!3JIFzOeWZM1_wH+*n^4}|eA(?AH^tT=2!%A3tkzqDz+h@`Ll zx>DO7=DrAQxRyjGj~H5^?8FC(ALj~A*pyx*Xy9n`ZKoy1YpE1MFyzyBUat}fW@p;p z=X*Reu2{d|g|mOb_e+@N71wFr6Kt#mqy6rRRS#;SG3v{J zUUz2O8?QCZD;OIH9JqpXP_b>#Hw3*{*o}Jfh07cq)ox1zZ4m^(TJ2_}e3^U)sBR@F z4GXHGfkjV>hf=YOy@uaB2aUXVOqLa2)RDCTHrex(Yokv=f`FFKk7HFx(BK?MWasM6 zI^ocHFL@CJduAVQ_1W+J3|D(28=kp76=yLSaI!$9^jbMVGt*`aPkB!HbjiCf1#3|Dr&EB*0}dF z7==*~N!IN#^sH$c(}QIJJrcuxStnvdXrJz^d5|LF8UEuf&MMtvHG2Z|j}K-TrDZ;*k0SNsoUymy92GF{eo*UXek?G4(Zo2z|y5|8e3 ztc{1>&(O*`d&TmrcF`H;-=~1W0{r8qX^E{+>$-Q5^$GooTM5?aD{1y3?i5O_@0WHT zV?#w&ZO_=0D8DSu1me2H@0eA2b-MYL=%?U7w0IG;LducnMQ@wFPt65p^9wjOL4^Yb zZ3+f!D(tfOLxapipZ5bW)`-Y50D-BK-0;pXwL=VLOdTY;ZXg4nA)x&f5Okol(0dsu zL-(%Q6Kj{BpE;Z3R!D&2vwWxjp~_qFHa^7QGkaaDLMq_c&x$tin2dsLeaN-FNr|^a zNC{im_IqnpV<7?BwOvSn6}H)2qsrXDht$IX%(4&tdL_0x%JL^5$=lQJtab%Zrf(8N zK2ltJ!J~22ectU{)yt$S@W((II)`Rh!ji*!KUi2dKV^HLkQy+CmQ2htDw-x+|4int z!gg{qwnm8$>Y#-NI!`v*N82`05i|mdO9Sp~*55)Wqol09etq83dhGrY33B*ZE)Dct z2{@Km+uGviBDP^S$ZAnZ+>Qn5E_$m>!x`gB^+-6q`Hy3vNbKYOMqu_QqYSJF;wWzIi z5S7|exl&{2N@Y5ZfSNx{mEe_E0YQVVy(47{1Lh1F=A_OCSYgSo+;)V%db@xDuET3r#kLW-N+&a*C5?er_o!-JdRLWKAo6bw9R-e1(&W; z5b$mX6HHllWjM9HYz2WS!mh+yKlan8lR;P-pYeX{WP9Xb{lrvtD9p+$A{i~^Y3Fai$Fr@*;mF^hy=;%KE>mGniuLs(9-Tq+rgu~p{{7dlg`MKmD&@^ z4TH><@#;et`R@K&*O^kz=-m9#@_W_qSjX~UsDU}sV6my|dKWre3;jmsy^jw!wEGKg zCA0GKH{L*Vf_HMYHK8>%St{TY z_lDWv|QHPc{K}^!k)a>b!{UX3pL^zg_QWq34QZbFz3`!h2JT3GK+8iXfEayWUqvAFGB-9gIlr3vovG-K3b9 zk`2Pt;)X$Lsj7E1wf8JG3roe^%oe(5iFZI2rx)OAesQ)}^yFZB=}Fm2bhJ9Barq;I zF14WGql29m5S233DIfk7+{&juKc2nuKrG8Nj?=i(>y`q|g**Y=AHgU$+pdx=?2*5>u=*E8u2uj-}FFHEoGE6u$S zNUikwMw@3Oob0nRn=acrlZ(T&4-$ixC8h-v!PJOPjpBy~Nv<*&97-Vwtjpci*Di{u zV2KAf{qc$}!Ds6)K_1nEKSVKT1_Xb{0l<#1_(g(K{EPLMt@l%I<^l-@kLgMQXWl?% zW)jIM_HiFuLK12+r+~Z!;OH7pI$wFri;zqd;8v|k5^Hjb!gxId&wz_M+lb_?%&vD_ z_W1YT0e~uuOE5|p^F}M$xZwv~xsp^1*`>P4`FnIHgpx4|~ZG(O8a1;u~q?I7J z>@m@xqm`UQbc&9_)F@Zi*I%GeNVSV&L#O>CRwzX^;zGA{VW=oVZ0PuS?(^Uv`v`Wm z_r`vJUE#$8!wsBYuufM5ibrUAfiBwWTPPU-V8H*PN#Nk7_tNs3$*qNsam%^Bl_Px> z8nD|76MKO^*Ixl82nwozFH3l5Vk#Sk%Fj>T-QRDtE9NpOxMM~$ayk|i@)G{;0d81X zndhaYC&PaYkOm$T(>DcDIPlynL_oT6%&%TO(a`Xek%#dSq1l!VXai5@0RdauwS&+h zm_I;k&1BARIHy$cp&Qaz78k!CyyS%PHUzKF2-^}9ds@&U*BxCb=)V zV(yok9YCSW^kP72bU;9c2=&^@9|LY9A>T&-h77e3#oa3_`R^yazS6j+oDL2zBNC;` zT=~Z+j_J=(YCq0^>)JDG;Cr&%I#CC~_Y$6F z9n--99aRpNv9evcqA8nk%ePOib{%Osd-PiF^(!on_IucdtE z`3B5}^VC-gU^0XmS9z_asDS&;vx_P(A4S3oM%)OHbugrJ5`=C@I`C!akK}#iKk%vq zvX@%!@ID_CBVv51`2#C%r5r|DR*|sF`!h3*1p()(Ho-9l#)Q0tH&l@xJemKdE=ALm zsuUanbs)Qp<+mhJmkq1B7K;77y_*0802kxiYqZa8AY#k{%;7;);Hqs;YDn7Qk|~xX zJ>xR;(Os!n5_4z7NWtCf$gH}%m(L%WO&?|jbA1&(m8q+y$k1amDk7M%gAxY_1YwZy zQlD;5mnc z#!C!Y*G}KFL);Nrb3Cz>OCQi@kPu23dMbq|ae`T!wZSrztku+#Teo4TDR)4m6}g3; zYKddx;81Xx`pPC7PN}k~d4U!}G~z?{mn&y3Q4REoApP!7udT7-DYT_hZwLRi*M7aTJOSl#=$_!Em%QQ>-VGq;j$szmF>IM1&cu^K zsn}&nJalXGLE_Q*PUBx{2fdbzE@Nv$t;tc?KIE(oSbUOiY=&H37(SBhS}D zP=U6Zl@Drg_(YKU*GBs5X$5U2pEqS3o!-ZtT(uP|T~6C)AfIhz8v>;I06w&9@JlhB zYNuZ+iXM?f3L&sZh#+`Z#SR|iS>9Ak@{cBgnm)jWq%*AB8H59?@u2dxzxw#Q73Un3 z7O;ceJmsMb!aE3*1+S?5Uy9@JmR4n8?6{D|Qy#u_BP!VD=fn`#Q>n+C0l%7amV#l{ zqie76naY_9kaKNi2<1Q50Ixh5RSgG(@(=~EgTDiS#<}v~A1~ga-q&$M(nUA<;|Vpcy(Qb@`{o zee}e={Kph32*9@qAQl#&Rr0-5K`oM>vQV_uGNUTukMXG>&eKC2gkbPP=LO0B`61I1 zfKxM)?V1yQZ=Sz4M|2wM8Wzg5`X{I!gn&d?|C|9;VrNZ=nAlRZFD=b^ZGGLN?wzZ` zKV%jxrGz~2d${HaS%>{&7ch-|0;6W|TN8Evat^Pf$lVljdfPlB;UKaQNI{O8P~ zr7_6=W}WN6-`eJ{DM(>MLSqJfiAA!%W&KnTSJlDg`&{*ElJFm|vBv))0L=~`R8uSG z9y|5Fg^d~PP+N!pF{5yJBmu;owLf|VMGkWdv{NJdJ*r<*D4~GvgrT~wz|ldDe5w?G z^i~L%L&g(g9e+8c{{Pv|Lx}?{e$E>p?8v!Bf=E9~M;yHQ^R6j_!{M`zR8gzn_7<+KGSvY^@M6 ztY}a%eQrLYkRYWG-X}@TgrYxI8=2b6Fe53SOz{6Xnv0q+nyBs5r~du52mkBW2>iljEq=bIEy0PD@ zH}p>ajo6pEI*th^IGl!qS){T2!AR3sgXS1GKQo8F?hM_DxR9?nQiCxWt!0@==pOD& zrwU2F_DTsxlxo$}c>Hv%A^@bSgsP3>-z|f$TUW?)6J&X7(cO`VTywd!c40t=Miv3)sK-zo_k=i<%&k3ZpZsOg!G~|?`=kEKEVcX z|KdhO2noW%!cquX)E$9NIyxCNe1<9ngoLK}Ufa%US3PkzVW)g;fWUy~X-JawYvEh- zJL7hJdE6a39(b6p&x&^@U#l`PAe68E7LW2VjN!wc=5oUK(n(7jQTtjXI>Hzl-r`vr zVm9swIc43eyRh8EhZXzZijy4)pqyTF@15@AFojT!G|jA$%huf)+?Ov;+s<=v%>)8o zBS*=I9u=my#U>c}K=YTu;t@tsjMiIj=?h+7UK8JcRyuJvJKa-`_PIyk^Uan8QTSML zK>p?2W(tx&!)ffrGR+tKoZP(15rGC3<_)17`EYj8gV>65kR}n5BL9Z%yUpBANZGKP ztlR1%0%u+rOv=Kv5} z{zOI1X5M-_jXJ7-GA;y>$$w4BRA$qYbt|~wp1h*H{d;=F#1xdiWsx@MJ~YoAkUCTE zH*5z=n*jNtTX<3Fdl8ra22 zRU`4-&s>o8RtcfU(ht`z7KilKNUupsQqnLXbG7L72719~%9$GDs_l6r7`u^D$-*8S zuFdhv+Cs@E@3k4)hS14lfKHUp_7!P5&kshAmb80D@ZA3TjaRR<_7U1TTff40>S^`0 zR};@{7c#CVWdU|*5O{hE zcjQ#(ciH*lh>n`P>?^=x8&{{L(Q|$peroEGizTl8x$4X2ZE7ACg+<=Xa}PZ9P(D~J z7C=0W8JxEUbg2TBghzaq9}x30r&hNncgMe+&})+3Zw?OUHB#vj9bRair9~`^Q(3hf z9pygxskqA9LzgrQv&oL#IReXrDSMo2$|F~gRwN+dw+=U%c}?hMsH7q zhJoQij97l?CqGSZw>yox@r5qaE{lbxOrbEodgya+I}do^VWqua&p~M5efLbeC8jCXaVO6=y->MgT3 zY)ojQnrbu39mqr)gOJ={j`&A|mqc*l_1oP&Ze5nd;LVABGEgti;FUBk$D-q`2Jp?mWnt*|Fy?KB zLgq#TDJds`-CUMzYtTZ1E*PMx1At?{<~a7XIm-Lb5#G$aUE~|3k!agvvcI|XQ_ybs3UmR`Z^nxp z%{0Qsn>2-AgM<0m3Wv#%m$cjp#>Qya zqwwPcg=Q^hy?+UOD&8o;4F}N+d0{5kUhfd(I^D?Ar+2W~J)6AI<4X}^3Q`2ojkiEv zWg22$PSq1)eBEBJxmoX$=ZYP5A*Y}a!3}E2|D5exDYWRgyEPGWBNHe#JSnX0gU_6; z01s7bh!t0UB7zjxJRi;n&YLdy{ECBs-7tYr+-J8ghQlIOH^rn~_V&CcCZ{38aiXRM zXr3G#;GJ?x;Q{gIQq&7t<0Z}4GgJJ(9qjY!G>=9f$7+<@lie;fiQl@Aep#lk@GaZ< z^E#q-hSqX~Qy#Ndp1+J5>4S|cM6c|Jw{Q8YI1&U{1;H*mr@$Joc&l_nEZ=GIhxH!A zH?wXIJv-x;*Q*Chda6p)%R;N3a?3mRRu?$CFLrxjd6?MheR!Zrju8CcifG zRqLvq@axbui^Fw)cNnI(lI!;LZ0_y|gagiEf~nW*bk&V(zv z6f7W#VtYl$E5LLHY+hpC_oJ-S)XRUiI^bA_jXahzEwQHO@W`mAxduwMn?|@3Wp!hWe3FsCw@UClv}# zUy2>UUK)bA#2Mj3xh}?@7u@!AZ@yOy4KIA~)2t|=z5VDGG+I!tEArlNYY96xH>GRD zn!7rZ+7u(e)J;Z=pyNzF`?I87d_7+7)(rM{g(9=u@zD?7)YWFH(U`FgVK@3|{v zX?dRQTVn-2nGNFg{GHl#<_iv(bSmjzY+C7t`Eewd`#A%xi_KclLRJ&N>p=KG_`t(7*Ru^w%S7@Xy)?bT_^!+bMXJyD7)K-uc0slBFL z{XH|O&)>|*h^ni(Khzkhi8GcoZ@u(zw01sHx+zvXn_g<6(J6c0X>L_$s-Vu%!uuVM zUTJ&vU|ZYBkqw;XQRN6L(ucBH+WEY7ZBk*PD+_M0wzDcnAa=IYZwV)X0~UX z$P0sy#gOywIz#;pv|5eOaM83Yt=oW$C+{9m+^UnKzh0d6Izb)aSqzsnsn*7 zL97~68hk5TtM2rCsJc`TWN9l(ZD(MQFiCNBAfq6^n;_duPoNAVJhUf5Oe(!S;)L>2s2wl50@>Ak z+;h9T+6aqEW|VA7V=PaS#q#GHVBB8om)n1EjNV?`NgyKHj9I(~ZGI-kj~#pZ4s>v! zU%E>#ViiEfcE*E09Z07Tfp$KU4QeFp{0)^fm919C&pl@S#q?pUW+igQBXM&`8%7ewXN;8#E1zvOoE(;9WcT-WJtGx zw(aKDq>)Pc%1n>7#=H6yv#YWosTdn_agRAhP}z4F0dqykZ&_s|8~R}D6z1n$XA z_hj3t?5wqkWOT~R;PdMmM!ehTFw!ey4YqlA-g3QZP?uJX0Pla4S`i6D=(*QI+{aZC z_OkohGBwj%c2|4#3{tNrJvsMCVVVn}VCYuQ<6-`f79jNdr$aFun@>ru)gMsTlHG|P zG&>7^Hce~-WfORtSmxDLtHelwd*w$?iFO9ewExu3-Hx1UJihI)|B;-xh4j>r*Z!_K zybFC3H8^MKZHvoAoqmD7uG6WwW(6NWp#Af%C#J|sZCm(2^0^);u!N-3t#SAHzsUCe zUb*XI%6#ZwSKfWzT2jMpOcurK9U^3Yfm7$bf(l2ULC$I>hHkYh*gh$VzWMwh#@*#* z9!X+My!k|PU$c2fM~YvIx%_JeCzJg>Orht_(EHm)*_Rbdtkff0F}Y~u+RHE+#_4R9 z7rhkrt7$leHeBJfKLv#(oQ;lAhZvRHtF6zz*sGa*c?wm#Yd}ljv$ImPQ`M!57^Au< zp^0JDm>*M(y8Zp0-5)bYZ9Hf4K1F5U%4W>&N@Xnc_kX<4%W~Y4ZSFeMN-8tDji-+Z40<3le9c=c`ysL74oaVV`RXTH^r$J2;(KuT2#p*kal-aj^isBt zHApw=*{$(oJ-oNZIWn&&RZo+8?rn|g(4hJDQX~4Krx8U)RHFK)g*>@YQ(qbD5A>c~ zmrUyIE8IlBkDC`i%}^h5^7qI$)D2jW5PEVsArHU!&|q3-;BKGeX0>za!?F6{9Aq@c z?rT$f5^Hngk$y$HuC(V{8wb5D`pXt9eB3*d#w=aU||#BI`NFSErCE z%;IDMHj?teKq+6-6JnQ82ayhIEBeAotTe%N9-^gRf!&`IK@WPAjshlb4Z@PsvMR+z={EwxKgLo^jWaJ6)3Y?x) zgp8hL-OOYMc~Mw;LY`fK%j`ExIYAx1mW*)hFTq=|=tL$XG6s8lJ7UzLm^H1o$e@p% z8D!Ko$0$f0qy6ZLx<_82(N|^P)ObpV+11dW$lhhJ0cj~kml{RJ@%~j8pTOdRlNk%3 zJ!)1+D77`Jm<)Tpv&;Bp=K#TRIo94<(E0+$g6|hEExG1x&TD?=WR4+b@W>mNh}`Q* zXG6GgtY+wRuXwy;Idr!Jg;!}re~(%X2~y>3D@44XQwF6N#e`EaG{H(Y`T13(;3JLs zZ2{ui+smyI7f?Pcv!RY-#S8%PTR%ZNOdRPevGy}aBbw^cXKeLaOr%WDqvb|2Zlq+n za%IKo?aKSTu%0|$fXGbJZQ42aZe3Xl}ufyl43(3y9Hzqv96uN>FSQ=%6^vEoq0WY1nQk7 zc)g|hap;_7=xBBIp&VjSl_TaEBI!I!v<3ZnTB!A!gad}EHX74ZJ7hO_W8&X950`w( zSND_;4B%({O37FmiLlJCZ{r!qcQ@P{;CoEN^NUqb?dd=AYql%59Pqh11<7fI`K;%a z-ey_saRKkIURy$)7k3*I-W%gZ-|0r~7TEu|hrqkEbboh2(jmTo;)K!c(Us$#3B&my zb>#O>Q(svlD<`=K!>zzC$Dnch4IBx9W!45055Ir(Zm#xR>)5fMAC$h9?r~|~L+Eyi zm1@q$l(+}BTSgv> zdmv^|2qa)5R9#jv4kHUiWR$K|UJJdIB)Tp89}YP*^_MgOsIU{ICYEPk!d)FrE=R4A$j(+rw2#~kUBSx9Scidq@Ax*SJJs5nz~dGCtIl9xiC_?uh!{5~^j^pV>MY4bx>I1q7*8JyZ1u#-=B zIPN?nS)zK$T0@>x$v)_d(iD;mxjjGTGp(c7E=vSM&#+GV1Km~g^^I~pB`uUrp-E;` zVB+%;5XGMbC{N_m5^qdk7_|1%9)olIIgf&p>DWC`KQ)GNj>KG_1l3{xtPL(}t-N9a z{Z2pM&xyjP@vSw39$r-<06H1kOthWgGi?2J3zmAnX7xC9-=(GY+K;_y@wx#T3H$^7 z#ls%}nP7&%llfys@0=RK^KOZ;W=8i?=qEt?7^Qu?e>j$4D&QF%Y9a z?atn?srD4J?7Rr+7`)+|19s9)pQt7UQmAcIGu!#njl9Ia5^14Bt`ag)@UKq48kTnB zT^$i2Jt;E74NJE--Xg?hc>4a6i$jjxe(_jessj&<4+r^F`{wd^c6}gYObgeo-tQUC zZfi-O0cL9ELM-oeas1l8o%H3`50VeMR!SE8+SF|4nOX7g-H>^lSD@DAhZK1_c zASdYjce*EN6|IoOIKsG;q0FxAw9!FmNO2CkFEvEt(iNDNN*$t$s)atY?=(`UIBI(J ztl-qJd}qAzTH2n>%ETP#*x8rYa7M7@syBg`!t~eX3p3JHC^%Jy9y`zJej!58j+NJ5 znVE4tqicCpHGR~^)*Ke4Z(IZxm=P+eN9te@VP=*0Vo#Cxwq4P+`j*1V;h0zHtd-!N zmziV8h~3^L=d&olTuw8T!T0hF2_ffiB$xl%#;cnN%UxkbRvQWfFLn@kg?B{wY_v=$aQN91L!Ml^72%Fzhl-*sU;l8Dq7<%i0RDO zJ_xl5!+G~d{;eEMB1Z)etXD!K?0za$j=D;3&R|+Hg3AYH`@ADLmO1HBi`|;)jo%7V z0IJLd#n;-;xhmI{v;(0PhrT>wk#3Oh=lei4x>#^!_ov<~@Ip0QN@ckC9t%+Lw`Vrn z#YXreQ_{Bm!VzG1Xo8yFmH=r=V`Ufiw;rj|HuRr+(zube4yPmK&pnLf%V@q)(HqZB+GU_Wu^je#f<1{>E)A7b{nl0TKj?;(D zzC2Ton3o!-B5yKIdpG~B;Fj}3spRo?dNuSzS?o_}&(NDkHKDOGn1QnQj$Wrg-1==! zUqeD4v~*rU5_|T`Z2H)+d+3($4pu;<-<$YmROLK$qyv}k`k?F`2z;fKgZVvYE;`RX zDpJEx`t7ZDT^6XqjKKTKY5n1{!4EY*YrPZGU908V`7UFx#SA0h@WbJH*k|u9E-