spots-cli 0.1.0__tar.gz → 0.2.0__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.1.0 → spots_cli-0.2.0}/PKG-INFO +25 -11
- {spots_cli-0.1.0 → spots_cli-0.2.0}/README.md +22 -8
- {spots_cli-0.1.0 → spots_cli-0.2.0}/pyproject.toml +12 -2
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/__init__.py +45 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/domain_resolver.py +41 -5
- spots_cli-0.2.0/src/spots_cli/app/downloader.py +260 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/media_resolver.py +64 -18
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/bootstrap/container.py +3 -1
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/cli.py +15 -1
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/youtube.py +25 -3
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/ytdl.py +19 -8
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/video_converter.py +45 -1
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/youtube_extractor.py +39 -6
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/__init__.py +3 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/errors.py +10 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/playlist_info.py +5 -2
- spots_cli-0.2.0/src/spots_cli/models/unavailable_video.py +18 -0
- spots_cli-0.2.0/src/spots_cli/models/video_resolution.py +20 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/yt_video_info.py +1 -1
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/youtube_metadata_service.py +4 -2
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/setup_env.py +11 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/__init__.py +1 -0
- spots_cli-0.2.0/src/spots_cli/utils/detect_js_runtimes.py +35 -0
- spots_cli-0.2.0/tests/api/test_download_video.py +125 -0
- spots_cli-0.2.0/tests/app/test_downloader.py +149 -0
- spots_cli-0.2.0/tests/clients/test_deezer.py +61 -0
- spots_cli-0.2.0/tests/clients/test_secrets_manager.py +19 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/tests/clients/test_spotify.py +11 -11
- spots_cli-0.2.0/tests/clients/test_youtube.py +124 -0
- spots_cli-0.2.0/tests/clients/test_ytdl.py +103 -0
- spots_cli-0.2.0/tests/conftest.py +50 -0
- spots_cli-0.2.0/tests/test_cli.py +37 -0
- spots_cli-0.2.0/tests/test_spots.py +64 -0
- spots_cli-0.2.0/tests/utils/test_detect_js_runtimes.py +44 -0
- spots_cli-0.1.0/api/main.py +0 -38
- spots_cli-0.1.0/api/server/__init__.py +0 -324
- spots_cli-0.1.0/api/server/download_music.py +0 -161
- spots_cli-0.1.0/api/server/static/Ball-1s-500px.svg +0 -6
- spots_cli-0.1.0/api/server/static/Magnify-1s-401px.svg +0 -14
- spots_cli-0.1.0/api/server/static/android-chrome-192x192.png +0 -0
- spots_cli-0.1.0/api/server/static/android-chrome-512x512.png +0 -0
- spots_cli-0.1.0/api/server/static/apple-touch-icon.png +0 -0
- spots_cli-0.1.0/api/server/static/arrow-down-circle-svgrepo-com.svg +0 -7
- spots_cli-0.1.0/api/server/static/avatar.jpg +0 -0
- spots_cli-0.1.0/api/server/static/download-svgrepo-com.svg +0 -19
- spots_cli-0.1.0/api/server/static/favicon-16x16.png +0 -0
- spots_cli-0.1.0/api/server/static/favicon-32x32.png +0 -0
- spots_cli-0.1.0/api/server/static/favicon.ico +0 -0
- spots_cli-0.1.0/api/server/static/house-svgrepo-com.svg +0 -7
- spots_cli-0.1.0/api/server/static/icon.jpg +0 -0
- spots_cli-0.1.0/api/server/static/single-cover(mdesigns).jpg +0 -0
- spots_cli-0.1.0/api/server/static/single-cover.jpg +0 -0
- spots_cli-0.1.0/api/server/static/youtube-playlist(mdesigns).jpg +0 -0
- spots_cli-0.1.0/api/server/static/youtube-playlist.jpg +0 -0
- spots_cli-0.1.0/api/site.webmanifest +0 -1
- spots_cli-0.1.0/requirements.txt +0 -64
- spots_cli-0.1.0/src/spots_cli/app/downloader.py +0 -78
- spots_cli-0.1.0/tests/clients/test_deezer.py +0 -11
- spots_cli-0.1.0/tests/clients/test_secrets_manager.py +0 -0
- spots_cli-0.1.0/tests/clients/test_youtube.py +0 -149
- spots_cli-0.1.0/tests/clients/test_ytdl.py +0 -155
- {spots_cli-0.1.0 → spots_cli-0.2.0}/.gitignore +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/LICENSE +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/youtube_user_playlist.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/bootstrap/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/deezer.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/secrets_manager.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/spotify.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/config/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/history_manager.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/lyrics_finder.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/pattern_matcher.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/web_scraper.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/file_storage.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/persistence_model.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/retry.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/integrations/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/artist_metadata.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/helper_models.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/media_resource.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/metadata.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/metadata_provider.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/search_provider.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/sentinel.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/add_to_history.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/bootstrap.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/__init__.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/deezer_metadata_service.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/deezer_search_service.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/provider_search_container.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/providers_metadata.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/spotify_metadata_service.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/spotify_search_service.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/youtube_search_service.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/config_dir.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/detect_browser.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/fetch.py +0 -0
- {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/search_fallbacks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: spots_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -24,7 +24,7 @@ Requires-Dist: requests
|
|
|
24
24
|
Requires-Dist: spotipy
|
|
25
25
|
Requires-Dist: tenacity
|
|
26
26
|
Requires-Dist: typer
|
|
27
|
-
Requires-Dist: yt-dlp
|
|
27
|
+
Requires-Dist: yt-dlp[default]>=2026.10.26
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
30
|
# 🎧 Spots
|
|
@@ -63,7 +63,7 @@ It resolves tracks across platforms, matches metadata intelligently, and enables
|
|
|
63
63
|
## 📦 Installation
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
pip install spots
|
|
66
|
+
pip install spots-cli
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
---
|
|
@@ -73,22 +73,32 @@ pip install spots
|
|
|
73
73
|
### Run the setup script:
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
|
|
76
|
+
spots_cli setup
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
* follow the prompts to generate the config folder
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
### YouTube account features (optional)
|
|
83
|
+
|
|
84
|
+
Migrating likes uses your own Google credentials. In Google Cloud Console, enable the YouTube Data API v3, create an OAuth client ID of type *Desktop app*, and save its JSON as `client_secrets.json` in the Spots config folder:
|
|
85
|
+
|
|
86
|
+
* Linux: `~/.config/spots_cli/`
|
|
87
|
+
* macOS: `~/Library/Application Support/spots_cli/`
|
|
88
|
+
* Windows: `%APPDATA%\spots_cli\`
|
|
89
|
+
|
|
90
|
+
Then answer "y" to the YouTube question in `spots_cli setup`. You'll sign in to YouTube in your browser the first time.
|
|
91
|
+
|
|
82
92
|
---
|
|
83
93
|
|
|
84
94
|
## 🚀 Usage
|
|
85
95
|
```bash
|
|
86
96
|
# search a title
|
|
87
97
|
# required search format: `Artist - Title`
|
|
88
|
-
|
|
98
|
+
spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
|
|
89
99
|
|
|
90
100
|
# download a direct url
|
|
91
|
-
|
|
101
|
+
spots_cli download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
|
|
92
102
|
```
|
|
93
103
|
|
|
94
104
|
Spots will:
|
|
@@ -104,19 +114,23 @@ Spots will:
|
|
|
104
114
|
|
|
105
115
|
* download - downloads a direct url or a search query
|
|
106
116
|
```bash
|
|
107
|
-
|
|
117
|
+
spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
|
|
118
|
+
```
|
|
119
|
+
* download-video - downloads a YouTube video (a url, or search terms) as mp4. Picks the highest resolution unless you pass `--resolution`
|
|
120
|
+
```bash
|
|
121
|
+
spots_cli download-video "https://www.youtube.com/watch?v=UmZPa5Qudtw" --resolution 720p
|
|
108
122
|
```
|
|
109
123
|
* migrate-likes - transfers likes from Spotify to YouTube (more providers coming soon...)
|
|
110
124
|
```bash
|
|
111
|
-
|
|
125
|
+
spots_cli migrate-likes
|
|
112
126
|
```
|
|
113
127
|
* add-to-history - adds a user's downloaded songs to the Spots history. Avoids duplicate downloads.
|
|
114
128
|
```bash
|
|
115
|
-
|
|
129
|
+
spots_cli add-to-history path-to-music
|
|
116
130
|
```
|
|
117
131
|
* remove-duplicates - removes duplicate songs in a given folder.
|
|
118
132
|
```bash
|
|
119
|
-
|
|
133
|
+
spots_cli remove-duplicates path-to-music
|
|
120
134
|
```
|
|
121
135
|
|
|
122
136
|
---
|
|
@@ -34,7 +34,7 @@ It resolves tracks across platforms, matches metadata intelligently, and enables
|
|
|
34
34
|
## 📦 Installation
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
pip install spots
|
|
37
|
+
pip install spots-cli
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
---
|
|
@@ -44,22 +44,32 @@ pip install spots
|
|
|
44
44
|
### Run the setup script:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
|
|
47
|
+
spots_cli setup
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
* follow the prompts to generate the config folder
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
### YouTube account features (optional)
|
|
54
|
+
|
|
55
|
+
Migrating likes uses your own Google credentials. In Google Cloud Console, enable the YouTube Data API v3, create an OAuth client ID of type *Desktop app*, and save its JSON as `client_secrets.json` in the Spots config folder:
|
|
56
|
+
|
|
57
|
+
* Linux: `~/.config/spots_cli/`
|
|
58
|
+
* macOS: `~/Library/Application Support/spots_cli/`
|
|
59
|
+
* Windows: `%APPDATA%\spots_cli\`
|
|
60
|
+
|
|
61
|
+
Then answer "y" to the YouTube question in `spots_cli setup`. You'll sign in to YouTube in your browser the first time.
|
|
62
|
+
|
|
53
63
|
---
|
|
54
64
|
|
|
55
65
|
## 🚀 Usage
|
|
56
66
|
```bash
|
|
57
67
|
# search a title
|
|
58
68
|
# required search format: `Artist - Title`
|
|
59
|
-
|
|
69
|
+
spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
|
|
60
70
|
|
|
61
71
|
# download a direct url
|
|
62
|
-
|
|
72
|
+
spots_cli download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
|
|
63
73
|
```
|
|
64
74
|
|
|
65
75
|
Spots will:
|
|
@@ -75,19 +85,23 @@ Spots will:
|
|
|
75
85
|
|
|
76
86
|
* download - downloads a direct url or a search query
|
|
77
87
|
```bash
|
|
78
|
-
|
|
88
|
+
spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
|
|
89
|
+
```
|
|
90
|
+
* download-video - downloads a YouTube video (a url, or search terms) as mp4. Picks the highest resolution unless you pass `--resolution`
|
|
91
|
+
```bash
|
|
92
|
+
spots_cli download-video "https://www.youtube.com/watch?v=UmZPa5Qudtw" --resolution 720p
|
|
79
93
|
```
|
|
80
94
|
* migrate-likes - transfers likes from Spotify to YouTube (more providers coming soon...)
|
|
81
95
|
```bash
|
|
82
|
-
|
|
96
|
+
spots_cli migrate-likes
|
|
83
97
|
```
|
|
84
98
|
* add-to-history - adds a user's downloaded songs to the Spots history. Avoids duplicate downloads.
|
|
85
99
|
```bash
|
|
86
|
-
|
|
100
|
+
spots_cli add-to-history path-to-music
|
|
87
101
|
```
|
|
88
102
|
* remove-duplicates - removes duplicate songs in a given folder.
|
|
89
103
|
```bash
|
|
90
|
-
|
|
104
|
+
spots_cli remove-duplicates path-to-music
|
|
91
105
|
```
|
|
92
106
|
|
|
93
107
|
---
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spots_cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Marlon Baatjes", email="marlonbaatjes62@gmail.com" }
|
|
10
10
|
]
|
|
@@ -30,10 +30,13 @@ dependencies = [
|
|
|
30
30
|
"requests",
|
|
31
31
|
"spotipy",
|
|
32
32
|
"tenacity",
|
|
33
|
-
"yt-dlp",
|
|
33
|
+
"yt-dlp[default]>=2026.10.26",
|
|
34
34
|
"typer"
|
|
35
35
|
]
|
|
36
36
|
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
test = ["pytest"]
|
|
39
|
+
|
|
37
40
|
[project.urls]
|
|
38
41
|
Homepage = "https://github.com/mdesignscode/spots_flask"
|
|
39
42
|
Issues = "https://github.com/mdesignscode/spots_flask"
|
|
@@ -41,6 +44,13 @@ Issues = "https://github.com/mdesignscode/spots_flask"
|
|
|
41
44
|
[tool.setuptools.packages.find]
|
|
42
45
|
where = ["src"]
|
|
43
46
|
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
# only the package, its tests and docs; not the API, deploy files or local scratch files
|
|
49
|
+
include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
|
|
44
54
|
[tool.mypy]
|
|
45
55
|
explicit_package_bases = true
|
|
46
56
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from logging import getLogger
|
|
2
4
|
from os.path import exists
|
|
3
5
|
|
|
@@ -8,6 +10,7 @@ from .models import (
|
|
|
8
10
|
TitleExistsError,
|
|
9
11
|
InvalidSearchFormat,
|
|
10
12
|
InvalidURL,
|
|
13
|
+
ResolutionUnavailable,
|
|
11
14
|
)
|
|
12
15
|
|
|
13
16
|
logger = getLogger(__name__)
|
|
@@ -114,6 +117,41 @@ class Spots:
|
|
|
114
117
|
)
|
|
115
118
|
self.container.core.storage.save()
|
|
116
119
|
|
|
120
|
+
def download_video(
|
|
121
|
+
self, query: str, resolution: str | None = None, directory_path: str = ""
|
|
122
|
+
) -> str | None:
|
|
123
|
+
"""Downloads a YouTube video as mp4
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
query (str): A YouTube video url, or search terms (the top result is used).
|
|
127
|
+
resolution (str, Optional): e.g. "720p". Defaults to the highest available.
|
|
128
|
+
directory_path (str, Optional): sub-folder of the downloads folder.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
str | None: the downloaded file's path, or None if nothing was downloaded.
|
|
132
|
+
"""
|
|
133
|
+
is_url = query.startswith(("http://", "https://"))
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
logger.info(f"Searching for {query}...")
|
|
137
|
+
search = self.container.domain.youtube_search.video_search(
|
|
138
|
+
query=query, is_general_search=not is_url
|
|
139
|
+
)
|
|
140
|
+
video = search.result if is_url else search.result[0]
|
|
141
|
+
|
|
142
|
+
logger.info(f"Downloading {video.full_title}...")
|
|
143
|
+
path = self.downloader.download_video(
|
|
144
|
+
video_info=video, resolution=resolution, directory_path=directory_path
|
|
145
|
+
)
|
|
146
|
+
except (SongNotFound, ResolutionUnavailable) as e:
|
|
147
|
+
logger.error(e)
|
|
148
|
+
return None
|
|
149
|
+
finally:
|
|
150
|
+
self.container.core.storage.save()
|
|
151
|
+
|
|
152
|
+
logger.info(f"Saved to {path}")
|
|
153
|
+
return path
|
|
154
|
+
|
|
117
155
|
def transfer_spotify_likes(self):
|
|
118
156
|
"""Transfers all spotify likes to YouTube library"""
|
|
119
157
|
try:
|
|
@@ -121,5 +159,12 @@ class Spots:
|
|
|
121
159
|
except SongNotFound:
|
|
122
160
|
logger.info("No liked songs found.")
|
|
123
161
|
|
|
162
|
+
def download_spotify_likes(self):
|
|
163
|
+
"""Downloads all Spotify likes"""
|
|
164
|
+
try:
|
|
165
|
+
self.container.app.youtube_user_playlist.transfer_spotify_likes_to_yt()
|
|
166
|
+
except SongNotFound:
|
|
167
|
+
logger.info("No liked songs found.")
|
|
168
|
+
|
|
124
169
|
|
|
125
170
|
__all__ = ["Spots"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
from logging import getLogger
|
|
4
5
|
from typing import TYPE_CHECKING, Sequence, overload
|
|
5
6
|
|
|
6
7
|
from spots_cli.models import Metadata, SongNotFound, Sentinel, YTVideoInfo
|
|
@@ -11,6 +12,9 @@ if TYPE_CHECKING:
|
|
|
11
12
|
from spots_cli.services import YoutubeSearchService
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
logger = getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
14
18
|
@dataclass
|
|
15
19
|
class MatchingDomainResults:
|
|
16
20
|
provider: list[Metadata]
|
|
@@ -23,6 +27,23 @@ class ResolvedDomainResult:
|
|
|
23
27
|
youtube: YTVideoInfo
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
def _fallback_metadata(video: YTVideoInfo) -> Metadata:
|
|
31
|
+
"""Builds a bare-bones Metadata object straight from a YouTube result.
|
|
32
|
+
|
|
33
|
+
Used when no provider (Spotify/Deezer) has metadata for a track, or the
|
|
34
|
+
provider's result doesn't match. The provider is only used to prettify
|
|
35
|
+
the library (cover art, album, lyrics, etc) - the audio always comes
|
|
36
|
+
from YouTube, so a missing/mismatched provider entry should never cause
|
|
37
|
+
a track to be dropped as long as it exists on YouTube.
|
|
38
|
+
"""
|
|
39
|
+
return Metadata(
|
|
40
|
+
title=video.title,
|
|
41
|
+
artist=video.uploader,
|
|
42
|
+
link=video.video_link,
|
|
43
|
+
artist_id="",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
26
47
|
class DomainResolver:
|
|
27
48
|
def __init__(
|
|
28
49
|
self,
|
|
@@ -71,25 +92,40 @@ class DomainResolver:
|
|
|
71
92
|
video_title
|
|
72
93
|
)
|
|
73
94
|
except SongNotFound:
|
|
95
|
+
logger.debug(
|
|
96
|
+
f"No provider metadata for '{video_title}', "
|
|
97
|
+
"falling back to YouTube-derived metadata"
|
|
98
|
+
)
|
|
99
|
+
fallback = _fallback_metadata(video)
|
|
74
100
|
self.core.storage.new(
|
|
75
|
-
query=video_title, result=
|
|
101
|
+
query=video_title, result=fallback, query_type="metadata"
|
|
76
102
|
)
|
|
77
103
|
self.core.storage.new(
|
|
78
|
-
query=video_title, result=
|
|
104
|
+
query=video_title, result=video, query_type="youtube"
|
|
79
105
|
)
|
|
106
|
+
provider_playlist.append(fallback)
|
|
107
|
+
youtube_videos.append(video)
|
|
108
|
+
if (index % 10 == 0) or index == (len(youtube_results) - 1):
|
|
109
|
+
self.core.storage.save()
|
|
80
110
|
continue
|
|
81
111
|
|
|
82
112
|
tracks_match = self.core.matcher.match_tracks(
|
|
83
113
|
video_info=video, metadata=spotify_search_result
|
|
84
114
|
)
|
|
85
115
|
if not tracks_match:
|
|
116
|
+
logger.debug(
|
|
117
|
+
f"Provider metadata for '{video_title}' didn't match, "
|
|
118
|
+
"falling back to YouTube-derived metadata"
|
|
119
|
+
)
|
|
120
|
+
fallback = _fallback_metadata(video)
|
|
86
121
|
self.core.storage.new(
|
|
87
|
-
query=video_title, result=
|
|
122
|
+
query=video_title, result=fallback, query_type="metadata"
|
|
88
123
|
)
|
|
89
124
|
self.core.storage.new(
|
|
90
|
-
query=video_title, result=
|
|
125
|
+
query=video_title, result=video, query_type="youtube"
|
|
91
126
|
)
|
|
92
|
-
|
|
127
|
+
provider_playlist.append(fallback)
|
|
128
|
+
youtube_videos.append(video)
|
|
93
129
|
else:
|
|
94
130
|
self.core.storage.new(
|
|
95
131
|
query=video_title,
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import md5
|
|
4
|
+
from os.path import join, exists
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from spots_cli.models import (
|
|
9
|
+
ResolutionUnavailable,
|
|
10
|
+
TitleExistsError,
|
|
11
|
+
Metadata,
|
|
12
|
+
VideoResolution,
|
|
13
|
+
YTVideoInfo,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from spots_cli.bootstrap.container import Core, Clients
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Downloader:
|
|
21
|
+
"""A service for interacting with YouTube."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *, core: Core, clients: Clients):
|
|
24
|
+
self.core = core
|
|
25
|
+
self.clients = clients
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _filename(video_info: YTVideoInfo) -> str:
|
|
29
|
+
"""A file-system safe name for a video: "Uploader - Title"."""
|
|
30
|
+
# '/' will read file name as folder in *nix systems
|
|
31
|
+
filename = video_info.full_title.replace("/", "|")
|
|
32
|
+
# Check if the file name length is too long, truncate if necessary
|
|
33
|
+
max_filename_length = 255 # Maximum allowed file name length on most systems
|
|
34
|
+
if len(filename) > max_filename_length:
|
|
35
|
+
filename = md5(filename.encode()).hexdigest()[:25]
|
|
36
|
+
return filename
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _download_folder(directory_path: str) -> str:
|
|
40
|
+
"""The folder downloads are saved to: ~/Downloads (or ~), plus directory_path."""
|
|
41
|
+
download_folder = Path.home() / "Downloads"
|
|
42
|
+
if not exists(download_folder):
|
|
43
|
+
download_folder = Path.home()
|
|
44
|
+
return join(download_folder, directory_path)
|
|
45
|
+
|
|
46
|
+
def download(
|
|
47
|
+
self, *, video_info: YTVideoInfo, metadata: Metadata, directory_path: str = ""
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""Downloads a YouTube video as mp3
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
video_info (YTVideoInfo): the extracted info.
|
|
53
|
+
directory_path (str, Optional): the folder to be downloaded to. Defaults to the root of the Music folder.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
bool: True if downloaded successfully.
|
|
57
|
+
"""
|
|
58
|
+
url = video_info.video_link
|
|
59
|
+
filename = self._filename(video_info)
|
|
60
|
+
|
|
61
|
+
# check if downloaded already
|
|
62
|
+
if self.core.history.read(filename):
|
|
63
|
+
raise TitleExistsError(filename)
|
|
64
|
+
|
|
65
|
+
folder = self._download_folder(directory_path)
|
|
66
|
+
download_path = join(folder, f"{filename}.{video_info.audio_ext}")
|
|
67
|
+
converted_path = join(folder, f"{filename}.mp3")
|
|
68
|
+
|
|
69
|
+
# set template for download titles
|
|
70
|
+
self.clients.ytdlp.options = {"outtmpl": download_path}
|
|
71
|
+
|
|
72
|
+
# download video
|
|
73
|
+
self.clients.ytdlp.client.download([url])
|
|
74
|
+
|
|
75
|
+
self.clients.ytdlp.reset_options()
|
|
76
|
+
|
|
77
|
+
# post download processing
|
|
78
|
+
converted = self.core.converter.convert_to_mp3(
|
|
79
|
+
old_file=download_path, new_file=converted_path
|
|
80
|
+
)
|
|
81
|
+
if converted:
|
|
82
|
+
metadata_updated = self.core.converter.update_metadata(
|
|
83
|
+
audio_path=converted_path, metadata=metadata
|
|
84
|
+
)
|
|
85
|
+
if metadata_updated:
|
|
86
|
+
self.core.history.write(filename)
|
|
87
|
+
else:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def get_available_resolutions(
|
|
93
|
+
self, *, video_info: YTVideoInfo
|
|
94
|
+
) -> dict[str, VideoResolution]:
|
|
95
|
+
"""
|
|
96
|
+
Detects all available video resolutions for a YouTube video.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
video_info (YTVideoInfo): the extracted video info (only .id is used
|
|
100
|
+
to look up the video).
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
dict[str, VideoResolution]: maps a resolution label (e.g. "1080p")
|
|
104
|
+
to a VideoResolution describing the best available format at
|
|
105
|
+
that resolution. filesize_mb is reported in megabytes (MB).
|
|
106
|
+
Most high-resolution formats are video-only (acodec == "none");
|
|
107
|
+
download_video automatically merges in the best available audio
|
|
108
|
+
track for those.
|
|
109
|
+
"""
|
|
110
|
+
url = video_info.video_link
|
|
111
|
+
|
|
112
|
+
self.clients.ytdlp.options = {"quiet": True, "noplaylist": True}
|
|
113
|
+
try:
|
|
114
|
+
info = self.clients.ytdlp.client.extract_info(url, download=False)
|
|
115
|
+
finally:
|
|
116
|
+
self.clients.ytdlp.reset_options()
|
|
117
|
+
|
|
118
|
+
all_formats = info.get("formats", [])
|
|
119
|
+
|
|
120
|
+
# Estimate the size of the audio track yt-dlp will pick for "bestaudio",
|
|
121
|
+
# so video-only formats can report a total (video + audio) download size
|
|
122
|
+
# instead of just the video stream's size.
|
|
123
|
+
best_audio_bytes = 0
|
|
124
|
+
best_audio_abr = -1.0
|
|
125
|
+
for fmt in all_formats:
|
|
126
|
+
is_audio_only = fmt.get("vcodec") in (None, "none") and fmt.get(
|
|
127
|
+
"acodec"
|
|
128
|
+
) not in (None, "none")
|
|
129
|
+
if not is_audio_only:
|
|
130
|
+
continue
|
|
131
|
+
abr = fmt.get("abr") or 0
|
|
132
|
+
if abr > best_audio_abr:
|
|
133
|
+
best_audio_abr = abr
|
|
134
|
+
best_audio_bytes = (
|
|
135
|
+
fmt.get("filesize") or fmt.get("filesize_approx") or 0
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
resolutions: dict[str, VideoResolution] = {}
|
|
139
|
+
for fmt in all_formats:
|
|
140
|
+
height = fmt.get("height")
|
|
141
|
+
# skip audio-only formats and formats with no video track
|
|
142
|
+
if not height or fmt.get("vcodec") in (None, "none"):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
label = f"{height}p"
|
|
146
|
+
is_video_only = fmt.get("acodec") in (None, "none")
|
|
147
|
+
video_bytes = fmt.get("filesize") or fmt.get(
|
|
148
|
+
"filesize_approx") or 0
|
|
149
|
+
# video-only formats get merged with bestaudio on download, so
|
|
150
|
+
# reflect that in the reported size; progressive formats already
|
|
151
|
+
# include audio in their own filesize.
|
|
152
|
+
total_bytes = video_bytes + best_audio_bytes if is_video_only else video_bytes
|
|
153
|
+
|
|
154
|
+
existing = resolutions.get(label)
|
|
155
|
+
existing_bytes = (existing.filesize_mb or 0) * \
|
|
156
|
+
1024 * 1024 if existing else -1
|
|
157
|
+
|
|
158
|
+
# keep the best-quality format available for each resolution
|
|
159
|
+
if not existing or total_bytes > existing_bytes:
|
|
160
|
+
resolutions[label] = VideoResolution(
|
|
161
|
+
format_id=fmt.get("format_id"),
|
|
162
|
+
height=height,
|
|
163
|
+
ext=fmt.get("ext"),
|
|
164
|
+
vcodec=fmt.get("vcodec"),
|
|
165
|
+
acodec=fmt.get("acodec"),
|
|
166
|
+
fps=fmt.get("fps"),
|
|
167
|
+
filesize_mb=(
|
|
168
|
+
round(total_bytes / (1024 * 1024),
|
|
169
|
+
2) if total_bytes else None
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return resolutions
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def pick_resolution(
|
|
177
|
+
resolutions: dict[str, VideoResolution], label: str | None = None
|
|
178
|
+
) -> VideoResolution:
|
|
179
|
+
"""
|
|
180
|
+
Chooses one resolution from get_available_resolutions().
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
resolutions: the available resolutions, keyed by label.
|
|
184
|
+
label (str, Optional): e.g. "720p" (a bare "720" is accepted too).
|
|
185
|
+
Defaults to the highest resolution available.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ResolutionUnavailable: no resolutions, or `label` is not among them.
|
|
189
|
+
"""
|
|
190
|
+
by_height = sorted(resolutions.values(), key=lambda r: r.height)
|
|
191
|
+
available = [r.label for r in by_height]
|
|
192
|
+
|
|
193
|
+
if label is None:
|
|
194
|
+
if not by_height:
|
|
195
|
+
raise ResolutionUnavailable("best", available)
|
|
196
|
+
return by_height[-1]
|
|
197
|
+
|
|
198
|
+
wanted = label.strip().lower()
|
|
199
|
+
if not wanted.endswith("p"):
|
|
200
|
+
wanted += "p"
|
|
201
|
+
if wanted not in resolutions:
|
|
202
|
+
raise ResolutionUnavailable(wanted, available)
|
|
203
|
+
return resolutions[wanted]
|
|
204
|
+
|
|
205
|
+
def download_video(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
video_info: YTVideoInfo,
|
|
209
|
+
resolution: VideoResolution | str | None = None,
|
|
210
|
+
directory_path: str = "",
|
|
211
|
+
) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Downloads a YouTube video as mp4 at a chosen resolution.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
video_info (YTVideoInfo): the extracted info.
|
|
217
|
+
resolution (VideoResolution | str, Optional): one of the values
|
|
218
|
+
returned by get_available_resolutions(), a label such as
|
|
219
|
+
"1080p", or None for the highest resolution available.
|
|
220
|
+
directory_path (str, Optional): the folder to be downloaded to.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
str: the path of the downloaded file.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ResolutionUnavailable: the requested label is not offered.
|
|
227
|
+
"""
|
|
228
|
+
if not isinstance(resolution, VideoResolution):
|
|
229
|
+
resolution = self.pick_resolution(
|
|
230
|
+
self.get_available_resolutions(video_info=video_info), resolution
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
url = video_info.video_link
|
|
234
|
+
download_path = join(
|
|
235
|
+
self._download_folder(directory_path),
|
|
236
|
+
f"{self._filename(video_info)}.%(ext)s",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# video-only formats (common at higher resolutions) need audio merged in
|
|
240
|
+
format_selector = (
|
|
241
|
+
f"{resolution.format_id}+bestaudio/best"
|
|
242
|
+
if resolution.is_video_only
|
|
243
|
+
else resolution.format_id
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
self.clients.ytdlp.options = {
|
|
247
|
+
"outtmpl": download_path,
|
|
248
|
+
"format": format_selector,
|
|
249
|
+
"merge_output_format": "mp4",
|
|
250
|
+
"postprocessors": [
|
|
251
|
+
{"key": "FFmpegVideoConvertor", "preferedformat": "mp4"}
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
info = self.clients.ytdlp.client.extract_info(url, download=True)
|
|
257
|
+
finally:
|
|
258
|
+
self.clients.ytdlp.reset_options()
|
|
259
|
+
|
|
260
|
+
return info["requested_downloads"][-1]["filepath"]
|