SpotDown 0.1.1__py3-none-any.whl → 1.3.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.
- SpotDown/downloader/youtube_downloader.py +20 -6
- SpotDown/extractor/spotify_extractor.py +165 -278
- SpotDown/extractor/youtube_extractor.py +15 -1
- SpotDown/main.py +6 -8
- SpotDown/upload/version.py +2 -2
- SpotDown/utils/config_json.py +2 -2
- SpotDown/utils/console_utils.py +1 -1
- SpotDown/utils/ffmpeg_installer.py +374 -0
- SpotDown/utils/file_utils.py +105 -1
- SpotDown/utils/logger.py +90 -0
- {spotdown-0.1.1.dist-info → spotdown-1.3.0.dist-info}/METADATA +63 -30
- spotdown-1.3.0.dist-info/RECORD +21 -0
- spotdown-0.1.1.dist-info/RECORD +0 -19
- {spotdown-0.1.1.dist-info → spotdown-1.3.0.dist-info}/WHEEL +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.3.0.dist-info}/entry_points.txt +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.3.0.dist-info}/top_level.txt +0 -0
@@ -3,6 +3,7 @@
|
|
3
3
|
import re
|
4
4
|
import json
|
5
5
|
import difflib
|
6
|
+
import logging
|
6
7
|
from urllib.parse import quote_plus
|
7
8
|
from typing import Dict, List, Optional
|
8
9
|
|
@@ -42,6 +43,7 @@ class YouTubeExtractor:
|
|
42
43
|
List[Dict]: List of found videos
|
43
44
|
"""
|
44
45
|
try:
|
46
|
+
logging.info(f"Starting YouTube search for query: {query}")
|
45
47
|
search_url = f"https://www.youtube.com/results?search_query={quote_plus(query)}"
|
46
48
|
console.print(f"\n[bold blue]Searching on YouTube:[/bold blue] {query}")
|
47
49
|
|
@@ -49,9 +51,12 @@ class YouTubeExtractor:
|
|
49
51
|
response = client.get(search_url, headers={"User-Agent": get_userAgent()})
|
50
52
|
html = response.text
|
51
53
|
|
52
|
-
|
54
|
+
results = self._extract_youtube_videos(html, max_results)
|
55
|
+
logging.info(f"Found {len(results)} results for query: {query}")
|
56
|
+
return results
|
53
57
|
|
54
58
|
except Exception as e:
|
59
|
+
logging.error(f"YouTube search error: {e}")
|
55
60
|
print(f"YouTube search error: {e}")
|
56
61
|
return []
|
57
62
|
|
@@ -63,6 +68,7 @@ class YouTubeExtractor:
|
|
63
68
|
youtube_results (List[Dict]): List of YouTube videos
|
64
69
|
target_duration (int): Target duration in seconds
|
65
70
|
"""
|
71
|
+
logging.info(f"Sorting {len(youtube_results)} results by duration similarity to {target_duration}s")
|
66
72
|
for result in youtube_results:
|
67
73
|
if result.get('duration_seconds') is not None:
|
68
74
|
result['duration_difference'] = abs(result['duration_seconds'] - target_duration)
|
@@ -80,6 +86,7 @@ class YouTubeExtractor:
|
|
80
86
|
youtube_results (List[Dict]): List of YouTube videos
|
81
87
|
spotify_info (Dict): Spotify track info
|
82
88
|
"""
|
89
|
+
logging.info(f"Sorting {len(youtube_results)} results by affinity and duration using Spotify info")
|
83
90
|
target_duration = spotify_info.get('duration_seconds')
|
84
91
|
target_title = spotify_info.get('title', '').lower()
|
85
92
|
target_artist = spotify_info.get('artist', '').lower()
|
@@ -123,7 +130,9 @@ class YouTubeExtractor:
|
|
123
130
|
"""Extract videos from YouTube HTML"""
|
124
131
|
try:
|
125
132
|
yt_match = re.search(r'var ytInitialData = ({.+?});', html, re.DOTALL)
|
133
|
+
|
126
134
|
if not yt_match:
|
135
|
+
logging.warning("ytInitialData not found in HTML")
|
127
136
|
return []
|
128
137
|
|
129
138
|
yt_data = json.loads(yt_match.group(1))
|
@@ -152,9 +161,11 @@ class YouTubeExtractor:
|
|
152
161
|
if len(results) >= max_results:
|
153
162
|
break
|
154
163
|
|
164
|
+
logging.info(f"Extracted {len(results)} video(s) from HTML")
|
155
165
|
return results
|
156
166
|
|
157
167
|
except Exception as e:
|
168
|
+
logging.error(f"Video extraction error: {e}")
|
158
169
|
print(f"Video extraction error: {e}")
|
159
170
|
return []
|
160
171
|
|
@@ -163,6 +174,7 @@ class YouTubeExtractor:
|
|
163
174
|
try:
|
164
175
|
video_id = video_data.get('videoId')
|
165
176
|
if not video_id:
|
177
|
+
logging.warning("videoId not found in video_data")
|
166
178
|
return None
|
167
179
|
|
168
180
|
# Title
|
@@ -187,6 +199,7 @@ class YouTubeExtractor:
|
|
187
199
|
# Published date
|
188
200
|
published = self._extract_text(video_data.get('publishedTimeText', {}))
|
189
201
|
|
202
|
+
logging.info(f"Parsed video: {title} (ID: {video_id})")
|
190
203
|
return {
|
191
204
|
'video_id': video_id,
|
192
205
|
'url': f'https://www.youtube.com/watch?v={video_id}',
|
@@ -200,6 +213,7 @@ class YouTubeExtractor:
|
|
200
213
|
}
|
201
214
|
|
202
215
|
except Exception as e:
|
216
|
+
logging.error(f"Video parsing error: {e}")
|
203
217
|
print(f"Video parsing error: {e}")
|
204
218
|
return None
|
205
219
|
|
SpotDown/main.py
CHANGED
@@ -1,12 +1,14 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import time
|
4
|
-
import logging
|
5
4
|
from typing import Dict, List, Optional
|
6
5
|
|
7
6
|
|
8
7
|
# Internal utils
|
8
|
+
from SpotDown.utils.logger import Logger
|
9
|
+
from SpotDown.utils.file_utils import file_utils
|
9
10
|
from SpotDown.utils.console_utils import ConsoleUtils
|
11
|
+
from SpotDown.upload.update import update as git_update
|
10
12
|
from SpotDown.extractor.spotify_extractor import SpotifyExtractor
|
11
13
|
from SpotDown.extractor.youtube_extractor import YouTubeExtractor
|
12
14
|
from SpotDown.downloader.youtube_downloader import YouTubeDownloader
|
@@ -17,11 +19,6 @@ from SpotDown.downloader.youtube_downloader import YouTubeDownloader
|
|
17
19
|
console = ConsoleUtils()
|
18
20
|
|
19
21
|
|
20
|
-
def setup_logging():
|
21
|
-
"""Initialize basic logging configuration"""
|
22
|
-
logging.basicConfig(level=logging.ERROR)
|
23
|
-
|
24
|
-
|
25
22
|
def extract_spotify_data(spotify_url: str, max_retry: int = 3) -> Optional[Dict]:
|
26
23
|
"""Extract data from Spotify URL with retry mechanism"""
|
27
24
|
for attempt in range(1, max_retry + 1):
|
@@ -108,11 +105,12 @@ def handle_single_track_download(spotify_info: Dict, max_results: int):
|
|
108
105
|
|
109
106
|
def run():
|
110
107
|
"""Main execution function"""
|
111
|
-
|
108
|
+
Logger()
|
112
109
|
|
113
110
|
console = ConsoleUtils()
|
114
111
|
console.start_message()
|
115
|
-
|
112
|
+
git_update()
|
113
|
+
file_utils.get_system_summary()
|
116
114
|
|
117
115
|
spotify_url = console.get_spotify_url()
|
118
116
|
max_results = 5
|
SpotDown/upload/version.py
CHANGED
SpotDown/utils/config_json.py
CHANGED
@@ -44,7 +44,7 @@ class ConfigManager:
|
|
44
44
|
|
45
45
|
def download_config(self) -> None:
|
46
46
|
"""Download config.json from the Arrowar/SpotDown GitHub repository."""
|
47
|
-
url = "https://raw.githubusercontent.com/Arrowar/SpotDown/main/config.json"
|
47
|
+
url = "https://raw.githubusercontent.com/Arrowar/SpotDown/refs/heads/main/config.json"
|
48
48
|
try:
|
49
49
|
with httpx.Client(timeout=10, headers=get_headers()) as client:
|
50
50
|
response = client.get(url)
|
@@ -220,4 +220,4 @@ class ConfigManager:
|
|
220
220
|
return section in config_source
|
221
221
|
|
222
222
|
|
223
|
-
config_manager = ConfigManager()
|
223
|
+
config_manager = ConfigManager()
|
SpotDown/utils/console_utils.py
CHANGED
@@ -157,7 +157,7 @@ class ConsoleUtils:
|
|
157
157
|
str: Entered Spotify URL
|
158
158
|
"""
|
159
159
|
while True:
|
160
|
-
url = Prompt.ask("[purple]Enter Spotify URL[/purple][green]").strip()
|
160
|
+
url = Prompt.ask("\n[purple]Enter Spotify URL[/purple][green]").strip()
|
161
161
|
|
162
162
|
if not url:
|
163
163
|
self.console.print("[red]URL cannot be empty. Please enter a Spotify track URL.[/red]")
|
@@ -0,0 +1,374 @@
|
|
1
|
+
# 24.01.2024
|
2
|
+
|
3
|
+
import os
|
4
|
+
import glob
|
5
|
+
import gzip
|
6
|
+
import shutil
|
7
|
+
import logging
|
8
|
+
import platform
|
9
|
+
import subprocess
|
10
|
+
from typing import Optional, Tuple
|
11
|
+
|
12
|
+
|
13
|
+
# External library
|
14
|
+
import requests
|
15
|
+
from rich.console import Console
|
16
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
|
17
|
+
|
18
|
+
|
19
|
+
# Variable
|
20
|
+
console = Console()
|
21
|
+
|
22
|
+
|
23
|
+
FFMPEG_CONFIGURATION = {
|
24
|
+
'windows': {
|
25
|
+
'base_dir': lambda home: os.path.join(os.path.splitdrive(home)[0] + os.path.sep, 'binary'),
|
26
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-win32-{arch}.gz',
|
27
|
+
'file_extension': '.gz',
|
28
|
+
'executables': ['ffmpeg-win32-{arch}', 'ffprobe-win32-{arch}']
|
29
|
+
},
|
30
|
+
'darwin': {
|
31
|
+
'base_dir': lambda home: os.path.join(home, 'Applications', 'binary'),
|
32
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-darwin-{arch}.gz',
|
33
|
+
'file_extension': '.gz',
|
34
|
+
'executables': ['ffmpeg-darwin-{arch}', 'ffprobe-darwin-{arch}']
|
35
|
+
},
|
36
|
+
'linux': {
|
37
|
+
'base_dir': lambda home: os.path.join(home, '.local', 'bin', 'binary'),
|
38
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-linux-{arch}.gz',
|
39
|
+
'file_extension': '.gz',
|
40
|
+
'executables': ['ffmpeg-linux-{arch}', 'ffprobe-linux-{arch}']
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
|
45
|
+
class FFMPEGDownloader:
|
46
|
+
def __init__(self):
|
47
|
+
self.os_name = self._detect_system()
|
48
|
+
self.arch = self._detect_arch()
|
49
|
+
self.home_dir = os.path.expanduser('~')
|
50
|
+
self.base_dir = self._get_base_directory()
|
51
|
+
|
52
|
+
def _detect_system(self) -> str:
|
53
|
+
"""
|
54
|
+
Detect and normalize the operating system name.
|
55
|
+
|
56
|
+
Returns:
|
57
|
+
str: Normalized operating system name ('windows', 'darwin', or 'linux')
|
58
|
+
"""
|
59
|
+
system = platform.system().lower()
|
60
|
+
if system in FFMPEG_CONFIGURATION:
|
61
|
+
return system
|
62
|
+
raise ValueError(f"Unsupported operating system: {system}")
|
63
|
+
|
64
|
+
def _detect_arch(self) -> str:
|
65
|
+
"""
|
66
|
+
Detect and normalize the system architecture.
|
67
|
+
|
68
|
+
Returns:
|
69
|
+
str: Normalized architecture name (e.g., 'x86_64', 'arm64')
|
70
|
+
"""
|
71
|
+
machine = platform.machine().lower()
|
72
|
+
arch_map = {
|
73
|
+
'amd64': 'x64',
|
74
|
+
'x86_64': 'x64',
|
75
|
+
'x64': 'x64',
|
76
|
+
'arm64': 'arm64',
|
77
|
+
'aarch64': 'arm64',
|
78
|
+
'armv7l': 'arm',
|
79
|
+
'i386': 'ia32',
|
80
|
+
'i686': 'ia32'
|
81
|
+
}
|
82
|
+
return arch_map.get(machine, machine)
|
83
|
+
|
84
|
+
def _get_base_directory(self) -> str:
|
85
|
+
"""
|
86
|
+
Get and create the base directory for storing FFmpeg binaries.
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
str: Path to the base directory
|
90
|
+
"""
|
91
|
+
base_dir = FFMPEG_CONFIGURATION[self.os_name]['base_dir'](self.home_dir)
|
92
|
+
os.makedirs(base_dir, exist_ok=True)
|
93
|
+
return base_dir
|
94
|
+
|
95
|
+
def _check_existing_binaries(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
96
|
+
"""
|
97
|
+
Check if FFmpeg binaries already exist in the base directory.
|
98
|
+
Enhanced to check both the binary directory and system paths on macOS.
|
99
|
+
"""
|
100
|
+
config = FFMPEG_CONFIGURATION[self.os_name]
|
101
|
+
executables = config['executables']
|
102
|
+
found_executables = []
|
103
|
+
|
104
|
+
# For macOS, check both binary directory and system paths
|
105
|
+
if self.os_name == 'darwin':
|
106
|
+
potential_paths = [
|
107
|
+
'/usr/local/bin',
|
108
|
+
'/opt/homebrew/bin',
|
109
|
+
'/usr/bin',
|
110
|
+
self.base_dir
|
111
|
+
]
|
112
|
+
|
113
|
+
for executable in executables:
|
114
|
+
found = None
|
115
|
+
for path in potential_paths:
|
116
|
+
full_path = os.path.join(path, executable)
|
117
|
+
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
118
|
+
found = full_path
|
119
|
+
break
|
120
|
+
found_executables.append(found)
|
121
|
+
else:
|
122
|
+
|
123
|
+
# Original behavior for other operating systems
|
124
|
+
for executable in executables:
|
125
|
+
exe_paths = glob.glob(os.path.join(self.base_dir, executable))
|
126
|
+
found_executables.append(exe_paths[0] if exe_paths else None)
|
127
|
+
|
128
|
+
return tuple(found_executables) if len(found_executables) == 3 else (None, None, None)
|
129
|
+
|
130
|
+
def _get_latest_version(self, repo: str) -> Optional[str]:
|
131
|
+
"""
|
132
|
+
Get the latest FFmpeg version from the GitHub releases page.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
Optional[str]: The latest version string, or None if retrieval fails.
|
136
|
+
"""
|
137
|
+
try:
|
138
|
+
# Use GitHub API to fetch the latest release
|
139
|
+
response = requests.get(f'https://api.github.com/repos/{repo}/releases/latest')
|
140
|
+
response.raise_for_status()
|
141
|
+
latest_release = response.json()
|
142
|
+
|
143
|
+
# Extract the tag name or version from the release
|
144
|
+
return latest_release.get('tag_name')
|
145
|
+
|
146
|
+
except Exception as e:
|
147
|
+
logging.error(f"Unable to get version from GitHub: {e}")
|
148
|
+
return None
|
149
|
+
|
150
|
+
def _download_file(self, url: str, destination: str) -> bool:
|
151
|
+
"""
|
152
|
+
Download a file from URL with a Rich progress bar display.
|
153
|
+
|
154
|
+
Parameters:
|
155
|
+
url (str): The URL to download the file from. Should be a direct download link.
|
156
|
+
destination (str): Local file path where the downloaded file will be saved.
|
157
|
+
|
158
|
+
Returns:
|
159
|
+
bool: True if download was successful, False otherwise.
|
160
|
+
"""
|
161
|
+
try:
|
162
|
+
response = requests.get(url, stream=True)
|
163
|
+
response.raise_for_status()
|
164
|
+
total_size = int(response.headers.get('content-length', 0))
|
165
|
+
|
166
|
+
with open(destination, 'wb') as file, \
|
167
|
+
Progress(
|
168
|
+
SpinnerColumn(),
|
169
|
+
TextColumn("[progress.description]{task.description}"),
|
170
|
+
BarColumn(),
|
171
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
172
|
+
TimeRemainingColumn()
|
173
|
+
) as progress:
|
174
|
+
|
175
|
+
download_task = progress.add_task("[green]Downloading FFmpeg", total=total_size)
|
176
|
+
for chunk in response.iter_content(chunk_size=8192):
|
177
|
+
size = file.write(chunk)
|
178
|
+
progress.update(download_task, advance=size)
|
179
|
+
return True
|
180
|
+
|
181
|
+
except Exception as e:
|
182
|
+
logging.error(f"Download error: {e}")
|
183
|
+
return False
|
184
|
+
|
185
|
+
def _extract_file(self, gz_path: str, final_path: str) -> bool:
|
186
|
+
"""
|
187
|
+
Extract a gzipped file and set proper permissions.
|
188
|
+
|
189
|
+
Parameters:
|
190
|
+
gz_path (str): Path to the gzipped file
|
191
|
+
final_path (str): Path where the extracted file should be saved
|
192
|
+
|
193
|
+
Returns:
|
194
|
+
bool: True if extraction was successful, False otherwise
|
195
|
+
"""
|
196
|
+
try:
|
197
|
+
logging.info(f"Attempting to extract {gz_path} to {final_path}")
|
198
|
+
|
199
|
+
# Check if source file exists and is readable
|
200
|
+
if not os.path.exists(gz_path):
|
201
|
+
logging.error(f"Source file {gz_path} does not exist")
|
202
|
+
return False
|
203
|
+
|
204
|
+
if not os.access(gz_path, os.R_OK):
|
205
|
+
logging.error(f"Source file {gz_path} is not readable")
|
206
|
+
return False
|
207
|
+
|
208
|
+
# Extract the file
|
209
|
+
with gzip.open(gz_path, 'rb') as f_in:
|
210
|
+
# Test if the gzip file is valid
|
211
|
+
try:
|
212
|
+
f_in.read(1)
|
213
|
+
f_in.seek(0)
|
214
|
+
except Exception as e:
|
215
|
+
logging.error(f"Invalid gzip file {gz_path}: {e}")
|
216
|
+
return False
|
217
|
+
|
218
|
+
# Extract the file
|
219
|
+
with open(final_path, 'wb') as f_out:
|
220
|
+
shutil.copyfileobj(f_in, f_out)
|
221
|
+
|
222
|
+
# Set executable permissions
|
223
|
+
os.chmod(final_path, 0o755)
|
224
|
+
logging.info(f"Successfully extracted {gz_path} to {final_path}")
|
225
|
+
|
226
|
+
# Remove the gzip file
|
227
|
+
os.remove(gz_path)
|
228
|
+
return True
|
229
|
+
|
230
|
+
except Exception as e:
|
231
|
+
logging.error(f"Extraction error for {gz_path}: {e}")
|
232
|
+
return False
|
233
|
+
|
234
|
+
def download(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
235
|
+
"""
|
236
|
+
Main method to download and set up FFmpeg executables.
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
Tuple[Optional[str], Optional[str], Optional[str]]: Paths to ffmpeg, ffprobe, and ffplay executables.
|
240
|
+
"""
|
241
|
+
if self.os_name == 'linux':
|
242
|
+
try:
|
243
|
+
# Attempt to install FFmpeg using apt
|
244
|
+
console.print("[bold blue]Trying to install FFmpeg using 'sudo apt install ffmpeg'[/]")
|
245
|
+
result = subprocess.run(
|
246
|
+
['sudo', 'apt', 'install', '-y', 'ffmpeg'],
|
247
|
+
stdout=subprocess.PIPE,
|
248
|
+
stderr=subprocess.PIPE,
|
249
|
+
text=True
|
250
|
+
)
|
251
|
+
if result.returncode == 0:
|
252
|
+
ffmpeg_path = shutil.which('ffmpeg')
|
253
|
+
ffprobe_path = shutil.which('ffprobe')
|
254
|
+
|
255
|
+
if ffmpeg_path and ffprobe_path:
|
256
|
+
console.print("[bold green]FFmpeg successfully installed via apt[/]")
|
257
|
+
return ffmpeg_path, ffprobe_path, None
|
258
|
+
else:
|
259
|
+
console.print("[bold yellow]Failed to install FFmpeg via apt. Proceeding with static download.[/]")
|
260
|
+
|
261
|
+
except Exception as e:
|
262
|
+
logging.error(f"Error during 'sudo apt install ffmpeg': {e}")
|
263
|
+
console.print("[bold red]Error during 'sudo apt install ffmpeg'. Proceeding with static download.[/]")
|
264
|
+
|
265
|
+
# Proceed with static download if apt installation fails or is not applicable
|
266
|
+
config = FFMPEG_CONFIGURATION[self.os_name]
|
267
|
+
executables = [exe.format(arch=self.arch) for exe in config['executables']]
|
268
|
+
successful_extractions = []
|
269
|
+
|
270
|
+
for executable in executables:
|
271
|
+
try:
|
272
|
+
download_url = f"https://github.com/eugeneware/ffmpeg-static/releases/latest/download/{executable}.gz"
|
273
|
+
download_path = os.path.join(self.base_dir, f"{executable}.gz")
|
274
|
+
final_path = os.path.join(self.base_dir, executable)
|
275
|
+
|
276
|
+
# Log the current operation
|
277
|
+
logging.info(f"Processing {executable}")
|
278
|
+
console.print(f"[bold blue]Downloading {executable} from GitHub[/]")
|
279
|
+
|
280
|
+
# Download the file
|
281
|
+
if not self._download_file(download_url, download_path):
|
282
|
+
console.print(f"[bold red]Failed to download {executable}[/]")
|
283
|
+
continue
|
284
|
+
|
285
|
+
# Extract the file
|
286
|
+
if self._extract_file(download_path, final_path):
|
287
|
+
successful_extractions.append(final_path)
|
288
|
+
console.print(f"[bold green]Successfully installed {executable}[/]")
|
289
|
+
else:
|
290
|
+
console.print(f"[bold red]Failed to extract {executable}[/]")
|
291
|
+
|
292
|
+
except Exception as e:
|
293
|
+
logging.error(f"Error processing {executable}: {e}")
|
294
|
+
console.print(f"[bold red]Error processing {executable}: {str(e)}[/]")
|
295
|
+
continue
|
296
|
+
|
297
|
+
# Return the results based on successful extractions
|
298
|
+
return (
|
299
|
+
successful_extractions[0] if len(successful_extractions) > 0 else None,
|
300
|
+
successful_extractions[1] if len(successful_extractions) > 1 else None,
|
301
|
+
None # ffplay is not included in the current implementation
|
302
|
+
)
|
303
|
+
|
304
|
+
def check_ffmpeg() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
305
|
+
"""
|
306
|
+
Check for FFmpeg executables in the system and download them if not found.
|
307
|
+
Enhanced detection for macOS systems.
|
308
|
+
|
309
|
+
Returns:
|
310
|
+
Tuple[Optional[str], Optional[str], Optional[str]]: Paths to ffmpeg, ffprobe, and ffplay executables.
|
311
|
+
"""
|
312
|
+
try:
|
313
|
+
system_platform = platform.system().lower()
|
314
|
+
|
315
|
+
# Special handling for macOS
|
316
|
+
if system_platform == 'darwin':
|
317
|
+
|
318
|
+
# Common installation paths on macOS
|
319
|
+
potential_paths = [
|
320
|
+
'/usr/local/bin', # Homebrew default
|
321
|
+
'/opt/homebrew/bin', # Apple Silicon Homebrew
|
322
|
+
'/usr/bin', # System default
|
323
|
+
os.path.expanduser('~/Applications/binary'), # Custom installation
|
324
|
+
'/Applications/binary' # Custom installation
|
325
|
+
]
|
326
|
+
|
327
|
+
for path in potential_paths:
|
328
|
+
ffmpeg_path = os.path.join(path, 'ffmpeg')
|
329
|
+
ffprobe_path = os.path.join(path, 'ffprobe')
|
330
|
+
ffplay_path = os.path.join(path, 'ffplay')
|
331
|
+
|
332
|
+
if (os.path.exists(ffmpeg_path) and os.path.exists(ffprobe_path) and
|
333
|
+
os.access(ffmpeg_path, os.X_OK) and os.access(ffprobe_path, os.X_OK)):
|
334
|
+
|
335
|
+
# Return found executables, with ffplay being optional
|
336
|
+
ffplay_path = ffplay_path if os.path.exists(ffplay_path) else None
|
337
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
338
|
+
|
339
|
+
# Windows detection
|
340
|
+
elif system_platform == 'windows':
|
341
|
+
try:
|
342
|
+
ffmpeg_path = subprocess.check_output(
|
343
|
+
['where', 'ffmpeg'], stderr=subprocess.DEVNULL, text=True
|
344
|
+
).strip().split('\n')[0]
|
345
|
+
|
346
|
+
ffprobe_path = subprocess.check_output(
|
347
|
+
['where', 'ffprobe'], stderr=subprocess.DEVNULL, text=True
|
348
|
+
).strip().split('\n')[0]
|
349
|
+
|
350
|
+
ffplay_path = subprocess.check_output(
|
351
|
+
['where', 'ffplay'], stderr=subprocess.DEVNULL, text=True
|
352
|
+
).strip().split('\n')[0]
|
353
|
+
|
354
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
355
|
+
|
356
|
+
except subprocess.CalledProcessError:
|
357
|
+
logging.warning("One or more FFmpeg binaries were not found with command where")
|
358
|
+
|
359
|
+
# Linux detection
|
360
|
+
else:
|
361
|
+
ffmpeg_path = shutil.which('ffmpeg')
|
362
|
+
ffprobe_path = shutil.which('ffprobe')
|
363
|
+
ffplay_path = shutil.which('ffplay')
|
364
|
+
|
365
|
+
if ffmpeg_path and ffprobe_path:
|
366
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
367
|
+
|
368
|
+
# If executables were not found, attempt to download FFmpeg
|
369
|
+
downloader = FFMPEGDownloader()
|
370
|
+
return downloader.download()
|
371
|
+
|
372
|
+
except Exception as e:
|
373
|
+
logging.error(f"Error checking or downloading FFmpeg executables: {e}")
|
374
|
+
return None, None, None
|
SpotDown/utils/file_utils.py
CHANGED
@@ -1,15 +1,30 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import re
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
import glob
|
4
7
|
from pathlib import Path
|
5
8
|
from typing import Optional
|
9
|
+
import platform
|
6
10
|
|
7
11
|
|
8
12
|
# External imports
|
13
|
+
from rich.console import Console
|
9
14
|
from unidecode import unidecode
|
10
15
|
|
11
16
|
|
17
|
+
# Internal logic
|
18
|
+
from .ffmpeg_installer import check_ffmpeg
|
19
|
+
|
20
|
+
|
21
|
+
# Variable
|
22
|
+
console = Console()
|
23
|
+
|
24
|
+
|
12
25
|
class FileUtils:
|
26
|
+
ffmpeg_path = None
|
27
|
+
ffprobe_path = None
|
13
28
|
|
14
29
|
@staticmethod
|
15
30
|
def get_music_folder() -> Path:
|
@@ -126,4 +141,93 @@ class FileUtils:
|
|
126
141
|
return files[0] if files else None
|
127
142
|
|
128
143
|
except Exception:
|
129
|
-
return None
|
144
|
+
return None
|
145
|
+
|
146
|
+
@staticmethod
|
147
|
+
def get_binary_directory():
|
148
|
+
"""Get the binary directory based on OS."""
|
149
|
+
system = platform.system().lower()
|
150
|
+
home = os.path.expanduser('~')
|
151
|
+
|
152
|
+
if system == 'windows':
|
153
|
+
return os.path.join(os.path.splitdrive(home)[0] + os.path.sep, 'binary')
|
154
|
+
elif system == 'darwin':
|
155
|
+
return os.path.join(home, 'Applications', 'binary')
|
156
|
+
else: # linux
|
157
|
+
return os.path.join(home, '.local', 'bin', 'binary')
|
158
|
+
|
159
|
+
@staticmethod
|
160
|
+
def get_ffmpeg_path():
|
161
|
+
"""Returns the path of FFmpeg."""
|
162
|
+
return FileUtils.ffmpeg_path
|
163
|
+
|
164
|
+
@staticmethod
|
165
|
+
def get_ffprobe_path():
|
166
|
+
"""Returns the path of FFprobe."""
|
167
|
+
return FileUtils.ffprobe_path
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def check_python_version():
|
171
|
+
"""
|
172
|
+
Check if the installed Python is the official CPython distribution.
|
173
|
+
Exits with a message if not the official version.
|
174
|
+
"""
|
175
|
+
python_implementation = platform.python_implementation()
|
176
|
+
python_version = platform.python_version()
|
177
|
+
|
178
|
+
if python_implementation != "CPython":
|
179
|
+
console.print(f"[bold red]Warning: You are using a non-official Python distribution: {python_implementation}.[/bold red]")
|
180
|
+
console.print("Please install the official Python from [bold blue]https://www.python.org[/bold blue] and try again.", style="bold yellow")
|
181
|
+
sys.exit(0)
|
182
|
+
|
183
|
+
console.print(f"[cyan]Python version: [bold red]{python_version}[/bold red]")
|
184
|
+
|
185
|
+
@staticmethod
|
186
|
+
def get_system_summary():
|
187
|
+
FileUtils.check_python_version()
|
188
|
+
|
189
|
+
# FFmpeg detection
|
190
|
+
binary_dir = FileUtils.get_binary_directory()
|
191
|
+
system = platform.system().lower()
|
192
|
+
arch = platform.machine().lower()
|
193
|
+
|
194
|
+
# Map architecture names
|
195
|
+
arch_map = {
|
196
|
+
'amd64': 'x64',
|
197
|
+
'x86_64': 'x64',
|
198
|
+
'x64': 'x64',
|
199
|
+
'arm64': 'arm64',
|
200
|
+
'aarch64': 'arm64',
|
201
|
+
'armv7l': 'arm',
|
202
|
+
'i386': 'ia32',
|
203
|
+
'i686': 'ia32'
|
204
|
+
}
|
205
|
+
arch = arch_map.get(arch, arch)
|
206
|
+
|
207
|
+
# Check FFmpeg binaries
|
208
|
+
if os.path.exists(binary_dir):
|
209
|
+
ffmpeg_files = glob.glob(os.path.join(binary_dir, f'*ffmpeg*{arch}*'))
|
210
|
+
ffprobe_files = glob.glob(os.path.join(binary_dir, f'*ffprobe*{arch}*'))
|
211
|
+
|
212
|
+
if ffmpeg_files and ffprobe_files:
|
213
|
+
FileUtils.ffmpeg_path = ffmpeg_files[0]
|
214
|
+
FileUtils.ffprobe_path = ffprobe_files[0]
|
215
|
+
|
216
|
+
if system != 'windows':
|
217
|
+
os.chmod(FileUtils.ffmpeg_path, 0o755)
|
218
|
+
os.chmod(FileUtils.ffprobe_path, 0o755)
|
219
|
+
else:
|
220
|
+
FileUtils.ffmpeg_path, FileUtils.ffprobe_path, ffplay_path = check_ffmpeg()
|
221
|
+
else:
|
222
|
+
FileUtils.ffmpeg_path, FileUtils.ffprobe_path, ffplay_path = check_ffmpeg()
|
223
|
+
|
224
|
+
if not FileUtils.ffmpeg_path or not FileUtils.ffprobe_path:
|
225
|
+
console.log("[red]Can't locate ffmpeg or ffprobe")
|
226
|
+
sys.exit(0)
|
227
|
+
|
228
|
+
ffmpeg_str = f"'{FileUtils.ffmpeg_path}'" if FileUtils.ffmpeg_path else "None"
|
229
|
+
ffprobe_str = f"'{FileUtils.ffprobe_path}'" if FileUtils.ffprobe_path else "None"
|
230
|
+
console.print(f"[cyan]Path: [red]ffmpeg [bold yellow]{ffmpeg_str}[/bold yellow][white], [red]ffprobe [bold yellow]{ffprobe_str}[/bold yellow][white].")
|
231
|
+
|
232
|
+
|
233
|
+
file_utils = FileUtils()
|