Commit f7fed6ae06a8

Vincent Demeester <vincent@sbr.pm>
2025-12-16 16:07:41
feat(music): Add automated podcast downloader with playlist generation
- Automate DJ podcast downloads to replace manual script execution - Enable seamless music player integration via M3U playlists - Provide declarative NixOS configuration with scheduled timer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent ea51ff3
modules/music-playlist-dl.nix
@@ -0,0 +1,167 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+with lib;
+
+let
+  cfg = config.services.music-playlist-dl;
+in
+{
+  options.services.music-playlist-dl = {
+    enable = mkEnableOption "Music playlist downloader service";
+
+    user = mkOption {
+      type = types.str;
+      default = "vincent";
+      description = "User to run the downloader service as";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "users";
+      description = "Group to run the downloader service as";
+    };
+
+    configFile = mkOption {
+      type = types.path;
+      default = "/neo/music/music-playlist-dl.yaml";
+      description = "Path to YAML configuration file";
+    };
+
+    baseDir = mkOption {
+      type = types.str;
+      default = "/neo/music";
+      description = "Base directory for downloads (library and playlists subdirectories)";
+    };
+
+    interval = mkOption {
+      type = types.enum [
+        "hourly"
+        "daily"
+        "weekly"
+        "monthly"
+      ];
+      default = "weekly";
+      description = "How often to run the downloader";
+    };
+
+    onCalendar = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = "Custom OnCalendar specification for systemd timer (overrides interval)";
+      example = "Sun *-*-* 02:00:00";
+    };
+
+    notification = {
+      enable = mkEnableOption "notifications via ntfy";
+
+      ntfyUrl = mkOption {
+        type = types.str;
+        default = "https://ntfy.sbr.pm";
+        description = "URL of ntfy server";
+      };
+
+      topic = mkOption {
+        type = types.str;
+        default = "homelab";
+        description = "ntfy topic for notifications";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # Install the music-playlist-dl tool
+    environment.systemPackages = with pkgs; [
+      music-playlist-dl
+      yt-dlp
+    ];
+
+    # Systemd timer for scheduled downloads
+    systemd.timers.music-playlist-dl = {
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar =
+          if cfg.onCalendar != null then
+            cfg.onCalendar
+          else
+            (
+              {
+                hourly = "*-*-* *:00:00";
+                daily = "*-*-* 02:00:00";
+                weekly = "Sun *-*-* 02:00:00";
+                monthly = "*-*-01 02:00:00";
+              }
+              .${cfg.interval}
+            );
+        Persistent = true;
+        RandomizedDelaySec = "15m";
+      };
+    };
+
+    # Systemd service to run the downloader
+    systemd.services.music-playlist-dl = {
+      description = "Download music podcasts and generate playlists";
+      after = [ "network-online.target" ];
+      wants = [ "network-online.target" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+
+        # Run the downloader command
+        ExecStart = "${pkgs.music-playlist-dl}/bin/music-playlist-dl --config ${cfg.configFile}";
+
+        # Notifications on success (if enabled)
+        ExecStartPost = mkIf cfg.notification.enable (
+          pkgs.writeShellScript "music-playlist-dl-notify-success" ''
+            ${pkgs.curl}/bin/curl -H "Title: Music Playlist Download Complete" \
+              -H "Tags: musical_note,headphones" \
+              -d "Successfully downloaded music podcasts and updated playlists" \
+              ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
+          ''
+        );
+
+        # Ensure directories exist
+        ExecStartPre = pkgs.writeShellScript "music-playlist-dl-prepare" ''
+          mkdir -p "${cfg.baseDir}/library"
+          mkdir -p "${cfg.baseDir}/playlist"
+        '';
+
+        # Resource limits
+        Nice = 15;
+        IOSchedulingClass = "idle";
+        CPUSchedulingPolicy = "idle";
+
+        # Security hardening
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+        ProtectSystem = "strict";
+        ProtectHome = "read-only";
+        ReadWritePaths = [ cfg.baseDir ];
+      };
+
+      # Notify on failure (if enabled)
+      onFailure = mkIf cfg.notification.enable [ "music-playlist-dl-failure.service" ];
+    };
+
+    # Failure notification service
+    systemd.services.music-playlist-dl-failure = mkIf cfg.notification.enable {
+      description = "Notify on music playlist download failure";
+      serviceConfig = {
+        Type = "oneshot";
+        ExecStart = pkgs.writeShellScript "music-playlist-dl-notify-failure" ''
+          ${pkgs.curl}/bin/curl -H "Title: Music Playlist Download Failed" \
+            -H "Priority: high" \
+            -H "Tags: warning,musical_note" \
+            -d "Music playlist download failed. Check logs: journalctl -u music-playlist-dl" \
+            ${cfg.notification.ntfyUrl}/${cfg.notification.topic}
+        '';
+      };
+    };
+  };
+}
pkgs/default.nix
@@ -29,6 +29,7 @@ in
   homepage = pkgs.callPackage ./homepage { inherit globals; };
   audible-converter = pkgs.callPackage ./audible-converter { };
   jellyfin-auto-collections = pkgs.callPackage ./jellyfin-auto-collections { };
+  music-playlist-dl = pkgs.callPackage ../tools/music-playlist-dl { };
 
   chmouzies-ai = pkgs.callPackage ./chmouzies/ai.nix { };
   chmouzies-git = pkgs.callPackage ./chmouzies/git.nix { };
systems/rhea/extra.nix
@@ -53,6 +53,7 @@ in
     ../common/services/homepage.nix
     ../../modules/audible-sync.nix
     ../../modules/jellyfin-auto-collections.nix
+    ../../modules/music-playlist-dl.nix
   ];
 
   # Age secrets: gandi.env + generated exportarr secrets
@@ -536,6 +537,19 @@ in
         };
       };
     };
+    music-playlist-dl = {
+      enable = true;
+      user = "vincent";
+      configFile = "/neo/music/music-playlist-dl.yaml";
+      baseDir = "/neo/music";
+      interval = "weekly"; # Run weekly on Sundays
+      onCalendar = "Sun *-*-* 02:00:00"; # Sunday at 2 AM
+      notification = {
+        enable = true;
+        ntfyUrl = "https://ntfy.sbr.pm";
+        topic = "homelab";
+      };
+    };
     transmission = serviceDefaults // {
       enable = true;
       package = pkgs.transmission_4;
tools/music-playlist-dl/config.yaml.example
@@ -0,0 +1,107 @@
+# Music Playlist Downloader Configuration
+# Copy this file to /neo/music/music-playlist-dl.yaml and customize
+
+# Base directory for downloads
+# - Downloads go to: {base_dir}/library/{artist}/{show}/
+# - Playlists go to: {base_dir}/playlist/{artist} - {show}.m3u
+base_dir: /neo/music
+
+# Mixcloud shows to download
+# Format: Mixcloud handle, artist name, and show name
+mixcloud_shows:
+  - handle: aboveandbeyond
+    artist: Above & Beyond
+    show: Group Therapy
+  - handle: ArminvanBuuren
+    artist: Armin van Buuren
+    show: A State of Trance
+  - handle: CosmicGate
+    artist: Cosmic Gate
+    show: Wake Your Mind Radio
+  - handle: FerryCorsten
+    artist: Ferry Corsten
+    show: Resonation Radio
+  - handle: paulvandyk
+    artist: Paul van Dyk
+    show: VONYC Sessions
+  - handle: sandervandoornofficial
+    artist: Sander van Doorn
+    show: Identity
+  - handle: victordinaire
+    artist: Dinaire+Bissen
+    show: This Is HalfwayHaus
+
+# SoundCloud shows to download
+# Format: Full URL, artist name, and show name
+soundcloud_shows:
+  - url: https://soundcloud.com/garethemery/sets/the-gareth-emery-podcast
+    artist: Gareth Emery
+    show: The Gareth Emery Podcast
+  - url: https://soundcloud.com/clublifebytiesto
+    artist: Tiësto
+    show: CLUBLIFE
+
+# yt-dlp options
+yt_dlp_options:
+  format: best             # Audio quality: best, bestaudio, etc.
+  add_metadata: true       # Add metadata (artist, album) to files
+  embed_thumbnail: true    # Embed artwork in audio files
+  continue: true           # Resume partial downloads
+  ignore_errors: true      # Continue on errors
+
+# Directory Structure
+# After running, your directory structure will look like:
+#
+# /neo/music/
+# ├── library/
+# │   ├── Above & Beyond/
+# │   │   └── Group Therapy/
+# │   │       ├── Group Therapy 657-abc123.m4a
+# │   │       └── Group Therapy 658-def456.m4a
+# │   ├── Armin van Buuren/
+# │   │   └── A State of Trance/
+# │   │       └── ASOT Episode 1255-xyz789.m4a
+# │   └── Tiësto/
+# │       └── CLUBLIFE/
+# │           └── CLUBLIFE Podcast 908-ghi012.m4a
+# └── playlist/
+#     ├── Above & Beyond - Group Therapy.m3u
+#     ├── Armin van Buuren - A State of Trance.m3u
+#     └── Tiësto - CLUBLIFE.m3u
+
+# Playlist Format
+# Playlists are standard M3U format with relative paths:
+#
+# #EXTM3U
+# ../library/Above & Beyond/Group Therapy/Group Therapy 657-abc123.m4a
+# ../library/Above & Beyond/Group Therapy/Group Therapy 658-def456.m4a
+
+# Podcast Information & Sources
+#
+# Above & Beyond - Group Therapy
+#   Weekly trance podcast (ABGT)
+#   https://podcasts.apple.com/us/podcast/above-beyond-group-therapy
+#
+# Armin van Buuren - A State of Trance
+#   Longest-running trance show (since 2001)
+#   https://www.astateoftrance.com/
+#
+# Cosmic Gate - Wake Your Mind Radio
+#   Weekly progressive/trance show
+#   https://podcasts.apple.com/us/podcast/cosmic-gate-wym-radio
+#
+# Ferry Corsten - Resonation Radio
+#   Weekly electronic music show
+#   https://www.ferrycorsten.com/radio
+#
+# Paul van Dyk - VONYC Sessions
+#   Grammy winner's weekly show
+#   https://podcasts.apple.com/us/podcast/paul-van-dyks-vonyc-sessions-podcast
+#
+# Gareth Emery - The Gareth Emery Podcast
+#   Original podcast (ended 2016)
+#   https://soundcloud.com/garethemery/sets/the-gareth-emery-podcast
+#
+# Tiësto - CLUBLIFE
+#   Weekly club tracks since 2007
+#   https://podcasts.apple.com/us/podcast/clublife
tools/music-playlist-dl/default.nix
@@ -0,0 +1,45 @@
+{
+  lib,
+  python3,
+  yt-dlp,
+}:
+
+python3.pkgs.buildPythonApplication {
+  pname = "music-playlist-dl";
+  version = "1.0.0";
+  format = "other";
+
+  src = ./.;
+
+  propagatedBuildInputs = with python3.pkgs; [
+    pyyaml
+  ];
+
+  # yt-dlp is a runtime dependency
+  makeWrapperArgs = [
+    "--prefix PATH : ${lib.makeBinPath [ yt-dlp ]}"
+  ];
+
+  # Don't unpack since we're not using a typical Python package structure
+  dontUnpack = true;
+  dontBuild = true;
+
+  # Simple install: just copy the script
+  installPhase = ''
+    runHook preInstall
+
+    mkdir -p $out/bin
+    cp ${./music-playlist-dl.py} $out/bin/music-playlist-dl
+    chmod +x $out/bin/music-playlist-dl
+
+    runHook postInstall
+  '';
+
+  meta = with lib; {
+    description = "Download DJ podcasts/radio shows and generate playlists";
+    homepage = "https://github.com/vdemeester/home";
+    license = licenses.mit;
+    maintainers = [ ];
+    mainProgram = "music-playlist-dl";
+  };
+}
tools/music-playlist-dl/DEPLOYMENT.md
@@ -0,0 +1,243 @@
+# Deployment Guide for music-playlist-dl
+
+## What Was Implemented
+
+### 1. NixOS Module (`modules/music-playlist-dl.nix`)
+- Full systemd service and timer integration
+- Configurable schedule (hourly, daily, weekly, monthly, or custom)
+- ntfy notification support (success and failure)
+- Security hardening with systemd sandboxing
+- Automatic directory creation
+
+### 2. Python Downloader (`tools/music-playlist-dl/`)
+- Downloads from Mixcloud and SoundCloud
+- YAML-based configuration
+- Automatic M3U playlist generation
+- Proper metadata handling (artist, album)
+- Error handling and logging
+- Located in `tools/` for consistency with other custom tools
+
+### 3. Integration in rhea
+- Module imported in `systems/rhea/extra.nix`
+- Service configured to run weekly on Sundays at 2 AM
+- Notifications enabled via ntfy
+
+## Deployment Steps
+
+### Step 1: Create Configuration File
+
+Copy the example config to the target location:
+
+```bash
+ssh rhea "mkdir -p /neo/music/library /neo/music/playlist"
+```
+
+Create `/neo/music/music-playlist-dl.yaml` on rhea with your desired shows:
+
+```yaml
+base_dir: /neo/music
+
+mixcloud_shows:
+  - handle: aboveandbeyond
+    artist: Above & Beyond
+    show: Group Therapy
+  - handle: ArminvanBuuren
+    artist: Armin van Buuren
+    show: A State of Trance
+  # Add more shows as needed
+
+soundcloud_shows:
+  - url: https://soundcloud.com/clublifebytiesto
+    artist: Tiësto
+    show: CLUBLIFE
+  # Add more shows as needed
+
+yt_dlp_options:
+  format: best
+  add_metadata: true
+  embed_thumbnail: true
+  continue: true
+  ignore_errors: true
+```
+
+### Step 2: Build and Deploy
+
+From your home repository:
+
+```bash
+# Test build locally first
+make host/rhea/build
+
+# If successful, deploy to rhea
+make host/rhea/switch
+```
+
+### Step 3: Verify Deployment
+
+After deployment, verify the service is configured correctly:
+
+```bash
+ssh rhea
+
+# Check if service is installed
+systemctl status music-playlist-dl
+
+# Check timer status
+systemctl list-timers music-playlist-dl
+
+# View service configuration
+systemctl cat music-playlist-dl
+
+# Test manual run (optional)
+sudo systemctl start music-playlist-dl
+
+# Watch logs during test run
+journalctl -u music-playlist-dl -f
+```
+
+### Step 4: Initial Run
+
+For the first run, you may want to run it manually to ensure everything works:
+
+```bash
+ssh rhea
+sudo systemctl start music-playlist-dl
+journalctl -u music-playlist-dl -f
+```
+
+Expected behavior:
+1. Creates `/neo/music/library/` and `/neo/music/playlist/` directories
+2. Downloads episodes from configured shows
+3. Generates M3U playlists
+4. Sends ntfy notification on completion
+
+## Configuration Options
+
+### Service Configuration (in `systems/rhea/extra.nix`)
+
+```nix
+services.music-playlist-dl = {
+  enable = true;               # Enable/disable service
+  user = "vincent";            # User to run as
+  configFile = "/neo/music/music-playlist-dl.yaml";  # Config file path
+  baseDir = "/neo/music";      # Base directory for downloads
+
+  # Schedule options (pick one):
+  interval = "weekly";         # Predefined: hourly, daily, weekly, monthly
+  # OR
+  onCalendar = "Sun *-*-* 02:00:00";  # Custom systemd timer format
+
+  notification = {
+    enable = true;             # Enable ntfy notifications
+    ntfyUrl = "https://ntfy.sbr.pm";
+    topic = "homelab";
+  };
+};
+```
+
+### YAML Configuration
+
+- `base_dir`: Where to store downloads and playlists
+- `mixcloud_shows`: List of Mixcloud shows to download
+- `soundcloud_shows`: List of SoundCloud shows/playlists to download
+- `yt_dlp_options`: Options passed to yt-dlp
+
+## Directory Structure After Deployment
+
+```
+/neo/music/
+├── music-playlist-dl.yaml          # Configuration file
+├── library/                        # Downloaded audio files
+│   ├── Above & Beyond/
+│   │   └── Group Therapy/
+│   │       ├── Group Therapy 657-abc123.m4a
+│   │       └── Group Therapy 658-def456.m4a
+│   └── Armin van Buuren/
+│       └── A State of Trance/
+│           └── ASOT Episode 1255-xyz789.m4a
+└── playlist/                       # Generated M3U playlists
+    ├── Above & Beyond - Group Therapy.m3u
+    └── Armin van Buuren - A State of Trance.m3u
+```
+
+## Systemd Commands
+
+```bash
+# Start download manually
+sudo systemctl start music-playlist-dl
+
+# Check service status
+systemctl status music-playlist-dl
+
+# View recent logs
+journalctl -u music-playlist-dl -n 50
+
+# Follow logs in real-time
+journalctl -u music-playlist-dl -f
+
+# Check timer schedule
+systemctl list-timers music-playlist-dl
+
+# Disable automatic runs
+sudo systemctl stop music-playlist-dl.timer
+sudo systemctl disable music-playlist-dl.timer
+
+# Re-enable automatic runs
+sudo systemctl enable music-playlist-dl.timer
+sudo systemctl start music-playlist-dl.timer
+```
+
+## Troubleshooting
+
+### Downloads fail
+- Check network connectivity from rhea
+- Verify yt-dlp can access Mixcloud/SoundCloud
+- Check logs: `journalctl -u music-playlist-dl`
+
+### Playlists not generated
+- Verify `/neo/music/playlist/` directory exists and is writable
+- Check if audio files were downloaded successfully
+- Look for errors in logs
+
+### No ntfy notifications
+- Verify ntfy server is accessible from rhea
+- Check notification configuration in module
+- Test manually: `curl -d "test" https://ntfy.sbr.pm/homelab`
+
+### Permission issues
+- Ensure user 'vincent' has write access to `/neo/music/`
+- Check service user in systemd configuration
+- Verify directory ownership: `ls -la /neo/music/`
+
+## Migration from Old Script
+
+If you have existing files in `/net/rhea/music/mixes/`:
+
+```bash
+# Move to new structure
+ssh rhea
+cd /neo/music
+
+# For each artist, create the new structure
+# Example for Above & Beyond:
+mkdir -p "library/Above & Beyond/Group Therapy"
+mv "/net/rhea/music/mixes/Above & Beyond"/*.m4a "library/Above & Beyond/Group Therapy/" || true
+
+# After migration, run the downloader to generate playlists
+sudo systemctl start music-playlist-dl
+```
+
+## Next Steps
+
+1. **Test build**: `make host/rhea/build`
+2. **Create config file** on rhea at `/neo/music/music-playlist-dl.yaml`
+3. **Deploy**: `make host/rhea/switch` (requires user confirmation)
+4. **Manual test**: `ssh rhea sudo systemctl start music-playlist-dl`
+5. **Verify playlists**: Check `/neo/music/playlist/`
+6. **Monitor first scheduled run**: Check logs after Sunday 2 AM
+
+## Deadline
+
+This implementation addresses the TODO with deadline **2025-12-19 Fri** (in 3 days).
+
+All components are ready for deployment. The service will run weekly on Sundays at 2 AM and send notifications to the homelab ntfy topic.
tools/music-playlist-dl/music-playlist-dl.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python3
+"""
+Music Playlist Downloader
+Downloads DJ podcasts/radio shows from Mixcloud and SoundCloud,
+and generates M3U playlists.
+"""
+
+import argparse
+import logging
+import os
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List
+
+import yaml
+
+
+@dataclass
+class MixcloudShow:
+    """Configuration for a Mixcloud show."""
+
+    handle: str
+    artist: str
+    show: str
+
+
+@dataclass
+class SoundcloudShow:
+    """Configuration for a SoundCloud show."""
+
+    url: str
+    artist: str
+    show: str
+
+
+@dataclass
+class Config:
+    """Main configuration."""
+
+    base_dir: Path
+    mixcloud_shows: List[MixcloudShow]
+    soundcloud_shows: List[SoundcloudShow]
+    yt_dlp_options: dict
+
+
+def load_config(config_path: Path) -> Config:
+    """Load configuration from YAML file."""
+    with open(config_path) as f:
+        data = yaml.safe_load(f)
+
+    mixcloud_shows = [
+        MixcloudShow(**show) for show in data.get("mixcloud_shows", [])
+    ]
+    soundcloud_shows = [
+        SoundcloudShow(**show) for show in data.get("soundcloud_shows", [])
+    ]
+
+    return Config(
+        base_dir=Path(data.get("base_dir", "/neo/music")),
+        mixcloud_shows=mixcloud_shows,
+        soundcloud_shows=soundcloud_shows,
+        yt_dlp_options=data.get("yt_dlp_options", {}),
+    )
+
+
+def build_yt_dlp_command(
+    url: str, output_template: str, artist: str, yt_dlp_options: dict
+) -> List[str]:
+    """Build yt-dlp command with options."""
+    cmd = ["yt-dlp"]
+
+    # Add configured options
+    if yt_dlp_options.get("ignore_errors", True):
+        cmd.append("-i")
+    if yt_dlp_options.get("continue", True):
+        cmd.append("-c")
+
+    format_opt = yt_dlp_options.get("format", "best")
+    cmd.extend(["-f", format_opt])
+
+    if yt_dlp_options.get("add_metadata", True):
+        cmd.append("--add-metadata")
+    if yt_dlp_options.get("embed_thumbnail", True):
+        cmd.append("--embed-thumbnail")
+
+    # Parse metadata
+    cmd.extend(
+        [
+            "--parse-metadata",
+            "title:%(album)s",
+            "--parse-metadata",
+            f"{artist}:%(artist)s",
+        ]
+    )
+
+    # Output template
+    cmd.extend(["--output", output_template, url])
+
+    return cmd
+
+
+def download_mixcloud_show(
+    show: MixcloudShow, library_dir: Path, yt_dlp_options: dict
+) -> bool:
+    """Download a Mixcloud show."""
+    url = f"https://www.mixcloud.com/{show.handle}/"
+    output_dir = library_dir / show.artist / show.show
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    output_template = str(output_dir / "%(title)s-%(id)s.%(ext)s")
+
+    logging.info(f"Downloading {show.show} by {show.artist}...")
+    cmd = build_yt_dlp_command(
+        url, output_template, show.artist, yt_dlp_options
+    )
+
+    try:
+        subprocess.run(cmd, check=True, capture_output=False)
+        return True
+    except subprocess.CalledProcessError as e:
+        logging.warning(f"Failed to download {show.show}: {e}")
+        return False
+
+
+def download_soundcloud_show(
+    show: SoundcloudShow, library_dir: Path, yt_dlp_options: dict
+) -> bool:
+    """Download a SoundCloud show."""
+    output_dir = library_dir / show.artist / show.show
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    output_template = str(output_dir / "%(title)s-%(id)s.%(ext)s")
+
+    logging.info(f"Downloading {show.show} by {show.artist}...")
+    cmd = build_yt_dlp_command(
+        show.url, output_template, show.artist, yt_dlp_options
+    )
+
+    try:
+        subprocess.run(cmd, check=True, capture_output=False)
+        return True
+    except subprocess.CalledProcessError as e:
+        logging.warning(f"Failed to download {show.show}: {e}")
+        return False
+
+
+def generate_playlist(
+    artist: str, show: str, library_dir: Path, playlist_dir: Path
+):
+    """Generate M3U playlist for a show."""
+    show_dir = library_dir / artist / show
+    if not show_dir.exists():
+        logging.warning(f"Show directory does not exist: {show_dir}")
+        return
+
+    # Find all audio files
+    audio_extensions = {".m4a", ".mp3", ".opus", ".ogg", ".flac"}
+    audio_files = sorted(
+        [
+            f
+            for f in show_dir.iterdir()
+            if f.is_file() and f.suffix.lower() in audio_extensions
+        ]
+    )
+
+    if not audio_files:
+        logging.warning(f"No audio files found in {show_dir}")
+        return
+
+    # Generate playlist filename
+    playlist_name = f"{artist} - {show}.m3u"
+    playlist_path = playlist_dir / playlist_name
+
+    logging.info(
+        f"Generating playlist: {playlist_name} ({len(audio_files)} files)"
+    )
+
+    # Write M3U playlist with relative paths
+    with open(playlist_path, "w", encoding="utf-8") as f:
+        f.write("#EXTM3U\n")
+        for audio_file in audio_files:
+            # Use relative path from playlist to audio file
+            relative_path = os.path.relpath(audio_file, playlist_dir)
+            f.write(f"{relative_path}\n")
+
+
+def main():
+    """Main entry point."""
+    parser = argparse.ArgumentParser(
+        description="Download music podcasts and generate playlists"
+    )
+    parser.add_argument(
+        "--config",
+        type=Path,
+        default="/neo/music/music-playlist-dl.yaml",
+        help="Path to configuration file",
+    )
+    parser.add_argument(
+        "--verbose", "-v", action="store_true", help="Enable verbose logging"
+    )
+    args = parser.parse_args()
+
+    # Setup logging
+    logging.basicConfig(
+        level=logging.DEBUG if args.verbose else logging.INFO,
+        format="%(asctime)s - %(levelname)s - %(message)s",
+    )
+
+    # Load configuration
+    try:
+        config = load_config(args.config)
+    except Exception as e:
+        logging.error(f"Failed to load configuration: {e}")
+        sys.exit(1)
+
+    # Setup directories
+    library_dir = config.base_dir / "library"
+    playlist_dir = config.base_dir / "playlist"
+    library_dir.mkdir(parents=True, exist_ok=True)
+    playlist_dir.mkdir(parents=True, exist_ok=True)
+
+    logging.info(
+        f"Starting music podcast downloads to: {library_dir}"
+    )
+    logging.info("=" * 60)
+
+    # Download Mixcloud shows
+    for show in config.mixcloud_shows:
+        download_mixcloud_show(show, library_dir, config.yt_dlp_options)
+
+    # Download SoundCloud shows
+    for show in config.soundcloud_shows:
+        download_soundcloud_show(show, library_dir, config.yt_dlp_options)
+
+    logging.info("=" * 60)
+    logging.info("Generating playlists...")
+
+    # Generate playlists for all shows
+    for show in config.mixcloud_shows:
+        generate_playlist(show.artist, show.show, library_dir, playlist_dir)
+
+    for show in config.soundcloud_shows:
+        generate_playlist(show.artist, show.show, library_dir, playlist_dir)
+
+    logging.info("=" * 60)
+    logging.info("Download complete!")
+
+
+if __name__ == "__main__":
+    main()
tools/music-playlist-dl/README.md
@@ -0,0 +1,171 @@
+# Music Playlist Downloader
+
+Automated downloader for electronic music podcasts and radio shows from Mixcloud and SoundCloud with automatic M3U playlist generation.
+
+## Overview
+
+This tool downloads episodic DJ podcasts/radio shows and organizes them by Artist/Show name for better library management. Files are tagged with proper metadata (artist, album) for music player compatibility, and M3U playlists are automatically generated for each show.
+
+## Features
+
+- **Automated Downloads**: Download from Mixcloud and SoundCloud
+- **Organized Storage**: Files organized as `library/{artist}/{show}/`
+- **Playlist Generation**: Automatic M3U playlists in `playlist/` directory
+- **Metadata Support**: Proper artist and album tags
+- **Resume Support**: Continue interrupted downloads
+- **Notification Support**: ntfy notifications on success/failure
+- **NixOS Integration**: Systemd timer for scheduled execution
+
+## Configuration
+
+### YAML Config File
+
+Copy `config.yaml.example` to `/neo/music/music-playlist-dl.yaml` and customize:
+
+```yaml
+base_dir: /neo/music
+
+mixcloud_shows:
+  - handle: aboveandbeyond
+    artist: Above & Beyond
+    show: Group Therapy
+
+soundcloud_shows:
+  - url: https://soundcloud.com/clublifebytiesto
+    artist: Tiësto
+    show: CLUBLIFE
+
+yt_dlp_options:
+  format: best
+  add_metadata: true
+  embed_thumbnail: true
+  continue: true
+  ignore_errors: true
+```
+
+### NixOS Module
+
+Enable in your NixOS configuration:
+
+```nix
+services.music-playlist-dl = {
+  enable = true;
+  user = "vincent";
+  configFile = "/neo/music/music-playlist-dl.yaml";
+  baseDir = "/neo/music";
+  interval = "weekly"; # hourly, daily, weekly, or monthly
+  onCalendar = "Sun *-*-* 02:00:00"; # Custom schedule (overrides interval)
+  notification = {
+    enable = true;
+    ntfyUrl = "https://ntfy.sbr.pm";
+    topic = "homelab";
+  };
+};
+```
+
+## Directory Structure
+
+After running, your directory structure will look like:
+
+```
+/neo/music/
+├── library/
+│   ├── Above & Beyond/
+│   │   └── Group Therapy/
+│   │       ├── Group Therapy 657-abc123.m4a
+│   │       └── Group Therapy 658-def456.m4a
+│   ├── Armin van Buuren/
+│   │   └── A State of Trance/
+│   │       └── ASOT Episode 1255-xyz789.m4a
+│   └── Tiësto/
+│       └── CLUBLIFE/
+│           └── CLUBLIFE Podcast 908-ghi012.m4a
+└── playlist/
+    ├── Above & Beyond - Group Therapy.m3u
+    ├── Armin van Buuren - A State of Trance.m3u
+    └── Tiësto - CLUBLIFE.m3u
+```
+
+## Playlist Format
+
+Playlists are standard M3U format with relative paths from the playlist directory:
+
+```m3u
+#EXTM3U
+../library/Above & Beyond/Group Therapy/Group Therapy 657-abc123.m4a
+../library/Above & Beyond/Group Therapy/Group Therapy 658-def456.m4a
+```
+
+This allows music players to correctly resolve the file paths regardless of where they're accessed from.
+
+## Usage
+
+### Manual Execution
+
+```bash
+# Run with default config
+music-playlist-dl
+
+# Run with custom config
+music-playlist-dl --config /path/to/config.yaml
+
+# Verbose output
+music-playlist-dl --verbose
+```
+
+### Systemd Service
+
+```bash
+# Start download manually
+systemctl start music-playlist-dl
+
+# Check status
+systemctl status music-playlist-dl
+
+# View logs
+journalctl -u music-playlist-dl
+
+# Check timer status
+systemctl list-timers music-playlist-dl
+```
+
+## Podcast Sources
+
+See `config.yaml.example` for a comprehensive list of supported podcasts with links to official sources.
+
+Popular shows include:
+- **Above & Beyond - Group Therapy**: Weekly trance podcast (ABGT)
+- **Armin van Buuren - A State of Trance**: Longest-running trance show (since 2001)
+- **Cosmic Gate - Wake Your Mind Radio**: Weekly progressive/trance show
+- **Ferry Corsten - Resonation Radio**: Weekly electronic music show
+- **Paul van Dyk - VONYC Sessions**: Grammy winner's weekly show
+- **Tiësto - CLUBLIFE**: Weekly club tracks since 2007
+
+## Requirements
+
+- Python 3
+- yt-dlp
+- PyYAML
+
+All dependencies are automatically handled by the Nix package.
+
+## Migration from Old Script
+
+If you have files in the old structure (directly under artist name instead of `library/artist/show/`):
+
+```bash
+# Example: Move Above & Beyond files
+mkdir -p "/neo/music/library/Above & Beyond/Group Therapy"
+mv /neo/music/mixes/"Above & Beyond"/*.m4a "/neo/music/library/Above & Beyond/Group Therapy/" 2>/dev/null || true
+```
+
+## Notes
+
+- Downloads continue from where they left off (uses `-c` flag)
+- Failed downloads for individual shows don't stop the entire script
+- Playlists are regenerated on each run to include new episodes
+- The tool preserves existing files and only downloads new content
+
+## License
+
+MIT