6 Commits

Author SHA1 Message Date
Your Name 74d71da230 Hotfix: Redis cache clearing, seek drag lag, session persistence (v1.3.4) 2026-01-09 20:30:55 -06:00
Your Name bddea9ef36 Hotfix: Fixed mobile session persistance 2026-01-09 19:53:13 -06:00
Your Name 0ac805b6fc chore: Release v1.3.3 - Critical bug fixes and QoL improvements
Critical Fixes:
- Docker permissions for PostgreSQL/Redis bind mounts
  Fixes #59, fixes #62
- Audio analyzer memory consumption and OOM crashes
  Fixes #21, fixes #26, fixes #53
- LastFM array normalization preventing .map crashes
  Fixes #37, fixes #39
- Wikidata 403 errors from missing User-Agent
  Fixes #57
- Singles directory creation race conditions
  Fixes #58
- Firefox FLAC playback stopping at ~4:34 mark
  Fixes #42, fixes #17

Quality of Life:
- Add Releases link to desktop sidebar navigation
  Fixes #41
- iPhone safe area insets for Dynamic Island/notch
  Fixes #54

Contributors: @arsaboo, @rustyricky, @RustyJonez, @tombatossals

No regressions detected, backward compatible, production ready.
2026-01-09 18:46:16 -06:00
Your Name ce597a318e Release v1.3.2: Fix mobile scrolling blocked by pull-to-refresh
Hotfix for v1.3.0 production issue where PullToRefresh component was
blocking all mobile scrolling.

Root Cause:
- CSS flex chain break (container uses h-full instead of flex-1)
- Touch event handlers interfering with native scroll

Fix:
Temporarily disabled PullToRefresh via early return that bypasses all
functionality while still rendering children. Feature will be properly
fixed in v1.4.

This restores normal scrolling on mobile devices across all pages.

Also includes:
- CHANGELOG.md updates for v1.3.1 and v1.3.2
- README.md typo fix
2026-01-06 21:25:31 -06:00
Your Name ffb8bda9d1 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 21:25:31 -06:00
Kevin Allen d78eaed15b Refine note on native app development plans
Removed redundant text in the Native Apps section.
2026-01-06 20:16:25 -06:00
22 changed files with 1066 additions and 114 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Build and Publish Docker Image name: Release ${{ github.ref_name }}
on: on:
push: push:
+279
View File
@@ -0,0 +1,279 @@
# ==============================================================================
# .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,6 +5,77 @@ 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
+29 -5
View File
@@ -193,6 +193,7 @@ 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
@@ -235,7 +236,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" 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"
priority=50 priority=50
EOF EOF
@@ -274,10 +275,33 @@ if [ -z "$PG_BIN" ]; then
fi fi
echo "Using PostgreSQL from: $PG_BIN" echo "Using PostgreSQL from: $PG_BIN"
# Fix permissions on data directories (may have different UID from previous container) # Prepare data directories (bind-mount safe)
echo "Fixing data directory permissions..." echo "Preparing data directories..."
chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true mkdir -p /data/postgres /data/redis /run/postgresql
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
+75 -12
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
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. 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,6 +45,7 @@ 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">
@@ -66,6 +67,8 @@ 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))
@@ -74,6 +77,7 @@ 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">
@@ -84,6 +88,7 @@ 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">
@@ -172,7 +177,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 / notification media controls (via Media Session API) - Lock screen and notification media controls (iOS Control Center and Android notifications)
- Offline caching for faster loads - Offline caching for faster loads
- Installable icon on home screen - Installable icon on home screen
@@ -270,6 +275,16 @@ 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.
@@ -310,12 +325,17 @@ 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) |
| `TZ` | `UTC` | Timezone for the container | | `SETTINGS_ENCRYPTION_KEY` | Required | Encryption key for stored credentials (generate with `openssl rand -base64 32`) |
| `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) | | `TZ` | `UTC` | Timezone for the container |
| `NUM_WORKERS` | `2` | Number of parallel workers for audio analysis | | `PORT` | `3030` | Port to access Lidify |
| `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`).
@@ -344,7 +364,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 | Recommended | | `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Yes |
| `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 |
@@ -352,6 +372,24 @@ 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:
@@ -367,7 +405,7 @@ If using Mullvad VPN for Soulseek:
openssl rand -base64 32 openssl rand -base64 32
# Generate encryption key # Generate encryption key
openssl rand -hex 32 openssl rand -base64 32
``` ```
### Network Security ### Network Security
@@ -389,7 +427,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
@@ -601,6 +639,22 @@ 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:
@@ -619,7 +673,16 @@ 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. API documentation is available at `/api-docs` when the backend is running (requires authentication in production).
### 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
@@ -0,0 +1,200 @@
#!/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
@@ -0,0 +1,107 @@
#!/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,6 +36,17 @@ 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..."
@@ -0,0 +1,21 @@
-- Add downloadSource column if it doesn't exist (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'SystemSettings' AND column_name = 'downloadSource'
) THEN
ALTER TABLE "SystemSettings" ADD COLUMN "downloadSource" TEXT NOT NULL DEFAULT 'soulseek';
END IF;
END $$;
-- Add primaryFailureFallback column if it doesn't exist (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'SystemSettings' AND column_name = 'primaryFailureFallback'
) THEN
ALTER TABLE "SystemSettings" ADD COLUMN "primaryFailureFallback" TEXT NOT NULL DEFAULT 'none';
END IF;
END $$;
+24 -7
View File
@@ -5,6 +5,7 @@ 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();
@@ -158,8 +159,10 @@ 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 lastFmImage = lastFmService.getBestImage(lastFmInfo.image); const images = normalizeToArray(lastFmInfo.image);
const lastFmImage = lastFmService.getBestImage(images);
// Filter out Last.fm placeholder // Filter out Last.fm placeholder
if ( if (
lastFmImage && lastFmImage &&
@@ -274,10 +277,13 @@ 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
const similarArtistsRaw = lastFmInfo?.similar?.artist || []; // NORMALIZATION: lastFmInfo.similar.artist could be a single object or array
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) => {
const similarImage = artist.image?.find( // NORMALIZATION: artist.image could be a single object or array
const images = normalizeToArray(artist.image);
const similarImage = images.find(
(img: any) => img.size === "large" (img: any) => img.size === "large"
)?.[" #text"]; )?.[" #text"];
@@ -325,14 +331,19 @@ 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: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], tags,
genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency genres: tags, // 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,
@@ -470,7 +481,10 @@ 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, { method: "HEAD" }); const response = await fetch(coverArtUrl, {
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}`);
@@ -529,7 +543,10 @@ 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,
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
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,
+8 -50
View File
@@ -2754,32 +2754,12 @@ router.get("/tracks/:id/stream", async (req, res) => {
`[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}` `[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}`
); );
res.sendFile( await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType);
filePath, streamingService.destroy();
{ logger.debug(
headers: { `[STREAM] File sent successfully: ${path.basename(
"Content-Type": mimeType, filePath
"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;
@@ -2812,30 +2792,8 @@ router.get("/tracks/:id/stream", async (req, res) => {
absolutePath absolutePath
); );
res.sendFile( await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType);
filePath, streamingService.destroy();
{
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,4 +1,6 @@
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";
@@ -384,6 +386,95 @@ 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
*/ */
+8 -3
View File
@@ -6,6 +6,7 @@
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";
@@ -700,10 +701,14 @@ class SoulseekService {
return { success: false, error: "Not connected" }; return { success: false, error: "Not connected" };
} }
// Ensure destination directory exists // Ensure destination directory exists (idempotent - won't fail if exists)
const destDir = path.dirname(destPath); const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) { try {
fs.mkdirSync(destDir, { recursive: true }); await mkdir(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(
+4 -7
View File
@@ -78,14 +78,11 @@ class WikidataService {
LIMIT 1 LIMIT 1
`; `;
const response = await axios.get("https://query.wikidata.org/sparql", { const response = await this.client.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 || [];
@@ -100,7 +97,7 @@ class WikidataService {
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
// Get English Wikipedia article title // Get English Wikipedia article title
const response = await axios.get( const response = await this.client.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json` `https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
); );
@@ -110,7 +107,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 axios.get( const summaryResponse = await this.client.get(
"https://en.wikipedia.org/api/rest_v1/page/summary/" + "https://en.wikipedia.org/api/rest_v1/page/summary/" +
encodeURIComponent(enWikiTitle) encodeURIComponent(enWikiTitle)
); );
@@ -129,7 +126,7 @@ class WikidataService {
wikidataId: string wikidataId: string
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const response = await axios.get( const response = await this.client.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json` `https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
); );
+5 -2
View File
@@ -121,9 +121,12 @@ async function migrateExistingSoulseekFiles(musicPath: string): Promise<void> {
continue; continue;
} }
// Create destination directory // Create destination directory (idempotent - won't fail if exists)
if (!fs.existsSync(destDir)) { try {
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: "58px", marginTop: "calc(58px + env(safe-area-inset-top, 0px))",
marginBottom: marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)", "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}} }}
+1
View File
@@ -17,6 +17,7 @@ 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" },
+4 -1
View File
@@ -142,7 +142,10 @@ 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={{ height: isMobileOrTablet ? "58px" : "64px" }} style={{
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 ? (
+2 -1
View File
@@ -247,7 +247,8 @@ export function SeekSlider({
> >
<div <div
className={cn( className={cn(
"h-full rounded-full relative transition-all duration-150", "h-full rounded-full relative",
!isDragging && "transition-all duration-150",
styles.progress styles.progress
)} )}
style={{ width: `${displayProgress}%` }} style={{ width: `${displayProgress}%` }}
+5
View File
@@ -13,6 +13,11 @@ 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);
+80 -7
View File
@@ -1,4 +1,5 @@
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 {
@@ -113,6 +114,7 @@ class ApiClient {
if (this.token) { if (this.token) {
this.tokenInitialized = true; this.tokenInitialized = true;
} }
// Note: Refresh token is loaded on-demand via getRefreshToken()
} }
} }
@@ -153,19 +155,31 @@ class ApiClient {
this.baseUrl = ""; this.baseUrl = "";
} }
// Store JWT token // Store JWT token and optionally refresh token
setToken(token: string) { setToken(token: string, refreshToken?: 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);
}
} }
} }
// Clear JWT token // Get refresh token from storage
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);
} }
} }
@@ -177,15 +191,56 @@ 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 } = {} options: RequestInit & { silent404?: boolean; _retryCount?: number } = {}
): Promise<T> { ): Promise<T> {
const { silent404, ...fetchOptions } = options; const { silent404, _retryCount = 0, ...fetchOptions } = options;
const headers: HeadersInit = { const headers: HeadersInit = {
"Content-Type": "application/json", "Content-Type": "application/json",
...fetchOptions.headers, ...fetchOptions.headers,
@@ -217,6 +272,23 @@ 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;
@@ -260,6 +332,7 @@ 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;
@@ -274,9 +347,9 @@ class ApiClient {
body: JSON.stringify({ username, password, token }), body: JSON.stringify({ username, password, token }),
}); });
// If login returned a JWT token, store it // If login returned JWT tokens, store them
if (data.token) { if (data.token) {
this.setToken(data.token); this.setToken(data.token, data.refreshToken);
} }
// Return user data in consistent format // Return user data in consistent format
+39 -17
View File
@@ -65,6 +65,7 @@ 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:
@@ -112,6 +113,14 @@ 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")
@@ -376,14 +385,18 @@ 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) -> Optional[Any]: def load_audio(self, file_path: str, sample_rate: int = 16000, max_duration: int = 90) -> Optional[Any]:
"""Load audio file as mono signal""" """Load up to max_duration seconds of audio as mono signal (to limit memory usage)"""
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}")
@@ -514,12 +527,17 @@ class AudioAnalyzer:
result['_error'] = 'Essentia library not installed' result['_error'] = 'Essentia library not installed'
return result return result
# Load audio at different sample rates for different algorithms # Limit memory: only analyze up to MAX_ANALYZE_SECONDS (default 90s)
audio_44k = self.load_audio(file_path, 44100) MAX_ANALYZE_SECONDS = int(os.getenv('MAX_ANALYZE_SECONDS', '90'))
audio_16k = self.load_audio(file_path, 16000) try:
# 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)
@@ -586,7 +604,10 @@ 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
for i in range(0, len(audio_44k) - frame_size, hop_size): max_frames = int((44100 * MAX_ANALYZE_SECONDS - 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)
@@ -599,7 +620,6 @@ 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
@@ -616,7 +636,6 @@ 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)
@@ -632,22 +651,25 @@ 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]: