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/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,100 @@ 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
+
32
+ def start(
33
+ self, *, total: int, total_label: str = "Total", current_label: str = "Current"
34
+ ):
35
+ # Global progress (elapsed only)
36
+ self.global_progress = Progress(
37
+ SpinnerColumn(),
38
+ TextColumn("[bold cyan]{task.description}"),
39
+ BarColumn(),
40
+ TextColumn("{task.completed}/{task.total}"),
41
+ TimeElapsedColumn(),
42
+ console=self.console,
43
+ transient=False,
44
+ )
45
+
46
+ # Current task progress (ETA included)
47
+ self.current_progress = Progress(
48
+ SpinnerColumn(),
49
+ TextColumn("[bold cyan]{task.description}"),
50
+ BarColumn(),
51
+ TextColumn("{task.completed}/{task.total}"),
52
+ TimeRemainingColumn(),
53
+ console=self.console,
54
+ transient=False,
55
+ )
56
+
57
+ group = Group(self.global_progress, self.current_progress, self.status_text)
58
+ self.live = Live(group, console=self.console)
59
+ self.live.__enter__()
60
+
61
+ self.total_task = self.global_progress.add_task(total_label, total=total)
62
+ self.current_task = self.current_progress.add_task(
63
+ current_label, total=1, visible=False
64
+ )
65
+
66
+ # total task helpers
67
+ def advance_total(self, step: int = 1):
68
+ if self.global_progress and self.total_task is not None:
69
+ self.global_progress.advance(self.total_task, step) # type: ignore
70
+
71
+ def start_current(self, description: str, total: int | None = None):
72
+ if not self.current_progress or self.current_task is None:
73
+ return
74
+
75
+ self.current_progress.update(
76
+ self.current_task, # type: ignore
77
+ description=description,
78
+ total=total,
79
+ completed=0,
80
+ visible=True,
81
+ )
82
+
83
+ def advance_current(self, step: int | float = 1):
84
+ if self.current_progress and self.current_task is not None:
85
+ self.current_progress.advance(self.current_task, step) # type: ignore
86
+
87
+ def finish_current(self):
88
+ if self.current_progress and self.current_task is not None:
89
+ self.current_progress.update(self.current_task, visible=False) # type: ignore
90
+
91
+ # ---------------------
92
+ # Status line
93
+ # ---------------------
94
+ def set_status(self, cyan: str, green: str = ""):
95
+ self.status_text.plain = ""
96
+ self.status_text.append(cyan, style="cyan")
97
+ if green:
98
+ self.status_text.append(green, style="green")
99
+
100
+ # ---------------------
101
+ # Close
102
+ # ---------------------
103
+ def close(self):
104
+ if self.live:
105
+ self.live.__exit__(None, None, None)
106
+ self.live = None
17
107
 
18
108
 
19
109
  class UI:
@@ -23,6 +113,7 @@ class UI:
23
113
  _video_progress_text: Text | None = None
24
114
  _concat_progress_text: Text | None = None
25
115
  _live: Live | None = None
116
+ _generic_text: Text | None = None
26
117
 
27
118
  @staticmethod
28
119
  def _log_to_file(log_level, msg: str):
@@ -41,8 +132,8 @@ class UI:
41
132
 
42
133
  @classmethod
43
134
  def debug(cls, msg: str):
44
- """Print & log debug msg"""
45
- cls.console.print(f"[dim]{msg}[/]")
135
+ """Log debug msg"""
136
+ # cls.console.print(f"[dim]{msg}[/]")
46
137
  cls._log_to_file(cls.logger.debug, msg)
47
138
 
48
139
  @classmethod
@@ -57,26 +148,6 @@ class UI:
57
148
  cls.console.print(f"[red]{msg}[/]")
58
149
  cls._log_to_file(cls.logger.debug, msg)
59
150
 
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
151
  @classmethod
81
152
  def table_creators(cls, creators: list[Creator]):
82
153
  """Print to cli a table with all creators in creators.txt. Format is Creator ID | Service"""
@@ -84,7 +155,7 @@ class UI:
84
155
  table.add_column("Creators ID")
85
156
  table.add_column("Service")
86
157
  for creator in creators:
87
- table.add_row(creator.creator_id, creator.service)
158
+ table.add_row(creator.id, creator.service)
88
159
  cls.console.print(table)
89
160
 
90
161
  @classmethod
@@ -98,96 +169,14 @@ class UI:
98
169
  )
99
170
  return progress
100
171
 
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
172
 
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
-
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
173
+ def print_db_info(info: dict):
174
+ click.echo("--- TABLES ---")
175
+ click.echo("Posts:")
176
+ click.echo(f"\t{info['posts']} total")
177
+ click.echo("FusedMedias:")
178
+ for status in FusedStatus:
179
+ click.echo(f"\t{info['fuses'][status]} {status}")
180
+ click.echo("Medias:")
181
+ for status in Status:
182
+ click.echo(f"\t{info['medias'][status]} {status}")
rcdl/utils.py CHANGED
@@ -1,11 +1,169 @@
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
+ import rcdl.core.downloader_subprocess as dls
13
+ from rcdl.core.config import Config
14
+ from rcdl.interface.ui import UI
8
15
 
9
16
 
10
- def echoc(text: str, color: str):
11
- click.echo(click.style(text, fg=color))
17
+ def get_date_now() -> str:
18
+ """Return an str of current datetime in isoformat"""
19
+ return datetime.now(timezone.utc).isoformat()
20
+
21
+
22
+ def get_media_metadata(full_path: str) -> tuple[int, int, str]:
23
+ """Get duration (if possible), file_size and checksum"""
24
+ path = Path(full_path)
25
+ if not os.path.exists(path):
26
+ UI.error(f"{path} path should exist but does not.")
27
+
28
+ file_size = path.stat().st_size
29
+ checksum = get_media_hash(path)
30
+ if checksum is None:
31
+ checksum = ""
32
+ duration = dls.ffprobe_get_duration(path)
33
+ if duration is None:
34
+ duration = 0
35
+ return duration, file_size, checksum
36
+
37
+
38
+ def get_media_hash(path: Path, retries: int = Config.CHECKSUM_RETRY) -> str | None:
39
+ """Return a hash of a video file"""
40
+ for attempt in range(retries + 1):
41
+ try:
42
+ sha256_hash = hashlib.sha256()
43
+ with path.open("rb") as f:
44
+ for chunk in iter(lambda: f.read(8192), b""):
45
+ sha256_hash.update(chunk)
46
+ checksum = sha256_hash.hexdigest()
47
+ return checksum
48
+ except OSError as e:
49
+ if attempt >= retries:
50
+ UI.error(
51
+ f"OSError: Failed to checksum {path} "
52
+ f"at attempt {attempt + 1} due to: {e}"
53
+ )
54
+ return None
55
+ UI.warning(
56
+ f"OSError: Failed to checksum {path} "
57
+ f"at attempt {attempt + 1} due to: {e}"
58
+ )
59
+ time.sleep(1.0)
60
+
61
+
62
+ def get_json_hash(data: dict) -> tuple[str, str]:
63
+ """Return a hash of a dict"""
64
+ raw_json = json.dumps(data, sort_keys=True)
65
+ json_hash = hashlib.sha256(raw_json.encode("utf-8")).hexdigest()
66
+ return raw_json, json_hash
67
+
68
+
69
+ def bytes_to_mb(bytes: float | int):
70
+ return round(bytes / (1024 * 1024), 1)
71
+
72
+
73
+ def clean_all(all: bool, partial: bool, cache: bool, medias_deleted: bool):
74
+ """Remove partial file & external programm cache"""
75
+
76
+ from rcdl.core.db import DB
77
+ from rcdl.core.models import Status
78
+
79
+ if all:
80
+ partial = True
81
+ cache = True
82
+ medias_deleted = True
83
+
84
+ # remove all partial file
85
+ if partial:
86
+ path = Config.BASE_DIR
87
+ folders = os.listdir(path)
88
+
89
+ for folder in folders:
90
+ if folder.startswith("."):
91
+ continue
92
+
93
+ folder_full_path = os.path.join(path, folder)
94
+ files = os.listdir(folder_full_path)
95
+
96
+ for file in files:
97
+ if (
98
+ file.endswith(".aria2")
99
+ or file.endswith(".part")
100
+ or file.endswith(".opti.mp4")
101
+ ):
102
+ full_path = os.path.join(folder_full_path, file)
103
+ os.remove(full_path)
104
+ UI.info(f"Removed {full_path}")
105
+
106
+ # cache
107
+ if cache:
108
+ # clear yt-dlp cache
109
+ dls.ytdlp_clear_cache()
110
+ UI.info("Cleared yt-dlp cache dir")
111
+
112
+ # kill aria2c
113
+ dls.kill_aria2c()
114
+ UI.info("Kill aria2c")
115
+
116
+ # medias with status deleted
117
+ if medias_deleted:
118
+ with DB() as db:
119
+ medias = db.query_media_by_status(Status.TO_BE_DELETED)
120
+ UI.info(f"Found {len(medias)} medias with DELETED status")
121
+
122
+ if len(medias) == 0:
123
+ return
124
+
125
+ total_size = 0.0
126
+ total_vid = 0
127
+ for media in medias:
128
+ with DB() as db:
129
+ post = db.query_post_by_id(media.post_id)
130
+ if post is None:
131
+ UI.warning(f"Could not match post id {media.post_id} to a post")
132
+ continue
133
+
134
+ path = os.path.join(Config.creator_folder(post.user), media.file_path)
135
+ if not os.path.exists:
136
+ UI.error(f"Path {path} should exist. Does not")
137
+ continue
138
+
139
+ try:
140
+ os.remove(path)
141
+ UI.info(f"Removed '{path}' ({bytes_to_mb(media.file_size)} MB)")
142
+ total_size += media.file_size
143
+ total_vid += 1
144
+ except Exception as e:
145
+ UI.error(f"Failed to rm media {media.post_id}/{media.url} due to: {e}")
146
+ continue
147
+
148
+ with DB() as db:
149
+ media.status = Status.DELETED
150
+ db.update_media(media)
151
+
152
+ UI.info(
153
+ f"Removed a total of {total_vid} videos ({bytes_to_mb(total_size)}MB,"
154
+ f" {round(bytes_to_mb(total_size) / 100, 1)}GB)"
155
+ )
156
+
157
+
158
+ def format_seconds(seconds: int | float) -> str:
159
+ seconds = int(seconds)
160
+ h = seconds // 3600
161
+ m = (seconds % 3600) // 60
162
+ s = seconds % 60
163
+
164
+ if h > 0:
165
+ return f"{h}h:{m:02d}m:{s:02d}s"
166
+ elif m > 0:
167
+ return f"{m}m:{s:02d}s"
168
+ else:
169
+ return f"{s}s"