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
20 changed files with 950 additions and 102 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
# ==============================================================================
+46
View File
@@ -5,6 +5,52 @@ 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 ## [1.3.2] - 2025-01-07
### Fixed ### Fixed
+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
+10
View File
@@ -275,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.
+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..."
+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}%` }}
+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]: