File size: 14,607 Bytes
3ce445b
 
1a5f803
1614441
308dee3
1a5f803
3ce445b
f916083
1a5f803
 
 
8d7f55f
3ce445b
8d7f55f
edad03c
 
 
8d7f55f
f916083
3ce445b
edad03c
 
 
 
3ce445b
fb93808
1007d5f
13367cb
1007d5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb93808
1007d5f
 
 
 
 
 
 
 
1e7fd76
 
 
 
3ce445b
f916083
 
 
3ce445b
f916083
1a5f803
f916083
 
9b5b26a
edad03c
98e479e
9b5b26a
1007d5f
f916083
98e479e
1007d5f
98e479e
1007d5f
98e479e
 
 
d79880a
edad03c
 
 
 
 
 
 
 
bd7c9f5
 
3a3a87e
 
edad03c
bd7c9f5
3a3a87e
 
bd7c9f5
13367cb
1007d5f
f916083
 
 
 
bd7c9f5
 
 
 
1a5f803
 
 
1007d5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a5f803
 
053e2a6
 
 
1a5f803
d79880a
 
 
 
 
3a3a87e
8d7f55f
053e2a6
100f1e5
1a5f803
 
3a3a87e
1a5f803
 
 
 
 
 
 
 
 
 
 
 
3430f6c
3a3a87e
d79880a
c3a69a1
 
8d7f55f
 
 
 
 
 
c3a69a1
 
 
3a3a87e
1007d5f
053e2a6
 
ecba72d
053e2a6
308dee3
053e2a6
308dee3
 
 
 
 
 
 
3a3a87e
308dee3
3a3a87e
308dee3
3a3a87e
100f1e5
308dee3
f45089c
308dee3
 
f45089c
1007d5f
1614441
308dee3
 
1007d5f
d93cb29
 
8d7f55f
100f1e5
 
 
 
 
308dee3
100f1e5
8d7f55f
100f1e5
1614441
100f1e5
8d7f55f
 
100f1e5
f45089c
 
d93cb29
308dee3
 
 
 
f45089c
308dee3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f45089c
3a3a87e
1a5f803
1007d5f
 
 
13367cb
 
3a3a87e
1007d5f
 
 
 
 
3a3a87e
 
f45089c
bc5e932
 
1007d5f
f45089c
 
8d7f55f
f45089c
8d7f55f
 
f45089c
 
 
 
 
 
8d7f55f
3a3a87e
f45089c
8d7f55f
308dee3
8d7f55f
f45089c
308dee3
 
d79880a
308dee3
3a3a87e
308dee3
f45089c
 
8d7f55f
d79880a
3a3a87e
308dee3
 
 
 
 
 
f45089c
9b5b26a
3ce445b
99fb280
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
import os
import sys
import io
import random
import math
import requests
import spotipy
import gradio as gr
import matplotlib.pyplot as plt
import pandas as pd
from spotipy.oauth2 import SpotifyClientCredentials
from PIL import Image

# Spotify credentials from environment variables (fallback)
ENV_SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
ENV_SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
if not ENV_SPOTIFY_CLIENT_ID or not ENV_SPOTIFY_CLIENT_SECRET:
    print("Error: Spotify credentials not set.")
    sys.exit(1)

global_sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials(
    client_id=ENV_SPOTIFY_CLIENT_ID,
    client_secret=ENV_SPOTIFY_CLIENT_SECRET
))

def get_musicbrainz_genre(artist_name):
    search_url = "https://musicbrainz.org/ws/2/artist/"
    headers = {"User-Agent": "SpotifyAnalyzer/1.0 ([email protected])"}
    params = {"query": artist_name, "fmt": "json"}
    try:
        search_response = requests.get(search_url, params=params, headers=headers)
        search_data = search_response.json()
        if "artists" in search_data and search_data["artists"]:
            best_artist = None
            best_score = 0
            for artist in search_data["artists"]:
                name = artist.get("name", "")
                score = int(artist.get("score", 0))
                if name.lower() == artist_name.lower():
                    best_artist = artist
                    break
                if score > best_score:
                    best_score = score
                    best_artist = artist
            if best_artist:
                mbid = best_artist.get("id")
                if mbid:
                    lookup_url = f"https://musicbrainz.org/ws/2/artist/{mbid}"
                    lookup_params = {"inc": "tags+genres", "fmt": "json"}
                    lookup_response = requests.get(lookup_url, params=lookup_params, headers=headers)
                    lookup_data = lookup_response.json()
                    official_genres = lookup_data.get("genres", [])
                    if official_genres:
                        return official_genres[0].get("name", "Unknown")
                    tags = lookup_data.get("tags", [])
                    if tags:
                        sorted_tags = sorted(tags, key=lambda t: t.get("count", 0), reverse=True)
                        return sorted_tags[0].get("name", "Unknown")
    except Exception:
        pass
    return "Unknown"

def get_audiodb_genre(artist_name):
    url = "https://theaudiodb.com/api/v1/json/1/search.php"
    params = {"s": artist_name}
    try:
        response = requests.get(url, params=params)
        if response.ok:
            data = response.json()
            if data and data.get("artists"):
                artist_data = data["artists"][0]
                genre = artist_data.get("strGenre", "")
                if genre:
                    return genre
    except Exception:
        pass
    return "Unknown"

def extract_playlist_id(url: str) -> str:
    if "playlist" not in url:
        return ""
    parts = url.split("/")
    try:
        idx = parts.index("playlist")
        return parts[idx + 1].split("?")[0]
    except (ValueError, IndexError):
        return ""

def get_playlist_tracks(playlist_id: str, spotify_client) -> list:
    tracks = []
    try:
        results = spotify_client.playlist_tracks(playlist_id)
        tracks.extend(results["items"])
        while results["next"]:
            results = spotify_client.next(results)
            tracks.extend(results["items"])
    except spotipy.SpotifyException:
        return []
    return tracks

def analyze_playlist(playlist_url: str, spotify_client_id: str, spotify_client_secret: str):
    if spotify_client_id.strip() and spotify_client_secret.strip():
        local_sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials(
            client_id=spotify_client_id.strip(),
            client_secret=spotify_client_secret.strip()
        ))
    else:
        local_sp = global_sp

    playlist_id = extract_playlist_id(playlist_url.strip())
    if not playlist_id:
        return ("Invalid playlist URL.", None, None, None, None, "")
    
    tracks = get_playlist_tracks(playlist_id, local_sp)
    if not tracks:
        return ("No tracks found or playlist is private.", None, None, None, None, "")
    
    genre_count = {}
    artist_cache = {}
    tracks_table = []
    for item in tracks:
        track = item.get("track")
        if not track:
            continue
        track_name = track.get("name", "Unknown Track")
        artists = track.get("artists", [])
        if not artists:
            continue
        artist_info = artists[0]
        artist_name = artist_info.get("name", "Unknown Artist")
        artist_id = artist_info.get("id")
        # Try Spotify genres; if empty, fallback to MusicBrainz then AudioDB.
        if artist_id:
            if artist_id in artist_cache:
                genres = artist_cache[artist_id]
            else:
                try:
                    artist_data = local_sp.artist(artist_id)
                    genres = artist_data.get("genres", [])
                except spotipy.SpotifyException:
                    genres = []
                if not genres:
                    mb_genre = get_musicbrainz_genre(artist_name)
                    if mb_genre == "Unknown":
                        audiodb_genre = get_audiodb_genre(artist_name)
                        if audiodb_genre != "Unknown":
                            genres = [audiodb_genre]
                    else:
                        genres = [mb_genre]
                artist_cache[artist_id] = genres
        else:
            genres = []
        if genres:
            for g in genres:
                genre_count[g] = genre_count.get(g, 0) + 1
        primary_genre = genres[0] if genres else "Unknown"
        spotify_url = track.get("external_urls", {}).get("spotify", "#")
        query = f"{track_name} {artist_name}"
        yt_link = f'<a href="https://music.youtube.com/search?q={requests.utils.quote(query)}" target="_blank">YouTube Music</a>'
        tracks_table.append([track_name, artist_name, primary_genre,
                             f'<a href="{spotify_url}" target="_blank">Listen on Spotify</a>', yt_link])
    
    total_occurrences = sum(genre_count.values())
    genres_table_data = [[genre, count, f"{(count / total_occurrences * 100):.2f}%"]
                         for genre, count in genre_count.items()]
    genres_table_data.sort(key=lambda x: x[1], reverse=True)
    genres_df = pd.DataFrame(genres_table_data, columns=["Genre", "Count", "Percentage"])
    
    top15 = genres_df.head(15)
    plt.figure(figsize=(10, 6))
    plt.bar(top15["Genre"], top15["Count"], color='skyblue')
    plt.xticks(rotation=45, ha="right")
    plt.xlabel("Genre")
    plt.ylabel("Count")
    plt.title("Top 15 Genres")
    plt.tight_layout()
    buf = io.BytesIO()
    plt.savefig(buf, format='png')
    plt.close()
    buf.seek(0)
    chart_image = Image.open(buf).convert("RGB")
    
    tracks_df = pd.DataFrame(tracks_table, columns=["Song Name", "Artist", "Genre", "Spotify Link", "YouTube Music Link"])
    table_style = """
    <style>
    .nice-table { width: 100%; border-collapse: collapse; margin-top: 1em; }
    .nice-table th, .nice-table td { border: 1px solid #ccc; padding: 8px; }
    .nice-table th { background-color: #f9f9f9; font-weight: bold; }
    .nice-table tr:nth-child(even) { background-color: #f2f2f2; }
    .nice-table a { color: #007bff; text-decoration: none; }
    .nice-table a:hover { text-decoration: underline; }
    </style>
    """
    tracks_html = table_style + tracks_df.to_html(escape=False, index=False, classes="nice-table")
    
    # Prepare state for recommendations: exclude "Unknown" genres.
    top_genres = [genre for genre in genres_df["Genre"].head(15).tolist() if genre.lower() != "unknown"]
    if not top_genres:
        top_genres = ["pop"]
    num_genres = len(top_genres)
    rec_per_genre = math.ceil(75 / num_genres)
    existing_tracks = set((t[0].strip().lower(), t[1].strip().lower()) for t in tracks_table)
    analysis_state = {
        "top_genres": top_genres,
        "existing_tracks": existing_tracks,
        "sp_client_id": spotify_client_id,
        "sp_client_secret": spotify_client_secret,
        "rec_per_genre": rec_per_genre
    }
    
    recommended_html = generate_recommendations(analysis_state, local_sp, table_style)
    processed_info = f"Processed {len(tracks_table)} songs from the playlist."
    
    return (genres_df, chart_image, tracks_html, recommended_html, analysis_state, processed_info)

def generate_recommendations(state, local_sp, table_style):
    rec_tracks = []
    recommended_artists = set()
    for genre in state["top_genres"]:
        try:
            search_result = local_sp.search(q=f'genre:"{genre}"', type="track", limit=state["rec_per_genre"])
            total = search_result.get("tracks", {}).get("total", 0)
            if total > state["rec_per_genre"]:
                offset = random.randint(0, min(total - state["rec_per_genre"], 100))
                search_result = local_sp.search(q=f'genre:"{genre}"', type="track", limit=state["rec_per_genre"], offset=offset)
            items = search_result.get("tracks", {}).get("items", [])
            for t in items:
                track_name = t.get("name", "Unknown Track")
                artists_list = [a.get("name", "") for a in t.get("artists", [])]
                if not artists_list:
                    continue
                artist_str = ", ".join(artists_list)
                first_artist = artists_list[0].strip().lower()
                if (track_name.strip().lower(), first_artist) in state["existing_tracks"] or first_artist in recommended_artists:
                    continue
                spotify_url = t.get("external_urls", {}).get("spotify", "#")
                query = f"{track_name} {artist_str}"
                yt_link = f'<a href="https://music.youtube.com/search?q={requests.utils.quote(query)}" target="_blank">YouTube Music</a>'
                rec_tracks.append([f"{track_name} by {artist_str}", genre,
                                   f'<a href="{spotify_url}" target="_blank">Listen on Spotify</a>',
                                   yt_link])
                recommended_artists.add(first_artist)
        except Exception:
            continue

    if len(rec_tracks) > 75:
        rec_tracks = random.sample(rec_tracks, 75)
    rec_tracks_df = pd.DataFrame(rec_tracks, columns=["Title + Author", "Genre", "Spotify Link", "YouTube Music Link"])
    return table_style + rec_tracks_df.to_html(escape=False, index=False, classes="nice-table")

def refresh_recommendations(state):
    if state["sp_client_id"].strip() and state["sp_client_secret"].strip():
        local_sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials(
            client_id=state["sp_client_id"].strip(),
            client_secret=state["sp_client_secret"].strip()
        ))
    else:
        local_sp = global_sp
    table_style = """
    <style>
    .nice-table { width: 100%; border-collapse: collapse; margin-top: 1em; }
    .nice-table th, .nice-table td { border: 1px solid #ccc; padding: 8px; }
    .nice-table th { background-color: #f9f9f9; font-weight: bold; }
    .nice-table tr:nth-child(even) { background-color: #f2f2f2; }
    .nice-table a { color: #007bff; text-decoration: none; }
    .nice-table a:hover { text-decoration: underline; }
    </style>
    """
    return generate_recommendations(state, local_sp, table_style)

# Main interface description and disclaimer
description_text = (
    "This agent analyzes a public Spotify playlist (must be user-shared; providing a playlist uploaded by Spotify will result in an error) "
    "by generating a genre distribution, a track list (with direct Spotify and YouTube Music search links), and a table of recommended tracks "
    "based on the top genres found in the playlist. API keys are not stored. Use the 'Refresh recommendations' button to get a new set of recommendations."
)

disclaimer_text = (
    "<b>Disclaimer:</b> This tool works best for playlists with around 100-200 songs (30-60s). For larger playlists, processing may take multiple minutes. "
    "A default API key is provided, but if you reach the limits, you can supply your own API keys, which you can quickly obtain from "
    "<a href='https://developer.spotify.com/' target='_blank'>Spotify Developer</a>.<br>"
    "Note: If the agent is processing for too long, check the logs. If you see a message like 'Your application has reached a rate/request limit', "
    "it means that the provided Spotify API key has reached its limits. Please generate your own API keys and add them."
)

with gr.Blocks() as demo:
    gr.Markdown("# Spotify Playlist Analyzer & Recommendations + YouTube Music Links")
    gr.Markdown(disclaimer_text)
    gr.Markdown(description_text)
    
    with gr.Row():
        playlist_url = gr.Textbox(label="Spotify Playlist URL")
    with gr.Row():
        sp_client_id = gr.Textbox(label="Spotify Client ID (optional)")
        sp_client_secret = gr.Textbox(label="Spotify Client Secret (optional)")
    
    analyze_button = gr.Button("Analyze Playlist")
    
    with gr.Tab("Analysis Results"):
        output_genres = gr.Dataframe(label="Genre Distribution Table")
        output_chart = gr.Image(label="Top 15 Genre Chart")
        output_tracks_html = gr.HTML(label="Playlist Tracks Table")
        output_processed = gr.HTML(label="Processing Info")
    
    with gr.Tab("Recommended Tracks"):
        refresh_button = gr.Button("Refresh recommendations")
        recommended_html_output = gr.HTML(label="Recommended Tracks Table")
    
    state_out = gr.State()  # Will hold the analysis state

    def run_analysis(playlist_url, sp_client_id, sp_client_secret):
        result = analyze_playlist(playlist_url, sp_client_id, sp_client_secret)
        # result: (genres_df, chart_image, tracks_html, recommended_html, analysis_state, processed_info)
        return result

    analyze_button.click(
        fn=run_analysis,
        inputs=[playlist_url, sp_client_id, sp_client_secret],
        outputs=[output_genres, output_chart, output_tracks_html, recommended_html_output, state_out, output_processed]
    )
    
    refresh_button.click(
        fn=refresh_recommendations,
        inputs=[state_out],
        outputs=[recommended_html_output]
    )

if __name__ == "__main__":
    demo.launch(share=True)