videostore 0.1.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.
@@ -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.
@@ -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,34 @@
1
+ # VideoStore
2
+
3
+ Small, single-window yt-dlp wrapper for the desktop. Paste a link, pick a
4
+ format, hit Download. Nothing else.
5
+
6
+ pipx install videostore
7
+ videostore
8
+
9
+ ## What it is
10
+
11
+ - URL field, format dropdown (best video+audio, ≤1080p, ≤720p, mp3,
12
+ best audio), download-folder field with a picker, Download and Clear.
13
+ - Downloads run in a worker thread; the progress bar fills live.
14
+ - Defaults to ~/Downloads.
15
+
16
+ ## What it is not
17
+
18
+ - No history, no logs, no queue, no memory. Paste, download, close.
19
+ - Not a library, not a service. A box.
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.10+
24
+ - PySide6 (installed automatically)
25
+ - `yt-dlp` on PATH (install via your package manager or `pipx install yt-dlp`)
26
+
27
+ ## Layout
28
+
29
+ - `videostore/core.py` — pure download logic (yt-dlp subprocess, progress
30
+ parsing, cancel). Zero GUI imports.
31
+ - `videostore/app.py` — the PySide6 shell.
32
+
33
+ The Omarchy bar-widget sibling lives in
34
+ [VideoStoreBar](https://github.com/CallMeBakugo/VideoStoreBar).
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "videostore"
7
+ version = "0.1.0"
8
+ description = "Small, single-window yt-dlp wrapper. Paste, download, close. No memory."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "CallMeBakugo" }]
13
+ keywords = ["yt-dlp", "downloader", "video", "audio", "pyside6"]
14
+ classifiers = [
15
+ "Environment :: X11 Applications :: Qt",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Video",
20
+ ]
21
+ dependencies = [
22
+ "PySide6>=6.6",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/CallMeBakugo/VideoStore"
27
+ Source = "https://github.com/CallMeBakugo/VideoStore"
28
+
29
+ [project.scripts]
30
+ videostore = "videostore.app:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["videostore"]
@@ -0,0 +1,3 @@
1
+ """VideoStore: paste, download, close. No memory."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()
@@ -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