Skip to content

Commit d723b64

Browse files
committed
Changes, need to review PR
1 parent 8ae9c14 commit d723b64

File tree

8 files changed

+528
-21
lines changed

8 files changed

+528
-21
lines changed

poetry.lock

+219-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ requests = "^2.31.0"
1313
rtoml = "^0.10.0"
1414
schedule = "^1.2.1"
1515
flask = "^3.0.2"
16+
levenshtein = "^0.25.0"
17+
fuzzywuzzy = "^0.18.0"
1618

1719

1820
[build-system]

spotiplex/__init__.py

+51-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from flask import Flask, render_template, request, redirect, url_for, flash
1+
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
22
from .web_ui import functions
33
from .confighandler import read_config, write_config
44
import os
55

6-
app = Flask(__name__)
6+
7+
app = Flask(__name__, static_url_path='/static')
78

89
web_functions = functions()
910

@@ -68,6 +69,54 @@ def settings():
6869
)
6970

7071

72+
def update_manual_match(track_element_id, match_id):
73+
# Logic to update your data storage with the manual match
74+
# This function should update the match so that future processing uses this information
75+
print(f"Updating manual match for {track_element_id} with {match_id}")
76+
return True
77+
78+
@app.route('/manual-match', methods=['POST'])
79+
def manual_match():
80+
matches_json = web_functions.man_match()
81+
return jsonify(matches_json)
82+
83+
@app.route('/confirm-match', methods=['POST'])
84+
def confirm_match():
85+
data = request.json # Get the JSON data sent with the POST request
86+
track_element_id = data.get('trackElementId')
87+
selected_match_id = data.get('selectedMatchId')
88+
89+
if not track_element_id or not selected_match_id:
90+
# Missing data, return an error
91+
return jsonify({"error": "Missing trackElementId or selectedMatchId"}), 400
92+
93+
# Call the function to update the match with the provided IDs
94+
if update_manual_match(track_element_id, selected_match_id):
95+
return jsonify({"success": True}), 200
96+
else:
97+
# If updating the match fails for some reason
98+
return jsonify({"error": "Failed to update manual match"}), 500
99+
100+
@app.route('/update-manual-match', methods=['POST'])
101+
def update_manual_match():
102+
data = request.json
103+
track_element_id = data.get('trackElementId')
104+
selected_match_id = data.get('selectedMatchId')
105+
106+
# Here you would convert selected_match_id into track_name and artist_name
107+
# For this example, let's assume selected_match_id contains both separated by a delimiter
108+
track_name, artist_name = selected_match_id.split('|', 1)
109+
110+
# Now, assuming man_match can also handle updating a match (or you have a similar function for this)
111+
success = Spotiplex.PlexService().update_match(track_name, artist_name)
112+
113+
if success:
114+
return jsonify({"success": True, "message": "Manual match updated successfully."}), 200
115+
else:
116+
return jsonify({"success": False, "message": "Failed to update manual match."}), 500
117+
118+
119+
71120
@app.route("/playlists")
72121
def playlists_view():
73122
playlists = web_functions.get_playlists_data()

spotiplex/main.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,19 @@ def fetch_playlist_data(playlist):
168168
if playlist_name is None:
169169
playlist_name = f"Error processing playlist ID {playlist_id}"
170170
spotify_tracks = self.spotify_service.get_playlist_tracks(playlist_id)
171-
return playlist_name, spotify_tracks
171+
track_statuses = self.plex_service.exists_in_plex(
172+
spotify_tracks
173+
) # Updated method call
174+
return (
175+
playlist_name,
176+
track_statuses,
177+
) # Return track_statuses instead of spotify_tracks
172178
except Exception as e:
173179
print(f"Error processing playlist {playlist}: {e}")
174-
# Return a tuple indicating an error with the playlist to handle later
175180
return f"Error processing playlist ID {playlist_id}", None
176181

177182
playlists_data = {}
178183
with ThreadPoolExecutor(max_workers=self.worker_count) as executor:
179-
# Schedule the fetch_playlist_data function to be called for each playlist
180184
future_to_playlist = {
181185
executor.submit(fetch_playlist_data, playlist): playlist
182186
for playlist in self.sync_lists
@@ -185,11 +189,14 @@ def fetch_playlist_data(playlist):
185189
for future in concurrent.futures.as_completed(future_to_playlist):
186190
playlist = future_to_playlist[future]
187191
try:
188-
playlist_name, spotify_tracks = future.result()
192+
playlist_name, track_statuses = (
193+
future.result()
194+
) # Use track_statuses here
189195
if (
190-
spotify_tracks is not None
191-
): # Ensure there was no error fetching data
192-
playlists_data[playlist_name] = spotify_tracks
196+
track_statuses is not None
197+
): # Ensure data was fetched successfully
198+
# Store track_statuses directly as it already includes both Spotify data and Plex presence
199+
playlists_data[playlist_name] = track_statuses
193200
except Exception as e:
194201
print(f"Exception processing playlist {playlist}: {e}")
195202
continue

spotiplex/plex.py

+112
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import urllib3
33
from plexapi.server import PlexServer
44
from .confighandler import read_config
5+
from concurrent.futures import ThreadPoolExecutor, as_completed
6+
from fuzzywuzzy import process, fuzz
57

68

79
class PlexService:
@@ -39,6 +41,116 @@ def check_tracks_in_plex(self, spotify_tracks):
3941
continue
4042

4143
return plex_tracks
44+
45+
def manual_track_match(self, track_name, artist_name):
46+
music_lib = self.plex.library.section("Music")
47+
print(f"No automatic match found for: {track_name} by {artist_name}")
48+
49+
# Fuzzy search for artist in Plex
50+
artist_matches = process.extract(
51+
artist_name,
52+
[artist.title for artist in music_lib.searchArtists()],
53+
scorer=fuzz.token_set_ratio,
54+
limit=5
55+
)
56+
57+
print("Top artist matches:")
58+
for i, (artist_match, score) in enumerate(artist_matches, 1):
59+
print(f"{i}. {artist_match} (score: {score})")
60+
artist_selection = input("Select the correct artist by number (or enter to skip): ")
61+
62+
if artist_selection.isdigit():
63+
selected_artist_name = artist_matches[int(artist_selection)-1][0]
64+
artist_object = music_lib.searchArtists(title=selected_artist_name)[0]
65+
66+
# List tracks for the selected artist
67+
print(f"Tracks by {selected_artist_name}:")
68+
tracks = artist_object.tracks()
69+
for i, track in enumerate(tracks, 1):
70+
print(f"{i}. {track.title}")
71+
72+
track_selection = input("Select the correct track by number: ")
73+
if track_selection.isdigit():
74+
selected_track = tracks[int(track_selection)-1]
75+
return selected_track
76+
return None
77+
78+
def fuzzy_search_single_track(self, track_info, threshold=80):
79+
track_name, artist_name = track_info
80+
music_lib = self.plex.library.section("Music")
81+
in_plex = False
82+
83+
# Fuzzy search for artist
84+
artist_matches = process.extractOne(
85+
artist_name,
86+
[artist.title for artist in music_lib.searchArtists()],
87+
scorer=fuzz.token_set_ratio,
88+
)
89+
if artist_matches and artist_matches[1] > threshold:
90+
matched_artist_name = artist_matches[0]
91+
artist_objects = music_lib.searchArtists(title=matched_artist_name)
92+
93+
for artist in artist_objects:
94+
tracks = artist.tracks()
95+
# Fuzzy search for track within the artist's tracks
96+
track_matches = process.extractOne(
97+
track_name,
98+
[track.title for track in tracks],
99+
scorer=fuzz.token_set_ratio,
100+
)
101+
102+
if track_matches and track_matches[1] > threshold:
103+
in_plex = True
104+
break # Track found, no need to continue searching
105+
106+
return track_name, artist_name, in_plex
107+
108+
def fuzzy_exists_in_plex(self, spotify_tracks, threshold=80, max_workers=10):
109+
track_statuses = []
110+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
111+
# Create a future for each spotify track
112+
future_to_track = {
113+
executor.submit(
114+
self.fuzzy_search_single_track, track_info, threshold
115+
): track_info
116+
for track_info in spotify_tracks
117+
}
118+
119+
for future in as_completed(future_to_track):
120+
print("processing track")
121+
track_info = future_to_track[future]
122+
try:
123+
result = future.result()
124+
track_statuses.append(result)
125+
except Exception as exc:
126+
print(f"{track_info} generated an exception: {exc}")
127+
128+
return track_statuses
129+
130+
def exists_in_plex(self, spotify_tracks):
131+
music_lib = self.plex.library.section("Music")
132+
track_statuses = []
133+
134+
for track_name, artist_name in spotify_tracks:
135+
in_plex = False
136+
137+
# Step 1: Search for artist objects by name
138+
artist_objects = music_lib.searchArtists(title=artist_name)
139+
140+
# Step 2: For each artist, check if they have a track matching the track_name
141+
for artist in artist_objects:
142+
tracks = artist.tracks()
143+
for track in tracks:
144+
if track.title.lower() == track_name.lower():
145+
in_plex = True
146+
break
147+
if in_plex:
148+
break # Found the track, no need to continue
149+
150+
track_statuses.append((track_name, artist_name, in_plex))
151+
152+
return track_statuses
153+
42154

43155
def create_or_update_playlist(self, playlist_name, playlist_id, tracks):
44156
existing_playlist = self.find_playlist_by_name(playlist_name)

spotiplex/static/js/script.js

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
function fetchMatches(trackName, artistName, trackElementId) {
2+
fetch('/get-matches', {
3+
method: 'POST',
4+
headers: {
5+
'Content-Type': 'application/json',
6+
},
7+
body: JSON.stringify({trackName: trackName, artistName: artistName}),
8+
})
9+
.then(response => response.json())
10+
.then(data => {
11+
const dropdown = createDropdown(data.matches, trackElementId);
12+
document.getElementById(trackElementId).appendChild(dropdown);
13+
})
14+
.catch((error) => {
15+
console.error('Error:', error);
16+
});
17+
}
18+
19+
function createDropdown(matches, trackElementId) {
20+
const select = document.createElement('select');
21+
select.id = `match-select-${trackElementId}`;
22+
matches.forEach(match => {
23+
const option = document.createElement('option');
24+
option.value = match.id; // Assuming each match has an ID
25+
option.textContent = `${match.title} by ${match.artist}`;
26+
select.appendChild(option);
27+
});
28+
29+
// Optional: add a default "Select a match" option
30+
const defaultOption = document.createElement('option');
31+
defaultOption.selected = true;
32+
defaultOption.disabled = true;
33+
defaultOption.textContent = "Select a match";
34+
select.insertBefore(defaultOption, select.firstChild);
35+
36+
// Append a checkmark button for confirming the selection
37+
const checkButton = document.createElement('button');
38+
checkButton.textContent = '✔';
39+
checkButton.onclick = () => confirmMatch(trackElementId, select.value);
40+
41+
const container = document.createElement('div');
42+
container.appendChild(select);
43+
container.appendChild(checkButton);
44+
return container;
45+
}
46+
47+
function confirmMatch(trackElementId, matchId) {
48+
// Implement the logic to confirm the match
49+
// This might involve sending the matchId back to the server to update the database
50+
console.log(`Confirmed match ${matchId} for track ${trackElementId}`);
51+
// Example of sending the confirmed match back to the server
52+
fetch('/confirm-match', {
53+
method: 'POST',
54+
headers: {
55+
'Content-Type': 'application/json',
56+
},
57+
body: JSON.stringify({trackElementId: trackElementId, matchId: matchId}),
58+
})
59+
.then(response => response.json())
60+
.then(data => {
61+
// Handle response, e.g., update UI to show the match is confirmed
62+
console.log(data.message); // Assuming the server sends back a confirmation message
63+
})
64+
.catch((error) => {
65+
console.error('Error:', error);
66+
});
67+
}
68+
69+
70+
function manualMatch(trackName, artistName, trackElementId) {
71+
fetch('/manual-match', {
72+
method: 'POST',
73+
headers: {
74+
'Content-Type': 'application/json',
75+
},
76+
body: JSON.stringify({trackName: trackName, artistName: artistName}),
77+
})
78+
.then(response => response.json())
79+
.then(data => {
80+
// Assuming `data` contains the list of possible matches
81+
// Update the DOM with these matches and allow the user to select one
82+
// This is an example and would need to be expanded based on your application's structure
83+
const trackElement = document.getElementById(trackElementId);
84+
trackElement.innerHTML += "<select onchange='submitMatch(this.value)'>" + data.matches.map(match => `<option value="${match.id}">${match.title} by ${match.artist}</option>`).join('') + "</select>";
85+
})
86+
.catch((error) => {
87+
console.error('Error:', error);
88+
});
89+
}

spotiplex/templates/playlists.html.j2

+22-8
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
{% block content %}
66
<script>
77
var startTime = new Date();
8+
{% include "js/script.js" %}
89
</script>
10+
<script src="{{ url_for('static', filename='js/script.js')}}"> </script>
911

1012
<div class="container mt-4">
1113
<h1>Playlists</h1>
@@ -37,23 +39,35 @@
3739
<div class="tracks">
3840
<h3>Tracks</h3>
3941
<ul>
40-
{% for track in tracks %}
41-
<li>
42-
<span class="track-name">{{ track[0] }}</span>
43-
<span class="by-text">by</span>
44-
<span class="artist-name">{{ track[1] }}</span>
45-
</li>
46-
{% endfor %}
42+
<ul>
43+
{% for track in tracks %}
44+
<li>
45+
<span class="track-name">{{ track[0] }}</span> {# track name #}
46+
<span class="by-text">by</span>
47+
<span class="artist-name">{{ track[1] }}</span> {# artist name #}
48+
{% if track[2] %}
49+
<span class="track-status">&#10003;</span> {# Checkmark symbol #}
50+
{% else %}
51+
<span class="track-status">&#10007;</span> {# "X" symbol #}
52+
<button onclick="manualMatch('{{ track[0] | tojson }}', '{{ track[1] | tojson }}', 'track{{ loop.index }}')">Manual Match</button>
53+
{% endif %}
54+
</li>
55+
{% endfor %}
56+
</ul>
57+
4758
</ul>
4859
</div>
60+
4961
</div>
62+
63+
5064
</div>
5165
{% else %}
5266

5367
{% endif %}
5468
</div>
5569
{% endfor %}
56-
70+
5771
</div>
5872

5973

0 commit comments

Comments
 (0)