Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d71da230 | |||
| bddea9ef36 | |||
| 0ac805b6fc | |||
| ce597a318e | |||
| ffb8bda9d1 | |||
| d78eaed15b |
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Publish Docker Image
|
name: Release ${{ github.ref_name }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
+279
@@ -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
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+200
@@ -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 "=============================================================================="
|
||||||
Executable
+107
@@ -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 "=============================================================================="
|
||||||
@@ -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 $$;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}%` }}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user