├── 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 |
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 |
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 |
22 |
23 |
24 |
25 | Add new url
26 |
27 |
28 |
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 |
--------------------------------------------------------------------------------