rcdl 3.0.0b20__tar.gz

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-3.0.0b20/PKG-INFO ADDED
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: rcdl
3
+ Version: 3.0.0b20
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
+
@@ -0,0 +1,104 @@
1
+ # RCDL
2
+
3
+ Riton Coomer Download Manager
4
+ `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)
5
+
6
+
7
+ ## Install
8
+ ### Dependencies
9
+ - [yt-dlp](https://github.com/yt-dlp/yt-dlp)
10
+ - [aria2](https://github.com/aria2/aria2)
11
+ - [ffmpeg](https://www.ffmpeg.org/download.html) (Only for `fuse` command)
12
+ - [HandBrakeCLI](https://handbrake.fr/docs/en/latest/cli/cli-options.html) (Only for `opti` command)
13
+ Recommended install:
14
+ ```bash
15
+ pipx install yt-dlp streamlit
16
+ sudo apt update
17
+ sudo apt install aria2 ffmpeg handbrake-cli python3-tk
18
+ ```
19
+ ### Install RCDL
20
+ It is recommended to use `pipx` to install `rcdl`
21
+ ```bash
22
+ pipx install rcdl
23
+ ```
24
+ or else:
25
+ ```bash
26
+ pip install rcdl
27
+ ```
28
+
29
+ ## How to use
30
+
31
+ Run the CLI with:
32
+
33
+ ```bash
34
+ rcdl --help
35
+ ```
36
+
37
+ By default all files will live in `~/Videos/rcdl/`. Cache, configuration and log file will be in a hidden `rcdl/.cache/` folder.
38
+
39
+ Main function:
40
+ ```bash
41
+ rcdl refresh # look creators.json and find all possible videos
42
+ rcdl dlsf # download all found videos
43
+ rcdl discover # Discover new creator (WIP)
44
+ rcdl opti # Optimized video to reduce disk storage usage
45
+ rcdl fuse # Fuse all videos within a same post if they are fully downloaded
46
+ ```
47
+
48
+ Manage creators:
49
+ ```bash
50
+ rcdl list # list all current creators
51
+ rcdl add [URL]
52
+ rcdl add [service]/[creator_id]
53
+ rcdl remove [creator_id]
54
+ ```
55
+
56
+ Helper function:
57
+ ```bash
58
+ rcdl status # give number of entry in the database per tables and status
59
+ rcdl clean --all # remove all partially downloaded file, external dependencies cache, etc...
60
+ rcdl show-config # print all config var and theirs value (paths, etc...)
61
+ ```
62
+
63
+ ### Settings
64
+ Default settings file:
65
+ ```toml
66
+ [app]
67
+ default_max_page = 10
68
+ max_fail_count = 7
69
+ timeout = 10
70
+
71
+ [fuse]
72
+ max_width = 1920
73
+ max_height = 1080
74
+ fps = 30
75
+ preset = "veryfast"
76
+ threads = 0
77
+
78
+ [paths]
79
+ base_dir = "~/Videos/rcdl"
80
+ handbrake_run_cmd = "HandBrakeCLI"
81
+ ```
82
+
83
+ In `rcdl/.cache/config.toml`:
84
+ ```toml
85
+ [paths]
86
+ handbrake_run_cmd = "HandBrakeCLI" # if installed via apt
87
+ handbrake_run_cmd = "flatpak run --command=HandBrakeCLI fr.handbrake.ghb" # if installed via flatpak
88
+ ```
89
+
90
+ ## Dev
91
+ ### Install
92
+ ```bash
93
+ git clone https://github.com/ritonun/cdl.git rcdl
94
+ cd rcdl
95
+ python3 -m venv .venv
96
+ source .venv/bin/activate
97
+ pip install -e .
98
+ ```
99
+
100
+ ## Deploy
101
+ ```bash
102
+ python3 -m pip install --upgrade build
103
+ python3 -m pip install --upgrade twine
104
+ ```
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "rcdl"
3
+ version = "3.0.0b20"
4
+ description = "Coomer/Kemono CLI Downloader"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "click>=8.2",
8
+ "requests>=2.32",
9
+ "pathvalidate==3.3.1",
10
+ "rich==14.2.0",
11
+ "streamlit==1.52.2"
12
+ ]
13
+ authors = [
14
+ {name = "Anonymous"},
15
+ ]
16
+ readme = "README.md"
17
+ keywords = ["downloader", "video", "media"]
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+
24
+ [project.scripts]
25
+ rcdl = "rcdl.__main__:cli"
26
+
27
+ [build-system]
28
+ requires = ["flit_core<4"]
29
+ build-backend = "flit_core.buildapi"
@@ -0,0 +1,10 @@
1
+ # __init__.py
2
+
3
+ """
4
+ Init file for package structure.
5
+ Get the version from pyproject.toml version tag
6
+ """
7
+
8
+ from importlib.metadata import version
9
+
10
+ __version__ = version("rcdl")
@@ -0,0 +1,37 @@
1
+ # __main__.py
2
+
3
+ """
4
+ __main__: entry in the programm
5
+ Setup logging, create files/folders structures, check dependencies,
6
+ init Config global variables, init database,
7
+ Then call cli group
8
+ """
9
+
10
+ import logging
11
+
12
+ from rcdl.core.config import Config, setup_logging, check_dependencies
13
+
14
+ # setup file structure
15
+ Config.ensure_dirs()
16
+ Config.ensure_files()
17
+
18
+ # load config file settings
19
+ Config.load_config()
20
+
21
+ # setup logging
22
+ setup_logging(Config.LOG_FILE, level=0)
23
+
24
+ # check dependencies
25
+ check_dependencies()
26
+
27
+ logging.info("--- INIT ---")
28
+ logging.info("Logger initialized")
29
+
30
+ # init database
31
+ from rcdl.core.db import DB # noqa: E402
32
+
33
+ db = DB()
34
+ db.init_database()
35
+ db.close()
36
+
37
+ from rcdl.interface.cli import cli # noqa: E402, F401
File without changes
@@ -0,0 +1,241 @@
1
+ # core/adapters.py
2
+
3
+ """Convert [Any] into proper Models from models.py"""
4
+
5
+ import json
6
+ import sqlite3
7
+ from dataclasses import fields
8
+
9
+ from rcdl.core import parser
10
+ from rcdl.interface.ui import UI
11
+ from rcdl.core.models import Post, Media, Status, FusedMedia
12
+ from rcdl.utils import get_date_now, get_json_hash
13
+
14
+ VALID_POST_KEYS = set(
15
+ [
16
+ "id",
17
+ "user",
18
+ "service",
19
+ "title",
20
+ "substring",
21
+ "published",
22
+ "file",
23
+ "attachments",
24
+ ]
25
+ )
26
+
27
+
28
+ def _compute_json_metadata(raw: dict) -> tuple[str, str, str]:
29
+ """From a json dict, return:
30
+ - raw_json: str
31
+ - json_hash: str
32
+ - fetched_at str (datetime)
33
+ """
34
+ raw_json, json_hash = get_json_hash(raw)
35
+ fetched_at = get_date_now()
36
+ return raw_json, json_hash, fetched_at
37
+
38
+
39
+ def json_posts_to_posts(posts: list[dict]) -> list[Post]:
40
+ """Convert a list of json post (dict) into a list of Post model
41
+ Ignore if conversion failed"""
42
+ formatted_posts = []
43
+ for post in posts:
44
+ p = json_post_to_post(post)
45
+ if p is not None:
46
+ formatted_posts.append(p)
47
+ return formatted_posts
48
+
49
+
50
+ def json_post_to_post(post: dict) -> Post | None:
51
+ """Convert a json post (dict) into a Post model
52
+ or return None if covnersion failed"""
53
+ post_keys = set(post)
54
+ if post_keys != VALID_POST_KEYS:
55
+ UI.error(
56
+ f"Post id {post.get('id')} of {post.get('user')} "
57
+ f"has invalid schema. "
58
+ f"Missing: {VALID_POST_KEYS - post_keys}, "
59
+ f"Extra: {post_keys - VALID_POST_KEYS}"
60
+ )
61
+ return None
62
+
63
+ try:
64
+ domain = parser.get_domain(post["service"])
65
+ raw_json, json_hash, fetched_at = _compute_json_metadata(post)
66
+ return Post(
67
+ **post,
68
+ domain=domain,
69
+ json_hash=json_hash,
70
+ raw_json=raw_json,
71
+ fetched_at=fetched_at,
72
+ )
73
+ except TypeError as e:
74
+ UI.error(
75
+ f"Post id {post.get('id')} from {post.get('user')} could not be parsed: {e}"
76
+ )
77
+ return None
78
+
79
+
80
+ def row_to_post(row: sqlite3.Row) -> Post | None:
81
+ """Convert a sqlite3 row into a Post model.
82
+ Return None if conversion failed"""
83
+ try:
84
+ raw = json.loads(row["raw_json"])
85
+ return Post(
86
+ id=row["id"],
87
+ user=row["user"],
88
+ service=row["service"],
89
+ domain=row["domain"],
90
+ published=row["published"],
91
+ json_hash=row["json_hash"],
92
+ raw_json=row["raw_json"],
93
+ fetched_at=row["fetched_at"],
94
+ title=raw["title"],
95
+ substring=raw["substring"],
96
+ file=raw["file"],
97
+ attachments=raw["attachments"],
98
+ )
99
+ except KeyError as e:
100
+ UI.error(
101
+ f"KeyError: Failed to convert {row['id']} (row_id) into Post model due to: {e}"
102
+ )
103
+ return None
104
+ except TypeError as e:
105
+ UI.error(
106
+ f"TypeError: Failed to convert {row['id']} (row_id) into Post model due to: {e}"
107
+ )
108
+ return None
109
+ except ValueError as e:
110
+ UI.error(
111
+ f"ValueError/JSONDecodeError: Failed to convert "
112
+ f"{row['id']} (row_id) into Post model due to: {e}"
113
+ )
114
+ return None
115
+
116
+
117
+ def rows_to_posts(rows: list[sqlite3.Row]) -> list[Post]:
118
+ """Convert a list of sqlite3 rows. Return a list of Post model.
119
+ Ignore the row if conversion fail"""
120
+ posts: list[Post] = []
121
+ for row in rows:
122
+ post = row_to_post(row)
123
+ if post is not None:
124
+ posts.append(post)
125
+
126
+ if len(posts) != len(rows):
127
+ UI.error(
128
+ f"From {len(rows)} rows, only converted {len(posts)}."
129
+ f" {len(rows) - len(posts)} error."
130
+ )
131
+
132
+ return posts
133
+
134
+
135
+ def row_to_media(row: sqlite3.Row) -> Media | None:
136
+ """Convert a sqlite3 row into a Media model.
137
+ Return None if conversion failed"""
138
+ try:
139
+ # create a dict to hold column of row that are present in Media.
140
+ # Ignore column (like default autoincrement ID) that are not a field in Media
141
+ media_data = {}
142
+ for field in fields(Media):
143
+ field_name = field.name
144
+ if field_name in row.keys():
145
+ value = row[field_name]
146
+ if field_name == "status" and value is not None:
147
+ value = Status(value)
148
+ media_data[field_name] = value
149
+ return Media(**media_data)
150
+ except (KeyError, TypeError, ValueError) as e:
151
+ UI.error(
152
+ f"Key/Type/Value Error: Failed to convert row {row['id']} into Post model due to {e}"
153
+ )
154
+ return None
155
+
156
+
157
+ def rows_to_medias(rows: list[sqlite3.Row]) -> list[Media]:
158
+ """Convert a list of sqlite3 rows. Return a list of Media model.
159
+ Ignore row if conversion failed"""
160
+ medias: list[Media] = []
161
+ for row in rows:
162
+ media = row_to_media(row)
163
+ if media is not None:
164
+ medias.append(media)
165
+
166
+ if len(medias) != len(rows):
167
+ UI.error(
168
+ f"From {len(rows)} rows, only converted {len(medias)}."
169
+ f" {len(rows) - len(medias)} error."
170
+ )
171
+
172
+ return medias
173
+
174
+
175
+ def row_to_fused_media(row: sqlite3.Row) -> FusedMedia | None:
176
+ """Convert a sqlite3 row into a FusedMedia model.
177
+ Return None if conversion fail"""
178
+ if row is None:
179
+ return None
180
+ try:
181
+ fuses_data = {}
182
+ for field in fields(FusedMedia):
183
+ field_name = field.name
184
+ if field_name in row.keys():
185
+ value = row[field_name]
186
+ if field_name == "status" and value is not None:
187
+ value = Status(value)
188
+ fuses_data[field_name] = value
189
+ return FusedMedia(**fuses_data)
190
+ except (KeyError, TypeError, ValueError) as e:
191
+ UI.error(
192
+ f"Key/Type/Value Error: Failed to convert row "
193
+ f"{row['id']} into FusedMedia model due to {e}"
194
+ )
195
+ return None
196
+
197
+
198
+ def rows_to_fuses(rows: list[sqlite3.Row]) -> list[FusedMedia]:
199
+ """Convert a lsit of sqlite3 rows into a list of FusedMedia model
200
+ Ignore row if conversion failed"""
201
+ fuses: list[FusedMedia] = []
202
+ for row in rows:
203
+ fuse = row_to_fused_media(row)
204
+ if fuse is not None:
205
+ fuses.append(fuse)
206
+
207
+ if len(fuses) != len(rows):
208
+ UI.error(
209
+ f"From {len(rows)} rows, only converted {len(fuses)}."
210
+ f" {len(rows) - len(fuses)} error."
211
+ )
212
+
213
+ return fuses
214
+
215
+
216
+ def post_to_videos(post: Post) -> list[Media]:
217
+ """Extract a list of Media model from a Post model"""
218
+ json_post = json.loads(post.raw_json)
219
+
220
+ urls = parser.extract_video_urls(json_post)
221
+ sequence = 0
222
+ medias: list[Media] = []
223
+ for url in urls:
224
+ medias.append(
225
+ Media(
226
+ post_id=post.id,
227
+ service=post.service,
228
+ url=url,
229
+ duration=0.0,
230
+ sequence=sequence,
231
+ status=Status.PENDING,
232
+ checksum="",
233
+ file_path=parser.get_filename(json_post, url),
234
+ created_at="",
235
+ updated_at="",
236
+ file_size=0,
237
+ fail_count=0,
238
+ )
239
+ )
240
+ sequence += 1
241
+ return medias
@@ -0,0 +1,76 @@
1
+ # core/api.py
2
+
3
+ """Build real URL for api request"""
4
+
5
+ from rcdl.core.models import Creator
6
+
7
+
8
+ class URL:
9
+ """Build real URL for api request"""
10
+
11
+ DOMAINS_BASE_URL = {
12
+ "coomer": "https://coomer.st/api/v1/",
13
+ "kemono": "https://kemono.cr/api/v1/",
14
+ }
15
+
16
+ @staticmethod
17
+ def get_base_url(domain: str) -> str:
18
+ """Return https://domain.com"""
19
+ if domain not in URL.DOMAINS_BASE_URL:
20
+ raise KeyError(f"{domain} not in known domains urls")
21
+ return URL.DOMAINS_BASE_URL[domain]
22
+
23
+ @staticmethod
24
+ def get_post_revision(creator: Creator, post_id) -> str:
25
+ """Return post revision url"""
26
+ return (
27
+ f"{URL.get_base_url(creator.domain)}{creator.service}"
28
+ f"/user/{creator.id}/post/{post_id}/revisions"
29
+ )
30
+
31
+ @staticmethod
32
+ def get_headers() -> dict:
33
+ """Return necessary request header for successful request"""
34
+ return {
35
+ "User-Agent": (
36
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
37
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
38
+ "Chrome/117.0 Safari/537.36"
39
+ ),
40
+ "Accept": "text/css",
41
+ }
42
+
43
+ @staticmethod
44
+ def get_url_from_file(domain: str, path_url: str):
45
+ """Add path_url to based domain url"""
46
+ if domain == "coomer":
47
+ return f"https://coomer.st{path_url}"
48
+ if domain == "kemono":
49
+ return f"https://kemono.cr{path_url}"
50
+
51
+ raise ValueError(
52
+ f"Domain {domain} is not an accepted value/does not exist. "
53
+ f"Please check your creators.json file"
54
+ )
55
+
56
+ @staticmethod
57
+ def add_params(url: str, params: dict):
58
+ """Create all parameters string (key=params&key=...)"""
59
+ url += "?"
60
+ for key in params:
61
+ url += f"{key}={params[key]}&"
62
+ return url[:-1]
63
+
64
+ @staticmethod
65
+ def get_creator_post_wo_param(creator: Creator) -> str:
66
+ """Get creator post without parameters"""
67
+ return (
68
+ f"{URL.get_base_url(creator.domain)}{creator.service}"
69
+ f"/user/{creator.id}/posts"
70
+ )
71
+
72
+ @staticmethod
73
+ def get_posts_page_url_wo_param():
74
+ """Get posts page without parameters -> use in tag search"""
75
+ domain = URL.DOMAINS_BASE_URL["coomer"]
76
+ return f"{domain}posts"