spots-cli 0.1.0__py3-none-any.whl
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/__init__.py +125 -0
- spots_cli/app/__init__.py +5 -0
- spots_cli/app/domain_resolver.py +157 -0
- spots_cli/app/downloader.py +78 -0
- spots_cli/app/media_resolver.py +180 -0
- spots_cli/app/spotify_playlist_compilation.py +251 -0
- spots_cli/app/youtube_user_playlist.py +215 -0
- spots_cli/bootstrap/__init__.py +1 -0
- spots_cli/bootstrap/container.py +230 -0
- spots_cli/cli.py +42 -0
- spots_cli/clients/__init__.py +5 -0
- spots_cli/clients/deezer.py +80 -0
- spots_cli/clients/secrets_manager.py +25 -0
- spots_cli/clients/spotify.py +79 -0
- spots_cli/clients/youtube.py +66 -0
- spots_cli/clients/ytdl.py +72 -0
- spots_cli/config/__init__.py +0 -0
- spots_cli/core/__init__.py +6 -0
- spots_cli/core/history_manager.py +39 -0
- spots_cli/core/lyrics_finder.py +58 -0
- spots_cli/core/pattern_matcher.py +226 -0
- spots_cli/core/video_converter.py +146 -0
- spots_cli/core/web_scraper.py +82 -0
- spots_cli/core/youtube_extractor.py +162 -0
- spots_cli/engine/__init__.py +3 -0
- spots_cli/engine/file_storage.py +347 -0
- spots_cli/engine/persistence_model.py +3 -0
- spots_cli/engine/retry.py +34 -0
- spots_cli/integrations/__init__.py +1 -0
- spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
- spots_cli/models/__init__.py +24 -0
- spots_cli/models/artist_metadata.py +23 -0
- spots_cli/models/errors.py +69 -0
- spots_cli/models/helper_models.py +25 -0
- spots_cli/models/media_resource.py +20 -0
- spots_cli/models/metadata.py +36 -0
- spots_cli/models/metadata_provider.py +52 -0
- spots_cli/models/playlist_info.py +27 -0
- spots_cli/models/search_provider.py +65 -0
- spots_cli/models/sentinel.py +4 -0
- spots_cli/models/yt_video_info.py +29 -0
- spots_cli/scripts/__init__.py +2 -0
- spots_cli/scripts/add_to_history.py +45 -0
- spots_cli/scripts/bootstrap.py +92 -0
- spots_cli/scripts/duplicate_songs_remover.py +54 -0
- spots_cli/services/__init__.py +8 -0
- spots_cli/services/deezer_metadata_service.py +108 -0
- spots_cli/services/deezer_search_service.py +132 -0
- spots_cli/services/provider_search_container.py +67 -0
- spots_cli/services/providers_metadata.py +22 -0
- spots_cli/services/spotify_metadata_service.py +151 -0
- spots_cli/services/spotify_search_service.py +228 -0
- spots_cli/services/youtube_metadata_service.py +73 -0
- spots_cli/services/youtube_search_service.py +211 -0
- spots_cli/setup_env.py +118 -0
- spots_cli/utils/__init__.py +4 -0
- spots_cli/utils/config_dir.py +17 -0
- spots_cli/utils/detect_browser.py +30 -0
- spots_cli/utils/fetch.py +73 -0
- spots_cli/utils/search_fallbacks.py +21 -0
- spots_cli-0.1.0.dist-info/METADATA +146 -0
- spots_cli-0.1.0.dist-info/RECORD +65 -0
- spots_cli-0.1.0.dist-info/WHEEL +4 -0
- spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
- spots_cli-0.1.0.dist-info/licenses/LICENSE +675 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import unicode_literals, annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from re import compile, sub, IGNORECASE, VERBOSE
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from spots_cli.engine import storage
|
|
8
|
+
from spots_cli.models import SongNotFound, Sentinel, YTVideoInfo, Metadata
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from spots_cli.core import YouTubeExtractor
|
|
12
|
+
|
|
13
|
+
# -----------------------------
|
|
14
|
+
# logging setup
|
|
15
|
+
# -----------------------------
|
|
16
|
+
|
|
17
|
+
logger = getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# -----------------------------
|
|
20
|
+
# regex
|
|
21
|
+
# -----------------------------
|
|
22
|
+
|
|
23
|
+
FEAT_PATTERN = compile(
|
|
24
|
+
r"""
|
|
25
|
+
(\(|\[)?
|
|
26
|
+
\b(feat|ft|featuring)\b
|
|
27
|
+
[\s\.:,-]*.*
|
|
28
|
+
(\)|\])?
|
|
29
|
+
""",
|
|
30
|
+
flags=IGNORECASE | VERBOSE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
NON_WORD_PATTERN = compile(r"\W+")
|
|
34
|
+
|
|
35
|
+
# -----------------------------
|
|
36
|
+
# constants
|
|
37
|
+
# -----------------------------
|
|
38
|
+
|
|
39
|
+
STOPWORDS = {
|
|
40
|
+
"official",
|
|
41
|
+
"audio",
|
|
42
|
+
"video",
|
|
43
|
+
"lyrics",
|
|
44
|
+
"lyric",
|
|
45
|
+
"visualizer",
|
|
46
|
+
"remix",
|
|
47
|
+
"version",
|
|
48
|
+
"edit",
|
|
49
|
+
"hd",
|
|
50
|
+
"explicit",
|
|
51
|
+
"clean",
|
|
52
|
+
"topic",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
BAD_UPLOADER_KEYWORDS = {
|
|
56
|
+
"karaoke",
|
|
57
|
+
"lyrics",
|
|
58
|
+
"remastered",
|
|
59
|
+
"mashup",
|
|
60
|
+
"nightcore",
|
|
61
|
+
"slowed",
|
|
62
|
+
"reverb",
|
|
63
|
+
"8d",
|
|
64
|
+
"cover",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# -----------------------------
|
|
68
|
+
# helpers
|
|
69
|
+
# -----------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def strip_features(text: str) -> str:
|
|
73
|
+
cleaned = sub(FEAT_PATTERN, "", text)
|
|
74
|
+
logger.debug("strip_features: '%s' -> '%s'", text, cleaned)
|
|
75
|
+
return cleaned
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tokenize(text: str) -> set[str]:
|
|
79
|
+
original = text
|
|
80
|
+
text = text.lower()
|
|
81
|
+
text = strip_features(text)
|
|
82
|
+
tokens = NON_WORD_PATTERN.sub(" ", text).split()
|
|
83
|
+
filtered = {t for t in tokens if t not in STOPWORDS}
|
|
84
|
+
|
|
85
|
+
logger.debug(
|
|
86
|
+
"tokenize: original='%s', tokens=%s, filtered=%s",
|
|
87
|
+
original,
|
|
88
|
+
tokens,
|
|
89
|
+
filtered,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return filtered
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def token_similarity(a: set[str], b: set[str]) -> float:
|
|
96
|
+
if not a or not b:
|
|
97
|
+
logger.debug("token_similarity: empty tokens -> 0.0")
|
|
98
|
+
return 0.0
|
|
99
|
+
|
|
100
|
+
score = len(a & b) / max(len(a), len(b))
|
|
101
|
+
logger.debug("token_similarity: %s vs %s -> %.3f", a, b, score)
|
|
102
|
+
return score
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def uploader_penalty(uploader: str) -> float:
|
|
106
|
+
uploader_lower = uploader.lower()
|
|
107
|
+
penalty = 0.35 if any(k in uploader_lower for k in BAD_UPLOADER_KEYWORDS) else 0.0
|
|
108
|
+
|
|
109
|
+
logger.debug("uploader_penalty: '%s' -> %.2f", uploader, penalty)
|
|
110
|
+
return penalty
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def artist_in_title_or_uploader(
|
|
114
|
+
sp_artist: str, yt_title: str, yt_uploader: str
|
|
115
|
+
) -> bool:
|
|
116
|
+
sp_tokens = tokenize(sp_artist)
|
|
117
|
+
yt_tokens = tokenize(yt_title) | tokenize(yt_uploader)
|
|
118
|
+
result = bool(sp_tokens & yt_tokens)
|
|
119
|
+
|
|
120
|
+
logger.debug(
|
|
121
|
+
"artist_in_title_or_uploader: sp=%s, yt=%s -> %s",
|
|
122
|
+
sp_tokens,
|
|
123
|
+
yt_tokens,
|
|
124
|
+
result,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -----------------------------
|
|
131
|
+
# matcher
|
|
132
|
+
# -----------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class PatternMatcher:
|
|
136
|
+
TITLE_THRESHOLD = 0.50
|
|
137
|
+
ARTIST_THRESHOLD = 0.40
|
|
138
|
+
FINAL_THRESHOLD = 0.60
|
|
139
|
+
|
|
140
|
+
def __init__(self, *, extractor: YouTubeExtractor):
|
|
141
|
+
self.extractor = extractor
|
|
142
|
+
|
|
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 ""
|
|
146
|
+
|
|
147
|
+
sp_artist = metadata.artist or ""
|
|
148
|
+
sp_title = metadata.title or ""
|
|
149
|
+
|
|
150
|
+
logger.debug(
|
|
151
|
+
"Matching track: YT(title='%s', uploader='%s') vs SP(title='%s', artist='%s')",
|
|
152
|
+
yt_title,
|
|
153
|
+
yt_artist,
|
|
154
|
+
sp_title,
|
|
155
|
+
sp_artist,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
yt_title_tokens = tokenize(yt_title)
|
|
159
|
+
sp_title_tokens = tokenize(sp_title)
|
|
160
|
+
|
|
161
|
+
yt_artist_tokens = tokenize(yt_artist)
|
|
162
|
+
sp_artist_tokens = tokenize(sp_artist)
|
|
163
|
+
|
|
164
|
+
title_score = token_similarity(yt_title_tokens, sp_title_tokens)
|
|
165
|
+
artist_score = token_similarity(yt_artist_tokens, sp_artist_tokens)
|
|
166
|
+
|
|
167
|
+
penalty = uploader_penalty(yt_artist)
|
|
168
|
+
|
|
169
|
+
final_score = 0.6 * title_score + 0.3 * artist_score - penalty
|
|
170
|
+
|
|
171
|
+
logger.debug(
|
|
172
|
+
"Scores -> title: %.3f, artist: %.3f, penalty: %.2f, final: %.3f",
|
|
173
|
+
title_score,
|
|
174
|
+
artist_score,
|
|
175
|
+
penalty,
|
|
176
|
+
final_score,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
title_dominant_match = title_score >= 0.60 and artist_in_title_or_uploader(
|
|
180
|
+
sp_artist, yt_title, yt_artist
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
score_based_match = final_score >= self.FINAL_THRESHOLD and (
|
|
184
|
+
title_score >= self.TITLE_THRESHOLD or artist_score >= self.ARTIST_THRESHOLD
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
is_match = title_dominant_match or score_based_match
|
|
188
|
+
|
|
189
|
+
logger.debug(
|
|
190
|
+
"Decision -> title_dominant: %s, score_based: %s, final_match: %s",
|
|
191
|
+
title_dominant_match,
|
|
192
|
+
score_based_match,
|
|
193
|
+
is_match,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return is_match
|
|
197
|
+
|
|
198
|
+
def find_best_match(
|
|
199
|
+
self, *, search_results: list[YTVideoInfo], metadata: Metadata
|
|
200
|
+
) -> YTVideoInfo:
|
|
201
|
+
query = f"{metadata.artist} - {metadata.title}"
|
|
202
|
+
logger.debug("Searching best match for query: '%s'", query)
|
|
203
|
+
|
|
204
|
+
cache = storage.get(query=query, query_type="youtube")
|
|
205
|
+
if cache:
|
|
206
|
+
logger.debug("Cache hit for query: '%s'", query)
|
|
207
|
+
return cache
|
|
208
|
+
|
|
209
|
+
for idx, result in enumerate(search_results):
|
|
210
|
+
logger.debug("#" * 50)
|
|
211
|
+
logger.debug("Evaluating result #%d: %s", idx + 1, result.title)
|
|
212
|
+
|
|
213
|
+
title_and_artist = self.extractor.extract_artist_and_title(
|
|
214
|
+
video_info=result, metadata=metadata
|
|
215
|
+
)
|
|
216
|
+
result.full_title = title_and_artist
|
|
217
|
+
|
|
218
|
+
if self.match_tracks(video_info=result, metadata=metadata):
|
|
219
|
+
logger.debug("Match found: '%s'", result.title)
|
|
220
|
+
storage.new(query=query, result=result, query_type="youtube")
|
|
221
|
+
logger.debug("#" * 50)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
logger.warning("No match found for query: '%s'", query)
|
|
225
|
+
storage.new(query=query, result=Sentinel(), query_type="youtube")
|
|
226
|
+
raise SongNotFound(query)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
|
3
|
+
from mutagen.id3 import ID3
|
|
4
|
+
from mutagen.id3._frames import APIC, TIT2, TPE1, TRCK, TALB, USLT, TDRL
|
|
5
|
+
from mutagen.mp3 import MP3
|
|
6
|
+
from os import remove
|
|
7
|
+
from requests import get
|
|
8
|
+
|
|
9
|
+
from spots_cli.models.metadata import Metadata
|
|
10
|
+
|
|
11
|
+
logger = getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VideoConverter:
|
|
15
|
+
"""
|
|
16
|
+
Service responsible for converting audio files to MP3 format
|
|
17
|
+
and updating their metadata.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
@add_to_history (AddToHistoryService): Service for adding downloaded songs to history.
|
|
21
|
+
|
|
22
|
+
Methods:
|
|
23
|
+
@update_metadata
|
|
24
|
+
|
|
25
|
+
@convert_to_mp3
|
|
26
|
+
|
|
27
|
+
This service handles:
|
|
28
|
+
- Converting audio files to MP3
|
|
29
|
+
- Updating ID3 metadata (title, artist, album, cover art, lyrics, etc.)
|
|
30
|
+
- Removing the original file after conversion
|
|
31
|
+
- Adding successful downloads to the download history
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def update_metadata(self, *, audio_path: str, metadata: Metadata) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Update the ID3 metadata of an MP3 file.
|
|
37
|
+
|
|
38
|
+
Existing ID3 tags are removed and replaced with new ones
|
|
39
|
+
based on the provided metadata.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
audio_path (str): Path to the MP3 file whose metadata will be updated.
|
|
43
|
+
metadata (Metadata): Metadata object containing song information.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError: If the audio file does not exist.
|
|
47
|
+
"""
|
|
48
|
+
logger.debug("Updating metadata")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
audio = MP3(audio_path, ID3=ID3)
|
|
52
|
+
|
|
53
|
+
# Remove existing ID3 tags
|
|
54
|
+
audio.tags = None
|
|
55
|
+
|
|
56
|
+
# Create new ID3 tags
|
|
57
|
+
audio.tags = ID3()
|
|
58
|
+
|
|
59
|
+
# Set basic metadata
|
|
60
|
+
audio.tags.add(TIT2(encoding=3, text=metadata.title))
|
|
61
|
+
audio.tags.add(TPE1(encoding=3, text=metadata.artist))
|
|
62
|
+
audio.tags.add(TRCK(encoding=3, text=metadata.tracknumber))
|
|
63
|
+
audio.tags.add(TALB(encoding=3, text=metadata.album))
|
|
64
|
+
|
|
65
|
+
# Handle cover art
|
|
66
|
+
cover = metadata.cover
|
|
67
|
+
if cover:
|
|
68
|
+
audio.tags.add(
|
|
69
|
+
APIC(
|
|
70
|
+
encoding=3,
|
|
71
|
+
mime="image/jpeg",
|
|
72
|
+
type=3,
|
|
73
|
+
desc="Cover",
|
|
74
|
+
data=get(cover).content,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Add lyrics if provided
|
|
79
|
+
if metadata.lyrics:
|
|
80
|
+
audio.tags.add(
|
|
81
|
+
USLT(
|
|
82
|
+
encoding=3,
|
|
83
|
+
lang="eng",
|
|
84
|
+
desc="",
|
|
85
|
+
text=metadata.lyrics,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Add release date if provided
|
|
90
|
+
if metadata.release_date:
|
|
91
|
+
audio.tags.add(
|
|
92
|
+
TDRL(
|
|
93
|
+
encoding=3,
|
|
94
|
+
text=metadata.release_date,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Save changes
|
|
99
|
+
audio.save(audio_path)
|
|
100
|
+
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
except FileNotFoundError as exc:
|
|
104
|
+
logger.error(f"{audio_path} not found...")
|
|
105
|
+
raise exc
|
|
106
|
+
|
|
107
|
+
def convert_to_mp3(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
old_file: str,
|
|
111
|
+
new_file: str,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Convert an audio file to MP3 format and update its metadata.
|
|
115
|
+
|
|
116
|
+
This method:
|
|
117
|
+
- Converts the input audio file to MP3
|
|
118
|
+
- Deletes the original file
|
|
119
|
+
- Updates the MP3 metadata
|
|
120
|
+
- Adds the song to the download history on success
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
old_file (str): Path to the source audio file.
|
|
124
|
+
new_file (str): Path where the converted MP3 file will be saved.
|
|
125
|
+
song_title (str): Song title to be added to the download history.
|
|
126
|
+
metadata (Metadata): Metadata to apply to the converted MP3 file.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: True indicates a successful download
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
# Load the audio clip
|
|
133
|
+
clip = AudioFileClip(old_file)
|
|
134
|
+
|
|
135
|
+
# Convert and save as MP3
|
|
136
|
+
clip.write_audiofile(new_file, codec="mp3")
|
|
137
|
+
|
|
138
|
+
# Clean up resources
|
|
139
|
+
clip.close()
|
|
140
|
+
remove(old_file)
|
|
141
|
+
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
logger.error(f"{old_file} not found...")
|
|
146
|
+
return False
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from bs4 import BeautifulSoup, Comment, Tag
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from re import search
|
|
5
|
+
from requests import get
|
|
6
|
+
|
|
7
|
+
logger = getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ScrapedResult:
|
|
12
|
+
cover: str
|
|
13
|
+
name: str
|
|
14
|
+
ids: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_element_by_comment(soup: BeautifulSoup, comment_text: str):
|
|
18
|
+
"""Find the first element that follows a comment with exact `comment_text`."""
|
|
19
|
+
comments = soup.find_all(string=lambda text: isinstance(text, Comment))
|
|
20
|
+
|
|
21
|
+
for comment in comments:
|
|
22
|
+
if comment.strip() == comment_text.strip():
|
|
23
|
+
parent_el = comment.parent
|
|
24
|
+
if parent_el:
|
|
25
|
+
return parent_el
|
|
26
|
+
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WebScraper:
|
|
31
|
+
def scrape_azlyrics(self, *, artist: str, title: str):
|
|
32
|
+
|
|
33
|
+
logger.debug("Searching for lyrics on AZLyrics")
|
|
34
|
+
artist = artist.lower().replace(" ", "")
|
|
35
|
+
title = title.lower().replace(" ", "")
|
|
36
|
+
|
|
37
|
+
url = f"https://azlyrics.com/lyrics/{artist}/{title}.html"
|
|
38
|
+
|
|
39
|
+
response = get(url)
|
|
40
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
41
|
+
lyrics_comment = "Usage of azlyrics.com content by any third-party lyrics provider is prohibited by our licensing agreement. Sorry about that."
|
|
42
|
+
lyrics_el = get_element_by_comment(soup, lyrics_comment)
|
|
43
|
+
return lyrics_el.text if lyrics_el else ""
|
|
44
|
+
|
|
45
|
+
def scrape_spotify_playlist(self, url: str) -> ScrapedResult:
|
|
46
|
+
if not (search("spotify", url) and search("playlist", url)):
|
|
47
|
+
raise TypeError("Please provide a valid Spotify playlist url")
|
|
48
|
+
|
|
49
|
+
logger.debug("Scraping spotify playlist")
|
|
50
|
+
|
|
51
|
+
response = get(url)
|
|
52
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
53
|
+
|
|
54
|
+
tracklist_rows = soup.find_all(attrs={"data-encore-id": "listRowTitle"})
|
|
55
|
+
|
|
56
|
+
cover_container = soup.find(attrs={"data-testid": "entity-image"})
|
|
57
|
+
title_container = soup.find(attrs={"data-encore-id": "text"})
|
|
58
|
+
|
|
59
|
+
# Cover
|
|
60
|
+
cover_image: str = ""
|
|
61
|
+
if cover_container:
|
|
62
|
+
img_tag = cover_container.find("img")
|
|
63
|
+
if isinstance(img_tag, Tag):
|
|
64
|
+
cover_image = img_tag.get("src", "")
|
|
65
|
+
|
|
66
|
+
# Title
|
|
67
|
+
playlist_title: str = ""
|
|
68
|
+
if isinstance(title_container, Tag):
|
|
69
|
+
playlist_title = title_container.get_text(strip=True)
|
|
70
|
+
|
|
71
|
+
# Track IDs
|
|
72
|
+
playlist_ids: list[str] = []
|
|
73
|
+
|
|
74
|
+
for tag in tracklist_rows:
|
|
75
|
+
if isinstance(tag, Tag):
|
|
76
|
+
id_value = tag.get("id")
|
|
77
|
+
if isinstance(id_value, str):
|
|
78
|
+
match = search(r"spotify:track:([a-zA-Z0-9]+)", id_value)
|
|
79
|
+
if match:
|
|
80
|
+
playlist_ids.append(match.group(1))
|
|
81
|
+
|
|
82
|
+
return ScrapedResult(ids=playlist_ids, cover=cover_image, name=playlist_title)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from html import unescape
|
|
2
|
+
from unicodedata import normalize
|
|
3
|
+
from re import compile, escape, IGNORECASE, search, sub, split
|
|
4
|
+
|
|
5
|
+
from spots_cli.models.yt_video_info import YTVideoInfo
|
|
6
|
+
from spots_cli.models.metadata import Metadata
|
|
7
|
+
from spots_cli.models.helper_models import ArtistAndTitle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class YouTubeExtractor:
|
|
11
|
+
"""A service for extracting data from a YouTubeVideoInfo"""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def remove_odd_keywords(title: str, keywords_list: list[str] = []) -> str:
|
|
15
|
+
defined_keywords = [
|
|
16
|
+
" (Live Session) | Vevo Ctrl",
|
|
17
|
+
" [Official Audio]",
|
|
18
|
+
" (Audio Visual)",
|
|
19
|
+
" | Official Audio",
|
|
20
|
+
" | Official",
|
|
21
|
+
" (Official Audio)",
|
|
22
|
+
" (Official Video)",
|
|
23
|
+
" (Audio)",
|
|
24
|
+
" Uncut [HD]",
|
|
25
|
+
" [Video]",
|
|
26
|
+
" (HD)",
|
|
27
|
+
" (Official Music Video)",
|
|
28
|
+
" [Official Music Video]",
|
|
29
|
+
" - Topic",
|
|
30
|
+
" (Official Visualizer)",
|
|
31
|
+
" (Complete)",
|
|
32
|
+
" (Visualizer)",
|
|
33
|
+
" [Official Lyrics Video]",
|
|
34
|
+
" (Lyric Video)",
|
|
35
|
+
" (Lyrics)",
|
|
36
|
+
" (Official HD Video)",
|
|
37
|
+
" (Audio Oficial)",
|
|
38
|
+
" Official Music Video",
|
|
39
|
+
" [Official Video]",
|
|
40
|
+
" - Official Video",
|
|
41
|
+
" (Original Video)",
|
|
42
|
+
"(LYRICS+PICTURES)",
|
|
43
|
+
" [Lyrics]",
|
|
44
|
+
" HQ",
|
|
45
|
+
" - Audio",
|
|
46
|
+
" (Album Version)",
|
|
47
|
+
" (Clean Version)",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
title = title.replace("(with", "(feat.")
|
|
51
|
+
|
|
52
|
+
odd_keywords = (
|
|
53
|
+
defined_keywords + keywords_list
|
|
54
|
+
if all(isinstance(k, str) for k in keywords_list)
|
|
55
|
+
else defined_keywords
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
pattern = compile("|".join(escape(k) for k in odd_keywords), flags=IGNORECASE)
|
|
59
|
+
return pattern.sub("", title).strip()
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def normalize_special_chars(text: str) -> str:
|
|
63
|
+
return (
|
|
64
|
+
normalize("NFKD", text)
|
|
65
|
+
.encode("ascii", "ignore")
|
|
66
|
+
.decode("ascii")
|
|
67
|
+
.lower()
|
|
68
|
+
.replace("–", "-")
|
|
69
|
+
.strip()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def extract_artist_and_title(
|
|
73
|
+
self, *, video_info: YTVideoInfo, metadata: Metadata
|
|
74
|
+
) -> ArtistAndTitle:
|
|
75
|
+
"""Extracts the title and artist from a YouTube search result.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
video_info (YTVideoInfo): The search result object to be processed.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
ArtistAndTitle: The extracted title and artist.
|
|
82
|
+
"""
|
|
83
|
+
# Decode the string
|
|
84
|
+
try:
|
|
85
|
+
video_title = unescape(video_info.title)
|
|
86
|
+
except TypeError:
|
|
87
|
+
video_title = video_info.title
|
|
88
|
+
|
|
89
|
+
# normalize tokens
|
|
90
|
+
normalized_yt_artist = self.normalize_special_chars(video_info.uploader)
|
|
91
|
+
normalized_yt_title = self.normalize_special_chars(video_title)
|
|
92
|
+
normalized_sp_artist = self.normalize_special_chars(metadata.artist)
|
|
93
|
+
|
|
94
|
+
title_parts = split(r"\s*[-|:•]\s*|\s{2,}", normalized_yt_title, maxsplit=1)
|
|
95
|
+
|
|
96
|
+
ARTIST_CANDIDATES = [normalized_yt_artist.strip()] + title_parts
|
|
97
|
+
|
|
98
|
+
def recursive_artist_search(pattern: str, index: int):
|
|
99
|
+
if search(escape(pattern), normalized_sp_artist, flags=IGNORECASE):
|
|
100
|
+
return pattern, index
|
|
101
|
+
if index == (len(ARTIST_CANDIDATES) - 1):
|
|
102
|
+
return video_info.uploader, 0
|
|
103
|
+
next_index = index + 1
|
|
104
|
+
return recursive_artist_search(ARTIST_CANDIDATES[next_index], next_index)
|
|
105
|
+
|
|
106
|
+
_, uploader_index = recursive_artist_search(ARTIST_CANDIDATES[0], 0)
|
|
107
|
+
|
|
108
|
+
match uploader_index:
|
|
109
|
+
case 0:
|
|
110
|
+
# artist is uploader
|
|
111
|
+
youtube_artist, youtube_title = video_info.uploader, video_title
|
|
112
|
+
|
|
113
|
+
# check for artist repeat in title
|
|
114
|
+
if normalized_yt_artist in normalized_yt_title:
|
|
115
|
+
artist_pattern = compile(rf"{youtube_artist}\W?", flags=IGNORECASE)
|
|
116
|
+
youtube_title = sub(artist_pattern, "", video_title)
|
|
117
|
+
|
|
118
|
+
# strip title
|
|
119
|
+
youtube_title = sub(r"\W", "", youtube_title, count=1)
|
|
120
|
+
youtube_title = youtube_title
|
|
121
|
+
case 1:
|
|
122
|
+
# different uploader, artist in title
|
|
123
|
+
pattern = rf"{escape(metadata.artist)}\W"
|
|
124
|
+
pattern_match = search(pattern, video_title)
|
|
125
|
+
|
|
126
|
+
if pattern_match:
|
|
127
|
+
# strip artist
|
|
128
|
+
youtube_artist = pattern_match.group(0)
|
|
129
|
+
rev_artist = youtube_artist[::-1]
|
|
130
|
+
youtube_artist = sub(r"\W", "", rev_artist, count=1)
|
|
131
|
+
youtube_artist = youtube_artist[::-1]
|
|
132
|
+
|
|
133
|
+
# strip title
|
|
134
|
+
youtube_title = video_title[pattern_match.end() :]
|
|
135
|
+
youtube_title = sub(r"\W", "", youtube_title, count=1)
|
|
136
|
+
else:
|
|
137
|
+
youtube_artist, youtube_title = video_info.uploader, video_title
|
|
138
|
+
case _:
|
|
139
|
+
# different uploader, artist and title reversed
|
|
140
|
+
pattern = rf"\W{escape(self.normalize_special_chars(metadata.artist))}"
|
|
141
|
+
pattern_match = search(pattern, video_title, flags=IGNORECASE)
|
|
142
|
+
|
|
143
|
+
if pattern_match:
|
|
144
|
+
# strip artist
|
|
145
|
+
youtube_artist = pattern_match.group(0)
|
|
146
|
+
youtube_artist = sub(r"\W", "", youtube_artist, count=1)
|
|
147
|
+
|
|
148
|
+
# strip title
|
|
149
|
+
youtube_title = video_title[: pattern_match.start()]
|
|
150
|
+
rev_title = youtube_title[::-1]
|
|
151
|
+
youtube_title = sub(r"\W", "", rev_title, count=1)
|
|
152
|
+
youtube_title = youtube_title[::-1]
|
|
153
|
+
else:
|
|
154
|
+
youtube_artist, youtube_title = video_info.uploader, video_title
|
|
155
|
+
|
|
156
|
+
youtube_title = youtube_title.rstrip(" |–-")
|
|
157
|
+
youtube_title = self.remove_odd_keywords(youtube_title)
|
|
158
|
+
youtube_artist = self.remove_odd_keywords(youtube_artist)
|
|
159
|
+
|
|
160
|
+
return ArtistAndTitle(
|
|
161
|
+
artist=youtube_artist.strip(), title=youtube_title.strip()
|
|
162
|
+
)
|