streamflacr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamflacr
3
+ Version: 0.1.0
4
+ Summary: Auto-download FLAC from Soulseek for SoundCloud playlist additions
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aioslsk>=1.6.0
9
+ Requires-Dist: mutagen>=1.47
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: pillow>=10.0
12
+ Requires-Dist: pycryptodome>=3.20
13
+ Requires-Dist: python-dotenv>=1.2
14
+ Requires-Dist: pydantic-settings>=2.4
15
+ Requires-Dist: requests>=2.31
16
+ Requires-Dist: yt-dlp>=2024.0
17
+
18
+ # StreamFLACr
19
+
20
+ Automatically download FLAC versions of songs added to SoundCloud playlists via Soulseek, tag them with metadata, and create matching Serato smart crates.
21
+
22
+ ## How it works
23
+
24
+ 1. **Discovers** all your SoundCloud playlists automatically (including private ones)
25
+ 2. **Monitors** for new tracks added to any playlist
26
+ 3. **Detects** new playlists as they're created
27
+ 4. **Searches** Soulseek for FLAC versions of each new track
28
+ 5. **Downloads** the best candidate (prefers free slots, fast speeds, larger files)
29
+ 6. **Tags** the FLAC with artist, title, and the playlist name as the **Label** field
30
+ 7. **Creates** a Serato smart crate per playlist with rule: `Label IS <playlist_name>`
31
+ 8. Files land in `~/Music/_Serato_/Auto Import` — Serato picks them up automatically
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pipx install streamflacr
37
+ ```
38
+
39
+ Or from source:
40
+
41
+ ```bash
42
+ pipx install .
43
+ ```
44
+
45
+ ## Setup
46
+
47
+ ```bash
48
+ streamflacr setup
49
+ ```
50
+
51
+ The setup wizard will:
52
+
53
+ - **Detect SoundCloud login** in Chrome (auto-extracts OAuth token from cookies)
54
+ - **Auto-discover your profile URL** from the SoundCloud API
55
+ - **Detect SoulseekQt** installation and data
56
+ - **Prompt for credentials** when something is missing
57
+ - **Install Serato tools** (smart crate support)
58
+ - **Write configuration** to `.env`
59
+ - **Register a LaunchDaemon** so StreamFLACr starts on login
60
+
61
+ All existing and future playlists are monitored automatically — no need to specify them manually.
62
+
63
+ ## Usage
64
+
65
+ Single pass (check once and exit):
66
+ ```bash
67
+ streamflacr
68
+ ```
69
+
70
+ Daemon mode (poll continuously):
71
+ ```bash
72
+ streamflacr --daemon
73
+ ```
74
+
75
+ Re-run setup:
76
+ ```bash
77
+ streamflacr setup
78
+ ```
79
+
80
+ Unregister LaunchDaemon:
81
+ ```bash
82
+ streamflacr setup --uninstall
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ All config lives in `.env` in the project directory (created by `streamflacr setup`):
88
+
89
+ | Variable | Default | Description |
90
+ |---|---|---|
91
+ | `SLSK_USERNAME` | — | Soulseek username (required) |
92
+ | `SLSK_PASSWORD` | — | Soulseek password (required) |
93
+ | `SOUNDCLOUD_USER_URL` | auto | Your SoundCloud profile URL |
94
+ | `SOUNDCLOUD_POLL_INTERVAL` | `300` | Seconds between polls |
95
+ | `DOWNLOAD_DIR` | `~/Music/_Serato_/Auto Import` | Where FLACs land |
96
+ | `SERATO_DIR` | `~/Music/_Serato_` | Serato database directory |
97
+ | `SEARCH_TIMEOUT` | `30` | Seconds to wait for Soulseek results |
98
+ | `PREFER_FREE_SLOTS` | `1` | Prefer users with free upload slots |
99
+ | `MIN_FILESIZE_MB` | `5` | Skip files smaller than this |
@@ -0,0 +1,82 @@
1
+ # StreamFLACr
2
+
3
+ Automatically download FLAC versions of songs added to SoundCloud playlists via Soulseek, tag them with metadata, and create matching Serato smart crates.
4
+
5
+ ## How it works
6
+
7
+ 1. **Discovers** all your SoundCloud playlists automatically (including private ones)
8
+ 2. **Monitors** for new tracks added to any playlist
9
+ 3. **Detects** new playlists as they're created
10
+ 4. **Searches** Soulseek for FLAC versions of each new track
11
+ 5. **Downloads** the best candidate (prefers free slots, fast speeds, larger files)
12
+ 6. **Tags** the FLAC with artist, title, and the playlist name as the **Label** field
13
+ 7. **Creates** a Serato smart crate per playlist with rule: `Label IS <playlist_name>`
14
+ 8. Files land in `~/Music/_Serato_/Auto Import` — Serato picks them up automatically
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pipx install streamflacr
20
+ ```
21
+
22
+ Or from source:
23
+
24
+ ```bash
25
+ pipx install .
26
+ ```
27
+
28
+ ## Setup
29
+
30
+ ```bash
31
+ streamflacr setup
32
+ ```
33
+
34
+ The setup wizard will:
35
+
36
+ - **Detect SoundCloud login** in Chrome (auto-extracts OAuth token from cookies)
37
+ - **Auto-discover your profile URL** from the SoundCloud API
38
+ - **Detect SoulseekQt** installation and data
39
+ - **Prompt for credentials** when something is missing
40
+ - **Install Serato tools** (smart crate support)
41
+ - **Write configuration** to `.env`
42
+ - **Register a LaunchDaemon** so StreamFLACr starts on login
43
+
44
+ All existing and future playlists are monitored automatically — no need to specify them manually.
45
+
46
+ ## Usage
47
+
48
+ Single pass (check once and exit):
49
+ ```bash
50
+ streamflacr
51
+ ```
52
+
53
+ Daemon mode (poll continuously):
54
+ ```bash
55
+ streamflacr --daemon
56
+ ```
57
+
58
+ Re-run setup:
59
+ ```bash
60
+ streamflacr setup
61
+ ```
62
+
63
+ Unregister LaunchDaemon:
64
+ ```bash
65
+ streamflacr setup --uninstall
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ All config lives in `.env` in the project directory (created by `streamflacr setup`):
71
+
72
+ | Variable | Default | Description |
73
+ |---|---|---|
74
+ | `SLSK_USERNAME` | — | Soulseek username (required) |
75
+ | `SLSK_PASSWORD` | — | Soulseek password (required) |
76
+ | `SOUNDCLOUD_USER_URL` | auto | Your SoundCloud profile URL |
77
+ | `SOUNDCLOUD_POLL_INTERVAL` | `300` | Seconds between polls |
78
+ | `DOWNLOAD_DIR` | `~/Music/_Serato_/Auto Import` | Where FLACs land |
79
+ | `SERATO_DIR` | `~/Music/_Serato_` | Serato database directory |
80
+ | `SEARCH_TIMEOUT` | `30` | Seconds to wait for Soulseek results |
81
+ | `PREFER_FREE_SLOTS` | `1` | Prefer users with free upload slots |
82
+ | `MIN_FILESIZE_MB` | `5` | Skip files smaller than this |
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "streamflacr"
7
+ version = "0.1.0"
8
+ description = "Auto-download FLAC from Soulseek for SoundCloud playlist additions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "aioslsk>=1.6.0",
14
+ "mutagen>=1.47",
15
+ "numpy>=1.24",
16
+ "pillow>=10.0",
17
+ "pycryptodome>=3.20",
18
+ "python-dotenv>=1.2",
19
+ "pydantic-settings>=2.4",
20
+ "requests>=2.31",
21
+ "yt-dlp>=2024.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ streamflacr = "streamflacr.cli:main"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["streamflacr*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """StreamFLACr - Auto-download FLAC from Soulseek for SoundCloud playlist additions."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,200 @@
1
+ """StreamFLACr main daemon.
2
+
3
+ Monitors ALL SoundCloud playlists for the authenticated user, searches
4
+ Soulseek for FLAC versions of new tracks, downloads them, tags metadata,
5
+ and creates matching Serato smart crates.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ from .config import (
13
+ DOWNLOAD_DIR,
14
+ SEARCH_TIMEOUT,
15
+ SOUNDCLOUD_POLL_INTERVAL,
16
+ SOUNDCLOUD_USER_URL,
17
+ )
18
+ from .metadata import tag_flac
19
+ from .notify import send_notification
20
+ from .serato_crate import ensure_smart_crate
21
+ from .soundcloud import (
22
+ PlaylistInfo,
23
+ TrackInfo,
24
+ discover_user_playlists,
25
+ fetch_playlist_tracks,
26
+ refresh_playlist_tracks,
27
+ _api_get,
28
+ )
29
+ from .soulseek import SoulseekDownloader
30
+ from .state import StateManager
31
+
32
+ logger = logging.getLogger("streamflacr")
33
+
34
+
35
+ async def process_new_track(
36
+ track: TrackInfo,
37
+ playlist_name: str,
38
+ slsk: SoulseekDownloader,
39
+ state: StateManager,
40
+ playlist_url: str,
41
+ ) -> Path | None:
42
+ """Search, download, tag, and integrate a single track. Returns local path on success."""
43
+ logger.info("Processing: %s - %s", track.artist, track.title)
44
+
45
+ candidates = await slsk.search_flac(track.artist, track.title, timeout=SEARCH_TIMEOUT)
46
+ if not candidates:
47
+ logger.warning("No FLAC found on Soulseek for: %s - %s", track.artist, track.title)
48
+ send_notification("StreamFLACr", f"No FLAC found: {track.artist} - {track.title}")
49
+ return None
50
+
51
+ for candidate in candidates[:3]:
52
+ local_path = await slsk.download(candidate["username"], candidate["remote_path"])
53
+ if local_path and local_path.exists():
54
+ tag_flac(
55
+ filepath=local_path,
56
+ artist=track.artist,
57
+ title=track.title,
58
+ playlist_name=playlist_name,
59
+ )
60
+ state.mark_downloaded(
61
+ playlist_url=playlist_url,
62
+ track_id=track.track_id,
63
+ artist=track.artist,
64
+ title=track.title,
65
+ local_path=str(local_path),
66
+ )
67
+ send_notification("StreamFLACr", f"Downloaded: {track.artist} - {track.title}")
68
+ return local_path
69
+
70
+ logger.error("All download attempts failed for: %s - %s", track.artist, track.title)
71
+ send_notification("StreamFLACr", f"Download failed: {track.artist} - {track.title}")
72
+ return None
73
+
74
+
75
+ async def sync_playlist(
76
+ playlist: PlaylistInfo,
77
+ slsk: SoulseekDownloader,
78
+ state: StateManager,
79
+ ) -> None:
80
+ """Check a single playlist for new tracks and download FLAC for them."""
81
+ playlist_url = playlist.url
82
+ playlist_name = playlist.title
83
+
84
+ # Ensure there's a smart crate for this playlist
85
+ ensure_smart_crate(playlist_name)
86
+
87
+ # Fetch current tracks
88
+ tracks = fetch_playlist_tracks(playlist_url)
89
+ if not tracks:
90
+ logger.debug("No tracks found in playlist: %s", playlist_name)
91
+ return
92
+
93
+ current_ids = {t.track_id for t in tracks}
94
+ seen_ids = state.get_seen_ids(playlist_url)
95
+ new_ids = current_ids - seen_ids
96
+
97
+ if not new_ids:
98
+ return
99
+
100
+ logger.info("Found %d new track(s) in '%s'", len(new_ids), playlist_name)
101
+ new_tracks = [t for t in tracks if t.track_id in new_ids]
102
+
103
+ for track in new_tracks:
104
+ try:
105
+ await process_new_track(track, playlist_name, slsk, state, playlist_url)
106
+ except Exception as e:
107
+ logger.error("Error processing track %s: %s", track.title, e)
108
+
109
+ # Mark all new tracks as seen (even failed ones)
110
+ state.mark_seen(playlist_url, list(new_ids))
111
+ state.save()
112
+
113
+
114
+ async def poll_loop(slsk: SoulseekDownloader, state: StateManager) -> None:
115
+ """Main polling loop: discover all playlists, check each for new tracks."""
116
+ # Initial sync: discover all existing playlists and mark their tracks as seen
117
+ existing_playlists = discover_user_playlists(
118
+ f"{SOUNDCLOUD_USER_URL}/sets" if SOUNDCLOUD_USER_URL else None
119
+ )
120
+
121
+ for playlist in existing_playlists:
122
+ tracks = fetch_playlist_tracks(playlist.url)
123
+ playlist.tracks = tracks
124
+ state.set_playlist_name(playlist.url, playlist.title)
125
+ state.mark_seen(playlist.url, [t.track_id for t in tracks])
126
+ # Create smart crate for each existing playlist
127
+ ensure_smart_crate(playlist.title)
128
+ state.save()
129
+
130
+ total_tracks = sum(len(p.tracks) for p in existing_playlists)
131
+ logger.info(
132
+ "Initial sync: %d playlists, %d tracks already known",
133
+ len(existing_playlists),
134
+ total_tracks,
135
+ )
136
+ send_notification("StreamFLACr", f"Watching {len(existing_playlists)} playlists")
137
+
138
+ known_playlist_urls = {p.url for p in existing_playlists}
139
+
140
+ while True:
141
+ await asyncio.sleep(SOUNDCLOUD_POLL_INTERVAL)
142
+
143
+ try:
144
+ # Re-discover playlists to catch newly created ones
145
+ current_playlists = discover_user_playlists(
146
+ f"{SOUNDCLOUD_USER_URL}/sets" if SOUNDCLOUD_USER_URL else None
147
+ )
148
+ except Exception as e:
149
+ logger.error("Error discovering playlists: %s", e)
150
+ continue
151
+
152
+ # Check for newly created playlists
153
+ for playlist in current_playlists:
154
+ if playlist.url not in known_playlist_urls:
155
+ logger.info("New playlist detected: '%s'", playlist.title)
156
+ state.set_playlist_name(playlist.url, playlist.title)
157
+ ensure_smart_crate(playlist.title)
158
+ known_playlist_urls.add(playlist.url)
159
+
160
+ # Sync each playlist
161
+ for playlist in current_playlists:
162
+ try:
163
+ await sync_playlist(playlist, slsk, state)
164
+ except Exception as e:
165
+ logger.error("Error syncing playlist '%s': %s", playlist.title, e)
166
+
167
+
168
+ async def run_once(slsk: SoulseekDownloader, state: StateManager) -> None:
169
+ """Single-pass mode: check all playlists for new tracks, then exit."""
170
+ playlists = discover_user_playlists(
171
+ f"{SOUNDCLOUD_USER_URL}/sets" if SOUNDCLOUD_USER_URL else None
172
+ )
173
+
174
+ for playlist in playlists:
175
+ try:
176
+ await sync_playlist(playlist, slsk, state)
177
+ except Exception as e:
178
+ logger.error("Error syncing playlist '%s': %s", playlist.title, e)
179
+
180
+
181
+ async def amain(daemon: bool = False) -> None:
182
+ DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
183
+
184
+ state = StateManager()
185
+ slsk = SoulseekDownloader()
186
+
187
+ try:
188
+ await slsk.connect()
189
+
190
+ if daemon:
191
+ await poll_loop(slsk, state)
192
+ else:
193
+ await run_once(slsk, state)
194
+ finally:
195
+ await slsk.disconnect()
196
+
197
+
198
+ if __name__ == "__main__":
199
+ from .cli import main
200
+ main()
@@ -0,0 +1,62 @@
1
+ """CLI entry point for StreamFLACr."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import sys
7
+
8
+ from .setup import run_setup, register_launchdaemon, unregister_launchdaemon
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(
13
+ prog="streamflacr",
14
+ description="StreamFLACr - Auto-download FLAC from Soulseek for SoundCloud playlist additions",
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command")
17
+
18
+ # Default run command (no subcommand)
19
+ parser.add_argument("-d", "--daemon", action="store_true", help="Run as persistent daemon (poll loop)")
20
+ parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
21
+
22
+ # setup subcommand
23
+ setup_parser = subparsers.add_parser("setup", help="Run interactive setup wizard")
24
+ setup_parser.add_argument("--uninstall", action="store_true", help="Unregister LaunchDaemon and remove config")
25
+
26
+ args = parser.parse_args()
27
+
28
+ if args.command == "setup":
29
+ if args.uninstall:
30
+ unregister_launchdaemon()
31
+ return
32
+ run_setup()
33
+ return
34
+
35
+ # Run the main daemon
36
+ level = logging.DEBUG if args.verbose else logging.INFO
37
+ logging.basicConfig(
38
+ level=level,
39
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
40
+ datefmt="%H:%M:%S",
41
+ )
42
+
43
+ from .config import SLSK_USERNAME, SLSK_PASSWORD, SOUNDCLOUD_USER_URL
44
+ missing = []
45
+ if not SLSK_USERNAME or not SLSK_PASSWORD:
46
+ missing.append("Soulseek credentials")
47
+ if not SOUNDCLOUD_USER_URL:
48
+ missing.append("SoundCloud user URL")
49
+
50
+ if missing:
51
+ logger = logging.getLogger("streamflacr")
52
+ for m in missing:
53
+ logger.error("Missing configuration: %s", m)
54
+ print("\n Run `streamflacr setup` to configure StreamFLACr.\n")
55
+ sys.exit(1)
56
+
57
+ from .__main__ import amain
58
+ asyncio.run(amain(daemon=args.daemon))
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,29 @@
1
+ """Configuration management via environment / .env file."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ # Soulseek credentials
10
+ SLSK_USERNAME: str = os.environ.get("SLSK_USERNAME", "")
11
+ SLSK_PASSWORD: str = os.environ.get("SLSK_PASSWORD", "")
12
+
13
+ # SoundCloud
14
+ SOUNDCLOUD_USER_URL: str = os.environ.get("SOUNDCLOUD_USER_URL", "")
15
+ SOUNDCLOUD_POLL_INTERVAL: int = int(os.environ.get("SOUNDCLOUD_POLL_INTERVAL", "300")) # seconds
16
+
17
+ # Download destination
18
+ DOWNLOAD_DIR: Path = Path(os.environ.get("DOWNLOAD_DIR", "/Users/djtchill/Music/_Serato_/Auto Import"))
19
+
20
+ # Serato
21
+ SERATO_DIR: Path = Path(os.environ.get("SERATO_DIR", "/Users/djtchill/Music/_Serato_"))
22
+
23
+ # State file (tracks last-seen set to avoid re-downloading)
24
+ STATE_FILE: Path = Path(os.environ.get("STATE_FILE", str(Path(__file__).parent.parent / "state.json")))
25
+
26
+ # Search preferences
27
+ SEARCH_TIMEOUT: int = int(os.environ.get("SEARCH_TIMEOUT", "30"))
28
+ PREFER_FREE_SLOTS: bool = os.environ.get("PREFER_FREE_SLOTS", "1") == "1"
29
+ MIN_FILESIZE_MB: int = int(os.environ.get("MIN_FILESIZE_MB", "5"))
@@ -0,0 +1,43 @@
1
+ """FLAC metadata tagging via mutagen.
2
+
3
+ Writes artist, title, album, label (set to the SoundCloud playlist name),
4
+ and other standard Vorbis comment fields.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ from mutagen.flac import FLAC
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def tag_flac(
16
+ filepath: Path,
17
+ artist: str,
18
+ title: str,
19
+ playlist_name: str,
20
+ album: str | None = None,
21
+ genre: str | None = None,
22
+ year: str | None = None,
23
+ ) -> None:
24
+ """Write metadata to a FLAC file.
25
+
26
+ The 'label' field (Vorbis comment LABEL / publisher) is set to the
27
+ SoundCloud playlist name so Serato smart crates can match on it.
28
+ """
29
+ audio = FLAC(str(filepath))
30
+
31
+ audio["artist"] = artist
32
+ audio["title"] = title
33
+ audio["label"] = playlist_name # This is the key Serato field
34
+
35
+ if album:
36
+ audio["album"] = album
37
+ if genre:
38
+ audio["genre"] = genre
39
+ if year:
40
+ audio["date"] = year
41
+
42
+ audio.save()
43
+ logger.info("Tagged %s: artist=%s, title=%s, label=%s", filepath.name, artist, title, playlist_name)
@@ -0,0 +1,18 @@
1
+ """macOS notification support."""
2
+
3
+ import logging
4
+ import subprocess
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def send_notification(title: str, message: str) -> None:
10
+ """Send a macOS notification via osascript."""
11
+ # Escape for AppleScript
12
+ title_escaped = title.replace('"', '\\"').replace("\\", "\\\\")
13
+ message_escaped = message.replace('"', '\\"').replace("\\", "\\\\")
14
+ script = f'display notification "{message_escaped}" with title "{title_escaped}"'
15
+ try:
16
+ subprocess.run(["osascript", "-e", script], check=True, capture_output=True, timeout=5)
17
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
18
+ logger.warning("Could not send notification: %s", e)
@@ -0,0 +1,63 @@
1
+ """Serato smart crate management via serato-tools.
2
+
3
+ Creates a smart crate per SoundCloud playlist with a rule:
4
+ Label IS <playlist_name>
5
+ so that all downloaded FLACs with that label tag auto-populate the crate.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from .config import SERATO_DIR
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _import_serato_tools():
17
+ """Lazy import serato-tools. Raises ImportError if not installed."""
18
+ from serato_tools.smart_crate import SmartCrate # noqa: F401
19
+ return SmartCrate
20
+
21
+
22
+ def ensure_smart_crate(playlist_name: str) -> Path | None:
23
+ """Create or update a Serato smart crate that matches on Label IS playlist_name.
24
+
25
+ Returns the path to the .scrate file, or None if serato-tools is not installed.
26
+ """
27
+ try:
28
+ SmartCrate = _import_serato_tools()
29
+ except ImportError:
30
+ logger.warning("serato-tools not installed; skipping smart crate creation")
31
+ logger.warning("Install with: pip install serato-tools --no-deps")
32
+ return None
33
+
34
+ safe_name = playlist_name.replace("/", "≫").replace("\\", "≫")
35
+ smart_crates_dir = SERATO_DIR / "SmartCrates"
36
+ smart_crates_dir.mkdir(parents=True, exist_ok=True)
37
+ scrate_path = smart_crates_dir / f"{safe_name}.scrate"
38
+
39
+ if scrate_path.exists():
40
+ logger.info("Smart crate already exists: %s", scrate_path.name)
41
+ sc = SmartCrate(str(scrate_path))
42
+ _ensure_label_rule(sc, playlist_name)
43
+ sc.save()
44
+ return scrate_path
45
+
46
+ sc = SmartCrate(str(scrate_path))
47
+ _ensure_label_rule(sc, playlist_name)
48
+
49
+ # Enable live update so Serato refreshes automatically
50
+ for i, (f, v) in enumerate(sc.entries):
51
+ if f == SmartCrate.Fields.SMARTCRATE_LIVE_UPDATE:
52
+ sc.entries[i] = (f, [("brut", True)])
53
+ if f == SmartCrate.Fields.SMARTCRATE_MATCH_ALL:
54
+ sc.entries[i] = (f, [("brut", True)])
55
+
56
+ sc.save()
57
+ logger.info("Created smart crate: %s (Label IS '%s')", scrate_path.name, playlist_name)
58
+ return scrate_path
59
+
60
+
61
+ def _ensure_label_rule(sc, playlist_name: str) -> None:
62
+ """Make sure the smart crate has a Label IS rule for the playlist name."""
63
+ sc.set_rule(sc.RuleField.LABEL, sc.RuleComparison.STR_IS, playlist_name)