rcdl 3.0.0b18__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.
Potentially problematic release.
This version of rcdl might be problematic. Click here for more details.
- rcdl/__init__.py +10 -0
- rcdl/__main__.py +37 -0
- rcdl/core/__init__.py +0 -0
- rcdl/core/adapters.py +241 -0
- rcdl/core/api.py +76 -0
- rcdl/core/config.py +212 -0
- rcdl/core/db.py +283 -0
- rcdl/core/db_queries.py +97 -0
- rcdl/core/downloader.py +307 -0
- rcdl/core/downloader_subprocess.py +366 -0
- rcdl/core/file_io.py +41 -0
- rcdl/core/fuse.py +127 -0
- rcdl/core/models.py +105 -0
- rcdl/core/opti.py +90 -0
- rcdl/core/parser.py +282 -0
- 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 +216 -0
- rcdl/interface/ui.py +194 -0
- rcdl/utils.py +180 -0
- rcdl-3.0.0b18.dist-info/METADATA +122 -0
- rcdl-3.0.0b18.dist-info/RECORD +28 -0
- rcdl-3.0.0b18.dist-info/WHEEL +4 -0
- rcdl-3.0.0b18.dist-info/entry_points.txt +3 -0
rcdl/interface/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# interface/cli.py
|
|
2
|
+
|
|
3
|
+
"""Hold all cli commands"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import inspect
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
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
|
|
16
|
+
from rcdl.core.config import Config
|
|
17
|
+
from rcdl.core.parser import (
|
|
18
|
+
get_creators,
|
|
19
|
+
get_creator_from_line,
|
|
20
|
+
parse_creator_input,
|
|
21
|
+
append_creator,
|
|
22
|
+
)
|
|
23
|
+
from rcdl.core.db import DB
|
|
24
|
+
from rcdl.utils import clean_all
|
|
25
|
+
|
|
26
|
+
from rcdl import __version__
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.command(help="Refresh video to be downloaded")
|
|
30
|
+
def refresh():
|
|
31
|
+
"""Refresh database with creators videos
|
|
32
|
+
|
|
33
|
+
- get all creators from creators.txt
|
|
34
|
+
- for each creators find all videos and put them in the database
|
|
35
|
+
No download is done in this command
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
UI.info("Welcome to RCDL refresh")
|
|
39
|
+
dl.refresh_creators_videos()
|
|
40
|
+
|
|
41
|
+
with DB() as db:
|
|
42
|
+
info = db.get_nb_per_status()
|
|
43
|
+
print_db_info(info)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.command(help="Download all videos from all creator")
|
|
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):
|
|
53
|
+
"""Download video based on DB information
|
|
54
|
+
|
|
55
|
+
- read databse
|
|
56
|
+
- for each video NOT_DOWNLOADED or FAILED & fail_count < settings, dl video
|
|
57
|
+
"""
|
|
58
|
+
UI.info("Welcome to RCDL dlsf")
|
|
59
|
+
dl.download_videos_to_be_dl(max_fail_count)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.command("fuse", help="Fuse part video into one")
|
|
63
|
+
def fuse():
|
|
64
|
+
"""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)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.command(help="Discover videos/creators with tags")
|
|
95
|
+
@click.option("--tag", required=True, type=str, help="Tag to search for")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--max-page", default=10, type=int, help="Maximum number of pages to fetch"
|
|
98
|
+
)
|
|
99
|
+
def discover(tag, max_page):
|
|
100
|
+
"""Discover new creators/videos
|
|
101
|
+
currently WIP. Do not use in prod"""
|
|
102
|
+
msg = f"[cdl] discover with tag={tag} max_page={max_page}"
|
|
103
|
+
click.echo(msg)
|
|
104
|
+
logging.info(msg)
|
|
105
|
+
UI.info("WIP - UNIMPLEMENTED")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@click.command("add", help="Add a creator")
|
|
109
|
+
@click.argument("creator_input")
|
|
110
|
+
def add_creator(creator_input):
|
|
111
|
+
"""Add a creator (URL or str) to creators.txt"""
|
|
112
|
+
service, creator_id = parse_creator_input(creator_input)
|
|
113
|
+
line = f"{service}/{creator_id}"
|
|
114
|
+
creator = get_creator_from_line(line)
|
|
115
|
+
if creator is not None:
|
|
116
|
+
append_creator(creator)
|
|
117
|
+
UI.info(f"Added {line} to creators.txt")
|
|
118
|
+
else:
|
|
119
|
+
UI.warning("Could not extract creator from input. Please check input is valid")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@click.command("remove", help="Remove a creator")
|
|
123
|
+
@click.option("--db", is_flag=True)
|
|
124
|
+
@click.argument("creator_input")
|
|
125
|
+
def remove_creator(db, creator_input):
|
|
126
|
+
"""Remove a creator (excat line) from creators.txt"""
|
|
127
|
+
_service, creator_id = parse_creator_input(str(creator_input))
|
|
128
|
+
|
|
129
|
+
creators = get_creators()
|
|
130
|
+
all_creators = []
|
|
131
|
+
matched_creator = None
|
|
132
|
+
for creator in creators:
|
|
133
|
+
if creator.id == creator_id:
|
|
134
|
+
matched_creator = creator
|
|
135
|
+
continue
|
|
136
|
+
all_creators.append(creator)
|
|
137
|
+
|
|
138
|
+
if matched_creator is None:
|
|
139
|
+
UI.error(f"Could not find creator from {creator_input}")
|
|
140
|
+
return
|
|
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}"
|
|
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")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@click.command("list", help="List all creators")
|
|
158
|
+
def list_creators():
|
|
159
|
+
"""List all creators in creators.txt"""
|
|
160
|
+
creators = get_creators()
|
|
161
|
+
UI.table_creators(creators)
|
|
162
|
+
|
|
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
|
+
|
|
189
|
+
# --- CLI GROUP ---
|
|
190
|
+
@click.group()
|
|
191
|
+
@click.option("--debug", is_flag=True)
|
|
192
|
+
@click.option("--dry-run", is_flag=True)
|
|
193
|
+
@click.version_option(version=__version__, prog_name=Config.APP_NAME)
|
|
194
|
+
def cli(debug, dry_run):
|
|
195
|
+
"""Init cli app. Assign Config var depending on flag used when calling prgm"""
|
|
196
|
+
Config.set_debug(debug)
|
|
197
|
+
Config.set_dry_run(dry_run)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# main commands
|
|
201
|
+
cli.add_command(dlsf)
|
|
202
|
+
cli.add_command(discover)
|
|
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
|
|
209
|
+
cli.add_command(add_creator)
|
|
210
|
+
cli.add_command(remove_creator)
|
|
211
|
+
cli.add_command(list_creators)
|
|
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
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# interface/ui.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console, Group
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
Progress,
|
|
9
|
+
SpinnerColumn,
|
|
10
|
+
BarColumn,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeRemainingColumn,
|
|
13
|
+
TimeElapsedColumn,
|
|
14
|
+
)
|
|
15
|
+
from rich import box
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.text import Text
|
|
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
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class UI:
|
|
122
|
+
console = Console()
|
|
123
|
+
logger = logging.getLogger()
|
|
124
|
+
|
|
125
|
+
_video_progress_text: Text | None = None
|
|
126
|
+
_concat_progress_text: Text | None = None
|
|
127
|
+
_live: Live | None = None
|
|
128
|
+
_generic_text: Text | None = None
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _log_to_file(log_level, msg: str):
|
|
132
|
+
log_level(msg)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def success(cls, msg: str):
|
|
136
|
+
"""Print success msg"""
|
|
137
|
+
cls.console.print(f"[green]{msg}[/]")
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def info(cls, msg: str):
|
|
141
|
+
"""Print & log info msg"""
|
|
142
|
+
cls.console.print(msg)
|
|
143
|
+
cls._log_to_file(cls.logger.info, msg)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def debug(cls, msg: str):
|
|
147
|
+
"""Log debug msg"""
|
|
148
|
+
# cls.console.print(f"[dim]{msg}[/]")
|
|
149
|
+
cls._log_to_file(cls.logger.debug, msg)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def warning(cls, msg: str):
|
|
153
|
+
"""Print & log warning msg"""
|
|
154
|
+
cls.console.print(f"[yellow]{msg}[/]")
|
|
155
|
+
cls._log_to_file(cls.logger.debug, msg)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def error(cls, msg: str):
|
|
159
|
+
"""Print & log error msg"""
|
|
160
|
+
cls.console.print(f"[red]{msg}[/]")
|
|
161
|
+
cls._log_to_file(cls.logger.debug, msg)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def table_creators(cls, creators: list[Creator]):
|
|
165
|
+
"""Print to cli a table with all creators in creators.txt. Format is Creator ID | Service"""
|
|
166
|
+
table = Table(title="Creators", box=box.MINIMAL, show_lines=True)
|
|
167
|
+
table.add_column("Creators ID")
|
|
168
|
+
table.add_column("Service")
|
|
169
|
+
for creator in creators:
|
|
170
|
+
table.add_row(creator.id, creator.service)
|
|
171
|
+
cls.console.print(table)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def progress_posts_fetcher(cls, max_pages: int):
|
|
175
|
+
progress = Progress(
|
|
176
|
+
SpinnerColumn(),
|
|
177
|
+
TextColumn("[progress.description]{task.description}"),
|
|
178
|
+
BarColumn(),
|
|
179
|
+
console=cls.console,
|
|
180
|
+
transient=False, # remove progress bar after finish
|
|
181
|
+
)
|
|
182
|
+
return progress
|
|
183
|
+
|
|
184
|
+
|
|
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
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# utils.py
|
|
2
|
+
|
|
3
|
+
"""Hold collection of useful functions"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from rcdl.core.config import Config
|
|
13
|
+
from rcdl.interface.ui import UI
|
|
14
|
+
|
|
15
|
+
|
|
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"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rcdl
|
|
3
|
+
Version: 3.0.0b18
|
|
4
|
+
Summary: Coomer/Kemono CLI Downloader
|
|
5
|
+
Keywords: downloader,video,media
|
|
6
|
+
Author: Anonymous
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Dist: click>=8.2
|
|
13
|
+
Requires-Dist: requests>=2.32
|
|
14
|
+
Requires-Dist: pathvalidate==3.3.1
|
|
15
|
+
Requires-Dist: rich==14.2.0
|
|
16
|
+
Requires-Dist: streamlit==1.52.2
|
|
17
|
+
|
|
18
|
+
# RCDL
|
|
19
|
+
|
|
20
|
+
Riton Coomer Download Manager
|
|
21
|
+
`rcdl` is a tool to automatically download the videos of your favorites creators from [coomer.st](https://coomer.st) and [kemono.cr](https://kemono.cr)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
### Dependencies
|
|
26
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
27
|
+
- [aria2](https://github.com/aria2/aria2)
|
|
28
|
+
- [ffmpeg](https://www.ffmpeg.org/download.html) (Only for `fuse` command)
|
|
29
|
+
- [HandBrakeCLI](https://handbrake.fr/docs/en/latest/cli/cli-options.html) (Only for `opti` command)
|
|
30
|
+
Recommended install:
|
|
31
|
+
```bash
|
|
32
|
+
pipx install yt-dlp streamlit
|
|
33
|
+
sudo apt update
|
|
34
|
+
sudo apt install aria2 ffmpeg handbrake-cli python3-tk
|
|
35
|
+
```
|
|
36
|
+
### Install RCDL
|
|
37
|
+
It is recommended to use `pipx` to install `rcdl`
|
|
38
|
+
```bash
|
|
39
|
+
pipx install rcdl
|
|
40
|
+
```
|
|
41
|
+
or else:
|
|
42
|
+
```bash
|
|
43
|
+
pip install rcdl
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How to use
|
|
47
|
+
|
|
48
|
+
Run the CLI with:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
rcdl --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
By default all files will live in `~/Videos/rcdl/`. Cache, configuration and log file will be in a hidden `rcdl/.cache/` folder.
|
|
55
|
+
|
|
56
|
+
Main function:
|
|
57
|
+
```bash
|
|
58
|
+
rcdl refresh # look creators.json and find all possible videos
|
|
59
|
+
rcdl dlsf # download all found videos
|
|
60
|
+
rcdl discover # Discover new creator (WIP)
|
|
61
|
+
rcdl opti # Optimized video to reduce disk storage usage
|
|
62
|
+
rcdl fuse # Fuse all videos within a same post if they are fully downloaded
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Manage creators:
|
|
66
|
+
```bash
|
|
67
|
+
rcdl list # list all current creators
|
|
68
|
+
rcdl add [URL]
|
|
69
|
+
rcdl add [service]/[creator_id]
|
|
70
|
+
rcdl remove [creator_id]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Helper function:
|
|
74
|
+
```bash
|
|
75
|
+
rcdl status # give number of entry in the database per tables and status
|
|
76
|
+
rcdl clean --all # remove all partially downloaded file, external dependencies cache, etc...
|
|
77
|
+
rcdl show-config # print all config var and theirs value (paths, etc...)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Settings
|
|
81
|
+
Default settings file:
|
|
82
|
+
```toml
|
|
83
|
+
[app]
|
|
84
|
+
default_max_page = 10
|
|
85
|
+
max_fail_count = 7
|
|
86
|
+
timeout = 10
|
|
87
|
+
|
|
88
|
+
[fuse]
|
|
89
|
+
max_width = 1920
|
|
90
|
+
max_height = 1080
|
|
91
|
+
fps = 30
|
|
92
|
+
preset = "veryfast"
|
|
93
|
+
threads = 0
|
|
94
|
+
|
|
95
|
+
[paths]
|
|
96
|
+
base_dir = "~/Videos/rcdl"
|
|
97
|
+
handbrake_run_cmd = "HandBrakeCLI"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
In `rcdl/.cache/config.toml`:
|
|
101
|
+
```toml
|
|
102
|
+
[paths]
|
|
103
|
+
handbrake_run_cmd = "HandBrakeCLI" # if installed via apt
|
|
104
|
+
handbrake_run_cmd = "flatpak run --command=HandBrakeCLI fr.handbrake.ghb" # if installed via flatpak
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Dev
|
|
108
|
+
### Install
|
|
109
|
+
```bash
|
|
110
|
+
git clone https://github.com/ritonun/cdl.git rcdl
|
|
111
|
+
cd rcdl
|
|
112
|
+
python3 -m venv .venv
|
|
113
|
+
source .venv/bin/activate
|
|
114
|
+
pip install -e .
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Deploy
|
|
118
|
+
```bash
|
|
119
|
+
python3 -m pip install --upgrade build
|
|
120
|
+
python3 -m pip install --upgrade twine
|
|
121
|
+
```
|
|
122
|
+
|