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.
@@ -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
- from rcdl.core.models import Video
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
- from rcdl.interface.ui import UI
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(f"CMD: {' '.join(cmd)}")
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(f"yt-dlp failed to dl vid: {result.stderr}")
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 ffmpeg_concat_build_command(videos: list[Video]) -> dict:
39
- # parameters
40
- width: int = 1920
41
- height: int = 1080
42
- fps: int = 30
43
- preset: str = "veryfast"
44
- threads: int = 0 # 0 for max
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
- v = videos[0]
48
- output_filename = f"tmp_{v.published}_{v.title}.mp4"
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 v in videos:
56
- input_path = os.path.join(Config.creator_folder(v.creator_id), v.relative_path)
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(videos)):
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={fps},setsar=1"
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(videos)):
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(videos)}:v=1:a=1[outv][outa]")
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
- preset,
205
+ Config.PRESET,
90
206
  "-threads",
91
- str(threads),
207
+ str(Config.THREADS),
92
208
  "-c:a",
93
209
  "aac",
94
- "-f",
95
- "mp4",
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 get_duration(path: str) -> int:
104
- cmd = [
105
- "ffprobe",
106
- "-v",
107
- "error",
108
- "-select_streams",
109
- "v:0",
110
- "-show_entries",
111
- "format=duration",
112
- "-of",
113
- "default=noprint_wrappers=1:nokey=1",
114
- path,
115
- ]
116
-
117
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
118
- return int(float(result.stdout.strip()) * 1000)
119
-
120
-
121
- def get_total_duration(videos: list[Video]) -> int:
122
- duration = 0
123
- for v in videos:
124
- path = os.path.join(Config.creator_folder(v.creator_id), v.relative_path)
125
- duration += get_duration(path)
126
- return duration
127
-
128
-
129
- def ffmpeg_concat(videos: list[Video]):
130
- command_builder = ffmpeg_concat_build_command(videos)
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(f"CMD: {' '.join(cmd)}")
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(videos)
150
- progress, task = UI.concat_progress(total=total_duration)
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
- progres_key = "out_time_ms"
162
- if line.startswith(progres_key):
163
- current_progress_str = line.replace(f"{progres_key}=", "").strip()
164
- try:
165
- current_progress_us = int(current_progress_str)
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
- UI.close_concat_progress()
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.readlines()
180
- logging.error("---FFMPEG LOG---")
181
- for line in lines:
182
- logging.error(line)
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
- with open(path, mode) as f:
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
- with open(path, "r") as f:
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
- with open(path, "r") as f:
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 range(len(lines)):
21
- lines[i] = lines[i].strip()
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")