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/core/parser.py CHANGED
@@ -1,12 +1,14 @@
1
1
  # core/parser.py
2
2
 
3
+ """Handle function to parse post and files"""
4
+
3
5
  import logging
4
6
  from pathvalidate import sanitize_filename
5
7
 
6
- from .models import Video, VideoStatus, Creator
7
- from .file_io import load_json, load_txt, write_txt
8
- from .config import Config
9
8
  from rcdl.interface.ui import UI
9
+ from rcdl.core.models import Media, Creator, Post, CreatorStatus
10
+ from rcdl.core.file_io import load_json, load_txt, write_txt
11
+ from rcdl.core.config import Config
10
12
 
11
13
 
12
14
  COOMER_PAYSITES = ["onlyfans", "fansly", "candfans"]
@@ -21,7 +23,7 @@ KEMONO_PAYSITES = [
21
23
  ]
22
24
 
23
25
 
24
- def get_domain(arg: str | dict | Video) -> str:
26
+ def get_domain(arg: str | dict | Media) -> str:
25
27
  """From a service get the domain (coomer or kemono)
26
28
  Input is either: service(str), post(dict), video(models.Video)
27
29
  """
@@ -31,25 +33,34 @@ def get_domain(arg: str | dict | Video) -> str:
31
33
  return "coomer"
32
34
  if service in KEMONO_PAYSITES:
33
35
  return "kemono"
34
- logging.error(f"Service {service} not associated to any domain")
36
+ logging.error("Service %s not associated to any domain", service)
35
37
  return ""
36
38
 
37
39
  if isinstance(arg, dict):
38
40
  return _service(arg["service"])
39
- elif isinstance(arg, Video):
41
+ if isinstance(arg, Media):
40
42
  return _service(arg.service)
41
43
 
42
44
  return _service(arg)
43
45
 
44
46
 
45
- def get_title(post: dict) -> str:
47
+ def get_title(post: Post) -> str:
48
+ """From a Post Model return the title"""
49
+ title = post.title
50
+ if title == "":
51
+ title = post.substring
52
+ if title == "":
53
+ title = post.id
54
+ return sanitize_filename(title)
55
+
56
+
57
+ def get_title_json(post: dict) -> str:
46
58
  """Extract title from a post(dict)"""
47
59
  title = post["title"]
48
60
  if title == "":
49
- if "content" in post:
50
- title = post["content"]
51
- elif "substring" in post:
52
- title = post["substring"]
61
+ title = post["substring"]
62
+ if title == "":
63
+ title = post["id"]
53
64
  return sanitize_filename(title)
54
65
 
55
66
 
@@ -60,7 +71,7 @@ def get_date(post: dict) -> str:
60
71
  elif "added" in post:
61
72
  date = post["added"][0:10]
62
73
  else:
63
- logging.error(f"Could not extract date from {post['id']}")
74
+ logging.error("Could not extract date from %s", post["id"])
64
75
  date = "NA"
65
76
  return date
66
77
 
@@ -81,13 +92,14 @@ def get_part(post: dict, url: str) -> int:
81
92
  part += 1
82
93
 
83
94
  logging.error(
84
- f"Could not extract part number for post id {post['id']} with url {url}"
95
+ "Could not extract part number for post id %s with url %s", post["id"], url
85
96
  )
86
97
  return -1
87
98
 
88
99
 
89
100
  def get_filename(post: dict, url: str) -> str:
90
- title = get_title(post)
101
+ """Get filename from pst dict and url"""
102
+ title = get_title_json(post)
91
103
  date = get_date(post)
92
104
  part = get_part(post, url)
93
105
  file_title = f"{date}_{title}".replace("'", " ").replace('"', "")
@@ -95,45 +107,19 @@ def get_filename(post: dict, url: str) -> str:
95
107
  return filename
96
108
 
97
109
 
98
- def convert_post_to_video(post: dict, url: str, discover=False) -> Video:
99
- part = get_part(post, url)
110
+ def get_filename_fuse(post: Post) -> str:
111
+ """Get filename for fuse output from Post Model
112
+ Fuse output has 'X' as part number"""
100
113
  title = get_title(post)
101
- date = get_date(post)
102
- filename = get_filename(post, url)
103
-
104
- if discover:
105
- filename = f"{post['user']}_{post['id']}.mp4"
106
-
107
- return Video(
108
- post_id=post["id"],
109
- creator_id=post["user"],
110
- service=post["service"],
111
- relative_path=filename,
112
- url=url,
113
- domain=get_domain(post),
114
- part=part,
115
- published=date,
116
- title=title,
117
- status=VideoStatus.NOT_DOWNLOADED,
118
- fail_count=0,
119
- )
120
-
121
-
122
- def convert_posts_to_videos(posts: list[dict], discover: bool = False) -> list[Video]:
123
- videos = []
124
- for post in posts:
125
- urls = extract_video_urls(post)
126
- if not discover:
127
- for url in urls:
128
- videos.append(convert_post_to_video(post, url))
129
- else:
130
- if len(urls) == 0:
131
- continue
132
- videos.append(convert_post_to_video(post, urls[0], discover=discover))
133
- return videos
114
+ date = post.published[0:10]
115
+ part = "X"
116
+ file_title = f"{date}_{title}".replace("'", " ").replace('"', "")
117
+ filename = f"{file_title}_p{part}.mp4"
118
+ return filename
134
119
 
135
120
 
136
121
  def extract_video_urls(post: dict) -> list:
122
+ """Extract all videos urls from a dict post"""
137
123
  video_extensions = (".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v")
138
124
  urls = set()
139
125
 
@@ -176,6 +162,7 @@ def filter_posts_with_videos_from_json(path: str) -> list:
176
162
 
177
163
 
178
164
  def valid_service(service: str) -> bool:
165
+ """Check if a service is valid (within list of DOMAIN services)"""
179
166
  if service in COOMER_PAYSITES:
180
167
  return True
181
168
  if service in KEMONO_PAYSITES:
@@ -183,31 +170,44 @@ def valid_service(service: str) -> bool:
183
170
  return False
184
171
 
185
172
 
173
+ def _default_creator(_id: str, service: str, domain: str):
174
+ return Creator(
175
+ id=_id,
176
+ service=service,
177
+ domain=domain,
178
+ name="",
179
+ indexed="",
180
+ updated="",
181
+ favorited=1,
182
+ status=CreatorStatus.NA,
183
+ max_date="",
184
+ max_posts=1,
185
+ max_size=1,
186
+ min_date="",
187
+ )
188
+
189
+
186
190
  def get_creator_from_line(line: str) -> Creator | None:
187
191
  """
188
192
  Convert a line into a Creator model
189
193
  arg: line -> 'service/creator'
190
194
  This is the format of creators.txt
191
195
  """
196
+
192
197
  parts = line.split("/")
193
198
  if valid_service(parts[0].strip()):
194
- return Creator(
195
- creator_id=parts[1].strip(),
196
- service=parts[0].strip(),
197
- domain=get_domain(parts[0].strip()),
198
- status=None,
199
- )
200
- elif valid_service(parts[1].strip()):
201
- return Creator(
202
- creator_id=parts[0].strip(),
203
- service=parts[1].strip(),
204
- domain=get_domain(parts[1].strip()),
205
- status=None,
199
+ return _default_creator(
200
+ parts[1].strip(), parts[0].strip(), get_domain(parts[0].strip())
206
201
  )
207
- else:
208
- UI.error(
209
- f"Creator file not valid: {line} can not be interpreted. Format is: 'service/creator_id'"
202
+ if valid_service(parts[1].strip()):
203
+ return _default_creator(
204
+ parts[0].strip(), parts[1].strip(), get_domain(parts[1].strip())
210
205
  )
206
+
207
+ UI.error(
208
+ f"Creator file not valid: {line} can not be interpreted."
209
+ f" Format is: 'service/creator_id'"
210
+ )
211
211
  return None
212
212
 
213
213
 
@@ -228,7 +228,8 @@ def get_creators() -> list[Creator]:
228
228
 
229
229
 
230
230
  def get_creators_from_posts(posts: list[dict]) -> list[Creator]:
231
- creators = list()
231
+ """Extract a list of Creators model form a list of dict posts"""
232
+ creators = []
232
233
  seen = set()
233
234
 
234
235
  for post in posts:
@@ -237,24 +238,20 @@ def get_creators_from_posts(posts: list[dict]) -> list[Creator]:
237
238
  continue
238
239
 
239
240
  seen.add(key)
240
- creators.append(
241
- Creator(
242
- creator_id=post["user"],
243
- service=post["service"],
244
- domain="coomer",
245
- status="to_be_treated",
246
- )
247
- )
241
+ creators.append(_default_creator(post["user"], post["service"], "coomer"))
248
242
  return creators
249
243
 
250
244
 
251
245
  def parse_creator_input(value: str) -> tuple[str | None, str]:
246
+ """Parse user input in cli to extract creator id & service"""
252
247
  value = value.strip()
253
248
 
254
249
  # url
255
250
  if "://" in value:
256
251
  parts = value.replace("https://", "").strip().split("/")
257
- logging.info(f"From {value} extracte service {parts[1]} and creator {parts[3]}")
252
+ logging.info(
253
+ "From %s extracte service %s and creator %s", value, parts[1], parts[3]
254
+ )
258
255
  return parts[1], parts[3] # service, creator_id
259
256
 
260
257
  # creators.txt format
@@ -262,16 +259,21 @@ def parse_creator_input(value: str) -> tuple[str | None, str]:
262
259
  c = get_creator_from_line(value)
263
260
  if c is not None:
264
261
  logging.info(
265
- f"From {value} extracte service {c.service} and creator {c.creator_id}"
262
+ "From %s extracte service %s and creator %s",
263
+ value,
264
+ c.service,
265
+ c.id,
266
266
  )
267
- return c.service, c.creator_id
267
+ return c.service, c.id
268
268
 
269
- logging.info(f"From {value} extracte service None and creator {value}")
269
+ logging.info("From %s extracted service None and creator %s", value, value)
270
270
  return None, value
271
271
 
272
272
 
273
273
  def append_creator(creator: Creator):
274
- line = f"{creator.service}/{creator.creator_id}"
274
+ """Append a creator to the creators.txt file
275
+ Creators.txt hold all creators used in refresh command"""
276
+ line = f"{creator.service}/{creator.id}"
275
277
  lines = load_txt(Config.CREATORS_FILE)
276
278
 
277
279
  if line in lines:
rcdl/gui/__init__.py ADDED
File without changes
rcdl/gui/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # rcdl/gui/__main__.py
2
+
3
+ from rcdl.gui.gui import run_gui
4
+
5
+ run_gui()
rcdl/gui/db_viewer.py ADDED
@@ -0,0 +1,41 @@
1
+ # gui/db_viewer.py
2
+
3
+ import streamlit as st
4
+ import sqlite3
5
+
6
+ import pandas as pd
7
+
8
+ from rcdl.core.config import Config
9
+
10
+ TABLES = ["medias", "posts", "fuses"]
11
+
12
+
13
+ def get_table_columns(table_name):
14
+ conn = sqlite3.connect(Config.DB_PATH)
15
+ cur = conn.cursor()
16
+ cur.execute(f"PRAGMA table_info({table_name})")
17
+ columns = [info[1] for info in cur.fetchall()]
18
+ conn.close()
19
+ return columns
20
+
21
+
22
+ def get_table_data(table_name, sort_by=None, ascending=True):
23
+ conn = sqlite3.connect(Config.DB_PATH)
24
+ df = pd.read_sql_query(f"SELECT * FROM {table_name}", conn)
25
+ conn.close()
26
+ if sort_by and sort_by in df.columns:
27
+ df = df.sort_values(by=sort_by, ascending=ascending)
28
+ return df
29
+
30
+
31
+ def run_db_viewer():
32
+ st.set_page_config(page_title="DB Viewer", layout="wide")
33
+ st.title("Database Viewer")
34
+
35
+ table_name = st.selectbox("Select Table", TABLES)
36
+
37
+ # Load data
38
+ df = get_table_data(table_name, sort_by=None, ascending=True)
39
+
40
+ st.write(f"Showing `{table_name}` table ({len(df)} rows)")
41
+ st.dataframe(df, width="stretch")
rcdl/gui/gui.py ADDED
@@ -0,0 +1,54 @@
1
+ # gui/gui.py
2
+
3
+ import streamlit as st
4
+
5
+ from rcdl.gui.db_viewer import run_db_viewer
6
+ from rcdl.gui.video_manager import video_manager
7
+
8
+ st.markdown(
9
+ """
10
+ <style>
11
+ /* Remove top padding */
12
+ .block-container {
13
+ padding-top: 1rem !important;
14
+ }
15
+
16
+ /* Optional: remove Streamlit header */
17
+ header[data-testid="stHeader"] {
18
+ display: none;
19
+ }
20
+
21
+ /* Optional: remove footer */
22
+ footer {
23
+ display: none;
24
+ }
25
+ </style>
26
+ """,
27
+ unsafe_allow_html=True,
28
+ )
29
+
30
+
31
+ def run_gui():
32
+ """
33
+ Launches the Streamlit GUI.
34
+ This function can be called from a CLI command.
35
+ """
36
+ # Streamlit code
37
+ st.set_page_config(page_title="RCDL", layout="wide")
38
+
39
+ # Sidebar navigation
40
+ page = st.sidebar.radio("Go to", ["Home", "Manage Videos", "View DB"])
41
+
42
+ if page == "Home":
43
+ st.header("Home Page")
44
+ st.write("Develloped by - ritonun -")
45
+
46
+ elif page == "Manage Videos":
47
+ video_manager()
48
+
49
+ elif page == "View DB":
50
+ run_db_viewer()
51
+
52
+
53
+ if __name__ == "__main__":
54
+ run_gui()
@@ -0,0 +1,170 @@
1
+ # gui/video_manager.py
2
+
3
+ import os
4
+
5
+ import streamlit as st
6
+
7
+ from rcdl.core.config import Config
8
+ from rcdl.core.models import Status, Media
9
+ from rcdl.core.db import DB
10
+ from rcdl.utils import format_seconds
11
+
12
+
13
+ previous_statuses = {}
14
+
15
+
16
+ def set_status(media: Media, status: Status):
17
+ key = media.post_id + media.url
18
+ previous_statuses[key] = media.status
19
+ media.status = status
20
+ with DB() as db:
21
+ db.update_media(media)
22
+ print(f"Set {media.post_id} to {status.value}")
23
+
24
+ for m in st.session_state.medias:
25
+ if m.post_id == media.post_id and m.url == media.url:
26
+ m.status = status
27
+ break
28
+
29
+
30
+ def video_manager():
31
+ st.title("Video Manager")
32
+
33
+ # Filter & Sorting UI
34
+ with st.expander("Filters & Sorting", expanded=True):
35
+ col1, col2, col3 = st.columns(3)
36
+ with col1:
37
+ sort_by = st.selectbox(
38
+ "Sort By",
39
+ options=["file_size", "service", "duration", "file_path"],
40
+ index=0,
41
+ )
42
+ with col2:
43
+ ascending = st.radio(
44
+ "Order",
45
+ options=[True, False],
46
+ format_func=lambda x: "Ascending" if x else "Descending",
47
+ horizontal=True,
48
+ )
49
+ with col3:
50
+ creator_filter = st.text_input(
51
+ "Creator ID(user)", placeholder="Leave empty for all"
52
+ )
53
+ status_filter = st.multiselect(
54
+ "Status",
55
+ options=list(Status),
56
+ default=[Status.DOWNLOADED, Status.OPTIMIZED],
57
+ )
58
+
59
+ reload = st.button("Apply")
60
+
61
+ # load db
62
+ if reload or "medias" not in st.session_state:
63
+ with DB() as db:
64
+ medias = db.query_medias_by_status_sorted(
65
+ status_filter,
66
+ sort_by=sort_by,
67
+ ascending=ascending,
68
+ )
69
+
70
+ # check if in a fuse group
71
+
72
+ # creator filter
73
+ if creator_filter:
74
+ filtered = []
75
+ for m in medias:
76
+ post = db.query_post_by_id(m.post_id)
77
+ if post and post.user == creator_filter:
78
+ filtered.append(m)
79
+ # check i na fuse group
80
+ fm = db.query_fuses_by_id(m.post_id)
81
+ if fm is None:
82
+ filtered.append(m)
83
+ medias = filtered
84
+
85
+ st.session_state.medias = medias
86
+ st.session_state.media_index = 0
87
+
88
+ medias = st.session_state.medias
89
+ if not medias:
90
+ st.info("No media found")
91
+ return
92
+
93
+ # session state
94
+ if "media_index" not in st.session_state:
95
+ st.session_state.media_index = 0
96
+
97
+ idx = st.session_state.media_index
98
+ media = medias[idx]
99
+
100
+ # media info
101
+ st.subheader(f"Media {idx + 1} / {len(medias)}")
102
+
103
+ with DB() as db:
104
+ post = db.query_post_by_id(media.post_id)
105
+ if post is None:
106
+ st.info("No matching post found")
107
+ return
108
+
109
+ col_video, col_info = st.columns([1, 2])
110
+ with col_info:
111
+ col1, col2 = st.columns(2)
112
+ with col1:
113
+ st.write("**Post ID:**", media.post_id)
114
+ st.write("**Service:**", media.service)
115
+ st.write("**User:**", post.user)
116
+ st.write("**Duration:**", format_seconds(media.duration))
117
+ st.write("**Sequence:**", media.sequence)
118
+ st.write("**Size:**", round(media.file_size / (1024 * 1024), 1), "MB")
119
+ st.write("**Status:**", media.status)
120
+ key = media.post_id + media.url
121
+ if key in previous_statuses:
122
+ st.write("**PREV STATUS:**", previous_statuses[key])
123
+ st.write("**Path:**", media.file_path)
124
+ st.write("**Created at**:", media.created_at[0:16])
125
+
126
+ with col2:
127
+ # controls
128
+ c1, c2, c3 = st.columns([1, 1, 2])
129
+ with c1:
130
+ if st.button("⏮ Prev", disabled=idx == 0):
131
+ st.session_state.media_index -= 1
132
+ st.rerun()
133
+ if st.button("⏭ Next", disabled=idx >= len(medias) - 1):
134
+ st.session_state.media_index += 1
135
+ st.rerun()
136
+ with c2:
137
+ if st.button("Remove"):
138
+ set_status(media, Status.TO_BE_DELETED)
139
+ st.rerun()
140
+ if st.button("Revert Status"):
141
+ key = media.post_id + media.url
142
+ if key in previous_statuses:
143
+ set_status(media, previous_statuses[key])
144
+ else:
145
+ print("Not in previous status")
146
+ st.rerun()
147
+ with c3:
148
+ chosen_status = st.selectbox(
149
+ "Set Status",
150
+ options=list(Status),
151
+ index=list(Status).index(media.status)
152
+ if media.status in list(Status)
153
+ else 0,
154
+ )
155
+ if st.button("Apply Status"):
156
+ set_status(media, chosen_status)
157
+ st.rerun()
158
+
159
+ # video player
160
+ full_path = os.path.join(Config.creator_folder(post.user), media.file_path)
161
+ if os.path.exists(full_path):
162
+ with col_video:
163
+ with st.container():
164
+ if media.file_size > 199 * 1024 * 1024: # 199MB
165
+ with open(full_path, "rb") as f:
166
+ st.video(f.read(), autoplay=True, loop=True)
167
+ else:
168
+ st.video(full_path, autoplay=True, loop=True)
169
+ else:
170
+ st.error(f"Video file {full_path} not found on disk")
File without changes