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.
- {spots_cli-0.2.0 → spots_cli-0.2.1}/PKG-INFO +1 -1
- {spots_cli-0.2.0 → spots_cli-0.2.1}/pyproject.toml +1 -1
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/domain_resolver.py +4 -3
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/media_resolver.py +30 -28
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/pattern_matcher.py +8 -3
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/retry.py +16 -3
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/metadata.py +2 -0
- spots_cli-0.2.1/tests/app/test_media_resolver.py +80 -0
- spots_cli-0.2.1/tests/core/test_pattern_matcher.py +52 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/.gitignore +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/LICENSE +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/README.md +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/downloader.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/app/youtube_user_playlist.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/bootstrap/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/bootstrap/container.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/cli.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/deezer.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/secrets_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/spotify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/youtube.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/clients/ytdl.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/config/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/history_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/lyrics_finder.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/video_converter.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/web_scraper.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/core/youtube_extractor.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/file_storage.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/engine/persistence_model.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/integrations/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/artist_metadata.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/errors.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/helper_models.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/media_resource.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/metadata_provider.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/playlist_info.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/search_provider.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/sentinel.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/unavailable_video.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/video_resolution.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/models/yt_video_info.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/add_to_history.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/bootstrap.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/deezer_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/deezer_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/provider_search_container.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/providers_metadata.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/spotify_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/spotify_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/youtube_metadata_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/services/youtube_search_service.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/setup_env.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/__init__.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/config_dir.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/detect_browser.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/detect_js_runtimes.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/fetch.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/src/spots_cli/utils/search_fallbacks.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/api/test_download_video.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/app/test_downloader.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_deezer.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_secrets_manager.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_spotify.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_youtube.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/clients/test_ytdl.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/conftest.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/test_cli.py +0 -0
- {spots_cli-0.2.0 → spots_cli-0.2.1}/tests/test_spots.py +0 -0
- {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.
|
|
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
|
|
@@ -27,7 +27,7 @@ class ResolvedDomainResult:
|
|
|
27
27
|
youtube: YTVideoInfo
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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,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
|
|
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.1}/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
|