Files
SpotifyRecAlg/apps/backend/internal/provider/spotify/parser.go
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

154 lines
4.1 KiB
Go

package spotify
import (
"errors"
"net/url"
"regexp"
"strings"
)
var (
uriPattern = regexp.MustCompile(`(?i)^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,}$`)
pathIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
)
type ParsedSource struct {
Type string
ID string
URL string
}
func ParseSource(sourceType, value string) (ParsedSource, error) {
sourceType = strings.ToLower(strings.TrimSpace(sourceType))
value = strings.TrimSpace(value)
if value == "" {
return ParsedSource{}, errors.New("source value is required")
}
if sourceType == "" || sourceType == "url" {
parsed, err := ParseURL(value)
if err == nil {
return parsed, nil
}
if sourceType == "url" {
return ParsedSource{}, err
}
}
if sourceType == "" {
sourceType = "track"
}
if !validSpotifyType(sourceType) {
return ParsedSource{}, errors.New("source type must be track, album, playlist, artist, or url")
}
if parsed, err := ParseURL(value); err == nil {
if parsed.Type != sourceType {
return ParsedSource{}, errors.New("source URL type does not match requested type")
}
return parsed, nil
}
if !idPattern.MatchString(value) {
return ParsedSource{}, errors.New("source value must be a Spotify ID, URI, or open.spotify.com URL")
}
return ParsedSource{Type: sourceType, ID: value, URL: "https://open.spotify.com/" + sourceType + "/" + value}, nil
}
func ParseURL(raw string) (ParsedSource, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return ParsedSource{}, errors.New("url is required")
}
if match := uriPattern.FindStringSubmatch(raw); len(match) == 3 {
return ParsedSource{Type: strings.ToLower(match[1]), ID: match[2], URL: "https://open.spotify.com/" + strings.ToLower(match[1]) + "/" + match[2]}, nil
}
parsedURL, err := parseURLWithDefaultScheme(raw)
if err == nil {
if value := parsedURL.Query().Get("uri"); value != "" {
return ParseURL(value)
}
host := spotifyHost(parsedURL.Host)
switch host {
case "open.spotify.com", "play.spotify.com":
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
return parsed, nil
}
case "embed.spotify.com":
if parsed, ok := parseSpotifyPath(parsedURL.Path); ok {
parsed.URL = canonicalURL(parsed.Type, parsed.ID)
return parsed, nil
}
}
}
return ParsedSource{}, errors.New("unsupported Spotify URL")
}
func parseURLWithDefaultScheme(raw string) (*url.URL, error) {
if strings.Contains(raw, "://") {
return url.Parse(raw)
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "open.spotify.com/") ||
strings.HasPrefix(lower, "play.spotify.com/") ||
strings.HasPrefix(lower, "embed.spotify.com/") {
return url.Parse("https://" + raw)
}
return url.Parse(raw)
}
func spotifyHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
host = strings.TrimPrefix(host, "www.")
return host
}
func parseSpotifyPath(path string) (ParsedSource, bool) {
parts := pathSegments(path)
if len(parts) == 0 {
return ParsedSource{}, false
}
if strings.HasPrefix(strings.ToLower(parts[0]), "intl-") {
parts = parts[1:]
}
if len(parts) > 0 && strings.EqualFold(parts[0], "embed") {
parts = parts[1:]
}
if len(parts) >= 4 && strings.EqualFold(parts[0], "user") && strings.EqualFold(parts[2], "playlist") && pathIDPattern.MatchString(parts[3]) {
return ParsedSource{Type: "playlist", ID: parts[3]}, true
}
itemType := strings.ToLower(parts[0])
if len(parts) >= 2 && validSpotifyType(itemType) && pathIDPattern.MatchString(parts[1]) {
return ParsedSource{Type: itemType, ID: parts[1]}, true
}
return ParsedSource{}, false
}
func pathSegments(path string) []string {
rawParts := strings.Split(path, "/")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
part = strings.TrimSpace(part)
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func canonicalURL(itemType, id string) string {
return "https://open.spotify.com/" + itemType + "/" + id
}
func validSpotifyType(value string) bool {
switch value {
case "track", "album", "playlist", "artist":
return true
default:
return false
}
}