Commit f7fed6ae06a8
Changed files (8)
modules
pkgs
systems
rhea
tools
music-playlist-dl
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