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.
- streamflacr/__init__.py +2 -0
- streamflacr/__main__.py +200 -0
- streamflacr/cli.py +62 -0
- streamflacr/config.py +29 -0
- streamflacr/metadata.py +43 -0
- streamflacr/notify.py +18 -0
- streamflacr/serato_crate.py +63 -0
- streamflacr/setup.py +302 -0
- streamflacr/soulseek.py +136 -0
- streamflacr/soundcloud.py +308 -0
- streamflacr/state.py +76 -0
- streamflacr-0.1.0.dist-info/METADATA +99 -0
- streamflacr-0.1.0.dist-info/RECORD +16 -0
- streamflacr-0.1.0.dist-info/WHEEL +5 -0
- streamflacr-0.1.0.dist-info/entry_points.txt +2 -0
- streamflacr-0.1.0.dist-info/top_level.txt +1 -0
streamflacr/__init__.py
ADDED
streamflacr/__main__.py
ADDED
|
@@ -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"))
|
streamflacr/metadata.py
ADDED
|
@@ -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)
|