├── .gitignore ├── README.md ├── downloader.py ├── main.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | settings.json 3 | __pycache__/downloader.cpython-37.pyc 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Youtube_dl-GUI -------------------------------------------------------------------------------- /downloader.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from queue import Queue 3 | from threading import Thread 4 | import json 5 | import logging 6 | import os 7 | import subprocess 8 | import time 9 | 10 | from youtube_dl import YoutubeDL, utils 11 | import mutagen 12 | import requests 13 | 14 | logging.basicConfig( 15 | style="{", 16 | level=logging.INFO, 17 | format="[{levelname}] {asctime} {module} {message}", 18 | datefmt='%H:%M:%S' 19 | ) 20 | 21 | os.makedirs("Downloads", exist_ok=True) 22 | 23 | class BaseThread(Queue, Thread): 24 | def __init__(self, callback): 25 | Queue.__init__(self) 26 | Thread.__init__(self, daemon=True) 27 | 28 | self.callback = callback 29 | self.start() 30 | 31 | def run(self): 32 | while True: 33 | self.process(self.get()) 34 | 35 | class Preview(BaseThread): 36 | def __init__(self, callback): 37 | self.ytd = YoutubeDL({ 38 | "logger": logging, 39 | "progress_hooks": [callback] 40 | }) 41 | super().__init__(callback) 42 | 43 | def process(self, url): 44 | if "youtube" in url: 45 | url = url.split("&")[0] 46 | elif "youtu.be" in url: 47 | yt_id = url.split("?")[0].split("/")[-1] 48 | url = f"https://www.youtube.com/watch?v={yt_id}" 49 | 50 | self.callback({ 51 | "status": "Extracting", 52 | "url": url, 53 | "thumbnail": None, 54 | "title": "Extracting", 55 | "uploader": "Extracting info" 56 | }) 57 | 58 | try: 59 | result = self.ytd.extract_info(url, process=False) 60 | except utils.DownloadError as e: 61 | if "is not valid URL." in e.args[0]: 62 | self.callback({ 63 | "status": "Error", 64 | "url": url, 65 | "thumbnail": None, 66 | "title": "Error", 67 | "uploader": "Invalid url" 68 | }) 69 | else: 70 | # TODO: Add playlist functionality 71 | # if result.get("_type") == "playlist": 72 | # base = "https://www.youtube.com/watch?v=" 73 | # self.check(base + next(result["entries"])["id"]) 74 | # else: 75 | 76 | max_video = max_audio = {} 77 | 78 | for _format in result["formats"]: 79 | if _format["acodec"] != "none": 80 | if _format["vcodec"] != "none": 81 | if (_format["filesize"] or 0) > max_video.get("filesize", 0): 82 | max_video = _format 83 | else: 84 | if (_format["filesize"] or 0) > max_audio.get("filesize", 0): 85 | max_audio = _format 86 | 87 | self.callback({ 88 | "status": "Ok", 89 | "url": url, 90 | "id": result["id"], 91 | "title": result["title"], 92 | "uploader": result["uploader"], 93 | "thumbnail": BytesIO(requests.get(result["thumbnail"]).content), 94 | "best_video": max_video, 95 | "best_audio": max_audio or max_video 96 | }) 97 | 98 | class Downloader(BaseThread): 99 | def process(self, info): 100 | filetype = f"best_{info['filetype'].lower()}" 101 | filename = f"Downloads/{info['title']}.{info[filetype]['ext']}" 102 | 103 | if not os.path.isfile(filename): 104 | info["status"] = "Downloading" 105 | 106 | info["progress"] = 0 107 | previous_time = time.time() 108 | start, end = 0, 2**20 - 1 109 | 110 | while True: 111 | response = requests.get(info[filetype]["url"], headers={"range":f"bytes={start}-{end}"}) 112 | 113 | if response.ok: 114 | with open(filename, "ab") as fp: 115 | fp.write(response.content) 116 | info["progress"] += len(response.content) 117 | else: 118 | break 119 | 120 | content_end, info["length"] = map(int, response.headers["Content-Range"].split("-")[1].split("/")) 121 | 122 | info["speed"] = len(response.content) / (time.time() - previous_time) 123 | if self.callback(info): 124 | return 125 | 126 | if content_end + 1 == info["length"]: 127 | break 128 | 129 | previous_time = time.time() 130 | start += 2**20 131 | end += 2**20 132 | 133 | info["status"] = "Finished" 134 | self.callback(info) 135 | 136 | class Converter(BaseThread): 137 | def process(self, info): 138 | ext = info[f"best_{info['filetype'].lower()}"]['ext'] 139 | old_filename = f"Downloads/{info['title']}.{ext}" 140 | new_filename = f"Downloads/{info['title']}.mp3" 141 | 142 | if not os.path.isfile(new_filename): 143 | info["status"] = "Converting" 144 | self.callback(info) 145 | 146 | subprocess.run([ 147 | "ffmpeg.exe", 148 | "-i", old_filename, 149 | new_filename, 150 | "-y" 151 | ]) 152 | 153 | os.remove(old_filename) 154 | 155 | muta = mutagen.File(new_filename, easy=True) 156 | muta["artist"] = info["uploader"] 157 | muta.save() 158 | 159 | info["status"] = "Converted" 160 | self.callback(info) 161 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from tkinter import Tk, ttk, Menu, StringVar, Entry, TclError 2 | import json 3 | 4 | from PIL import Image, ImageTk 5 | 6 | import downloader 7 | 8 | class PopupMenu(Menu): 9 | def __init__(self, parent, *functions): 10 | super().__init__(parent, tearoff=0) 11 | 12 | self.widget = None 13 | 14 | for func in functions: 15 | self.add_command(label=f"\t{func.capitalize().replace('_', ' ')}", command=lambda f=func:getattr(self.widget, f)()) 16 | 17 | def post(self, widget, x, y): 18 | self.widget = widget 19 | super().post(x, y) 20 | 21 | class MyEntry(Entry): 22 | def __init__(self, parent, **kwargs): 23 | self.var = StringVar() 24 | self.default = "" 25 | 26 | super().__init__(parent, textvariable=self.var, **kwargs) 27 | 28 | self.bind("", self.reset) 29 | 30 | def cut(self): 31 | if self.selection_present(): 32 | self.copy() 33 | self.delete(*sorted((self.index("anchor"), self.index("insert")))) 34 | 35 | def copy(self): 36 | if self.selection_present(): 37 | self.clipboard_append(self.selection_get()) 38 | 39 | def paste(self): 40 | if self.selection_present(): 41 | self.delete(*sorted((self.index("anchor"), self.index("insert")))) 42 | self.insert("insert", self.clipboard_get()) 43 | 44 | def set(self, text): 45 | self.var.set(text) 46 | self.default = text 47 | 48 | def get(self): 49 | return self.var.get() 50 | 51 | def reset(self, args): 52 | self.var.set(self.default) 53 | 54 | class PreviewFrame(ttk.Frame): 55 | def __init__(self, parent): 56 | super().__init__(parent) 57 | 58 | self.columnconfigure(2, weight=1) 59 | 60 | self.image = self._blank_image = ImageTk.PhotoImage(Image.new("RGB", (128, 72), (240, 240, 240))) 61 | 62 | self.thumbnail_frame = ttk.Label(self, image=self.image) 63 | self.thumbnail_frame.grid(rowspan=3) 64 | 65 | ttk.Label(self, text="URL: ").grid(column=1, row=0, sticky="ne") 66 | self.url_entry = MyEntry(self, state="readonly") 67 | self.url_entry.grid(column=2, row=0, sticky="we") 68 | 69 | ttk.Label(self, text="Title: ").grid(column=1, row=1, sticky="ne") 70 | self.title_entry = MyEntry(self) 71 | self.title_entry.grid(column=2, row=1, sticky="we") 72 | 73 | ttk.Label(self, text="Uploader: ").grid(column=1, row=2, sticky="ne") 74 | self.uploader_entry = MyEntry(self) 75 | self.uploader_entry.grid(column=2, row=2, sticky="we") 76 | 77 | def __setattr__(self, key, value): 78 | if key == "thumbnail": 79 | if value is None: 80 | self.image = self._blank_image 81 | else: 82 | img = Image.open(value).resize((128, 72), Image.ANTIALIAS) 83 | self.image = ImageTk.PhotoImage(img) 84 | self.thumbnail_frame["image"] = self.image 85 | elif key in ("url", "title", "uploader"): 86 | getattr(self, f"{key}_entry").set(value) 87 | else: 88 | super().__setattr__(key, value) 89 | 90 | class Tree(ttk.Treeview): 91 | def __init__(self, parent, headers): 92 | self.frame = ttk.Frame(parent) 93 | self.frame.columnconfigure(0, weight=1) 94 | self.frame.rowconfigure(0, weight=1) 95 | 96 | xscroll = lambda first, last: self.scroll(xs, first, last) 97 | yscroll = lambda first, last: self.scroll(ys, first, last) 98 | super().__init__(self.frame, xscroll=xscroll, yscroll=yscroll) 99 | super().grid(sticky="nsew") 100 | 101 | xs = ttk.Scrollbar(self.frame, orient="horizontal", command=self.xview) 102 | xs.grid(column=0, row=1, sticky="we") 103 | 104 | ys = ttk.Scrollbar(self.frame, orient="vertical", command=self.yview) 105 | ys.grid(column=1, row=0, sticky="ns") 106 | 107 | ttk.Style().layout("Treeview", [("treeview.treearea", {"sticky": "nswe"})]) 108 | 109 | self["columns"], widths = zip(*headers[1:]) 110 | self.heading("#0", text=headers[0][0], anchor="w") 111 | self.column("#0", stretch=0, anchor="w", minwidth=headers[0][1], width=headers[0][1]) 112 | 113 | for i, w in headers[1:]: 114 | self.heading(i, text=i, anchor="w") 115 | self.column(i, stretch=0, anchor="w", minwidth=w, width=w) 116 | 117 | def grid(self, **kwargs): 118 | self.frame.grid(**kwargs) 119 | 120 | def scroll(self, sbar, first, last): 121 | """Hide and show scrollbar as needed.""" 122 | first, last = float(first), float(last) 123 | if first <= 0 and last >= 1: 124 | sbar.grid_remove() 125 | else: 126 | sbar.grid() 127 | sbar.set(first, last) 128 | 129 | class BottomFrame(ttk.Frame): 130 | def __init__(self, parent): 131 | super().__init__(parent) 132 | 133 | self.cmb = ttk.Combobox(self, values=["Audio", "Video"]) 134 | self.cmb.set("Audio") 135 | self.cmb.grid(padx=5, pady=5, sticky="nw") 136 | 137 | self.download_btn = ttk.Button(self, text="Download", command=self.download) 138 | self.download_btn.grid(column=1, row=0, padx=5, pady=5, sticky="nw") 139 | 140 | def download(self): 141 | self.master.download(self.cmb.get()) 142 | 143 | class Main(Tk): 144 | def __init__(self): 145 | super().__init__() 146 | 147 | self.current_url = None 148 | self.current_info = None 149 | 150 | self.settings = { 151 | "geometry": "600x250+400+300", 152 | "treeview": [ 153 | ["Uploader", 100], 154 | ["Title", 190], 155 | ["Progress", 70], 156 | ["ETA (s)", 50], 157 | ["Speed", 70] 158 | ] 159 | } 160 | 161 | try: 162 | with open("settings.json") as fp: 163 | self.settings.update(json.load(fp)) 164 | except FileNotFoundError: 165 | with open("settings.json", "w") as fp: 166 | json.dump(self.settings, fp) 167 | 168 | self.title("Video Downloader") 169 | self.attributes("-topmost", True) 170 | self.geometry(self.settings["geometry"]) 171 | self.columnconfigure(0, weight=1) 172 | self.rowconfigure(1, weight=1) 173 | self.minsize(600, 250) 174 | 175 | self.preview_frame = PreviewFrame(self) 176 | self.preview_frame.grid(padx=5, pady=5, sticky="nwe") 177 | 178 | self.tv = Tree(self, self.settings["treeview"]) 179 | self.tv.grid(column=0, row=1, padx=5, pady=5, sticky="nswe") 180 | 181 | self.bottom_frame = BottomFrame(self) 182 | self.bottom_frame.grid(column=0, row=2, sticky="w") 183 | 184 | self.menu = PopupMenu(self, "cut", "copy", "paste") 185 | self.tv_menu = PopupMenu(self, "cancel", "pause", "download_speed") 186 | 187 | self.pv_thread = downloader.Preview(self.preview_callback) 188 | self.dl_thread = downloader.Downloader(self.callback) 189 | self.cv_thread = downloader.Converter(self.callback) 190 | 191 | try: 192 | self.pv_thread.put(self.clipboard_get()) 193 | except TclError: 194 | pass 195 | 196 | self.bind("", self.popup) 197 | 198 | self.after(100, self.check_clipboard) 199 | 200 | self.protocol("WM_DELETE_WINDOW", self.end) 201 | self.mainloop() 202 | 203 | def download(self, filetype): 204 | info = self.current_info 205 | 206 | if info["status"] not in ("Extracting", "Error") and not self.tv.exists(info["id"]): 207 | info["filetype"] = filetype 208 | info["title"] = self.preview_frame.title_entry.get() 209 | info["uploader"] = self.preview_frame.uploader_entry.get() 210 | 211 | values = info["title"], "Queued", "-", "-" 212 | self.tv.insert("", "end", info["id"], text=info["uploader"], values=values) 213 | self.dl_thread.put(info) 214 | 215 | def preview_callback(self, info): 216 | self.preview_frame.thumbnail = info["thumbnail"] 217 | self.preview_frame.title = info["title"] 218 | self.preview_frame.uploader = info["uploader"] 219 | 220 | self.current_info = info 221 | 222 | def callback(self, info): 223 | remaining = info["length"] - info["progress"] 224 | eta = f"{remaining / info['speed']:.2f}" 225 | 226 | if info["speed"] < 2e3: 227 | speed = f"{info['speed']:.0f}bps" 228 | elif info["speed"] < 2e6: 229 | speed = f"{info['speed']/1e3:.0f}Kbps" 230 | elif info["speed"] < 2e9: 231 | speed = f"{info['speed']/1e6:.0f}Mbps" 232 | elif info["speed"] < 2e12: 233 | speed = f"{info['speed']/1e9:.0f}Gbps" 234 | 235 | values = [info["title"], f"{info['progress']*100/info['length']:.2f}%", eta, speed] 236 | 237 | if info["status"] == "Finished": 238 | if info["filetype"] == "Video": 239 | if self.tv.exists(info["id"]): 240 | self.tv.delete(info["id"]) 241 | return True 242 | self.cv_thread.put(info) 243 | elif info["status"] == "Converted": 244 | if self.tv.exists(info["id"]): 245 | self.tv.delete(info["id"]) 246 | return True 247 | elif info["status"] == "Converting": 248 | values[1] = "Converting" 249 | 250 | if self.tv.exists(info["id"]): 251 | self.tv.item(info["id"], values=values) 252 | else: 253 | return True 254 | 255 | def check_clipboard(self): 256 | try: 257 | url = self.clipboard_get() 258 | except TclError: 259 | pass 260 | else: 261 | if self.current_url != url: 262 | self.current_url = url 263 | self.preview_frame.url = url 264 | 265 | if self.focus_get() is None: 266 | self.pv_thread.put(self.current_url) 267 | 268 | self.after(100, self.check_clipboard) 269 | 270 | def popup(self, args): 271 | if args.widget == self.tv: 272 | tv_id = self.tv.identify_row(args.y) 273 | if tv_id: 274 | self.tv.selection_set(tv_id) 275 | self.tv_menu.post(self, args.x_root, args.y_root) 276 | else: 277 | self.tv.selection_set() 278 | elif args.widget in (self.preview_frame.title_entry, self.preview_frame.uploader_entry): 279 | self.menu.post(args.widget, args.x_root, args.y_root) 280 | 281 | def cancel(self): 282 | self.tv.delete(self.tv.selection()[0]) 283 | 284 | def pause(self): 285 | # TODO 286 | print("main pause") 287 | 288 | def download_speed(self): 289 | # TODO 290 | print("main download_speed") 291 | 292 | def end(self): 293 | self.settings["geometry"] = self.geometry() 294 | 295 | for n, header in enumerate(self.settings["treeview"]): 296 | header[1] = self.tv.column(f"#{n}", "width") 297 | 298 | with open("settings.json", "w") as fp: 299 | json.dump(self.settings, fp) 300 | 301 | self.destroy() 302 | 303 | if __name__ == "__main__": 304 | Main() 305 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mutagen==1.43.0 2 | requests==2.22.0 3 | youtube-dl==2019.12.25 --------------------------------------------------------------------------------