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