From 9a6d742a6a1bf0b9297f0d1cbd6024bbc50dd740 Mon Sep 17 00:00:00 2001 From: intergrav Date: Sun, 12 Jul 2026 16:13:05 -0400 Subject: [PATCH] feat: navidrome playlist ipod sync --- hosts/nixos/bluepill/services/ipod-sync.nix | 125 +++++++++++++++++++- secrets/ipod-sync.age | Bin 0 -> 371 bytes secrets/secrets.nix | 1 + 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 secrets/ipod-sync.age diff --git a/hosts/nixos/bluepill/services/ipod-sync.nix b/hosts/nixos/bluepill/services/ipod-sync.nix index 9b15282..84ba8f8 100644 --- a/hosts/nixos/bluepill/services/ipod-sync.nix +++ b/hosts/nixos/bluepill/services/ipod-sync.nix @@ -1,21 +1,123 @@ -{pkgs, ...}: let +{ + config, + pkgs, + ... +}: let ipodUuid = "A751-935A"; + musicDir = "/srv/media/music"; in { + age.secrets.ipod-sync = { + file = ../../../../secrets/ipod_sync.age; + }; + services.udev.extraRules = '' ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", ENV{ID_FS_UUID}=="${ipodUuid}", TAG+="systemd", ENV{SYSTEMD_WANTS}+="ipod-sync.service" ''; systemd.services.ipod-sync = { - description = "Sync music to iPod"; + description = "Sync Navidrome playlists and music to iPod"; + + wants = ["navidrome.service"]; + after = ["navidrome.service"]; serviceConfig = { Type = "oneshot"; - RuntimeDirectory = "ipod"; - TimeoutStartSec = "4h"; + RuntimeDirectory = [ + "ipod" + "ipod-sync" + ]; + TimeoutStartSec = "8h"; + EnvironmentFile = config.age.secrets.ipod-sync.path; }; script = '' - set -eu + set -euo pipefail + + ${pkgs.python3}/bin/python3 <<'PY' + import hashlib + import json + import os + import re + import secrets + from pathlib import Path, PurePosixPath + from urllib.parse import urlencode + from urllib.request import urlopen + + username = os.environ["NAVIDROME_USERNAME"] + password = os.environ["NAVIDROME_PASSWORD"] + source = PurePosixPath("${musicDir}") + destination = Path("/run/ipod-sync") + + def api(method, **params): + salt = secrets.token_hex(8) + token = hashlib.md5( + (password + salt).encode(), + usedforsecurity=False, + ).hexdigest() + + query = urlencode({ + "u": username, + "t": token, + "s": salt, + "v": "1.16.1", + "c": "ipod-sync", + "f": "json", + **params, + }) + + with urlopen( + "http://127.0.0.1:" + "${toString config.services.navidrome.settings.Port}" + f"/rest/{method}.view?{query}", + timeout=30, + ) as response: + result = json.load(response)["subsonic-response"] + + if result["status"] != "ok": + raise RuntimeError( + result.get("error", {}).get("message", method) + ) + + return result + + used_names = set() + + for summary in ( + api("getPlaylists") + .get("playlists", {}) + .get("playlist", []) + ): + playlist = api("getPlaylist", id=summary["id"])["playlist"] + + name = re.sub( + r'[\x00-\x1f<>:"/\\|?*]', + "_", + playlist["name"], + ) + name = name[:200].rstrip(" .") or playlist["id"] + filename = f"{name}.m3u8" + + if filename.casefold() in used_names: + filename = f"{name} [{playlist['id'][:8]}].m3u8" + + used_names.add(filename.casefold()) + tracks = [] + + for entry in playlist.get("entry", []): + path = PurePosixPath(entry["path"]) + relative = path.relative_to(source) + tracks.append(f"/Sync/{relative.as_posix()}") + + (destination / filename).write_text( + "\n".join(tracks) + ("\n" if tracks else ""), + encoding="utf-8", + newline="\n", + ) + + print(f"generated {len(used_names)} Navidrome playlists") + PY + + unset NAVIDROME_USERNAME NAVIDROME_PASSWORD ${pkgs.util-linux}/bin/mount \ /dev/disk/by-uuid/${ipodUuid} \ @@ -27,8 +129,19 @@ in { -rt \ --delete \ --modify-window=1 \ - /srv/media/music/ \ + --human-readable \ + --info=stats2,name1 \ + ${musicDir}/ \ /run/ipod/Sync/ + + ${pkgs.rsync}/bin/rsync \ + -rc \ + --delete \ + --mkpath \ + --human-readable \ + --info=stats2,name1 \ + /run/ipod-sync/ \ + /run/ipod/Playlists/Navidrome/ ''; }; } diff --git a/secrets/ipod-sync.age b/secrets/ipod-sync.age new file mode 100644 index 0000000000000000000000000000000000000000..b02a92a43c02c6bcc48d4c52bd58930cfa0f48f4 GIT binary patch literal 371 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCT4a?(#Ta#ScRDK9p$ z2rV)*@iB1pajf*x4l^srC`?W(Os{k{jVdoR$&5<(EX+vt@a4)bbTmpTHO|k?GtW#8 zDt9%_tSZc?aE!|GG4V?Y^9nTx2sJ2jb`Fw3#PE#Dx>qnNA6-ODg3+rre}C*0LB+pW?(EZp6r z%AmZ$*xA@9EipVb$k8z`&Bw#H+?PvNS687TH!Q`Y($T{$J1jRU%eTy{vaq};#M>mP zJTB-itqt~)`@SH1g(%d}PhVA$2%-KiG4y?{jsJs2kP@!bWJ0EdzrQbGI E0Jl1X#{d8T literal 0 HcmV?d00001 diff --git a/secrets/secrets.nix b/secrets/secrets.nix index df8da41..657be02 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -5,6 +5,7 @@ let devin = "age1reah3h72zsk29ahhd0eskyque2s3exjvpt695mrytc6wqzetpcdqqr758y"; in { "draupnir-at.age".publicKeys = [bluepill d-macbook]; + "ipod-sync.age".publicKeys = [bluepill d-macbook]; "msmtp.age".publicKeys = [bluepill d-macbook]; "navidrome.age".publicKeys = [bluepill d-macbook]; "slskd.age".publicKeys = [bluepill d-macbook]; -- 2.51.2