Skip to content

Commit

Permalink
(feat) adding similar artist search
Browse files Browse the repository at this point in the history
  • Loading branch information
tphoney committed May 15, 2024
1 parent fc39586 commit 4801730
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 118 deletions.
6 changes: 4 additions & 2 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

## bugs

- parallel requests for spotify search
- allow amazon tv search for indivdual series
- allow amazon tv search for newer series
- update movies to use tv like search
- allow amazon tv search for indivdual series
- update movies, remove the plex resolution filter add plex resolution as a column
- music, a-ha/ash doesnt match as an artist why ?

## done

Expand All @@ -28,3 +29,4 @@
- remove links to tv series we already have in plex. eg dont show adventure time series 1 and 2 ?
- write a function to calculate plex dates
- similar artist search for music
- parallel requests for spotify search
109 changes: 43 additions & 66 deletions spotify/spotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,92 +240,69 @@ func SearchSpotifyAlbum(m *types.SearchResults, clientID, clientSecret string, c
ch <- m
}

func FindSimilarArtists(ownedArtists []types.MusicArtistSearchResult, clientID, clientSecret string) (
similar map[string]types.MusicSimilarArtistResult, err error) {
// make a map of SearchSimilarArtists
similar = make(map[string]types.MusicSimilarArtistResult, len(ownedArtists))
// get oauth token
err = SpotifyOauthToken(context.Background(), clientID, clientSecret)
func SearchSpotifySimilarArtist(m *types.SearchResults, clientID, clientSecret string, ch chan<- SimilarArtistsResponse) {
err := SpotifyOauthToken(context.Background(), clientID, clientSecret)
if err != nil {
return similar, fmt.Errorf("FindSimilarArtists: unable to get oauth token: %s", err.Error())
}
for i := range ownedArtists {
// check if the artist is in the similar map already
artist, ok := similar[ownedArtists[i].ID]
if !ok {
// add the artist to the map
similar[ownedArtists[i].ID] = types.MusicSimilarArtistResult{
Name: ownedArtists[i].Name,
URL: ownedArtists[i].URL,
Owned: true,
SimilarityCount: 0,
}
} else {
// set owned to true
artist.Owned = true
similar[ownedArtists[i].ID] = artist
}
// get the similar artists
similarArtists, err := SearchSpotifySimilarArtist(ownedArtists[i].ID, clientID, clientSecret)
if err != nil {
fmt.Printf("FindSimilarArtists: unable to get similar artists: %s\n", err.Error())
continue
}
// iterate through the similar artists, if they are not in the owned artists, add them to the similar artists
for j := range similarArtists.Artists {
if _, ok := similar[similarArtists.Artists[j].ID]; !ok {
similar[similarArtists.Artists[j].ID] = types.MusicSimilarArtistResult{
Name: similarArtists.Artists[j].Name,
URL: fmt.Sprintf("https://open.spotify.com/artist/%s", similarArtists.Artists[j].ID),
Owned: false,
SimilarityCount: 1,
}
} else {
// increment the similarity count
artist := similar[similarArtists.Artists[j].ID]
artist.SimilarityCount++
similar[similarArtists.Artists[j].ID] = artist
}
}
fmt.Printf("SearchSpotifySimilarArtist: unable to get oauth token: %s\n", err.Error())
ch <- SimilarArtistsResponse{}
return
}
return similar, nil
}

func SearchSpotifySimilarArtist(artistID, clientID, clientSecret string) (similar SimilarArtistsResponse, err error) {
err = SpotifyOauthToken(context.Background(), clientID, clientSecret)
if err != nil {
return similar, fmt.Errorf("spotifyLookupArtistSimilar: unable to get oauth token: %s", err.Error())
if len(m.MusicSearchResults) == 0 {
// no artist found for the plex artist
fmt.Printf("SearchSpotifySimilarArtist: no artist found for %s\n", m.PlexMusicArtist.Name)
ch <- SimilarArtistsResponse{}
return
}
similarArtistURL := fmt.Sprintf("%s/artists/%s/related-artists", spotifyAPIURL, artistID)
similarArtistURL := fmt.Sprintf("%s/artists/%s/related-artists", spotifyAPIURL, m.MusicSearchResults[0].ID)
client := &http.Client{
Timeout: time.Second * lookupTimeout,
}
req, httpErr := http.NewRequestWithContext(context.Background(), http.MethodGet, similarArtistURL, http.NoBody)
if httpErr != nil {
return similar, fmt.Errorf("spotifyLookupArtistSimilar: get failed from spotify: %s", httpErr.Error())
fmt.Printf("SearchSpotifySimilarArtist: get failed from spotify: %s\n", httpErr.Error())
ch <- SimilarArtistsResponse{}
return
}
bearer := fmt.Sprintf("Bearer %s", oauthToken)
req.Header.Add("Authorization", bearer)
response, err := client.Do(req)
if err != nil {
return similar, fmt.Errorf("spotifyLookupArtistSimilar: get failed from spotify: %s", err.Error())
}
if response.StatusCode == http.StatusTooManyRequests {
return similar, fmt.Errorf("lookupArtist: rate limited by spotify")
var response *http.Response
for {
response, err = client.Do(req)
if err != nil {
response.Body.Close()
fmt.Printf("SearchSpotifySimilarArtist: get failed from spotify: %s\n", err.Error())
ch <- SimilarArtistsResponse{}
return
}
if response.StatusCode == http.StatusTooManyRequests {
wait := response.Header.Get("Retry-After")
waitSeconds, _ := strconv.Atoi(wait)
if waitSeconds > lookupTimeout {
fmt.Printf("SearchSpotifySimilarArtist: rate limited for %d seconds\n", waitSeconds)
}
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
if response.StatusCode == http.StatusOK {
break
}
}

defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return similar, fmt.Errorf("spotifyLookupArtistSimilar: unable to parse response from spotify: %s", err.Error())
fmt.Printf("SearchSpotifySimilarArtist: unable to parse response from spotify: %s\n", err.Error())
ch <- SimilarArtistsResponse{}
return
}

var similarArtistsResponse SimilarArtistsResponse
jsonErr := json.Unmarshal(body, &similarArtistsResponse)
if jsonErr != nil {
return similar, fmt.Errorf("spotifyLookupArtistSimilar: unable to unmarshal response from spotify: %s", jsonErr.Error())
fmt.Printf("SearchSpotifySimilarArtist: unable to unmarshal response from spotify: %s\n", jsonErr.Error())
ch <- SimilarArtistsResponse{}
return
}
similar.Artists = similarArtistsResponse.Artists
return similarArtistsResponse, nil
ch <- similarArtistsResponse
}

// function that gets an oauth token from spotify from the client id and secret
Expand Down
51 changes: 14 additions & 37 deletions spotify/spotify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,50 +116,27 @@ func TestSpotifyLookupSimilarArtists(t *testing.T) {
t.Skip("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET not set")
}
tests := []struct {
name string
artistID string
wantErr bool
wantLength int
name string
searchResult types.SearchResults
wantErr bool
wantLength int
}{
{
name: "similar artists exist",
artistID: "711MCceyCBcFnzjGY4Q7Un",
wantErr: false,
wantLength: 20,
name: "similar artists exist",
searchResult: types.SearchResults{MusicSearchResults: []types.MusicArtistSearchResult{{ID: "711MCceyCBcFnzjGY4Q7Un"}}},
wantErr: false,
wantLength: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotArtists, err := SearchSpotifySimilarArtist(tt.artistID, spotifyClientID, spotifyClientSecret)
if (err != nil) != tt.wantErr {
t.Errorf("SpotifyLookupSimilarArtists() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(gotArtists.Artists) != tt.wantLength {
t.Errorf("SpotifyLookupSimilarArtists() = %v, want %v", len(gotArtists.Artists), tt.wantLength)
ch := make(chan SimilarArtistsResponse, 1)
searchResult := tt.searchResult // Create a local variable to avoid implicit memory aliasing
SearchSpotifySimilarArtist(&searchResult, spotifyClientID, spotifyClientSecret, ch)
got := <-ch
if len(got.Artists) != tt.wantLength {
t.Errorf("SpotifyLookupSimilarArtists() = %v, want %v", len(got.Artists), tt.wantLength)
}
})
}
}

// func TestFindSimilarArtists(t *testing.T) {
// if spotifyClientID == "" || spotifyClientSecret == "" {
// t.Skip("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET not set")
// }
// ownedArtists := []types.MusicArtistSearchResult{
// {
// Name: "The Beatles",s
// ID: "3WrFJ7ztbogyGnTHbHJFl2",
// URL: "https://open.spotify.com/artist/3WrFJ7ztbogyGnTHbHJFl2",
// },
// {
// Name: "Queens of the Stone Age",
// ID: "4pejUc4iciQfgdX6OKulQn",
// URL: "https://open.spotify.com/artist/4pejUc4iciQfgdX6OKulQn",
// },
// {
// Name: "Eagles of Death Metal",
// ID: "02uYdhMhCgdB49hZlYRm9o",
// URL: "https://open.spotify.com/artist/02uYdhMhCgdB49hZlYRm9o",
// },
// }
65 changes: 62 additions & 3 deletions web/music/music.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ func (c MusicConfig) ProcessHTML(w http.ResponseWriter, r *http.Request) {
} else {
switch lookup {
case "spotify":

go func() {
getSpotifyArtistsInParallel(c.Config.SpotifyClientID, c.Config.SpotifyClientSecret)
numberOfArtistsProcessed = 0
getSpotifySimilarArtistsInParallel(c.Config.SpotifyClientID, c.Config.SpotifyClientSecret)
totalArtists = numberOfArtistsProcessed
artistsJobRunning = false
}()
fmt.Println("Searching Spotify for similar artists")
default:
fmt.Fprintf(w, `<div class="alert alert-danger" role="alert">Similar Artist search is not available for this lookup provider</div>`)
Expand Down Expand Up @@ -165,8 +171,61 @@ func getSpotifyAlbumsInParallel(id, token string) {
fmt.Printf("Processed %d artists in %v", len(bla), time.Since(startTime))
}

func getSimilarArtistsInParallel(id, token string) {
func getSpotifySimilarArtistsInParallel(id, token string) {
fmt.Println("Searching Spotify for similar artists")
startTime := time.Now()
ch := make(chan spotify.SimilarArtistsResponse, len(artistsSearchResults))
semaphore := make(chan struct{}, spotifyThreads)
for i := range artistsSearchResults {
go func(i int) {
semaphore <- struct{}{}
defer func() { <-semaphore }()
spotify.SearchSpotifySimilarArtist(&artistsSearchResults[i], id, token, ch)
}(i)
}
// gather results
rawSimilarArtists := make([]spotify.SimilarArtistsResponse, 0)
for range artistsSearchResults {
result := <-ch
rawSimilarArtists = append(rawSimilarArtists, result)
fmt.Print(".")
numberOfArtistsProcessed++
}
fmt.Printf("Retrieved %d similar artists in %v\n", len(rawSimilarArtists), time.Since(startTime))
// seed the similar artists map with our owned artists
similarArtistsResults = make(map[string]types.MusicSimilarArtistResult)
for i := range artistsSearchResults {
// skip artists with no search results
if len(artistsSearchResults[i].MusicSearchResults) == 0 {
continue
}
similarArtistsResults[artistsSearchResults[i].MusicSearchResults[0].ID] = types.MusicSimilarArtistResult{
Name: artistsSearchResults[i].MusicSearchResults[0].Name,
URL: artistsSearchResults[i].MusicSearchResults[0].URL,
Owned: true,
SimilarityCount: 0,
}
}
// iterate over searches
for i := range rawSimilarArtists {
// iterate over artists in each search
for j := range rawSimilarArtists[i].Artists {
artist, ok := similarArtistsResults[rawSimilarArtists[i].Artists[j].ID]
if !ok {
similarArtistsResults[rawSimilarArtists[i].Artists[j].ID] = types.MusicSimilarArtistResult{
Name: rawSimilarArtists[i].Artists[j].Name,
URL: fmt.Sprintf("https://open.spotify.com/artist/%s", rawSimilarArtists[i].Artists[j].ID),
Owned: false,
SimilarityCount: 1,
}
} else {
// increment the similarity count
artist.SimilarityCount++
similarArtistsResults[rawSimilarArtists[i].Artists[j].ID] = artist
}
}
}
fmt.Printf("Processed %d similar artists in %v\n", len(rawSimilarArtists), time.Since(startTime))
}

func renderArtistAlbumsTable() (tableRows string) {
Expand All @@ -192,7 +251,7 @@ func renderArtistAlbumsTable() (tableRows string) {
}

func renderSimilarArtistsTable() (tableRows string) {
tableRows = `<thead><tr><th data-sort="string"><strong>Plex Artist</strong></th><th data-sort="string"><strong>Owned</strong></th><th data-sort="string"><strong>Similarity Count</strong></th></tr></thead><tbody>` //nolint: lll
tableRows = `<thead><tr><th data-sort="string"><strong>Plex Artist</strong></th><th data-sort="string"><strong>Owned</strong></th><th data-sort="int"><strong>Similarity Count</strong></th></tr></thead><tbody>` //nolint: lll
for i := range similarArtistsResults {
ownedString := "No"
if similarArtistsResults[i].Owned {
Expand Down
2 changes: 2 additions & 0 deletions web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func settingsSaveHandler(w http.ResponseWriter, r *http.Request) {
config.PlexMovieLibraryID = r.FormValue("plexMovieLibraryID")
config.PlexTVLibraryID = r.FormValue("plexTVLibraryID")
config.PlexMusicLibraryID = r.FormValue("plexMusicLibraryID")
config.SpotifyClientID = r.FormValue("spotifyClientID")
config.SpotifyClientSecret = r.FormValue("spotifyClientSecret")
fmt.Fprint(w, `<h2>Saved!</h2><a href="/">Back</a>`)
fmt.Printf("Saved Settings\nold\n%+v\nnew\n%+v\n", oldConfig, config)
}
Expand Down
6 changes: 1 addition & 5 deletions web/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,6 @@ func renderLibraries(libraries []types.PlexLibrary) string {
for _, library := range libraries {
html += fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td></tr>`, library.Title, library.Type, library.ID)
}
html += `</tbody></table>
<input type="text" placeholder="Plex Movie Library Section ID" name="plexMovieLibraryID"id="plexMovieLibraryID">
<input type="text" placeholder="Plex TV Series Library Section ID" name="plexTVLibraryID"id="plexTVLibraryID">
<input type="text" placeholder="Plex Music Library Section ID" name="plexMusicLibraryID"id="plexMusicLibraryID">
<button hx-post="/settings/save" hx-include="#plexMovieLibraryID,#plexTVLibraryID,#plexMusicLibraryID,#plexIP,#plexToken" hx-swap="outerHTML">Save</button>` //nolint: lll
html += "</tbody></table>"
return html
}
33 changes: 28 additions & 5 deletions web/settings/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
</head>

<body>
<h1 class="container">Plex-lookup Settings</h1>
<form hx-post="/settings/plexlibraries" class="container" hx-target="#table" hx-boost="true">
<h1 class="container">Settings</h1>
<h2 class="container">Plex</h2>
<div class="container">
<p class="container">Enter the <em
data-tooltip="Find your Plex server IP by going to your server then go to settings, then remote-access. It is the private IP address."><a
href="https://plex.tv/web" target="_blank">Plex
Expand All @@ -22,9 +23,31 @@ <h1 class="container">Plex-lookup Settings</h1>
</p>
<input type="text" placeholder="Plex Server IP" name="plexIP" id="plexIP">
<input type="text" placeholder="Plex X-Plex-Token" name="plexToken" id="plexToken">
<button type="lookupPlex">Lookup Plex libraries</button>
</form>
<div id="table" class="container"></div>
<button type="lookupPlex" hx-post="/settings/plexlibraries" class="container" hx-target="#table"
hx-include="#plexIP, #plexToken" hx-boost="true">Lookup Plex libraries</button>
<div id="table" class="container"></div>
<input type="text" placeholder="Plex Movie Library Section ID" name="plexMovieLibraryID"
id="plexMovieLibraryID">
<input type="text" placeholder="Plex TV Series Library Section ID" name="plexTVLibraryID" id="plexTVLibraryID">
<input type="text" placeholder="Plex Music Library Section ID" name="plexMusicLibraryID"
id="plexMusicLibraryID">
</div>
<h2 class="container">Spotify</h2>
<p class="container">Enter your Spotify client ID and secret to get started. You will need to follow the Spotify
documentation to get these. <a href="https://developer.spotify.com/documentation/web-api/concepts/apps"
target="_blank">Create
an app on the Spotify developer dashboard</a>
then get the client ID and secret.
</p>
<div class="container">
<input type="text" placeholder="Spotify client ID" name="spotifyClientID" id="spotifyClientID">
<input type="text" placeholder="Spotify Secret" name="spotifyClientSecret" id="spotifyClientSecret">
</div>
<div class="container">
<button hx-post="/settings/save"
hx-include="#plexMovieLibraryID, #plexTVLibraryID, #plexMusicLibraryID, #plexIP, #plexToken, #spotifyClientID, #spotifyClientSecret"
hx-swap="outerHTML">Save</button>
</div>
</body>

</html>

0 comments on commit 4801730

Please sign in to comment.