rcdl 2.2.2__py3-none-any.whl → 3.0.0b13__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.
- rcdl/__init__.py +5 -0
- rcdl/__main__.py +15 -3
- rcdl/core/__init__.py +0 -0
- rcdl/core/adapters.py +241 -0
- rcdl/core/api.py +31 -9
- rcdl/core/config.py +133 -14
- rcdl/core/db.py +239 -191
- rcdl/core/db_queries.py +75 -44
- rcdl/core/downloader.py +184 -142
- rcdl/core/downloader_subprocess.py +257 -85
- rcdl/core/file_io.py +13 -6
- rcdl/core/fuse.py +115 -106
- rcdl/core/models.py +83 -34
- rcdl/core/opti.py +90 -0
- rcdl/core/parser.py +80 -78
- rcdl/gui/__init__.py +0 -0
- rcdl/gui/__main__.py +5 -0
- rcdl/gui/db_viewer.py +41 -0
- rcdl/gui/gui.py +54 -0
- rcdl/gui/video_manager.py +170 -0
- rcdl/interface/__init__.py +0 -0
- rcdl/interface/cli.py +100 -20
- rcdl/interface/ui.py +105 -116
- rcdl/utils.py +163 -5
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b13.dist-info}/METADATA +48 -15
- rcdl-3.0.0b13.dist-info/RECORD +28 -0
- rcdl/scripts/migrate_creators_json_txt.py +0 -37
- rcdl/scripts/migrate_old_format_to_db.py +0 -188
- rcdl/scripts/upload_pypi.py +0 -98
- rcdl-2.2.2.dist-info/RECORD +0 -22
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b13.dist-info}/WHEEL +0 -0
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b13.dist-info}/entry_points.txt +0 -0
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
# core/downloader_subprocess.py
|
|
2
2
|
|
|
3
|
+
"""
|
|
4
|
+
Handle all subprocess call to external tool (yt-dlp, ffmpeg, ...)
|
|
5
|
+
"""
|
|
6
|
+
|
|
3
7
|
import subprocess
|
|
4
8
|
import logging
|
|
5
9
|
from pathlib import Path
|
|
6
10
|
import os
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
|
|
13
|
+
from rcdl.interface.ui import UI, NestedProgress
|
|
14
|
+
from rcdl.core import parser
|
|
15
|
+
from rcdl.core.models import Media, Post
|
|
9
16
|
from rcdl.core.config import Config
|
|
10
|
-
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ytdlp_clear_cache():
|
|
20
|
+
"""Clear yt-dlp cache"""
|
|
21
|
+
cmd = ["yt-dlp", "--rm-cache-dir"]
|
|
22
|
+
subprocess.run(cmd, check=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def kill_aria2c():
|
|
26
|
+
"""Kill all aria2c process"""
|
|
27
|
+
cmd = ["pkill", "-f", "aria2c"]
|
|
28
|
+
subprocess.run(cmd, check=False)
|
|
11
29
|
|
|
12
30
|
|
|
13
31
|
def ytdlp_subprocess(
|
|
14
32
|
url: str,
|
|
15
33
|
filepath: Path | str,
|
|
16
34
|
):
|
|
17
|
-
"""Call yt-dlp in a subprocess"""
|
|
35
|
+
"""Call yt-dlp in a subprocess to download a video"""
|
|
36
|
+
|
|
18
37
|
cmd = [
|
|
19
38
|
"yt-dlp",
|
|
20
39
|
"-q",
|
|
@@ -26,53 +45,150 @@ def ytdlp_subprocess(
|
|
|
26
45
|
"aria2c",
|
|
27
46
|
]
|
|
28
47
|
|
|
29
|
-
logging.info(
|
|
48
|
+
logging.info("CMD: %s", " ".join(cmd))
|
|
30
49
|
|
|
31
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
50
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
32
51
|
if result.returncode != 0:
|
|
33
|
-
logging.error(
|
|
52
|
+
logging.error("yt-dlp failed to dl vid: %s", result.stderr)
|
|
34
53
|
|
|
35
54
|
return result.returncode
|
|
36
55
|
|
|
37
56
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
def ffprobe_get_duration(path: Path) -> int | None:
|
|
58
|
+
"""Get duration of a video in seconds with ffprobe
|
|
59
|
+
Return an int or None if command failed"""
|
|
60
|
+
cmd = [
|
|
61
|
+
"ffprobe",
|
|
62
|
+
"-v",
|
|
63
|
+
"error",
|
|
64
|
+
"-show_entries",
|
|
65
|
+
"format=duration",
|
|
66
|
+
"-of",
|
|
67
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
68
|
+
str(path),
|
|
69
|
+
]
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
cmd,
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.DEVNULL,
|
|
75
|
+
text=True,
|
|
76
|
+
check=True,
|
|
77
|
+
)
|
|
78
|
+
return int(float(result.stdout.strip()))
|
|
79
|
+
except subprocess.CalledProcessError as e:
|
|
80
|
+
UI.error(f"Failed to use ffprobe on {path} due to {e}")
|
|
81
|
+
return None
|
|
82
|
+
except (AttributeError, ValueError, OverflowError) as e:
|
|
83
|
+
UI.error(f"Failed to parse duration result of {path} due to {e}")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_max_width_height(medias: list[Media], post: Post) -> tuple[int, int]:
|
|
88
|
+
"""Get width and height of all media in list. Return max within video found and config"""
|
|
89
|
+
|
|
90
|
+
cmd = [
|
|
91
|
+
"ffprobe",
|
|
92
|
+
"-v",
|
|
93
|
+
"error",
|
|
94
|
+
"-select_streams",
|
|
95
|
+
"v:0",
|
|
96
|
+
"-show_entries",
|
|
97
|
+
"stream=width,height",
|
|
98
|
+
"-of",
|
|
99
|
+
"csv=p=0",
|
|
100
|
+
]
|
|
101
|
+
width = 0
|
|
102
|
+
height = 0
|
|
103
|
+
max_width = 1920
|
|
104
|
+
max_height = 1080
|
|
105
|
+
for m in medias:
|
|
106
|
+
path = os.path.join(Config.creator_folder(post.user), m.file_path)
|
|
107
|
+
full_cmd = cmd + [path]
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
full_cmd, capture_output=True, text=True, check=True
|
|
112
|
+
)
|
|
113
|
+
w_str, h_str = result.stdout.strip().split(",")
|
|
114
|
+
|
|
115
|
+
width = min(int(w_str), max_width)
|
|
116
|
+
height = min(int(h_str), max_height)
|
|
117
|
+
except subprocess.CalledProcessError as e:
|
|
118
|
+
UI.error(f"Fail to use ffprobe to get width, height on {path} due to {e}")
|
|
119
|
+
except (AttributeError, ValueError, OverflowError) as e:
|
|
120
|
+
UI.error(f"Failed to parse duration for {path} due to {e}")
|
|
121
|
+
return (width, height)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_total_duration(medias: list[Media], post: Post) -> int:
|
|
125
|
+
"""Get total duration in ms of all medias in list"""
|
|
126
|
+
|
|
127
|
+
def _get_duration(path: str) -> int:
|
|
128
|
+
"""Get video duration in ms"""
|
|
129
|
+
|
|
130
|
+
cmd = [
|
|
131
|
+
"ffprobe",
|
|
132
|
+
"-v",
|
|
133
|
+
"error",
|
|
134
|
+
"-select_streams",
|
|
135
|
+
"v:0",
|
|
136
|
+
"-show_entries",
|
|
137
|
+
"format=duration",
|
|
138
|
+
"-of",
|
|
139
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
140
|
+
path,
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
144
|
+
return int(float(result.stdout.strip()) * 1000)
|
|
145
|
+
|
|
146
|
+
duration = 0
|
|
147
|
+
for m in medias:
|
|
148
|
+
path = os.path.join(Config.creator_folder(post.user), m.file_path)
|
|
149
|
+
duration += _get_duration(path)
|
|
150
|
+
return duration
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ffmpeg_concat_build_command(medias: list[Media], post: Post) -> dict:
|
|
154
|
+
"""Build the ffmpeg concat command"""
|
|
155
|
+
|
|
156
|
+
width, height = get_max_width_height(medias, post)
|
|
157
|
+
logging.info("Found (%s, %s) (width, height) for this group.", width, height)
|
|
158
|
+
if width == 0:
|
|
159
|
+
width = Config.MAX_WIDTH
|
|
160
|
+
if height == 0:
|
|
161
|
+
height = Config.MAX_HEIGHT
|
|
45
162
|
|
|
46
163
|
# output path
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
output_path = os.path.join(Config.creator_folder(v.creator_id), output_filename)
|
|
164
|
+
output_filename = parser.get_filename_fuse(post)
|
|
165
|
+
output_path = os.path.join(Config.creator_folder(post.user), output_filename)
|
|
50
166
|
|
|
51
167
|
# build cmd
|
|
52
168
|
cmd = ["ffmpeg", "-y", "-progress", "pipe:2", "-nostats"]
|
|
53
169
|
|
|
54
170
|
# inputs
|
|
55
|
-
for
|
|
56
|
-
input_path = os.path.join(Config.creator_folder(
|
|
171
|
+
for media in medias:
|
|
172
|
+
input_path = os.path.join(Config.creator_folder(post.user), media.file_path)
|
|
57
173
|
cmd.extend(["-i", input_path])
|
|
58
174
|
|
|
59
175
|
# filter complex
|
|
60
176
|
filter_lines = []
|
|
61
|
-
for idx in range(len(
|
|
177
|
+
for idx in range(len(medias)):
|
|
62
178
|
filter_lines.append(
|
|
63
179
|
f"[{idx}:v]"
|
|
64
180
|
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
65
181
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
|
66
|
-
f"fps={
|
|
182
|
+
f"fps={Config.FPS},setsar=1"
|
|
67
183
|
f"[v{idx}]"
|
|
68
184
|
)
|
|
69
185
|
|
|
70
186
|
# concat inputs
|
|
71
187
|
concat = []
|
|
72
|
-
for idx in range(len(
|
|
188
|
+
for idx in range(len(medias)):
|
|
73
189
|
concat.append(f"[v{idx}][{idx}:a]")
|
|
74
190
|
|
|
75
|
-
filter_lines.append(f"{''.join(concat)}concat=n={len(
|
|
191
|
+
filter_lines.append(f"{''.join(concat)}concat=n={len(medias)}:v=1:a=1[outv][outa]")
|
|
76
192
|
filter_complex = ";".join(filter_lines)
|
|
77
193
|
|
|
78
194
|
cmd.extend(
|
|
@@ -86,13 +202,13 @@ def ffmpeg_concat_build_command(videos: list[Video]) -> dict:
|
|
|
86
202
|
"-c:v",
|
|
87
203
|
"libx264",
|
|
88
204
|
"-preset",
|
|
89
|
-
|
|
205
|
+
Config.PRESET,
|
|
90
206
|
"-threads",
|
|
91
|
-
str(
|
|
207
|
+
str(Config.THREADS),
|
|
92
208
|
"-c:a",
|
|
93
209
|
"aac",
|
|
94
|
-
"-
|
|
95
|
-
"
|
|
210
|
+
"-movflags",
|
|
211
|
+
"+faststart",
|
|
96
212
|
output_path,
|
|
97
213
|
]
|
|
98
214
|
)
|
|
@@ -100,38 +216,38 @@ def ffmpeg_concat_build_command(videos: list[Video]) -> dict:
|
|
|
100
216
|
return {"cmd": cmd, "output_path": output_path}
|
|
101
217
|
|
|
102
218
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
219
|
+
def parse_line_ffmpeg_concat_into_advance(line: str) -> int | None:
|
|
220
|
+
line = line.strip()
|
|
221
|
+
if not line:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
progres_key = "out_time_ms"
|
|
225
|
+
if line.startswith(progres_key):
|
|
226
|
+
current_progress_str = line.replace(f"{progres_key}=", "").strip()
|
|
227
|
+
try:
|
|
228
|
+
current_progress_us = int(current_progress_str)
|
|
229
|
+
current_progress_ms = current_progress_us // 1000
|
|
230
|
+
return current_progress_ms
|
|
231
|
+
except ValueError as e:
|
|
232
|
+
logging.warning(
|
|
233
|
+
"Skipping invalid progress line: %r (%s)",
|
|
234
|
+
current_progress_str,
|
|
235
|
+
e,
|
|
236
|
+
)
|
|
237
|
+
return None
|
|
238
|
+
except Exception as e:
|
|
239
|
+
UI.error(f"Unexpected error while updating progress: {e}")
|
|
240
|
+
return None
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def ffmpeg_concat(medias: list[Media], post: Post, progress: NestedProgress):
|
|
245
|
+
"""Run ffmpeg concat command to merge video together"""
|
|
246
|
+
|
|
247
|
+
command_builder = ffmpeg_concat_build_command(medias, post)
|
|
131
248
|
cmd = command_builder["cmd"]
|
|
132
|
-
output_path = command_builder["output_path"]
|
|
133
249
|
|
|
134
|
-
logging.info(
|
|
250
|
+
logging.info("CMD: %s", " ".join(cmd))
|
|
135
251
|
|
|
136
252
|
ffmpeg_log = Config.CACHE_DIR / "ffmpeg.log"
|
|
137
253
|
with open(ffmpeg_log, "w", encoding="utf-8") as log_file:
|
|
@@ -146,45 +262,101 @@ def ffmpeg_concat(videos: list[Video]):
|
|
|
146
262
|
)
|
|
147
263
|
|
|
148
264
|
assert process.stderr is not None
|
|
149
|
-
total_duration = get_total_duration(
|
|
150
|
-
progress
|
|
265
|
+
total_duration = get_total_duration(medias, post)
|
|
266
|
+
progress.start_current(
|
|
267
|
+
description=f"{post.user}->{medias[0].file_path}", total=total_duration
|
|
268
|
+
)
|
|
269
|
+
|
|
151
270
|
last_progress = 0
|
|
152
|
-
UI.set_current_concat_progress(f"{videos[0].relative_path}", output_path)
|
|
153
271
|
|
|
154
272
|
for line in process.stderr:
|
|
155
273
|
line = line.strip()
|
|
156
|
-
if not line:
|
|
157
|
-
continue
|
|
158
|
-
|
|
159
274
|
print(line, file=log_file)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
current_progress_ms = current_progress_us // 1000
|
|
167
|
-
delta = current_progress_ms - last_progress
|
|
168
|
-
progress.advance(task, advance=delta)
|
|
169
|
-
last_progress = current_progress_ms
|
|
170
|
-
except Exception:
|
|
171
|
-
pass
|
|
275
|
+
current_progress_ms = parse_line_ffmpeg_concat_into_advance(line)
|
|
276
|
+
if current_progress_ms is None:
|
|
277
|
+
continue
|
|
278
|
+
delta = current_progress_ms - last_progress
|
|
279
|
+
progress.advance_current(step=delta)
|
|
280
|
+
last_progress = current_progress_ms
|
|
172
281
|
|
|
173
282
|
process.wait()
|
|
174
|
-
|
|
283
|
+
progress.finish_current()
|
|
175
284
|
|
|
285
|
+
UI.debug(f"Result: {process.returncode}")
|
|
176
286
|
if process.returncode != 0:
|
|
177
287
|
UI.error(f"Failed to concat videos. See ffmpeg log file {ffmpeg_log}")
|
|
178
|
-
with open(ffmpeg_log, "r") as f:
|
|
179
|
-
lines = f.
|
|
180
|
-
logging.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
logging.error("---END FFMPEG LOG---")
|
|
288
|
+
with open(ffmpeg_log, "r", encoding="utf-8") as f:
|
|
289
|
+
lines = f.read()
|
|
290
|
+
logging.warning("---FFMPEG LOG---")
|
|
291
|
+
logging.warning(lines)
|
|
292
|
+
logging.warning("---END FFMPEG LOG---")
|
|
184
293
|
return process.returncode
|
|
185
294
|
|
|
186
|
-
temp_output_path = output_path
|
|
187
|
-
new_output_path = temp_output_path.replace("tmp_", "")
|
|
188
|
-
os.replace(temp_output_path, new_output_path)
|
|
189
|
-
UI.info(f"Rename {output_path} -> {output_path}")
|
|
190
295
|
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def parse_line_into_pourcent(line: str) -> float | None:
|
|
299
|
+
line = line.strip()
|
|
300
|
+
if not line:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
if "%" in line:
|
|
304
|
+
try:
|
|
305
|
+
parts = line.split("%")
|
|
306
|
+
parts = parts[0].strip().split(" ")
|
|
307
|
+
pourcent = parts[-1]
|
|
308
|
+
flt_prcnt = float(pourcent)
|
|
309
|
+
return flt_prcnt
|
|
310
|
+
except Exception as e:
|
|
311
|
+
UI.error(f"Error parsing line {line}: {e}")
|
|
312
|
+
return None
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def handbrake_optimized(media: Media, user: str, progress: NestedProgress):
|
|
317
|
+
"""Optimize video size with handbrake software"""
|
|
318
|
+
|
|
319
|
+
handbrake_process = Config.HANDBRAKE_RUN_CMD.split(" ")
|
|
320
|
+
|
|
321
|
+
folder_path = Config.creator_folder(user)
|
|
322
|
+
video_path = os.path.join(folder_path, media.file_path)
|
|
323
|
+
|
|
324
|
+
output_path = video_path + ".opti.mp4"
|
|
325
|
+
|
|
326
|
+
cmd = ["-i", video_path, "-o", output_path, "--preset", "HQ 1080p30 Surround"]
|
|
327
|
+
|
|
328
|
+
full_cmd = handbrake_process + cmd
|
|
329
|
+
UI.debug(f"Running cmd '{full_cmd}'")
|
|
330
|
+
|
|
331
|
+
# -- process
|
|
332
|
+
process = subprocess.Popen(
|
|
333
|
+
full_cmd,
|
|
334
|
+
stdout=subprocess.PIPE,
|
|
335
|
+
stderr=subprocess.DEVNULL,
|
|
336
|
+
text=True,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
assert process.stdout is not None
|
|
340
|
+
progress.start_current(description="Optimizing", total=100)
|
|
341
|
+
progress.set_status(f"user@({media.service}) ->", f"{media.file_size}")
|
|
342
|
+
|
|
343
|
+
current_progress = 0.0
|
|
344
|
+
|
|
345
|
+
for line in process.stdout:
|
|
346
|
+
float_pourcent = parse_line_into_pourcent(line)
|
|
347
|
+
if float_pourcent is None:
|
|
348
|
+
continue
|
|
349
|
+
delta = float_pourcent - current_progress
|
|
350
|
+
current_progress = float_pourcent
|
|
351
|
+
progress.advance_current(step=delta)
|
|
352
|
+
|
|
353
|
+
process.wait()
|
|
354
|
+
progress.finish_current()
|
|
355
|
+
# -- end process
|
|
356
|
+
|
|
357
|
+
if process.returncode == 0:
|
|
358
|
+
UI.debug("Return code: 0")
|
|
359
|
+
else:
|
|
360
|
+
UI.error(f"Return code: {process.returncode}")
|
|
361
|
+
|
|
362
|
+
return process.returncode
|
rcdl/core/file_io.py
CHANGED
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
# core/file_io.py
|
|
2
2
|
|
|
3
|
+
"""All write/read to file function (excluding sqlite database)"""
|
|
4
|
+
|
|
3
5
|
import json
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
def write_json(path, data, mode="w"):
|
|
7
|
-
|
|
9
|
+
"""Write dict data to json"""
|
|
10
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
8
11
|
json.dump(data, f, indent=4)
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
def load_json(path) -> dict:
|
|
12
|
-
|
|
15
|
+
"""Load data from json"""
|
|
16
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
13
17
|
data = json.load(f)
|
|
14
18
|
return data
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def load_txt(path) -> list[str]:
|
|
18
|
-
|
|
22
|
+
"""Read text from a .txt file.
|
|
23
|
+
Return list of stripped lines"""
|
|
24
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
19
25
|
lines = f.readlines()
|
|
20
|
-
for i in
|
|
21
|
-
lines[i] =
|
|
26
|
+
for i, line in enumerate(lines):
|
|
27
|
+
lines[i] = line.strip()
|
|
22
28
|
return lines
|
|
23
29
|
|
|
24
30
|
|
|
25
31
|
def write_txt(path, lines: list[str] | str, mode: str = "a"):
|
|
32
|
+
"""Write txt to .txt file"""
|
|
26
33
|
if isinstance(lines, str):
|
|
27
34
|
lines = [lines]
|
|
28
35
|
|
|
29
|
-
with open(path, mode) as f:
|
|
36
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
30
37
|
for line in lines:
|
|
31
38
|
if not line.endswith("\n"):
|
|
32
39
|
f.write(line + "\n")
|