streamflacr 0.1.0__py3-none-any.whl

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,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()
streamflacr/cli.py ADDED
@@ -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()
streamflacr/config.py ADDED
@@ -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)
streamflacr/notify.py ADDED
@@ -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)