├── requirements.txt ├── extract.py ├── templates ├── info.jinja2 ├── gallery.jinja2 ├── base.jinja2 └── index.jinja2 ├── README.md └── lo2.py /requirements.txt: -------------------------------------------------------------------------------- 1 | argh==0.26.2 2 | click==8.0.1 3 | dnspython==2.1.0 4 | email-validator==1.1.3 5 | Flask==2.0.1 6 | flask-mongoengine==1.0.0 7 | Flask-WTF==0.15.1 8 | humanize==3.11.0 9 | idna==3.2 10 | itsdangerous==2.0.1 11 | Jinja2==3.0.1 12 | MarkupSafe==2.0.1 13 | mongoengine==0.23.1 14 | pymongo==3.12.0 15 | Werkzeug==2.0.1 16 | WTForms==2.3.3 17 | -------------------------------------------------------------------------------- /extract.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from bs4 import BeautifulSoup 3 | 4 | def extract_youtube_links(html_content): 5 | soup = BeautifulSoup(html_content, 'html.parser') 6 | base_url = "https://www.youtube.com" 7 | for link in soup.find_all('a', href=True): 8 | href = link['href'] 9 | if 'watch?v=' in href: 10 | print(base_url + href) 11 | 12 | def main(): 13 | html_content = sys.stdin.read() 14 | extract_youtube_links(html_content) 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /templates/info.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block style %} 4 | .truncate { 5 | width: 250px; 6 | white-space: nowrap; 7 | overflow: hidden; 8 | text-overflow: ellipsis; 9 | } 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 |

{{ icon('bank') }} Info

15 |
16 | 17 |
18 | 19 |
20 | {{ item_json }}
21 | 
22 |

23 | {{ icon('trash') }} Delete 24 |

25 | {% endblock content %} 26 | -------------------------------------------------------------------------------- /templates/gallery.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block style %} 4 | .masonry { 5 | column-count: 3; 6 | column-gap: 1em; 7 | } 8 | 9 | /* Responsive layout - makes the menu and the content stack on top of each other */ 10 | @media (max-width:600px) { 11 | .masonry { 12 | column-count: 1; 13 | } 14 | } 15 | 16 | @media (min-width:601px) and (max-width:900px) { 17 | .masonry { 18 | column-count: 2; 19 | } 20 | } 21 | 22 | .masonry img { 23 | width: 100%; 24 | } 25 | 26 | .masonry a { 27 | display: block; 28 | margin-bottom: 1em; 29 | break-inside: avoid; 30 | } 31 | {% endblock %} 32 | 33 | {% block content %} 34 | 35 |
36 |
37 | 38 | {% for q in queue %} 39 | {% if q.thumbnail_url %} 40 | 41 | {% endif %} 42 | {% endfor %} 43 | 44 |
45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /templates/base.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {# 5 | 6 | 7 | 8 | 9 | #} 10 | {{ title }} 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% block head %} 18 | {% endblock %} 19 | 24 | 25 | 26 | 27 |
lo2
28 | 29 | {% block content %} 30 | {% endblock %} 31 | 32 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lo2: Simple youtube-dl web frontend 2 | 3 | - Queue up videos to download 4 | - Click on thumbnail to play the video with mpv 5 | - Click on title to see full youtube-dl json info file 6 | - Click on status to open Youtube channel page 7 | - List of optional youtube-dl arguments (video formats: 480p, 720p, etc) 8 | - Extracts thumbnail, title and duration, and tracks when it was added and last watched 9 | 10 | ## Screenshot 11 | 12 | 13 | 14 | ## Installation 15 | 16 | You will need mongodb: 17 | 18 | apt install mongodb-server 19 | 20 | Mongodb isn't distributed by some later linux distributions, so you will need to download and install the MongoDB Community Server from mongodb.com. 21 | 22 | No further database configuration is needed. 23 | 24 | git clone https://gitea.mmmoxford.uk/dvolk/lo2 25 | cd lo2 26 | virtualenv env 27 | source env/bin/activate 28 | pip3 install -r requirements.txt 29 | mkdir static 30 | 31 | Now install yt-dl from https://github.com/yt-dlp/yt-dlp/releases/ 32 | 33 | rename the binary to youtube-dl and mark it as executable: 34 | 35 | mv yt-dlp youtube-dl 36 | chmod a+x youtube-dl 37 | 38 | ## Running 39 | 40 | python3 lo2.py serve 41 | 42 | Open browser at http://127.0.0.1:5555/ 43 | -------------------------------------------------------------------------------- /templates/index.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block style %} 4 | body { 5 | font-family: Roboto !important; 6 | } 7 | .truncate { 8 | white-space: nowrap; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | } 12 | h3 { 13 | font-family: Roboto !important; 14 | font-weight: 700; 15 | } 16 | {% endblock %} 17 | 18 | {% block content %} 19 |
20 |

{{ icon('bank') }} Index

21 |
22 | 23 |
24 |
25 |

Add new url

26 |
27 |

28 |

29 |
30 | 31 |
32 |
33 | 38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |

46 |
47 | 48 |
49 | 50 |
51 |

List of videos (total {{ queue_count }}):

52 | Pages: 53 | {% for page in queue.iter_pages() %} 54 | {% if page %} 55 | {% if page != queue.page %} 56 | {{ page }} 57 | {% else %} 58 | {{ page }} 59 | {% endif %} 60 |   61 | {% else %} 62 | 63 | {% endif %} 64 | {% endfor %} 65 |
66 | 67 | 68 | 69 | 70 | {% for q in queue.items %} 71 | 72 |
73 |
74 | 75 | {% if q.thumbnail_url %} 76 | 77 | 78 | 79 | {% endif %} 80 | 81 |
82 |
83 | 84 | {% if q.status == Status.OK %} 85 | 86 | 87 | {{ q.youtube_dl_json.get("title")|capitalize }} 88 | 89 |
90 | {{ q.url }} 91 |
92 | {% else %} 93 | 94 | {{ q.url }} 95 | 96 | {% endif %} 97 | 98 |
99 |
100 | Added: {{ nice_time(q.added_epochtime) }} 101 | 102 | {% if q.status == Status.OK %} 103 |
104 | Duration: {{ nice_duration(q.youtube_dl_json.get("duration")) }} 105 | {% endif %} 106 | 107 |
108 | Status: {{ q.status.name }} 109 | 110 | {% if q.status == Status.OK and q.youtube_dl_json.get("channel_url") %} 111 |
112 | Channel: {{ q.youtube_dl_json.get("channel_url") }} 113 | {% endif %} 114 | 115 | {% if q.status == Status.OK %} 116 |
117 | JSON dump 118 | {% endif %} 119 | 120 |
121 |
122 |
123 | {% endfor %} 124 | 125 |

126 | Pages: 127 | {% for page in queue.iter_pages() %} 128 | {% if page %} 129 | {% if page != queue.page %} 130 | {{ page }} 131 | {% else %} 132 | {{ page }} 133 | {% endif %} 134 |   135 | {% else %} 136 | 137 | {% endif %} 138 | {% endfor %} 139 |

140 | 141 |
142 | 143 | {% endblock %} 144 | -------------------------------------------------------------------------------- /lo2.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import logging 4 | import subprocess 5 | import threading 6 | import time 7 | from enum import Enum 8 | import datetime as dt 9 | from pathlib import Path 10 | import shlex 11 | 12 | import argh 13 | import flask 14 | import flask_mongoengine as fm 15 | import humanize 16 | 17 | logging.basicConfig(level=logging.DEBUG) 18 | 19 | APP = flask.Flask(__name__) 20 | APP.config["SEND_FILE_MAX_AGE_DEFAULT"] = dt.timedelta(hours=24) 21 | APP.secret_key = "secret" 22 | APP.config["MONGODB_DB"] = "lo2-1" 23 | db = fm.MongoEngine(APP) 24 | 25 | 26 | class Status(Enum): 27 | QUEUED = "queued" 28 | RUNNING = "running" 29 | OK = "ok" 30 | ERROR = "error" 31 | 32 | 33 | class Queue(db.Document): 34 | url = db.StringField() 35 | added_epochtime = db.IntField() 36 | youtube_dl_optional_arg = db.StringField() 37 | finished_epochtime = db.IntField() 38 | lastplayed_epochtime = db.IntField() 39 | status = db.EnumField(Status, default=Status.QUEUED) 40 | thumbnail_url = db.StringField() 41 | youtube_dl_json = db.DynamicField() 42 | meta = {"indexes": ["added_epochtime"]} 43 | 44 | 45 | def or_404(arg): 46 | if not arg: 47 | return flask.abort(404) 48 | return arg 49 | 50 | 51 | def icon(name): 52 | return f'' 53 | 54 | 55 | try: 56 | cmd = "git describe --tags --always --dirty" 57 | version = subprocess.check_output(shlex.split(cmd)).decode().strip() 58 | except: 59 | version = "" 60 | 61 | 62 | youtube_dl_optional_args = ["", "-f 18", "-f 22", "--audio-format best -f 18"] 63 | 64 | 65 | def run_youtube_dl(url, opt): 66 | cmd = f"./youtube-dl {opt} --ignore-errors -o './%(uploader)s_%(uploader_id)s/%(title)s_%(id)s.%(ext)s' --write-description --write-info-json --write-annotations --write-all-thumbnails --restrict-filenames --all-subs --print-json {url}" 67 | logging.info(cmd) 68 | try: 69 | out = subprocess.check_output(cmd, shell=True).decode() 70 | except Exception as e: 71 | logging.error(f"Unexpected error: {str(e)}") 72 | return dict() 73 | out = out.split("\n") 74 | return json.loads(out[0]) 75 | 76 | 77 | def downloader(): 78 | while True: 79 | q = Queue.objects(status=Status.QUEUED).first() 80 | if q: 81 | q.status = Status.RUNNING 82 | q.save() 83 | r = run_youtube_dl(q.url, q.youtube_dl_optional_arg) 84 | if r: 85 | q.status = Status.OK 86 | q.youtube_dl_json = r 87 | th = "" 88 | if r.get("_filename"): 89 | th = make_thumbnail(r.get("_filename")) 90 | elif r.get("filename"): 91 | th = make_thumbnail(r.get("filename")) 92 | q.thumbnail_url = th 93 | else: 94 | q.status = Status.ERROR 95 | q.finished_epochtime = int(time.time()) 96 | q.save() 97 | time.sleep(5) 98 | 99 | 100 | def add_url_to_queue(url): 101 | q = Queue(url=url, status=Status.QUEUED, added_epochtime=int(time.time())) 102 | q.save() 103 | 104 | 105 | def add_url_from_xclip(): 106 | url = subprocess.check_output("xclip -o", shell=True).decode("utf-8").split("\n")[0] 107 | add_url_to_queue(url) 108 | 109 | 110 | @APP.context_processor 111 | def inject_globals(): 112 | return { 113 | "icon": icon, 114 | "version": version, 115 | } 116 | 117 | 118 | @APP.route("/play/") 119 | def play(video_id): 120 | vid = Queue.objects(id=video_id).first_or_404() 121 | logging.info(vid) 122 | filename = vid.youtube_dl_json.get("_filename") 123 | logging.info(filename) 124 | if not Path(filename).is_file(): 125 | filename = str(Path(filename).with_suffix(".mkv")) 126 | if not Path(filename).is_file(): 127 | return flask.abort(404) 128 | logging.info(f"playing {filename}") 129 | cmd = f"nohup mpv --volume=30 --mute {filename} &" 130 | logging.info(cmd) 131 | os.system(cmd) 132 | vid.lastplayed_epochtime = int(time.time()) 133 | vid.save() 134 | return flask.Response( 135 | """ 136 | 137 | 138 | 141 | 142 | 143 | """, 144 | mimetype="text/html", 145 | ) 146 | 147 | 148 | def make_thumbnail(fp: str): 149 | if not fp: 150 | return "" 151 | f = Path(fp) 152 | if not f.is_file(): 153 | if not f.with_suffix(".mkv").is_file(): 154 | logging.warning("can't find file") 155 | return "" 156 | cmd = "" 157 | thumb_file = "" 158 | 159 | thumb_try1 = f.with_suffix(".jpg") 160 | if Path(thumb_try1).is_file(): 161 | cmd = f"ln -s ../{thumb_try1} static/{thumb_try1.name}" 162 | thumb_file = f"static/{thumb_try1.name}" 163 | thumb_try2 = Path(str(f) + "_0.jpg") 164 | if Path(thumb_try2).is_file(): 165 | os.system(f"ln -s ../{thumb_try2} static/{thumb_try2.name}") 166 | thumb_file = f"static/{thumb_try2.name}" 167 | thumb_try3 = f.with_suffix(".0.jpg") 168 | if Path(thumb_try3).is_file(): 169 | cmd = f"ln -s ../{thumb_try3} static/{thumb_try3.name}" 170 | thumb_file = f"static/{thumb_try3.name}" 171 | thumb_try4 = f.with_suffix(".webp") 172 | if Path(thumb_try4).is_file(): 173 | cmd = f"ln -s ../{thumb_try4} static/{thumb_try4.name}" 174 | thumb_file = f"static/{thumb_try4.name}" 175 | 176 | if cmd: 177 | logging.info(cmd) 178 | os.system(cmd) 179 | else: 180 | logging.warning("couldn't find thumbnail file") 181 | return thumb_file 182 | 183 | 184 | def fix_thumbnails(): 185 | for q in Queue.objects(youtube_dl_json__filename__exists=True): 186 | q.thumbnail_url = make_thumbnail(q.youtube_dl_json["filename"]) 187 | q.save() 188 | 189 | 190 | def remove_disk_missing(): 191 | """Remove database entries that aren't on disk any more.""" 192 | for q in Queue.objects(youtube_dl_json__filename__exists=True): 193 | f = Path(q.youtube_dl_json["filename"]) 194 | print(f) 195 | if not f.parent.is_dir(): 196 | q.delete() 197 | else: 198 | if not f.is_file(): 199 | q.delete() 200 | 201 | 202 | @APP.route("/collage", methods=["GET", "POST"]) 203 | def collage(): 204 | queue = Queue.objects.order_by("-added_epochtime").all() 205 | return flask.render_template( 206 | "gallery.jinja2", 207 | title="lo2", 208 | queue=queue, 209 | ) 210 | 211 | 212 | @APP.route("/", methods=["GET", "POST"]) 213 | def index(): 214 | time_now = int(time.time()) 215 | page = int(flask.request.args.get("page", 1)) 216 | if flask.request.method == "GET": 217 | 218 | def nice_duration(t): 219 | return humanize.naturaldelta(dt.timedelta(seconds=t)).capitalize() 220 | 221 | def nice_time(t2): 222 | return humanize.naturaltime( 223 | dt.timedelta(seconds=(time_now - t2)) 224 | ).capitalize() 225 | 226 | queue = Queue.objects.order_by("-added_epochtime").paginate( 227 | page=page, per_page=1000 228 | ) 229 | queue_count = Queue.objects.count() 230 | return flask.render_template( 231 | "index.jinja2", 232 | title="lo2", 233 | queue=queue, 234 | Status=Status, 235 | youtube_dl_optional_args=youtube_dl_optional_args, 236 | nice_time=nice_time, 237 | nice_duration=nice_duration, 238 | page=page, 239 | queue_count=queue_count, 240 | ) 241 | if flask.request.method == "POST": 242 | if flask.request.form.get("Submit") == "Submit_add_url": 243 | unsafe_url = flask.request.form.get("new_url") 244 | youtube_dl_optional_arg = flask.request.form.get("youtube_dl_optional_arg") 245 | new_item = Queue( 246 | url=unsafe_url, 247 | status=Status.QUEUED, 248 | added_epochtime=int(time.time()), 249 | youtube_dl_optional_arg=youtube_dl_optional_arg, 250 | ) 251 | new_item.save() 252 | return flask.redirect(flask.url_for("index")) 253 | 254 | 255 | @APP.route("/info/") 256 | def info(queue_id): 257 | item_json = json.dumps( 258 | json.loads(Queue.objects(id=queue_id).first_or_404().to_json()), indent=4 259 | ) 260 | return flask.render_template("info.jinja2", queue_id=queue_id, item_json=item_json) 261 | 262 | 263 | @APP.route("/delete/") 264 | def delete(queue_id): 265 | q = Queue.objects(id=queue_id).first_or_404() 266 | q.delete() 267 | return flask.redirect(flask.url_for("index")) 268 | 269 | 270 | def enqueue_from_file(filename): 271 | import pathlib 272 | 273 | links = pathlib.Path(filename).read_text().split("\n") 274 | for link in links: 275 | if not link or not link.strip(): 276 | continue 277 | if Queue.objects(url=link).first(): 278 | print(f"already exists: {link}") 279 | continue 280 | 281 | new_item = Queue( 282 | url=link, 283 | status=Status.QUEUED, 284 | added_epochtime=int(time.time()), 285 | youtube_dl_optional_arg="", 286 | ) 287 | new_item.save() 288 | 289 | 290 | def show_urls(): 291 | for q in Queue.objects: 292 | print(q.url) 293 | 294 | 295 | def dedupe(): 296 | seen = [] 297 | for q in Queue.objects().order_by("-id"): 298 | filename = q.youtube_dl_json["filename"] 299 | if filename in seen: 300 | print("deleting") 301 | q.delete() 302 | else: 303 | seen.append(filename) 304 | 305 | 306 | def serve(): 307 | for q in Queue.objects: 308 | if q.status == Status.RUNNING: 309 | q.status = Status.ERROR 310 | q.save() 311 | if q.status == Status.ERROR: 312 | logging.info(f"deleting entry with url: {q.url} due to error state") 313 | q.delete() 314 | 315 | threading.Thread(target=downloader).start() 316 | APP.run(port=5555, debug=True) 317 | 318 | 319 | if __name__ == "__main__": 320 | argh.dispatch_commands( 321 | [ 322 | serve, 323 | make_thumbnail, 324 | fix_thumbnails, 325 | remove_disk_missing, 326 | enqueue_from_file, 327 | show_urls, 328 | dedupe, 329 | ] 330 | ) 331 | --------------------------------------------------------------------------------