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/__init__.py CHANGED
@@ -1,5 +1,10 @@
1
1
  # __init__.py
2
2
 
3
+ """
4
+ Init file for package structure.
5
+ Get the version from pyproject.toml version tag
6
+ """
7
+
3
8
  from importlib.metadata import version
4
9
 
5
10
  __version__ = version("rcdl")
rcdl/__main__.py CHANGED
@@ -1,16 +1,29 @@
1
1
  # __main__.py
2
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
+
3
10
  import logging
4
11
 
5
- from rcdl.core.config import Config, setup_logging
12
+ from rcdl.core.config import Config, setup_logging, check_dependencies
6
13
 
7
14
  # setup file structure
8
15
  Config.ensure_dirs()
9
16
  Config.ensure_files()
10
17
 
18
+ # load config file settings
19
+ Config.load_config()
20
+
11
21
  # setup logging
12
22
  setup_logging(Config.LOG_FILE, level=0)
13
23
 
24
+ # check dependencies
25
+ check_dependencies()
26
+
14
27
  logging.info("--- INIT ---")
15
28
  logging.info("Logger initialized")
16
29
 
@@ -18,8 +31,7 @@ logging.info("Logger initialized")
18
31
  from rcdl.core.db import DB # noqa: E402
19
32
 
20
33
  db = DB()
21
- db.init_table()
22
- logging.info(f"DB version: {db.get_schema_version()}")
34
+ db.init_database()
23
35
  db.close()
24
36
 
25
37
  from rcdl.interface.cli import cli # noqa: E402, F401
rcdl/core/__init__.py ADDED
File without changes
rcdl/core/adapters.py ADDED
@@ -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
rcdl/core/api.py CHANGED
@@ -1,9 +1,13 @@
1
1
  # core/api.py
2
2
 
3
- from .models import Creator
3
+ """Build real URL for api request"""
4
+
5
+ from rcdl.core.models import Creator
4
6
 
5
7
 
6
8
  class URL:
9
+ """Build real URL for api request"""
10
+
7
11
  DOMAINS_BASE_URL = {
8
12
  "coomer": "https://coomer.st/api/v1/",
9
13
  "kemono": "https://kemono.cr/api/v1/",
@@ -11,34 +15,47 @@ class URL:
11
15
 
12
16
  @staticmethod
13
17
  def get_base_url(domain: str) -> str:
18
+ """Return https://domain.com"""
14
19
  if domain not in URL.DOMAINS_BASE_URL:
15
20
  raise KeyError(f"{domain} not in known domains urls")
16
21
  return URL.DOMAINS_BASE_URL[domain]
17
22
 
18
23
  @staticmethod
19
24
  def get_post_revision(creator: Creator, post_id) -> str:
20
- return f"{URL.get_base_url(creator.domain)}{creator.service}/user/{creator.creator_id}/post/{post_id}/revisions"
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
+ )
21
30
 
22
31
  @staticmethod
23
32
  def get_headers() -> dict:
33
+ """Return necessary request header for successful request"""
24
34
  return {
25
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0 Safari/537.36",
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
+ ),
26
40
  "Accept": "text/css",
27
41
  }
28
42
 
29
43
  @staticmethod
30
44
  def get_url_from_file(domain: str, path_url: str):
45
+ """Add path_url to based domain url"""
31
46
  if domain == "coomer":
32
47
  return f"https://coomer.st{path_url}"
33
- elif domain == "kemono":
48
+ if domain == "kemono":
34
49
  return f"https://kemono.cr{path_url}"
35
- else:
36
- raise ValueError(
37
- f"Domain {domain} is not an accepted value/does not exist. Please check your creators.json file"
38
- )
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
+ )
39
55
 
40
56
  @staticmethod
41
57
  def add_params(url: str, params: dict):
58
+ """Create all parameters string (key=params&key=...)"""
42
59
  url += "?"
43
60
  for key in params:
44
61
  url += f"{key}={params[key]}&"
@@ -46,9 +63,14 @@ class URL:
46
63
 
47
64
  @staticmethod
48
65
  def get_creator_post_wo_param(creator: Creator) -> str:
49
- return f"{URL.get_base_url(creator.domain)}{creator.service}/user/{creator.creator_id}/posts"
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
+ )
50
71
 
51
72
  @staticmethod
52
73
  def get_posts_page_url_wo_param():
74
+ """Get posts page without parameters -> use in tag search"""
53
75
  domain = URL.DOMAINS_BASE_URL["coomer"]
54
76
  return f"{domain}posts"
rcdl/core/config.py CHANGED
@@ -1,13 +1,22 @@
1
1
  # core/config.py
2
2
 
3
+ """
4
+ Handle Config init, dependencies check, logging setup,
5
+ files and folders structures, config settings parameters init
6
+ """
7
+
3
8
  from pathlib import Path
4
9
  import logging
5
10
  import os
11
+ import tomllib
12
+ import subprocess
6
13
 
7
- from .file_io import write_txt
14
+ from rcdl.core.file_io import write_txt
8
15
 
9
16
 
10
17
  class Config:
18
+ """Global app var/parameters"""
19
+
11
20
  # paths
12
21
  APP_NAME = "rcdl"
13
22
 
@@ -16,62 +25,103 @@ class Config:
16
25
  CACHE_DIR = BASE_DIR / ".cache"
17
26
  DB_PATH = CACHE_DIR / "cdl.db"
18
27
  LOG_FILE = CACHE_DIR / "cdl.log"
19
- FUSE_CSV_FILE = CACHE_DIR / "cdl_fuse.csv"
20
28
  CREATORS_FILE = CACHE_DIR / "creators.txt"
21
29
  DISCOVER_DIR = CACHE_DIR / "discover"
22
-
23
- # default creators
24
- DEFAULT_CREATORS = ["boixd/onlyfans"]
30
+ CONFIG_FILE = CACHE_DIR / "config.toml"
25
31
 
26
32
  DEBUG = False
27
33
  DRY_RUN = False
28
34
 
29
35
  # api settings
30
- POST_PER_PAGE = 50
31
- DEFAULT_MAX_PAGE = 10
32
- MAX_FAIL_COUNT = 7
36
+ POST_PER_PAGE: int = 50
37
+ DEFAULT_MAX_PAGE: int = 10
38
+ MAX_FAIL_COUNT: int = 7
39
+ TIMEOUT: int = 10
40
+
41
+ # fuse settings
42
+ MAX_WIDTH: int = 1920
43
+ MAX_HEIGHT: int = 1080
44
+ FPS: int = 30
45
+ PRESET: str = "veryfast"
46
+ THREADS: int = 0
47
+
48
+ HANDBRAKE_RUN_CMD = "HandBrakeCLI"
49
+
50
+ CHECKSUM_RETRY = 2
33
51
 
34
52
  @classmethod
35
53
  def ensure_dirs(cls):
54
+ """Ensure directory exist"""
36
55
  cls.CACHE_DIR.mkdir(parents=True, exist_ok=True)
37
56
  cls.DISCOVER_DIR.mkdir(exist_ok=True)
38
57
 
39
58
  @classmethod
40
59
  def ensure_files(cls):
41
- files = [
42
- cls.DB_PATH,
43
- cls.FUSE_CSV_FILE,
44
- cls.CREATORS_FILE,
45
- ]
60
+ """Ensure file exist, populate default if necessary"""
61
+ files = [cls.DB_PATH, cls.CREATORS_FILE, cls.CONFIG_FILE]
46
62
  for file in files:
47
63
  if not file.exists():
48
64
  file.touch()
49
65
  logging.info("Created file %s", file)
50
66
  if file == cls.CREATORS_FILE:
51
- write_txt(cls.CREATORS_FILE, cls.DEFAULT_CREATORS, mode="w")
67
+ write_txt(cls.CREATORS_FILE, DEFAULT_CREATORS, mode="w")
68
+ if file == cls.CONFIG_FILE:
69
+ write_txt(cls.CONFIG_FILE, DEFAULT_CONFIG, mode="w")
52
70
 
53
71
  @classmethod
54
72
  def creator_folder(cls, creator_id: str) -> Path:
73
+ """Return creator folder path base on user/creator_id"""
55
74
  folder = cls.BASE_DIR / creator_id
56
75
  folder.mkdir(exist_ok=True)
57
76
  return folder
58
77
 
59
78
  @classmethod
60
79
  def cache_file(cls, filename: str, ext: str = ".json") -> Path:
80
+ """Return filepath of a file in the .cache/ folder"""
61
81
  file_name = filename + ext
62
82
  file = cls.CACHE_DIR / file_name
63
83
  return file
64
84
 
65
85
  @classmethod
66
86
  def set_debug(cls, debug: bool):
87
+ """Set class variable DEBUG"""
67
88
  cls.DEBUG = debug
68
89
 
69
90
  @classmethod
70
91
  def set_dry_run(cls, dry_run: bool):
92
+ """Set class variable DRY_RUN"""
71
93
  cls.DRY_RUN = dry_run
72
94
 
95
+ @classmethod
96
+ def load_config(cls):
97
+ """Load config.toml and set class var with value in config.toml"""
98
+ with open(cls.CONFIG_FILE, "rb") as f:
99
+ data = tomllib.load(f)
100
+ app = data.get("app", {})
101
+ cls.DEFAULT_MAX_PAGE = app.get("default_max_page", cls.DEFAULT_MAX_PAGE)
102
+ cls.MAX_FAIL_COUNT = app.get("max_fail_count", cls.MAX_FAIL_COUNT)
103
+ cls.TIMEOUT = app.get("timeout", cls.TIMEOUT)
104
+ cls.CHECKSUM_RETRY = app.get("checksum_retry", cls.CHECKSUM_RETRY)
105
+
106
+ video = data.get("video", {})
107
+ cls.MAX_WIDTH = video.get("max_width", cls.MAX_WIDTH)
108
+ cls.MAX_HEIGHT = video.get("max_height", cls.MAX_HEIGHT)
109
+ cls.FPS = video.get("fps", cls.FPS)
110
+ cls.PRESET = video.get("preset", cls.PRESET)
111
+ cls.THREADS = video.get("threads", cls.THREADS)
112
+
113
+ paths = data.get("paths", {})
114
+ if "base_dir" in paths:
115
+ cls.BASE_DIR = Path(
116
+ os.environ.get("RCDL_BASE_DIR", os.path.expanduser(paths["base_dir"]))
117
+ )
118
+ cls.CACHE_DIR = cls.BASE_DIR / ".cache"
119
+ if "handbrake_run_cmd" in paths:
120
+ cls.HANDBRAKE_RUN_CMD = paths.get("handbrake_run_cmd")
121
+
73
122
 
74
123
  def setup_logging(log_file: Path, level: int = 0):
124
+ """Setup logging for rcdl"""
75
125
  logger = logging.getLogger()
76
126
  logger.setLevel(level)
77
127
  logger.handlers.clear() # avoid double handlers if called multiple times
@@ -91,3 +141,72 @@ def setup_logging(log_file: Path, level: int = 0):
91
141
  stream = logging.StreamHandler()
92
142
  stream.setLevel(logging.ERROR) # only show warnings/errors from libraries
93
143
  logger.addHandler(stream)
144
+
145
+
146
+ def check_dependencies():
147
+ """Check external program version against last tested working version"""
148
+ for prgrm, info in DEPENDENCIES_TEST_VERSION.items():
149
+ try:
150
+ result = subprocess.run(
151
+ info["cmd"],
152
+ capture_output=True,
153
+ text=True,
154
+ shell=True,
155
+ check=False,
156
+ )
157
+ version = result.stdout.strip()
158
+
159
+ if version != info["version"]:
160
+ print(
161
+ f"Last tested version for {prgrm}:"
162
+ f" {info['version']} -> yours: {version}"
163
+ )
164
+ if version == "":
165
+ print(f"{prgrm} is not installed.")
166
+ print(f"Check {prgrm} is installed if your version is empty.")
167
+ except (OSError, subprocess.SubprocessError) as e:
168
+ print(
169
+ f"Failed to check {prgrm} version due to: {e}\nCheck {prgrm} is installed."
170
+ )
171
+
172
+
173
+ DEPENDENCIES_TEST_VERSION = {
174
+ "yt-dlp": {"cmd": "yt-dlp --version", "version": "2025.12.08"},
175
+ "aria2c": {
176
+ "cmd": "aria2c -v | head -n 1",
177
+ "version": "aria2 version 1.37.0",
178
+ },
179
+ "ffmpeg": {
180
+ "cmd": 'ffmpeg -version | sed -n "s/ffmpeg version \\([-0-9.]*\\).*/\\1/p;"',
181
+ "version": "7.1.1-1",
182
+ },
183
+ "handbrake": {
184
+ "cmd": Config.HANDBRAKE_RUN_CMD
185
+ + ' --version 2>&1 | sed -n "s/HandBrake \\([0-9.]*\\).*/\\1/p"',
186
+ "version": "1.9.2",
187
+ },
188
+ }
189
+
190
+
191
+ # default creators
192
+ DEFAULT_CREATORS = ["boixd/onlyfans"]
193
+
194
+ # default config params
195
+ DEFAULT_CONFIG: str = """\
196
+ [app]
197
+ default_max_page = 10
198
+ max_fail_count = 7
199
+ timeout = 10
200
+ checksum_retry = 2
201
+
202
+ [fuse]
203
+ max_width = 1920
204
+ max_height = 1080
205
+ fps = 30
206
+ preset = "veryfast"
207
+ threads = 0
208
+
209
+ [paths]
210
+ base_dir = "~/Videos/rcdl"
211
+ handbrake_run_cmd = "HandBrakeCLI"
212
+ """