mooze 1.0.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.
- mooze/__init__.py +0 -0
- mooze/downloader.py +105 -0
- mooze/main.py +316 -0
- mooze-1.0.0.dist-info/METADATA +121 -0
- mooze-1.0.0.dist-info/RECORD +9 -0
- mooze-1.0.0.dist-info/WHEEL +5 -0
- mooze-1.0.0.dist-info/entry_points.txt +2 -0
- mooze-1.0.0.dist-info/licenses/LICENSE +25 -0
- mooze-1.0.0.dist-info/top_level.txt +1 -0
mooze/__init__.py
ADDED
|
File without changes
|
mooze/downloader.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import yt_dlp
|
|
2
|
+
import urllib.request
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
def get_spotify_query(url: str) -> str:
|
|
8
|
+
"""Uses a Googlebot disguise to extract both the song title AND the artist."""
|
|
9
|
+
clean_url = url.split('?')[0]
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
headers = {
|
|
13
|
+
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
|
|
14
|
+
}
|
|
15
|
+
req = urllib.request.Request(clean_url, headers=headers)
|
|
16
|
+
html = urllib.request.urlopen(req).read().decode('utf-8')
|
|
17
|
+
|
|
18
|
+
title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
|
|
19
|
+
if title_match:
|
|
20
|
+
raw_title = title_match.group(1)
|
|
21
|
+
clean_title = raw_title.replace('| Spotify', '').replace('- song and lyrics by', ' ').replace('- song by', ' ').replace('- single by', ' ')
|
|
22
|
+
if clean_title.strip():
|
|
23
|
+
return clean_title.strip()
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
oembed_url = f"https://open.spotify.com/oembed?url={clean_url}"
|
|
29
|
+
req = urllib.request.Request(oembed_url, headers={'User-Agent': 'Mozilla/5.0'})
|
|
30
|
+
response = urllib.request.urlopen(req).read().decode('utf-8')
|
|
31
|
+
data = json.loads(response)
|
|
32
|
+
|
|
33
|
+
if "title" in data and "author_name" in data:
|
|
34
|
+
return f"{data['title']} {data['author_name']}"
|
|
35
|
+
elif "title" in data:
|
|
36
|
+
return data["title"]
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
raise ValueError("Could not translate Spotify link. Try typing the song name instead!")
|
|
41
|
+
|
|
42
|
+
def download_song(search_query: str, save_location: str, format_choice: str, progress_callback=None):
|
|
43
|
+
|
|
44
|
+
if "spotify.com" in search_query:
|
|
45
|
+
search_query = get_spotify_query(search_query)
|
|
46
|
+
|
|
47
|
+
codec = "mp3"
|
|
48
|
+
quality = "192"
|
|
49
|
+
embed_art = True
|
|
50
|
+
|
|
51
|
+
# 1. Parse the custom syntax (e.g. ".flac, 192")
|
|
52
|
+
try:
|
|
53
|
+
parts = format_choice.split(",")
|
|
54
|
+
if len(parts) == 2:
|
|
55
|
+
codec = parts[0].strip()[1:].lower() # Removes the '.' and forces lowercase
|
|
56
|
+
# Extract just the digits in case they accidentally typed "192kbps"
|
|
57
|
+
quality_str = ''.join(filter(str.isdigit, parts[1]))
|
|
58
|
+
if quality_str:
|
|
59
|
+
quality = quality_str
|
|
60
|
+
except Exception:
|
|
61
|
+
pass # Fallback to mp3 defaults if parsing catastrophically fails
|
|
62
|
+
|
|
63
|
+
# 2. Prevent FFmpeg crashes by disabling image art for specific containers
|
|
64
|
+
if codec in ["wav", "flac", "opus", "ogg"]:
|
|
65
|
+
embed_art = False
|
|
66
|
+
|
|
67
|
+
def my_hook(d):
|
|
68
|
+
if d['status'] == 'downloading':
|
|
69
|
+
downloaded = d.get('downloaded_bytes', 0)
|
|
70
|
+
total = d.get('total_bytes') or d.get('total_bytes_estimate', 0)
|
|
71
|
+
if progress_callback and total > 0:
|
|
72
|
+
progress_callback(downloaded, total)
|
|
73
|
+
|
|
74
|
+
postprocessors: list[dict[str, Any]] = [{
|
|
75
|
+
'key': 'FFmpegExtractAudio',
|
|
76
|
+
'preferredcodec': codec,
|
|
77
|
+
'preferredquality': quality,
|
|
78
|
+
}]
|
|
79
|
+
|
|
80
|
+
if embed_art:
|
|
81
|
+
postprocessors.append({
|
|
82
|
+
'key': 'FFmpegThumbnailsConvertor',
|
|
83
|
+
'format': 'jpg',
|
|
84
|
+
})
|
|
85
|
+
postprocessors.append({
|
|
86
|
+
'key': 'EmbedThumbnail',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
postprocessors.append({
|
|
90
|
+
'key': 'FFmpegMetadata',
|
|
91
|
+
'add_metadata': True,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
options: Any = {
|
|
95
|
+
'format': 'bestaudio/best',
|
|
96
|
+
'outtmpl': f'{save_location}/%(title)s.%(ext)s',
|
|
97
|
+
'default_search': 'ytsearch1:',
|
|
98
|
+
'noplaylist': True,
|
|
99
|
+
'writethumbnail': embed_art,
|
|
100
|
+
'progress_hooks': [my_hook],
|
|
101
|
+
'postprocessors': postprocessors,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
with yt_dlp.YoutubeDL(options) as ydl:
|
|
105
|
+
ydl.download([search_query])
|
mooze/main.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.widgets import Header, Footer, Input, Button, TextArea, Label, ProgressBar, TabbedContent, TabPane, Collapsible
|
|
3
|
+
from textual.containers import Horizontal, Vertical
|
|
4
|
+
from textual import on, work, events
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from .downloader import download_song
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
CONFIG_FILE = os.path.expanduser("~/.mooze_settings.json")
|
|
14
|
+
|
|
15
|
+
class StaticHeader(Header):
|
|
16
|
+
def on_click(self, event: events.Click):
|
|
17
|
+
event.stop()
|
|
18
|
+
event.prevent_default()
|
|
19
|
+
|
|
20
|
+
def load_settings():
|
|
21
|
+
if os.path.exists(CONFIG_FILE):
|
|
22
|
+
try:
|
|
23
|
+
with open(CONFIG_FILE, "r") as f:
|
|
24
|
+
return json.load(f)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
def save_settings(format_choice, save_location, theme_choice):
|
|
30
|
+
try:
|
|
31
|
+
with open(CONFIG_FILE, "w") as f:
|
|
32
|
+
json.dump({
|
|
33
|
+
"format": format_choice,
|
|
34
|
+
"save_location": save_location,
|
|
35
|
+
"theme": theme_choice
|
|
36
|
+
}, f)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
class MoozeApp(App):
|
|
41
|
+
TITLE = "Mooze"
|
|
42
|
+
COMMAND_PALETTE_BINDING = ""
|
|
43
|
+
|
|
44
|
+
CSS = """
|
|
45
|
+
#main-area { height: 1fr; margin: 1; }
|
|
46
|
+
|
|
47
|
+
/* FIXED: Changed from 1fr to auto so it hugs the search inputs tightly */
|
|
48
|
+
#tabs { height: auto; }
|
|
49
|
+
#single-tab, #batch-tab { padding: 1 2; height: auto; }
|
|
50
|
+
|
|
51
|
+
#single-search-input { margin-bottom: 1; }
|
|
52
|
+
|
|
53
|
+
#batch-label { margin-bottom: 1; color: $text-muted; }
|
|
54
|
+
#batch-search-input { height: auto; min-height: 3; max-height: 12; }
|
|
55
|
+
|
|
56
|
+
#download-settings { margin-top: 1; height: auto; }
|
|
57
|
+
#options-row { height: auto; align: left middle; padding-top: 1; }
|
|
58
|
+
#options-row Input { width: 1fr; margin: 0 1; }
|
|
59
|
+
|
|
60
|
+
.download-btn { width: 100%; margin-top: 1; }
|
|
61
|
+
#my-progress-bar { height: auto; margin: 1 2; display: none; }
|
|
62
|
+
|
|
63
|
+
#options-row Input.error, #single-search-input.error, #batch-search-input.error { border: tall red; }
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
BINDINGS = [
|
|
67
|
+
Binding("d", "quit", "Quit Mooze"),
|
|
68
|
+
Binding("s", "save_png", "Screenshot (.png)"),
|
|
69
|
+
Binding("k", "toggle_footer", "Toggle Keys Help"),
|
|
70
|
+
Binding("escape", "remove_focus", "Deselect", show=False),
|
|
71
|
+
Binding("alt+p", "command_palette", "Palette", show=False),
|
|
72
|
+
Binding("ctrl+p", "command_palette", "Palette", show=False),
|
|
73
|
+
Binding("backslash", "command_palette", "Palette", show=False),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def compose(self) -> ComposeResult:
|
|
77
|
+
yield StaticHeader(show_clock=False)
|
|
78
|
+
|
|
79
|
+
with Vertical(id="main-area"):
|
|
80
|
+
with TabbedContent(id="tabs"):
|
|
81
|
+
with TabPane("Single Download", id="single-tab"):
|
|
82
|
+
yield Input(placeholder="Paste a Spotify link or search for a song here...", id="single-search-input")
|
|
83
|
+
|
|
84
|
+
with TabPane("Batch Download", id="batch-tab"):
|
|
85
|
+
yield Label("Paste your list of Spotify links (One per line):", id="batch-label")
|
|
86
|
+
yield TextArea(id="batch-search-input")
|
|
87
|
+
|
|
88
|
+
with Collapsible(title="Download Settings (Format & Save Path)", id="download-settings"):
|
|
89
|
+
with Horizontal(id="options-row"):
|
|
90
|
+
yield Input(placeholder="Format (e.g. .flac, 192)", id="format-input")
|
|
91
|
+
yield Input(placeholder="Save Path (e.g. C:/Music)", id="save-location")
|
|
92
|
+
|
|
93
|
+
yield Button("Start Download", variant="success", id="download-btn", classes="download-btn")
|
|
94
|
+
yield ProgressBar(id="my-progress-bar")
|
|
95
|
+
|
|
96
|
+
yield Footer()
|
|
97
|
+
|
|
98
|
+
def on_mount(self):
|
|
99
|
+
settings = load_settings()
|
|
100
|
+
|
|
101
|
+
if "theme" in settings and settings["theme"]:
|
|
102
|
+
self.theme = str(settings["theme"])
|
|
103
|
+
else:
|
|
104
|
+
self.theme = "textual-dark"
|
|
105
|
+
|
|
106
|
+
if "format" in settings and settings["format"]:
|
|
107
|
+
self.query_one("#format-input", Input).value = settings["format"]
|
|
108
|
+
|
|
109
|
+
if "save_location" in settings and settings["save_location"]:
|
|
110
|
+
self.query_one("#save-location", Input).value = settings["save_location"]
|
|
111
|
+
|
|
112
|
+
@on(events.MouseDown)
|
|
113
|
+
def handle_background_click(self, event: events.MouseDown):
|
|
114
|
+
widget, _ = self.screen.get_widget_at(event.screen_x, event.screen_y)
|
|
115
|
+
if widget and widget.id in ("main-area", "options-row", "single-tab", "batch-tab", "download-settings"):
|
|
116
|
+
self.set_focus(None)
|
|
117
|
+
|
|
118
|
+
def action_remove_focus(self):
|
|
119
|
+
self.set_focus(None)
|
|
120
|
+
|
|
121
|
+
def action_toggle_footer(self):
|
|
122
|
+
footer = self.query_one(Footer)
|
|
123
|
+
footer.display = not footer.display
|
|
124
|
+
|
|
125
|
+
# =========================================================================
|
|
126
|
+
# COMMAND PALETTE
|
|
127
|
+
# =========================================================================
|
|
128
|
+
def _apply_and_save_theme(self, theme_name: str):
|
|
129
|
+
self.theme = theme_name
|
|
130
|
+
fmt = self.query_one("#format-input", Input).value
|
|
131
|
+
loc = self.query_one("#save-location", Input).value
|
|
132
|
+
save_settings(fmt, loc, theme_name)
|
|
133
|
+
self.notify(f"Theme updated to {theme_name}", severity="information")
|
|
134
|
+
|
|
135
|
+
def action_theme_dark(self): self._apply_and_save_theme("textual-dark")
|
|
136
|
+
def action_theme_light(self): self._apply_and_save_theme("textual-light")
|
|
137
|
+
def action_theme_solarized_dark(self): self._apply_and_save_theme("solarized-dark")
|
|
138
|
+
def action_theme_solarized_light(self): self._apply_and_save_theme("solarized-light")
|
|
139
|
+
def action_theme_dracula(self): self._apply_and_save_theme("dracula")
|
|
140
|
+
def action_theme_nord(self): self._apply_and_save_theme("nord")
|
|
141
|
+
def action_theme_monokai(self): self._apply_and_save_theme("monokai")
|
|
142
|
+
|
|
143
|
+
def action_clear_inputs(self):
|
|
144
|
+
self.query_one("#single-search-input", Input).value = ""
|
|
145
|
+
self.query_one("#batch-search-input", TextArea).text = ""
|
|
146
|
+
self.notify("All inputs cleared.", severity="information")
|
|
147
|
+
|
|
148
|
+
def action_open_download_folder(self):
|
|
149
|
+
path = self.query_one("#save-location", Input).value
|
|
150
|
+
if not path or not os.path.exists(path):
|
|
151
|
+
self.notify("The save folder does not exist yet!", severity="error")
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
if sys.platform == "win32": os.startfile(path)
|
|
155
|
+
elif sys.platform == "darwin": subprocess.call(["open", path])
|
|
156
|
+
else: subprocess.call(["xdg-open", path])
|
|
157
|
+
except Exception as e:
|
|
158
|
+
self.notify(f"Could not open folder: {e}", severity="error")
|
|
159
|
+
|
|
160
|
+
# =========================================================================
|
|
161
|
+
# DOWNLOAD LOGIC & STRICT VALIDATION
|
|
162
|
+
# =========================================================================
|
|
163
|
+
|
|
164
|
+
@on(Button.Pressed)
|
|
165
|
+
def start_downloading(self, event: Button.Pressed):
|
|
166
|
+
if event.button.id != "download-btn":
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
format_input = self.query_one("#format-input", Input)
|
|
170
|
+
save_location = self.query_one("#save-location", Input)
|
|
171
|
+
single_input = self.query_one("#single-search-input", Input)
|
|
172
|
+
batch_input = self.query_one("#batch-search-input", TextArea)
|
|
173
|
+
|
|
174
|
+
format_input.remove_class("error")
|
|
175
|
+
save_location.remove_class("error")
|
|
176
|
+
single_input.remove_class("error")
|
|
177
|
+
batch_input.remove_class("error")
|
|
178
|
+
|
|
179
|
+
has_error = False
|
|
180
|
+
|
|
181
|
+
raw_format = format_input.value.strip()
|
|
182
|
+
is_valid_format = False
|
|
183
|
+
|
|
184
|
+
if raw_format.startswith(".") and "," in raw_format:
|
|
185
|
+
parts = raw_format.split(",")
|
|
186
|
+
ext = parts[0].strip()[1:]
|
|
187
|
+
try:
|
|
188
|
+
bitrate_str = ''.join(filter(str.isdigit, parts[1]))
|
|
189
|
+
bitrate = int(bitrate_str) if bitrate_str else 0
|
|
190
|
+
|
|
191
|
+
if 92 <= bitrate <= 320 and ext.isalnum():
|
|
192
|
+
is_valid_format = True
|
|
193
|
+
format_input.value = f".{ext.lower()}, {bitrate}"
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
if not is_valid_format:
|
|
198
|
+
format_input.add_class("error")
|
|
199
|
+
has_error = True
|
|
200
|
+
self.notify("Format must be '.ext, bitrate' (e.g. .mp3, 320). Bitrate limit: 92-320.", severity="error", timeout=6)
|
|
201
|
+
self.query_one("#download-settings", Collapsible).collapsed = False
|
|
202
|
+
|
|
203
|
+
if save_location.value.strip() == "":
|
|
204
|
+
save_location.add_class("error")
|
|
205
|
+
has_error = True
|
|
206
|
+
self.query_one("#download-settings", Collapsible).collapsed = False
|
|
207
|
+
|
|
208
|
+
active_tab = self.query_one("#tabs", TabbedContent).active
|
|
209
|
+
is_batch_mode = (active_tab == "batch-tab")
|
|
210
|
+
|
|
211
|
+
if is_batch_mode:
|
|
212
|
+
raw_text = batch_input.text
|
|
213
|
+
if not raw_text.strip():
|
|
214
|
+
batch_input.add_class("error")
|
|
215
|
+
has_error = True
|
|
216
|
+
songs_to_download = raw_text.strip().split("\n")
|
|
217
|
+
else:
|
|
218
|
+
raw_text = single_input.value
|
|
219
|
+
if not raw_text.strip():
|
|
220
|
+
single_input.add_class("error")
|
|
221
|
+
has_error = True
|
|
222
|
+
songs_to_download = [raw_text.strip()]
|
|
223
|
+
|
|
224
|
+
if has_error:
|
|
225
|
+
if is_valid_format:
|
|
226
|
+
self.notify("Oops! Please fill out all required fields.", severity="error")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
save_settings(format_input.value, save_location.value, self.theme)
|
|
230
|
+
|
|
231
|
+
progress_bar = self.query_one("#my-progress-bar", ProgressBar)
|
|
232
|
+
progress_bar.display = True
|
|
233
|
+
progress_bar.update(total=100, progress=0)
|
|
234
|
+
|
|
235
|
+
self.notify("Starting your download process! Please wait...")
|
|
236
|
+
self.run_engine(songs_to_download, save_location.value, format_input.value, is_batch_mode)
|
|
237
|
+
|
|
238
|
+
def update_progress_ui(self, downloaded, total):
|
|
239
|
+
progress_bar = self.query_one("#my-progress-bar", ProgressBar)
|
|
240
|
+
progress_bar.update(total=total, progress=downloaded)
|
|
241
|
+
|
|
242
|
+
@work(thread=True)
|
|
243
|
+
def run_engine(self, songs, save_path, audio_format, is_batch):
|
|
244
|
+
try:
|
|
245
|
+
def progress_callback(downloaded, total):
|
|
246
|
+
self.app.call_from_thread(self.update_progress_ui, downloaded, total)
|
|
247
|
+
|
|
248
|
+
if is_batch and len(songs) > 1:
|
|
249
|
+
working_path = os.path.join(save_path, "Mooze_Temp_Batch")
|
|
250
|
+
os.makedirs(working_path, exist_ok=True)
|
|
251
|
+
else:
|
|
252
|
+
working_path = save_path
|
|
253
|
+
|
|
254
|
+
for song in songs:
|
|
255
|
+
if song.strip():
|
|
256
|
+
self.app.call_from_thread(self.update_progress_ui, 0, 100)
|
|
257
|
+
download_song(song.strip(), working_path, audio_format, progress_callback)
|
|
258
|
+
|
|
259
|
+
if is_batch and len(songs) > 1:
|
|
260
|
+
zip_filename = os.path.join(save_path, "Mooze_Batch_Archive")
|
|
261
|
+
shutil.make_archive(zip_filename, 'zip', working_path)
|
|
262
|
+
shutil.rmtree(working_path)
|
|
263
|
+
|
|
264
|
+
self.app.call_from_thread(self.finish_download, True, "All downloads completed successfully!")
|
|
265
|
+
except Exception as e:
|
|
266
|
+
self.app.call_from_thread(self.finish_download, False, str(e))
|
|
267
|
+
|
|
268
|
+
def finish_download(self, success: bool, message: str):
|
|
269
|
+
progress_bar = self.query_one("#my-progress-bar", ProgressBar)
|
|
270
|
+
progress_bar.display = False
|
|
271
|
+
if success:
|
|
272
|
+
self.notify(message, title="Success!")
|
|
273
|
+
else:
|
|
274
|
+
self.notify(f"Engine Error: {message}", title="Oops!", severity="error")
|
|
275
|
+
|
|
276
|
+
# =========================================================================
|
|
277
|
+
# DPI-AWARE SCREENSHOT TOOL
|
|
278
|
+
# =========================================================================
|
|
279
|
+
def action_save_png(self):
|
|
280
|
+
try:
|
|
281
|
+
from PIL import ImageGrab
|
|
282
|
+
import sys
|
|
283
|
+
|
|
284
|
+
self.notify("Capturing screenshot...", title="Processing")
|
|
285
|
+
filename = "mooze_screenshot.png"
|
|
286
|
+
|
|
287
|
+
if sys.platform == "win32":
|
|
288
|
+
import ctypes
|
|
289
|
+
from ctypes import wintypes
|
|
290
|
+
|
|
291
|
+
user32 = ctypes.windll.user32
|
|
292
|
+
user32.SetProcessDPIAware()
|
|
293
|
+
|
|
294
|
+
hwnd = user32.GetForegroundWindow()
|
|
295
|
+
rect = wintypes.RECT()
|
|
296
|
+
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
|
297
|
+
|
|
298
|
+
bbox = (rect.left, rect.top, rect.right, rect.bottom)
|
|
299
|
+
screenshot = ImageGrab.grab(bbox=bbox)
|
|
300
|
+
else:
|
|
301
|
+
screenshot = ImageGrab.grab()
|
|
302
|
+
|
|
303
|
+
screenshot.save(filename)
|
|
304
|
+
self.notify(f"Saved: {filename}", title="Screenshot", severity="information")
|
|
305
|
+
|
|
306
|
+
except ImportError:
|
|
307
|
+
self.notify("Missing dependency! Run: pip install pillow", severity="error", timeout=10)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
self.notify(f"Screenshot failed: {str(e)}", title="Error", severity="error", timeout=15)
|
|
310
|
+
|
|
311
|
+
def start():
|
|
312
|
+
app = MoozeApp()
|
|
313
|
+
app.run()
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
start()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mooze
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A modern, TUI-based Spotify and YouTube audio downloader.
|
|
5
|
+
Author-email: Reyhan Janus <reyhanjanus@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: textual>=0.38.1
|
|
14
|
+
Requires-Dist: yt-dlp>=2023.10.13
|
|
15
|
+
Requires-Dist: pillow>=10.0.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# 🎵 Mooze
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
<!-- PROJECT BADGES -->
|
|
22
|
+
|
|
23
|
+
<div align="left">
|
|
24
|
+
<img src="https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python Version" />
|
|
25
|
+
<img src="https://img.shields.io/badge/UI-Textual%20TUI-22C55E?style=flat-square&logo=gnuterminal&logoColor=white" alt="Textual TUI" />
|
|
26
|
+
<img src="https://img.shields.io/badge/Engine-yt--dlp-FF0000?style=flat-square&logo=youtube&logoColor=white" alt="yt-dlp Engine" />
|
|
27
|
+
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-4B5563?style=flat-square" alt="Platform Support" />
|
|
28
|
+
<img src="https://img.shields.io/badge/License-MIT-F59E0B?style=flat-square&logo=opensourceinitiative&logoColor=white" alt="MIT License" />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## ✨ Features
|
|
38
|
+
|
|
39
|
+
* **Dual Download Modes:**
|
|
40
|
+
* **Single Mode:** Paste a Spotify track link or type a raw search query (e.g., "Coldplay Yellow").
|
|
41
|
+
* **Batch Mode:** Paste a list of multiple Spotify links (one per line) to download them all at once. Batch downloads are automatically zipped into a neat archive!
|
|
42
|
+
* **Smart Spotify Translation:** Bypasses web-scrapers using oEmbed and Googlebot masking to accurately extract Track + Artist data.
|
|
43
|
+
* **Audiophile Quality:** Choose your preferred output:
|
|
44
|
+
* MP3 - High Quality (320kbps)
|
|
45
|
+
* MP3 - Normal (128kbps)
|
|
46
|
+
* WAV - Best Quality (Lossless)
|
|
47
|
+
* **Modern Terminal UI:** Built with Textual, featuring mouse support, smooth toggle switches, and background asynchronous workers to keep the UI perfectly responsive while downloading.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 🛠️ Prerequisites
|
|
52
|
+
|
|
53
|
+
Mooze uses `yt-dlp` under the hood, which requires **FFmpeg** to convert video files into clean MP3/WAV audio.
|
|
54
|
+
|
|
55
|
+
**Windows:**
|
|
56
|
+
|
|
57
|
+
```PowerShell
|
|
58
|
+
winget install ffmpeg
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**MacOS:**
|
|
62
|
+
|
|
63
|
+
```PowerShell
|
|
64
|
+
brew install ffmpeg
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Linux:**
|
|
68
|
+
|
|
69
|
+
```PowerShell
|
|
70
|
+
sudo apt install ffmpeg
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
*(Note: Restart your terminal after installing FFmpeg so your system recognizes it.)*
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 🚀 Installation
|
|
78
|
+
|
|
79
|
+
Because Mooze is packaged professionally, you can install it globally on your system.
|
|
80
|
+
|
|
81
|
+
1. Clone the repository:
|
|
82
|
+
|
|
83
|
+
```Shell
|
|
84
|
+
git clone [https://github.com/X0fx/mooze.git](https://github.com/X0fx/mooze.git)
|
|
85
|
+
cd mooze
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
2. Install the application:
|
|
89
|
+
|
|
90
|
+
```Shell
|
|
91
|
+
pip install -e .
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 📖 User Manual
|
|
97
|
+
|
|
98
|
+
Once installed, simply type `mooze` in any terminal to launch the dashboard.
|
|
99
|
+
|
|
100
|
+
### 📥 Single Song Download
|
|
101
|
+
|
|
102
|
+
1. Ensure the **Batch Mode** switch is toggled **OFF** .
|
|
103
|
+
2. In the input box, paste a **Spotify Track Link** (e.g., `https://open.spotify.com/track/...`) OR type a search term (e.g., `Mozart Requiem`).
|
|
104
|
+
3. Select your desired audio format from the dropdown menu.
|
|
105
|
+
4. Type your save folder path (e.g., `C:/Users/YourName/Downloads` or just `./` for the current folder).
|
|
106
|
+
5. Click **Search & Download** .
|
|
107
|
+
|
|
108
|
+
### 📦 Batch Downloading
|
|
109
|
+
|
|
110
|
+
1. Toggle the **Batch Mode** switch to **ON** . The UI will dynamically expand.
|
|
111
|
+
2. Paste multiple Spotify links into the text area. **You must put exactly one link per line.**
|
|
112
|
+
3. Select your audio format and your save location.
|
|
113
|
+
4. Click **Download Batch** .
|
|
114
|
+
|
|
115
|
+
*Note: Mooze will create a temporary folder, process all your songs, compress them into a `Mooze_Batch_Archive.zip` file in your save directory, and clean up the temporary files automatically.*
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## ⚠️ Disclaimer
|
|
120
|
+
|
|
121
|
+
This tool is for educational purposes and personal use only. It relies on publicly available APIs and YouTube search mechanisms. Please respect digital rights and support the artists you listen to.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mooze/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mooze/downloader.py,sha256=3Jv3nB480d4473S-Xjl4IKIcaAN_WIhoxMcTK9TpSeM,3782
|
|
3
|
+
mooze/main.py,sha256=jCzY2Avp4FgpWlLRIqq8Npq9FAZSZKxyjJWmhBECY5c,13387
|
|
4
|
+
mooze-1.0.0.dist-info/licenses/LICENSE,sha256=c8SqFGEYyHRkmRWsNinAhXoOJ56ZheunEtDI9PAij6o,1166
|
|
5
|
+
mooze-1.0.0.dist-info/METADATA,sha256=pQceWg0GDKdAAXbhVd0sVoZpi2qX5zkbpjTzM02YUsI,4170
|
|
6
|
+
mooze-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
mooze-1.0.0.dist-info/entry_points.txt,sha256=2b9tg0wc46rks9LCHFdqEge1vF04eeJfvoha44sDJOA,43
|
|
8
|
+
mooze-1.0.0.dist-info/top_level.txt,sha256=YXLb673Zm6qyE--gy9GdKkvflfmOFyVkCg9pzfDA-6c,6
|
|
9
|
+
mooze-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 X0fx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
<<<<<<< HEAD
|
|
22
|
+
SOFTWARE.
|
|
23
|
+
=======
|
|
24
|
+
SOFTWARE.
|
|
25
|
+
>>>>>>> 0978b1d7cfdb5680d7cb2ca9e75957e063ab97c0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mooze
|