diff --git a/TODO b/TODO index b771493..4d721fa 100644 --- a/TODO +++ b/TODO @@ -2,8 +2,6 @@ ## features -- allow the use of playlists for finding music / TV / movies `https://www.plexopedia.com/plex-media-server/api/playlists/view/` - ## bugs - mandalorian is not showing up on amazon tv search @@ -45,3 +43,4 @@ - allow amazon tv search for indivdual series - improve best match for tv series - vikings, Once Upon a Time in Wonderland, What We Do in the Shadows, The Peter Serafinowicz Show is not showing up on cinema-paradiso tv search +- allow the use of playlists for finding music / TV / movies `https://www.plexopedia.com/plex-media-server/api/playlists/view/` diff --git a/plex/plex.go b/plex/plex.go index 3328cda..4e671ef 100644 --- a/plex/plex.go +++ b/plex/plex.go @@ -624,6 +624,108 @@ type AlbumContainer struct { } `xml:"Directory"` } +type PlaylistContainer struct { + XMLName xml.Name `xml:"MediaContainer"` + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + Playlist []struct { + Text string `xml:",chardata"` + RatingKey string `xml:"ratingKey,attr"` + Key string `xml:"key,attr"` + GUID string `xml:"guid,attr"` + Type string `xml:"type,attr"` + Title string `xml:"title,attr"` + TitleSort string `xml:"titleSort,attr"` + Summary string `xml:"summary,attr"` + Smart string `xml:"smart,attr"` + PlaylistType string `xml:"playlistType,attr"` + Composite string `xml:"composite,attr"` + Icon string `xml:"icon,attr"` + ViewCount string `xml:"viewCount,attr"` + LastViewedAt string `xml:"lastViewedAt,attr"` + Duration string `xml:"duration,attr"` + LeafCount string `xml:"leafCount,attr"` + AddedAt string `xml:"addedAt,attr"` + UpdatedAt string `xml:"updatedAt,attr"` + } `xml:"Playlist"` +} + +type MusicPlayList struct { + XMLName xml.Name `xml:"MediaContainer"` + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + Composite string `xml:"composite,attr"` + Duration string `xml:"duration,attr"` + LeafCount string `xml:"leafCount,attr"` + PlaylistType string `xml:"playlistType,attr"` + RatingKey string `xml:"ratingKey,attr"` + Smart string `xml:"smart,attr"` + Title string `xml:"title,attr"` + Track []struct { + Text string `xml:",chardata"` + RatingKey string `xml:"ratingKey,attr"` + Key string `xml:"key,attr"` + ParentRatingKey string `xml:"parentRatingKey,attr"` + GrandparentRatingKey string `xml:"grandparentRatingKey,attr"` + GUID string `xml:"guid,attr"` + ParentGUID string `xml:"parentGuid,attr"` + GrandparentGUID string `xml:"grandparentGuid,attr"` + ParentStudio string `xml:"parentStudio,attr"` + Type string `xml:"type,attr"` + Title string `xml:"title,attr"` + TitleSort string `xml:"titleSort,attr"` + GrandparentKey string `xml:"grandparentKey,attr"` + ParentKey string `xml:"parentKey,attr"` + LibrarySectionTitle string `xml:"librarySectionTitle,attr"` + LibrarySectionID string `xml:"librarySectionID,attr"` + LibrarySectionKey string `xml:"librarySectionKey,attr"` + GrandparentTitle string `xml:"grandparentTitle,attr"` + ParentTitle string `xml:"parentTitle,attr"` + Summary string `xml:"summary,attr"` + Index string `xml:"index,attr"` + ParentIndex string `xml:"parentIndex,attr"` + RatingCount string `xml:"ratingCount,attr"` + ViewCount string `xml:"viewCount,attr"` + LastViewedAt string `xml:"lastViewedAt,attr"` + ParentYear string `xml:"parentYear,attr"` + Thumb string `xml:"thumb,attr"` + Art string `xml:"art,attr"` + ParentThumb string `xml:"parentThumb,attr"` + GrandparentThumb string `xml:"grandparentThumb,attr"` + GrandparentArt string `xml:"grandparentArt,attr"` + Duration string `xml:"duration,attr"` + AddedAt string `xml:"addedAt,attr"` + UpdatedAt string `xml:"updatedAt,attr"` + SkipCount string `xml:"skipCount,attr"` + OriginalTitle string `xml:"originalTitle,attr"` + UserRating string `xml:"userRating,attr"` + LastRatedAt string `xml:"lastRatedAt,attr"` + Media struct { + Text string `xml:",chardata"` + ID string `xml:"id,attr"` + Duration string `xml:"duration,attr"` + Bitrate string `xml:"bitrate,attr"` + AudioChannels string `xml:"audioChannels,attr"` + AudioCodec string `xml:"audioCodec,attr"` + Container string `xml:"container,attr"` + Part struct { + Text string `xml:",chardata"` + ID string `xml:"id,attr"` + Key string `xml:"key,attr"` + Duration string `xml:"duration,attr"` + File string `xml:"file,attr"` + Size string `xml:"size,attr"` + Container string `xml:"container,attr"` + HasThumbnail string `xml:"hasThumbnail,attr"` + } `xml:"Part"` + } `xml:"Media"` + Genre []struct { + Text string `xml:",chardata"` + Tag string `xml:"tag,attr"` + } `xml:"Genre"` + } `xml:"Track"` +} + type Filter struct { Name string Value string @@ -901,7 +1003,7 @@ func extractTVEpisodes(xmlString string) (episodeList []types.PlexTVEpisode) { } // ================================================================================================= -func GetPlexMusicArtists(ipAddress, libraryID, plexToken string) (artists []types.PlexMusicArtist) { +func GetPlexMusicArtists(ipAddress, plexToken, libraryID string) (artists []types.PlexMusicArtist) { url := fmt.Sprintf("http://%s:32400/library/sections/%s/all", ipAddress, libraryID) response, err := makePlexAPIRequest(url, plexToken) @@ -973,6 +1075,7 @@ func extractMusicAlbums(xmlString string) (albums []types.PlexMusicAlbum, err er return albums, nil } +// ================================================================================================= func GetPlexLibraries(ipAddress, plexToken string) (libraryList []types.PlexLibrary, err error) { url := fmt.Sprintf("http://%s:32400/library/sections", ipAddress) @@ -1003,6 +1106,94 @@ func extractLibraries(xmlString string) (libraryList []types.PlexLibrary, err er // ================================================================================================= +func GetPlaylists(ipAddress, plexToken, libraryID string) (playlists []types.PlexPlaylist, err error) { + start := time.Now() + url := fmt.Sprintf("http://%s:32400/playlists?sectionID=%s", ipAddress, libraryID) + + response, err := makePlexAPIRequest(url, plexToken) + if err != nil { + fmt.Println("GetPlaylists: Error making request:", err) + return playlists, err + } + + playlists, err = extractPlaylists(response) + fmt.Printf("Plex playlists: %d. Duration: %v\n", len(playlists), time.Since(start)) + return playlists, err +} + +func extractPlaylists(xmlString string) (playlistList []types.PlexPlaylist, err error) { + var container PlaylistContainer + err = xml.Unmarshal([]byte(xmlString), &container) + if err != nil { + fmt.Println("Error parsing XML:", err) + return playlistList, err + } + + for i := range container.Playlist { + playlistList = append(playlistList, types.PlexPlaylist{ + Title: container.Playlist[i].Title, + RatingKey: container.Playlist[i].RatingKey, + Type: container.Playlist[i].PlaylistType, + }) + } + + return playlistList, nil +} + +func GetArtistsFromPlaylist(ipAddress, plexToken, ratingKey string) (playlistItems []types.PlexMusicArtist) { + url := fmt.Sprintf("http://%s:32400/playlists/%s/items", ipAddress, ratingKey) + response, err := makePlexAPIRequest(url, plexToken) + if err != nil { + fmt.Println("GetPlaylistItems: Error making request:", err) + return playlistItems + } + + playlistItems, err = extractPlaylistItems(response) + if err != nil { + fmt.Println("Error extracting playlist items:", err) + } + return playlistItems +} + +func extractPlaylistItems(xmlString string) (playlistItems []types.PlexMusicArtist, err error) { + var container MusicPlayList + err = xml.Unmarshal([]byte(xmlString), &container) + if err != nil { + fmt.Println("Error parsing XML:", err) + return playlistItems, err + } + + // verify the library ID matches + artists := make(map[string]types.PlexMusicArtist) + for i := range container.Track { + album := types.PlexMusicAlbum{ + Title: container.Track[i].ParentTitle, + RatingKey: container.Track[i].ParentRatingKey, + Year: container.Track[i].ParentYear, + DateAdded: parsePlexDate(container.Track[i].AddedAt), + } + foundArtist, ok := artists[container.Track[i].GrandparentTitle] + if !ok { + artists[container.Track[i].GrandparentTitle] = types.PlexMusicArtist{ + Name: container.Track[i].GrandparentTitle, + RatingKey: container.Track[i].GrandparentRatingKey, + Albums: []types.PlexMusicAlbum{album}, + } + } else if !slices.Contains(artists[container.Track[i].GrandparentTitle].Albums, album) { + foundArtist.Albums = append(artists[container.Track[i].GrandparentTitle].Albums, album) //nolint:gocritic + // replace the artist in the map with the updated artist + artists[container.Track[i].GrandparentTitle] = foundArtist + } + } + // convert map to slice + for _, value := range artists { + playlistItems = append(playlistItems, value) + } + return playlistItems, nil +} + +// ================================================================================================= + func makePlexAPIRequest(inputURL, plexToken string) (response string, err error) { req, err := http.NewRequestWithContext(context.Background(), "GET", inputURL, http.NoBody) if err != nil { diff --git a/plex/plex_test.go b/plex/plex_test.go index 2c483ea..91a0ad0 100644 --- a/plex/plex_test.go +++ b/plex/plex_test.go @@ -102,7 +102,7 @@ func TestGetPlexMusic(t *testing.T) { if plexIP == "" || plexMusicLibraryID == "" || plexToken == "" { t.Skip("ACCEPTANCE TEST: PLEX environment variables not set") } - result := GetPlexMusicArtists(plexIP, plexMusicLibraryID, plexToken) + result := GetPlexMusicArtists(plexIP, plexToken, plexMusicLibraryID) if len(result) == 0 { t.Errorf("Expected at least one album, but got %d", len(result)) @@ -117,6 +117,30 @@ func TestGetPlexMusic(t *testing.T) { } } +func TestGetPlaylists(t *testing.T) { + if plexIP == "" || plexToken == "" { + t.Skip("ACCEPTANCE TEST: PLEX environment variables not set") + } + playlists, err := GetPlaylists(plexIP, plexToken, "1") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + // Check the number of playlists + if len(playlists) == 0 { + t.Errorf("Expected at least one playlist, but got %d", len(playlists)) + } +} + +func TestGetPlaylistItems(t *testing.T) { + if plexIP == "" || plexToken == "" { + t.Skip("ACCEPTANCE TEST: PLEX environment variables not set") + } + items := GetArtistsFromPlaylist(plexIP, plexToken, "111897") + // Check the number of items + if len(items) == 0 { + t.Errorf("Expected at least one item, but got %d", len(items)) + } +} func Test_findLowestResolution(t *testing.T) { tests := []struct { name string diff --git a/types/types.go b/types/types.go index 492eb74..6981601 100644 --- a/types/types.go +++ b/types/types.go @@ -168,3 +168,9 @@ type PlexLibrary struct { Type string ID string } + +type PlexPlaylist struct { + Title string + Type string + RatingKey string +} diff --git a/web/music/music.go b/web/music/music.go index 6ca8e85..03215b0 100644 --- a/web/music/music.go +++ b/web/music/music.go @@ -52,6 +52,7 @@ func MusicHandler(w http.ResponseWriter, _ *http.Request) { // nolint: lll, nolintlint func (c MusicConfig) ProcessHTML(w http.ResponseWriter, r *http.Request) { + playlist := r.FormValue("playlist") lookup = r.FormValue("lookup") if lookup == "musicbrainz" { if c.Config.MusicBrainzURL == "" { @@ -75,8 +76,10 @@ func (c MusicConfig) ProcessHTML(w http.ResponseWriter, r *http.Request) { } lookupType = r.FormValue("lookuptype") // only get the artists from plex once - if len(plexMusic) == 0 { - plexMusic = plex.GetPlexMusicArtists(c.Config.PlexIP, c.Config.PlexMusicLibraryID, c.Config.PlexToken) + if playlist == "all" { + plexMusic = plex.GetPlexMusicArtists(c.Config.PlexIP, c.Config.PlexToken, c.Config.PlexMusicLibraryID) + } else { + plexMusic = plex.GetArtistsFromPlaylist(c.Config.PlexIP, c.Config.PlexToken, playlist) } //nolint: gocritic // plexMusic = plexMusic[:30] @@ -131,6 +134,26 @@ func (c MusicConfig) ProcessHTML(w http.ResponseWriter, r *http.Request) { fmt.Printf("Processed %d artists in %v\n", totalArtists, time.Since(startTime)) } +func (c MusicConfig) PlaylistHTML(w http.ResponseWriter, _ *http.Request) { + playlistHTML := `
+ ` + playlists, _ := plex.GetPlaylists(c.Config.PlexIP, c.Config.PlexToken, c.Config.PlexMusicLibraryID) + fmt.Println("Playlists:", len(playlists)) + for i := range playlists { + playlistHTML += fmt.Sprintf( + ``, + playlists[i].Title, playlists[i].RatingKey, playlists[i].Title) + } + + playlistHTML += `
` + fmt.Fprint(w, playlistHTML) +} + func ProgressBarHTML(w http.ResponseWriter, _ *http.Request) { if lookup == spotifyString { numberOfArtistsProcessed = spotify.GetJobProgress() @@ -146,7 +169,7 @@ func ProgressBarHTML(w http.ResponseWriter, _ *http.Request) { tableContents = renderSimilarArtistsTable() } fmt.Fprintf(w, - `%s
+ `%s
`, tableContents) // reset variables diff --git a/web/music/music.html b/web/music/music.html index 0d2169f..42d61b5 100644 --- a/web/music/music.html +++ b/web/music/music.html @@ -27,8 +27,13 @@

Music

-
- Plex Filter: only lookup music that matches the following criteria. + Plex: filter by playlist +
+
Lookup: diff --git a/web/server.go b/web/server.go index ac68a8e..660e219 100644 --- a/web/server.go +++ b/web/server.go @@ -50,6 +50,7 @@ func StartServer(startingConfig *types.Configuration) { mux.HandleFunc("/music", music.MusicHandler) mux.HandleFunc("/musicprocess", music.MusicConfig{Config: config}.ProcessHTML) + mux.HandleFunc("/musicplaylists", music.MusicConfig{Config: config}.PlaylistHTML) mux.HandleFunc("/musicprogress", music.ProgressBarHTML) mux.HandleFunc("/", indexHandler)