diff --git a/README.md b/README.md index 9252e7d..2255595 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,25 @@ Streaminx is a library that unifies interactions with various music streaming links into a single system. With Streaminx, you can integrate platforms such as Apple Music, Spotify, YouTube and Yandex Music into your applications using a unified interface for searching and retrieving data about tracks and albums. +- [Motivation](#motivation) +- [Providers Supported](#providers-supported) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) +- [API reference](#api-reference) + - [Registry](#registry) + - [Provider](#provider) + - [EntityType](#entitytype) + - [Entity](#entity) + - [Link](#link) +- [Testing](#testing) +- [Why do we need translator?](#why-do-we-need-translator) +- [Contribution and development](#contribution-and-development) + ## Motivation The main user of this library is the Telegram bot [Vibeshare](https://t.me/vibeshare_bot). -This bot helps users share song links from the streaming services they use, enabling others to listen to the same songs on different platforms. +This bot helps users to share song links from the streaming services they use, enabling others to listen to the same songs on different platforms. Therefore, the primary use case for this library is *converting links* from one service to another. ## Providers Supported @@ -39,7 +54,7 @@ Here are the steps to configure the library: 1) *Google Translator API.* Obtain the Google Translator API key and project ID from the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 2) *YouTube API*. Obtain the YouTube API key from the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 3) *Spotify*. Register your application and obtain the Client ID with Client Secret on the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard). -4) *Init registry*. When you have all the necessary credentials, you can initialize the Streaminx *registry* with the following code. +4) *Build registry*. When you have all the necessary credentials, you can initialize the Streaminx *registry* with the following code. ``` golang package main @@ -53,112 +68,174 @@ import ( func main() { ctx := context.Background() registry, err := streaminx.NewRegistry(ctx, streaminx.Credentials{ - GoogleTranslatorAPIKeyJSON: "YOUR_GOOGLE_TRANSLATOR_API_KEY_JSON", - GoogleTranslatorProjectID: "YOUR_GOOGLE_TRANSLATOR_PROJECT_ID", - YoutubeAPIKey: "YOUR_YOUTUBE_API_KEY", - SpotifyClientID: "YOUR_SPOTIFY_CLIENT_ID", - SpotifyClientSecret: "YOUR_SPOTIFY_CLIENT_SECRET", + GoogleTranslatorAPIKeyJSON: "[your google translator api key json]", + GoogleTranslatorProjectID: "[your google translator project id]", + YoutubeAPIKey: "[your youtube api key]", + SpotifyClientID: "[your spotify client id]", + SpotifyClientSecret: "[your spotify client secret]", }) if err != nil { // Handle error } defer registry.Close() - // Your code to use the registry + // use the registry to fetch or search for tracks and albums } ``` Replace the placeholders with your actual API keys and credentials. ## Usage -Here is an example of how to convert a link from Spotify to YouTube: +Here is an example of how to convert a link from Apple to Spotify: ``` golang -func convert(ctx context.Context, link string) string { - spotify := registry.Adapter(streaminx.Spotify) - id, err := spotify.DetectTrackID(link) +func appleTrackToSpotify(ctx context.Context, link string) (string, error) { + parsedLink, err := streaminx.ParseLink(link) if err != nil { // Handle error } - track, err := spotify.GetTrack(ctx, link) + + track, err := registry.Fetch(ctx, streaminx.Apple, streaminx.Track, parsedLink.ID) if err != nil { // Handle error } - convertedTrack, err := registry.Adapter(streaminx.Youtube).SearchTrack(ctx, track.Artist, track.Name) + converted, err := registry.Search(ctx, streaminx.Spotify, streaminx.Track, track.Artist, track.Name) if err != nil { // Handle error } - return convertedTrack.URL + return converted.URL } ``` -## API +## API reference #### Registry -The root of the library is the `Registry` struct. -The purpose of the `Registry` is to provide a unified interface for working with different streaming services. -Basically, `Registry` is a map, where the key is `Provider` and the value is the `Adapter` for this provider. -``` golang -apple := registry.Adapter(streaminx.Apple) -spotify := registry.Adapter(streaminx.Spotify) -yandex := registry.Adapter(streaminx.Yandex) -youtube := registry.Adapter(streaminx.Youtube) +`Registry` struct is the main entry point of the library. + +The purpose of the `Registry` is to provide a unified interface for working with streaming services by HTTP API. + +It implements two main methods: +```golang +// Fetch(...) – allows to get entities by their ID +entity, err := registry.Fetch(ctx, provider, entityType, entityID) + +// Search(...) – allows to search for entities by name and artist +entity, err := registry.Search(ctx, provider, entityType, entityArtist, entityTitle) ``` +This methods requires to specify the *provider*, the *entity type* and *identifiers* explained below. + #### Provider -Provider represents a music streaming service. All providers are accessible via the `Providers` enum: +`Provider` represents a music streaming service, implemented as an enum. + +All providers are accessible via the `Providers` enum: ``` golang for _, provider := range streaminx.Providers { - fmt.Println(provider.Name) + fmt.Println(provider.Name()) } + ``` -#### Adapter +It implements following methods: + +``` golang +p := streaminx.Apple + +p.Name() +// => "Apple" +// human readable name of the provider + +code := p.Code() +// => "ap" +// short code of the provider, useful for runtime provider definition + +regions := p.Regions() +// => []string{"us", "es", "fr", "ru", ... } +// optional region codes for the provider, used for region-specific requests and referenced in the URL + +trackID, err := p.DetectTrackID("https://music.apple.com/us/album/song-name/1234?i=4567") +// => "4567", nil +// extract track ID from the link + +albumID, err := p.DetectAlbumID("https://music.apple.com/us/album/album-name/1234") +// => "1234", nil +// extract album ID from the link + -Using *adapters* you can fetch and search for tracks and albums on the supported streaming services. -Each adapter implements the `Adapter` interface, which provides methods for working with tracks and albums. +``` + +When you need to define a provider in runtime, you can use the `FindProviderByCode(string)` method: ``` golang -type Adapter interface { - DetectTrackID(ctx context.Context, trackURL string) (string, error) - GetTrack(ctx context.Context, id string) (*Track, error) - SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) - - DetectAlbumID(ctx context.Context, albumURL string) (string, error) - GetAlbum(ctx context.Context, id string) (*Album, error) - SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) -} +provider := streaminx.FindProviderByCode("ap") +// => streaminx.Apple + +``` + +#### EntityType + +`EntityType` simple string enum that represents the type of entity you want to fetch or search for. + +For now, it has two values: `Track` and `Album`. + +``` golang +streaminx.Track +// => "track" + +streaminx.Album +// => "album" ``` -#### Track, Album +#### Entity + +`Entity` struct implements unified representation of tracks and albums. -`Track` and `Album` are the main entities of the library. They represent a song and an album, respectively. -Each entity has a set of fields that contain information about the song or album, such as the name, artist, album, and URL. +This struct is returned by the `Fetch` and `Search` methods of the `Registry`. ``` golang -type Track struct { - ID string - Title string - Artist string - URL string - Provider *Provider +type Entity struct { + ID string + Title string + Artist string + URL string + Provider *Provider + Type EntityType } +``` -type Album struct { - ID string - Title string - Artist string +#### Link + +`Link` struct represents a parsed link to a track or album on a streaming service. + +Useful to extract the ID and provider from the link. + +``` golang +type Link struct { URL string Provider *Provider + Type EntityType + ID string } ``` +It is returned by the `ParseLink` method: + +``` golang +link, err := streaminx.ParseLink("https://music.apple.com/us/album/song-name/1234?i=4567") +// => Link{ +// URL: "https://music.apple.com/us/album/song-name/1234?i=4567", +// Provider: streaminx.Apple, +// Type: streaminx.Track, +// ID: "4567", +// }, nil +``` + ## Testing For testing purposes, you can use the `RegistryOption`. @@ -208,7 +285,7 @@ For example Spotify doesn't allow non-latin characters in artist names. If we ha ## Contribution and development -Contributions are welcome. It would be great if you could help us to add more providers or languages to the library. +Contributions are welcome. It would be great if you could help us to add more providers (e.g. Deezer) or entities (e.g. artist, playlist) to the library. To run the test and linter use the following commands: diff --git a/adapter.go b/adapter.go index 2c79609..1ae8cb6 100644 --- a/adapter.go +++ b/adapter.go @@ -2,19 +2,12 @@ package streaminx import ( "context" - "fmt" -) - -var ( - IDNotFoundError = fmt.Errorf("invalid id") ) type Adapter interface { - DetectTrackID(trackURL string) (string, error) - GetTrack(ctx context.Context, id string) (*Track, error) - SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) + FetchTrack(ctx context.Context, id string) (*Entity, error) + SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) - DetectAlbumID(albumURL string) (string, error) - GetAlbum(ctx context.Context, id string) (*Album, error) - SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) + FetchAlbum(ctx context.Context, id string) (*Entity, error) + SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) } diff --git a/album.go b/album.go deleted file mode 100644 index cb7b493..0000000 --- a/album.go +++ /dev/null @@ -1,9 +0,0 @@ -package streaminx - -type Album struct { - ID string - Title string - Artist string - URL string - Provider *Provider -} diff --git a/apple_adapter.go b/apple_adapter.go index 492e167..1b23a6b 100644 --- a/apple_adapter.go +++ b/apple_adapter.go @@ -17,35 +17,13 @@ func newAppleAdapter(client apple.Client) *AppleAdapter { } } -func (a *AppleAdapter) DetectTrackID(trackURL string) (string, error) { - ck := apple.CompositeKey{} - ck.ParseFromTrackURL(trackURL) - - if ck.Storefront == "" || ck.ID == "" || !apple.IsValidStorefront(ck.Storefront) { - return "", IDNotFoundError - } - - return ck.Marshal(), nil -} - -func (a *AppleAdapter) DetectAlbumID(albumURL string) (string, error) { - ck := apple.CompositeKey{} - ck.ParseFromAlbumURL(albumURL) - - if ck.Storefront == "" || ck.ID == "" || !apple.IsValidStorefront(ck.Storefront) { - return "", IDNotFoundError - } - - return ck.Marshal(), nil -} - -func (a *AppleAdapter) GetTrack(ctx context.Context, id string) (*Track, error) { +func (a *AppleAdapter) FetchTrack(ctx context.Context, id string) (*Entity, error) { ck := apple.CompositeKey{} if err := ck.Unmarshal(id); err != nil { return nil, fmt.Errorf("failed to unmarshal track id: %w", err) } - track, err := a.client.GetTrack(ctx, ck.ID, ck.Storefront) + track, err := a.client.FetchTrack(ctx, ck.ID, ck.Storefront) if err != nil { return nil, fmt.Errorf("failed to get track from apple: %w", err) } @@ -53,10 +31,14 @@ func (a *AppleAdapter) GetTrack(ctx context.Context, id string) (*Track, error) return nil, nil } - return a.adaptTrack(track), nil + res, err := a.adaptTrack(track) + if err != nil { + return nil, err + } + return res, nil } -func (a *AppleAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) { +func (a *AppleAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) { track, err := a.client.SearchTrack(ctx, artistName, trackName) if err != nil { return nil, fmt.Errorf("failed to search track from apple: %w", err) @@ -64,16 +46,20 @@ func (a *AppleAdapter) SearchTrack(ctx context.Context, artistName, trackName st if track == nil { return nil, nil } - return a.adaptTrack(track), nil + res, err := a.adaptTrack(track) + if err != nil { + return nil, err + } + return res, nil } -func (a *AppleAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) { +func (a *AppleAdapter) FetchAlbum(ctx context.Context, id string) (*Entity, error) { ck := apple.CompositeKey{} if err := ck.Unmarshal(id); err != nil { return nil, fmt.Errorf("failed to unmarshal album id: %w", err) } - album, err := a.client.GetAlbum(ctx, ck.ID, ck.Storefront) + album, err := a.client.FetchAlbum(ctx, ck.ID, ck.Storefront) if err != nil { return nil, fmt.Errorf("failed to get album from apple: %w", err) } @@ -81,10 +67,14 @@ func (a *AppleAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) return nil, nil } - return a.adaptAlbum(album), nil + res, err := a.adaptAlbum(album) + if err != nil { + return nil, err + } + return res, nil } -func (a *AppleAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) { +func (a *AppleAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) { album, err := a.client.SearchAlbum(ctx, artistName, albumName) if err != nil { return nil, fmt.Errorf("failed to search album from apple: %w", err) @@ -92,31 +82,41 @@ func (a *AppleAdapter) SearchAlbum(ctx context.Context, artistName, albumName st if album == nil { return nil, nil } - return a.adaptAlbum(album), nil + res, err := a.adaptAlbum(album) + if err != nil { + return nil, err + } + return res, nil } -func (a *AppleAdapter) adaptTrack(track *apple.MusicEntity) *Track { +func (a *AppleAdapter) adaptTrack(track *apple.Entity) (*Entity, error) { ck := apple.CompositeKey{} - ck.ParseFromTrackURL(track.Attributes.URL) + if err := ck.ParseFromTrackURL(track.Attributes.URL); err != nil { + return nil, err + } - return &Track{ + return &Entity{ ID: ck.Marshal(), Title: track.Attributes.Name, Artist: track.Attributes.ArtistName, URL: track.Attributes.URL, Provider: Apple, - } + Type: Track, + }, nil } -func (a *AppleAdapter) adaptAlbum(album *apple.MusicEntity) *Album { +func (a *AppleAdapter) adaptAlbum(album *apple.Entity) (*Entity, error) { ck := apple.CompositeKey{} - ck.ParseFromAlbumURL(album.Attributes.URL) + if err := ck.ParseFromAlbumURL(album.Attributes.URL); err != nil { + return nil, err + } - return &Album{ + return &Entity{ ID: ck.Marshal(), Title: album.Attributes.Name, Artist: album.Attributes.ArtistName, URL: album.Attributes.URL, Provider: Apple, - } + Type: Album, + }, nil } diff --git a/apple_adapter_test.go b/apple_adapter_test.go index cc7ff93..cd5acc6 100644 --- a/apple_adapter_test.go +++ b/apple_adapter_test.go @@ -1,134 +1,261 @@ package streaminx import ( + "context" "testing" + "time" + + "github.com/GeorgeGorbanev/streaminx/internal/apple" "github.com/stretchr/testify/require" ) -func TestAppleAdapter_DetectTrackID(t *testing.T) { +type appleClientMock struct { + fetchTrack map[string]*apple.Entity + fetchAlbum map[string]*apple.Entity + searchTrack map[string]map[string]*apple.Entity + searchAlbum map[string]map[string]*apple.Entity +} + +func (c *appleClientMock) FetchTrack(_ context.Context, id, storefront string) (*apple.Entity, error) { + return c.fetchTrack[storefront+"-"+id], nil +} + +func (c *appleClientMock) SearchTrack(_ context.Context, artistName, trackName string) (*apple.Entity, error) { + if tracks, ok := c.searchTrack[artistName]; ok { + return tracks[trackName], nil + } + return nil, nil +} + +func (c *appleClientMock) FetchAlbum(_ context.Context, id, storefront string) (*apple.Entity, error) { + return c.fetchAlbum[storefront+"-"+id], nil +} + +func (c *appleClientMock) SearchAlbum(_ context.Context, artistName, albumName string) (*apple.Entity, error) { + if albums, ok := c.searchAlbum[artistName]; ok { + return albums[albumName], nil + } + return nil, nil +} + +func TestAppleAdapter_FetchTrack(t *testing.T) { tests := []struct { name string - input string - expected string - expectedError error + id string + clientMock *appleClientMock + expectedTrack *Entity }{ { - name: "valid URL with track ID", - input: "https://music.apple.com/us/album/song-name/1234567890?i=987654321", - expected: "us-987654321", - }, - { - name: "valid URL with track ID and th storefront", - input: "https://music.apple.com/th/album/song-name/1234567890?i=987654321", - expected: "th-987654321", - }, - { - name: "valid URL with track ID and invalid iso3611 storefront", - input: "https://music.apple.com/invalidstorefront/album/song-name/1234567890?i=987654321", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "valid URL without album", - input: "https://music.apple.com/us/song/angel/724466660", - expected: "us-724466660", - }, - { - name: "valid URL without album and invalid storefront", - input: "https://music.apple.com/invalidstorefront/song/angel/724466660", - expected: "", - expectedError: IDNotFoundError, + name: "found ID", + id: "ru-123", + clientMock: &appleClientMock{ + fetchTrack: map[string]*apple.Entity{ + "ru-123": { + ID: "ru-123", + Attributes: apple.Attributes{ + ArtistName: "sample artist", + Name: "sample name", + URL: "https://music.apple.com/ru/album/song-name/1234567890?i=123", + }, + }, + }, + }, + expectedTrack: &Entity{ + ID: "ru-123", + Title: "sample name", + Artist: "sample artist", + URL: "https://music.apple.com/ru/album/song-name/1234567890?i=123", + Provider: Apple, + Type: Track, + }, }, { - name: "URL without track ID", - input: "https://music.apple.com/us/album/song-name/1234567890", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "invalid host URL", - input: "https://music.orange.com/us/album/song-name/1234567890?i=987654321", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "empty string", - input: "", - expected: "", - expectedError: IDNotFoundError, + name: "not found ID", + id: "ru-123", + clientMock: &appleClientMock{}, + expectedTrack: nil, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - adapter := newAppleAdapter(nil) - result, err := adapter.DetectTrackID(tt.input) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a := newAppleAdapter(tt.clientMock) + result, err := a.FetchTrack(ctx, tt.id) + + require.NoError(t, err) + require.Equal(t, tt.expectedTrack, result) }) } } -func TestAppleAdapter_DetectAlbumID(t *testing.T) { +func TestAppleAdapter_SearchTrack(t *testing.T) { tests := []struct { name string - input string - expected string - expectedError error + artistName string + searchName string + clientMock *appleClientMock + expectedTrack *Entity }{ { - name: "valid URL with album ID", - input: "https://music.apple.com/us/album/album-name/123456789", - expected: "us-123456789", + name: "found query", + artistName: "sample artist", + searchName: "sample name", + clientMock: &appleClientMock{ + searchTrack: map[string]map[string]*apple.Entity{ + "sample artist": { + "sample name": { + ID: "ru-123", + Attributes: apple.Attributes{ + ArtistName: "sample artist", + Name: "sample name", + URL: "https://music.apple.com/ru/album/song-name/1234567890?i=123", + }, + }, + }, + }, + }, + expectedTrack: &Entity{ + ID: "ru-123", + Title: "sample name", + Artist: "sample artist", + URL: "https://music.apple.com/ru/album/song-name/1234567890?i=123", + Provider: Apple, + Type: Track, + }, }, { - name: "valid URL with album ID and gb locale", - input: "https://music.apple.com/gb/album/another-album/987654321", - expected: "gb-987654321", + name: "not found query", + artistName: "not found artist", + searchName: "not found name", + clientMock: &appleClientMock{}, + expectedTrack: nil, }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a := newAppleAdapter(tt.clientMock) + result, err := a.SearchTrack(ctx, tt.artistName, tt.searchName) + + require.NoError(t, err) + require.Equal(t, tt.expectedTrack, result) + }) + } +} + +func TestAppleAdapter_FetchAlbum(t *testing.T) { + tests := []struct { + name string + id string + storefront string + clientMock *appleClientMock + expectedAlbum *Entity + }{ { - name: "valid URL with album ID and invalid iso3611 storefront", - input: "https://music.apple.com/invalidstorefront/album/another-album/987654321", - expected: "", - expectedError: IDNotFoundError, + name: "found ID", + id: "ru-456", + storefront: "sampleStorefront", + clientMock: &appleClientMock{ + fetchAlbum: map[string]*apple.Entity{ + "ru-456": { + ID: "ru-456", + Attributes: apple.Attributes{ + ArtistName: "sample artist", + Name: "sample name", + URL: "https://music.apple.com/ru/album/name/456", + }, + }, + }, + }, + expectedAlbum: &Entity{ + ID: "ru-456", + Title: "sample name", + Artist: "sample artist", + URL: "https://music.apple.com/ru/album/name/456", + Provider: Apple, + Type: Album, + }, }, { - name: "URL without album ID", - input: "https://music.apple.com/us/album/album-name", - expected: "", - expectedError: IDNotFoundError, + name: "not found ID", + id: "ru-456", + storefront: "notFoundStorefront", + clientMock: &appleClientMock{}, + expectedAlbum: nil, }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a := newAppleAdapter(tt.clientMock) + result, err := a.FetchAlbum(ctx, tt.id) + + require.NoError(t, err) + require.Equal(t, tt.expectedAlbum, result) + }) + } +} + +func TestAppleAdapter_SearchAlbum(t *testing.T) { + tests := []struct { + name string + artistName string + searchName string + clientMock *appleClientMock + expectedAlbum *Entity + }{ { - name: "invalid host URL", - input: "https://music.orange.com/us/album/album-name/123456789", - expected: "", - expectedError: IDNotFoundError, + name: "found query", + artistName: "sample artist", + searchName: "sample name", + clientMock: &appleClientMock{ + searchAlbum: map[string]map[string]*apple.Entity{ + "sample artist": { + "sample name": { + ID: "ru-456", + Attributes: apple.Attributes{ + ArtistName: "sample artist", + Name: "sample name", + URL: "https://music.apple.com/ru/album/name/456", + }, + }, + }, + }, + }, + expectedAlbum: &Entity{ + ID: "ru-456", + Title: "sample name", + Artist: "sample artist", + URL: "https://music.apple.com/ru/album/name/456", + Provider: Apple, + Type: Album, + }, }, { - name: "empty string", - input: "", - expected: "", - expectedError: IDNotFoundError, + name: "not found query", + artistName: "not found artist", + searchName: "not found name", + clientMock: &appleClientMock{}, + expectedAlbum: nil, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - adapter := newAppleAdapter(nil) - result, err := adapter.DetectAlbumID(tt.input) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a := newAppleAdapter(tt.clientMock) + result, err := a.SearchAlbum(ctx, tt.artistName, tt.searchName) + + require.NoError(t, err) + require.Equal(t, tt.expectedAlbum, result) }) } } diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..ec4b7a2 --- /dev/null +++ b/credentials.go @@ -0,0 +1,28 @@ +package streaminx + +import ( + "github.com/GeorgeGorbanev/streaminx/internal/spotify" + "github.com/GeorgeGorbanev/streaminx/internal/translator" +) + +type Credentials struct { + GoogleTranslatorAPIKeyJSON string + GoogleTranslatorProjectID string + YoutubeAPIKey string + SpotifyClientID string + SpotifyClientSecret string +} + +func (c Credentials) google() *translator.GoogleCredentials { + return &translator.GoogleCredentials{ + APIKeyJSON: c.GoogleTranslatorAPIKeyJSON, + ProjectID: c.GoogleTranslatorProjectID, + } +} + +func (c Credentials) spotify() *spotify.Credentials { + return &spotify.Credentials{ + ClientID: c.SpotifyClientID, + ClientSecret: c.SpotifyClientSecret, + } +} diff --git a/entity.go b/entity.go new file mode 100644 index 0000000..c86054f --- /dev/null +++ b/entity.go @@ -0,0 +1,17 @@ +package streaminx + +const ( + Track EntityType = "track" + Album EntityType = "album" +) + +type EntityType string + +type Entity struct { + ID string + Title string + Artist string + URL string + Provider *Provider + Type EntityType +} diff --git a/internal/apple/client.go b/internal/apple/client.go index 385ab1a..d71194f 100644 --- a/internal/apple/client.go +++ b/internal/apple/client.go @@ -15,10 +15,10 @@ const ( ) type Client interface { - GetTrack(ctx context.Context, id, storefront string) (*MusicEntity, error) - SearchTrack(ctx context.Context, artistName, trackName string) (*MusicEntity, error) - GetAlbum(ctx context.Context, id, storefront string) (*MusicEntity, error) - SearchAlbum(ctx context.Context, artistName, albumName string) (*MusicEntity, error) + FetchTrack(ctx context.Context, id, storefront string) (*Entity, error) + SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) + FetchAlbum(ctx context.Context, id, storefront string) (*Entity, error) + SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) } type HTTPClient struct { @@ -47,12 +47,12 @@ type searchDataItem struct { } type getResponse struct { - Data []*MusicEntity `json:"data"` + Data []*Entity `json:"data"` } type searchResources struct { - Songs map[string]*MusicEntity `json:"songs"` - Albums map[string]*MusicEntity `json:"albums"` + Songs map[string]*Entity `json:"songs"` + Albums map[string]*Entity `json:"albums"` } func NewHTTPClient(opts ...ClientOption) *HTTPClient { @@ -69,7 +69,7 @@ func NewHTTPClient(opts ...ClientOption) *HTTPClient { return &c } -func (c *HTTPClient) GetTrack(ctx context.Context, id, storefront string) (*MusicEntity, error) { +func (c *HTTPClient) FetchTrack(ctx context.Context, id, storefront string) (*Entity, error) { url := fmt.Sprintf(`%s/v1/catalog/%s/songs/%s`, c.apiURL, storefront, id) response, err := c.getAPI(ctx, url) if err != nil { @@ -88,7 +88,7 @@ func (c *HTTPClient) GetTrack(ctx context.Context, id, storefront string) (*Musi return gr.Data[0], nil } -func (c *HTTPClient) SearchTrack(ctx context.Context, artistName, trackName string) (*MusicEntity, error) { +func (c *HTTPClient) SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) { url := fmt.Sprintf(`%s/v1/catalog/us/search?%s`, c.apiURL, searchQuery(artistName+" "+trackName)) response, err := c.getAPI(ctx, url) if err != nil { @@ -107,7 +107,7 @@ func (c *HTTPClient) SearchTrack(ctx context.Context, artistName, trackName stri } return nil, nil } -func (c *HTTPClient) GetAlbum(ctx context.Context, id, storefront string) (*MusicEntity, error) { +func (c *HTTPClient) FetchAlbum(ctx context.Context, id, storefront string) (*Entity, error) { url := fmt.Sprintf(`%s/v1/catalog/%s/albums/%s`, c.apiURL, storefront, id) response, err := c.getAPI(ctx, url) if err != nil { @@ -124,7 +124,7 @@ func (c *HTTPClient) GetAlbum(ctx context.Context, id, storefront string) (*Musi } return gr.Data[0], nil } -func (c *HTTPClient) SearchAlbum(ctx context.Context, artistName, albumName string) (*MusicEntity, error) { +func (c *HTTPClient) SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) { url := fmt.Sprintf(`%s/v1/catalog/us/search?%s`, c.apiURL, searchQuery(artistName+" "+albumName)) response, err := c.getAPI(ctx, url) if err != nil { diff --git a/internal/apple/client_test.go b/internal/apple/client_test.go index e1e5dee..4154dde 100644 --- a/internal/apple/client_test.go +++ b/internal/apple/client_test.go @@ -11,18 +11,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestHTTPClient_GetTrack(t *testing.T) { +func TestHTTPClient_FetchTrack(t *testing.T) { tests := []struct { name string trackID string storeFront string - want *MusicEntity + want *Entity }{ { name: "when track found", trackID: "foundId", storeFront: "us", - want: &MusicEntity{ + want: &Entity{ ID: "foundID", Attributes: Attributes{ ArtistName: "sampleArtistName", @@ -77,7 +77,7 @@ func TestHTTPClient_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := client.GetTrack(ctx, tt.trackID, tt.storeFront) + result, err := client.FetchTrack(ctx, tt.trackID, tt.storeFront) require.NoError(t, err) require.Equal(t, tt.want, result) }) @@ -89,13 +89,13 @@ func TestHTTPClient_SearchTrack(t *testing.T) { name string artistName string trackName string - want *MusicEntity + want *Entity }{ { name: "when track found", artistName: "foundArtistName", trackName: "foundTrackName", - want: &MusicEntity{ + want: &Entity{ ID: "foundID", Attributes: Attributes{ ArtistName: "sampleArtistName", @@ -191,18 +191,18 @@ func TestHTTPClient_SearchTrack(t *testing.T) { } } -func TestHTTPClient_GetAlbum(t *testing.T) { +func TestHTTPClient_FetchAlbum(t *testing.T) { tests := []struct { name string albumID string storeFront string - want *MusicEntity + want *Entity }{ { name: "when album found", albumID: "foundId", storeFront: "us", - want: &MusicEntity{ + want: &Entity{ ID: "foundID", Attributes: Attributes{ ArtistName: "sampleArtistName", @@ -257,7 +257,7 @@ func TestHTTPClient_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := client.GetAlbum(ctx, tt.albumID, tt.storeFront) + result, err := client.FetchAlbum(ctx, tt.albumID, tt.storeFront) require.NoError(t, err) require.Equal(t, tt.want, result) }) @@ -269,13 +269,13 @@ func TestHTTPClient_SearchAlbum(t *testing.T) { name string artistName string albumName string - want *MusicEntity + want *Entity }{ { name: "when album found", artistName: "foundArtistName", albumName: "foundAlbumName", - want: &MusicEntity{ + want: &Entity{ ID: "foundID", Attributes: Attributes{ ArtistName: "sampleArtistName", diff --git a/internal/apple/composite_key.go b/internal/apple/composite_key.go index 89375e2..31cd6ae 100644 --- a/internal/apple/composite_key.go +++ b/internal/apple/composite_key.go @@ -1,43 +1,59 @@ package apple import ( + "errors" "fmt" "regexp" ) -const delimiter = "-" - -type CompositeKey struct { - ID string - Storefront string -} +const ( + delimiter = "-" +) var ( compositeKeyRe = regexp.MustCompile( fmt.Sprintf(`^([a-z]{2})%s([0-9]+)$`, delimiter), ) + CompositeKeyError = errors.New("invalid composite key") ) -func (k *CompositeKey) ParseFromTrackURL(trackURL string) { - matches := AlbumTrackRe.FindStringSubmatch(trackURL) - if len(matches) == 4 { +type CompositeKey struct { + ID string + Storefront string +} + +func (k *CompositeKey) ParseFromTrackURL(url string) error { + if matches := AlbumTrackRe.FindStringSubmatch(url); len(matches) == 4 { + if !IsValidStorefront(matches[1]) { + return fmt.Errorf("%w (invalid storefront)", CompositeKeyError) + } k.Storefront = matches[1] k.ID = matches[3] - return + return nil } - matches = SongRe.FindStringSubmatch(trackURL) - if len(matches) == 3 { + if matches := SongRe.FindStringSubmatch(url); len(matches) == 3 { + if !IsValidStorefront(matches[1]) { + return fmt.Errorf("%w (invalid storefront)", CompositeKeyError) + } k.Storefront = matches[1] k.ID = matches[2] + return nil } + return fmt.Errorf("%w (not valid url)", CompositeKeyError) } -func (k *CompositeKey) ParseFromAlbumURL(albumURL string) { - matches := AlbumRe.FindStringSubmatch(albumURL) - if len(matches) == 3 { - k.Storefront = matches[1] - k.ID = matches[2] +func (k *CompositeKey) ParseFromAlbumURL(url string) error { + matches := AlbumRe.FindStringSubmatch(url) + if len(matches) != 3 { + return fmt.Errorf("%w (not valid url)", CompositeKeyError) + } + if !IsValidStorefront(matches[1]) { + return fmt.Errorf("%w (invalid storefront)", CompositeKeyError) } + + k.Storefront = matches[1] + k.ID = matches[2] + return nil } func (k *CompositeKey) Marshal() string { diff --git a/internal/apple/composite_key_test.go b/internal/apple/composite_key_test.go index c6a2dcb..668c923 100644 --- a/internal/apple/composite_key_test.go +++ b/internal/apple/composite_key_test.go @@ -12,19 +12,31 @@ func TestCompositeKey_ParseFromTrackURL(t *testing.T) { name string trackURL string want CompositeKey + wantErr error }{ { name: "valid track URL", - trackURL: "https://music.apple.com/storefront/album/song-name/1234567890?i=987654321", - want: CompositeKey{ID: "987654321", Storefront: "storefront"}, + trackURL: "https://music.apple.com/ru/album/song-name/1234567890?i=987654321", + want: CompositeKey{ID: "987654321", Storefront: "ru"}, + }, + { + name: "invalid storefront URL", + trackURL: "https://music.apple.com/invalid/album/song-name/1234567890?i=987654321", + wantErr: CompositeKeyError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { k := &CompositeKey{} - k.ParseFromTrackURL(tt.trackURL) - require.Equal(t, tt.want, *k) + err := k.ParseFromTrackURL(tt.trackURL) + + if tt.wantErr != nil { + require.ErrorAs(t, err, &tt.wantErr) + } else { + require.Equal(t, tt.want, *k) + require.NoError(t, err) + } }) } } @@ -34,19 +46,31 @@ func TestCompositeKey_ParseFromAlbumURL(t *testing.T) { name string albumURL string want CompositeKey + wantErr error }{ { name: "valid album URL", - albumURL: "https://music.apple.com/storefront/album/album-name/123456789", - want: CompositeKey{ID: "123456789", Storefront: "storefront"}, + albumURL: "https://music.apple.com/us/album/album-name/123456789", + want: CompositeKey{ID: "123456789", Storefront: "us"}, + }, + { + name: "invalid storefront URL", + albumURL: "https://music.apple.com/invalid/album/album-name/123456789", + wantErr: CompositeKeyError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { k := &CompositeKey{} - k.ParseFromAlbumURL(tt.albumURL) - require.Equal(t, tt.want, *k) + err := k.ParseFromAlbumURL(tt.albumURL) + + if tt.wantErr != nil { + require.ErrorAs(t, err, &tt.wantErr) + } else { + require.Equal(t, tt.want, *k) + require.NoError(t, err) + } }) } } diff --git a/internal/apple/music_entity.go b/internal/apple/entity.go similarity index 56% rename from internal/apple/music_entity.go rename to internal/apple/entity.go index 135c51b..26cd6de 100644 --- a/internal/apple/music_entity.go +++ b/internal/apple/entity.go @@ -1,8 +1,16 @@ package apple -import "regexp" +import ( + "regexp" +) + +var ( + AlbumRe = regexp.MustCompile(`music\.apple\.com/(\w+)/album/.*/(\d+)`) + AlbumTrackRe = regexp.MustCompile(`music\.apple\.com/(\w+)/album/.*/(\d+)\?i=(\d+)`) + SongRe = regexp.MustCompile(`music\.apple\.com/(\w+)/song/.*/(\d+)`) +) -type MusicEntity struct { +type Entity struct { ID string `json:"id"` Attributes Attributes `json:"attributes"` } @@ -13,9 +21,18 @@ type Attributes struct { ArtistName string `json:"artistName"` } -var ( - AlbumRe = regexp.MustCompile(`music\.apple\.com/(\w+)/album/.*/(\d+)`) - AlbumTrackRe = regexp.MustCompile(`music\.apple\.com/(\w+)/album/.*/(\d+)\?i=(\d+)`) - SongRe = regexp.MustCompile(`music\.apple\.com/(\w+)/song/.*/(\d+)`) - TrackRe = regexp.MustCompile(AlbumTrackRe.String() + "|" + SongRe.String()) -) +func DetectTrackID(trackURL string) string { + ck := CompositeKey{} + if err := ck.ParseFromTrackURL(trackURL); err != nil { + return "" + } + return ck.Marshal() +} + +func DetectAlbumID(albumURL string) string { + ck := CompositeKey{} + if err := ck.ParseFromAlbumURL(albumURL); err != nil { + return "" + } + return ck.Marshal() +} diff --git a/internal/apple/entity_test.go b/internal/apple/entity_test.go new file mode 100644 index 0000000..688787c --- /dev/null +++ b/internal/apple/entity_test.go @@ -0,0 +1,109 @@ +package apple + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_DetectTrackID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid URL with track ID", + input: "https://music.apple.com/us/album/song-name/1234567890?i=987654321", + expected: "us-987654321", + }, + { + name: "valid URL with track ID and th storefront", + input: "https://music.apple.com/th/album/song-name/1234567890?i=987654321", + expected: "th-987654321", + }, + { + name: "valid URL with track ID and invalid iso3611 storefront", + input: "https://music.apple.com/invalidstorefront/album/song-name/1234567890?i=987654321", + expected: "", + }, + { + name: "valid URL without album", + input: "https://music.apple.com/us/song/angel/724466660", + expected: "us-724466660", + }, + { + name: "valid URL without album and invalid storefront", + input: "https://music.apple.com/invalidstorefront/song/angel/724466660", + expected: "", + }, + { + name: "URL without track ID", + input: "https://music.apple.com/us/album/song-name/1234567890", + expected: "", + }, + { + name: "invalid host URL", + input: "https://music.orange.com/us/album/song-name/1234567890?i=987654321", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectTrackID(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_DetectAlbumID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid URL with album ID", + input: "https://music.apple.com/us/album/album-name/123456789", + expected: "us-123456789", + }, + { + name: "valid URL with album ID and gb locale", + input: "https://music.apple.com/gb/album/another-album/987654321", + expected: "gb-987654321", + }, + { + name: "valid URL with album ID and invalid iso3611 storefront", + input: "https://music.apple.com/invalidstorefront/album/another-album/987654321", + expected: "", + }, + { + name: "URL without album ID", + input: "https://music.apple.com/us/album/album-name", + expected: "", + }, + { + name: "invalid host URL", + input: "https://music.orange.com/us/album/album-name/123456789", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectAlbumID(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/spotify/album.go b/internal/spotify/album.go deleted file mode 100644 index 175534f..0000000 --- a/internal/spotify/album.go +++ /dev/null @@ -1,18 +0,0 @@ -package spotify - -import ( - "fmt" - "regexp" -) - -type Album struct { - ID string `json:"id"` - Name string `json:"name"` - Artists []Artist `json:"artists"` -} - -var AlbumRe = regexp.MustCompile(`https://open\.spotify\.com/(?:[\w-]+/)?album/([a-zA-Z0-9]+)(?:\?.*)?`) - -func (a *Album) URL() string { - return fmt.Sprintf("https://open.spotify.com/album/%s", a.ID) -} diff --git a/internal/spotify/album_test.go b/internal/spotify/album_test.go deleted file mode 100644 index 4c3792c..0000000 --- a/internal/spotify/album_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package spotify - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestAlbum_URL(t *testing.T) { - album := Album{ID: "sample_id"} - result := album.URL() - require.Equal(t, "https://open.spotify.com/album/sample_id", result) -} diff --git a/internal/spotify/client.go b/internal/spotify/client.go index 33e623b..c99e86d 100644 --- a/internal/spotify/client.go +++ b/internal/spotify/client.go @@ -18,9 +18,9 @@ const ( ) type Client interface { - GetTrack(ctx context.Context, id string) (*Track, error) + FetchTrack(ctx context.Context, id string) (*Track, error) SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) - GetAlbum(ctx context.Context, id string) (*Album, error) + FetchAlbum(ctx context.Context, id string) (*Album, error) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) } @@ -63,7 +63,7 @@ func NewHTTPClient(credentials *Credentials, opts ...ClientOption) *HTTPClient { } // https://developer.spotify.com/documentation/web-api/reference/get-track -func (c *HTTPClient) GetTrack(ctx context.Context, id string) (*Track, error) { +func (c *HTTPClient) FetchTrack(ctx context.Context, id string) (*Track, error) { path := fmt.Sprintf("/v1/tracks/%s", id) body, err := c.getAPI(ctx, path, nil) if err != nil { @@ -105,7 +105,7 @@ func (c *HTTPClient) SearchTrack(ctx context.Context, artistName, trackName stri } // https://developer.spotify.com/documentation/web-api/reference/get-an-album -func (c *HTTPClient) GetAlbum(ctx context.Context, id string) (*Album, error) { +func (c *HTTPClient) FetchAlbum(ctx context.Context, id string) (*Album, error) { path := fmt.Sprintf("/v1/albums/%s", id) body, err := c.getAPI(ctx, path, nil) if err != nil { diff --git a/internal/spotify/client_test.go b/internal/spotify/client_test.go index 915b5cc..e38110d 100644 --- a/internal/spotify/client_test.go +++ b/internal/spotify/client_test.go @@ -24,7 +24,7 @@ var ( sampleBasicAuth = "Basic c2FtcGxlQ2xpZW50SUQ6c2FtcGxlQ2xpZW50U2VjcmV0" ) -func TestHTTPClient_GetTrack(t *testing.T) { +func TestHTTPClient_FetchTrack(t *testing.T) { mockAuthServer := newAuthServerMock(t) defer mockAuthServer.Close() @@ -52,7 +52,7 @@ func TestHTTPClient_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - track, err := client.GetTrack(ctx, "sampletrackid") + track, err := client.FetchTrack(ctx, "sampletrackid") require.NoError(t, err) require.Equal(t, &Track{ ID: "sampletrackid", @@ -111,7 +111,7 @@ func TestHTTPClient_SearchTrack(t *testing.T) { }, track) } -func TestHTTPClient_GetAlbum(t *testing.T) { +func TestHTTPClient_FetchAlbum(t *testing.T) { mockAuthServer := newAuthServerMock(t) defer mockAuthServer.Close() @@ -139,7 +139,7 @@ func TestHTTPClient_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - album, err := client.GetAlbum(ctx, "samplealbumid") + album, err := client.FetchAlbum(ctx, "samplealbumid") require.NoError(t, err) require.Equal(t, &Album{ ID: "samplealbumid", @@ -227,7 +227,7 @@ func TestHTTPClient_TokenNotExpired(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - track, err := client.GetTrack(ctx, "sampletrackid") + track, err := client.FetchTrack(ctx, "sampletrackid") require.NoError(t, err) require.Equal(t, &Track{ ID: "sampletrackid", @@ -272,7 +272,7 @@ func TestHTTPClient_RefreshTokenWhenExpired(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - track, err := client.GetTrack(ctx, "sampletrackid") + track, err := client.FetchTrack(ctx, "sampletrackid") require.NoError(t, err) require.Equal(t, &Track{ ID: "sampletrackid", @@ -322,7 +322,7 @@ func TestHTTPClient_RefreshTokenWhenUnauthorized(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - track, err := client.GetTrack(ctx, "sampletrackid") + track, err := client.FetchTrack(ctx, "sampletrackid") require.NoError(t, err) require.Equal(t, &Track{ ID: "sampletrackid", diff --git a/internal/spotify/entity.go b/internal/spotify/entity.go new file mode 100644 index 0000000..3b193eb --- /dev/null +++ b/internal/spotify/entity.go @@ -0,0 +1,51 @@ +package spotify + +import ( + "fmt" + "regexp" +) + +var ( + TrackRe = regexp.MustCompile(`https://open\.spotify\.com/(?:[\w-]+/)?track/([a-zA-Z0-9]+)(?:\?.*)?`) + AlbumRe = regexp.MustCompile(`https://open\.spotify\.com/(?:[\w-]+/)?album/([a-zA-Z0-9]+)(?:\?.*)?`) +) + +type Track struct { + Artists []Artist `json:"artists"` + ID string `json:"id"` + Name string `json:"name"` +} + +type Album struct { + ID string `json:"id"` + Name string `json:"name"` + Artists []Artist `json:"artists"` +} + +type Artist struct { + Name string `json:"name"` +} + +func DetectTrackID(trackURL string) string { + match := TrackRe.FindStringSubmatch(trackURL) + if len(match) < 2 { + return "" + } + return match[1] +} + +func DetectAlbumID(albumURL string) string { + match := AlbumRe.FindStringSubmatch(albumURL) + if len(match) < 2 { + return "" + } + return match[1] +} + +func (t *Track) URL() string { + return fmt.Sprintf("https://open.spotify.com/track/%s", t.ID) +} + +func (a *Album) URL() string { + return fmt.Sprintf("https://open.spotify.com/album/%s", a.ID) +} diff --git a/internal/spotify/entity_test.go b/internal/spotify/entity_test.go new file mode 100644 index 0000000..eea885b --- /dev/null +++ b/internal/spotify/entity_test.go @@ -0,0 +1,136 @@ +package spotify + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTrack_URL(t *testing.T) { + track := Track{ID: "sample_id"} + result := track.URL() + require.Equal(t, "https://open.spotify.com/track/sample_id", result) +} + +func TestAlbum_URL(t *testing.T) { + album := Album{ID: "sample_id"} + result := album.URL() + require.Equal(t, "https://open.spotify.com/album/sample_id", result) +} + +func Test_DetectTrackID(t *testing.T) { + tests := []struct { + name string + inputURL string + expected string + }{ + { + name: "Valid URL", + inputURL: "https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + { + name: "Invalid URL - Entity", + inputURL: "https://open.spotify.com/album/3hARuIUZqAIAKSuNvW5dGh", + expected: "", + }, + { + name: "Empty URL", + inputURL: "", + expected: "", + }, + { + name: "Non-Spotify URL", + inputURL: "https://example.com/track/7uv632EkfwYhXoqf8rhYrg", + expected: "", + }, + { + name: "URL without ID", + inputURL: "https://open.spotify.com/track/", + expected: "", + }, + { + name: "Valid URL with query", + inputURL: "https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg?test=123", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + { + name: "Valid URL with intl path", + inputURL: "https://open.spotify.com/intl-pt/track/2xmQMKTjiOdkdGVgqDzezo", + expected: "2xmQMKTjiOdkdGVgqDzezo", + }, + { + name: "Valid URL with intl path and query", + inputURL: "https://open.spotify.com/intl-pt/track/2xmQMKTjiOdkdGVgqDzezo?sample=query", + expected: "2xmQMKTjiOdkdGVgqDzezo", + }, + { + name: "Valid URL with prefix and suffix", + inputURL: "prefix https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg?test=123 suffix", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectTrackID(tt.inputURL) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_DetectAlbumID(t *testing.T) { + tests := []struct { + name string + inputURL string + expected string + }{ + { + name: "Valid URL", + inputURL: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + { + name: "Valid URL with intl path", + inputURL: "https://open.spotify.com/intl-pt/album/7uv632EkfwYhXoqf8rhYrg", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + { + name: "Invalid URL - Entity", + inputURL: "https://open.spotify.com/track/3hARuIUZqAIAKSuNvW5dGh", + expected: "", + }, + { + name: "Empty URL", + inputURL: "", + expected: "", + }, + { + name: "Non-Spotify URL", + inputURL: "https://example.com/album/7uv632EkfwYhXoqf8rhYrg", + expected: "", + }, + { + name: "URL without ID", + inputURL: "https://open.spotify.com/album/", + expected: "", + }, + { + name: "Valid URL with query", + inputURL: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg?test=123", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + { + name: "Valid URL with prefix and suffix", + inputURL: "prefix https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg?test=123 suffix", + expected: "7uv632EkfwYhXoqf8rhYrg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectAlbumID(tt.inputURL) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/spotify/track.go b/internal/spotify/track.go deleted file mode 100644 index be3921d..0000000 --- a/internal/spotify/track.go +++ /dev/null @@ -1,22 +0,0 @@ -package spotify - -import ( - "fmt" - "regexp" -) - -type Track struct { - Artists []Artist `json:"artists"` - ID string `json:"id"` - Name string `json:"name"` -} - -type Artist struct { - Name string `json:"name"` -} - -var TrackRe = regexp.MustCompile(`https://open\.spotify\.com/(?:[\w-]+/)?track/([a-zA-Z0-9]+)(?:\?.*)?`) - -func (t *Track) URL() string { - return fmt.Sprintf("https://open.spotify.com/track/%s", t.ID) -} diff --git a/internal/spotify/track_test.go b/internal/spotify/track_test.go deleted file mode 100644 index c9af39b..0000000 --- a/internal/spotify/track_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package spotify - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestTrack_URL(t *testing.T) { - track := Track{ID: "sample_id"} - result := track.URL() - require.Equal(t, "https://open.spotify.com/track/sample_id", result) -} diff --git a/internal/yandex/album.go b/internal/yandex/album.go deleted file mode 100644 index 5007429..0000000 --- a/internal/yandex/album.go +++ /dev/null @@ -1,24 +0,0 @@ -package yandex - -import ( - "fmt" - "regexp" -) - -type Album struct { - ID int `json:"id"` - Title string `json:"title"` - Artists []Artist `json:"artists"` -} - -var ( - AlbumRe = regexp.MustCompile( - fmt.Sprintf( - `https://music\.yandex\.(%s)/album/(\d+)`, allDomainZonesRe(), - ), - ) -) - -func (a *Album) URL() string { - return fmt.Sprintf("https://music.yandex.%s/album/%d", noRegionDomainZone, a.ID) -} diff --git a/internal/yandex/album_test.go b/internal/yandex/album_test.go deleted file mode 100644 index a1ffa48..0000000 --- a/internal/yandex/album_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package yandex - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestAlbum_URL(t *testing.T) { - album := Album{ID: 42} - result := album.URL() - require.Equal(t, "https://music.yandex.com/album/42", result) -} diff --git a/internal/yandex/client.go b/internal/yandex/client.go index 7154a34..28293e9 100644 --- a/internal/yandex/client.go +++ b/internal/yandex/client.go @@ -12,9 +12,9 @@ import ( const defaultAPIURL = "https://api.music.yandex.net" type Client interface { - GetTrack(ctx context.Context, id string) (*Track, error) + FetchTrack(ctx context.Context, id string) (*Track, error) SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) - GetAlbum(ctx context.Context, id string) (*Album, error) + FetchAlbum(ctx context.Context, id string) (*Album, error) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) } @@ -61,7 +61,7 @@ func NewHTTPClient(opts ...ClientOption) *HTTPClient { return &c } -func (c *HTTPClient) GetTrack(ctx context.Context, trackID string) (*Track, error) { +func (c *HTTPClient) FetchTrack(ctx context.Context, trackID string) (*Track, error) { path := fmt.Sprintf("/tracks/%s", trackID) body, err := c.getAPI(ctx, path, url.Values{}) if err != nil { @@ -102,7 +102,7 @@ func (c *HTTPClient) SearchTrack(ctx context.Context, artistName, trackName stri return &sr.Result.Tracks.Results[0], nil } -func (c *HTTPClient) GetAlbum(ctx context.Context, albumID string) (*Album, error) { +func (c *HTTPClient) FetchAlbum(ctx context.Context, albumID string) (*Album, error) { path := fmt.Sprintf("/albums/%s", albumID) body, err := c.getAPI(ctx, path, url.Values{}) if err != nil { diff --git a/internal/yandex/client_test.go b/internal/yandex/client_test.go index c1a3654..d8ec68e 100644 --- a/internal/yandex/client_test.go +++ b/internal/yandex/client_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestClient_GetTrack(t *testing.T) { +func TestClient_FetchTrack(t *testing.T) { tests := []struct { name string trackID string @@ -81,14 +81,14 @@ func TestClient_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := client.GetTrack(ctx, tt.trackID) + result, err := client.FetchTrack(ctx, tt.trackID) require.NoError(t, err) require.Equal(t, tt.want, result) }) } } -func TestClient_GetAlbum(t *testing.T) { +func TestClient_FetchAlbum(t *testing.T) { tests := []struct { name string albumID string @@ -144,7 +144,7 @@ func TestClient_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := client.GetAlbum(ctx, tt.albumID) + result, err := client.FetchAlbum(ctx, tt.albumID) require.NoError(t, err) require.Equal(t, tt.want, result) }) diff --git a/internal/yandex/track.go b/internal/yandex/entity.go similarity index 53% rename from internal/yandex/track.go rename to internal/yandex/entity.go index b0cbd33..b50715b 100644 --- a/internal/yandex/track.go +++ b/internal/yandex/entity.go @@ -5,6 +5,19 @@ import ( "regexp" ) +var ( + TrackRe = regexp.MustCompile( + fmt.Sprintf( + `https://music\.yandex\.(%s)/album/\d+/track/(\d+)`, allDomainZonesRe(), + ), + ) + AlbumRe = regexp.MustCompile( + fmt.Sprintf( + `https://music\.yandex\.(%s)/album/(\d+)`, allDomainZonesRe(), + ), + ) +) + type Track struct { Albums []Album `json:"albums"` Artists []Artist `json:"artists"` @@ -12,18 +25,36 @@ type Track struct { Title string `json:"title"` } +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Artists []Artist `json:"artists"` +} + type Artist struct { ID int `json:"id"` Name string `json:"name"` } -var ( - TrackRe = regexp.MustCompile( - fmt.Sprintf( - `https://music\.yandex\.(%s)/album/\d+/track/(\d+)`, allDomainZonesRe(), - ), - ) -) +func DetectTrackID(trackURL string) string { + match := TrackRe.FindStringSubmatch(trackURL) + if match == nil || len(match) < 3 { + return "" + } + return match[2] +} + +func DetectAlbumID(albumURL string) string { + match := AlbumRe.FindStringSubmatch(albumURL) + if match == nil || len(match) < 3 { + return "" + } + return match[2] +} + +func (a *Album) URL() string { + return fmt.Sprintf("https://music.yandex.%s/album/%d", noRegionDomainZone, a.ID) +} func (t *Track) URL() string { return fmt.Sprintf("https://music.yandex.%s/album/%d/track/%s", noRegionDomainZone, t.Albums[0].ID, t.IDString()) diff --git a/internal/yandex/entity_test.go b/internal/yandex/entity_test.go new file mode 100644 index 0000000..2721e42 --- /dev/null +++ b/internal/yandex/entity_test.go @@ -0,0 +1,167 @@ +package yandex + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_DetectTrackID(t *testing.T) { + tests := []struct { + name string + url string + wantID string + }{ + { + name: "Valid Track URL – .com", + url: "https://music.yandex.com/album/3192570/track/1197793", + wantID: "1197793", + }, + { + name: "Valid Track URL – .ru", + url: "https://music.yandex.ru/album/3192570/track/1197793", + wantID: "1197793", + }, + { + name: "Valid Track URL – .by", + url: "https://music.yandex.by/album/3192570/track/1197793", + wantID: "1197793", + }, + { + name: "Valid Track URL – .kz", + url: "https://music.yandex.kz/album/3192570/track/1197793", + wantID: "1197793", + }, + { + name: "Valid Track URL – .uz", + url: "https://music.yandex.uz/album/3192570/track/1197793", + wantID: "1197793", + }, + { + name: "Invalid URL - Missing track ID", + url: "https://music.yandex.ru/album/3192570/track/", + wantID: "", + }, + { + name: "Invalid URL - Non-numeric track ID", + url: "https://music.yandex.ru/album/3192570/track/abc", + wantID: "", + }, + { + name: "Invalid URL - Incorrect format", + url: "https://example.com/album/3192570/track/1197793", + wantID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectTrackID(tt.url) + require.Equal(t, tt.wantID, result) + }) + } +} + +func TestYandexAdapter_DetectAlbumID(t *testing.T) { + tests := []struct { + name string + url string + wantID string + expectedError error + }{ + { + name: "Valid album URL – .by", + url: "https://music.yandex.by/album/1197793", + wantID: "1197793", + }, + { + name: "Valid album URL – .kz", + url: "https://music.yandex.kz/album/1197793", + wantID: "1197793", + }, + { + name: "Valid album URL – .uz", + url: "https://music.yandex.uz/album/1197793", + wantID: "1197793", + }, + { + name: "Valid album URL – .ru", + url: "https://music.yandex.ru/album/1197793", + wantID: "1197793", + }, + { + name: "Invalid URL - Missing album ID", + url: "https://music.yandex.ru/album/", + wantID: "", + }, + { + name: "Invalid URL - Non-numeric album ID", + url: "https://music.yandex.ru/album/letters", + wantID: "", + }, + { + name: "Invalid URL - Incorrect host", + url: "https://example.com/album/3192570", + wantID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectAlbumID(tt.url) + require.Equal(t, tt.wantID, result) + }) + } +} + +func TestTrack_URL(t *testing.T) { + tests := []struct { + name string + track Track + want string + }{ + { + name: "track with int ID", + track: Track{ + ID: 123, + Albums: []Album{ + {ID: 456}, + }, + }, + want: "https://music.yandex.com/album/456/track/123", + }, + { + name: "track with string ID", + track: Track{ + ID: "123", + Albums: []Album{ + {ID: 456}, + }, + }, + want: "https://music.yandex.com/album/456/track/123", + }, + { + name: "track with float ID", + track: Track{ + ID: 123.0, + Albums: []Album{ + {ID: 456}, + }, + }, + want: "https://music.yandex.com/album/456/track/123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.track.URL() + require.Equal(t, tt.want, result) + }) + } +} + +func TestAlbum_URL(t *testing.T) { + album := Album{ID: 42} + result := album.URL() + require.Equal(t, "https://music.yandex.com/album/42", result) +} diff --git a/internal/yandex/track_test.go b/internal/yandex/track_test.go deleted file mode 100644 index 97431dc..0000000 --- a/internal/yandex/track_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package yandex - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestTrack_URL(t *testing.T) { - tests := []struct { - name string - track Track - want string - }{ - { - name: "track with int ID", - track: Track{ - ID: 123, - Albums: []Album{ - {ID: 456}, - }, - }, - want: "https://music.yandex.com/album/456/track/123", - }, - { - name: "track with string ID", - track: Track{ - ID: "123", - Albums: []Album{ - {ID: 456}, - }, - }, - want: "https://music.yandex.com/album/456/track/123", - }, - { - name: "track with float ID", - track: Track{ - ID: 123.0, - Albums: []Album{ - {ID: 456}, - }, - }, - want: "https://music.yandex.com/album/456/track/123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.track.URL() - require.Equal(t, tt.want, result) - }) - } -} diff --git a/internal/youtube/client.go b/internal/youtube/client.go index e8753d8..8cb8637 100644 --- a/internal/youtube/client.go +++ b/internal/youtube/client.go @@ -25,6 +25,14 @@ type HTTPClient struct { httpClient *http.Client } +type ClientOption func(client *HTTPClient) + +func WithAPIURL(url string) ClientOption { + return func(client *HTTPClient) { + client.apiURL = url + } +} + type getSnippetResponse struct { Items []*getSnippetItem `json:"items"` } @@ -52,6 +60,13 @@ type getPlaylistItemsResponse struct { Items []*getSnippetItem `json:"items"` } +type snippet struct { + Title string `json:"title"` + ChannelTitle string `json:"channelTitle"` + Description string `json:"description"` + VideoOwnerChannelTitle string `json:"videoOwnerChannelTitle"` +} + func NewHTTPClient(apiKey string, opts ...ClientOption) *HTTPClient { c := HTTPClient{ apiKey: apiKey, @@ -220,3 +235,10 @@ func (c *HTTPClient) getWithKey(ctx context.Context, path string, values url.Val return io.ReadAll(response.Body) } + +func (s *snippet) ownerChannelTitle() string { + if s.VideoOwnerChannelTitle != "" { + return s.VideoOwnerChannelTitle + } + return s.ChannelTitle +} diff --git a/internal/youtube/client_option.go b/internal/youtube/client_option.go deleted file mode 100644 index 4dc5d8e..0000000 --- a/internal/youtube/client_option.go +++ /dev/null @@ -1,9 +0,0 @@ -package youtube - -type ClientOption func(client *HTTPClient) - -func WithAPIURL(url string) ClientOption { - return func(client *HTTPClient) { - client.apiURL = url - } -} diff --git a/internal/youtube/entity.go b/internal/youtube/entity.go new file mode 100644 index 0000000..7a77390 --- /dev/null +++ b/internal/youtube/entity.go @@ -0,0 +1,74 @@ +package youtube + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + autogenVideoDescriptionSubstring = "Auto-generated by YouTube" + autogenVideoChannelTitleSuffix = " - Topic" + autogenPlaylistTitlePrefix = "Album - " +) + +var ( + VideoRe = regexp.MustCompile(`(?:youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})`) + PlaylistRe = regexp.MustCompile(`(?:youtube\.com/playlist\?list=|youtu\.be/playlist\?list=)([a-zA-Z0-9_-]+)`) +) + +type Video struct { + ID string + Title string + ChannelTitle string + Description string +} +type Playlist struct { + ID string + Title string + ChannelTitle string +} + +func DetectTrackID(trackURL string) string { + if matches := VideoRe.FindStringSubmatch(trackURL); len(matches) > 1 { + return matches[1] + } + return "" +} + +func DetectAlbumID(albumURL string) string { + if matches := PlaylistRe.FindStringSubmatch(albumURL); len(matches) > 1 { + return matches[1] + } + return "" +} + +func (v *Video) URL() string { + return fmt.Sprintf("https://www.youtube.com/watch?v=%s", v.ID) +} + +func (v *Video) IsAutogenerated() bool { + return strings.Contains(v.Description, autogenVideoDescriptionSubstring) +} + +func (v *Video) Artist() string { + if !strings.HasSuffix(v.ChannelTitle, autogenVideoChannelTitleSuffix) { + return "" + } + return strings.TrimSuffix(v.ChannelTitle, autogenVideoChannelTitleSuffix) +} + +func (p *Playlist) URL() string { + return fmt.Sprintf("https://www.youtube.com/playlist?list=%s", p.ID) +} + +func (p *Playlist) IsAutogenerated() bool { + return strings.HasPrefix(p.Title, autogenPlaylistTitlePrefix) +} + +func (p *Playlist) Album() string { + if !p.IsAutogenerated() { + return p.Title + } + return strings.TrimPrefix(p.Title, autogenPlaylistTitlePrefix) +} diff --git a/internal/youtube/entity_test.go b/internal/youtube/entity_test.go new file mode 100644 index 0000000..6e935db --- /dev/null +++ b/internal/youtube/entity_test.go @@ -0,0 +1,228 @@ +package youtube + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVideo_URL(t *testing.T) { + video := Video{ID: "dQw4w9WgXcQ"} + result := video.URL() + require.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", result) +} + +func TestVideo_IsAutogenerated(t *testing.T) { + tests := []struct { + name string + description string + want bool + }{ + { + name: "not autogenerated", + description: "sample not autogenerated description", + want: false, + }, + { + name: "autogenerated", + description: "Provided to YouTube by Parlophone UK\n\nSpace Oddity (2015 Remaster) · " + + "David Bowie\n\nDavid Bowie (aka Space Oddity)\n\n℗ 1969, 2015 Jones/Tintoretto Entertainment Company " + + "LLC under exclusive licence to Parlophone Records Ltd, a Warner Music Group Company\n\nEngineer: " + + "Barry Sheffield\nAcoustic Guitar: David Bowie\nKeyboards, Vocals: David Bowie\nProducer: " + + "Gus Dudgeon\nBass: Herbie Flowers\nUnknown: Ken Scott\nUnknown: Ken Scott\nEngineer: Malcolm Toft\n" + + "Guitar: Mick Wayne\nRemastering Engineer: Ray Staff\nMellotron: Rick Wakeman\nDrums: Terry Cox\n" + + "Writer: David Bowie\n\nAuto-generated by YouTube.", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &Video{Description: tt.description} + require.Equal(t, tt.want, v.IsAutogenerated()) + }) + } +} + +func TestVideo_Artist(t *testing.T) { + tests := []struct { + name string + channelTitle string + want string + }{ + { + name: "not autogenerated", + channelTitle: "David Bowie", + want: "", + }, + { + name: "autogenerated", + channelTitle: "David Bowie - Topic", + want: "David Bowie", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &Video{ChannelTitle: tt.channelTitle} + require.Equal(t, tt.want, v.Artist()) + }) + } +} + +func TestPlaylist_URL(t *testing.T) { + playlist := Playlist{ID: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj"} + result := playlist.URL() + require.Equal(t, "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", result) +} + +func TestPlaylist_IsAutogenerated(t *testing.T) { + tests := []struct { + name string + title string + want bool + }{ + { + name: "not autogenerated", + title: "sample not autogenerated title", + want: false, + }, + { + name: "autogenerated", + title: "Album - David Bowie", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &Playlist{Title: tt.title} + require.Equal(t, tt.want, v.IsAutogenerated()) + }) + } +} + +func TestPlaylist_Album(t *testing.T) { + tests := []struct { + name string + title string + want string + }{ + { + name: "not autogenerated", + title: "Space Oddity", + want: "Space Oddity", + }, + { + name: "autogenerated", + title: "Album - Space Oddity", + want: "Space Oddity", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Playlist{Title: tt.title} + require.Equal(t, tt.want, p.Album()) + }) + } +} + +func Test_DetectTrackID(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectedError error + }{ + { + name: "Short URL", + input: "https://youtu.be/dQw4w9WgXcQ", + expected: "dQw4w9WgXcQ", + }, + { + name: "Long URL", + input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + expected: "dQw4w9WgXcQ", + }, + { + name: "URL with extra parameters", + input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be", + expected: "dQw4w9WgXcQ", + }, + { + name: "Youtube music URL", + input: "https://music.youtube.com/watch?v=5PgdZDXg0z0&si=LkthPMI6H_I04dhP", + expected: "5PgdZDXg0z0", + }, + { + name: "Invalid URL", + input: "https://www.youtube.com/watch?v=", + expected: "", + }, + { + name: "Non-YouTube URL", + input: "https://www.example.com/watch?v=dQw4w9WgXcQ", + expected: "", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Invalid URL", + input: "https://www.youtube.com/watch?v=notFound", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectTrackID(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_DetectAlbumID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Standard URL", + input: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + }, + { + name: "Shortened URL", + input: "https://youtu.be/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + }, + { + name: "URL with extra parameters", + input: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj&feature=share", + expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + }, + { + name: "Youtube music URL", + input: "https://music.youtube.com/playlist?list=OLAK5uy_n4xauusTJSj6Mtt4cIuq4KZziSfjABYWU", + expected: "OLAK5uy_n4xauusTJSj6Mtt4cIuq4KZziSfjABYWU", + }, + { + name: "Invalid URL", + input: "https://www.example.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + expected: "", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectAlbumID(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/youtube/playlist.go b/internal/youtube/playlist.go deleted file mode 100644 index 2f2a994..0000000 --- a/internal/youtube/playlist.go +++ /dev/null @@ -1,34 +0,0 @@ -package youtube - -import ( - "fmt" - "regexp" - "strings" -) - -const ( - autogeneratedPlaylistTitlePrefix = "Album - " -) - -type Playlist struct { - ID string - Title string - ChannelTitle string -} - -var PlaylistRe = regexp.MustCompile(`(?:youtube\.com/playlist\?list=|youtu\.be/playlist\?list=)([a-zA-Z0-9_-]+)`) - -func (p *Playlist) URL() string { - return fmt.Sprintf("https://www.youtube.com/playlist?list=%s", p.ID) -} - -func (p *Playlist) IsAutogenerated() bool { - return strings.HasPrefix(p.Title, autogeneratedPlaylistTitlePrefix) -} - -func (p *Playlist) Album() string { - if !p.IsAutogenerated() { - return p.Title - } - return strings.TrimPrefix(p.Title, autogeneratedPlaylistTitlePrefix) -} diff --git a/internal/youtube/playlist_test.go b/internal/youtube/playlist_test.go deleted file mode 100644 index 38bfea6..0000000 --- a/internal/youtube/playlist_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package youtube - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestPlaylist_URL(t *testing.T) { - playlist := Playlist{ID: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj"} - result := playlist.URL() - require.Equal(t, "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", result) -} - -func TestPlaylist_IsAutogenerated(t *testing.T) { - tests := []struct { - name string - title string - want bool - }{ - { - name: "not autogenerated", - title: "sample not autogenerated title", - want: false, - }, - { - name: "autogenerated", - title: "Album - David Bowie", - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := &Playlist{Title: tt.title} - require.Equal(t, tt.want, v.IsAutogenerated()) - }) - } -} - -func TestPlaylist_Album(t *testing.T) { - tests := []struct { - name string - title string - want string - }{ - { - name: "not autogenerated", - title: "Space Oddity", - want: "Space Oddity", - }, - { - name: "autogenerated", - title: "Album - Space Oddity", - want: "Space Oddity", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &Playlist{Title: tt.title} - require.Equal(t, tt.want, p.Album()) - }) - } -} diff --git a/internal/youtube/snippet.go b/internal/youtube/snippet.go deleted file mode 100644 index 92b64d7..0000000 --- a/internal/youtube/snippet.go +++ /dev/null @@ -1,15 +0,0 @@ -package youtube - -type snippet struct { - Title string `json:"title"` - ChannelTitle string `json:"channelTitle"` - Description string `json:"description"` - VideoOwnerChannelTitle string `json:"videoOwnerChannelTitle"` -} - -func (s *snippet) ownerChannelTitle() string { - if s.VideoOwnerChannelTitle != "" { - return s.VideoOwnerChannelTitle - } - return s.ChannelTitle -} diff --git a/internal/youtube/video.go b/internal/youtube/video.go deleted file mode 100644 index e6d9d09..0000000 --- a/internal/youtube/video.go +++ /dev/null @@ -1,36 +0,0 @@ -package youtube - -import ( - "fmt" - "regexp" - "strings" -) - -const ( - autogeneratedVideoDescriptionSubstring = "Auto-generated by YouTube" - autogeneratedVideoChannelTitleSuffix = " - Topic" -) - -type Video struct { - ID string - Title string - ChannelTitle string - Description string -} - -var VideoRe = regexp.MustCompile(`(?:youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})`) - -func (v *Video) URL() string { - return fmt.Sprintf("https://www.youtube.com/watch?v=%s", v.ID) -} - -func (v *Video) IsAutogenerated() bool { - return strings.Contains(v.Description, autogeneratedVideoDescriptionSubstring) -} - -func (v *Video) Artist() string { - if !strings.HasSuffix(v.ChannelTitle, autogeneratedVideoChannelTitleSuffix) { - return "" - } - return strings.TrimSuffix(v.ChannelTitle, autogeneratedVideoChannelTitleSuffix) -} diff --git a/internal/youtube/video_test.go b/internal/youtube/video_test.go deleted file mode 100644 index 9173751..0000000 --- a/internal/youtube/video_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package youtube - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestVideo_URL(t *testing.T) { - video := Video{ID: "dQw4w9WgXcQ"} - result := video.URL() - require.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", result) -} - -func TestVideo_IsAutogenerated(t *testing.T) { - tests := []struct { - name string - description string - want bool - }{ - { - name: "not autogenerated", - description: "sample not autogenerated description", - want: false, - }, - { - name: "autogenerated", - description: "Provided to YouTube by Parlophone UK\n\nSpace Oddity (2015 Remaster) · " + - "David Bowie\n\nDavid Bowie (aka Space Oddity)\n\n℗ 1969, 2015 Jones/Tintoretto Entertainment Company " + - "LLC under exclusive licence to Parlophone Records Ltd, a Warner Music Group Company\n\nEngineer: " + - "Barry Sheffield\nAcoustic Guitar: David Bowie\nKeyboards, Vocals: David Bowie\nProducer: " + - "Gus Dudgeon\nBass: Herbie Flowers\nUnknown: Ken Scott\nUnknown: Ken Scott\nEngineer: Malcolm Toft\n" + - "Guitar: Mick Wayne\nRemastering Engineer: Ray Staff\nMellotron: Rick Wakeman\nDrums: Terry Cox\n" + - "Writer: David Bowie\n\nAuto-generated by YouTube.", - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := &Video{Description: tt.description} - require.Equal(t, tt.want, v.IsAutogenerated()) - }) - } -} - -func TestVideo_Artist(t *testing.T) { - tests := []struct { - name string - channelTitle string - want string - }{ - { - name: "not autogenerated", - channelTitle: "David Bowie", - want: "", - }, - { - name: "autogenerated", - channelTitle: "David Bowie - Topic", - want: "David Bowie", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v := &Video{ChannelTitle: tt.channelTitle} - require.Equal(t, tt.want, v.Artist()) - }) - } -} diff --git a/link.go b/link.go new file mode 100644 index 0000000..e3ab363 --- /dev/null +++ b/link.go @@ -0,0 +1,37 @@ +package streaminx + +import "errors" + +var ( + UnknownLinkError = errors.New("unknown entity link") +) + +type Link struct { + URL string + Provider *Provider + EntityID string + EntityType EntityType +} + +func ParseLink(url string) (*Link, error) { + for _, provider := range Providers { + if id := provider.DetectTrackID(url); id != "" { + return &Link{ + URL: url, + Provider: provider, + EntityID: id, + EntityType: Track, + }, nil + } + if id := provider.DetectAlbumID(url); id != "" { + return &Link{ + URL: url, + Provider: provider, + EntityID: id, + EntityType: Album, + }, nil + } + } + + return nil, UnknownLinkError +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..14e48d8 --- /dev/null +++ b/link_test.go @@ -0,0 +1,74 @@ +package streaminx + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseLink(t *testing.T) { + tests := []struct { + name string + url string + want *Link + expectedError error + }{ + { + name: "Apple track", + url: "https://music.apple.com/us/album/song-name/1234567890?i=987654321", + want: &Link{ + URL: "https://music.apple.com/us/album/song-name/1234567890?i=987654321", + Provider: Apple, + EntityID: "us-987654321", + EntityType: Track, + }, + }, + { + name: "Spotify album", + url: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg", + want: &Link{ + URL: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg", + Provider: Spotify, + EntityID: "7uv632EkfwYhXoqf8rhYrg", + EntityType: Album, + }, + }, + { + name: "Yandex track", + url: "https://music.yandex.by/album/3192570/track/1197793", + want: &Link{ + URL: "https://music.yandex.by/album/3192570/track/1197793", + Provider: Yandex, + EntityID: "1197793", + EntityType: Track, + }, + }, + { + name: "Youtube album", + url: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + want: &Link{ + URL: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + Provider: Youtube, + EntityID: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", + EntityType: Album, + }, + }, + { + name: "Unknown provider", + url: "https://example.com/track/123456789", + expectedError: UnknownLinkError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseLink(tt.url) + if tt.expectedError != nil { + require.ErrorAs(t, err, &tt.expectedError) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/provider.go b/provider.go index 5a5e267..2ffbb55 100644 --- a/provider.go +++ b/provider.go @@ -1,61 +1,80 @@ package streaminx import ( - "regexp" - "github.com/GeorgeGorbanev/streaminx/internal/apple" "github.com/GeorgeGorbanev/streaminx/internal/spotify" "github.com/GeorgeGorbanev/streaminx/internal/yandex" "github.com/GeorgeGorbanev/streaminx/internal/youtube" ) -type Provider struct { - Name string - Code string - TrackRe *regexp.Regexp - AlbumRe *regexp.Regexp - Regions []string -} - var ( + Providers = []*Provider{ + Apple, + Spotify, + Yandex, + Youtube, + } + Apple = &Provider{ - Name: "Apple", - Code: "ap", - TrackRe: apple.TrackRe, - AlbumRe: apple.AlbumRe, - Regions: apple.ISO3166codes, + name: "Apple", + сode: "ap", + regions: apple.ISO3166codes, + trackIDParser: apple.DetectTrackID, + albumIDParser: apple.DetectAlbumID, } Spotify = &Provider{ - Name: "Spotify", - Code: "sf", - TrackRe: spotify.TrackRe, - AlbumRe: spotify.AlbumRe, + name: "Spotify", + сode: "sf", + trackIDParser: spotify.DetectTrackID, + albumIDParser: spotify.DetectAlbumID, } Yandex = &Provider{ - Name: "Yandex", - Code: "ya", - TrackRe: yandex.TrackRe, - AlbumRe: yandex.AlbumRe, - Regions: yandex.Regions, + name: "Yandex", + сode: "ya", + regions: yandex.Regions, + trackIDParser: yandex.DetectTrackID, + albumIDParser: yandex.DetectAlbumID, } Youtube = &Provider{ - Name: "Youtube", - Code: "yt", - TrackRe: youtube.VideoRe, - AlbumRe: youtube.PlaylistRe, - } - - Providers = []*Provider{ - Apple, - Spotify, - Yandex, - Youtube, + name: "Youtube", + сode: "yt", + trackIDParser: youtube.DetectTrackID, + albumIDParser: youtube.DetectAlbumID, } ) +type Provider struct { + name string + сode string + regions []string + + trackIDParser func(trackURL string) string + albumIDParser func(albumURL string) string +} + +func (p *Provider) Name() string { + return p.name +} + +func (p *Provider) Code() string { + return p.сode +} + +func (p *Provider) Regions() []string { + return p.regions +} + +func (p *Provider) DetectTrackID(trackURL string) string { + return p.trackIDParser(trackURL) +} + +func (p *Provider) DetectAlbumID(albumURL string) string { + return p.albumIDParser(albumURL) +} + func FindProviderByCode(code string) *Provider { for _, provider := range Providers { - if provider.Code == code { + if provider.сode == code { return provider } } diff --git a/registry.go b/registry.go index 7e90172..982cd39 100644 --- a/registry.go +++ b/registry.go @@ -2,6 +2,7 @@ package streaminx import ( "context" + "errors" "fmt" "github.com/GeorgeGorbanev/streaminx/internal/apple" @@ -11,74 +12,89 @@ import ( "github.com/GeorgeGorbanev/streaminx/internal/youtube" ) -type Registry struct { - adapters map[string]Adapter - options registryOptionsMap - translator translator.Translator -} +var ( + InvalidProviderError = errors.New("invalid provider") + InvalidEntityTypeError = errors.New("invalid entity type") +) -type Credentials struct { - GoogleTranslatorAPIKeyJSON string - GoogleTranslatorProjectID string - YoutubeAPIKey string - SpotifyClientID string - SpotifyClientSecret string +type Registry struct { + adapters map[string]Adapter + clientOptions clientOptions + translator translator.Translator } -func NewRegistry(ctx context.Context, credentials Credentials, opts ...RegistryOption) (*Registry, error) { +func NewRegistry(ctx context.Context, cred Credentials, opts ...RegistryOption) (*Registry, error) { registry := Registry{ - adapters: make(map[string]Adapter), + adapters: map[string]Adapter{}, } for _, opt := range opts { opt(®istry) } if registry.translator == nil { - translatorClient, err := translator.NewGoogleClient(ctx, &translator.GoogleCredentials{ - APIKeyJSON: credentials.GoogleTranslatorAPIKeyJSON, - ProjectID: credentials.GoogleTranslatorProjectID, - }) + translatorClient, err := translator.NewGoogleClient(ctx, cred.google()) if err != nil { return nil, fmt.Errorf("failed to create google translator client: %w", err) } registry.translator = translatorClient } - if registry.Adapter(Apple) == nil { - registry.adapters[Apple.Code] = newAppleAdapter( - apple.NewHTTPClient(registry.options.appleClientOptions...), - ) + if registry.adapter(Apple) == nil { + client := apple.NewHTTPClient(registry.clientOptions.apple...) + registry.adapters[Apple.сode] = newAppleAdapter(client) } - if registry.Adapter(Spotify) == nil { - registry.adapters[Spotify.Code] = newSpotifyAdapter( - spotify.NewHTTPClient( - &spotify.Credentials{ - ClientID: credentials.SpotifyClientID, - ClientSecret: credentials.SpotifyClientSecret, - }, - registry.options.spotifyClientOptions..., - ), - ) + if registry.adapter(Spotify) == nil { + client := spotify.NewHTTPClient(cred.spotify(), registry.clientOptions.spotify...) + registry.adapters[Spotify.сode] = newSpotifyAdapter(client) } - if registry.Adapter(Yandex) == nil { - registry.adapters[Yandex.Code] = newYandexAdapter( - yandex.NewHTTPClient(registry.options.yandexClientOptions...), - registry.translator, - ) + if registry.adapter(Yandex) == nil { + client := yandex.NewHTTPClient(registry.clientOptions.yandex...) + registry.adapters[Yandex.сode] = newYandexAdapter(client, registry.translator) } - if registry.Adapter(Youtube) == nil { - registry.adapters[Youtube.Code] = newYoutubeAdapter( - youtube.NewHTTPClient(credentials.YoutubeAPIKey, registry.options.youtubeClientOptions...), - ) + if registry.adapter(Youtube) == nil { + client := youtube.NewHTTPClient(cred.YoutubeAPIKey, registry.clientOptions.youtube...) + registry.adapters[Youtube.сode] = newYoutubeAdapter(client) } return ®istry, nil } -func (r *Registry) Adapter(p *Provider) Adapter { - return r.adapters[p.Code] -} - func (r *Registry) Close() error { return r.translator.Close() } + +func (r *Registry) Fetch(ctx context.Context, p *Provider, et EntityType, id string) (*Entity, error) { + adapter := r.adapter(p) + if adapter == nil { + return nil, InvalidProviderError + } + + switch et { + case Track: + return adapter.FetchTrack(ctx, id) + case Album: + return adapter.FetchAlbum(ctx, id) + default: + return nil, InvalidEntityTypeError + } +} + +func (r *Registry) Search(ctx context.Context, p *Provider, et EntityType, artist, name string) (*Entity, error) { + adapter := r.adapter(p) + if adapter == nil { + return nil, InvalidProviderError + } + + switch et { + case Track: + return adapter.SearchTrack(ctx, artist, name) + case Album: + return adapter.SearchAlbum(ctx, artist, name) + default: + return nil, InvalidEntityTypeError + } +} + +func (r *Registry) adapter(p *Provider) Adapter { + return r.adapters[p.сode] +} diff --git a/registry_options.go b/registry_options.go index ba376a3..c743f7f 100644 --- a/registry_options.go +++ b/registry_options.go @@ -10,16 +10,16 @@ import ( type RegistryOption func(registry *Registry) -type registryOptionsMap struct { - appleClientOptions []apple.ClientOption - spotifyClientOptions []spotify.ClientOption - yandexClientOptions []yandex.ClientOption - youtubeClientOptions []youtube.ClientOption +type clientOptions struct { + apple []apple.ClientOption + spotify []spotify.ClientOption + yandex []yandex.ClientOption + youtube []youtube.ClientOption } func WithProviderAdapter(provider *Provider, adapter Adapter) RegistryOption { return func(r *Registry) { - r.adapters[provider.Code] = adapter + r.adapters[provider.сode] = adapter } } @@ -31,36 +31,36 @@ func WithTranslator(translator translator.Translator) RegistryOption { func WithAppleWebPlayerURL(url string) RegistryOption { return func(r *Registry) { - r.options.appleClientOptions = append(r.options.appleClientOptions, apple.WithWebPlayerURL(url)) + r.clientOptions.apple = append(r.clientOptions.apple, apple.WithWebPlayerURL(url)) } } func WithAppleAPIURL(url string) RegistryOption { return func(r *Registry) { - r.options.appleClientOptions = append(r.options.appleClientOptions, apple.WithAPIURL(url)) + r.clientOptions.apple = append(r.clientOptions.apple, apple.WithAPIURL(url)) } } func WithSpotifyAuthURL(url string) RegistryOption { return func(r *Registry) { - r.options.spotifyClientOptions = append(r.options.spotifyClientOptions, spotify.WithAuthURL(url)) + r.clientOptions.spotify = append(r.clientOptions.spotify, spotify.WithAuthURL(url)) } } func WithSpotifyAPIURL(url string) RegistryOption { return func(r *Registry) { - r.options.spotifyClientOptions = append(r.options.spotifyClientOptions, spotify.WithAPIURL(url)) + r.clientOptions.spotify = append(r.clientOptions.spotify, spotify.WithAPIURL(url)) } } func WithYandexAPIURL(url string) RegistryOption { return func(r *Registry) { - r.options.yandexClientOptions = append(r.options.yandexClientOptions, yandex.WithAPIURL(url)) + r.clientOptions.yandex = append(r.clientOptions.yandex, yandex.WithAPIURL(url)) } } func WithYoutubeAPIURL(url string) RegistryOption { return func(r *Registry) { - r.options.youtubeClientOptions = append(r.options.youtubeClientOptions, youtube.WithAPIURL(url)) + r.clientOptions.youtube = append(r.clientOptions.youtube, youtube.WithAPIURL(url)) } } diff --git a/registry_test.go b/registry_test.go index 019b806..c7ca851 100644 --- a/registry_test.go +++ b/registry_test.go @@ -1,25 +1,262 @@ package streaminx import ( + "context" "testing" "github.com/stretchr/testify/require" ) -func TestRegistry_Adapter(t *testing.T) { - spotifyAdapter := newSpotifyAdapter(nil) - yandexAdapter := newYandexAdapter(nil, nil) - youtubeAdapter := newYoutubeAdapter(nil) +type adapterMock struct { + fetchTrack map[string]*Entity + searchTrack map[string]map[string]*Entity + fetchAlbum map[string]*Entity + searchAlbum map[string]map[string]*Entity +} + +func (a *adapterMock) FetchTrack(_ context.Context, id string) (*Entity, error) { + return a.fetchTrack[id], nil +} + +func (a *adapterMock) SearchTrack(_ context.Context, artistName, trackName string) (*Entity, error) { + return a.searchTrack[artistName][trackName], nil +} + +func (a *adapterMock) FetchAlbum(_ context.Context, id string) (*Entity, error) { + return a.fetchAlbum[id], nil +} + +func (a *adapterMock) SearchAlbum(_ context.Context, artistName, albumName string) (*Entity, error) { + return a.searchAlbum[artistName][albumName], nil +} + +func TestRegistry_Fetch(t *testing.T) { + sampleProvider := Apple + + type args struct { + p *Provider + et EntityType + id string + } + + tests := []struct { + name string + args args + adapterMock adapterMock + want *Entity + wantErr error + }{ + { + name: "track found", + args: args{ + p: sampleProvider, + et: Track, + id: "1", + }, + adapterMock: adapterMock{ + fetchTrack: map[string]*Entity{ + "1": {ID: "1"}, + }, + }, + want: &Entity{ID: "1"}, + }, + { + name: "album found", + args: args{ + p: sampleProvider, + et: Album, + id: "1", + }, + adapterMock: adapterMock{ + fetchAlbum: map[string]*Entity{ + "1": {ID: "1"}, + }, + }, + want: &Entity{ID: "1"}, + }, + { + name: "track not found", + args: args{ + p: sampleProvider, + et: Track, + id: "1", + }, + want: nil, + }, + { + name: "album not found", + args: args{ + p: sampleProvider, + et: Album, + id: "1", + }, + want: nil, + }, + { + name: "invalid provider", + args: args{ + p: &Provider{}, + et: Track, + id: "1", + }, + wantErr: InvalidProviderError, + }, + { + name: "invalid entity type", + args: args{ + p: sampleProvider, + et: EntityType("invalid"), + id: "1", + }, + wantErr: InvalidEntityTypeError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + registry, err := NewRegistry( + ctx, + Credentials{}, + WithTranslator(&translatorMock{}), + WithProviderAdapter(sampleProvider, &tt.adapterMock), + WithProviderAdapter(Spotify, &adapterMock{}), + WithProviderAdapter(Yandex, &adapterMock{}), + WithProviderAdapter(Youtube, &adapterMock{}), + ) + require.NoError(t, err) + + result, err := registry.Fetch(ctx, tt.args.p, tt.args.et, tt.args.id) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, result) + } + }) + } +} + +func TestRegistry_Search(t *testing.T) { + sampleProvider := Apple + + type args struct { + p *Provider + et EntityType + artist string + name string + } - registry := Registry{ - adapters: map[string]Adapter{ - Spotify.Code: spotifyAdapter, - Yandex.Code: yandexAdapter, - Youtube.Code: youtubeAdapter, + tests := []struct { + name string + args args + adapterMock adapterMock + want *Entity + wantErr error + }{ + { + name: "track found", + args: args{ + p: sampleProvider, + et: Track, + artist: "artist", + name: "name", + }, + adapterMock: adapterMock{ + searchTrack: map[string]map[string]*Entity{ + "artist": { + "name": { + ID: "1", + }, + }, + }, + }, + want: &Entity{ID: "1"}, + }, + { + name: "album found", + args: args{ + p: sampleProvider, + et: Album, + artist: "artist", + name: "name", + }, + adapterMock: adapterMock{ + searchAlbum: map[string]map[string]*Entity{ + "artist": { + "name": { + ID: "1", + }, + }, + }, + }, + want: &Entity{ID: "1"}, + }, + { + name: "track not found", + args: args{ + p: sampleProvider, + et: Track, + artist: "artist", + name: "name", + }, + want: nil, + }, + { + name: "album not found", + args: args{ + p: sampleProvider, + et: Album, + artist: "artist", + name: "name", + }, + want: nil, + }, + { + name: "invalid provider", + args: args{ + p: &Provider{}, + et: Track, + artist: "artist", + name: "name", + }, + wantErr: InvalidProviderError, + }, + { + name: "invalid entity type", + args: args{ + p: sampleProvider, + et: EntityType("invalid"), + artist: "artist", + name: "name", + }, + wantErr: InvalidEntityTypeError, }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + registry, err := NewRegistry( + ctx, + Credentials{}, + WithTranslator(&translatorMock{}), + WithProviderAdapter(sampleProvider, &tt.adapterMock), + WithProviderAdapter(Spotify, &adapterMock{}), + WithProviderAdapter(Yandex, &adapterMock{}), + WithProviderAdapter(Youtube, &adapterMock{}), + ) + require.NoError(t, err) - require.Equal(t, registry.Adapter(Spotify), spotifyAdapter) - require.Equal(t, registry.Adapter(Yandex), yandexAdapter) - require.Equal(t, registry.Adapter(Youtube), youtubeAdapter) + result, err := registry.Search(ctx, tt.args.p, tt.args.et, tt.args.artist, tt.args.name) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, result) + } + }) + } } diff --git a/spotify_adapter.go b/spotify_adapter.go index c0477dd..525daf3 100644 --- a/spotify_adapter.go +++ b/spotify_adapter.go @@ -17,24 +17,8 @@ func newSpotifyAdapter(client spotify.Client) *SpotifyAdapter { } } -func (a *SpotifyAdapter) DetectTrackID(trackURL string) (string, error) { - match := spotify.TrackRe.FindStringSubmatch(trackURL) - if len(match) < 2 { - return "", IDNotFoundError - } - return match[1], nil -} - -func (a *SpotifyAdapter) DetectAlbumID(albumURL string) (string, error) { - match := spotify.AlbumRe.FindStringSubmatch(albumURL) - if len(match) < 2 { - return "", IDNotFoundError - } - return match[1], nil -} - -func (a *SpotifyAdapter) GetTrack(ctx context.Context, id string) (*Track, error) { - track, err := a.client.GetTrack(ctx, id) +func (a *SpotifyAdapter) FetchTrack(ctx context.Context, id string) (*Entity, error) { + track, err := a.client.FetchTrack(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get track from spotify: %w", err) } @@ -45,7 +29,7 @@ func (a *SpotifyAdapter) GetTrack(ctx context.Context, id string) (*Track, error return a.adaptTrack(track), nil } -func (a *SpotifyAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) { +func (a *SpotifyAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) { track, err := a.client.SearchTrack(ctx, artistName, trackName) if err != nil { return nil, fmt.Errorf("failed to search track on spotify: %w", err) @@ -57,8 +41,8 @@ func (a *SpotifyAdapter) SearchTrack(ctx context.Context, artistName, trackName return a.adaptTrack(track), nil } -func (a *SpotifyAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) { - album, err := a.client.GetAlbum(ctx, id) +func (a *SpotifyAdapter) FetchAlbum(ctx context.Context, id string) (*Entity, error) { + album, err := a.client.FetchAlbum(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get album from spotify: %w", err) } @@ -69,7 +53,7 @@ func (a *SpotifyAdapter) GetAlbum(ctx context.Context, id string) (*Album, error return a.adaptAlbum(album), nil } -func (a *SpotifyAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) { +func (a *SpotifyAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) { album, err := a.client.SearchAlbum(ctx, artistName, albumName) if err != nil { return nil, fmt.Errorf("failed to search album on spotify: %w", err) @@ -81,22 +65,24 @@ func (a *SpotifyAdapter) SearchAlbum(ctx context.Context, artistName, albumName return a.adaptAlbum(album), nil } -func (a *SpotifyAdapter) adaptTrack(track *spotify.Track) *Track { - return &Track{ +func (a *SpotifyAdapter) adaptTrack(track *spotify.Track) *Entity { + return &Entity{ ID: track.ID, Title: track.Name, Artist: track.Artists[0].Name, URL: track.URL(), Provider: Spotify, + Type: Track, } } -func (a *SpotifyAdapter) adaptAlbum(album *spotify.Album) *Album { - return &Album{ +func (a *SpotifyAdapter) adaptAlbum(album *spotify.Album) *Entity { + return &Entity{ ID: album.ID, Title: album.Name, Artist: album.Artists[0].Name, URL: album.URL(), Provider: Spotify, + Type: Album, } } diff --git a/spotify_adapter_test.go b/spotify_adapter_test.go index 0a6cc35..6f14137 100644 --- a/spotify_adapter_test.go +++ b/spotify_adapter_test.go @@ -6,225 +6,75 @@ import ( "time" "github.com/GeorgeGorbanev/streaminx/internal/spotify" + "github.com/stretchr/testify/require" ) -type spotifyClientMock struct{} +type spotifyClientMock struct { + fetchTrack map[string]*spotify.Track + fetchAlbum map[string]*spotify.Album + searchTrack map[string]map[string]*spotify.Track + searchAlbum map[string]map[string]*spotify.Album +} -func (c *spotifyClientMock) GetTrack(_ context.Context, id string) (*spotify.Track, error) { - if id != "sampleID" { - return nil, nil - } - return &spotify.Track{ - ID: id, - Name: "sample name", - Artists: []spotify.Artist{ - {Name: "sample artist"}, - }, - }, nil +func (c *spotifyClientMock) FetchTrack(_ context.Context, id string) (*spotify.Track, error) { + return c.fetchTrack[id], nil } func (c *spotifyClientMock) SearchTrack(_ context.Context, artistName, trackName string) (*spotify.Track, error) { - if artistName != "sample artist" || trackName != "sample name" { - return nil, nil + if tracks, ok := c.searchTrack[artistName]; ok { + return tracks[trackName], nil } - - return &spotify.Track{ - ID: "sampleID", - Name: "sample name", - Artists: []spotify.Artist{ - {Name: "sample artist"}, - }, - }, nil + return nil, nil } -func (c *spotifyClientMock) GetAlbum(_ context.Context, id string) (*spotify.Album, error) { - if id != "sampleID" { - return nil, nil - } - return &spotify.Album{ - ID: id, - Name: "sample name", - Artists: []spotify.Artist{ - {Name: "sample artist"}, - }, - }, nil +func (c *spotifyClientMock) FetchAlbum(_ context.Context, id string) (*spotify.Album, error) { + return c.fetchAlbum[id], nil } func (c *spotifyClientMock) SearchAlbum(_ context.Context, artistName, albumName string) (*spotify.Album, error) { - if artistName != "sample artist" || albumName != "sample name" { - return nil, nil + if albums, ok := c.searchAlbum[artistName]; ok { + return albums[albumName], nil } - return &spotify.Album{ - ID: "sampleID", - Name: "sample name", - Artists: []spotify.Artist{ - {Name: "sample artist"}, - }, - }, nil + return nil, nil } -func TestSpotifyAdapter_DetectTrackID(t *testing.T) { - tests := []struct { - name string - inputURL string - expected string - expectedError error - }{ - { - name: "Valid URL", - inputURL: "https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - { - name: "Invalid URL - Album", - inputURL: "https://open.spotify.com/album/3hARuIUZqAIAKSuNvW5dGh", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Empty URL", - inputURL: "", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Non-Spotify URL", - inputURL: "https://example.com/track/7uv632EkfwYhXoqf8rhYrg", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "URL without ID", - inputURL: "https://open.spotify.com/track/", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Valid URL with query", - inputURL: "https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg?test=123", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - { - name: "Valid URL with intl path", - inputURL: "https://open.spotify.com/intl-pt/track/2xmQMKTjiOdkdGVgqDzezo", - expected: "2xmQMKTjiOdkdGVgqDzezo", - }, - { - name: "Valid URL with intl path and query", - inputURL: "https://open.spotify.com/intl-pt/track/2xmQMKTjiOdkdGVgqDzezo?sample=query", - expected: "2xmQMKTjiOdkdGVgqDzezo", - }, - { - name: "Valid URL with prefix and suffix", - inputURL: "prefix https://open.spotify.com/track/7uv632EkfwYhXoqf8rhYrg?test=123 suffix", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newSpotifyAdapter(nil) - result, err := adapter.DetectTrackID(tt.inputURL) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestSpotifyAdapter_DetectAlbumID(t *testing.T) { - tests := []struct { - name string - inputURL string - expected string - expectedError error - }{ - { - name: "Valid URL", - inputURL: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - { - name: "Valid URL with intl path", - inputURL: "https://open.spotify.com/intl-pt/album/7uv632EkfwYhXoqf8rhYrg", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - { - name: "Invalid URL - Track", - inputURL: "https://open.spotify.com/track/3hARuIUZqAIAKSuNvW5dGh", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Empty URL", - inputURL: "", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Non-Spotify URL", - inputURL: "https://example.com/album/7uv632EkfwYhXoqf8rhYrg", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "URL without ID", - inputURL: "https://open.spotify.com/album/", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Valid URL with query", - inputURL: "https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg?test=123", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - { - name: "Valid URL with prefix and suffix", - inputURL: "prefix https://open.spotify.com/album/7uv632EkfwYhXoqf8rhYrg?test=123 suffix", - expected: "7uv632EkfwYhXoqf8rhYrg", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newSpotifyAdapter(nil) - result, err := adapter.DetectAlbumID(tt.inputURL) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestSpotifyAdapter_GetTrack(t *testing.T) { +func TestSpotifyAdapter_FetchTrack(t *testing.T) { tests := []struct { name string id string - expectedTrack *Track + clientMock *spotifyClientMock + expectedTrack *Entity }{ { name: "found ID", id: "sampleID", - expectedTrack: &Track{ + clientMock: &spotifyClientMock{ + fetchTrack: map[string]*spotify.Track{ + "sampleID": { + ID: "sampleID", + Name: "sample name", + Artists: []spotify.Artist{ + { + Name: "sample artist", + }, + }, + }, + }, + }, + expectedTrack: &Entity{ ID: "sampleID", Title: "sample name", Artist: "sample artist", URL: "https://open.spotify.com/track/sampleID", Provider: Spotify, + Type: Track, }, }, { name: "not found ID", id: "notFoundID", + clientMock: &spotifyClientMock{}, expectedTrack: nil, }, } @@ -233,9 +83,8 @@ func TestSpotifyAdapter_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - a := newSpotifyAdapter(&spotifyClientMock{}) - - result, err := a.GetTrack(ctx, tt.id) + a := newSpotifyAdapter(tt.clientMock) + result, err := a.FetchTrack(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedTrack, result) @@ -248,24 +97,42 @@ func TestSpotifyAdapter_SearchTrack(t *testing.T) { name string artistName string searchName string - expectedTrack *Track + clientMock *spotifyClientMock + expectedTrack *Entity }{ { name: "found query", artistName: "sample artist", searchName: "sample name", - expectedTrack: &Track{ + clientMock: &spotifyClientMock{ + searchTrack: map[string]map[string]*spotify.Track{ + "sample artist": { + "sample name": { + ID: "sampleID", + Name: "sample name", + Artists: []spotify.Artist{ + { + Name: "sample artist", + }, + }, + }, + }, + }, + }, + expectedTrack: &Entity{ ID: "sampleID", Title: "sample name", Artist: "sample artist", URL: "https://open.spotify.com/track/sampleID", Provider: Spotify, + Type: Track, }, }, { name: "not found query", artistName: "not found artist", searchName: "not found name", + clientMock: &spotifyClientMock{}, expectedTrack: nil, }, } @@ -274,8 +141,7 @@ func TestSpotifyAdapter_SearchTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - a := newSpotifyAdapter(&spotifyClientMock{}) - + a := newSpotifyAdapter(tt.clientMock) result, err := a.SearchTrack(ctx, tt.artistName, tt.searchName) require.NoError(t, err) @@ -284,26 +150,42 @@ func TestSpotifyAdapter_SearchTrack(t *testing.T) { } } -func TestSpotifyAdapter_GetAlbum(t *testing.T) { +func TestSpotifyAdapter_FetchAlbum(t *testing.T) { tests := []struct { name string id string - expectedTrack *Album + clientMock *spotifyClientMock + expectedTrack *Entity }{ { name: "found ID", id: "sampleID", - expectedTrack: &Album{ + clientMock: &spotifyClientMock{ + fetchAlbum: map[string]*spotify.Album{ + "sampleID": { + ID: "sampleID", + Name: "sample name", + Artists: []spotify.Artist{ + { + Name: "sample artist", + }, + }, + }, + }, + }, + expectedTrack: &Entity{ ID: "sampleID", Title: "sample name", Artist: "sample artist", URL: "https://open.spotify.com/album/sampleID", Provider: Spotify, + Type: Album, }, }, { name: "not found ID", id: "notFoundID", + clientMock: &spotifyClientMock{}, expectedTrack: nil, }, } @@ -312,9 +194,8 @@ func TestSpotifyAdapter_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - a := newSpotifyAdapter(&spotifyClientMock{}) - - result, err := a.GetAlbum(ctx, tt.id) + a := newSpotifyAdapter(tt.clientMock) + result, err := a.FetchAlbum(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedTrack, result) @@ -327,24 +208,42 @@ func TestSpotifyAdapter_SearchAlbum(t *testing.T) { name string artistName string searchName string - expectedTrack *Album + clientMock *spotifyClientMock + expectedTrack *Entity }{ { name: "found query", artistName: "sample artist", searchName: "sample name", - expectedTrack: &Album{ + clientMock: &spotifyClientMock{ + searchAlbum: map[string]map[string]*spotify.Album{ + "sample artist": { + "sample name": { + ID: "sampleID", + Name: "sample name", + Artists: []spotify.Artist{ + { + Name: "sample artist", + }, + }, + }, + }, + }, + }, + expectedTrack: &Entity{ ID: "sampleID", Title: "sample name", Artist: "sample artist", URL: "https://open.spotify.com/album/sampleID", Provider: Spotify, + Type: Album, }, }, { name: "not found query", artistName: "not found artist", searchName: "not found name", + clientMock: &spotifyClientMock{}, expectedTrack: nil, }, } @@ -353,8 +252,7 @@ func TestSpotifyAdapter_SearchAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - a := newSpotifyAdapter(&spotifyClientMock{}) - + a := newSpotifyAdapter(tt.clientMock) result, err := a.SearchAlbum(ctx, tt.artistName, tt.searchName) require.NoError(t, err) diff --git a/track.go b/track.go deleted file mode 100644 index ff7b3a4..0000000 --- a/track.go +++ /dev/null @@ -1,9 +0,0 @@ -package streaminx - -type Track struct { - ID string - Title string - Artist string - URL string - Provider *Provider -} diff --git a/yandex_adapter.go b/yandex_adapter.go index db34039..d151819 100644 --- a/yandex_adapter.go +++ b/yandex_adapter.go @@ -22,24 +22,8 @@ func newYandexAdapter(c yandex.Client, t translator.Translator) *YandexAdapter { } } -func (a *YandexAdapter) DetectTrackID(trackURL string) (string, error) { - match := yandex.TrackRe.FindStringSubmatch(trackURL) - if match == nil || len(match) < 3 { - return "", IDNotFoundError - } - return match[2], nil -} - -func (a *YandexAdapter) DetectAlbumID(albumURL string) (string, error) { - match := yandex.AlbumRe.FindStringSubmatch(albumURL) - if match == nil || len(match) < 3 { - return "", IDNotFoundError - } - return match[2], nil -} - -func (a *YandexAdapter) GetTrack(ctx context.Context, id string) (*Track, error) { - yandexTrack, err := a.client.GetTrack(ctx, id) +func (a *YandexAdapter) FetchTrack(ctx context.Context, id string) (*Entity, error) { + yandexTrack, err := a.client.FetchTrack(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get track from yandex music: %w", err) } @@ -50,7 +34,7 @@ func (a *YandexAdapter) GetTrack(ctx context.Context, id string) (*Track, error) return a.adaptTrack(yandexTrack), nil } -func (a *YandexAdapter) SearchTrack(ctx context.Context, artist, track string) (*Track, error) { +func (a *YandexAdapter) SearchTrack(ctx context.Context, artist, track string) (*Entity, error) { lowcasedArtist := strings.ToLower(artist) lowcasedTrack := strings.ToLower(track) @@ -65,8 +49,8 @@ func (a *YandexAdapter) SearchTrack(ctx context.Context, artist, track string) ( return nil, nil } -func (a *YandexAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) { - yandexAlbum, err := a.client.GetAlbum(ctx, id) +func (a *YandexAdapter) FetchAlbum(ctx context.Context, id string) (*Entity, error) { + yandexAlbum, err := a.client.FetchAlbum(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get album from yandex music: %w", err) } @@ -77,7 +61,7 @@ func (a *YandexAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) return a.adaptAlbum(yandexAlbum), nil } -func (a *YandexAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) { +func (a *YandexAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) { lowcasedArtist := strings.ToLower(artistName) lowcasedAlbum := strings.ToLower(albumName) @@ -150,23 +134,25 @@ func (a *YandexAdapter) findAlbum(ctx context.Context, artist, album string) (*y return nil, nil } -func (a *YandexAdapter) adaptTrack(yandexTrack *yandex.Track) *Track { - return &Track{ +func (a *YandexAdapter) adaptTrack(yandexTrack *yandex.Track) *Entity { + return &Entity{ ID: yandexTrack.IDString(), Title: yandexTrack.Title, Artist: yandexTrack.Artists[0].Name, URL: yandexTrack.URL(), Provider: Yandex, + Type: Track, } } -func (a *YandexAdapter) adaptAlbum(yandexAlbum *yandex.Album) *Album { - return &Album{ +func (a *YandexAdapter) adaptAlbum(yandexAlbum *yandex.Album) *Entity { + return &Entity{ ID: strconv.Itoa(yandexAlbum.ID), Title: yandexAlbum.Title, Artist: yandexAlbum.Artists[0].Name, URL: yandexAlbum.URL(), Provider: Yandex, + Type: Album, } } diff --git a/yandex_adapter_test.go b/yandex_adapter_test.go index 2cca09a..3f845bc 100644 --- a/yandex_adapter_test.go +++ b/yandex_adapter_test.go @@ -6,18 +6,19 @@ import ( "time" "github.com/GeorgeGorbanev/streaminx/internal/yandex" + "github.com/stretchr/testify/require" ) type yandexClientMock struct { - getTrack map[string]*yandex.Track - getAlbum map[string]*yandex.Album + fetchTrack map[string]*yandex.Track + fetchAlbum map[string]*yandex.Album searchTrack map[string]map[string]*yandex.Track searchAlbum map[string]map[string]*yandex.Album } -func (c *yandexClientMock) GetTrack(_ context.Context, id string) (*yandex.Track, error) { - return c.getTrack[id], nil +func (c *yandexClientMock) FetchTrack(_ context.Context, id string) (*yandex.Track, error) { + return c.fetchTrack[id], nil } func (c *yandexClientMock) SearchTrack(_ context.Context, artistName, trackName string) (*yandex.Track, error) { @@ -27,8 +28,8 @@ func (c *yandexClientMock) SearchTrack(_ context.Context, artistName, trackName return nil, nil } -func (c *yandexClientMock) GetAlbum(_ context.Context, id string) (*yandex.Album, error) { - return c.getAlbum[id], nil +func (c *yandexClientMock) FetchAlbum(_ context.Context, id string) (*yandex.Album, error) { + return c.fetchAlbum[id], nil } func (c *yandexClientMock) SearchAlbum(_ context.Context, artistName, albumName string) (*yandex.Album, error) { @@ -50,147 +51,18 @@ func (t *translatorMock) Close() error { return nil } -func TestYandexAdapter_DetectTrackID(t *testing.T) { - tests := []struct { - name string - url string - wantID string - expectedError error - }{ - { - name: "Valid Track URL – .com", - url: "https://music.yandex.com/album/3192570/track/1197793", - wantID: "1197793", - }, - { - name: "Valid Track URL – .ru", - url: "https://music.yandex.ru/album/3192570/track/1197793", - wantID: "1197793", - }, - { - name: "Valid Track URL – .by", - url: "https://music.yandex.by/album/3192570/track/1197793", - wantID: "1197793", - }, - { - name: "Valid Track URL – .kz", - url: "https://music.yandex.kz/album/3192570/track/1197793", - wantID: "1197793", - }, - { - name: "Valid Track URL – .uz", - url: "https://music.yandex.uz/album/3192570/track/1197793", - wantID: "1197793", - }, - { - name: "Invalid URL - Missing track ID", - url: "https://music.yandex.ru/album/3192570/track/", - wantID: "", - expectedError: IDNotFoundError, - }, - { - name: "Invalid URL - Non-numeric track ID", - url: "https://music.yandex.ru/album/3192570/track/abc", - wantID: "", - expectedError: IDNotFoundError, - }, - { - name: "Invalid URL - Incorrect format", - url: "https://example.com/album/3192570/track/1197793", - wantID: "", - expectedError: IDNotFoundError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newYandexAdapter(nil, nil) - result, err := adapter.DetectTrackID(tt.url) - require.Equal(t, tt.wantID, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestYandexAdapter_DetectAlbumID(t *testing.T) { - tests := []struct { - name string - url string - wantID string - expectedError error - }{ - { - name: "Valid album URL – .by", - url: "https://music.yandex.by/album/1197793", - wantID: "1197793", - }, - { - name: "Valid album URL – .kz", - url: "https://music.yandex.kz/album/1197793", - wantID: "1197793", - }, - { - name: "Valid album URL – .uz", - url: "https://music.yandex.uz/album/1197793", - wantID: "1197793", - }, - { - name: "Valid album URL – .ru", - url: "https://music.yandex.ru/album/1197793", - wantID: "1197793", - }, - { - name: "Invalid URL - Missing album ID", - url: "https://music.yandex.ru/album/", - wantID: "", - expectedError: IDNotFoundError, - }, - { - name: "Invalid URL - Non-numeric album ID", - url: "https://music.yandex.ru/album/letters", - wantID: "", - expectedError: IDNotFoundError, - }, - { - name: "Invalid URL - Incorrect host", - url: "https://example.com/album/3192570", - wantID: "", - expectedError: IDNotFoundError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newYandexAdapter(nil, nil) - result, err := adapter.DetectAlbumID(tt.url) - require.Equal(t, tt.wantID, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestYandexAdapter_GetTrack(t *testing.T) { +func TestYandexAdapter_FetchTrack(t *testing.T) { tests := []struct { name string id string yandexClientMock yandexClientMock - expectedTrack *Track + expectedTrack *Entity }{ { name: "found ID", id: "42", yandexClientMock: yandexClientMock{ - getTrack: map[string]*yandex.Track{ + fetchTrack: map[string]*yandex.Track{ "42": { ID: 42, Title: "sample name", @@ -203,12 +75,13 @@ func TestYandexAdapter_GetTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "sample artist", URL: "https://music.yandex.com/album/41/track/42", Provider: Yandex, + Type: Track, }, }, { @@ -225,7 +98,7 @@ func TestYandexAdapter_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := a.GetTrack(ctx, tt.id) + result, err := a.FetchTrack(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedTrack, result) @@ -240,7 +113,7 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { searchName string yandexClientMock yandexClientMock translatorMock translatorMock - expectedTrack *Track + expectedTrack *Entity }{ { name: "found query", @@ -262,12 +135,13 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "sample artist", URL: "https://music.yandex.com/album/41/track/42", Provider: Yandex, + Type: Track, }, }, { @@ -312,12 +186,13 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "сампле артист матчинг транслит", URL: "https://music.yandex.com/album/41/track/42", Provider: Yandex, + Type: Track, }, }, { @@ -340,12 +215,13 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "сампле артист афтер транслит", URL: "https://music.yandex.com/album/41/track/42", Provider: Yandex, + Type: Track, }, }, { @@ -373,12 +249,13 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { "translatable artist": "переведенный артист", }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "переведенный артист", URL: "https://music.yandex.com/album/41/track/42", Provider: Yandex, + Type: Track, }, }, { @@ -403,18 +280,18 @@ func TestYandexAdapter_SearchTrack(t *testing.T) { } } -func TestYandexAdapter_GetAlbum(t *testing.T) { +func TestYandexAdapter_FetchAlbum(t *testing.T) { tests := []struct { name string id string yandexClientMock yandexClientMock - expectedTrack *Album + expectedTrack *Entity }{ { name: "found id", id: "42", yandexClientMock: yandexClientMock{ - getAlbum: map[string]*yandex.Album{ + fetchAlbum: map[string]*yandex.Album{ "42": { ID: 42, Title: "sample name", @@ -424,12 +301,13 @@ func TestYandexAdapter_GetAlbum(t *testing.T) { }, }, }, - expectedTrack: &Album{ + expectedTrack: &Entity{ ID: "42", Title: "sample name", Artist: "sample artist", URL: "https://music.yandex.com/album/42", Provider: Yandex, + Type: Album, }, }, { @@ -445,7 +323,7 @@ func TestYandexAdapter_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := a.GetAlbum(ctx, tt.id) + result, err := a.FetchAlbum(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedTrack, result) @@ -460,7 +338,7 @@ func TestYandexAdapter_SearchAlbum(t *testing.T) { searchName string yandexClientMock yandexClientMock translatorMock translatorMock - expectedAlbum *Album + expectedAlbum *Entity }{ { name: "found query", @@ -479,12 +357,13 @@ func TestYandexAdapter_SearchAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "42", Title: "sample name", Artist: "sample artist", URL: "https://music.yandex.com/album/42", Provider: Yandex, + Type: Album, }, }, { @@ -523,12 +402,13 @@ func TestYandexAdapter_SearchAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "42", Title: "sample name", Artist: "сампле артист матчинг транслит", URL: "https://music.yandex.com/album/42", Provider: Yandex, + Type: Album, }, }, { @@ -548,12 +428,13 @@ func TestYandexAdapter_SearchAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "42", Title: "sample name", Artist: "сампле артист афтер транслит", URL: "https://music.yandex.com/album/42", Provider: Yandex, + Type: Album, }, }, { @@ -578,12 +459,13 @@ func TestYandexAdapter_SearchAlbum(t *testing.T) { "translatable artist": "переведенный артист", }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "42", Title: "sample name", Artist: "переведенный артист", URL: "https://music.yandex.com/album/42", Provider: Yandex, + Type: Album, }, }, { diff --git a/youtube_adapter.go b/youtube_adapter.go index 61740ce..9eafb88 100644 --- a/youtube_adapter.go +++ b/youtube_adapter.go @@ -20,22 +20,7 @@ func newYoutubeAdapter(client youtube.Client) *YoutubeAdapter { client: client, } } - -func (a *YoutubeAdapter) DetectTrackID(trackURL string) (string, error) { - if matches := youtube.VideoRe.FindStringSubmatch(trackURL); len(matches) > 1 { - return matches[1], nil - } - return "", IDNotFoundError -} - -func (a *YoutubeAdapter) DetectAlbumID(albumURL string) (string, error) { - if matches := youtube.PlaylistRe.FindStringSubmatch(albumURL); len(matches) > 1 { - return matches[1], nil - } - return "", IDNotFoundError -} - -func (a *YoutubeAdapter) GetTrack(ctx context.Context, id string) (*Track, error) { +func (a *YoutubeAdapter) FetchTrack(ctx context.Context, id string) (*Entity, error) { video, err := a.client.GetVideo(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get video from youtube: %w", err) @@ -46,7 +31,7 @@ func (a *YoutubeAdapter) GetTrack(ctx context.Context, id string) (*Track, error return a.adaptTrack(video), nil } -func (a *YoutubeAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Track, error) { +func (a *YoutubeAdapter) SearchTrack(ctx context.Context, artistName, trackName string) (*Entity, error) { query := fmt.Sprintf("%s – %s", artistName, trackName) video, err := a.client.SearchVideo(ctx, query) if err != nil { @@ -59,7 +44,7 @@ func (a *YoutubeAdapter) SearchTrack(ctx context.Context, artistName, trackName return a.adaptTrack(video), nil } -func (a *YoutubeAdapter) GetAlbum(ctx context.Context, id string) (*Album, error) { +func (a *YoutubeAdapter) FetchAlbum(ctx context.Context, id string) (*Entity, error) { album, err := a.client.GetPlaylist(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get playlist from youtube: %w", err) @@ -70,7 +55,7 @@ func (a *YoutubeAdapter) GetAlbum(ctx context.Context, id string) (*Album, error return a.adaptAlbum(ctx, album) } -func (a *YoutubeAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Album, error) { +func (a *YoutubeAdapter) SearchAlbum(ctx context.Context, artistName, albumName string) (*Entity, error) { query := fmt.Sprintf("%s – %s", artistName, albumName) album, err := a.client.SearchPlaylist(ctx, query) if err != nil { @@ -83,16 +68,17 @@ func (a *YoutubeAdapter) SearchAlbum(ctx context.Context, artistName, albumName return a.adaptAlbum(ctx, album) } -func (a *YoutubeAdapter) adaptTrack(video *youtube.Video) *Track { +func (a *YoutubeAdapter) adaptTrack(video *youtube.Video) *Entity { trackTitle := a.extractTrackTitle(video) artist, track := a.cleanAndSplitTitle(trackTitle) - return &Track{ + return &Entity{ ID: video.ID, Title: track, Artist: artist, URL: video.URL(), Provider: Youtube, + Type: Track, } } @@ -103,7 +89,7 @@ func (a *YoutubeAdapter) extractTrackTitle(video *youtube.Video) string { return video.Title } -func (a *YoutubeAdapter) adaptAlbum(ctx context.Context, playlist *youtube.Playlist) (*Album, error) { +func (a *YoutubeAdapter) adaptAlbum(ctx context.Context, playlist *youtube.Playlist) (*Entity, error) { albumTitle, err := a.extractAlbumTitle(ctx, playlist) if err != nil { return nil, fmt.Errorf("failed to extract album title: %w", err) @@ -111,12 +97,13 @@ func (a *YoutubeAdapter) adaptAlbum(ctx context.Context, playlist *youtube.Playl artist, album := a.cleanAndSplitTitle(albumTitle) - return &Album{ + return &Entity{ ID: playlist.ID, Title: album, Artist: artist, URL: playlist.URL(), Provider: Youtube, + Type: Album, }, nil } diff --git a/youtube_adapter_test.go b/youtube_adapter_test.go index a3eedcd..2afbbd1 100644 --- a/youtube_adapter_test.go +++ b/youtube_adapter_test.go @@ -38,130 +38,12 @@ func (c *youtubeClientMock) GetPlaylistItems(_ context.Context, id string) ([]yo return c.getPlaylistItems[id], nil } -func TestYoutubeAdapter_DetectTrackID(t *testing.T) { - tests := []struct { - name string - input string - expected string - expectedError error - }{ - { - name: "Short URL", - input: "https://youtu.be/dQw4w9WgXcQ", - expected: "dQw4w9WgXcQ", - }, - { - name: "Long URL", - input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - expected: "dQw4w9WgXcQ", - }, - { - name: "URL with extra parameters", - input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be", - expected: "dQw4w9WgXcQ", - }, - { - name: "Youtube music URL", - input: "https://music.youtube.com/watch?v=5PgdZDXg0z0&si=LkthPMI6H_I04dhP", - expected: "5PgdZDXg0z0", - }, - { - name: "Invalid URL", - input: "https://www.youtube.com/watch?v=", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Non-YouTube URL", - input: "https://www.example.com/watch?v=dQw4w9WgXcQ", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Empty string", - input: "", - expected: "", - expectedError: IDNotFoundError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newYoutubeAdapter(nil) - result, err := adapter.DetectTrackID(tt.input) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestYoutubeAdapter_DetectAlbumID(t *testing.T) { - tests := []struct { - name string - input string - expected string - expectedError error - }{ - { - name: "Standard URL", - input: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - }, - { - name: "Shortened URL", - input: "https://youtu.be/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - }, - { - name: "URL with extra parameters", - input: "https://www.youtube.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj&feature=share", - expected: "PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - }, - { - name: "Youtube music URL", - input: "https://music.youtube.com/playlist?list=OLAK5uy_n4xauusTJSj6Mtt4cIuq4KZziSfjABYWU", - expected: "OLAK5uy_n4xauusTJSj6Mtt4cIuq4KZziSfjABYWU", - }, - { - name: "Invalid URL", - input: "https://www.example.com/playlist?list=PLMC9KNkIncKtPzgY-5rmhvj7fax8fdxoj", - expected: "", - expectedError: IDNotFoundError, - }, - { - name: "Empty string", - input: "", - expected: "", - expectedError: IDNotFoundError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := newYoutubeAdapter(nil) - result, err := adapter.DetectAlbumID(tt.input) - require.Equal(t, tt.expected, result) - - if tt.expectedError != nil { - require.ErrorAs(t, err, &tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestYoutubeAdapter_GetTrack(t *testing.T) { +func TestYoutubeAdapter_FetchTrack(t *testing.T) { tests := []struct { name string id string youtubeClientMock youtubeClientMock - expectedTrack *Track + expectedTrack *Entity }{ { name: "found ID", @@ -174,12 +56,13 @@ func TestYoutubeAdapter_GetTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "sampleID", Title: "sample track", Artist: "sample artist", URL: "https://www.youtube.com/watch?v=sampleID", Provider: Youtube, + Type: Track, }, }, { @@ -195,12 +78,13 @@ func TestYoutubeAdapter_GetTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "sampleID", Title: "track name", Artist: "sample artist", URL: "https://www.youtube.com/watch?v=sampleID", Provider: Youtube, + Type: Track, }, }, { @@ -217,7 +101,7 @@ func TestYoutubeAdapter_GetTrack(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := a.GetTrack(ctx, tt.id) + result, err := a.FetchTrack(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedTrack, result) @@ -231,7 +115,7 @@ func TestYoutubeAdapter_SearchTrack(t *testing.T) { artistName string searchName string youtubeClientMock youtubeClientMock - expectedTrack *Track + expectedTrack *Entity }{ { name: "found query", @@ -245,12 +129,13 @@ func TestYoutubeAdapter_SearchTrack(t *testing.T) { }, }, }, - expectedTrack: &Track{ + expectedTrack: &Entity{ ID: "sampleID", Title: "sample track", Artist: "sample artist", URL: "https://www.youtube.com/watch?v=sampleID", Provider: Youtube, + Type: Track, }, }, { @@ -276,12 +161,12 @@ func TestYoutubeAdapter_SearchTrack(t *testing.T) { } } -func TestYoutubeAdapter_GetAlbum(t *testing.T) { +func TestYoutubeAdapter_FetchAlbum(t *testing.T) { tests := []struct { name string id string youtubeClientMock youtubeClientMock - expectedAlbum *Album + expectedAlbum *Entity }{ { name: "found ID", @@ -294,12 +179,13 @@ func TestYoutubeAdapter_GetAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "sampleID", Title: "sample album", Artist: "sample artist", URL: "https://www.youtube.com/playlist?list=sampleID", Provider: Youtube, + Type: Album, }, }, { @@ -323,12 +209,13 @@ func TestYoutubeAdapter_GetAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "sampleID", Title: "sample album", Artist: "sample artist", URL: "https://www.youtube.com/playlist?list=sampleID", Provider: Youtube, + Type: Album, }, }, { @@ -345,12 +232,13 @@ func TestYoutubeAdapter_GetAlbum(t *testing.T) { "sampleID": {}, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "sampleID", Title: "sample album", Artist: "Album", URL: "https://www.youtube.com/playlist?list=sampleID", Provider: Youtube, + Type: Album, }, }, { @@ -374,12 +262,13 @@ func TestYoutubeAdapter_GetAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "sampleID", Title: "sample album", Artist: "Album", URL: "https://www.youtube.com/playlist?list=sampleID", Provider: Youtube, + Type: Album, }, }, { @@ -396,7 +285,7 @@ func TestYoutubeAdapter_GetAlbum(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - result, err := a.GetAlbum(ctx, tt.id) + result, err := a.FetchAlbum(ctx, tt.id) require.NoError(t, err) require.Equal(t, tt.expectedAlbum, result) @@ -410,7 +299,7 @@ func TestYoutubeAdapter_SearchAlbum(t *testing.T) { artistName string searchName string youtubeClientMock youtubeClientMock - expectedAlbum *Album + expectedAlbum *Entity }{ { name: "found query", @@ -424,12 +313,13 @@ func TestYoutubeAdapter_SearchAlbum(t *testing.T) { }, }, }, - expectedAlbum: &Album{ + expectedAlbum: &Entity{ ID: "sampleID", Title: "sample album", Artist: "sample artist", URL: "https://www.youtube.com/playlist?list=sampleID", Provider: Youtube, + Type: Album, }, }, {