diff --git a/.env.example b/.env.example index 73b2fa9..8abb180 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,12 @@ # Jellyfin Server Configuration JELLYFIN_URL=http://jellyfin:8096 -JELLYFIN_API_KEY=your_jellyfin_api_key_here +JELLYFIN_API_KEY= +PLAYLIST_DIR_HOST=/host/path/to/jellyfin/config/data/playlists +MUSIC_DIR_HOST=/host/path/to/music +MUSIC_DIR_CONTAINER=/jellyfin/container/path/to/music # Playlist Configuration -PLAYLIST_FOLDER=/app/playlists GENERATION_INTERVAL=24 MAX_TRACKS_PER_PLAYLIST=100 MIN_TRACKS_PER_PLAYLIST=5 diff --git a/Dockerfile b/Dockerfile index 46bbdd9..19fc473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,23 +2,16 @@ FROM python:3.11-alpine # Set working directory and copy app files WORKDIR /app -COPY . /app +COPY app /app RUN chmod +x /app/start.sh # Install required packages -RUN pip install --no-cache-dir -r requirements.txt - -# Create directories for playlists, logs, config, and cover -RUN mkdir -p playlists logs config cover - -# Copy default cover files to a temporary location -COPY data/cover default_cover +RUN pip install --no-cache-dir -r /app/requirements.txt # Set environment variables ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 ENV JELLYFIN_URL=http://jellyfin:8096 -ENV PLAYLIST_FOLDER=/app/playlists ENV LOG_LEVEL=INFO ENV GENERATION_INTERVAL=24 ENV ENABLE_WEB_UI=true diff --git a/README.md b/README.md index 307787a..5ae31e8 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ docker run -p 5000:5000 jonasmore/jellyjams - **Custom Generated Covers** - "This is [Artist]" text overlays on artist folder images - **Spotify Integration** - Automatic artist playlist cover downloads from Spotify - **Predefined Custom Covers** - Manual cover art for specific playlists -- **Smart Fallbacks** - Generic covers per playlist type ("Top Tracks - all.jpg") -- **Artist Folder Integration** - Uses existing folder.jpg from music directories +- **Smart Fallbacks** - Generic covers per playlist type ("Top Tracks - all.ext") +- **Multi-format Support** - Searches for images in multiple formats (JPG, JPEG, PNG, WebP, AVIF, BMP) +- **Artist Folder Integration** - Uses existing folder.ext from music directories - **Unicode Support** - Handles special characters in artist names (alt‐J, Sigur RΓ³s, etc.) -- **Extension Preservation** - Maintains original image formats (PNG, JPG) +- **Extension Preservation** - Maintains original image format when copying. +- **Generated Image Format** - Saves generated images as WebP for high quality and compression. ### 🌐 Modern Web Interface - **Beautiful Dark Theme** - Modern, responsive design @@ -108,21 +110,23 @@ WEBUI_BASIC_AUTH_PASSWORD=your_password - **Custom Generated Covers** - "This is [Artist]" text overlays on artist folder images - **Spotify Integration** - Automatic artist playlist cover downloads from Spotify - **Predefined Custom Covers** - Manual cover art for specific playlists -- **Smart Fallbacks** - Generic covers per playlist type ("Top Tracks - all.jpg") -- **Artist Folder Integration** - Uses existing folder.jpg from music directories +- **Smart Fallbacks** - Generic covers per playlist type ("Top Tracks - all.ext") +- **Multi-format Support** - Searches for images in multiple formats (JPG, JPEG, PNG, WebP, AVIF, BMP) +- **Artist Folder Integration** - Uses existing folder.ext from music directories - **Unicode Support** - Handles special characters in artist names (alt‐J, Sigur RΓ³s, etc.) -- **Extension Preservation** - Maintains original image formats (PNG, JPG) +- **Extension Preservation** - Maintains original image format when copying. +- **Generated Image Format** - Saves generated images as WebP for high quality and compression. ### 🎯 Cover Art Priority System 1. **Custom Generated Covers** (Artist playlists) 2. **Spotify Cover Art** (Artist playlists, if enabled) 3. **Predefined Custom Covers** (Manual covers) -4. **Artist Folder Fallback** (Uses existing folder.jpg) +4. **Artist Folder Fallback** (Uses existing folder.ext) 5. **Generic Fallbacks** (Type-specific defaults) #### πŸ–ΌοΈ Custom Generated Covers For artist playlists, JellyJams automatically generates professional "This is [Artist]" covers: -- Uses artist's existing folder.jpg as background +- Uses artist's existing folder.ext as background - Adds stylized text overlay with adaptive colors - Handles Unicode characters (alt‐J, Sigur RΓ³s, MΓΆtley CrΓΌe) - High-quality PNG output with text shadows @@ -130,17 +134,17 @@ For artist playlists, JellyJams automatically generates professional "This is [A #### πŸ“ Predefined Custom Covers Place custom images in your cover directory (mapped to `/app/cover`): -- Exact playlist name matching: `"Top Tracks - Jonas.jpg"` -- Generic fallbacks: `"Top Tracks - all.png"` -- Decade-specific covers: `"Back to the 1990s.jpg"` -- Genre-specific covers: `"Jazz Radio.jpg"` +- Exact playlist name matching: `"Top Tracks - Jonas.ext"` +- Generic fallbacks: `"Top Tracks - all.ext"` +- Decade-specific covers: `"Back to the 1990s.ext"` +- Genre-specific covers: `"Jazz Radio.ext"` #### 🎡 Artist Folder Integration JellyJams can use existing cover art from your music library: -- Searches for `folder.jpg`, `cover.jpg`, `artist.jpg` in artist directories -- Supports multiple music directory paths (`/app/music`, `/music`, `/media`, etc.) +- Searches for `folder.ext`, `cover.ext`, `artist.ext` in artist directories +- You set the music directory path (in .env) to the same path you set in Jellyfin - Case-insensitive artist folder matching -- Multiple image format support (JPG, PNG, WebP) +- Multiple image format support (JPG, PNG, WebP, AVIF, BMP) #### πŸ”„ Update Covers Feature Refresh cover art for existing playlists without regenerating: @@ -164,14 +168,14 @@ JellyJams automatically triggers a Jellyfin media library scan after playlist cr JellyJams creates playlists in the following format: ``` -πŸ“ Playlists/ -β”œβ”€β”€ Rock Radio/ +πŸ“/playlists/ +β”œβ”€β”€ πŸ“Rock Radio/ β”‚ └── playlist.xml -β”œβ”€β”€ Jazz Radio/ +β”œβ”€β”€ πŸ“Jazz Radio/ β”‚ └── playlist.xml -β”œβ”€β”€ Back to the 1970s/ +β”œβ”€β”€ πŸ“Back to the 1970s/ β”‚ └── playlist.xml -└── This is The Beatles!/ +└── πŸ“This is The Beatles!/ └── playlist.xml ``` @@ -203,41 +207,62 @@ version: '3.8' services: jellyjams: - build: . + image: jonasmore/jellyjams:latest container_name: jellyjams environment: - - JELLYFIN_URL=${JELLYFIN_URL} + # Default values are set here like ${VAR_NAME:-DEFAULT_VALUE} + # in case they aren't provided in the .env file. If you use the + # .env file, there is no need to change the default values. + + # Essential container settings (not configurable via web UI) + - JELLYFIN_URL=${JELLYFIN_URL:-http://jellyfin:8096} - JELLYFIN_API_KEY=${JELLYFIN_API_KEY} - - ENABLE_WEB_UI=true + - ENABLE_WEB_UI=${ENABLE_WEB_UI:-true} + - WEB_PORT=${WEB_PORT:-5000} + # Default fallback values (web UI settings will override these) + - LOG_LEVEL=${LOG_LEVEL:-DEBUG} + - GENERATION_INTERVAL=${GENERATION_INTERVAL} + - MAX_TRACKS_PER_PLAYLIST=${MAX_TRACKS_PER_PLAYLIST} + - MIN_TRACKS_PER_PLAYLIST=${MIN_TRACKS_PER_PLAYLIST} + - EXCLUDED_GENRES=${EXCLUDED_GENRES} + - SHUFFLE_TRACKS=${SHUFFLE_TRACKS} + - PLAYLIST_TYPES=${PLAYLIST_TYPES} + # Web UI Security (environment variables override web UI settings) + - WEBUI_BASIC_AUTH_ENABLED=${WEBUI_BASIC_AUTH_ENABLED} + - WEBUI_BASIC_AUTH_USERNAME=${WEBUI_BASIC_AUTH_USERNAME} + - WEBUI_BASIC_AUTH_PASSWORD=${WEBUI_BASIC_AUTH_PASSWORD} + # Discord Notifications + - DISCORD_WEBHOOK_ENABLED=${DISCORD_WEBHOOK_ENABLED} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} volumes: - - jellyjams_playlists:/app/playlists - - jellyjams_logs:/app/logs - - jellyjams_config:/app/config - # Music directory for cover art generation (adjust path for your system) - - /mnt/user/media/data/music:/mnt/user/media/data/music:ro + # JellyJames app data - bind to existing host directory + - ./jellyjams:/data + # Ready-only access to music directory for cover art generation + - ${MUSIC_DIR_HOST}:${MUSIC_DIR_CONTAINER}:ro + # Jellyfin playlists directory for playlist and art management + - ${PLAYLIST_DIR_HOST}:/playlists ports: - - "5000:5000" + - "${WEB_PORT:-5000}:${WEB_PORT:-5000}" restart: unless-stopped - -volumes: - jellyjams_playlists: - jellyjams_logs: - jellyjams_config: ``` ### Unraid Deployment -For Unraid users, use bind mounts to `/mnt/user/appdata/jellyjams/`: +For Unraid users, use bind app data to `/mnt/user/appdata/jallyjams/` for persistent storage: ```yaml -volumes: - # Playlist folder location of Jellyfin - - /mnt/user/appdata/Jellyfin/data/playlists:/app/playlists - # JellyJams settings and logs - - /mnt/user/appdata/jellyjams/logs:/app/logs - - /mnt/user/appdata/jellyjams/config:/app/config - # Music directory for cover art generation (adjust path for your system) - - /mnt/user/media/data/music:/mnt/user/media/data/music:ro + volumes: + # JellyJames app data + - /mnt/user/appdata/jellyjams:/data + # Ready-only access to music directory for cover art generation + - ${MUSIC_DIR_HOST}:${MUSIC_DIR_CONTAINER}:ro + # Jellyfin playlists directory for playlist and art management + - ${PLAYLIST_DIR_HOST}:/playlists + +# Set these vars in your .env +PLAYLIST_DIR_HOST=/mnt/user/appdata/jellyfin/data/playlists +MUSIC_DIR_HOST=/mnt/user/media/data/music +MUSIC_DIR_CONTAINER=/mnt/user/media/data/music ``` ## πŸ”§ API Integration @@ -249,6 +274,7 @@ JellyJams uses the Jellyfin REST API to: - Handle semicolon-separated genre strings - Test connection status - Retrieve audio item details +- Retrieve the primary image of artists ## 🎨 Web UI Features @@ -282,19 +308,25 @@ JellyJams uses the Jellyfin REST API to: ``` jellyjams/ -β”œβ”€β”€ vibecodeplugin.py # Main playlist generator -β”œβ”€β”€ webapp.py # Flask web UI -β”œβ”€β”€ start.sh # Container startup script +β”œβ”€β”€ .env.example # Environment template β”œβ”€β”€ Dockerfile # Container definition β”œβ”€β”€ docker-compose.yml # Docker Compose config -β”œβ”€β”€ requirements.txt # Python dependencies -β”œβ”€β”€ .env.example # Environment template -└── templates/ # HTML templates - β”œβ”€β”€ base.html - β”œβ”€β”€ index.html - β”œβ”€β”€ settings.html - β”œβ”€β”€ playlists.html - └── logs.html +└── app/ # Container app files + β”œβ”€β”€ vibecodeplugin.py # Main playlist generator + β”œβ”€β”€ webapp.py # Flask web UI + β”œβ”€β”€ start.sh # Container startup script + β”œβ”€β”€ requirements.txt # Python dependencies + └── cover # Default playlist images + β”œβ”€β”€ Playlist Name.jpg + └── static/ # WebUI assets + └── css/ + β”œβ”€β”€ app.css + └── templates/ # HTML templates + β”œβ”€β”€ base.html + β”œβ”€β”€ index.html + β”œβ”€β”€ settings.html + β”œβ”€β”€ playlists.html + └── logs.html ``` ## 🀝 Contributing diff --git a/SETTINGS.md b/SETTINGS.md index 7c79b72..e1ccd98 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -32,26 +32,35 @@ JellyJams supports two configuration methods: Set in your `.env` file or Docker environment. These serve as defaults. ### 2. Web UI Settings -Override environment variables through the web interface at `http://localhost:5000/settings`. These settings are persistent and take precedence over environment variables. +Override environment variables through the web interface at `http://localhost:{WEB_PORT}/settings`. These settings are persistent and take precedence over environment variables. ## 🎯 Essential Settings -### Jellyfin Connection +### Jellyfin Server Configuration | Variable | Description | Required | Default | |----------|-------------|----------|---------| -| `JELLYFIN_URL` | Your Jellyfin server URL | βœ… Yes | - | +| `JELLYFIN_URL` | Your Jellyfin server URL | βœ… Yes | http://jellyfin:8096 | | `JELLYFIN_API_KEY` | Jellyfin API key with media access | βœ… Yes | - | +| `PLAYLIST_DIR_HOST` | Path to Jellyfin playlists on host | βœ… Yes | ./jellyfin/config/data/playlists | +| `MUSIC_DIR_HOST` | Path to music on host | No | - | +| `MUSIC_DIR_CONTAINER` | Path to music in Jellyfin container | No | - | **Example:** ```bash JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your_32_character_api_key_here +PLAYLIST_DIR_HOST=/host/path/to/jellyfin/config/data/playlists +MUSIC_DIR_HOST=/host/path/to/music +MUSIC_DIR_CONTAINER=/jellyfin/container/path/to/music ``` +**Notes:** +- `PLAYLIST_DIR_HOST` - JellyJams needs direct R/W access to Jellyfin's playlists directory. +- `MUSIC_DIR_HOST` - Read-only access to you Jellyfin music library is needed if you want Jellyjams to pull artwork from there. +- `MUSIC_DIR_CONTAINER` - The music needs to be mapped to the same directory in the container as it is in Jellyfin. This is because JellyJams gets the path of music sub-directories from the Jellyfin API, which provides the path as it is in the Jellyfin container. ### Container Settings | Variable | Description | Required | Default | |----------|-------------|----------|---------| -| `PLAYLIST_FOLDER` | Container directory for playlists | No | `/app/playlists` | | `ENABLE_WEB_UI` | Enable web interface | No | `true` | | `WEB_PORT` | Web UI port | No | `5000` | | `LOG_LEVEL` | Logging verbosity | No | `INFO` | @@ -165,13 +174,13 @@ DISCOVERY_MAX_SONGS_PER_ARTIST=3 ### Custom Cover Art JellyJams supports custom playlist covers with intelligent fallback: -1. **Exact Match**: `"Top Tracks - Jonas.jpg"` -2. **Generic Fallback**: `"Top Tracks - all.png"` +1. **Exact Match**: `"Top Tracks - Jonas.ext"` +2. **Generic Fallback**: `"Top Tracks - all.ext"` 3. **Spotify Fallback**: For artist playlists -**Supported Formats**: `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp` +**Supported Formats**: `.jpg`, `.jpeg`, `.png`, `.webp`, `.avif`, `.bmp` -**Docker Volume**: Map your cover directory to `/app/cover` +**Docker Volume**: Put your 'cover' directory in the directory that you map to `/data` #### Other Playlist Types 1. **Predefined Custom Covers** - Exact name matching @@ -184,33 +193,23 @@ For artist playlists, JellyJams automatically generates professional covers: | Feature | Description | |---------|-------------| -| **Source Image** | Uses artist's `folder.jpg` from music directory | +| **Source Image** | Uses artist's `folder.ext` from music directory | | **Text Overlay** | "This is [Artist]" with adaptive colors | | **Unicode Support** | Handles special characters (alt‐J, Sigur RΓ³s, MΓΆtley CrΓΌe) | -| **Quality** | High-resolution PNG/JPG | +| **Quality** | High-resolution Webp | | **Color Analysis** | Automatic brightness detection for optimal contrast | -#### Music Directory Integration -JellyJams searches these paths for artist folders: -- `/mnt/user/media/data/music/[Artist]/` (Unraid) -- `/app/music/[Artist]/` -- `/music/[Artist]/` -- `/media/[Artist]/` -- `/data/music/[Artist]/` - -#### Supported Cover Files -- `folder.jpg`, `folder.jpeg`, `folder.png` -- `cover.jpg`, `cover.jpeg`, `cover.png` -- `artist.jpg`, `artist.jpeg`, `artist.png` -- `thumb.jpg`, `thumb.jpeg`, `thumb.png` +#### Supported Cover File Base Names and Extension +- `folder`, `cover`, `artist`, `thumb`, `front` +- `.jpg`, `.jpeg`, `.png`, `.webp`, `.avif`, `.bmp` ### πŸ“ Predefined Custom Covers -Place custom images in your cover directory (mapped to `/app/cover/`): +Place custom images in your appdata/cover directory (mapped to `/data/cover/`): #### Directory Structure Examples ``` -/app/cover/ +/data/cover/ β”œβ”€β”€ Top Tracks - Jonas.jpg # Personal playlist (specific user) β”œβ”€β”€ Top Tracks - all.jpg # Personal playlist (generic fallback) β”œβ”€β”€ Discovery Mix - Sarah.webp # Personal playlist (specific user) @@ -234,7 +233,7 @@ Place custom images in your cover directory (mapped to `/app/cover/`): ### πŸ”„ Update Covers Feature -Refresh cover art for existing playlists without regenerating them: +Refresh cover art for existing playlists without regenerating playlists: #### Web UI Usage 1. Navigate to **Playlists** page (`/playlists`) @@ -250,7 +249,7 @@ Refresh cover art for existing playlists without regenerating them: - **Error Handling**: Graceful fallbacks with detailed logging #### When to Use -- After adding new cover art files to `/app/cover/` +- After adding new cover art files to `/data/cover/` - When Spotify integration settings change - After updating music library with new artist folders - To fix missing or corrupted cover art @@ -258,10 +257,16 @@ Refresh cover art for existing playlists without regenerating them: ### πŸ› οΈ Cover Art Troubleshooting #### Custom Generated Covers Not Working -**Symptoms**: Artist playlists have no cover art or fallback to generic covers +**Symptoms**: Artist playlists have no cover art or fallback to album or generic covers **Solutions**: +1. **Check Artist Primary Image**: + - JellyJams tries to fetch the artist's primary image using the Jellyfin API first. + - Does the artist have a primary image set in Jellyfin? + - Music directory access is not required for this method. + 1. **Check Music Directory Access**: + - If getting the primary image from the API fails, the next step is searching the artist's directory. ```bash # Verify Docker volume mount includes music directory docker-compose logs jellyjams | grep "music directory" @@ -274,6 +279,7 @@ Refresh cover art for existing playlists without regenerating them: β”‚ β”œβ”€β”€ folder.jpg # βœ… This works β”‚ β”œβ”€β”€ cover.png # βœ… This works β”‚ └── Album/ + β”‚ └── cover.webp # βœ… Album image used as fallback └── Another Artist/ └── artist.jpeg # βœ… This works ``` @@ -290,19 +296,20 @@ Refresh cover art for existing playlists without regenerating them: ``` #### Predefined Covers Not Loading -**Symptoms**: Custom covers in `/app/cover/` are ignored +**Symptoms**: Custom covers in `/data/cover/` are ignored **Solutions**: 1. **Verify Docker Volume Mount**: + - Put your 'cover' directory in your 'appdata' directory. ```yaml volumes: - - /host/path/covers:/app/cover # Must be mounted + - /host/path/appdata:/data # Must be mounted ``` 2. **Check File Permissions**: ```bash # Ensure container can read cover files - ls -la /host/path/covers/ + ls -la /host/path/appdata/cover/ ``` 3. **Verify Exact Naming**: @@ -311,7 +318,7 @@ Refresh cover art for existing playlists without regenerating them: - Include file extensions 4. **Supported Formats**: - - `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp` + - `.jpg`, `.jpeg`, `.png`, `.webp`, `.avif`, `.bmp` - Other formats may not be recognized #### Spotify Integration Issues @@ -406,7 +413,7 @@ The web UI settings page (`/settings`) provides: - **User Management** - Select users for personalized playlists - **Spotify Integration** - Test Spotify API and view statistics - **Live Validation** - Real-time setting validation -- **Persistent Storage** - Settings saved to `/app/config/settings.json` +- **Persistent Storage** - Settings saved to `/data/config/settings.json` ### Settings Priority 1. **Web UI Settings** (highest priority) @@ -418,26 +425,32 @@ The web UI settings page (`/settings`) provides: ### Required Volumes ```yaml volumes: - - /host/path/playlists:/app/playlists # Playlist storage - - /host/path/logs:/app/logs # Application logs - - /host/path/config:/app/config # Web UI settings + # JellyJames app data (config, logs, cover) + - /host/path/appdata:/data + # Jellyfin playlists directory for playlist and art management + - ${PLAYLIST_DIR_HOST}:/playlists ``` ### Optional Volumes ```yaml volumes: - - /host/path/covers:/app/cover # Custom cover art + # Ready-only access to music directory for cover art generation + - ${MUSIC_DIR_HOST}:${MUSIC_DIR_CONTAINER}:ro ``` ### Unraid Configuration -For Unraid users, use the provided `docker-compose-unraid.yml`: +For Unraid users, your volumes and associated variables may look something like this: ```yaml volumes: - - /mnt/user/appdata/jellyjams/playlists:/app/playlists - - /mnt/user/appdata/jellyjams/logs:/app/logs - - /mnt/user/appdata/jellyjams/config:/app/config - - /mnt/user/appdata/jellyjams/cover:/app/cover + - /mnt/user/appdata/jellyjams:/data + - ${PLAYLIST_DIR_HOST}:/playlists + - ${MUSIC_DIR_HOST}:${MUSIC_DIR_CONTAINER}:ro +``` +```.env +PLAYLIST_DIR_HOST=-/mnt/user/appdata/jellyfin/data/playlists +MUSIC_DIR_HOST=/mnt/user/media/data/music +MUSIC_DIR_CONTAINER=/mnt/user/media/data/music ``` ## πŸ“ Examples @@ -447,6 +460,7 @@ volumes: # .env file for basic setup JELLYFIN_URL=http://localhost:8096 JELLYFIN_API_KEY=your_api_key_here +PLAYLIST_DIR_HOST=/path/to/jellyfin/config/data/playlists PLAYLIST_TYPES=Genre,Year,Artist MAX_TRACKS_PER_PLAYLIST=50 MIN_TRACKS_PER_PLAYLIST=10 @@ -457,6 +471,9 @@ MIN_TRACKS_PER_PLAYLIST=10 # .env file for advanced setup JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your_api_key_here +PLAYLIST_DIR_HOST=/path/to/jellyfin/config/data/playlists +MUSIC_DIR_HOST=/host/path/to/music +MUSIC_DIR_CONTAINER=/jellyfin/container/path/to/music # Playlist generation PLAYLIST_TYPES=Genre,Year,Artist,Personal @@ -503,20 +520,23 @@ EXCLUDED_GENRES=Classical,Opera,Spoken Word,Audiobook,Podcast - Ensure `TRIGGER_LIBRARY_SCAN=true` - Check Jellyfin API key permissions - Verify playlist folder is accessible + +2. **Playlists not appearing in JellyJams** + - Ensure `PLAYLIST_DIR_HOST` = your host path to the Jellyin playlists directory + - Verify Jellyfin playlists directory is mapped to /playlists -2. **Cover art not copying** - - Check Docker volume mount for `/app/cover` - - Verify file permissions on cover directory +3. **Cover art not copying** + - Verify file permissions on cover in your appdata directory - Check logs for detailed error messages -3. **Personalized playlists empty** +4. **Personalized playlists empty** - **Install Required Plugin**: Ensure [Jellyfin Playback Reporting Plugin](https://github.com/jellyfin/jellyfin-plugin-playbackreporting) is installed and enabled - Increase `PERSONAL_PLAYLIST_MIN_USER_TRACKS` - Check user has listening history in Jellyfin - Verify user selection in settings - Confirm plugin is collecting playback data -4. **Spotify integration not working** +5. **Spotify integration not working** - Test connection in web UI settings - Verify Client ID and Secret are correct - Check Spotify app permissions @@ -536,4 +556,4 @@ This provides comprehensive information about: --- -For additional help, check the application logs at `/app/logs/` or enable debug logging for detailed troubleshooting information. +For additional help, check the application logs at `/data/logs/` or enable debug logging for detailed troubleshooting information. You may also check the container logs using `docker logs jellyjams` from the host CLI. diff --git a/LICENSE b/app/LICENSE similarity index 100% rename from LICENSE rename to app/LICENSE diff --git a/data/cover/Afrobeat Radio.jpg b/app/cover/Afrobeat Radio.jpg similarity index 100% rename from data/cover/Afrobeat Radio.jpg rename to app/cover/Afrobeat Radio.jpg diff --git a/data/cover/Alternative Radio.jpg b/app/cover/Alternative Radio.jpg similarity index 100% rename from data/cover/Alternative Radio.jpg rename to app/cover/Alternative Radio.jpg diff --git a/data/cover/Back to the 1800s.jpg b/app/cover/Back to the 1800s.jpg similarity index 100% rename from data/cover/Back to the 1800s.jpg rename to app/cover/Back to the 1800s.jpg diff --git a/data/cover/Back to the 1900s.jpg b/app/cover/Back to the 1900s.jpg similarity index 100% rename from data/cover/Back to the 1900s.jpg rename to app/cover/Back to the 1900s.jpg diff --git a/data/cover/Back to the 1910s.jpg b/app/cover/Back to the 1910s.jpg similarity index 100% rename from data/cover/Back to the 1910s.jpg rename to app/cover/Back to the 1910s.jpg diff --git a/data/cover/Back to the 1920s.jpg b/app/cover/Back to the 1920s.jpg similarity index 100% rename from data/cover/Back to the 1920s.jpg rename to app/cover/Back to the 1920s.jpg diff --git a/data/cover/Back to the 1930s.jpg b/app/cover/Back to the 1930s.jpg similarity index 100% rename from data/cover/Back to the 1930s.jpg rename to app/cover/Back to the 1930s.jpg diff --git a/data/cover/Back to the 1940s.jpg b/app/cover/Back to the 1940s.jpg similarity index 100% rename from data/cover/Back to the 1940s.jpg rename to app/cover/Back to the 1940s.jpg diff --git a/data/cover/Back to the 1950s.jpg b/app/cover/Back to the 1950s.jpg similarity index 100% rename from data/cover/Back to the 1950s.jpg rename to app/cover/Back to the 1950s.jpg diff --git a/data/cover/Back to the 1960s.jpg b/app/cover/Back to the 1960s.jpg similarity index 100% rename from data/cover/Back to the 1960s.jpg rename to app/cover/Back to the 1960s.jpg diff --git a/data/cover/Back to the 1970s.jpg b/app/cover/Back to the 1970s.jpg similarity index 100% rename from data/cover/Back to the 1970s.jpg rename to app/cover/Back to the 1970s.jpg diff --git a/data/cover/Back to the 1980s.jpg b/app/cover/Back to the 1980s.jpg similarity index 100% rename from data/cover/Back to the 1980s.jpg rename to app/cover/Back to the 1980s.jpg diff --git a/data/cover/Back to the 1990s.jpg b/app/cover/Back to the 1990s.jpg similarity index 100% rename from data/cover/Back to the 1990s.jpg rename to app/cover/Back to the 1990s.jpg diff --git a/data/cover/Back to the 2000s.jpg b/app/cover/Back to the 2000s.jpg similarity index 100% rename from data/cover/Back to the 2000s.jpg rename to app/cover/Back to the 2000s.jpg diff --git a/data/cover/Back to the 2010s.jpg b/app/cover/Back to the 2010s.jpg similarity index 100% rename from data/cover/Back to the 2010s.jpg rename to app/cover/Back to the 2010s.jpg diff --git a/data/cover/Back to the 2020s.jpg b/app/cover/Back to the 2020s.jpg similarity index 100% rename from data/cover/Back to the 2020s.jpg rename to app/cover/Back to the 2020s.jpg diff --git a/data/cover/Back to the 2030s.jpg b/app/cover/Back to the 2030s.jpg similarity index 100% rename from data/cover/Back to the 2030s.jpg rename to app/cover/Back to the 2030s.jpg diff --git a/data/cover/Blues Radio.jpg b/app/cover/Blues Radio.jpg similarity index 100% rename from data/cover/Blues Radio.jpg rename to app/cover/Blues Radio.jpg diff --git a/data/cover/Country Radio.jpg b/app/cover/Country Radio.jpg similarity index 100% rename from data/cover/Country Radio.jpg rename to app/cover/Country Radio.jpg diff --git a/data/cover/Discovery Mix - all.jpg b/app/cover/Discovery Mix - all.jpg similarity index 100% rename from data/cover/Discovery Mix - all.jpg rename to app/cover/Discovery Mix - all.jpg diff --git a/data/cover/Electronic Radio.jpg b/app/cover/Electronic Radio.jpg similarity index 100% rename from data/cover/Electronic Radio.jpg rename to app/cover/Electronic Radio.jpg diff --git a/data/cover/Fallback Radio.jpg b/app/cover/Fallback Radio.jpg similarity index 100% rename from data/cover/Fallback Radio.jpg rename to app/cover/Fallback Radio.jpg diff --git a/data/cover/Genre Mix - all.jpg b/app/cover/Genre Mix - all.jpg similarity index 100% rename from data/cover/Genre Mix - all.jpg rename to app/cover/Genre Mix - all.jpg diff --git a/data/cover/Hip Hop Radio.jpg b/app/cover/Hip Hop Radio.jpg similarity index 100% rename from data/cover/Hip Hop Radio.jpg rename to app/cover/Hip Hop Radio.jpg diff --git a/data/cover/Jazz Radio.jpg b/app/cover/Jazz Radio.jpg similarity index 100% rename from data/cover/Jazz Radio.jpg rename to app/cover/Jazz Radio.jpg diff --git a/data/cover/Metal Radio.jpg b/app/cover/Metal Radio.jpg similarity index 100% rename from data/cover/Metal Radio.jpg rename to app/cover/Metal Radio.jpg diff --git a/data/cover/Pop Radio.jpg b/app/cover/Pop Radio.jpg similarity index 100% rename from data/cover/Pop Radio.jpg rename to app/cover/Pop Radio.jpg diff --git a/data/cover/Punk Radio.jpg b/app/cover/Punk Radio.jpg similarity index 100% rename from data/cover/Punk Radio.jpg rename to app/cover/Punk Radio.jpg diff --git a/data/cover/Recent Favorites - all.jpg b/app/cover/Recent Favorites - all.jpg similarity index 100% rename from data/cover/Recent Favorites - all.jpg rename to app/cover/Recent Favorites - all.jpg diff --git a/data/cover/Rock Radio.jpg b/app/cover/Rock Radio.jpg similarity index 100% rename from data/cover/Rock Radio.jpg rename to app/cover/Rock Radio.jpg diff --git a/data/cover/Schlager Radio.jpg b/app/cover/Schlager Radio.jpg similarity index 100% rename from data/cover/Schlager Radio.jpg rename to app/cover/Schlager Radio.jpg diff --git a/data/cover/Techno Radio.jpg b/app/cover/Techno Radio.jpg similarity index 100% rename from data/cover/Techno Radio.jpg rename to app/cover/Techno Radio.jpg diff --git a/data/cover/Top Tracks - all.jpg b/app/cover/Top Tracks - all.jpg similarity index 100% rename from data/cover/Top Tracks - all.jpg rename to app/cover/Top Tracks - all.jpg diff --git a/jellyjams-transparent.png b/app/jellyjams-transparent.png similarity index 100% rename from jellyjams-transparent.png rename to app/jellyjams-transparent.png diff --git a/app/jellyjams.jpeg b/app/jellyjams.jpeg new file mode 100644 index 0000000..5b07366 Binary files /dev/null and b/app/jellyjams.jpeg differ diff --git a/requirements.txt b/app/requirements.txt similarity index 100% rename from requirements.txt rename to app/requirements.txt diff --git a/app/start.sh b/app/start.sh new file mode 100644 index 0000000..e2f4471 --- /dev/null +++ b/app/start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh + +# JellyJams Container Startup Script + +echo "🎡 Starting JellyJams Generator..." + +# Create app-data directories +mkdir -p /data /data/config /data/logs + +# Put default cover art in /data if not already there +if [ ! -d "/data/cover" ]; then + echo "Moving default cover art to /data/cover" + cp -r /app/cover /data/ +fi + +# Function to run playlist generator in background +run_generator() { + echo "🎯 Starting playlist generator background process..." + python /app/vibecodeplugin.py & + GENERATOR_PID=$! + echo "πŸ“Š Playlist generator started with PID: $GENERATOR_PID" +} + +# Check if web UI is enabled +if [ "$ENABLE_WEB_UI" = "true" ]; then + echo "🌐 Web UI enabled - starting both web UI and playlist generator" + + # Start playlist generator in background + run_generator + + # Start the web application with Gunicorn (with logging to stdout) + echo "🌐 Starting web UI on port ${WEB_PORT}" + exec gunicorn --bind 0.0.0.0:${WEB_PORT} --workers 2 --timeout 300 --access-logfile - --error-logfile - webapp:app +else + echo "🎯 Web UI disabled - running playlist generator only" + + # Run the original playlist generator + exec python /app/vibecodeplugin.py +fi diff --git a/static/css/app.css b/app/static/css/app.css similarity index 100% rename from static/css/app.css rename to app/static/css/app.css diff --git a/templates/base.html b/app/templates/base.html similarity index 100% rename from templates/base.html rename to app/templates/base.html diff --git a/templates/index.html b/app/templates/index.html similarity index 100% rename from templates/index.html rename to app/templates/index.html diff --git a/templates/logs.html b/app/templates/logs.html similarity index 100% rename from templates/logs.html rename to app/templates/logs.html diff --git a/templates/playlists.html b/app/templates/playlists.html similarity index 100% rename from templates/playlists.html rename to app/templates/playlists.html diff --git a/templates/settings.html b/app/templates/settings.html similarity index 100% rename from templates/settings.html rename to app/templates/settings.html diff --git a/templates/spotify_test.html b/app/templates/spotify_test.html similarity index 100% rename from templates/spotify_test.html rename to app/templates/spotify_test.html diff --git a/vibecodeplugin.py b/app/vibecodeplugin.py similarity index 92% rename from vibecodeplugin.py rename to app/vibecodeplugin.py index f5b1d84..c2e3f18 100644 --- a/vibecodeplugin.py +++ b/app/vibecodeplugin.py @@ -13,11 +13,13 @@ import requests import schedule import signal +import re from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from xml.etree.ElementTree import Element, SubElement, tostring from xml.dom import minidom +from io import BytesIO # PIL/Pillow imports for custom cover art generation try: @@ -58,10 +60,12 @@ def normalize_name(s: str) -> str: return s.strip() class Config: def __init__(self): + # Set constants + self.playlist_folder = '/playlists' + # Load environment variables first (as defaults) self.jellyfin_url = os.getenv('JELLYFIN_URL', 'http://jellyfin:8096') self.api_key = os.getenv('JELLYFIN_API_KEY', '') - self.playlist_folder = os.getenv('PLAYLIST_FOLDER', '/app/playlists') self.log_level = os.getenv('LOG_LEVEL', 'INFO') self.generation_interval = int(os.getenv('GENERATION_INTERVAL', '24')) self.max_tracks_per_playlist = int(os.getenv('MAX_TRACKS_PER_PLAYLIST', '100')) @@ -111,7 +115,7 @@ def __init__(self): def load_web_ui_settings(self): """Load settings from web UI JSON file - these take precedence over environment variables""" - config_file = '/app/config/settings.json' + config_file = '/data/config/settings.json' try: if Path(config_file).exists(): with open(config_file, 'r') as f: @@ -521,14 +525,16 @@ def get_artist_cover_art(self, artist_name: str, playlist_dir: Path) -> bool: import time start_time = time.time() self.stats['total_attempts'] += 1 + exts = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.avif'] try: # Check if cover art already exists - cover_path = playlist_dir / 'cover.jpg' - if cover_path.exists(): - self.logger.debug(f"Cover art already exists for {artist_name}") - self.stats['successful_downloads'] += 1 - return True + for ext in exts: + cover_path = playlist_dir / f"cover{ext}" + if cover_path.exists(): + self.logger.debug(f"Cover art already exists for {artist_name}") + self.stats['successful_downloads'] += 1 + return True # Search for Spotify playlist playlist_info = self.search_artist_playlist(artist_name) @@ -628,40 +634,40 @@ def get_statistics(self) -> dict: def setup_logging(config: Config): """Setup logging configuration with timestamps - ensure all logs visible in Docker""" # Ensure log directory exists - log_dir = Path('/app/logs') + log_dir = Path('/data/logs') + log_level_num = logging._nameToLevel[config.log_level] try: log_dir.mkdir(parents=True, exist_ok=True) print(f"πŸ“ Log directory created/verified: {log_dir}") except Exception as e: print(f"⚠️ Could not create log directory {log_dir}: {e}") - # Force DEBUG level for comprehensive logging - log_level = logging.DEBUG - print(f"πŸ”§ Forcing DEBUG level logging for comprehensive output") + # Set log level according to settings/default + print(f"πŸ”§ Log level is set to {config.log_level}") # Create formatters with timestamps formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') # Configure root logger to catch ALL logging root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) + root_logger.setLevel(log_level_num) root_logger.handlers.clear() # Console handler for Docker logs console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.DEBUG) + console_handler.setLevel(log_level_num) console_handler.setFormatter(formatter) root_logger.addHandler(console_handler) # Also setup specific jellyjams logger logger = logging.getLogger('jellyjams') - logger.setLevel(logging.DEBUG) + logger.setLevel(log_level_num) logger.propagate = True # Ensure it propagates to root logger # File handler (with error handling) try: file_handler = logging.FileHandler(log_dir / 'jellyjams.log') - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(log_level_num) file_handler.setFormatter(formatter) root_logger.addHandler(file_handler) print(f"πŸ“ File logging enabled: {log_dir / 'jellyjams.log'}") @@ -673,7 +679,7 @@ def setup_logging(config: Config): logging.getLogger('urllib3').setLevel(logging.WARNING) logging.getLogger('requests').setLevel(logging.WARNING) - print(f"πŸ”§ Comprehensive logging initialized at DEBUG level with timestamps") + print(f"πŸ”§ Logging initialized at {config.log_level} level with timestamps") print(f"πŸ“Š Root logger handlers: {len(root_logger.handlers)} ({[type(h).__name__ for h in root_logger.handlers]})") print(f"πŸ“Š JellyJams logger propagate: {logger.propagate}") @@ -733,6 +739,50 @@ def test_connection(self) -> bool: self.logger.error(f"Failed to connect to Jellyfin: {e}") return False + def get_artist_image_by_name(self, artist_name: str, timeout: float = 10.0) -> Optional[bytes]: + if not artist_name: + raise ValueError("artist_name is required") + + url = f"{self.config.jellyfin_url}/Artists/{artist_name}/Images/Primary/0" + params = {"format": "webp", "maxWidth": 600, "maxHeight": 600} + + # Merge in an Accept header that prefers webp. + headers = {"Accept": "image/webp,image/*;q=0.8,*/*;q=0.5"} + + try: + resp = self.session.get(url, params=params, headers=headers, timeout=timeout) + if resp.status_code == 404: + self.logger.info("Artist not found or no primary image: %r", artist_name) + return None + resp.raise_for_status() + + ctype = resp.headers.get("Content-Type", "").lower() + if "image" not in ctype: + # Jellyfin should return an image; if not, log a short body preview + preview = "" + try: + preview = (resp.text or "")[:300] + except Exception: + pass + self.logger.error("Unexpected response (Content-Type=%s). Body: %s", ctype, preview) + raise requests.exceptions.RequestException( + f"Unexpected response (Content-Type={ctype}). Body: {preview}" + ) + return resp.content + + except requests.exceptions.HTTPError as e: + # Keep an informative log (with short body preview, if any) + body_preview = "" + try: + body_preview = (getattr(e.response, "text", "") or "")[:300] + except Exception: + pass + self.logger.error("Jellyfin HTTP error for %s: %s | %s", url, e, body_preview) + raise + except requests.exceptions.RequestException as e: + self.logger.error("Failed fetching artist image: %s", e) + raise + def get_users(self) -> List[Dict]: """Get all users from Jellyfin""" try: @@ -1033,9 +1083,7 @@ def _get_cached_audio_items(self) -> List[Dict]: def copy_custom_cover_art(self, playlist_name: str, playlist_dir: Path) -> bool: """Copy custom cover art from /app/cover/ directory with fallback system and extension preservation""" try: - # Define the source cover directory (matches Docker volume mount) - cover_source_dir = Path("/app/cover") - + cover_source_dir = Path("/data/cover") self.logger.info(f"Looking for custom cover art for playlist: {playlist_name}") self.logger.info(f"Checking cover source directory: {cover_source_dir}") @@ -1051,7 +1099,7 @@ def copy_custom_cover_art(self, playlist_name: str, playlist_dir: Path) -> bool: self.logger.warning(f"Could not list cover directory contents: {e}") # Look for cover image with playlist name (try common extensions) - extensions = ['.jpg', '.jpeg', '.png', '.webp', '.bmp'] + extensions = ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.bmp'] source_image = None found_extension = None @@ -1145,32 +1193,26 @@ def _apply_decade_cover_art(self, playlist_name: str, playlist_dir: Path) -> boo decade = playlist_name.replace("Back to the ", "").strip() self.logger.info(f"πŸ—“οΈ Looking for decade-specific cover art for: {decade}") - # Define the source cover directory - cover_source_dir = Path("/app/cover") + cover_source_dir = Path("/data/cover") if not cover_source_dir.exists(): self.logger.warning(f"Cover source directory does not exist: {cover_source_dir}") return False # Look for decade-specific cover art files in multiple naming formats - decade_cover_files = [ - # Full playlist name format (e.g., "Back to the 1990s.jpg") - f"{playlist_name}.jpg", - f"{playlist_name}.jpeg", - f"{playlist_name}.png", - # Decade-only format (e.g., "1990s-cover.jpg") - f"{decade}-cover.jpg", - f"{decade}-cover.jpeg", - f"{decade}-cover.png" - ] + # playlist_name is like "Back to the 1990s.jpg" + # Decade-only format is like "1990s-cover.jpg" + names = [playlist_name, f"{decade}-cover"] + exts = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.avif'] source_image = None - for cover_file in decade_cover_files: - potential_file = cover_source_dir / cover_file - if potential_file.exists() and potential_file.is_file(): - source_image = potential_file - self.logger.info(f"πŸ–ΌοΈ Found decade cover art: {source_image}") - break + for base in names: + for ext in exts: + candidate = cover_source_dir / f"{file}{ext}" + if candidate.exists() and candidate.is_file(): + source_image = candidate + self.logger.info(f"πŸ–ΌοΈ Found decade cover art: {source_image}") + break # If no specific decade cover found, try fallback for pre-1900s music if not source_image and decade.endswith('s'): @@ -1178,14 +1220,8 @@ def _apply_decade_cover_art(self, playlist_name: str, playlist_dir: Path) -> boo decade_year = int(decade[:-1]) # Remove 's' and convert to int if decade_year < 1900: self.logger.info(f"πŸ•°οΈ Decade {decade} is before 1900s, trying 1800s fallback...") - fallback_files = [ - "1800s-cover.jpg", - "1800s-cover.jpeg", - "1800s-cover.png" - ] - - for fallback_file in fallback_files: - potential_fallback = cover_source_dir / fallback_file + for ext in exts: + potential_fallback = cover_source_dir / f"1800s-cover{ext}" if potential_fallback.exists() and potential_fallback.is_file(): source_image = potential_fallback self.logger.info(f"πŸ–ΌοΈ Found 1800s fallback cover art: {source_image}") @@ -1227,30 +1263,23 @@ def _apply_genre_cover_art(self, playlist_name: str, genre_name: str, playlist_d try: self.logger.info(f"🎡 Looking for genre cover art for: {genre_name}") - # Define the source cover directory - cover_source_dir = Path("/app/cover") + cover_source_dir = Path("/data/cover") if not cover_source_dir.exists(): self.logger.warning(f"Cover source directory does not exist: {cover_source_dir}") return False # First, try to find predefined genre cover art - predefined_cover_files = [ - f"{genre_name} Radio.jpg", - f"{genre_name} Radio.jpeg", - f"{genre_name} Radio.png", - f"{genre_name}.jpg", - f"{genre_name}.jpeg", - f"{genre_name}.png" - ] - + names = [f"{genre_name} Radio", genre_name] + exts = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.avif'] source_image = None - for cover_file in predefined_cover_files: - potential_file = cover_source_dir / cover_file - if potential_file.exists() and potential_file.is_file(): - source_image = potential_file - self.logger.info(f"πŸ–ΌοΈ Found predefined genre cover art: {source_image}") - break + for base in names: + for ext in exts: + potential_file = cover_source_dir / f"{base}{ext}" + if potential_file.exists() and potential_file.is_file(): + source_image = potential_file + self.logger.info(f"πŸ–ΌοΈ Found predefined genre cover art: {source_image}") + break # If predefined cover found, copy it directly if source_image: @@ -1275,27 +1304,20 @@ def _apply_genre_cover_art(self, playlist_name: str, genre_name: str, playlist_d # If no predefined cover found, generate one using "Fallback Radio.jpg" background self.logger.info(f"🎨 No predefined cover found, generating custom genre cover...") - # Look for "Fallback Radio.jpg" background template - background_files = [ - "Fallback Radio.jpg", - "Fallback Radio.jpeg", - "Fallback Radio.png" - ] - background_image = None - for bg_file in background_files: - potential_bg = cover_source_dir / bg_file + for ext in exts: + potential_bg = cover_source_dir / f"Fallback Radio{ext}" if potential_bg.exists() and potential_bg.is_file(): background_image = potential_bg self.logger.info(f"πŸ–ΌοΈ Found background template: {background_image}") break if not background_image: - self.logger.warning(f"❌ No 'Fallback Radio.jpg' background template found for genre cover generation") + self.logger.warning(f"❌ No 'Fallback Radio' background template found for genre cover generation") return False # Generate custom genre cover with text overlay - destination_image = playlist_dir / "cover.jpg" + destination_image = playlist_dir / "cover.webp" success = self._generate_genre_cover_art(background_image, genre_name, destination_image) if success: @@ -1390,7 +1412,7 @@ def _generate_genre_cover_art(self, background_path: Path, genre_name: str, dest draw.text((x2, y2), line2, font=font, fill=text_color) # Save the final image - background.save(destination, "JPEG", quality=95) + background.save(destination, "JPEG", quality=85) self.logger.info(f"βœ… Generated genre cover art: {destination}") return True @@ -1402,7 +1424,7 @@ def _generate_genre_cover_art(self, background_path: Path, genre_name: str, dest return False def _try_artist_folder_fallback(self, playlist_name: str, playlist_dir: Path) -> bool: - """Generate custom cover art with 'This is ' text overlay from artist folder images""" + """Generate custom cover art with 'This is ' text overlay from artist images""" try: # Only apply this fallback for artist playlists if not playlist_name.startswith("This is "): @@ -1414,11 +1436,11 @@ def _try_artist_folder_fallback(self, playlist_name: str, playlist_dir: Path) -> # Find artist folder and source image source_cover = self._find_artist_cover_image(artist_name) - if not source_cover: + if source_cover is None: return False # Generate custom cover art with text overlay - destination_cover = playlist_dir / "folder.png" + destination_cover = playlist_dir / "folder.webp" success = self._generate_custom_cover_art(source_cover, artist_name, destination_cover) if success: @@ -1427,10 +1449,8 @@ def _try_artist_folder_fallback(self, playlist_name: str, playlist_dir: Path) -> else: # Fallback to simple copy if text overlay fails self.logger.warning("Text overlay failed, falling back to simple copy") - import shutil - file_extension = source_cover.suffix - fallback_destination = playlist_dir / f"folder{file_extension}" - shutil.copy2(source_cover, fallback_destination) + fallback_destination = playlist_dir / "folder.webp" + Image.open(BytesIO(source_cover)).save(fallback_destination) # Ensure cover art is world-readable on host mounts try: @@ -1447,102 +1467,19 @@ def _try_artist_folder_fallback(self, playlist_name: str, playlist_dir: Path) -> self.logger.debug(f"Full traceback: {traceback.format_exc()}") return False - def _find_artist_cover_image(self, artist_name: str) -> Path: - """Find cover image in artist folder using Jellyfin API and common paths""" + def _find_artist_cover_image(self, artist_name: str) -> Optional[bytes]: + """First, try getting artist image from Jellyfin API""" + artist_image = self.jellyfin.get_artist_image_by_name(artist_name) + if not (artist_image is None): + return artist_image + + """Else, search artist folder for image""" self.logger.debug(f"πŸ” Searching for artist folder: {artist_name}") - # First try to get artist info from Jellyfin API artist_path = self._get_artist_path_from_jellyfin(artist_name) if artist_path: self.logger.debug(f"πŸ“‘ Got artist path from Jellyfin API: {artist_path}") - cover_image = self._find_cover_in_directory(artist_path) - if cover_image: - return cover_image - - # Fallback to common paths including Unraid structure - possible_base_paths = [ - Path("/mnt/user/media/data/music"), # Unraid music path - Path("/app/music"), # Common Docker mount point - Path("/music"), # Alternative mount point - Path("/media"), # Another common mount - Path("/data/music"), # Data directory mount - Path("/mnt/music"), # Mount point variant - Path("/jellyfin/music"), # Jellyfin specific - ] - - # Debug: Show which paths exist - self.logger.debug(f"πŸ“ Checking base paths for artist folders:") - for base_path in possible_base_paths: - exists = base_path.exists() - self.logger.debug(f" {base_path}: {'βœ… exists' if exists else '❌ not found'}") - if exists: - try: - # Show first few directories as examples - dirs = [d.name for d in base_path.iterdir() if d.is_dir()][:5] - self.logger.debug(f" Sample directories: {dirs}") - except Exception as e: - self.logger.debug(f" Cannot list directories: {e}") - - # Try to find artist folder in various locations - artist_folder = None - for base_path in possible_base_paths: - if not base_path.exists(): - continue - - self.logger.debug(f"πŸ” Searching in: {base_path}") - - # Try direct artist folder - potential_artist_folder = base_path / artist_name - self.logger.debug(f" Trying exact match: {potential_artist_folder}") - if potential_artist_folder.exists() and potential_artist_folder.is_dir(): - artist_folder = potential_artist_folder - self.logger.debug(f" βœ… Found exact match!") - break - - # Try to find artist folder with case-insensitive search - try: - self.logger.debug(f" Trying case-insensitive search...") - found_dirs = [] - for item in base_path.iterdir(): - if item.is_dir(): - found_dirs.append(item.name) - if item.name.lower() == artist_name.lower(): - artist_folder = item - self.logger.debug(f" βœ… Found case-insensitive match: {item}") - break - - if not artist_folder: - # Show some directories for debugging - sample_dirs = found_dirs[:10] - self.logger.debug(f" No match found. Sample directories: {sample_dirs}") - - if artist_folder: - break - except Exception as e: - self.logger.debug(f" Error searching {base_path}: {e}") - continue - - if not artist_folder: - self.logger.debug(f"❌ No artist folder found for: {artist_name}") - return None - - self.logger.info(f"Found artist folder: {artist_folder}") - - # Look for folder.jpg or other cover art files in the artist folder - cover_files = [ - "folder.jpg", "folder.jpeg", "folder.png", - "cover.jpg", "cover.jpeg", "cover.png", - "artist.jpg", "artist.jpeg", "artist.png", - "thumb.jpg", "thumb.jpeg", "thumb.png" - ] - - for cover_file in cover_files: - potential_cover = artist_folder / cover_file - if potential_cover.exists() and potential_cover.is_file(): - self.logger.info(f"Found artist cover art: {potential_cover}") - return potential_cover - - self.logger.debug(f"No cover art files found in artist folder: {artist_folder}") + return self._find_cover_in_directory(artist_path) return None def _get_artist_path_from_jellyfin(self, artist_name: str) -> Path: @@ -1599,27 +1536,13 @@ def _get_artist_path_from_jellyfin(self, artist_name: str) -> Path: return None - def _find_cover_in_directory(self, directory_path: Path) -> Path: - """Find cover art files in a specific directory""" - if not directory_path or not directory_path.exists(): - return None - - self.logger.debug(f"πŸ” Searching for cover art in: {directory_path}") - - # Look for cover art files in the directory - cover_files = [ - "folder.jpg", "folder.jpeg", "folder.png", - "cover.jpg", "cover.jpeg", "cover.png", - "artist.jpg", "artist.jpeg", "artist.png", - "thumb.jpg", "thumb.jpeg", "thumb.png" - ] - - for cover_file in cover_files: - potential_cover = directory_path / cover_file - if potential_cover.exists() and potential_cover.is_file(): - self.logger.info(f"πŸ–ΌοΈ Found cover art: {potential_cover}") - return potential_cover - + def _find_cover_in_directory(self, directory_path: Path) -> Optional[bytes]: + pattern = re.compile(r"^(folder|cover|artist|thumb|front)\.(jpg|jpeg|png|webp|avif|bmp)$", re.IGNORECASE) + for root, dirs, files in os.walk(directory_path): + for fname in files: + if pattern.match(fname): + self.logger.info(f"πŸ–ΌοΈ Found cover art: {Path(root) / fname}") + return (Path(root) / fname).read_bytes() self.logger.debug(f"No cover art files found in: {directory_path}") return None @@ -1660,7 +1583,7 @@ def _sanitize_text_for_font(self, text: str) -> str: # Fallback: remove all non-ASCII characters return ''.join(char for char in text if ord(char) < 128) - def _generate_custom_cover_art(self, source_image: Path, artist_name: str, destination: Path) -> bool: + def _generate_custom_cover_art(self, source_image: bytes, artist_name: str, destination: Path) -> bool: """Generate custom cover art with 'This is ' text overlay using multi-stage scaling approach""" try: import signal @@ -1676,7 +1599,7 @@ def timeout_handler(signum, frame): signal.alarm(10) # 10 second timeout # Open the source image - with Image.open(source_image) as img: + with Image.open(BytesIO(source_image)) as img: # Convert to RGB if needed if img.mode != 'RGB': img = img.convert('RGB') @@ -1810,7 +1733,7 @@ def timeout_handler(signum, frame): final_img.paste(text_img, (paste_x, paste_y), text_img) # Save the final image as PNG - final_img.save(destination, 'PNG', quality=95) + final_img.save(destination, 'webp') # Clear timeout signal.alarm(0) @@ -1980,6 +1903,18 @@ def _sanitize_playlist_name(self, name: str) -> str: return sanitized + def _jellyfin_playlist_dir(self, playlist_name: str) -> str: + """ + Replace invalid filename characters with spaces as Jellyfin does so that + the playlist and corresponding image are saved in the same directory. + - Characters: \ / : * ? " < > | and ASCII control chars (0–31) + """ + invalid_chars = r'<>:"/\\|?*\x00-\x1F' + # Replace invalid chars with a space + sanitized = re.sub(f"[{invalid_chars}]", " ", playlist_name).strip() + self.logger.info(f"🧹 Sanitized playlist sub-directory: '{sanitized}'") + return sanitized.strip() + def save_playlist(self, playlist_type: str, name: str, tracks: List[Dict], user_id: str = None): """Save playlist using Jellyfin's REST API with proper privacy controls and custom cover art""" self.logger.info(f"=== STARTING PLAYLIST CREATION ===") @@ -1988,6 +1923,7 @@ def save_playlist(self, playlist_type: str, name: str, tracks: List[Dict], user_ # Sanitize the playlist name to prevent filesystem errors sanitized_name = self._sanitize_playlist_name(name) + playlist_subdir = self._jellyfin_playlist_dir(sanitized_name) self.logger.info(f"Sanitized Playlist Name: {sanitized_name}") self.logger.info(f"Track Count: {len(tracks)}") self.logger.info(f"User ID: {user_id}") @@ -2043,9 +1979,9 @@ def save_playlist(self, playlist_type: str, name: str, tracks: List[Dict], user_ if result['success']: self.logger.info(f"βœ… Successfully created {privacy_text} playlist '{sanitized_name}' with {result['track_count']} tracks") - # Create directory for cover art storage (use sanitized name for filesystem) + # Create directory for cover art storage (use Jellyfin-style sanitized name for filesystem) self.logger.debug(f"Creating directory with sanitized name: {sanitized_name}") - playlist_dir = Path(self.config.playlist_folder) / sanitized_name + playlist_dir = Path(self.config.playlist_folder) / playlist_subdir self.logger.debug(f"Full directory path: {playlist_dir}") try: @@ -2117,22 +2053,19 @@ def save_playlist(self, playlist_type: str, name: str, tracks: List[Dict], user_ self.logger.info(f"🎨 Generating custom cover art for artist: {artist_name}") # First, find the source image to use as base - artist_cover_path = self._find_artist_cover_image(artist_name) - if artist_cover_path: + artist_image = self._find_artist_cover_image(artist_name) + if not (artist_image is None): # Generate custom cover art with text overlay - cover_dest = playlist_dir / 'cover.jpg' - if self._generate_custom_cover_art(artist_cover_path, artist_name, cover_dest): + cover_dest = playlist_dir / 'cover.webp' + if self._generate_custom_cover_art(artist_image, artist_name, cover_dest): cover_added = True self.logger.info(f"βœ… Generated custom cover art for artist: {artist_name}") else: self.logger.info(f"❌ Failed to generate custom cover art for artist: {artist_name}") # Fallback: copy the original image directly - self.logger.info(f"πŸ–ΌοΈ Fallback: Using original artist cover image: {artist_cover_path}") - import shutil - file_extension = artist_cover_path.suffix - fallback_destination = playlist_dir / f"folder{file_extension}" - shutil.copy2(artist_cover_path, fallback_destination) - + self.logger.info(f"πŸ–ΌοΈ Fallback: Using original artist cover image") + fallback_destination = playlist_dir / "folder.webp" + Image.open(BytesIO(artist_image)).save(fallback_destination) # Ensure cover art is world-readable on host mounts try: os.chmod(fallback_destination, 0o664) diff --git a/webapp.py b/app/webapp.py similarity index 98% rename from webapp.py rename to app/webapp.py index 02acfd8..43bb3aa 100644 --- a/webapp.py +++ b/app/webapp.py @@ -23,10 +23,11 @@ # Global configuration config = Config() +log_level_num = logging._nameToLevel[config.log_level] -# Setup comprehensive logging for web UI +# Setup logging for web UI logging.basicConfig( - level=logging.DEBUG, + level=log_level_num, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', handlers=[ @@ -40,8 +41,8 @@ logging.getLogger('requests').setLevel(logging.WARNING) logger = logging.getLogger('JellyJams.WebUI') -logger.setLevel(logging.DEBUG) -logger.info("🌐 JellyJams Web UI logging initialized") +logger.setLevel(log_level_num) +logger.info(f"🌐 JellyJams Web UI logging initialized (level = {config.log_level})") # Serve logo asset @app.route('/assets/logo.png') @@ -55,7 +56,7 @@ def logo(): class ConfigManager: def __init__(self): - self.config_file = '/app/config/settings.json' + self.config_file = '/data/config/settings.json' self.ensure_config_dir() def ensure_config_dir(self): @@ -264,7 +265,7 @@ def _update_config(self): else: # Fall back to web UI settings try: - config_file = '/app/config/settings.json' + config_file = '/data/config/settings.json' if Path(config_file).exists(): with open(config_file, 'r') as f: settings = json.load(f) @@ -466,7 +467,7 @@ def read_last_lines(path: str, n: int = 100) -> str: except Exception as e: return f"Error reading logs: {e}" - log_file = '/app/logs/jellyjams.log' + log_file = '/data/logs/jellyjams.log' log_content = read_last_lines(log_file, 100) return render_template('logs.html', log_content=log_content) @@ -479,7 +480,7 @@ def api_logs_tail(): except (TypeError, ValueError): lines = 100 - log_file = '/app/logs/jellyjams.log' + log_file = '/data/logs/jellyjams.log' def read_last_lines(path: str, n: int) -> str: try: @@ -574,8 +575,8 @@ def _redact(key, value): def save_web_ui_settings(new_settings): """Save settings to web UI settings file""" - config_file = '/app/config/settings.json' - config_dir = Path('/app/config') + config_file = '/data/config/settings.json' + config_dir = Path('/data/config') try: # Ensure config directory exists @@ -786,13 +787,14 @@ def api_cover_art(playlist_name): # Search order: folder.* first (what generator writes), then cover.* names = ['folder', 'cover'] - exts = ['.jpg', '.jpeg', '.png', '.webp', '.bmp'] + exts = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.avif'] mimetypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', - '.bmp': 'image/bmp' + '.bmp': 'image/bmp', + '.avif': 'image/avif' } try: @@ -1422,7 +1424,7 @@ def get_detailed_playlist_info(): try: # Consider cover image if present as an additional hint for base in ['folder', 'cover']: - for ext in ['.png', '.jpg', '.jpeg']: + for ext in ['.png', '.jpg', '.jpeg', '.webp', '.avif']: cf = playlist_path / f"{base}{ext}" if cf.exists(): cfs = cf.stat() @@ -1601,4 +1603,5 @@ def get_jellyfin_metadata(jellyfin_api): return {'genres': [], 'years': [], 'artists': []} if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=True) + WEB_PORT = int(os.getenv('WEB_PORT', '5000')) + app.run(host='0.0.0.0', port=WEB_PORT, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml index ea43f31..54c5ff9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,17 @@ services: image: jonasmore/jellyjams:latest container_name: jellyjams environment: + # Default values are set here like ${VAR_NAME:-DEFAULT_VALUE} + # in case they aren't provided in the .env file. If you use the + # .env file, there is no need to change the default values. + # Essential container settings (not configurable via web UI) - - JELLYFIN_URL=${JELLYFIN_URL} + - JELLYFIN_URL=${JELLYFIN_URL:-http://jellyfin:8096} - JELLYFIN_API_KEY=${JELLYFIN_API_KEY} - - PLAYLIST_FOLDER=${PLAYLIST_FOLDER} - - ENABLE_WEB_UI=${ENABLE_WEB_UI} - - WEB_PORT=${WEB_PORT} + - ENABLE_WEB_UI=${ENABLE_WEB_UI:-true} + - WEB_PORT=${WEB_PORT:-5000} # Default fallback values (web UI settings will override these) - - LOG_LEVEL=${LOG_LEVEL} + - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - GENERATION_INTERVAL=${GENERATION_INTERVAL} - MAX_TRACKS_PER_PLAYLIST=${MAX_TRACKS_PER_PLAYLIST} - MIN_TRACKS_PER_PLAYLIST=${MIN_TRACKS_PER_PLAYLIST} @@ -25,19 +28,12 @@ services: - DISCORD_WEBHOOK_ENABLED=${DISCORD_WEBHOOK_ENABLED} - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} volumes: - # Local directories for Docker Desktop (using named volumes to avoid path issues) - - jellyjams_playlists:/app/playlists - - jellyjams_logs:/app/logs - - jellyjams_config:/app/config - - jellyjams_cover:/app/cover - # Music directory for cover art generation (adjust path for your system) - - /mnt/user/media/data/music:/mnt/user/media/data/music:ro + # JellyJames app data - bind to existing host directory + - ./jellyjams:/data + # Ready-only access to music directory for cover art generation + - ${MUSIC_DIR_HOST}:${MUSIC_DIR_CONTAINER}:ro + # Jellyfin playlists directory for playlist and art management + - ${PLAYLIST_DIR_HOST}:/playlists ports: - - "${WEB_PORT}:5000" + - "${WEB_PORT:-5000}:${WEB_PORT:-5000}" restart: unless-stopped - -volumes: - jellyjams_playlists: - jellyjams_logs: - jellyjams_config: - jellyjams_cover: diff --git a/start.sh b/start.sh deleted file mode 100644 index 220f218..0000000 --- a/start.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env sh - -# JellyJams Container Startup Script - -echo "🎡 Starting JellyJams Generator..." - -# Initialize default cover files if they don't exist -echo "πŸ–ΌοΈ Checking for default cover files..." -if [ -d "/app/default_cover" ] && [ "$(ls -A /app/default_cover 2>/dev/null)" ]; then - for file in /app/default_cover/*; do - filename=$(basename "$file") - if [ ! -f "/app/cover/$filename" ]; then - echo "πŸ“ Copying default cover: $filename" - cp "$file" "/app/cover/" - else - echo "βœ… Custom cover exists, skipping: $filename" - fi - done - echo "🎨 Cover file initialization complete" -else - echo "⚠️ No default cover files found in /app/default_cover" -fi - -# Set log level to DEBUG for comprehensive logging -export LOG_LEVEL=DEBUG - -# Function to run playlist generator in background -run_generator() { - echo "🎯 Starting playlist generator background process..." - python vibecodeplugin.py & - GENERATOR_PID=$! - echo "πŸ“Š Playlist generator started with PID: $GENERATOR_PID" -} - -# Check if web UI is enabled -if [ "$ENABLE_WEB_UI" = "true" ]; then - echo "🌐 Web UI enabled - starting both web UI and playlist generator" - - # Start playlist generator in background - run_generator - - # Start the web application with Gunicorn (with logging to stdout) - echo "🌐 Starting web UI on port 5000" - exec gunicorn --bind 0.0.0.0:5000 --workers 2 --timeout 120 --access-logfile - --error-logfile - webapp:app -else - echo "🎯 Web UI disabled - running playlist generator only" - - # Run the original playlist generator - exec python vibecodeplugin.py -fi