videostore 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.
- videostore/__init__.py +3 -0
- videostore/app.py +185 -0
- videostore/core.py +111 -0
- videostore-0.1.0.dist-info/METADATA +53 -0
- videostore-0.1.0.dist-info/RECORD +8 -0
- videostore-0.1.0.dist-info/WHEEL +4 -0
- videostore-0.1.0.dist-info/entry_points.txt +2 -0
- videostore-0.1.0.dist-info/licenses/LICENSE +21 -0
videostore/__init__.py
ADDED
videostore/app.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ytdl-box: single-window yt-dlp wrapper. Paste, choose, download. No memory."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import Qt, QThread, Signal
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QApplication,
|
|
9
|
+
QComboBox,
|
|
10
|
+
QFileDialog,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QLineEdit,
|
|
14
|
+
QMainWindow,
|
|
15
|
+
QProgressBar,
|
|
16
|
+
QPushButton,
|
|
17
|
+
QVBoxLayout,
|
|
18
|
+
QWidget,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from videostore.core import FORMATS, Grabber
|
|
23
|
+
except ImportError:
|
|
24
|
+
from core import FORMATS, Grabber
|
|
25
|
+
|
|
26
|
+
BG = "#08181c"
|
|
27
|
+
PANEL = "#0d2426"
|
|
28
|
+
ACCENT = "#00e5c7"
|
|
29
|
+
LIME = "#7dff9a"
|
|
30
|
+
TEXT = "#c9e7d4"
|
|
31
|
+
|
|
32
|
+
QSS = f"""
|
|
33
|
+
QMainWindow {{ background: {BG}; }}
|
|
34
|
+
QWidget {{ background: {BG}; color: {TEXT};
|
|
35
|
+
font-family: 'FantasqueSansM Nerd Font';
|
|
36
|
+
font-size: 14px; }}
|
|
37
|
+
QLineEdit, QComboBox {{
|
|
38
|
+
background: {PANEL}; color: {TEXT};
|
|
39
|
+
border: 1px solid #1d3a3c; border-radius: 6px; padding: 6px;
|
|
40
|
+
}}
|
|
41
|
+
QPushButton {{
|
|
42
|
+
background: {PANEL}; color: {ACCENT};
|
|
43
|
+
border: 1px solid {ACCENT}; border-radius: 6px; padding: 6px 16px;
|
|
44
|
+
}}
|
|
45
|
+
QPushButton:hover {{ background: #123; color: {LIME}; }}
|
|
46
|
+
QPushButton:disabled {{ color: #4a5f5f; border-color: #2a4446; }}
|
|
47
|
+
QProgressBar {{
|
|
48
|
+
background: {PANEL}; border: 1px solid #1d3a3c;
|
|
49
|
+
border-radius: 6px; text-align: center; height: 16px;
|
|
50
|
+
}}
|
|
51
|
+
QProgressBar::chunk {{ background: {ACCENT}; border-radius: 5px; }}
|
|
52
|
+
QLabel#status {{ color: {LIME}; }}
|
|
53
|
+
QLabel#title {{ font-family: Cinzel; font-size: 16px; color: {ACCENT}; }}
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class GrabWorker(QThread):
|
|
58
|
+
progress = Signal(float)
|
|
59
|
+
file_sig = Signal(str)
|
|
60
|
+
done = Signal(str)
|
|
61
|
+
failed = Signal(str)
|
|
62
|
+
|
|
63
|
+
def __init__(self, grabber: Grabber, cmd: list[str]) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.grabber = grabber
|
|
66
|
+
self.cmd = cmd
|
|
67
|
+
|
|
68
|
+
def run(self) -> None: # pragma: no cover - thread body
|
|
69
|
+
try:
|
|
70
|
+
final = self.grabber.run(
|
|
71
|
+
self.cmd,
|
|
72
|
+
on_progress=lambda p: self.progress.emit(p),
|
|
73
|
+
on_file=lambda f: self.file_sig.emit(f),
|
|
74
|
+
)
|
|
75
|
+
self.done.emit(final)
|
|
76
|
+
except Exception as exc: # noqa: BLE001
|
|
77
|
+
self.failed.emit(str(exc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Box(QMainWindow):
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
super().__init__()
|
|
83
|
+
self.grabber = Grabber()
|
|
84
|
+
self.worker: GrabWorker | None = None
|
|
85
|
+
self.setWindowTitle("VideoStore")
|
|
86
|
+
self.setMinimumWidth(520)
|
|
87
|
+
self.build_ui()
|
|
88
|
+
|
|
89
|
+
def build_ui(self) -> None:
|
|
90
|
+
root = QWidget()
|
|
91
|
+
self.setCentralWidget(root)
|
|
92
|
+
v = QVBoxLayout(root)
|
|
93
|
+
v.setContentsMargins(12, 12, 12, 12)
|
|
94
|
+
v.setSpacing(8)
|
|
95
|
+
|
|
96
|
+
title = QLabel("VideoStore")
|
|
97
|
+
title.setObjectName("title")
|
|
98
|
+
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
99
|
+
v.addWidget(title)
|
|
100
|
+
|
|
101
|
+
self.url = QLineEdit()
|
|
102
|
+
self.url.setPlaceholderText("Paste link…")
|
|
103
|
+
v.addWidget(self.url)
|
|
104
|
+
|
|
105
|
+
self.fmt = QComboBox()
|
|
106
|
+
for key, spec in FORMATS.items():
|
|
107
|
+
self.fmt.addItem(spec["label"], userData=key)
|
|
108
|
+
v.addWidget(self.fmt)
|
|
109
|
+
|
|
110
|
+
path_row = QHBoxLayout()
|
|
111
|
+
self.outdir = QLineEdit(self.grabber.default_outdir())
|
|
112
|
+
browse = QPushButton("Where?")
|
|
113
|
+
browse.setFixedWidth(84)
|
|
114
|
+
browse.clicked.connect(self.on_browse)
|
|
115
|
+
path_row.addWidget(self.outdir, 1)
|
|
116
|
+
path_row.addWidget(browse)
|
|
117
|
+
v.addLayout(path_row)
|
|
118
|
+
|
|
119
|
+
btn_row = QHBoxLayout()
|
|
120
|
+
self.btn_dl = QPushButton("Download")
|
|
121
|
+
self.btn_clear = QPushButton("Clear")
|
|
122
|
+
self.btn_dl.clicked.connect(self.on_download)
|
|
123
|
+
self.btn_clear.clicked.connect(self.on_clear)
|
|
124
|
+
btn_row.addWidget(self.btn_dl)
|
|
125
|
+
btn_row.addWidget(self.btn_clear)
|
|
126
|
+
v.addLayout(btn_row)
|
|
127
|
+
|
|
128
|
+
self.bar = QProgressBar()
|
|
129
|
+
self.bar.setRange(0, 100)
|
|
130
|
+
v.addWidget(self.bar)
|
|
131
|
+
|
|
132
|
+
self.status = QLabel("")
|
|
133
|
+
self.status.setObjectName("status")
|
|
134
|
+
v.addWidget(self.status)
|
|
135
|
+
|
|
136
|
+
# ---- handlers ----
|
|
137
|
+
def on_browse(self) -> None:
|
|
138
|
+
d = QFileDialog.getExistingDirectory(self, "Download to", self.outdir.text())
|
|
139
|
+
if d:
|
|
140
|
+
self.outdir.setText(d)
|
|
141
|
+
|
|
142
|
+
def on_download(self) -> None:
|
|
143
|
+
url = self.url.text().strip()
|
|
144
|
+
if not url:
|
|
145
|
+
self.status.setText("Paste a link first.")
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
cmd = self.grabber.build_cmd(url, self.fmt.currentData(), self.outdir.text())
|
|
149
|
+
except Exception as exc: # noqa: BLE001
|
|
150
|
+
self.status.setText(f"Can't start: {exc}")
|
|
151
|
+
return
|
|
152
|
+
self.btn_dl.setEnabled(False)
|
|
153
|
+
self.status.setText("Fetching…")
|
|
154
|
+
self.worker = GrabWorker(self.grabber, cmd)
|
|
155
|
+
self.worker.progress.connect(self.bar.setValue)
|
|
156
|
+
self.worker.file_sig.connect(lambda f: self.status.setText(f"→ {f}"))
|
|
157
|
+
self.worker.done.connect(self.on_done)
|
|
158
|
+
self.worker.failed.connect(self.on_fail)
|
|
159
|
+
self.worker.start()
|
|
160
|
+
|
|
161
|
+
def on_done(self, final: str) -> None:
|
|
162
|
+
self.btn_dl.setEnabled(True)
|
|
163
|
+
self.bar.setValue(100)
|
|
164
|
+
self.status.setText(f"Done.{(' ' + final) if final else ''} Next link.")
|
|
165
|
+
|
|
166
|
+
def on_fail(self, msg: str) -> None:
|
|
167
|
+
self.btn_dl.setEnabled(True)
|
|
168
|
+
self.status.setText(f"Failed: {msg}")
|
|
169
|
+
|
|
170
|
+
def on_clear(self) -> None:
|
|
171
|
+
self.url.clear()
|
|
172
|
+
self.bar.setValue(0)
|
|
173
|
+
self.status.setText("")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
app = QApplication(sys.argv)
|
|
178
|
+
app.setStyleSheet(QSS)
|
|
179
|
+
w = Box()
|
|
180
|
+
w.show()
|
|
181
|
+
sys.exit(app.exec())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
videostore/core.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""ytdl-box core: pure download logic, zero GUI imports.
|
|
2
|
+
|
|
3
|
+
Wraps the yt-dlp CLI as a subprocess so the same grab() contract can drive
|
|
4
|
+
the PySide6 desktop app today and an Android (BeeWare/Kivy) shell later.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
FORMATS = {
|
|
13
|
+
"label_best": {
|
|
14
|
+
"label": "Best video+audio",
|
|
15
|
+
"args": ["-f", "bv*+ba/b"],
|
|
16
|
+
},
|
|
17
|
+
"label_1080": {
|
|
18
|
+
"label": "Best ≤1080p",
|
|
19
|
+
"args": ["-f", "bv*[height<=1080]+ba/b[height<=1080]/b"],
|
|
20
|
+
},
|
|
21
|
+
"label_720": {
|
|
22
|
+
"label": "Best ≤720p",
|
|
23
|
+
"args": ["-f", "bv*[height<=720]+ba/b[height<=720]/b"],
|
|
24
|
+
},
|
|
25
|
+
"label_audio_mp3": {
|
|
26
|
+
"label": "Audio only (mp3)",
|
|
27
|
+
"args": ["-x", "--audio-format", "mp3"],
|
|
28
|
+
},
|
|
29
|
+
"label_audio_best": {
|
|
30
|
+
"label": "Audio only (best)",
|
|
31
|
+
"args": ["-f", "ba/b"],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
OUTPUT_TEMPLATE = "%(title)s.%(ext)s"
|
|
36
|
+
|
|
37
|
+
PROGRESS_RE = re.compile(r"(\d+(?:\.\d+)?)%")
|
|
38
|
+
DEST_RE = re.compile(r"Destination:\s+(.+)$")
|
|
39
|
+
Merging_RE = re.compile(r"\[Merger\].*?into\s+(.+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Grabber:
|
|
43
|
+
"""Builds and runs yt-dlp. Pure logic; safe to headless-test."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, yt_dlp_bin: str = "yt-dlp") -> None:
|
|
46
|
+
self.bin = yt_dlp_bin
|
|
47
|
+
self.proc: subprocess.Popen | None = None # set by run(); kill via cancel()
|
|
48
|
+
|
|
49
|
+
def cancel(self) -> None:
|
|
50
|
+
"""Terminate the running yt-dlp, if any."""
|
|
51
|
+
if self.proc and self.proc.poll() is None:
|
|
52
|
+
self.proc.terminate()
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def default_outdir() -> str:
|
|
56
|
+
return os.path.expanduser("~/Downloads")
|
|
57
|
+
|
|
58
|
+
def build_cmd(self, url: str, format_key: str, outdir: str) -> list[str]:
|
|
59
|
+
fmt = FORMATS.get(format_key)
|
|
60
|
+
if fmt is None:
|
|
61
|
+
raise ValueError(f"unknown format key: {format_key}")
|
|
62
|
+
url = url.strip()
|
|
63
|
+
if not url:
|
|
64
|
+
raise ValueError("empty URL")
|
|
65
|
+
outdir = os.path.expanduser(outdir)
|
|
66
|
+
os.makedirs(outdir, exist_ok=True)
|
|
67
|
+
return [
|
|
68
|
+
self.bin,
|
|
69
|
+
*fmt["args"],
|
|
70
|
+
"--no-playlist",
|
|
71
|
+
"--no-mtime",
|
|
72
|
+
"--restrict-filenames",
|
|
73
|
+
"--newline", # progress: one line per update, easy to parse
|
|
74
|
+
"-o", os.path.join(outdir, OUTPUT_TEMPLATE),
|
|
75
|
+
"--", url,
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def run(
|
|
79
|
+
self,
|
|
80
|
+
cmd: list[str],
|
|
81
|
+
on_progress: Callable[[float], None],
|
|
82
|
+
on_file: Callable[[str], None],
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Run the command synchronously; parse progress/dest from stdout."""
|
|
85
|
+
final_file = ""
|
|
86
|
+
proc = subprocess.Popen(
|
|
87
|
+
cmd,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.STDOUT,
|
|
90
|
+
text=True,
|
|
91
|
+
)
|
|
92
|
+
self.proc = proc
|
|
93
|
+
assert proc.stdout is not None
|
|
94
|
+
for line in proc.stdout:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
m = PROGRESS_RE.search(line)
|
|
97
|
+
if m:
|
|
98
|
+
on_progress(float(m.group(1)))
|
|
99
|
+
continue
|
|
100
|
+
d = DEST_RE.search(line)
|
|
101
|
+
if d:
|
|
102
|
+
on_file(d.group(1))
|
|
103
|
+
continue
|
|
104
|
+
d = Merging_RE.search(line)
|
|
105
|
+
if d:
|
|
106
|
+
final_file = d.group(1)
|
|
107
|
+
on_file(final_file)
|
|
108
|
+
rc = proc.wait()
|
|
109
|
+
if rc != 0:
|
|
110
|
+
raise RuntimeError(f"yt-dlp exited with code {rc}")
|
|
111
|
+
return final_file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: videostore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small, single-window yt-dlp wrapper. Paste, download, close. No memory.
|
|
5
|
+
Project-URL: Homepage, https://github.com/CallMeBakugo/VideoStore
|
|
6
|
+
Project-URL: Source, https://github.com/CallMeBakugo/VideoStore
|
|
7
|
+
Author: CallMeBakugo
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: audio,downloader,pyside6,video,yt-dlp
|
|
11
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: pyside6>=6.6
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# VideoStore
|
|
21
|
+
|
|
22
|
+
Small, single-window yt-dlp wrapper for the desktop. Paste a link, pick a
|
|
23
|
+
format, hit Download. Nothing else.
|
|
24
|
+
|
|
25
|
+
pipx install videostore
|
|
26
|
+
videostore
|
|
27
|
+
|
|
28
|
+
## What it is
|
|
29
|
+
|
|
30
|
+
- URL field, format dropdown (best video+audio, ≤1080p, ≤720p, mp3,
|
|
31
|
+
best audio), download-folder field with a picker, Download and Clear.
|
|
32
|
+
- Downloads run in a worker thread; the progress bar fills live.
|
|
33
|
+
- Defaults to ~/Downloads.
|
|
34
|
+
|
|
35
|
+
## What it is not
|
|
36
|
+
|
|
37
|
+
- No history, no logs, no queue, no memory. Paste, download, close.
|
|
38
|
+
- Not a library, not a service. A box.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.10+
|
|
43
|
+
- PySide6 (installed automatically)
|
|
44
|
+
- `yt-dlp` on PATH (install via your package manager or `pipx install yt-dlp`)
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
- `videostore/core.py` — pure download logic (yt-dlp subprocess, progress
|
|
49
|
+
parsing, cancel). Zero GUI imports.
|
|
50
|
+
- `videostore/app.py` — the PySide6 shell.
|
|
51
|
+
|
|
52
|
+
The Omarchy bar-widget sibling lives in
|
|
53
|
+
[VideoStoreBar](https://github.com/CallMeBakugo/VideoStoreBar).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
videostore/__init__.py,sha256=jkmzB2xOfSAmWQCRd9xcv9q4QgFNPUtDyWtomeagxOw,75
|
|
2
|
+
videostore/app.py,sha256=G3m-KcfKC6sjT0R-2sszNvtm-GfwnmC6NgZnnmYo13s,5481
|
|
3
|
+
videostore/core.py,sha256=J291l30B8MGTYl9UBmNXIZBTs12eSrDad-eLnD7bT3Y,3345
|
|
4
|
+
videostore-0.1.0.dist-info/METADATA,sha256=PnOadZnG77cV9p4Jy7QQOrnxqHNbP_sM4BGkDqStsG8,1673
|
|
5
|
+
videostore-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
videostore-0.1.0.dist-info/entry_points.txt,sha256=cVsYeZyVx192JRBzVQRdWLPIukjaA78h3B2FlYfnn4g,51
|
|
7
|
+
videostore-0.1.0.dist-info/licenses/LICENSE,sha256=Ge3keoLOPVrf3sXhtpmRpWGj1J8cxxAIl9O-XmjtY40,1068
|
|
8
|
+
videostore-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CallMeBakugo
|
|
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
|
+
SOFTWARE.
|