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.
- {spots_cli-0.2.0 → spots_cli-0.2.2}/PKG-INFO +1 -1
- {spots_cli-0.2.0 → spots_cli-0.2.2}/pyproject.toml +1 -1
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/domain_resolver.py +10 -6
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/media_resolver.py +31 -27
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/pattern_matcher.py +8 -3
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/youtube_extractor.py +12 -7
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/retry.py +16 -3
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/metadata.py +2 -0
- spots_cli-0.2.2/tests/app/test_media_resolver.py +95 -0
- spots_cli-0.2.2/tests/core/test_pattern_matcher.py +52 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/.gitignore +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/LICENSE +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/README.md +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/downloader.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/app/youtube_user_playlist.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/bootstrap/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/bootstrap/container.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/cli.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/deezer.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/secrets_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/spotify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/youtube.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/clients/ytdl.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/config/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/history_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/lyrics_finder.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/video_converter.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/core/web_scraper.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/file_storage.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/engine/persistence_model.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/integrations/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/artist_metadata.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/errors.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/helper_models.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/media_resource.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/metadata_provider.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/playlist_info.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/search_provider.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/sentinel.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/unavailable_video.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/video_resolution.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/models/yt_video_info.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/add_to_history.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/bootstrap.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/deezer_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/deezer_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/provider_search_container.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/providers_metadata.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/spotify_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/spotify_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/youtube_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/services/youtube_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/setup_env.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/config_dir.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/detect_browser.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/detect_js_runtimes.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/fetch.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/utils/search_fallbacks.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/api/test_download_video.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/app/test_downloader.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_deezer.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_secrets_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_spotify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_youtube.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/clients/test_ytdl.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/conftest.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/test_cli.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.2}/tests/test_spots.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
207
|
+
if metadata is None or not self.core.matcher.match_tracks(
|
|
203
208
|
metadata=metadata, video_info=video_info.result
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
):
|
|
210
|
+
logger.debug(
|
|
211
|
+
f"No matching provider metadata for '{yt_title}', "
|
|
212
|
+
"falling back to YouTube-derived metadata"
|
|
208
213
|
)
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
|
170
|
-
"""Cleans noisy YouTube uploader/title fields into a
|
|
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
|
|
174
|
-
uploader name when it's already repeated at the
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spots_cli-0.2.0 → spots_cli-0.2.2}/src/spots_cli/integrations/spotify_user_playlist_modify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|