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/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
+