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.
rcdl/interface/cli.py CHANGED
@@ -1,10 +1,18 @@
1
1
  # interface/cli.py
2
2
 
3
+ """Hold all cli commands"""
4
+
3
5
  import logging
6
+ import subprocess
7
+ import sys
8
+ import inspect
4
9
 
5
10
  import click
6
11
 
7
12
  from rcdl.core import downloader as dl
13
+ from rcdl.interface.ui import UI, print_db_info
14
+ from rcdl.core.fuse import fuse_medias
15
+ from rcdl.core.opti import optimize
8
16
  from rcdl.core.config import Config
9
17
  from rcdl.core.parser import (
10
18
  get_creators,
@@ -13,9 +21,7 @@ from rcdl.core.parser import (
13
21
  append_creator,
14
22
  )
15
23
  from rcdl.core.db import DB
16
- from .ui import UI
17
- from rcdl.core.fuse import fuse_videos
18
-
24
+ from rcdl.utils import clean_all
19
25
 
20
26
  from rcdl import __version__
21
27
 
@@ -33,27 +39,56 @@ def refresh():
33
39
  dl.refresh_creators_videos()
34
40
 
35
41
  with DB() as db:
36
- info = db.get_db_videos_info()
37
-
38
- UI.db_videos_status_table(info)
42
+ info = db.get_nb_per_status()
43
+ print_db_info(info)
39
44
 
40
45
 
41
46
  @click.command(help="Download all videos from all creator")
42
- def dlsf():
47
+ @click.option(
48
+ "--max-fail-count",
49
+ type=int,
50
+ help="Set maximum number of failed attempts. Take precedence over config.toml",
51
+ )
52
+ def dlsf(max_fail_count: int | None):
43
53
  """Download video based on DB information
44
54
 
45
55
  - read databse
46
56
  - for each video NOT_DOWNLOADED or FAILED & fail_count < settings, dl video
47
57
  """
48
58
  UI.info("Welcome to RCDL dlsf")
49
- dl.download_videos_to_be_dl()
59
+ dl.download_videos_to_be_dl(max_fail_count)
50
60
 
51
61
 
52
62
  @click.command("fuse", help="Fuse part video into one")
53
63
  def fuse():
54
64
  """Fuse videos"""
55
- UI.info("fuse")
56
- fuse_videos()
65
+ UI.info("Fuse/Concat video together")
66
+ fuse_medias()
67
+
68
+
69
+ @click.command(help="Optimized video size")
70
+ def opti():
71
+ """Optimized video size"""
72
+ UI.info("Optimized video")
73
+ optimize()
74
+
75
+
76
+ @click.command(help="Clean patial download file, cache, etc...")
77
+ @click.option("--all", is_flag=True, help="Act as if al lthe flags are true")
78
+ @click.option(
79
+ "--partial", is_flag=True, help="Remove partial file from download (.aria2, .part)"
80
+ )
81
+ @click.option(
82
+ "--cache", is_flag=True, help="remove cache from yt-dlp and kill aria2 process"
83
+ )
84
+ @click.option(
85
+ "--medias-deleted",
86
+ is_flag=True,
87
+ help="Remove media marked for deletion (TO_BE_DELETED status)",
88
+ )
89
+ def clean(all: bool, partial: bool, cache: bool, medias_deleted: bool):
90
+ """Remove partial download, clear subprocesses cache"""
91
+ clean_all(all, partial, cache, medias_deleted)
57
92
 
58
93
 
59
94
  @click.command(help="Discover videos/creators with tags")
@@ -67,7 +102,7 @@ def discover(tag, max_page):
67
102
  msg = f"[cdl] discover with tag={tag} max_page={max_page}"
68
103
  click.echo(msg)
69
104
  logging.info(msg)
70
- dl.discover(tag, max_page)
105
+ UI.info("WIP - UNIMPLEMENTED")
71
106
 
72
107
 
73
108
  @click.command("add", help="Add a creator")
@@ -85,8 +120,9 @@ def add_creator(creator_input):
85
120
 
86
121
 
87
122
  @click.command("remove", help="Remove a creator")
123
+ @click.option("--db", is_flag=True)
88
124
  @click.argument("creator_input")
89
- def remove_creator(creator_input):
125
+ def remove_creator(db, creator_input):
90
126
  """Remove a creator (excat line) from creators.txt"""
91
127
  _service, creator_id = parse_creator_input(str(creator_input))
92
128
 
@@ -94,7 +130,7 @@ def remove_creator(creator_input):
94
130
  all_creators = []
95
131
  matched_creator = None
96
132
  for creator in creators:
97
- if creator.creator_id == creator_id:
133
+ if creator.id == creator_id:
98
134
  matched_creator = creator
99
135
  continue
100
136
  all_creators.append(creator)
@@ -102,35 +138,79 @@ def remove_creator(creator_input):
102
138
  if matched_creator is None:
103
139
  UI.error(f"Could not find creator from {creator_input}")
104
140
  return
105
- else:
106
- open(Config.CREATORS_FILE, "w").close()
107
- for c in all_creators:
108
- append_creator(c)
109
- UI.info(
110
- f"Removed creator {matched_creator.creator_id}@({matched_creator.service})"
141
+
142
+ try:
143
+ with open(Config.CREATORS_FILE, "w", encoding="utf-8"):
144
+ pass
145
+ except OSError as e:
146
+ UI.error(
147
+ f"Failed to create creators file at {Config.CREATORS_FILE} due to: {e}"
111
148
  )
149
+ return
150
+ for c in all_creators:
151
+ append_creator(c)
152
+ UI.info(f"Removed creator {matched_creator.id}@({matched_creator.service})")
153
+ if db:
154
+ UI.info("Not yet implemented")
112
155
 
113
156
 
114
157
  @click.command("list", help="List all creators")
115
158
  def list_creators():
159
+ """List all creators in creators.txt"""
116
160
  creators = get_creators()
117
161
  UI.table_creators(creators)
118
162
 
119
163
 
164
+ @click.command("status", help="Print db info")
165
+ def db_status():
166
+ """Print number of entry per status per tables in the database"""
167
+ with DB() as db:
168
+ info = db.get_nb_per_status()
169
+ print_db_info(info)
170
+
171
+
172
+ @click.command("gui", help="Launch GUI")
173
+ def launch_gui():
174
+ UI.info("Launching GUI...")
175
+ import importlib.resources as resources
176
+
177
+ gui_path = resources.files("rcdl.gui").joinpath("gui.py")
178
+ subprocess.run([sys.executable, "-m", "streamlit", "run", str(gui_path)])
179
+
180
+
181
+ @click.command("show-config")
182
+ def show_config():
183
+ """Show all value in Config"""
184
+ for name, value in vars(Config).items():
185
+ if not name.startswith("__") and not inspect.isroutine(value):
186
+ UI.success(f"{name}: {value}")
187
+
188
+
120
189
  # --- CLI GROUP ---
121
190
  @click.group()
122
191
  @click.option("--debug", is_flag=True)
123
192
  @click.option("--dry-run", is_flag=True)
124
193
  @click.version_option(version=__version__, prog_name=Config.APP_NAME)
125
194
  def cli(debug, dry_run):
195
+ """Init cli app. Assign Config var depending on flag used when calling prgm"""
126
196
  Config.set_debug(debug)
127
197
  Config.set_dry_run(dry_run)
128
198
 
129
199
 
200
+ # main commands
130
201
  cli.add_command(dlsf)
131
202
  cli.add_command(discover)
132
203
  cli.add_command(refresh)
204
+ cli.add_command(fuse)
205
+ cli.add_command(opti)
206
+ cli.add_command(launch_gui)
207
+
208
+ # creators command
133
209
  cli.add_command(add_creator)
134
210
  cli.add_command(remove_creator)
135
211
  cli.add_command(list_creators)
136
- cli.add_command(fuse)
212
+
213
+ # helper command
214
+ cli.add_command(clean)
215
+ cli.add_command(db_status)
216
+ cli.add_command(show_config)
rcdl/interface/ui.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # interface/ui.py
2
2
 
3
3
  import logging
4
+ import click
4
5
  from rich.console import Console, Group
5
6
  from rich.table import Table
6
7
  from rich.progress import (
@@ -9,11 +10,112 @@ from rich.progress import (
9
10
  BarColumn,
10
11
  TextColumn,
11
12
  TimeRemainingColumn,
13
+ TimeElapsedColumn,
12
14
  )
13
15
  from rich import box
14
16
  from rich.live import Live
15
17
  from rich.text import Text
16
- from rcdl.core.models import VideoStatus, Creator
18
+ from rcdl.core.models import Creator, FusedStatus, Status
19
+
20
+
21
+ class NestedProgress:
22
+ def __init__(self, console: Console):
23
+ self.console = console
24
+ self.global_progress: Progress | None = None
25
+ self.current_progress: Progress | None = None
26
+ self.live: Live | None = None
27
+
28
+ self.total_task: int | None = None
29
+ self.current_task: int | None = None
30
+ self.status_text = Text("", style="cyan")
31
+ self.current_label = ""
32
+
33
+ def start(
34
+ self, *, total: int, total_label: str = "Total", current_label: str = "Current"
35
+ ):
36
+ # Global progress (elapsed only)
37
+ self.global_progress = Progress(
38
+ SpinnerColumn(),
39
+ TextColumn("[bold cyan]{task.description}"),
40
+ BarColumn(),
41
+ TextColumn("{task.completed}/{task.total}"),
42
+ TimeElapsedColumn(),
43
+ console=self.console,
44
+ transient=False,
45
+ )
46
+
47
+ # Current task progress (ETA included)
48
+ self.current_progress = Progress(
49
+ SpinnerColumn(),
50
+ TextColumn("[bold cyan]{task.description}"),
51
+ BarColumn(),
52
+ TextColumn("{task.completed}/{task.total}"),
53
+ TimeRemainingColumn(),
54
+ console=self.console,
55
+ transient=False,
56
+ )
57
+
58
+ self.current_label = current_label
59
+ group = Group(self.global_progress, self.current_progress, self.status_text)
60
+ self.live = Live(group, console=self.console)
61
+ self.live.__enter__()
62
+
63
+ self.total_task = self.global_progress.add_task(total_label, total=total)
64
+ self.current_task = self.current_progress.add_task(
65
+ current_label, total=1, visible=False
66
+ )
67
+
68
+ # total task helpers
69
+ def advance_total(self, step: int = 1):
70
+ if self.global_progress and self.total_task is not None:
71
+ self.global_progress.advance(self.total_task, step) # type: ignore
72
+
73
+ def reset_current(self):
74
+ if not self.current_progress or self.current_task is None:
75
+ return
76
+
77
+ self.current_progress.remove_task(self.current_task) # type: ignore
78
+
79
+ self.current_task = self.current_progress.add_task(
80
+ self.current_label,
81
+ total=1,
82
+ visible=False,
83
+ )
84
+
85
+ def start_current(self, description: str, total: int | None = None):
86
+ if not self.current_progress or self.current_task is None:
87
+ return
88
+
89
+ self.reset_current()
90
+
91
+ self.current_progress.update(
92
+ self.current_task, # type: ignore
93
+ description=description,
94
+ total=total or 1,
95
+ completed=0,
96
+ visible=True,
97
+ )
98
+
99
+ def advance_current(self, step: int | float = 1):
100
+ if self.current_progress and self.current_task is not None:
101
+ self.current_progress.advance(self.current_task, step) # type: ignore
102
+
103
+ def finish_current(self):
104
+ if self.current_progress and self.current_task is not None:
105
+ self.current_progress.update(self.current_task, visible=False) # type: ignore
106
+
107
+ # Status line
108
+ def set_status(self, cyan: str, green: str = ""):
109
+ self.status_text.plain = ""
110
+ self.status_text.append(cyan, style="cyan")
111
+ if green:
112
+ self.status_text.append(green, style="green")
113
+
114
+ # Close
115
+ def close(self):
116
+ if self.live:
117
+ self.live.__exit__(None, None, None)
118
+ self.live = None
17
119
 
18
120
 
19
121
  class UI:
@@ -23,6 +125,7 @@ class UI:
23
125
  _video_progress_text: Text | None = None
24
126
  _concat_progress_text: Text | None = None
25
127
  _live: Live | None = None
128
+ _generic_text: Text | None = None
26
129
 
27
130
  @staticmethod
28
131
  def _log_to_file(log_level, msg: str):
@@ -41,8 +144,8 @@ class UI:
41
144
 
42
145
  @classmethod
43
146
  def debug(cls, msg: str):
44
- """Print & log debug msg"""
45
- cls.console.print(f"[dim]{msg}[/]")
147
+ """Log debug msg"""
148
+ # cls.console.print(f"[dim]{msg}[/]")
46
149
  cls._log_to_file(cls.logger.debug, msg)
47
150
 
48
151
  @classmethod
@@ -57,26 +160,6 @@ class UI:
57
160
  cls.console.print(f"[red]{msg}[/]")
58
161
  cls._log_to_file(cls.logger.debug, msg)
59
162
 
60
- @classmethod
61
- def db_videos_status_table(cls, info: dict):
62
- """
63
- Print to cli a table with info of numbers of videos per status.
64
- Take in arg a dict: {
65
- "not_downloaded": int,
66
- "downloaded": int, "failed: int", "skipped: int", "ignored: int"}
67
- """
68
-
69
- table = Table(title="DB Videos status")
70
-
71
- table.add_column("Video status")
72
- table.add_column("Number of videos")
73
-
74
- for status in VideoStatus:
75
- name = status.value.replace("_", " ").capitalize()
76
- table.add_row(name, str(info[status.value]))
77
-
78
- cls.console.print(table)
79
-
80
163
  @classmethod
81
164
  def table_creators(cls, creators: list[Creator]):
82
165
  """Print to cli a table with all creators in creators.txt. Format is Creator ID | Service"""
@@ -84,7 +167,7 @@ class UI:
84
167
  table.add_column("Creators ID")
85
168
  table.add_column("Service")
86
169
  for creator in creators:
87
- table.add_row(creator.creator_id, creator.service)
170
+ table.add_row(creator.id, creator.service)
88
171
  cls.console.print(table)
89
172
 
90
173
  @classmethod
@@ -98,96 +181,14 @@ class UI:
98
181
  )
99
182
  return progress
100
183
 
101
- @classmethod
102
- def video_progress(cls, total: int):
103
- """Create video download progress output"""
104
- progress = Progress(
105
- SpinnerColumn(),
106
- TextColumn("[bold cyan]{task.description}"),
107
- BarColumn(),
108
- TextColumn("{task.completed}/{task.total}"),
109
- TimeRemainingColumn(),
110
- console=cls.console,
111
- transient=False, # remove the bar after completion
112
- )
113
-
114
- cls._video_progress_text = Text("Waiting...", style="Cyan")
115
- group = Group(progress, cls._video_progress_text)
116
- cls._live = Live(group, console=cls.console)
117
- cls._live.__enter__()
118
-
119
- task = progress.add_task("Downloading videos", total=total)
120
- return progress, task
121
-
122
- @classmethod
123
- def set_current_video_progress(cls, creator_info: str, filename: str):
124
- """Update video download output
125
- args:
126
- creator_info: str = 'creator_id@(service)'
127
- filename: str = video.relative_path
128
- """
129
- if cls._video_progress_text is None:
130
- return
131
- cls._video_progress_text.plain = ""
132
- cls._video_progress_text.append(f"{creator_info} -> ", style="Cyan")
133
- cls._video_progress_text.append(filename, style="green")
134
-
135
- @classmethod
136
- def close_video_progress(cls):
137
- """Close video progress"""
138
- if cls._live:
139
- cls._live.__exit__(None, None, None)
140
- cls._live = None
141
-
142
- @classmethod
143
- def concat_progress(cls, total: int):
144
- """Create concat progress bat"""
145
- progress = Progress(
146
- SpinnerColumn(),
147
- TextColumn("[bold cyan]{task.description}"),
148
- BarColumn(),
149
- TextColumn("{task.completed}/{task.total}"),
150
- TimeRemainingColumn(),
151
- console=cls.console,
152
- transient=False, # remove the bar after completion
153
- )
154
-
155
- cls._concat_progress_text = Text("Waiting...", style="Cyan")
156
- group = Group(progress, cls._concat_progress_text)
157
- cls._live = Live(group, console=cls.console)
158
- cls._live.__enter__()
159
-
160
- task = progress.add_task("Concatenating videos", total=total)
161
- return progress, task
162
-
163
- @classmethod
164
- def set_current_concat_progress(cls, msg: str, filename: str):
165
- """Update video download output
166
- args:
167
- creator_info: str = 'creator_id@(service)'
168
- filename: str = video.relative_path
169
- """
170
- if cls._concat_progress_text is None:
171
- return
172
- cls._concat_progress_text.plain = ""
173
- cls._concat_progress_text.append(f"{msg} -> ", style="Cyan")
174
- cls._concat_progress_text.append(filename, style="green")
175
-
176
- @classmethod
177
- def close_concat_progress(cls):
178
- """Close video progress"""
179
- if cls._live:
180
- cls._live.__exit__(None, None, None)
181
- cls._live = None
182
184
 
183
- @classmethod
184
- def progress_total_concat(cls):
185
- progress = Progress(
186
- SpinnerColumn(),
187
- TextColumn("[progress.description]{task.description}"),
188
- BarColumn(),
189
- TextColumn("{task.completed}/{task.total}"),
190
- console=cls.console,
191
- transient=False, # remove progress bar after finish
192
- )
193
- return progress
185
+ def print_db_info(info: dict):
186
+ click.echo("--- TABLES ---")
187
+ click.echo("Posts:")
188
+ click.echo(f"\t{info['posts']} total")
189
+ click.echo("FusedMedias:")
190
+ for status in FusedStatus:
191
+ click.echo(f"\t{info['fuses'][status]} {status}")
192
+ click.echo("Medias:")
193
+ for status in Status:
194
+ click.echo(f"\t{info['medias'][status]} {status}")
rcdl/utils.py CHANGED
@@ -1,11 +1,180 @@
1
1
  # utils.py
2
2
 
3
- import click
3
+ """Hold collection of useful functions"""
4
4
 
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import os
10
+ import time
5
11
 
6
- def echo(text: str):
7
- click.echo(text)
12
+ from rcdl.core.config import Config
13
+ from rcdl.interface.ui import UI
8
14
 
9
15
 
10
- def echoc(text: str, color: str):
11
- click.echo(click.style(text, fg=color))
16
+ def get_date_now() -> str:
17
+ """Return an str of current datetime in isoformat"""
18
+ return datetime.now(timezone.utc).isoformat()
19
+
20
+
21
+ def get_media_metadata(full_path: str) -> tuple[int, int, str]:
22
+ """Get duration (if possible), file_size and checksum"""
23
+ from rcdl.core.downloader_subprocess import ffprobe_get_duration
24
+
25
+ path = Path(full_path)
26
+ if not os.path.exists(path):
27
+ UI.error(f"{path} path should exist but does not.")
28
+
29
+ file_size = path.stat().st_size
30
+ checksum = get_media_hash(path)
31
+ if checksum is None:
32
+ checksum = ""
33
+ duration = ffprobe_get_duration(path)
34
+ if duration is None:
35
+ duration = 0
36
+ return duration, file_size, checksum
37
+
38
+
39
+ def get_media_hash(path: Path, retries: int = Config.CHECKSUM_RETRY) -> str | None:
40
+ """Return a hash of a video file"""
41
+ for attempt in range(retries + 1):
42
+ try:
43
+ sha256_hash = hashlib.sha256()
44
+ with path.open("rb") as f:
45
+ for chunk in iter(lambda: f.read(8192), b""):
46
+ sha256_hash.update(chunk)
47
+ checksum = sha256_hash.hexdigest()
48
+ return checksum
49
+ except OSError as e:
50
+ if attempt >= retries:
51
+ UI.error(
52
+ f"OSError: Failed to checksum {path} "
53
+ f"at attempt {attempt + 1} due to: {e}"
54
+ )
55
+ return None
56
+ UI.warning(
57
+ f"OSError: Failed to checksum {path} "
58
+ f"at attempt {attempt + 1} due to: {e}"
59
+ )
60
+ time.sleep(1.0)
61
+
62
+
63
+ def get_json_hash(data: dict) -> tuple[str, str]:
64
+ """Return a hash of a dict"""
65
+ raw_json = json.dumps(data, sort_keys=True)
66
+ json_hash = hashlib.sha256(raw_json.encode("utf-8")).hexdigest()
67
+ return raw_json, json_hash
68
+
69
+
70
+ def bytes_to_mb(bytes: float | int) -> float:
71
+ return bytes / (1024 * 1024)
72
+
73
+
74
+ def bytes_to_str(bytes: float | int) -> str:
75
+ mb = bytes_to_mb(bytes)
76
+
77
+ if mb > 1024:
78
+ gb = mb / 1024
79
+ return f"{gb:.1f} GB"
80
+ return f"{mb:.1f} MB"
81
+
82
+
83
+ def clean_all(all: bool, partial: bool, cache: bool, medias_deleted: bool):
84
+ """Remove partial file & external programm cache"""
85
+
86
+ from rcdl.core.db import DB
87
+ from rcdl.core.models import Status
88
+ from rcdl.core.downloader_subprocess import ytdlp_clear_cache, kill_aria2c
89
+
90
+ if all:
91
+ partial = True
92
+ cache = True
93
+ medias_deleted = True
94
+
95
+ # remove all partial file
96
+ if partial:
97
+ path = Config.BASE_DIR
98
+ folders = os.listdir(path)
99
+
100
+ for folder in folders:
101
+ if folder.startswith("."):
102
+ continue
103
+
104
+ folder_full_path = os.path.join(path, folder)
105
+ files = os.listdir(folder_full_path)
106
+
107
+ for file in files:
108
+ if (
109
+ file.endswith(".aria2")
110
+ or file.endswith(".part")
111
+ or file.endswith(".opti.mp4")
112
+ ):
113
+ full_path = os.path.join(folder_full_path, file)
114
+ os.remove(full_path)
115
+ UI.info(f"Removed {full_path}")
116
+
117
+ # cache
118
+ if cache:
119
+ # clear yt-dlp cache
120
+ ytdlp_clear_cache()
121
+ UI.info("Cleared yt-dlp cache dir")
122
+
123
+ # kill aria2c
124
+ kill_aria2c()
125
+ UI.info("Kill aria2c")
126
+
127
+ # medias with status deleted
128
+ if medias_deleted:
129
+ with DB() as db:
130
+ medias = db.query_media_by_status(Status.TO_BE_DELETED)
131
+ UI.info(f"Found {len(medias)} medias with DELETED status")
132
+
133
+ if len(medias) == 0:
134
+ return
135
+
136
+ total_size = 0.0
137
+ total_vid = 0
138
+ for media in medias:
139
+ with DB() as db:
140
+ post = db.query_post_by_id(media.post_id)
141
+ if post is None:
142
+ UI.warning(f"Could not match post id {media.post_id} to a post")
143
+ continue
144
+
145
+ path = os.path.join(Config.creator_folder(post.user), media.file_path)
146
+ if not os.path.exists:
147
+ UI.error(f"Path {path} should exist. Does not")
148
+ continue
149
+
150
+ try:
151
+ os.remove(path)
152
+ UI.info(f"Removed '{path}' ({bytes_to_mb(media.file_size)} MB)")
153
+ total_size += media.file_size
154
+ total_vid += 1
155
+ except Exception as e:
156
+ UI.error(f"Failed to rm media {media.post_id}/{media.url} due to: {e}")
157
+ continue
158
+
159
+ with DB() as db:
160
+ media.status = Status.DELETED
161
+ db.update_media(media)
162
+
163
+ UI.info(
164
+ f"Removed a total of {total_vid} videos ({bytes_to_mb(total_size)}MB,"
165
+ f" {round(bytes_to_mb(total_size) / 100, 1)}GB)"
166
+ )
167
+
168
+
169
+ def format_seconds(seconds: int | float) -> str:
170
+ seconds = int(seconds)
171
+ h = seconds // 3600
172
+ m = (seconds % 3600) // 60
173
+ s = seconds % 60
174
+
175
+ if h > 0:
176
+ return f"{h}h:{m:02d}m:{s:02d}s"
177
+ elif m > 0:
178
+ return f"{m}m:{s:02d}s"
179
+ else:
180
+ return f"{s}s"