spots-cli 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {spots_cli-0.2.0 → spots_cli-0.2.2}/PKG-INFO +1 -1
  2. {spots_cli-0.2.0 → spots_cli-0.2.2}/pyproject.toml +1 -1
  3. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/domain_resolver.py +10 -6
  4. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/media_resolver.py +31 -27
  5. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/pattern_matcher.py +8 -3
  6. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/youtube_extractor.py +12 -7
  7. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/retry.py +16 -3
  8. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/metadata.py +2 -0
  9. spots_cli-0.2.2/tests/app/test_media_resolver.py +95 -0
  10. spots_cli-0.2.2/tests/core/test_pattern_matcher.py +52 -0
  11. {spots_cli-0.2.0 → spots_cli-0.2.2}/.gitignore +0 -0
  12. {spots_cli-0.2.0 → spots_cli-0.2.2}/LICENSE +0 -0
  13. {spots_cli-0.2.0 → spots_cli-0.2.2}/README.md +0 -0
  14. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/__init__.py +0 -0
  15. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/__init__.py +0 -0
  16. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/downloader.py +0 -0
  17. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
  18. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/youtube_user_playlist.py +0 -0
  19. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/bootstrap/__init__.py +0 -0
  20. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/bootstrap/container.py +0 -0
  21. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/cli.py +0 -0
  22. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/__init__.py +0 -0
  23. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/deezer.py +0 -0
  24. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/secrets_manager.py +0 -0
  25. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/spotify.py +0 -0
  26. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/youtube.py +0 -0
  27. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/ytdl.py +0 -0
  28. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/config/__init__.py +0 -0
  29. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/__init__.py +0 -0
  30. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/history_manager.py +0 -0
  31. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/lyrics_finder.py +0 -0
  32. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/video_converter.py +0 -0
  33. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/web_scraper.py +0 -0
  34. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/__init__.py +0 -0
  35. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/file_storage.py +0 -0
  36. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/persistence_model.py +0 -0
  37. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/integrations/__init__.py +0 -0
  38. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
  39. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/__init__.py +0 -0
  40. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/artist_metadata.py +0 -0
  41. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/errors.py +0 -0
  42. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/helper_models.py +0 -0
  43. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/media_resource.py +0 -0
  44. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/metadata_provider.py +0 -0
  45. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/playlist_info.py +0 -0
  46. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/search_provider.py +0 -0
  47. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/sentinel.py +0 -0
  48. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/unavailable_video.py +0 -0
  49. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/video_resolution.py +0 -0
  50. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/yt_video_info.py +0 -0
  51. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/__init__.py +0 -0
  52. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/add_to_history.py +0 -0
  53. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/bootstrap.py +0 -0
  54. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
  55. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/__init__.py +0 -0
  56. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/deezer_metadata_service.py +0 -0
  57. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/deezer_search_service.py +0 -0
  58. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/provider_search_container.py +0 -0
  59. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/providers_metadata.py +0 -0
  60. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/spotify_metadata_service.py +0 -0
  61. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/spotify_search_service.py +0 -0
  62. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/youtube_metadata_service.py +0 -0
  63. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/youtube_search_service.py +0 -0
  64. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/setup_env.py +0 -0
  65. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/__init__.py +0 -0
  66. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/config_dir.py +0 -0
  67. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/detect_browser.py +0 -0
  68. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/detect_js_runtimes.py +0 -0
  69. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/fetch.py +0 -0
  70. {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/search_fallbacks.py +0 -0
  71. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/api/test_download_video.py +0 -0
  72. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/app/test_downloader.py +0 -0
  73. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_deezer.py +0 -0
  74. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_secrets_manager.py +0 -0
  75. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_spotify.py +0 -0
  76. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_youtube.py +0 -0
  77. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_ytdl.py +0 -0
  78. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/conftest.py +0 -0
  79. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/test_cli.py +0 -0
  80. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/test_spots.py +0 -0
  81. {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/utils/test_detect_js_runtimes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spots_cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Spots is a modular music coordination library that unifies multiple providers (e.g. Deezer, Spotify, YouTube) into a single, consistent interface.
5
5
  Project-URL: Homepage, https://github.com/mdesignscode/spots_flask
6
6
  Project-URL: Issues, https://github.com/mdesignscode/spots_flask
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "spots_cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  authors = [
9
9
  { name="Marlon Baatjes", email="marlonbaatjes62@gmail.com" }
10
10
  ]
@@ -8,6 +8,7 @@ from spots_cli.models import Metadata, SongNotFound, Sentinel, YTVideoInfo
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from spots_cli.bootstrap.container import Core
11
+ from spots_cli.core import YouTubeExtractor
11
12
  from spots_cli.models import SearchProvider
12
13
  from spots_cli.services import YoutubeSearchService
13
14
 
@@ -27,8 +28,9 @@ class ResolvedDomainResult:
27
28
  youtube: YTVideoInfo
28
29
 
29
30
 
30
- def _fallback_metadata(video: YTVideoInfo) -> Metadata:
31
- """Builds a bare-bones Metadata object straight from a YouTube result.
31
+ def fallback_metadata(video: YTVideoInfo, extractor: YouTubeExtractor) -> Metadata:
32
+ """Builds a bare-bones Metadata object straight from a YouTube result,
33
+ with the artist and title cleaned of video noise like "(Official Video)".
32
34
 
33
35
  Used when no provider (Spotify/Deezer) has metadata for a track, or the
34
36
  provider's result doesn't match. The provider is only used to prettify
@@ -36,11 +38,13 @@ def _fallback_metadata(video: YTVideoInfo) -> Metadata:
36
38
  from YouTube, so a missing/mismatched provider entry should never cause
37
39
  a track to be dropped as long as it exists on YouTube.
38
40
  """
41
+ cleaned = extractor.clean_artist_and_title(video.uploader, video.title)
39
42
  return Metadata(
40
- title=video.title,
41
- artist=video.uploader,
43
+ title=cleaned.title or video.title,
44
+ artist=cleaned.artist or video.uploader,
42
45
  link=video.video_link,
43
46
  artist_id="",
47
+ is_fallback=True,
44
48
  )
45
49
 
46
50
 
@@ -96,7 +100,7 @@ class DomainResolver:
96
100
  f"No provider metadata for '{video_title}', "
97
101
  "falling back to YouTube-derived metadata"
98
102
  )
99
- fallback = _fallback_metadata(video)
103
+ fallback = fallback_metadata(video, self.core.extractor)
100
104
  self.core.storage.new(
101
105
  query=video_title, result=fallback, query_type="metadata"
102
106
  )
@@ -117,7 +121,7 @@ class DomainResolver:
117
121
  f"Provider metadata for '{video_title}' didn't match, "
118
122
  "falling back to YouTube-derived metadata"
119
123
  )
120
- fallback = _fallback_metadata(video)
124
+ fallback = fallback_metadata(video, self.core.extractor)
121
125
  self.core.storage.new(
122
126
  query=video_title, result=fallback, query_type="metadata"
123
127
  )
@@ -4,10 +4,12 @@ from logging import getLogger
4
4
  from tenacity import stop_after_delay
5
5
  from typing import TYPE_CHECKING, Any, cast
6
6
 
7
+ from spots_cli.app.domain_resolver import fallback_metadata
7
8
  from spots_cli.engine import retry
8
9
  from spots_cli.models import (
9
10
  InvalidURL,
10
11
  SongNotFound,
12
+ SpotifyUnavailableError,
11
13
  MediaResourceSingle,
12
14
  MediaResourcePlaylist,
13
15
  PlaylistInfo,
@@ -50,6 +52,13 @@ class MediaResolver:
50
52
  """
51
53
  # process spotify link
52
54
  if "spotify" in url:
55
+ # a Spotify id means nothing to the other providers
56
+ if not self.clients.spotify:
57
+ raise SpotifyUnavailableError(
58
+ "Spotify links need Spotify turned on. Run `spots_cli setup` "
59
+ "and enable Spotify, or paste the YouTube link instead."
60
+ )
61
+
53
62
  # single
54
63
  if "track" in url:
55
64
  # retrieve Provider data
@@ -187,40 +196,35 @@ class MediaResolver:
187
196
  yt_title = self.core.extractor.build_search_query(
188
197
  video_info.result.uploader, video_info.result.title
189
198
  )
199
+ # the user picked this video, so it is downloaded either way;
200
+ # the provider only adds tags (cover, album, lyrics)
190
201
  try:
191
202
  metadata = self.domain.provider_search.search_track(
192
203
  yt_title)
193
204
  except SongNotFound:
194
- self.core.storage.new(
195
- query=yt_title, result=Sentinel(), query_type="metadata"
196
- )
197
- self.core.storage.new(
198
- query=yt_title, result=Sentinel(), query_type="youtube"
199
- )
200
- raise
205
+ metadata = None
201
206
 
202
- tracks_match = self.core.matcher.match_tracks(
207
+ if metadata is None or not self.core.matcher.match_tracks(
203
208
  metadata=metadata, video_info=video_info.result
204
- )
205
- if not tracks_match:
206
- self.core.storage.new(
207
- query=yt_title, result=Sentinel(), query_type="metadata"
209
+ ):
210
+ logger.debug(
211
+ f"No matching provider metadata for '{yt_title}', "
212
+ "falling back to YouTube-derived metadata"
208
213
  )
209
- self.core.storage.new(
210
- query=yt_title, result=Sentinel(), query_type="youtube"
211
- )
212
- raise SongNotFound(yt_title)
213
- else:
214
- self.core.storage.new(
215
- query=yt_title, result=metadata, query_type="metadata"
216
- )
217
- self.core.storage.new(
218
- query=yt_title, result=video_info.result, query_type="youtube"
219
- )
220
- return MediaResourceSingle(
221
- resource_type="single",
222
- metadata=metadata,
223
- video_info=video_info.result,
214
+ metadata = fallback_metadata(
215
+ video_info.result, self.core.extractor
224
216
  )
217
+
218
+ self.core.storage.new(
219
+ query=yt_title, result=metadata, query_type="metadata"
220
+ )
221
+ self.core.storage.new(
222
+ query=yt_title, result=video_info.result, query_type="youtube"
223
+ )
224
+ return MediaResourceSingle(
225
+ resource_type="single",
226
+ metadata=metadata,
227
+ video_info=video_info.result,
228
+ )
225
229
  else:
226
230
  raise InvalidURL(url)
@@ -141,8 +141,13 @@ class PatternMatcher:
141
141
  self.extractor = extractor
142
142
 
143
143
  def match_tracks(self, *, video_info: YTVideoInfo, metadata: Metadata) -> bool:
144
- yt_artist = video_info.uploader or ""
145
- yt_title = video_info.title or ""
144
+ # compare the song, not the raw upload: "Tyla - Water (Official
145
+ # Music Video)" by Tyla is the song "Water" by "Tyla"
146
+ cleaned = self.extractor.extract_artist_and_title(
147
+ video_info=video_info, metadata=metadata
148
+ )
149
+ yt_artist = cleaned.artist or video_info.uploader or ""
150
+ yt_title = cleaned.title or video_info.title or ""
146
151
 
147
152
  sp_artist = metadata.artist or ""
148
153
  sp_title = metadata.title or ""
@@ -164,7 +169,7 @@ class PatternMatcher:
164
169
  title_score = token_similarity(yt_title_tokens, sp_title_tokens)
165
170
  artist_score = token_similarity(yt_artist_tokens, sp_artist_tokens)
166
171
 
167
- penalty = uploader_penalty(yt_artist)
172
+ penalty = uploader_penalty(video_info.uploader or "")
168
173
 
169
174
  final_score = 0.6 * title_score + 0.3 * artist_score - penalty
170
175
 
@@ -166,15 +166,15 @@ class YouTubeExtractor:
166
166
  artist=youtube_artist.strip(), title=youtube_title.strip()
167
167
  )
168
168
 
169
- def build_search_query(self, uploader: str, title: str) -> str:
170
- """Cleans noisy YouTube uploader/title fields into a search-friendly
171
- 'Artist - Title' string for provider lookups (Deezer/Spotify).
169
+ def clean_artist_and_title(self, uploader: str, title: str) -> ArtistAndTitle:
170
+ """Cleans noisy YouTube uploader/title fields into an artist and a title.
172
171
 
173
- Strips common suffixes like "(Official Video)" and de-duplicates the
174
- uploader name when it's already repeated at the start of the title:
172
+ Strips common suffixes like "(Official Video)" and a trailing "VEVO",
173
+ and de-duplicates the uploader name when it's already repeated at the
174
+ start of the title:
175
175
 
176
176
  uploader="Tinashe", title="Tinashe - Needs (Official Video)"
177
- -> "Tinashe - Needs"
177
+ -> artist="Tinashe", title="Needs"
178
178
  """
179
179
  uploader = self.remove_odd_keywords((uploader or "").strip())
180
180
  uploader = sub(r"vevo$", "", uploader, flags=IGNORECASE).strip()
@@ -192,4 +192,9 @@ class YouTubeExtractor:
192
192
  if deduped_title:
193
193
  title = deduped_title
194
194
 
195
- return f"{uploader} - {title}".strip(" -")
195
+ return ArtistAndTitle(artist=uploader, title=title)
196
+
197
+ def build_search_query(self, uploader: str, title: str) -> str:
198
+ """The cleaned 'Artist - Title' string for provider lookups (Deezer/Spotify)."""
199
+ cleaned = self.clean_artist_and_title(uploader, title)
200
+ return f"{cleaned.artist} - {cleaned.title}".strip(" -")
@@ -1,18 +1,31 @@
1
1
  from tenacity import retry as _retry
2
2
  from tenacity import retry_if_exception
3
3
 
4
- from spots_cli.models import SongNotFound
4
+ from spots_cli.models import (
5
+ InvalidURL,
6
+ SongNotFound,
7
+ SpotifyUnavailableError,
8
+ YouTubeUnavailableError,
9
+ )
10
+
11
+ # not transient: retrying can't change the answer
12
+ PERMANENT_ERRORS = (
13
+ InvalidURL,
14
+ SongNotFound,
15
+ SpotifyUnavailableError,
16
+ YouTubeUnavailableError,
17
+ )
5
18
 
6
19
 
7
20
  def is_retryable_exception(exc: BaseException) -> bool:
8
21
  # Only retry transient failures
9
- return not isinstance(exc, SongNotFound)
22
+ return not isinstance(exc, PERMANENT_ERRORS)
10
23
 
11
24
 
12
25
  def retry(*args, **kwargs):
13
26
  """
14
27
  Project-wide retry decorator.
15
- - Never retries SongNotFound
28
+ - Never retries PERMANENT_ERRORS
16
29
  - Always re-raises the final exception
17
30
  """
18
31
 
@@ -17,6 +17,7 @@ class Metadata:
17
17
  preview_url (str | None, optional): A link to a 30 second preview (MP3 format) of the track. Defaults to None.
18
18
  artist_cover (str | None, optional): The artist's cover image. Defauls to None.
19
19
  artist_id (str): The artist's id.
20
+ is_fallback (bool): True when no provider had this song, so the tags come from the YouTube title. Defaults to False.
20
21
  """
21
22
 
22
23
  title: str
@@ -30,6 +31,7 @@ class Metadata:
30
31
  release_date: str | None = None
31
32
  preview_url: str | None = None
32
33
  artist_cover: str | None = None
34
+ is_fallback: bool = False
33
35
 
34
36
  @property
35
37
  def full_title(self):
@@ -0,0 +1,95 @@
1
+ from types import SimpleNamespace
2
+ from unittest.mock import Mock
3
+
4
+ import pytest
5
+
6
+ from spots_cli.app.media_resolver import MediaResolver
7
+ from spots_cli.core.pattern_matcher import PatternMatcher
8
+ from spots_cli.core.youtube_extractor import YouTubeExtractor
9
+ from spots_cli.models import Metadata, SongNotFound, SpotifyUnavailableError, YTVideoInfo
10
+
11
+ LINK = "https://www.youtube.com/watch?v=XoiOOiuH8iI"
12
+
13
+
14
+ @pytest.fixture
15
+ def upload() -> YTVideoInfo:
16
+ return YTVideoInfo(
17
+ id="XoiOOiuH8iI",
18
+ title="Tyla - Water (Official Music Video)",
19
+ uploader="Tyla",
20
+ audio_ext="webm",
21
+ filesize=4,
22
+ )
23
+
24
+
25
+ def build_resolver(upload, *, provider_result=None, provider_error=None, spotify=None):
26
+ extractor = YouTubeExtractor()
27
+ core = Mock(extractor=extractor, matcher=PatternMatcher(extractor=extractor))
28
+ domain = Mock()
29
+ domain.youtube_search.video_search.return_value = SimpleNamespace(
30
+ is_cached=False, result=upload
31
+ )
32
+ if provider_error:
33
+ domain.provider_search.search_track.side_effect = provider_error
34
+ else:
35
+ domain.provider_search.search_track.return_value = provider_result
36
+ clients = Mock(spotify=spotify)
37
+ return MediaResolver(core=core, domain=domain, clients=clients, domain_resolver=Mock())
38
+
39
+
40
+ def song(title: str, artist: str) -> Metadata:
41
+ return Metadata(title=title, artist=artist, link="", artist_id="", album=title)
42
+
43
+
44
+ def test_youtube_link_uses_matching_provider_metadata(upload):
45
+ resolver = build_resolver(upload, provider_result=song("Water", "Tyla"))
46
+
47
+ resource = resolver.resolve(url=LINK)
48
+
49
+ assert resource.metadata.album == "Water"
50
+ assert not resource.metadata.is_fallback
51
+ assert resource.video_info is upload
52
+
53
+
54
+ def test_youtube_link_without_provider_metadata_still_resolves(upload):
55
+ resolver = build_resolver(upload, provider_error=SongNotFound("Tyla - Water"))
56
+
57
+ resource = resolver.resolve(url=LINK)
58
+
59
+ assert resource.metadata.is_fallback
60
+ assert resource.metadata.artist == "Tyla"
61
+ assert resource.video_info is upload
62
+
63
+
64
+ def test_youtube_link_with_mismatched_provider_metadata_falls_back(upload):
65
+ resolver = build_resolver(upload, provider_result=song("Truth or Dare", "Tyla"))
66
+
67
+ resource = resolver.resolve(url=LINK)
68
+
69
+ assert resource.metadata.is_fallback
70
+ assert resource.metadata.title != "Truth or Dare"
71
+
72
+
73
+ def test_spotify_link_needs_spotify_turned_on(upload):
74
+ resolver = build_resolver(upload)
75
+
76
+ with pytest.raises(SpotifyUnavailableError, match="spots_cli setup"):
77
+ resolver.resolve(url="https://open.spotify.com/track/5aIVCx5tnk0ntmdiinnYvw")
78
+
79
+ # not retried, and the Spotify id never reaches another provider
80
+ resolver.domain.provider_metadata.get.assert_not_called()
81
+
82
+
83
+ def test_fallback_tags_drop_the_repeated_artist_and_video_tags():
84
+ upload = YTVideoInfo(
85
+ id="kSBQZH3dkZ8",
86
+ title="Tyla - THAT GIRL (Biuro Version) (Official Video)",
87
+ uploader="TylaVEVO",
88
+ audio_ext="webm",
89
+ filesize=4,
90
+ )
91
+ resolver = build_resolver(upload, provider_error=SongNotFound("Tyla - THAT GIRL"))
92
+
93
+ metadata = resolver.resolve(url=LINK).metadata
94
+
95
+ assert (metadata.artist, metadata.title) == ("Tyla", "THAT GIRL (Biuro Version)")
@@ -0,0 +1,52 @@
1
+ import pytest
2
+
3
+ from spots_cli.core.pattern_matcher import PatternMatcher
4
+ from spots_cli.core.youtube_extractor import YouTubeExtractor
5
+ from spots_cli.models import Metadata, YTVideoInfo
6
+
7
+
8
+ @pytest.fixture
9
+ def matcher() -> PatternMatcher:
10
+ return PatternMatcher(extractor=YouTubeExtractor())
11
+
12
+
13
+ def song(title: str, artist: str) -> Metadata:
14
+ return Metadata(title=title, artist=artist, link="", artist_id="")
15
+
16
+
17
+ def video(title: str, uploader: str) -> YTVideoInfo:
18
+ return YTVideoInfo(id="x", title=title, uploader=uploader, audio_ext="webm", filesize=4)
19
+
20
+
21
+ @pytest.mark.parametrize(
22
+ "title, uploader",
23
+ [
24
+ ("Tyla - Water (Official Music Video)", "Tyla"),
25
+ ("Tyla - Water (Official Audio)", "Tyla"),
26
+ ("Tyla - Water", "LatinHype"),
27
+ ("Water", "Tyla"),
28
+ ],
29
+ )
30
+ def test_matches_raw_youtube_titles(matcher, title, uploader):
31
+ assert matcher.match_tracks(video_info=video(title, uploader), metadata=song("Water", "Tyla"))
32
+
33
+
34
+ def test_rejects_a_different_song_by_the_same_artist(matcher):
35
+ raw = video("Tyla - Truth or Dare (Official Video)", "Tyla")
36
+ assert not matcher.match_tracks(video_info=raw, metadata=song("Water", "Tyla"))
37
+
38
+
39
+ def test_does_not_change_the_video(matcher):
40
+ raw = video("Tyla - Water (Official Music Video)", "Tyla")
41
+ matcher.match_tracks(video_info=raw, metadata=song("Water", "Tyla"))
42
+ assert (raw.title, raw.uploader) == ("Tyla - Water (Official Music Video)", "Tyla")
43
+
44
+
45
+ def test_song_named_after_the_artist(matcher):
46
+ raw = video("Tyla - Tyla (Official Video)", "Tyla")
47
+ assert matcher.match_tracks(video_info=raw, metadata=song("Tyla", "Tyla"))
48
+
49
+
50
+ def test_karaoke_channels_are_still_penalised(matcher):
51
+ raw = video("Tyla - Water (Karaoke Version)", "Sing King Karaoke")
52
+ assert not matcher.match_tracks(video_info=raw, metadata=song("Water", "Tyla"))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes