spots-cli 0.2.0__tar.gz → 0.2.1__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.1}/PKG-INFO +1 -1
  2. {spots_cli-0.2.0 → spots_cli-0.2.1}/pyproject.toml +1 -1
  3. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/domain_resolver.py +4 -3
  4. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/media_resolver.py +30 -28
  5. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/pattern_matcher.py +8 -3
  6. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/retry.py +16 -3
  7. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/metadata.py +2 -0
  8. spots_cli-0.2.1/tests/app/test_media_resolver.py +80 -0
  9. spots_cli-0.2.1/tests/core/test_pattern_matcher.py +52 -0
  10. {spots_cli-0.2.0 → spots_cli-0.2.1}/.gitignore +0 -0
  11. {spots_cli-0.2.0 → spots_cli-0.2.1}/LICENSE +0 -0
  12. {spots_cli-0.2.0 → spots_cli-0.2.1}/README.md +0 -0
  13. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/__init__.py +0 -0
  14. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/__init__.py +0 -0
  15. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/downloader.py +0 -0
  16. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
  17. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/youtube_user_playlist.py +0 -0
  18. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/bootstrap/__init__.py +0 -0
  19. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/bootstrap/container.py +0 -0
  20. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/cli.py +0 -0
  21. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/__init__.py +0 -0
  22. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/deezer.py +0 -0
  23. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/secrets_manager.py +0 -0
  24. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/spotify.py +0 -0
  25. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/youtube.py +0 -0
  26. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/ytdl.py +0 -0
  27. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/config/__init__.py +0 -0
  28. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/__init__.py +0 -0
  29. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/history_manager.py +0 -0
  30. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/lyrics_finder.py +0 -0
  31. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/video_converter.py +0 -0
  32. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/web_scraper.py +0 -0
  33. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/youtube_extractor.py +0 -0
  34. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/__init__.py +0 -0
  35. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/file_storage.py +0 -0
  36. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/persistence_model.py +0 -0
  37. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/integrations/__init__.py +0 -0
  38. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
  39. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/__init__.py +0 -0
  40. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/artist_metadata.py +0 -0
  41. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/errors.py +0 -0
  42. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/helper_models.py +0 -0
  43. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/media_resource.py +0 -0
  44. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/metadata_provider.py +0 -0
  45. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/playlist_info.py +0 -0
  46. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/search_provider.py +0 -0
  47. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/sentinel.py +0 -0
  48. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/unavailable_video.py +0 -0
  49. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/video_resolution.py +0 -0
  50. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/yt_video_info.py +0 -0
  51. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/__init__.py +0 -0
  52. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/add_to_history.py +0 -0
  53. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/bootstrap.py +0 -0
  54. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
  55. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/__init__.py +0 -0
  56. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/deezer_metadata_service.py +0 -0
  57. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/deezer_search_service.py +0 -0
  58. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/provider_search_container.py +0 -0
  59. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/providers_metadata.py +0 -0
  60. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/spotify_metadata_service.py +0 -0
  61. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/spotify_search_service.py +0 -0
  62. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/youtube_metadata_service.py +0 -0
  63. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/youtube_search_service.py +0 -0
  64. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/setup_env.py +0 -0
  65. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/__init__.py +0 -0
  66. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/config_dir.py +0 -0
  67. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/detect_browser.py +0 -0
  68. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/detect_js_runtimes.py +0 -0
  69. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/fetch.py +0 -0
  70. {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/search_fallbacks.py +0 -0
  71. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/api/test_download_video.py +0 -0
  72. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/app/test_downloader.py +0 -0
  73. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_deezer.py +0 -0
  74. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_secrets_manager.py +0 -0
  75. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_spotify.py +0 -0
  76. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_youtube.py +0 -0
  77. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_ytdl.py +0 -0
  78. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/conftest.py +0 -0
  79. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/test_cli.py +0 -0
  80. {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/test_spots.py +0 -0
  81. {spots_cli-0.2.0 → spots_cli-0.2.1}/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.1
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.1"
8
8
  authors = [
9
9
  { name="Marlon Baatjes", email="marlonbaatjes62@gmail.com" }
10
10
  ]
@@ -27,7 +27,7 @@ class ResolvedDomainResult:
27
27
  youtube: YTVideoInfo
28
28
 
29
29
 
30
- def _fallback_metadata(video: YTVideoInfo) -> Metadata:
30
+ def fallback_metadata(video: YTVideoInfo) -> Metadata:
31
31
  """Builds a bare-bones Metadata object straight from a YouTube result.
32
32
 
33
33
  Used when no provider (Spotify/Deezer) has metadata for a track, or the
@@ -41,6 +41,7 @@ def _fallback_metadata(video: YTVideoInfo) -> Metadata:
41
41
  artist=video.uploader,
42
42
  link=video.video_link,
43
43
  artist_id="",
44
+ is_fallback=True,
44
45
  )
45
46
 
46
47
 
@@ -96,7 +97,7 @@ class DomainResolver:
96
97
  f"No provider metadata for '{video_title}', "
97
98
  "falling back to YouTube-derived metadata"
98
99
  )
99
- fallback = _fallback_metadata(video)
100
+ fallback = fallback_metadata(video)
100
101
  self.core.storage.new(
101
102
  query=video_title, result=fallback, query_type="metadata"
102
103
  )
@@ -117,7 +118,7 @@ class DomainResolver:
117
118
  f"Provider metadata for '{video_title}' didn't match, "
118
119
  "falling back to YouTube-derived metadata"
119
120
  )
120
- fallback = _fallback_metadata(video)
121
+ fallback = fallback_metadata(video)
121
122
  self.core.storage.new(
122
123
  query=video_title, result=fallback, query_type="metadata"
123
124
  )
@@ -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,33 @@ 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"
208
- )
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,
209
+ ):
210
+ logger.debug(
211
+ f"No matching provider metadata for '{yt_title}', "
212
+ "falling back to YouTube-derived metadata"
224
213
  )
214
+ metadata = fallback_metadata(video_info.result)
215
+
216
+ self.core.storage.new(
217
+ query=yt_title, result=metadata, query_type="metadata"
218
+ )
219
+ self.core.storage.new(
220
+ query=yt_title, result=video_info.result, query_type="youtube"
221
+ )
222
+ return MediaResourceSingle(
223
+ resource_type="single",
224
+ metadata=metadata,
225
+ video_info=video_info.result,
226
+ )
225
227
  else:
226
228
  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
 
@@ -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,80 @@
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()
@@ -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