headless-music 1.1.1__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
+ __version__ = "1.1.0"
2
+ __author__ = "Rishav Ganguly"
@@ -0,0 +1,123 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from rich.prompt import Prompt, Confirm
6
+
7
+ console = Console()
8
+
9
+ CONFIG_FILE = Path.home() / ".headless_music_config.json"
10
+ LOG_FILE = Path.home() / ".headless_music.log"
11
+
12
+
13
+ def setup_logging():
14
+ logging.basicConfig(
15
+ filename=LOG_FILE,
16
+ level=logging.WARNING,
17
+ format='%(asctime)s - %(levelname)s - %(message)s'
18
+ )
19
+
20
+
21
+ def load_config():
22
+ if CONFIG_FILE.exists():
23
+ try:
24
+ with open(CONFIG_FILE, 'r') as f:
25
+ return json.load(f)
26
+ except Exception as e:
27
+ logging.warning(f"Could not load config: {e}")
28
+ console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
29
+ return {}
30
+
31
+
32
+ def save_config(config):
33
+ try:
34
+ with open(CONFIG_FILE, 'w') as f:
35
+ json.dump(config, f, indent=2)
36
+ return True
37
+ except Exception as e:
38
+ logging.error(f"Error saving config: {e}")
39
+ console.print(f"[red]Error saving config: {e}[/red]")
40
+ return False
41
+
42
+
43
+ def validate_config(config):
44
+ required = ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET',
45
+ 'PLAYLIST_SOURCE', 'PLAYLIST_URL']
46
+ missing = [key for key in required if not config.get(key)]
47
+
48
+ if missing:
49
+ console.print(f"[red]Missing configuration: {', '.join(missing)}[/red]")
50
+ return False
51
+ return True
52
+
53
+
54
+ def setup_wizard():
55
+ console.clear()
56
+ console.print("=" * 60, style="cyan")
57
+ console.print("🎵 Welcome to headless_music Setup!",
58
+ style="bold cyan", justify="center")
59
+ console.print("=" * 60, style="cyan")
60
+ console.print()
61
+
62
+ config = load_config()
63
+
64
+ console.print("📱 [bold]Spotify API Credentials[/bold]")
65
+ console.print(" Get these from: https://developer.spotify.com/dashboard",
66
+ style="dim")
67
+ console.print()
68
+
69
+ spotify_id = Prompt.ask(
70
+ " Spotify Client ID",
71
+ default=config.get('SPOTIFY_CLIENT_ID', '')
72
+ )
73
+ spotify_secret = Prompt.ask(
74
+ " Spotify Client Secret",
75
+ default=config.get('SPOTIFY_CLIENT_SECRET', ''),
76
+ password=True
77
+ )
78
+
79
+ console.print()
80
+ console.print("🎧 [bold]Playlist Source[/bold]")
81
+ console.print()
82
+
83
+ source_choice = Prompt.ask(
84
+ " Choose your playlist source",
85
+ choices=["spotify", "youtube"],
86
+ default=config.get('PLAYLIST_SOURCE', 'spotify')
87
+ )
88
+
89
+ console.print()
90
+
91
+ if source_choice == "spotify":
92
+ console.print(" Enter your Spotify playlist URL or URI", style="dim")
93
+ console.print(" Example: https://open.spotify.com/playlist/...",
94
+ style="dim")
95
+ playlist_url = Prompt.ask(" Spotify Playlist URL/URI")
96
+ else:
97
+ console.print(" Enter your YouTube playlist URL", style="dim")
98
+ console.print(" Example: https://www.youtube.com/playlist?list=...",
99
+ style="dim")
100
+ playlist_url = Prompt.ask(" YouTube Playlist URL")
101
+
102
+ console.print()
103
+
104
+ new_config = {
105
+ 'SPOTIFY_CLIENT_ID': spotify_id,
106
+ 'SPOTIFY_CLIENT_SECRET': spotify_secret,
107
+ 'PLAYLIST_SOURCE': source_choice,
108
+ 'PLAYLIST_URL': playlist_url
109
+ }
110
+
111
+ if save_config(new_config):
112
+ console.print("✓ Configuration saved!", style="bold green")
113
+ console.print(f" Config location: {CONFIG_FILE}", style="dim")
114
+ console.print(f" Log location: {LOG_FILE}", style="dim")
115
+ else:
116
+ console.print("⚠️ Could not save configuration.", style="yellow")
117
+
118
+ console.print()
119
+ if Confirm.ask("Start headless_music now?", default=True):
120
+ return new_config
121
+ else:
122
+ console.print("👋 Run this script again when you're ready!", style="cyan")
123
+ return None
@@ -0,0 +1,4 @@
1
+ from .spotify import SpotifyFetcher
2
+ from .youtube import YouTubeFetcher
3
+
4
+ __all__ = ['SpotifyFetcher', 'YouTubeFetcher']
@@ -0,0 +1,166 @@
1
+ import os
2
+ import logging
3
+ import random
4
+ import spotipy
5
+ from spotipy.oauth2 import SpotifyClientCredentials
6
+ from spotipy.cache_handler import CacheFileHandler
7
+
8
+
9
+ cache_path = os.path.join(os.path.expanduser("~"), ".cache_headless_music")
10
+ cache_handler = CacheFileHandler(cache_path=cache_path)
11
+
12
+
13
+ class SpotifyFetcher:
14
+ """Handle all Spotify API interactions."""
15
+
16
+ def __init__(self, client_id, client_secret):
17
+ self.client = None
18
+ self.client_id = client_id
19
+ self.client_secret = client_secret
20
+ self._initialize_client()
21
+
22
+ def _initialize_client(self):
23
+ """Initialize Spotify client with credentials."""
24
+ try:
25
+ self.client = spotipy.Spotify(
26
+ auth_manager=SpotifyClientCredentials(
27
+ client_id=self.client_id,
28
+ client_secret=self.client_secret,
29
+ cache_handler=cache_handler
30
+ ),
31
+ requests_timeout=5
32
+ )
33
+ logging.info("Spotify client initialized")
34
+ except Exception as e:
35
+ logging.error(f"Failed to initialize Spotify: {e}")
36
+ self.client = None
37
+
38
+ def get_playlist_tracks(self, playlist_url):
39
+ """Fetch all tracks from a Spotify playlist."""
40
+ if not self.client:
41
+ return []
42
+
43
+ try:
44
+ playlist_id = self._extract_playlist_id(playlist_url)
45
+ results = []
46
+ offset = 0
47
+
48
+ while True:
49
+ response = self.client.playlist_tracks(playlist_id, offset=offset, limit=100)
50
+ for item in response['items']:
51
+ if item['track']:
52
+ track = item['track']
53
+ image_url = (track['album']['images'][-1]['url']
54
+ if track['album']['images'] else None)
55
+ results.append((
56
+ track['name'],
57
+ track['artists'][0]['name'],
58
+ "Spotify",
59
+ image_url
60
+ ))
61
+
62
+ if not response['next']:
63
+ break
64
+ offset += 100
65
+
66
+ logging.info(f"Fetched {len(results)} tracks from Spotify")
67
+ return results
68
+ except Exception as e:
69
+ logging.error(f"Error fetching Spotify playlist: {e}")
70
+ return []
71
+
72
+ def get_recommendations(self, seed_tracks, limit=20):
73
+ """Get Spotify recommendations based on seed tracks."""
74
+ if not self.client:
75
+ return []
76
+
77
+ results = []
78
+ seen_ids = set()
79
+
80
+ sample_size = min(len(seed_tracks), 5)
81
+ sampled = random.sample(seed_tracks, sample_size)
82
+
83
+ for title, artist, _, _ in sampled:
84
+ if len(results) >= limit:
85
+ break
86
+
87
+ try:
88
+ search_results = self.client.search(
89
+ q=f"{title} {artist}", type="track", limit=1
90
+ )
91
+ if search_results['tracks']['items']:
92
+ seed_id = search_results['tracks']['items'][0]['id']
93
+ try:
94
+ recs = self.client.recommendations(seed_tracks=[seed_id], limit=5)
95
+ for track in recs['tracks']:
96
+ track_id = track['id']
97
+ if track_id not in seen_ids:
98
+ image_url = (track['album']['images'][-1]['url']
99
+ if track['album']['images'] else None)
100
+ results.append((
101
+ track['name'],
102
+ track['artists'][0]['name'],
103
+ "Spotify",
104
+ image_url
105
+ ))
106
+ seen_ids.add(track_id)
107
+ except Exception:
108
+ pass
109
+ except Exception:
110
+ continue
111
+
112
+ # Fallback: related artists
113
+ if len(results) < limit:
114
+ results.extend(self._get_related_artist_tracks(
115
+ sampled, limit - len(results), seen_ids
116
+ ))
117
+
118
+ logging.info(f"Generated {len(results)} recommendations")
119
+ return results
120
+
121
+ def _get_related_artist_tracks(self, seed_tracks, limit, seen_ids):
122
+ """Get tracks from related artists."""
123
+ results = []
124
+
125
+ for title, artist, _, _ in seed_tracks:
126
+ if len(results) >= limit:
127
+ break
128
+
129
+ try:
130
+ artist_results = self.client.search(
131
+ q=f"artist:{artist}", type="artist", limit=1
132
+ )
133
+ if artist_results['artists']['items']:
134
+ artist_id = artist_results['artists']['items'][0]['id']
135
+ related = self.client.artist_related_artists(artist_id)
136
+
137
+ for rel_artist in related['artists'][:3]:
138
+ top_tracks = self.client.artist_top_tracks(rel_artist['id'])
139
+ for track in top_tracks['tracks'][:2]:
140
+ track_id = track['id']
141
+ if track_id not in seen_ids:
142
+ image_url = (track['album']['images'][-1]['url']
143
+ if track['album']['images'] else None)
144
+ results.append((
145
+ track['name'],
146
+ track['artists'][0]['name'],
147
+ "Spotify",
148
+ image_url
149
+ ))
150
+ seen_ids.add(track_id)
151
+ if len(results) >= limit:
152
+ return results
153
+ except Exception:
154
+ continue
155
+
156
+ return results
157
+
158
+ @staticmethod
159
+ def _extract_playlist_id(playlist_url):
160
+ """Extract playlist ID from various URL formats."""
161
+ if 'spotify.com' in playlist_url:
162
+ return playlist_url.split('playlist/')[-1].split('?')[0]
163
+ elif 'spotify:playlist:' in playlist_url:
164
+ return playlist_url.split('spotify:playlist:')[-1]
165
+ return playlist_url
166
+
@@ -0,0 +1,59 @@
1
+ import logging
2
+ import random
3
+ import yt_dlp
4
+
5
+
6
+ class YouTubeFetcher:
7
+ def __init__(self):
8
+ self.ydl_opts_flat = {
9
+ 'quiet': True,
10
+ 'extract_flat': True,
11
+ 'no_warnings': True,
12
+ 'ignoreerrors': True,
13
+ 'no_color': True
14
+ }
15
+ self.ydl_opts_full = {
16
+ 'quiet': True,
17
+ 'extract_flat': False,
18
+ 'no_warnings': True,
19
+ 'ignoreerrors': True,
20
+ 'no_color': True
21
+ }
22
+
23
+ def get_playlist_titles(self, url):
24
+ try:
25
+ with yt_dlp.YoutubeDL(self.ydl_opts_flat) as ydl:
26
+ info = ydl.extract_info(url, download=False)
27
+ return [(e['title'], e.get('uploader', 'Unknown'), "YouTube", None)
28
+ for e in info['entries'] if e]
29
+ except Exception as e:
30
+ logging.error(f"Error fetching YouTube playlist: {e}")
31
+ return []
32
+
33
+ def search_similar_tracks(self, seed_tracks, limit=10):
34
+ results = []
35
+ sample = random.sample(seed_tracks, min(3, len(seed_tracks)))
36
+
37
+ for title, artist, _, _ in sample:
38
+ try:
39
+ search_query = f"{artist} {title} audio"
40
+ with yt_dlp.YoutubeDL(self.ydl_opts_full) as ydl:
41
+ search_results = ydl.extract_info(
42
+ f"ytsearch5:{search_query}", download=False
43
+ )
44
+ if search_results and 'entries' in search_results:
45
+ for entry in search_results['entries']:
46
+ if entry:
47
+ image_url = entry.get('thumbnail')
48
+ results.append((
49
+ entry.get('title', 'Unknown'),
50
+ entry.get('uploader', 'Unknown'),
51
+ "YouTube",
52
+ image_url
53
+ ))
54
+ if len(results) >= limit:
55
+ return results
56
+ except Exception:
57
+ continue
58
+
59
+ return results