1 Commits

Author SHA1 Message Date
Your Name b1bd8b1f82 Hotfix v1.3.1: Add missing SystemSettings columns
Fix production database schema mismatch where downloadSource and
primaryFailureFallback columns were missing from SystemSettings table.

Root cause: Migration gap between squashed init migration and production
database setup timing.

Fix: Idempotent migration adds both columns if they don't exist:
- downloadSource TEXT NOT NULL DEFAULT 'soulseek'
- primaryFailureFallback TEXT NOT NULL DEFAULT 'none'
2026-01-06 20:34:50 -06:00
21 changed files with 114 additions and 1045 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Release ${{ github.ref_name }} name: Build and Publish Docker Image
on: on:
push: push:
-279
View File
@@ -1,279 +0,0 @@
# ==============================================================================
# .rooignore - Custom for Lidify (Based on Context Analysis)
# ==============================================================================
# Created: 2026-01-09
# Current token usage: ~177,000 tokens per request
# Target: ~60,000-80,000 tokens per request (60% reduction)
# Expected savings: $335-395/month
# ==============================================================================
# ==============================================================================
# TEST ARTIFACTS - BIGGEST BLOAT (1.4MB found in your project)
# ==============================================================================
# Playwright test reports and results - these are generated artifacts
playwright-report/
test-results/
frontend/playwright-report/
frontend/test-results/
# Test files themselves
_.test.ts
_.test.tsx
_.test.js
_.test.jsx
_.spec.ts
_.spec.tsx
_.spec.js
_.spec.jsx
**/**tests**/
**/tests/
# ==============================================================================
# CONTEXT_PORTAL - Your RAG System (1MB of vector DB data)
# ==============================================================================
# This is YOUR context portal - Roo Code doesn't need to read it!
context_portal/
context_portal/conport_vector_data/
context_portal/context.db
\*.sqlite3
# ==============================================================================
# BUILD ARTIFACTS & CACHES (.next/ = 24MB)
# ==============================================================================
.next/
dist/
build/
out/
\*.tsbuildinfo
.turbo/
# ==============================================================================
# DEPENDENCIES - Never needed (429M backend + 729M frontend)
# ==============================================================================
node_modules/
.pnp
.pnp.js
.yarn/
# Lock files (488KB total)
package-lock.json
yarn.lock
pnpm-lock.yaml
**/node_modules/**/yarn.lock
# ==============================================================================
# IMAGES & MEDIA - (3MB+ of screenshots)
# ==============================================================================
# All image formats
_.png
_.jpg
_.jpeg
_.gif
_.webp
_.svg
_.ico
_.bmp
# Specifically your screenshot directories
assets/screenshots/
frontend/assets/splash.png
frontend/assets/splash-dark.png
# ==============================================================================
# DOCS - Large deployment doc (312KB)
# ==============================================================================
# Keep README.md, CONTRIBUTING.md, CHANGELOG.md
# Exclude large pending deploy docs
docs/PENDING_DEPLOY-1.md
# ==============================================================================
# DATABASE MIGRATIONS - Keep recent, exclude none (all are 2025+)
# ==============================================================================
# Your migrations are all from 2025/2026, so keep them all
# If you add older migrations later:
# backend/prisma/migrations/2024\*/
# ==============================================================================
# VERSION CONTROL
# ==============================================================================
.git/
.gitignore
.gitattributes
# ==============================================================================
# SOULARR - External project (separate tool)
# ==============================================================================
# If this is a separate tool/subproject, exclude it
soularr/
# ==============================================================================
# IDE & EDITOR
# ==============================================================================
.vscode/
.idea/
\*.sublime-workspace
.DS_Store
desktop.ini
# Roo-specific directories (don't need to analyze Roo's own metadata)
.roo/
.claude/
# ==============================================================================
# LOGS & TEMP
# ==============================================================================
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
_.tmp
_.temp
tmp/
temp/
# ==============================================================================
# ENVIRONMENT FILES
# ==============================================================================
.env.local
.env.\*.local
.env.production
.env.development
# ==============================================================================
# DOCKER (Keep these - you modify them)
# ==============================================================================
# Keeping Docker files as you have 8 docker-compose files
# Uncomment if you rarely modify:
# Dockerfile
# docker-compose\*.yml
# ==============================================================================
# GITHUB WORKFLOWS
# ==============================================================================
.github/workflows/
# ==============================================================================
# PYTHON CACHE (from services/audio-analyzer)
# ==============================================================================
**pycache**/
_.pyc
_.pyo
\*.pyd
.Python
# ==============================================================================
# VERIFICATION CHECKLIST
# ==============================================================================
# After adding this file:
#
# 1. Restart Roo Code
# 2. Make a simple request (e.g., "explain backend/src/routes/library.ts")
# 3. Check OpenRouter activity: https://openrouter.ai/activity
# 4. Verify token count: Should be ~60K-80K (down from 177K)
#
# If still high:
# - Check if node_modules/ is truly excluded
# - Verify .next/ is excluded
# - Check if test files are still being loaded
#
# If too aggressive (AI can't find files):
# - Remove specific exclusions one at a time
# - Start by uncommenting Docker files
# - Then uncomment docs if needed
#
# Expected cost per request:
# - Before: $0.141 (177K tokens)
# - After: $0.055-0.070 (60-80K tokens)
# - Savings: 50-60% reduction
# ==============================================================================
-71
View File
@@ -5,77 +5,6 @@ All notable changes to Lidify will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.3] - 2025-01-07
Bug fix patch release addressing 6 P1 critical issues and 2 P2 quality-of-life improvements.
### Fixed
#### Critical (P1)
- **Docker:** PostgreSQL/Redis bind mount permission errors on Linux hosts ([#59](https://github.com/Chevron7Locked/lidify/issues/59)) - @arsaboo via [#62](https://github.com/Chevron7Locked/lidify/pull/62)
- **Audio Analyzer:** Memory consumption/OOM crashes with large libraries ([#21](https://github.com/Chevron7Locked/lidify/issues/21), [#26](https://github.com/Chevron7Locked/lidify/issues/26)) - @rustyricky via [#53](https://github.com/Chevron7Locked/lidify/pull/53)
- **LastFM:** ".map is not a function" crashes with obscure artists ([#37](https://github.com/Chevron7Locked/lidify/issues/37)) - @RustyJonez via [#39](https://github.com/Chevron7Locked/lidify/pull/39)
- **Wikidata:** 403 Forbidden errors from missing User-Agent header ([#57](https://github.com/Chevron7Locked/lidify/issues/57))
- **Downloads:** Singles directory creation race conditions ([#58](https://github.com/Chevron7Locked/lidify/issues/58))
- **Firefox:** FLAC playback stopping at ~4:34 mark on large files ([#42](https://github.com/Chevron7Locked/lidify/issues/42), [#17](https://github.com/Chevron7Locked/lidify/issues/17))
#### Quality of Life (P2)
- **Desktop UI:** Added missing "Releases" link to desktop sidebar navigation ([#41](https://github.com/Chevron7Locked/lidify/issues/41))
- **iPhone:** Dynamic Island/notch overlapping TopBar buttons ([#54](https://github.com/Chevron7Locked/lidify/issues/54))
### Technical Details
- **Docker Permissions (#62):** Creates `/data/postgres` and `/data/redis` directories with proper ownership; validates write permissions at startup using `gosu <user> test -w`
- **Audio Analyzer Memory (#53):** TensorFlow GPU memory growth enabled; `MAX_ANALYZE_SECONDS` configurable (default 90s); explicit garbage collection in finally blocks
- **LastFM Normalization (#39):** `normalizeToArray()` utility wraps single-object API responses; protects 5 locations in artist discovery endpoints
- **Wikidata User-Agent (#57):** All 4 API endpoints now use configured axios client with proper User-Agent header
- **Singles Directory (#58):** Replaced TOCTOU `existsSync()`+`mkdirSync()` pattern with idempotent `mkdir({recursive: true})`
- **Firefox FLAC (#42):** Replaced Express `res.sendFile()` with manual range request handling via `fs.createReadStream()` with proper `Content-Range` headers
- **Desktop Releases (#41):** Single-line addition to Sidebar.tsx navigation array
- **iPhone Safe Area (#54):** TopBar and AuthenticatedLayout use `env(safe-area-inset-top)` CSS environment variable
### Deferred to Future Release
- **PR #49** - Playlist visibility toggle (needs PR review)
- **PR #47** - Mood bucket tags (already implemented, verify and close)
- **PR #36** - Docker --user flag (needs security review)
### Contributors
Thanks to everyone who contributed to this release:
- @arsaboo - Docker bind mount permissions fix ([#62](https://github.com/Chevron7Locked/lidify/pull/62))
- @rustyricky - Audio analyzer memory limits ([#53](https://github.com/Chevron7Locked/lidify/pull/53))
- @RustyJonez - LastFM array normalization ([#39](https://github.com/Chevron7Locked/lidify/pull/39))
- @tombatossals - Testing and validation
---
## [1.3.2] - 2025-01-07
### Fixed
- Mobile scrolling blocked by pull-to-refresh component
- Pull-to-refresh component temporarily disabled (will be properly fixed in v1.4)
### Technical Details
- Root cause: CSS flex chain break (`h-full`) and touch event interference
- Implemented early return to bypass problematic wrapper while preserving child rendering
- TODO: Re-enable in v1.4 with proper CSS fix (`flex-1 flex flex-col min-h-0`)
## [1.3.1] - 2025-01-07
### Fixed
- Production database schema mismatch causing SystemSettings endpoints to fail
- Added missing `downloadSource` and `primaryFailureFallback` columns to SystemSettings table
### Database Migrations
- `20260107000000_add_download_source_columns` - Idempotent migration adds missing columns with defaults
### Technical Details
- Root cause: Migration gap between squashed init migration and production database setup
- Uses PostgreSQL IF NOT EXISTS pattern for safe deployment across all environments
- Default values: `downloadSource='soulseek'`, `primaryFailureFallback='none'`
## [1.3.0] - 2026-01-06 ## [1.3.0] - 2026-01-06
### Added ### Added
+5 -29
View File
@@ -193,7 +193,6 @@ priority=10
[program:redis] [program:redis]
command=/usr/bin/redis-server --dir /data/redis --appendonly yes command=/usr/bin/redis-server --dir /data/redis --appendonly yes
user=redis
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
@@ -236,7 +235,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
environment=DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify",REDIS_URL="redis://localhost:6379",MUSIC_PATH="/music",BATCH_SIZE="10",SLEEP_INTERVAL="5",MAX_ANALYZE_SECONDS="90" environment=DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify",REDIS_URL="redis://localhost:6379",MUSIC_PATH="/music",BATCH_SIZE="10",SLEEP_INTERVAL="5"
priority=50 priority=50
EOF EOF
@@ -275,33 +274,10 @@ if [ -z "$PG_BIN" ]; then
fi fi
echo "Using PostgreSQL from: $PG_BIN" echo "Using PostgreSQL from: $PG_BIN"
# Prepare data directories (bind-mount safe) # Fix permissions on data directories (may have different UID from previous container)
echo "Preparing data directories..." echo "Fixing data directory permissions..."
mkdir -p /data/postgres /data/redis /run/postgresql chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true
chmod 700 /data/postgres 2>/dev/null || true
if id postgres >/dev/null 2>&1; then
chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true
chmod 700 /data/postgres 2>/dev/null || true
if ! gosu postgres test -w /data/postgres; then
POSTGRES_UID=$(id -u postgres)
POSTGRES_GID=$(id -g postgres)
echo "ERROR: /data/postgres is not writable by postgres (${POSTGRES_UID}:${POSTGRES_GID})."
echo "If you bind-mount /data, ensure the host path is writable by that UID/GID."
exit 1
fi
fi
if id redis >/dev/null 2>&1; then
chown -R redis:redis /data/redis 2>/dev/null || true
chmod 700 /data/redis 2>/dev/null || true
if ! gosu redis test -w /data/redis; then
REDIS_UID=$(id -u redis)
REDIS_GID=$(id -g redis)
echo "ERROR: /data/redis is not writable by redis (${REDIS_UID}:${REDIS_GID})."
echo "If you bind-mount /data, ensure the host path is writable by that UID/GID."
exit 1
fi
fi
# Clean up stale PID file if exists # Clean up stale PID file if exists
rm -f /data/postgres/postmaster.pid 2>/dev/null || true rm -f /data/postgres/postmaster.pid 2>/dev/null || true
+12 -75
View File
@@ -14,7 +14,7 @@ Lidify is built for music lovers who want the convenience of streaming services
## A Note on Native Apps ## A Note on Native Apps
Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now. I got a little and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now.
Thanks for your patience while I work through this. Thanks for your patience while I work through this.
@@ -45,7 +45,6 @@ Thanks for your patience while I work through this.
- **Stream your library** - FLAC, MP3, AAC, OGG, and other common formats work out of the box - **Stream your library** - FLAC, MP3, AAC, OGG, and other common formats work out of the box
- **Automatic cataloging** - Lidify scans your library and enriches it with metadata from MusicBrainz and Last.fm - **Automatic cataloging** - Lidify scans your library and enriches it with metadata from MusicBrainz and Last.fm
- **Audio transcoding** - Stream at original quality or transcode on-the-fly (320kbps, 192kbps, or 128kbps) - **Audio transcoding** - Stream at original quality or transcode on-the-fly (320kbps, 192kbps, or 128kbps)
- **Ultra-wide support** - Library grid scales up to 8 columns on large displays
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-library.png" alt="Library View" width="800"> <img src="assets/screenshots/desktop-library.png" alt="Library View" width="800">
@@ -67,8 +66,6 @@ Thanks for your patience while I work through this.
- Dynamic genre and decade stations generated from your library - Dynamic genre and decade stations generated from your library
- **Discover Weekly** - Weekly playlists of new music tailored to your listening habits (requires Lidarr) - **Discover Weekly** - Weekly playlists of new music tailored to your listening habits (requires Lidarr)
- **Artist recommendations** - Find similar artists based on what you already love - **Artist recommendations** - Find similar artists based on what you already love
- **Artist name resolution** - Smart alias lookup via Last.fm (e.g., "of mice" → "Of Mice & Men")
- **Discography sorting** - Sort artist albums by year or date added
- **Deezer previews** - Preview tracks you don't own before adding them to your library - **Deezer previews** - Preview tracks you don't own before adding them to your library
- **Vibe matching** - Find tracks that match your current mood (see [The Vibe System](#the-vibe-system)) - **Vibe matching** - Find tracks that match your current mood (see [The Vibe System](#the-vibe-system))
@@ -77,7 +74,6 @@ Thanks for your patience while I work through this.
- **Subscribe via RSS** - Search iTunes for podcasts and subscribe directly - **Subscribe via RSS** - Search iTunes for podcasts and subscribe directly
- **Track progress** - Pick up where you left off across devices - **Track progress** - Pick up where you left off across devices
- **Episode management** - Browse episodes, mark as played, and manage your subscriptions - **Episode management** - Browse episodes, mark as played, and manage your subscriptions
- **Mobile skip buttons** - Jump ±30 seconds on mobile for easy navigation
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-podcasts.png" alt="Podcasts" width="800"> <img src="assets/screenshots/desktop-podcasts.png" alt="Podcasts" width="800">
@@ -88,7 +84,6 @@ Thanks for your patience while I work through this.
- **Audiobookshelf integration** - Connect your existing Audiobookshelf instance - **Audiobookshelf integration** - Connect your existing Audiobookshelf instance
- **Unified experience** - Browse and listen to audiobooks alongside your music - **Unified experience** - Browse and listen to audiobooks alongside your music
- **Progress sync** - Your listening position syncs with Audiobookshelf - **Progress sync** - Your listening position syncs with Audiobookshelf
- **Mobile skip buttons** - Jump ±30 seconds on mobile for easy chapter navigation
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-audiobooks.png" alt="Audiobooks" width="800"> <img src="assets/screenshots/desktop-audiobooks.png" alt="Audiobooks" width="800">
@@ -177,7 +172,7 @@ Lidify works as a PWA on mobile devices, giving you a native app-like experience
- Full streaming functionality - Full streaming functionality
- Background audio playback - Background audio playback
- Lock screen and notification media controls (iOS Control Center and Android notifications) - Lock screen / notification media controls (via Media Session API)
- Offline caching for faster loads - Offline caching for faster loads
- Installable icon on home screen - Installable icon on home screen
@@ -275,16 +270,6 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
### Bind-mounting `/data` on Linux
Named volumes are recommended. If you bind-mount `/data`, make sure required subdirectories exist and are writable by the container service users.
```bash
mkdir -p /path/to/lidify-data/postgres /path/to/lidify-data/redis
```
If startup logs report a permission error, `chown` the host path to the UID/GID shown in the logs (for example, the postgres user).
--- ---
Lidify will begin scanning your music library automatically. Depending on the size of your collection, this may take a few minutes to several hours. Lidify will begin scanning your music library automatically. Depending on the size of your collection, this may take a few minutes to several hours.
@@ -325,17 +310,12 @@ docker pull chevron7locked/lidify:nightly
The unified Lidify container handles most configuration automatically. Here are the available options: The unified Lidify container handles most configuration automatically. Here are the available options:
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------------------- | ---------------------------------- | --------------------------------------------------------------------------- | | --------------------- | ---------------------------------- | --------------------------------------------------------------------------- |
| `SESSION_SECRET` | Auto-generated | Session encryption key (recommended to set for persistence across restarts) | | `SESSION_SECRET` | Auto-generated | Session encryption key (recommended to set for persistence across restarts) |
| `SETTINGS_ENCRYPTION_KEY` | Required | Encryption key for stored credentials (generate with `openssl rand -base64 32`) | | `TZ` | `UTC` | Timezone for the container |
| `TZ` | `UTC` | Timezone for the container | | `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) |
| `PORT` | `3030` | Port to access Lidify | | `NUM_WORKERS` | `2` | Number of parallel workers for audio analysis |
| `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) |
| `AUDIO_ANALYSIS_WORKERS` | `2` | Number of parallel workers for audio analysis (1-8) |
| `AUDIO_ANALYSIS_THREADS_PER_WORKER` | `1` | Threads per worker for TensorFlow/FFT operations (1-4) |
| `LOG_LEVEL` | `warn` (prod) / `debug` (dev) | Logging verbosity: debug, info, warn, error, silent |
| `DOCS_PUBLIC` | `false` | Set to `true` to allow public access to API docs in production |
The music library path is configured via Docker volume mount (`-v /path/to/music:/music`). The music library path is configured via Docker volume mount (`-v /path/to/music:/music`).
@@ -364,7 +344,7 @@ Lidify uses several sensitive environment variables. Never commit your `.env` fi
| Variable | Purpose | Required | | Variable | Purpose | Required |
| ------------------------- | ------------------------------ | ----------------- | | ------------------------- | ------------------------------ | ----------------- |
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes | | `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
| `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Yes | | `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Recommended |
| `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek | | `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek |
| `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek | | `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek |
| `LIDARR_API_KEY` | Lidarr integration | If using Lidarr | | `LIDARR_API_KEY` | Lidarr integration | If using Lidarr |
@@ -372,24 +352,6 @@ Lidify uses several sensitive environment variables. Never commit your `.env` fi
| `LASTFM_API_KEY` | Artist recommendations | Optional | | `LASTFM_API_KEY` | Artist recommendations | Optional |
| `FANART_API_KEY` | Artist images | Optional | | `FANART_API_KEY` | Artist images | Optional |
### Authentication & Session Security
- **JWT tokens** - Access tokens expire after 24 hours; refresh tokens after 30 days
- **Token refresh** - Automatic token refresh via `/api/auth/refresh` endpoint
- **Password changes** - Changing your password invalidates all existing sessions
- **Session cookies** - Secured with `httpOnly`, `sameSite=strict`, and `secure` (in production)
- **Encryption validation** - Encryption key is validated on startup to prevent insecure defaults
### Webhook Security
- **Lidarr webhooks** - Support signature verification with configurable secret
- Configure the webhook secret in Settings → Lidarr for additional security
### Admin Dashboard Security
- **Bull Board** - Job queue dashboard at `/admin/queues` requires authenticated admin user
- **API Documentation** - Swagger docs at `/api-docs` require authentication in production (unless `DOCS_PUBLIC=true`)
### VPN Configuration (Optional) ### VPN Configuration (Optional)
If using Mullvad VPN for Soulseek: If using Mullvad VPN for Soulseek:
@@ -405,7 +367,7 @@ If using Mullvad VPN for Soulseek:
openssl rand -base64 32 openssl rand -base64 32
# Generate encryption key # Generate encryption key
openssl rand -base64 32 openssl rand -hex 32
``` ```
### Network Security ### Network Security
@@ -427,7 +389,7 @@ Connect Lidify to your Lidarr instance to request and download new music directl
**What you get:** **What you get:**
- Browse artists and albums you don't own - Browse artists and albums you don't own
- Request downloads with a single click - Request downloads with a single click
- Discover Weekly playlists that automatically download new recommendations - Discover Weekly playlists that automatically download new recommendations
- Automatic library sync when Lidarr finishes importing - Automatic library sync when Lidarr finishes importing
@@ -639,22 +601,6 @@ Administrators have access to additional settings:
- **Cache Management** - Clear caches if needed - **Cache Management** - Clear caches if needed
- **Advanced** - Download retry settings, concurrent download limits - **Advanced** - Download retry settings, concurrent download limits
### Download Settings
Configure how Lidify acquires new music in Settings → Downloads:
- **Primary Source** - Choose between Soulseek or Lidarr as your main download source
- **Fallback Behavior** - Optionally fall back to the other source if the primary fails
- **Stale Job Cleanup** - Clear stuck Discovery batches and downloads that aren't progressing
### Enrichment Settings
Control metadata enrichment in Settings → Cache & Automation:
- **Enrichment Speed** - Adjust concurrency (1-5x) to balance speed vs. system load
- **Failure Notifications** - Get notified when enrichment fails for specific items
- **Retry/Skip Modal** - Choose to retry failed items or skip them to continue processing
### Activity Panel ### Activity Panel
The Activity Panel provides real-time visibility into downloads and system events: The Activity Panel provides real-time visibility into downloads and system events:
@@ -673,16 +619,7 @@ For programmatic access to Lidify:
2. Generate a new key with a descriptive name 2. Generate a new key with a descriptive name
3. Use the key in the `Authorization` header: `Bearer YOUR_API_KEY` 3. Use the key in the `Authorization` header: `Bearer YOUR_API_KEY`
API documentation is available at `/api-docs` when the backend is running (requires authentication in production). API documentation is available at `/api-docs` when the backend is running.
### Bull Board Dashboard
Monitor background job queues at `/admin/queues`:
- View active, waiting, completed, and failed jobs
- Retry or remove stuck jobs
- Monitor download progress and enrichment tasks
- Requires admin authentication
--- ---
-200
View File
@@ -1,200 +0,0 @@
#!/bin/bash
# ==============================================================================
# analyze-context-bloat.sh
# ==============================================================================
# Purpose: Find large files in your project that are bloating Roo Code context
# Usage: Run this in your Lidify project root directory
# chmod +x analyze-context-bloat.sh && ./analyze-context-bloat.sh
# ==============================================================================
echo "=============================================================================="
echo "Lidify Context Bloat Analysis"
echo "=============================================================================="
echo ""
echo "Analyzing your project to find files that should be excluded from Roo Code..."
echo ""
# Check if we're in a project directory (monorepo structure)
if [ ! -f "backend/package.json" ] && [ ! -f "frontend/package.json" ] && [ ! -f "package.json" ]; then
echo "❌ Error: Run this script from your Lidify project root directory"
echo " (Looking for backend/package.json or frontend/package.json or package.json)"
exit 1
fi
echo "✅ Found project structure (monorepo detected)"
echo ""
echo "📊 TOP 30 LARGEST FILES (excluding node_modules):"
echo "=============================================================================="
find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -exec du -h {} + 2>/dev/null | sort -rh | head -30
echo ""
echo "📦 DIRECTORY SIZES (top-level):"
echo "=============================================================================="
du -h --max-depth=1 . 2>/dev/null | sort -rh
echo ""
echo "📦 SUBDIRECTORY SIZES (backend, frontend, services):"
echo "=============================================================================="
for dir in backend frontend services scripts; do
if [ -d "$dir" ]; then
echo ""
echo "--- $dir/ ---"
du -h --max-depth=2 "$dir" 2>/dev/null | sort -rh | head -10
fi
done
echo ""
echo "🖼️ IMAGE FILES (all types):"
echo "=============================================================================="
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" -o -name "*.svg" -o -name "*.ico" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo "Total image files found"
echo ""
echo "Largest images:"
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \) -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📝 LOCK FILES & GENERATED CODE:"
echo "=============================================================================="
find . -type f \( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "*.tsbuildinfo" \) -exec du -h {} \; 2>/dev/null
echo ""
echo "📜 MIGRATION FILES:"
echo "=============================================================================="
if [ -d "backend/prisma/migrations" ]; then
echo "Total migration directory size:"
du -sh backend/prisma/migrations 2>/dev/null
echo ""
echo "Number of migrations:"
ls -1 backend/prisma/migrations 2>/dev/null | wc -l
echo ""
echo "Oldest migrations (first 10):"
ls -1 backend/prisma/migrations 2>/dev/null | head -10
echo ""
echo "Newest migrations (last 5):"
ls -1 backend/prisma/migrations 2>/dev/null | tail -5
else
echo "No migrations directory found"
fi
echo ""
echo "🗂️ FILE TYPE BREAKDOWN:"
echo "=============================================================================="
echo "TypeScript/JavaScript files:"
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) -not -path "*/node_modules/*" -not -path "*/.next/*" 2>/dev/null | wc -l
echo ""
echo "JSON files:"
find . -type f -name "*.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo ""
echo "CSS/Style files:"
find . -type f \( -name "*.css" -o -name "*.scss" -o -name "*.sass" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo ""
echo "Markdown files:"
find . -type f -name "*.md" 2>/dev/null | wc -l
echo ""
echo "💾 ESTIMATED TOKEN COUNT:"
echo "=============================================================================="
# Rough estimation: 1 token ≈ 4 characters
total_chars=$(find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.md" -o -name "*.css" -o -name "*.yml" -o -name "*.yaml" \) -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -exec cat {} \; 2>/dev/null | wc -c)
estimated_tokens=$((total_chars / 4))
if [ $estimated_tokens -gt 0 ]; then
echo "Total characters in text files: $(printf "%'d" $total_chars)"
echo "Estimated current token count: ~$(printf "%'d" $estimated_tokens) tokens"
echo ""
optimized_tokens=$((estimated_tokens * 40 / 100))
echo "Estimated AFTER .rooignore: ~$(printf "%'d" $optimized_tokens) tokens (60% reduction)"
else
echo "Could not calculate token estimate"
fi
echo ""
echo "🎯 LARGE FILES ANALYSIS:"
echo "=============================================================================="
echo "Large JSON files (>50KB):"
large_json=$(find . -type f -name "*.json" -not -path "*/node_modules/*" -not -name "package.json" -not -name "tsconfig.json" -size +50k 2>/dev/null)
if [ -n "$large_json" ]; then
echo "$large_json" | while read file; do
size=$(du -h "$file" | cut -f1)
echo " $size - $file"
done
else
echo " None found"
fi
echo ""
echo "Large CSS files (>30KB):"
large_css=$(find . -type f \( -name "*.css" -o -name "*.scss" \) -not -path "*/node_modules/*" -size +30k 2>/dev/null)
if [ -n "$large_css" ]; then
echo "$large_css" | while read file; do
size=$(du -h "$file" | cut -f1)
echo " $size - $file"
done
else
echo " None found"
fi
echo ""
echo "Test files:"
test_count=$(find . -type f \( -name "*.test.*" -o -name "*.spec.*" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l)
echo " Found $test_count test files"
if [ "$test_count" -gt 0 ]; then
echo " Consider excluding with: *.test.* and *.spec.*"
fi
echo ""
python_files=$(find . -type f -name "*.py" -not -path "*/node_modules/*" 2>/dev/null | wc -l)
if [ "$python_files" -gt 0 ]; then
echo "Python files (services):"
echo " Found $python_files Python files"
echo " Largest Python files:"
find . -type f -name "*.py" -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -5
echo ""
fi
docker_files=$(find . -maxdepth 2 -type f \( -name "Dockerfile*" -o -name "docker-compose*.yml" \) 2>/dev/null | wc -l)
if [ "$docker_files" -gt 0 ]; then
echo "Docker configuration files:"
find . -maxdepth 2 -type f \( -name "Dockerfile*" -o -name "docker-compose*.yml" \) -exec du -h {} \; 2>/dev/null
echo ""
fi
echo "=============================================================================="
echo "🎯 RECOMMENDED .rooignore ADDITIONS:"
echo "=============================================================================="
echo ""
echo "Based on this analysis, your .rooignore should definitely include:"
echo ""
echo "1. node_modules/ (if exists)"
echo "2. Lock files (package-lock.json, yarn.lock)"
echo "3. All images in assets/screenshots/"
echo "4. Build artifacts (.next/, dist/, build/)"
echo "5. Old migrations (backend/prisma/migrations/2024*/)"
echo ""
if [ -n "$large_json" ]; then
echo "6. Large JSON files:"
echo "$large_json" | while read file; do
echo " $file"
done
echo ""
fi
if [ "$test_count" -gt 5 ]; then
echo "7. Test files (*.test.*, *.spec.*)"
echo ""
fi
echo "=============================================================================="
echo "✅ Analysis complete!"
echo ""
echo "Next steps:"
echo "1. Share this output with Claude"
echo "2. Claude will create a custom .rooignore for your project"
echo "3. Copy .rooignore to project root"
echo "4. Make a Roo Code request and verify token reduction"
echo "=============================================================================="
-107
View File
@@ -1,107 +0,0 @@
#!/bin/bash
# ==============================================================================
# analyze-context-bloat.sh
# ==============================================================================
# Purpose: Find large files in your project that are bloating Roo Code context
# Usage: Run this in your Lidify project root directory
# chmod +x analyze-context-bloat.sh && ./analyze-context-bloat.sh
# ==============================================================================
echo "=============================================================================="
echo "Lidify Context Bloat Analysis"
echo "=============================================================================="
echo ""
echo "Analyzing your project to find files that should be excluded from Roo Code..."
echo ""
# Check if we're in a project directory
if [ ! -f "package.json" ]; then
echo "❌ Error: Run this script from your Lidify project root directory"
exit 1
fi
echo "📊 TOP 20 LARGEST FILES (excluding node_modules):"
echo "=============================================================================="
find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📦 DIRECTORY SIZES (excluding node_modules):"
echo "=============================================================================="
du -h --max-depth=2 . 2>/dev/null | grep -v node_modules | sort -rh | head -20
echo ""
echo "🖼️ IMAGE FILES TAKING UP SPACE:"
echo "=============================================================================="
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \) -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📝 LOCK FILES & GENERATED CODE:"
echo "=============================================================================="
find . -type f \( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "*.tsbuildinfo" \) -exec du -h {} \;
echo ""
echo "📜 MIGRATION FILES:"
echo "=============================================================================="
if [ -d "backend/prisma/migrations" ]; then
echo "Total migration directory size:"
du -sh backend/prisma/migrations
echo ""
echo "Number of migrations:"
ls -1 backend/prisma/migrations | wc -l
echo ""
echo "Oldest migrations (first 5):"
ls -1 backend/prisma/migrations | head -5
echo ""
echo "Newest migrations (last 5):"
ls -1 backend/prisma/migrations | tail -5
else
echo "No migrations directory found"
fi
echo ""
echo "💾 ESTIMATED TOKEN COUNT:"
echo "=============================================================================="
# Rough estimation: 1 token ≈ 4 characters
total_chars=$(find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.md" 2>/dev/null | xargs cat 2>/dev/null | wc -c)
estimated_tokens=$((total_chars / 4))
echo "Estimated current token count: ~$(printf "%'d" $estimated_tokens) tokens"
echo ""
echo "🎯 RECOMMENDED .rooignore ADDITIONS:"
echo "=============================================================================="
echo "Based on this analysis, consider adding these to .rooignore:"
echo ""
# Find large JSON files
large_json=$(find . -type f -name "*.json" -not -path "*/node_modules/*" -not -name "package.json" -not -name "tsconfig.json" -size +100k -exec du -h {} \; 2>/dev/null)
if [ -n "$large_json" ]; then
echo "Large JSON files (>100KB):"
echo "$large_json"
echo ""
fi
# Find CSS/SCSS files if they're large
large_css=$(find . -type f \( -name "*.css" -o -name "*.scss" \) -not -path "*/node_modules/*" -size +50k -exec du -h {} \; 2>/dev/null)
if [ -n "$large_css" ]; then
echo "Large CSS files (>50KB):"
echo "$large_css"
echo ""
fi
# Find test files
test_files=$(find . -type f \( -name "*.test.*" -o -name "*.spec.*" \) -not -path "*/node_modules/*" | wc -l)
if [ "$test_files" -gt 0 ]; then
echo "Found $test_files test files - consider excluding with: *.test.* and *.spec.*"
echo ""
fi
echo "=============================================================================="
echo "✅ Analysis complete!"
echo ""
echo "Next steps:"
echo "1. Copy .rooignore to your project root"
echo "2. Add any large files shown above to .rooignore"
echo "3. Make a Roo Code request and check token count in OpenRouter"
echo "4. Target: 60-80K tokens (down from 177K)"
echo "=============================================================================="
-11
View File
@@ -36,17 +36,6 @@ npx prisma migrate deploy
echo "[DB] Generating Prisma client..." echo "[DB] Generating Prisma client..."
npx prisma generate npx prisma generate
# Clear Redis cache on deployment to prevent stale data (e.g., 404 images)
echo "[REDIS] Clearing cache for fresh deployment..."
node -e "
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL || 'redis://redis:6379' });
client.connect()
.then(() => client.flushAll())
.then(() => { console.log('[REDIS] Cache cleared successfully'); return client.quit(); })
.catch(err => { console.warn('[REDIS] Cache clear failed (non-critical):', err.message); });
" || echo "[REDIS] Cache clear skipped (Redis unavailable)"
# Generate session secret if not provided # Generate session secret if not provided
if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then
echo "[WARN] SESSION_SECRET not set or using default. Generating random key..." echo "[WARN] SESSION_SECRET not set or using default. Generating random key..."
+7 -24
View File
@@ -5,7 +5,6 @@ import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart"; import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer"; import { deezerService } from "../services/deezer";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { normalizeToArray } from "../utils/normalize";
const router = Router(); const router = Router();
@@ -159,10 +158,8 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
} }
// Fallback to Last.fm (but filter placeholders) // Fallback to Last.fm (but filter placeholders)
// NORMALIZATION: lastFmInfo.image could be a single object or array
if (!image && lastFmInfo?.image) { if (!image && lastFmInfo?.image) {
const images = normalizeToArray(lastFmInfo.image); const lastFmImage = lastFmService.getBestImage(lastFmInfo.image);
const lastFmImage = lastFmService.getBestImage(images);
// Filter out Last.fm placeholder // Filter out Last.fm placeholder
if ( if (
lastFmImage && lastFmImage &&
@@ -277,13 +274,10 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
} }
// Get similar artists from Last.fm and fetch images // Get similar artists from Last.fm and fetch images
// NORMALIZATION: lastFmInfo.similar.artist could be a single object or array const similarArtistsRaw = lastFmInfo?.similar?.artist || [];
const similarArtistsRaw = normalizeToArray(lastFmInfo?.similar?.artist);
const similarArtists = await Promise.all( const similarArtists = await Promise.all(
similarArtistsRaw.slice(0, 10).map(async (artist: any) => { similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
// NORMALIZATION: artist.image could be a single object or array const similarImage = artist.image?.find(
const images = normalizeToArray(artist.image);
const similarImage = images.find(
(img: any) => img.size === "large" (img: any) => img.size === "large"
)?.[" #text"]; )?.[" #text"];
@@ -331,19 +325,14 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
}) })
); );
// NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
const tags = normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean);
const response = { const response = {
mbid, mbid,
name: artistName, name: artistName,
image, image,
bio, // Use filtered bio instead of raw Last.fm bio bio, // Use filtered bio instead of raw Last.fm bio
summary: bio, // Alias for consistency summary: bio, // Alias for consistency
tags, tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
genres: tags, // Alias for consistency genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency
listeners: parseInt(lastFmInfo?.stats?.listeners || "0"), listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
playcount: parseInt(lastFmInfo?.stats?.playcount || "0"), playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
url: lastFmInfo?.url || null, url: lastFmInfo?.url || null,
@@ -481,10 +470,7 @@ router.get("/album/:mbid", async (req, res) => {
// Check if Cover Art Archive actually has the image // Check if Cover Art Archive actually has the image
try { try {
const response = await fetch(coverArtUrl, { const response = await fetch(coverArtUrl, { method: "HEAD" });
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
if (response.ok) { if (response.ok) {
coverUrl = coverArtUrl; coverUrl = coverArtUrl;
logger.debug(`Cover Art Archive has cover for ${albumTitle}`); logger.debug(`Cover Art Archive has cover for ${albumTitle}`);
@@ -543,10 +529,7 @@ router.get("/album/:mbid", async (req, res) => {
coverUrl, coverUrl,
coverArt: coverUrl, // Alias for compatibility coverArt: coverUrl, // Alias for compatibility
bio: lastFmInfo?.wiki?.summary || null, bio: lastFmInfo?.wiki?.summary || null,
// NORMALIZATION: lastFmInfo.tags.tag could be a single object or array tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
tags: normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean),
tracks: tracks.map((track: any, index: number) => ({ tracks: tracks.map((track: any, index: number) => ({
id: `mb-${releaseGroupId}-${track.id || index}`, id: `mb-${releaseGroupId}-${track.id || index}`,
title: track.title, title: track.title,
+50 -8
View File
@@ -2754,12 +2754,32 @@ router.get("/tracks/:id/stream", async (req, res) => {
`[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}` `[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}`
); );
await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType); res.sendFile(
streamingService.destroy(); filePath,
logger.debug( {
`[STREAM] File sent successfully: ${path.basename( headers: {
filePath "Content-Type": mimeType,
)}` "Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Access-Control-Allow-Origin":
req.headers.origin || "*",
"Access-Control-Allow-Credentials": "true",
"Cross-Origin-Resource-Policy": "cross-origin",
},
},
(err) => {
// Always destroy the streaming service to clean up intervals
streamingService.destroy();
if (err) {
logger.error(`[STREAM] sendFile error:`, err);
} else {
logger.debug(
`[STREAM] File sent successfully: ${path.basename(
filePath
)}`
);
}
}
); );
return; return;
@@ -2792,8 +2812,30 @@ router.get("/tracks/:id/stream", async (req, res) => {
absolutePath absolutePath
); );
await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType); res.sendFile(
streamingService.destroy(); filePath,
{
headers: {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Access-Control-Allow-Origin":
req.headers.origin || "*",
"Access-Control-Allow-Credentials": "true",
"Cross-Origin-Resource-Policy": "cross-origin",
},
},
(err) => {
// Always destroy the streaming service to clean up intervals
streamingService.destroy();
if (err) {
logger.error(
`[STREAM] sendFile fallback error:`,
err
);
}
}
);
return; return;
} }
-91
View File
@@ -1,6 +1,4 @@
import * as fs from "fs"; import * as fs from "fs";
import { promises as fsPromises } from "fs";
import { Request, Response } from "express";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
import * as crypto from "crypto"; import * as crypto from "crypto";
@@ -386,95 +384,6 @@ export class AudioStreamingService {
return mimeTypes[ext] || "audio/mpeg"; return mimeTypes[ext] || "audio/mpeg";
} }
/**
* Stream file with proper HTTP Range support (fixes Firefox FLAC issue #42/#17)
* Manually handles Range requests to ensure compatibility with Firefox's strict
* Content-Range header validation for large FLAC files.
*/
async streamFileWithRangeSupport(
req: Request,
res: Response,
filePath: string,
mimeType: string
): Promise<void> {
try {
// Get file stats for size
const stats = await fsPromises.stat(filePath);
const fileSize = stats.size;
// Parse Range header
const range = req.headers.range;
let start = 0;
let end = fileSize - 1;
if (range) {
// Parse bytes=START-END or bytes=START-
const parts = range.replace(/bytes=/, "").split("-");
start = parseInt(parts[0], 10);
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
// Validate range
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416).set({
"Content-Range": `bytes */${fileSize}`,
});
res.end();
return;
}
}
const contentLength = end - start + 1;
// Set response headers
const headers: Record<string, string> = {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Content-Length": contentLength.toString(),
};
// Add CORS headers from request origin
if (req.headers.origin) {
headers["Access-Control-Allow-Origin"] = req.headers.origin;
headers["Access-Control-Allow-Credentials"] = "true";
}
// Set status and range-specific headers
if (range) {
res.status(206);
headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
} else {
res.status(200);
}
res.set(headers);
// Create read stream with range
const stream = fs.createReadStream(filePath, { start, end });
// Handle stream errors
stream.on("error", (err) => {
logger.error(`[AudioStreaming] Stream error for ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
});
// Handle cleanup on response close
res.on("close", () => {
stream.destroy();
});
// Pipe stream to response
stream.pipe(res);
} catch (err) {
logger.error(`[AudioStreaming] Failed to stream ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
}
}
/** /**
* Cleanup resources * Cleanup resources
*/ */
+3 -8
View File
@@ -6,7 +6,6 @@
import slsk from "slsk-client"; import slsk from "slsk-client";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { mkdir } from "fs/promises";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
import { sessionLog } from "../utils/playlistLogger"; import { sessionLog } from "../utils/playlistLogger";
@@ -701,14 +700,10 @@ class SoulseekService {
return { success: false, error: "Not connected" }; return { success: false, error: "Not connected" };
} }
// Ensure destination directory exists (idempotent - won't fail if exists) // Ensure destination directory exists
const destDir = path.dirname(destPath); const destDir = path.dirname(destPath);
try { if (!fs.existsSync(destDir)) {
await mkdir(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
} catch (err: any) {
sessionLog("SOULSEEK", `Failed to create directory ${destDir}: ${err.message}`, "ERROR");
this.activeDownloads--;
return { success: false, error: `Cannot create destination directory: ${err.message}` };
} }
sessionLog( sessionLog(
+7 -4
View File
@@ -78,11 +78,14 @@ class WikidataService {
LIMIT 1 LIMIT 1
`; `;
const response = await this.client.get("https://query.wikidata.org/sparql", { const response = await axios.get("https://query.wikidata.org/sparql", {
params: { params: {
query: sparqlQuery, query: sparqlQuery,
format: "json", format: "json",
}, },
headers: {
"User-Agent": "Lidify/1.0.0",
},
}); });
const bindings = response.data.results?.bindings || []; const bindings = response.data.results?.bindings || [];
@@ -97,7 +100,7 @@ class WikidataService {
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
// Get English Wikipedia article title // Get English Wikipedia article title
const response = await this.client.get( const response = await axios.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json` `https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
); );
@@ -107,7 +110,7 @@ class WikidataService {
if (!enWikiTitle) return undefined; if (!enWikiTitle) return undefined;
// Get article summary from Wikipedia API // Get article summary from Wikipedia API
const summaryResponse = await this.client.get( const summaryResponse = await axios.get(
"https://en.wikipedia.org/api/rest_v1/page/summary/" + "https://en.wikipedia.org/api/rest_v1/page/summary/" +
encodeURIComponent(enWikiTitle) encodeURIComponent(enWikiTitle)
); );
@@ -126,7 +129,7 @@ class WikidataService {
wikidataId: string wikidataId: string
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const response = await this.client.get( const response = await axios.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json` `https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
); );
+2 -5
View File
@@ -121,12 +121,9 @@ async function migrateExistingSoulseekFiles(musicPath: string): Promise<void> {
continue; continue;
} }
// Create destination directory (idempotent - won't fail if exists) // Create destination directory
try { if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
} catch (err: any) {
sessionLog('ORGANIZE', `Failed to create directory ${destDir}: ${err.message}`, 'WARN');
continue; // Skip this file, try next
} }
// Move file (copy then delete original) // Move file (copy then delete original)
@@ -129,7 +129,7 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
tabIndex={-1} tabIndex={-1}
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none" className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none"
style={{ style={{
marginTop: "calc(58px + env(safe-area-inset-top, 0px))", marginTop: "58px",
marginBottom: marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)", "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}} }}
-1
View File
@@ -17,7 +17,6 @@ const navigation = [
{ name: "Library", href: "/library" }, { name: "Library", href: "/library" },
{ name: "Radio", href: "/radio" }, { name: "Radio", href: "/radio" },
{ name: "Discovery", href: "/discover" }, { name: "Discovery", href: "/discover" },
{ name: "Releases", href: "/releases" },
{ name: "Audiobooks", href: "/audiobooks" }, { name: "Audiobooks", href: "/audiobooks" },
{ name: "Podcasts", href: "/podcasts" }, { name: "Podcasts", href: "/podcasts" },
{ name: "Browse", href: "/browse/playlists", badge: "Beta" }, { name: "Browse", href: "/browse/playlists", badge: "Beta" },
+1 -4
View File
@@ -142,10 +142,7 @@ export function TopBar() {
return ( return (
<header <header
className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50" className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50"
style={{ style={{ height: isMobileOrTablet ? "58px" : "64px" }}
height: isMobileOrTablet ? "58px" : "64px",
paddingTop: isMobileOrTablet ? "env(safe-area-inset-top)" : undefined,
}}
> >
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */} {/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */}
{isMobileOrTablet ? ( {isMobileOrTablet ? (
+1 -2
View File
@@ -247,8 +247,7 @@ export function SeekSlider({
> >
<div <div
className={cn( className={cn(
"h-full rounded-full relative", "h-full rounded-full relative transition-all duration-150",
!isDragging && "transition-all duration-150",
styles.progress styles.progress
)} )}
style={{ width: `${displayProgress}%` }} style={{ width: `${displayProgress}%` }}
-5
View File
@@ -13,11 +13,6 @@ export function PullToRefresh({
children, children,
threshold = 80, threshold = 80,
}: PullToRefreshProps) { }: PullToRefreshProps) {
// HOTFIX v1.3.2: Temporarily disabled - blocking mobile scrolling
// TODO: Fix in v1.4 - Issues: 1) h-full breaks flex layout, 2) touch handlers may interfere
// Proper fix: Change line 90 className to "relative flex-1 flex flex-col min-h-0"
return <>{children}</>;
const [pullDistance, setPullDistance] = useState(0); const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0); const startY = useRef(0);
+7 -80
View File
@@ -1,5 +1,4 @@
const AUTH_TOKEN_KEY = "auth_token"; const AUTH_TOKEN_KEY = "auth_token";
const REFRESH_TOKEN_KEY = "refresh_token";
// Mood Mix Types (Legacy - for old presets endpoint) // Mood Mix Types (Legacy - for old presets endpoint)
export interface MoodPreset { export interface MoodPreset {
@@ -114,7 +113,6 @@ class ApiClient {
if (this.token) { if (this.token) {
this.tokenInitialized = true; this.tokenInitialized = true;
} }
// Note: Refresh token is loaded on-demand via getRefreshToken()
} }
} }
@@ -155,31 +153,19 @@ class ApiClient {
this.baseUrl = ""; this.baseUrl = "";
} }
// Store JWT token and optionally refresh token // Store JWT token
setToken(token: string, refreshToken?: string) { setToken(token: string) {
this.token = token; this.token = token;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem(AUTH_TOKEN_KEY, token); localStorage.setItem(AUTH_TOKEN_KEY, token);
if (refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
} }
} }
// Get refresh token from storage // Clear JWT token
getRefreshToken(): string | null {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
// Clear both JWT tokens
clearToken() { clearToken() {
this.token = null; this.token = null;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
} }
} }
@@ -191,56 +177,15 @@ class ApiClient {
return getApiBaseUrl(); return getApiBaseUrl();
} }
/**
* Refresh the access token using the refresh token
* @returns true if refresh succeeded, false otherwise
*/
private async refreshAccessToken(): Promise<boolean> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return false;
}
try {
const response = await fetch(`${this.getBaseUrl()}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
credentials: "include",
});
if (!response.ok) {
// Refresh token invalid or expired - clear tokens
this.clearToken();
return false;
}
const data = await response.json();
// Store new tokens
if (data.token) {
this.setToken(data.token, data.refreshToken);
return true;
}
this.clearToken();
return false;
} catch (error) {
console.error("[API] Token refresh failed:", error);
this.clearToken();
return false;
}
}
/** /**
* Make an authenticated API request * Make an authenticated API request
* Public method for components that need custom API calls * Public method for components that need custom API calls
*/ */
async request<T>( async request<T>(
endpoint: string, endpoint: string,
options: RequestInit & { silent404?: boolean; _retryCount?: number } = {} options: RequestInit & { silent404?: boolean } = {}
): Promise<T> { ): Promise<T> {
const { silent404, _retryCount = 0, ...fetchOptions } = options; const { silent404, ...fetchOptions } = options;
const headers: HeadersInit = { const headers: HeadersInit = {
"Content-Type": "application/json", "Content-Type": "application/json",
...fetchOptions.headers, ...fetchOptions.headers,
@@ -272,23 +217,6 @@ class ApiClient {
console.error(`[API] Request failed: ${url}`, error); console.error(`[API] Request failed: ${url}`, error);
} }
// Handle 401 with token refresh (retry once)
if (response.status === 401 && _retryCount === 0 && endpoint !== "/auth/refresh") {
console.log("[API] 401 error - attempting token refresh");
const refreshed = await this.refreshAccessToken();
if (refreshed) {
console.log("[API] Token refreshed - retrying request");
// Retry the request with new token
return this.request<T>(endpoint, {
...options,
_retryCount: 1, // Prevent infinite loops
});
}
console.log("[API] Token refresh failed - user needs to re-login");
}
if (response.status === 401) { if (response.status === 401) {
const err = new Error("Not authenticated"); const err = new Error("Not authenticated");
(err as any).status = response.status; (err as any).status = response.status;
@@ -332,7 +260,6 @@ class ApiClient {
async login(username: string, password: string, token?: string) { async login(username: string, password: string, token?: string) {
const data = await this.request<{ const data = await this.request<{
token?: string; token?: string;
refreshToken?: string;
user?: { user?: {
id: string; id: string;
username: string; username: string;
@@ -347,9 +274,9 @@ class ApiClient {
body: JSON.stringify({ username, password, token }), body: JSON.stringify({ username, password, token }),
}); });
// If login returned JWT tokens, store them // If login returned a JWT token, store it
if (data.token) { if (data.token) {
this.setToken(data.token, data.refreshToken); this.setToken(data.token);
} }
// Return user data in consistent format // Return user data in consistent format
+17 -39
View File
@@ -65,7 +65,6 @@ import traceback
import numpy as np import numpy as np
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
import multiprocessing import multiprocessing
import gc
# BrokenProcessPool was added in Python 3.9, provide compatibility for Python 3.8 # BrokenProcessPool was added in Python 3.9, provide compatibility for Python 3.8
try: try:
@@ -113,14 +112,6 @@ except ImportError as e:
TF_MODELS_AVAILABLE = False TF_MODELS_AVAILABLE = False
TensorflowPredictMusiCNN = None TensorflowPredictMusiCNN = None
try: try:
import tensorflow as tf
# Limit TensorFlow memory usage (CPU & GPU)
try:
gpus = tf.config.experimental.list_physical_devices('GPU')
for gpu in gpus:
tf.config.experimental.set_memory_growth(gpu, True)
except Exception:
pass
from essentia.standard import TensorflowPredictMusiCNN from essentia.standard import TensorflowPredictMusiCNN
TF_MODELS_AVAILABLE = True TF_MODELS_AVAILABLE = True
logger.info("TensorflowPredictMusiCNN available - Enhanced mode enabled") logger.info("TensorflowPredictMusiCNN available - Enhanced mode enabled")
@@ -385,18 +376,14 @@ class AudioAnalyzer:
traceback.print_exc() traceback.print_exc()
self.enhanced_mode = False self.enhanced_mode = False
def load_audio(self, file_path: str, sample_rate: int = 16000, max_duration: int = 90) -> Optional[Any]: def load_audio(self, file_path: str, sample_rate: int = 16000) -> Optional[Any]:
"""Load up to max_duration seconds of audio as mono signal (to limit memory usage)""" """Load audio file as mono signal"""
if not ESSENTIA_AVAILABLE: if not ESSENTIA_AVAILABLE:
return None return None
try: try:
loader = es.MonoLoader(filename=file_path, sampleRate=sample_rate) loader = es.MonoLoader(filename=file_path, sampleRate=sample_rate)
audio = loader() audio = loader()
# Limit to max_duration seconds
max_samples = int(sample_rate * max_duration)
if len(audio) > max_samples:
audio = audio[:max_samples]
return audio return audio
except Exception as e: except Exception as e:
logger.error(f"Failed to load audio {file_path}: {e}") logger.error(f"Failed to load audio {file_path}: {e}")
@@ -527,17 +514,12 @@ class AudioAnalyzer:
result['_error'] = 'Essentia library not installed' result['_error'] = 'Essentia library not installed'
return result return result
# Limit memory: only analyze up to MAX_ANALYZE_SECONDS (default 90s) # Load audio at different sample rates for different algorithms
MAX_ANALYZE_SECONDS = int(os.getenv('MAX_ANALYZE_SECONDS', '90')) audio_44k = self.load_audio(file_path, 44100)
try: audio_16k = self.load_audio(file_path, 16000)
# Load audio at different sample rates for different algorithms, limit duration
audio_44k = self.load_audio(file_path, 44100, max_duration=MAX_ANALYZE_SECONDS)
audio_16k = self.load_audio(file_path, 16000, max_duration=MAX_ANALYZE_SECONDS)
except MemoryError:
logger.error(f"MemoryError: Could not load audio for {file_path}")
result['_error'] = 'MemoryError: audio file too large'
return result
if audio_44k is None or audio_16k is None: if audio_44k is None or audio_16k is None:
result['_error'] = 'Failed to load audio file'
return result return result
# Validate audio before analysis (Phase 2 defensive improvement) # Validate audio before analysis (Phase 2 defensive improvement)
@@ -604,10 +586,7 @@ class AudioAnalyzer:
# Process audio in frames for detailed analysis # Process audio in frames for detailed analysis
frame_size = 2048 frame_size = 2048
hop_size = 1024 hop_size = 1024
max_frames = int((44100 * MAX_ANALYZE_SECONDS - frame_size) / hop_size) for i in range(0, len(audio_44k) - frame_size, hop_size):
for idx, i in enumerate(range(0, len(audio_44k) - frame_size, hop_size)):
if idx > max_frames:
break
frame = audio_44k[i:i + frame_size] frame = audio_44k[i:i + frame_size]
windowed = self.windowing(frame) windowed = self.windowing(frame)
spectrum = self.spectrum(windowed) spectrum = self.spectrum(windowed)
@@ -620,6 +599,7 @@ class AudioAnalyzer:
# RMS-based energy (properly normalized to 0-1) # RMS-based energy (properly normalized to 0-1)
if rms_values: if rms_values:
avg_rms = np.mean(rms_values) avg_rms = np.mean(rms_values)
# RMS is typically 0.0-0.5 for normalized audio, scale to 0-1
result['energy'] = round(min(1.0, float(avg_rms) * 3), 3) result['energy'] = round(min(1.0, float(avg_rms) * 3), 3)
else: else:
result['energy'] = 0.5 result['energy'] = 0.5
@@ -636,6 +616,7 @@ class AudioAnalyzer:
result['_zcr'] = np.mean(zcr_values) if zcr_values else 0.1 result['_zcr'] = np.mean(zcr_values) if zcr_values else 0.1
# Basic Danceability (non-ML) # Basic Danceability (non-ML)
# Note: es.Danceability() can return values > 1.0, so we clamp
danceability, _ = self.danceability_extractor(audio_44k) danceability, _ = self.danceability_extractor(audio_44k)
result['danceability'] = round(max(0.0, min(1.0, float(danceability))), 3) result['danceability'] = round(max(0.0, min(1.0, float(danceability))), 3)
@@ -651,25 +632,22 @@ class AudioAnalyzer:
traceback.print_exc() traceback.print_exc()
self._apply_standard_estimates(result, scale, bpm) self._apply_standard_estimates(result, scale, bpm)
else: else:
# === STANDARD MODE: Use heuristics ===
self._apply_standard_estimates(result, scale, bpm) self._apply_standard_estimates(result, scale, bpm)
# Generate mood tags based on all features # Generate mood tags based on all features
result['moodTags'] = self._generate_mood_tags(result) result['moodTags'] = self._generate_mood_tags(result)
logger.info(f"Analysis complete [{result['analysisMode']}]: BPM={result['bpm']}, Key={result['key']} {result['keyScale']}, Valence={result['valence']}, Arousal={result['arousal']}") logger.info(f"Analysis complete [{result['analysisMode']}]: BPM={result['bpm']}, Key={result['key']} {result['keyScale']}, Valence={result['valence']}, Arousal={result['arousal']}")
except MemoryError:
logger.error(f"MemoryError during analysis of {file_path}")
result['_error'] = 'MemoryError: analysis exceeded memory limits'
except Exception as e: except Exception as e:
logger.error(f"Analysis error: {e}") logger.error(f"Analysis error: {e}")
traceback.print_exc() traceback.print_exc()
finally:
# Clean up internal fields before returning # Clean up internal fields before returning
for key in ['_spectral_centroid', '_spectral_flatness', '_zcr']: for key in ['_spectral_centroid', '_spectral_flatness', '_zcr']:
result.pop(key, None) result.pop(key, None)
# Explicitly free memory
del audio_44k, audio_16k
gc.collect()
return result return result
def _extract_ml_features(self, audio_16k) -> Dict[str, Any]: def _extract_ml_features(self, audio_16k) -> Dict[str, Any]: