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.
Files changed (65) hide show
  1. spots_cli/__init__.py +125 -0
  2. spots_cli/app/__init__.py +5 -0
  3. spots_cli/app/domain_resolver.py +157 -0
  4. spots_cli/app/downloader.py +78 -0
  5. spots_cli/app/media_resolver.py +180 -0
  6. spots_cli/app/spotify_playlist_compilation.py +251 -0
  7. spots_cli/app/youtube_user_playlist.py +215 -0
  8. spots_cli/bootstrap/__init__.py +1 -0
  9. spots_cli/bootstrap/container.py +230 -0
  10. spots_cli/cli.py +42 -0
  11. spots_cli/clients/__init__.py +5 -0
  12. spots_cli/clients/deezer.py +80 -0
  13. spots_cli/clients/secrets_manager.py +25 -0
  14. spots_cli/clients/spotify.py +79 -0
  15. spots_cli/clients/youtube.py +66 -0
  16. spots_cli/clients/ytdl.py +72 -0
  17. spots_cli/config/__init__.py +0 -0
  18. spots_cli/core/__init__.py +6 -0
  19. spots_cli/core/history_manager.py +39 -0
  20. spots_cli/core/lyrics_finder.py +58 -0
  21. spots_cli/core/pattern_matcher.py +226 -0
  22. spots_cli/core/video_converter.py +146 -0
  23. spots_cli/core/web_scraper.py +82 -0
  24. spots_cli/core/youtube_extractor.py +162 -0
  25. spots_cli/engine/__init__.py +3 -0
  26. spots_cli/engine/file_storage.py +347 -0
  27. spots_cli/engine/persistence_model.py +3 -0
  28. spots_cli/engine/retry.py +34 -0
  29. spots_cli/integrations/__init__.py +1 -0
  30. spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
  31. spots_cli/models/__init__.py +24 -0
  32. spots_cli/models/artist_metadata.py +23 -0
  33. spots_cli/models/errors.py +69 -0
  34. spots_cli/models/helper_models.py +25 -0
  35. spots_cli/models/media_resource.py +20 -0
  36. spots_cli/models/metadata.py +36 -0
  37. spots_cli/models/metadata_provider.py +52 -0
  38. spots_cli/models/playlist_info.py +27 -0
  39. spots_cli/models/search_provider.py +65 -0
  40. spots_cli/models/sentinel.py +4 -0
  41. spots_cli/models/yt_video_info.py +29 -0
  42. spots_cli/scripts/__init__.py +2 -0
  43. spots_cli/scripts/add_to_history.py +45 -0
  44. spots_cli/scripts/bootstrap.py +92 -0
  45. spots_cli/scripts/duplicate_songs_remover.py +54 -0
  46. spots_cli/services/__init__.py +8 -0
  47. spots_cli/services/deezer_metadata_service.py +108 -0
  48. spots_cli/services/deezer_search_service.py +132 -0
  49. spots_cli/services/provider_search_container.py +67 -0
  50. spots_cli/services/providers_metadata.py +22 -0
  51. spots_cli/services/spotify_metadata_service.py +151 -0
  52. spots_cli/services/spotify_search_service.py +228 -0
  53. spots_cli/services/youtube_metadata_service.py +73 -0
  54. spots_cli/services/youtube_search_service.py +211 -0
  55. spots_cli/setup_env.py +118 -0
  56. spots_cli/utils/__init__.py +4 -0
  57. spots_cli/utils/config_dir.py +17 -0
  58. spots_cli/utils/detect_browser.py +30 -0
  59. spots_cli/utils/fetch.py +73 -0
  60. spots_cli/utils/search_fallbacks.py +21 -0
  61. spots_cli-0.1.0.dist-info/METADATA +146 -0
  62. spots_cli-0.1.0.dist-info/RECORD +65 -0
  63. spots_cli-0.1.0.dist-info/WHEEL +4 -0
  64. spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
  65. 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
+ )
@@ -0,0 +1,3 @@
1
+ from spots_cli.engine.file_storage import FileStorage
2
+ from spots_cli.engine.persistence_model import storage
3
+ from spots_cli.engine.retry import retry