rcdl 2.0.0__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 +5 -0
- rcdl/__main__.py +23 -0
- rcdl/core/api.py +54 -0
- rcdl/core/config.py +115 -0
- rcdl/core/db.py +262 -0
- rcdl/core/downloader.py +246 -0
- rcdl/core/downloader_subprocess.py +29 -0
- rcdl/core/file_io.py +14 -0
- rcdl/core/models.py +52 -0
- rcdl/core/parser.py +208 -0
- rcdl/core/processor.py +290 -0
- rcdl/interface/cli.py +97 -0
- rcdl/interface/progress.py +25 -0
- rcdl/scripts/upload_pypi.py +83 -0
- rcdl/utils.py +40 -0
- rcdl-2.0.0.dist-info/METADATA +81 -0
- rcdl-2.0.0.dist-info/RECORD +19 -0
- rcdl-2.0.0.dist-info/WHEEL +4 -0
- rcdl-2.0.0.dist-info/entry_points.txt +3 -0
rcdl/__init__.py
ADDED
rcdl/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# __main__.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from rcdl.core.config import Config, setup_logging
|
|
6
|
+
|
|
7
|
+
# setup file structure
|
|
8
|
+
Config.ensure_dirs()
|
|
9
|
+
Config.ensure_files()
|
|
10
|
+
|
|
11
|
+
# setup logging
|
|
12
|
+
setup_logging(Config.LOG_FILE, level=0)
|
|
13
|
+
|
|
14
|
+
logging.info("--- INIT ---")
|
|
15
|
+
logging.info("Logger initialized")
|
|
16
|
+
|
|
17
|
+
# init database
|
|
18
|
+
from rcdl.core.db import DB # noqa: E402
|
|
19
|
+
|
|
20
|
+
d = DB()
|
|
21
|
+
d.close()
|
|
22
|
+
|
|
23
|
+
from rcdl.interface.cli import cli # noqa: E402, F401
|
rcdl/core/api.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# core/api.py
|
|
2
|
+
|
|
3
|
+
from .models import Creator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class URL:
|
|
7
|
+
DOMAINS_BASE_URL = {
|
|
8
|
+
"coomer": "https://coomer.st/api/v1/",
|
|
9
|
+
"kemono": "https://kemono.cr/api/v1/",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def get_base_url(domain: str) -> str:
|
|
14
|
+
if domain not in URL.DOMAINS_BASE_URL:
|
|
15
|
+
raise KeyError(f"{domain} not in known domains urls")
|
|
16
|
+
return URL.DOMAINS_BASE_URL[domain]
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
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"
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def get_headers() -> dict:
|
|
24
|
+
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",
|
|
26
|
+
"Accept": "text/css",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def get_url_from_file(domain: str, path_url: str):
|
|
31
|
+
if domain == "coomer":
|
|
32
|
+
return f"https://coomer.st{path_url}"
|
|
33
|
+
elif domain == "kemono":
|
|
34
|
+
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
|
+
)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def add_params(url: str, params: dict):
|
|
42
|
+
url += "?"
|
|
43
|
+
for key in params:
|
|
44
|
+
url += f"{key}={params[key]}&"
|
|
45
|
+
return url[:-1]
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
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"
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def get_posts_page_url_wo_param():
|
|
53
|
+
domain = URL.DOMAINS_BASE_URL["coomer"]
|
|
54
|
+
return f"{domain}posts"
|
rcdl/core/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# core/config.py
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .file_io import write_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Config:
|
|
12
|
+
# paths
|
|
13
|
+
APP_NAME = "cdl"
|
|
14
|
+
BASE_DIR = Path.home() / "Videos/rcdl"
|
|
15
|
+
|
|
16
|
+
BASE_DIR = Path(os.environ.get("RCDL_BASE_DIR", Path.home() / "Videos/rcdl"))
|
|
17
|
+
|
|
18
|
+
CACHE_DIR = BASE_DIR / ".cache"
|
|
19
|
+
DB_PATH = CACHE_DIR / "cdl.db"
|
|
20
|
+
LOG_FILE = CACHE_DIR / "cdl.log"
|
|
21
|
+
FUSE_CSV_FILE = CACHE_DIR / "cdl_fuse.csv"
|
|
22
|
+
SETTINGS_FILE = CACHE_DIR / "settings.json"
|
|
23
|
+
CREATORS_FILE = CACHE_DIR / "creators.json"
|
|
24
|
+
DISCOVER_DIR = CACHE_DIR / "discover"
|
|
25
|
+
|
|
26
|
+
# defaults
|
|
27
|
+
DEFAULT_SETTINGS = {
|
|
28
|
+
"work_folder": str(BASE_DIR),
|
|
29
|
+
"yt_dlp_args": "--external-downloader aria2c",
|
|
30
|
+
"preset": "veryfast",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# default creators
|
|
34
|
+
DEFAULT_CREATORS = [
|
|
35
|
+
{"creator_id": "boixd", "service": "onlyfans", "domain": "coomer"}
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DEBUG = False
|
|
39
|
+
DRY_RUN = False
|
|
40
|
+
|
|
41
|
+
# api settings
|
|
42
|
+
POST_PER_PAGE = 50
|
|
43
|
+
DEFAULT_MAX_PAGE = 10
|
|
44
|
+
MAX_FAIL_COUNT = 7
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def ensure_dirs(cls):
|
|
48
|
+
cls.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
cls.DISCOVER_DIR.mkdir(exist_ok=True)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def ensure_files(cls):
|
|
53
|
+
files = [
|
|
54
|
+
cls.DB_PATH,
|
|
55
|
+
cls.FUSE_CSV_FILE,
|
|
56
|
+
cls.CREATORS_FILE,
|
|
57
|
+
]
|
|
58
|
+
for file in files:
|
|
59
|
+
if not file.exists():
|
|
60
|
+
file.touch()
|
|
61
|
+
logging.info("Created file %s", file)
|
|
62
|
+
if file == cls.CREATORS_FILE:
|
|
63
|
+
write_json(file, cls.DEFAULT_CREATORS)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def load_settings(cls):
|
|
67
|
+
if not cls.SETTINGS_FILE.exists():
|
|
68
|
+
with open(cls.SETTINGS_FILE, "w") as f:
|
|
69
|
+
json.dump(cls.DEFAULT_SETTINGS, f, indent=4)
|
|
70
|
+
return cls.DEFAULT_SETTINGS
|
|
71
|
+
|
|
72
|
+
with open(cls.SETTINGS_FILE, "r") as f:
|
|
73
|
+
return json.load(f)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def creator_folder(cls, creator_id: str) -> Path:
|
|
77
|
+
folder = cls.BASE_DIR / creator_id
|
|
78
|
+
folder.mkdir(exist_ok=True)
|
|
79
|
+
return folder
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def cache_file(cls, filename: str, ext: str = ".json") -> Path:
|
|
83
|
+
file_name = filename + ext
|
|
84
|
+
file = cls.CACHE_DIR / file_name
|
|
85
|
+
return file
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def set_debug(cls, debug: bool):
|
|
89
|
+
cls.DEBUG = debug
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def set_dry_run(cls, dry_run: bool):
|
|
93
|
+
cls.DRY_RUN = dry_run
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def setup_logging(log_file: Path, level: int = 0):
|
|
97
|
+
logger = logging.getLogger()
|
|
98
|
+
logger.setLevel(level)
|
|
99
|
+
logger.handlers.clear() # avoid double handlers if called multiple times
|
|
100
|
+
|
|
101
|
+
# file handler
|
|
102
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8", mode="a")
|
|
103
|
+
file_handler.setFormatter(
|
|
104
|
+
logging.Formatter(
|
|
105
|
+
"{asctime} - {levelname} - {message}",
|
|
106
|
+
style="{",
|
|
107
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
logger.addHandler(file_handler)
|
|
111
|
+
|
|
112
|
+
# console handler (stdout)
|
|
113
|
+
console_handler = logging.StreamHandler()
|
|
114
|
+
console_handler.setFormatter(logging.Formatter("{levelname}: {message}", style="{"))
|
|
115
|
+
logger.addHandler(console_handler)
|
rcdl/core/db.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# core/db.py
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .models import Video, VideoStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DB:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.conn = sqlite3.connect(Config.DB_PATH)
|
|
14
|
+
self.conn.row_factory = sqlite3.Row
|
|
15
|
+
self._init_table()
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
21
|
+
self.close()
|
|
22
|
+
|
|
23
|
+
def _init_table(self):
|
|
24
|
+
# init table for videos to DL
|
|
25
|
+
q = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS videos (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
post_id TEXT,
|
|
29
|
+
creator_id TEXT,
|
|
30
|
+
service TEXT,
|
|
31
|
+
domain TEXT,
|
|
32
|
+
relative_path TEXT,
|
|
33
|
+
url TEXT,
|
|
34
|
+
part TEXT,
|
|
35
|
+
status TEXT DEFAULT 'not_downloaded',
|
|
36
|
+
fail_count INTEGER DEFAULT 0,
|
|
37
|
+
published TEXT,
|
|
38
|
+
title TEXT,
|
|
39
|
+
substring TEXT,
|
|
40
|
+
downloaded_at TEXT,
|
|
41
|
+
file_size REAL,
|
|
42
|
+
UNIQUE (service, url)
|
|
43
|
+
)
|
|
44
|
+
"""
|
|
45
|
+
self.conn.execute(q)
|
|
46
|
+
self.conn.commit()
|
|
47
|
+
|
|
48
|
+
def get_video_status(self, video: Video) -> Optional[dict]:
|
|
49
|
+
cur = self.conn.cursor()
|
|
50
|
+
cur.execute(
|
|
51
|
+
"SELECT status, fail_count FROM videos WHERE (service, url) = (?, ?)",
|
|
52
|
+
(video.service, video.url),
|
|
53
|
+
)
|
|
54
|
+
row = cur.fetchone()
|
|
55
|
+
if row:
|
|
56
|
+
return {
|
|
57
|
+
"status": VideoStatus(row["status"]),
|
|
58
|
+
"fail_count": row["fail_count"],
|
|
59
|
+
"downloaded_at": row["downloaded_at"],
|
|
60
|
+
"relative_path": row["relative_path"],
|
|
61
|
+
}
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def get_videos_by_status(self, status: VideoStatus) -> list[Video]:
|
|
65
|
+
cur = self.conn.cursor()
|
|
66
|
+
cur.execute(
|
|
67
|
+
"SELECT * FROM videos WHERE status = ?",
|
|
68
|
+
(status.value,),
|
|
69
|
+
)
|
|
70
|
+
rows = cur.fetchall()
|
|
71
|
+
videos: list[Video] = []
|
|
72
|
+
for row in rows:
|
|
73
|
+
v = Video(
|
|
74
|
+
post_id=row["post_id"],
|
|
75
|
+
creator_id=row["creator_id"],
|
|
76
|
+
service=row["service"],
|
|
77
|
+
domain=row["domain"],
|
|
78
|
+
relative_path=row["relative_path"],
|
|
79
|
+
url=row["url"],
|
|
80
|
+
part=row["part"],
|
|
81
|
+
status=VideoStatus(row["status"]),
|
|
82
|
+
fail_count=row["fail_count"],
|
|
83
|
+
published=row["published"],
|
|
84
|
+
title=row["title"],
|
|
85
|
+
substring=row["substring"],
|
|
86
|
+
downloaded_at=row["downloaded_at"],
|
|
87
|
+
file_size=row["file_size"],
|
|
88
|
+
)
|
|
89
|
+
videos.append(v)
|
|
90
|
+
logging.info(f"DB request status {status.value} returned {len(videos)} results")
|
|
91
|
+
return videos
|
|
92
|
+
|
|
93
|
+
def get_videos_by_creator_id(self, creator_id: str):
|
|
94
|
+
# discover_videos
|
|
95
|
+
cur = self.conn.cursor()
|
|
96
|
+
cur.execute("SELECT * FROM videos WHERE creator_id = ?", (creator_id,))
|
|
97
|
+
|
|
98
|
+
rows = cur.fetchall()
|
|
99
|
+
videos: list[Video] = []
|
|
100
|
+
for row in rows:
|
|
101
|
+
v = Video(
|
|
102
|
+
post_id=row["post_id"],
|
|
103
|
+
creator_id=row["creator_id"],
|
|
104
|
+
service=row["service"],
|
|
105
|
+
domain=row["domain"],
|
|
106
|
+
relative_path=row["relative_path"],
|
|
107
|
+
url=row["url"],
|
|
108
|
+
part=row["part"],
|
|
109
|
+
status=VideoStatus(row["status"]),
|
|
110
|
+
fail_count=row["fail_count"],
|
|
111
|
+
published=row["published"],
|
|
112
|
+
title=row["title"],
|
|
113
|
+
substring=row["substring"],
|
|
114
|
+
downloaded_at=row["downloaded_at"],
|
|
115
|
+
file_size=row["file_size"],
|
|
116
|
+
)
|
|
117
|
+
videos.append(v)
|
|
118
|
+
return videos
|
|
119
|
+
|
|
120
|
+
def mark_not_downloaded(self, video: Video):
|
|
121
|
+
video.status = VideoStatus.NOT_DOWNLOADED
|
|
122
|
+
self._upsert_video(video)
|
|
123
|
+
|
|
124
|
+
def mark_downloaded(self, video: Video):
|
|
125
|
+
video.status = VideoStatus.DOWNLOADED
|
|
126
|
+
self._upsert_video(video)
|
|
127
|
+
|
|
128
|
+
def mark_failed(self, video: Video, fail_count: int):
|
|
129
|
+
video.status = VideoStatus.FAILED
|
|
130
|
+
video.fail_count = fail_count
|
|
131
|
+
self._upsert_video(video)
|
|
132
|
+
|
|
133
|
+
def mark_skipped(self, video: Video):
|
|
134
|
+
video.status = VideoStatus.SKIPPED
|
|
135
|
+
self._upsert_video(video)
|
|
136
|
+
|
|
137
|
+
def mark_ignored(self, video: Video):
|
|
138
|
+
video.status = VideoStatus.IGNORED
|
|
139
|
+
self._upsert_video(video)
|
|
140
|
+
|
|
141
|
+
def _upsert_video(self, video: Video):
|
|
142
|
+
q = """
|
|
143
|
+
INSERT INTO videos (
|
|
144
|
+
post_id, creator_id, service, domain, relative_path, url, part,
|
|
145
|
+
status, fail_count, published, title, substring,
|
|
146
|
+
downloaded_at, file_size
|
|
147
|
+
)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
149
|
+
ON CONFLICT(service, url) DO UPDATE SET
|
|
150
|
+
status=excluded.status,
|
|
151
|
+
fail_count=excluded.fail_count,
|
|
152
|
+
relative_path=excluded.relative_path,
|
|
153
|
+
downloaded_at=excluded.downloaded_at,
|
|
154
|
+
file_size=excluded.file_size
|
|
155
|
+
"""
|
|
156
|
+
if video.status is None:
|
|
157
|
+
video.status = VideoStatus.NOT_DOWNLOADED
|
|
158
|
+
|
|
159
|
+
self.conn.execute(
|
|
160
|
+
q,
|
|
161
|
+
(
|
|
162
|
+
video.post_id,
|
|
163
|
+
video.creator_id,
|
|
164
|
+
video.service,
|
|
165
|
+
video.domain,
|
|
166
|
+
video.relative_path,
|
|
167
|
+
video.url,
|
|
168
|
+
video.part,
|
|
169
|
+
video.status.value,
|
|
170
|
+
video.fail_count,
|
|
171
|
+
video.published,
|
|
172
|
+
video.title,
|
|
173
|
+
video.substring,
|
|
174
|
+
video.downloaded_at,
|
|
175
|
+
video.file_size,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
self.conn.commit()
|
|
179
|
+
|
|
180
|
+
def get_pending_videos(
|
|
181
|
+
self, max_fail_count: int = Config.MAX_FAIL_COUNT
|
|
182
|
+
) -> list[Video]:
|
|
183
|
+
cur = self.conn.cursor()
|
|
184
|
+
cur.execute(
|
|
185
|
+
"SELECT * FROM videos WHERE status = ? OR (status = ? AND fail_count < ?)",
|
|
186
|
+
(
|
|
187
|
+
VideoStatus.NOT_DOWNLOADED,
|
|
188
|
+
VideoStatus.FAILED,
|
|
189
|
+
max_fail_count,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
rows = cur.fetchall()
|
|
194
|
+
videos: list[Video] = []
|
|
195
|
+
|
|
196
|
+
for row in rows:
|
|
197
|
+
videos.append(
|
|
198
|
+
Video(
|
|
199
|
+
post_id=row["post_id"],
|
|
200
|
+
creator_id=row["creator_id"],
|
|
201
|
+
service=row["service"],
|
|
202
|
+
domain=row["domain"],
|
|
203
|
+
relative_path=row["relative_path"],
|
|
204
|
+
url=row["url"],
|
|
205
|
+
part=row["part"],
|
|
206
|
+
status=VideoStatus(row["status"]),
|
|
207
|
+
fail_count=row["fail_count"],
|
|
208
|
+
published=row["published"],
|
|
209
|
+
title=row["title"],
|
|
210
|
+
substring=row["substring"],
|
|
211
|
+
downloaded_at=row["downloaded_at"],
|
|
212
|
+
file_size=row["file_size"],
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
logging.info(
|
|
217
|
+
"DB pending videos: %d (max_fail_count=%d)", len(videos), max_fail_count
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return videos
|
|
221
|
+
|
|
222
|
+
def close(self):
|
|
223
|
+
self.conn.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def update_db(videos: list[Video]):
|
|
227
|
+
if not videos:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
with DB() as d:
|
|
231
|
+
q = """
|
|
232
|
+
INSERT OR IGNORE INTO videos (
|
|
233
|
+
post_id, creator_id, service, domain, relative_path, url, part,
|
|
234
|
+
status, fail_count, published, title, substring,
|
|
235
|
+
downloaded_at, file_size
|
|
236
|
+
)
|
|
237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
rows = []
|
|
241
|
+
for video in videos:
|
|
242
|
+
rows.append(
|
|
243
|
+
(
|
|
244
|
+
video.post_id,
|
|
245
|
+
video.creator_id,
|
|
246
|
+
video.service,
|
|
247
|
+
video.domain,
|
|
248
|
+
video.relative_path,
|
|
249
|
+
video.url,
|
|
250
|
+
video.part,
|
|
251
|
+
VideoStatus.NOT_DOWNLOADED.value,
|
|
252
|
+
0,
|
|
253
|
+
video.published,
|
|
254
|
+
video.title,
|
|
255
|
+
video.substring,
|
|
256
|
+
None,
|
|
257
|
+
None,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
d.conn.executemany(q, rows)
|
|
262
|
+
d.conn.commit()
|
rcdl/core/downloader.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# core/downloader.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
import rcdl.core.parser as parser
|
|
9
|
+
from .api import URL
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .models import Creator, Video
|
|
12
|
+
from .db import DB, update_db
|
|
13
|
+
from .downloader_subprocess import ytdlp_subprocess
|
|
14
|
+
from .file_io import write_json, load_json
|
|
15
|
+
from rcdl.interface.progress import ProgressPrinter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PostsFetcher:
|
|
19
|
+
def __init__(
|
|
20
|
+
self, url: str, json_path: str, max_page: int = Config.DEFAULT_MAX_PAGE
|
|
21
|
+
):
|
|
22
|
+
self.url = url
|
|
23
|
+
self.json_path = json_path
|
|
24
|
+
|
|
25
|
+
self.page = 0
|
|
26
|
+
self.max_page = max_page
|
|
27
|
+
|
|
28
|
+
self.status = 200
|
|
29
|
+
|
|
30
|
+
def _request_page(self, url: str) -> requests.Response:
|
|
31
|
+
logging.info(f"RequestEngine url {url}")
|
|
32
|
+
headers = URL.get_headers()
|
|
33
|
+
return requests.get(url, headers=headers)
|
|
34
|
+
|
|
35
|
+
def request(self, continue_on_error: bool = False, params: dict = {}):
|
|
36
|
+
while self.status == 200 and self.page < self.max_page:
|
|
37
|
+
o = self.page * Config.POST_PER_PAGE
|
|
38
|
+
params["o"] = o
|
|
39
|
+
url = URL.add_params(self.url, params)
|
|
40
|
+
|
|
41
|
+
if Config.DRY_RUN:
|
|
42
|
+
logging.debug(f"DRY-RUN posts fetcher {url} -> {self.json_path}")
|
|
43
|
+
self.page += 1
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
response = self._request_page(url)
|
|
47
|
+
self.status = response.status_code
|
|
48
|
+
|
|
49
|
+
if self.status != 200:
|
|
50
|
+
if self.page == 0:
|
|
51
|
+
logging.error(f"Failed to get {url}")
|
|
52
|
+
else:
|
|
53
|
+
logging.warning(
|
|
54
|
+
f"Status code {self.status}. This behavior is expected."
|
|
55
|
+
)
|
|
56
|
+
if not continue_on_error:
|
|
57
|
+
break
|
|
58
|
+
else:
|
|
59
|
+
logging.info(f"Response Status Code: {self.status}")
|
|
60
|
+
if self.page > 0:
|
|
61
|
+
json_data = list(load_json(self.json_path))
|
|
62
|
+
else:
|
|
63
|
+
json_data = []
|
|
64
|
+
|
|
65
|
+
if "posts" in response.json():
|
|
66
|
+
json_data.extend(response.json()["posts"])
|
|
67
|
+
else:
|
|
68
|
+
json_data.extend(response.json())
|
|
69
|
+
|
|
70
|
+
write_json(self.json_path, json_data, mode="w")
|
|
71
|
+
|
|
72
|
+
self.page += 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class VideoDownloader:
|
|
76
|
+
def __init__(self, total_dl: int = -1):
|
|
77
|
+
self.progress = ProgressPrinter(total_dl)
|
|
78
|
+
|
|
79
|
+
def _build_url(self, domain: str, video: Video):
|
|
80
|
+
return URL.get_url_from_file(domain, video.url)
|
|
81
|
+
|
|
82
|
+
def _build_output_path(self, video: Video, discover: bool = False):
|
|
83
|
+
if not discover:
|
|
84
|
+
return os.path.join(
|
|
85
|
+
Config.creator_folder(video.creator_id), video.relative_path
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
return os.path.join(Config.DISCOVER_DIR, video.relative_path)
|
|
89
|
+
|
|
90
|
+
def _update_db_status(self, result: int, video: Video):
|
|
91
|
+
with DB() as d:
|
|
92
|
+
if result == 0:
|
|
93
|
+
d.mark_downloaded(video)
|
|
94
|
+
else:
|
|
95
|
+
d.mark_failed(video, video.fail_count + 1)
|
|
96
|
+
|
|
97
|
+
def _exists(self, path: str) -> bool:
|
|
98
|
+
if os.path.exists(path):
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def download(
|
|
103
|
+
self, domain: str, video: Video, write_db: bool = True, discover: bool = False
|
|
104
|
+
) -> bool:
|
|
105
|
+
url = self._build_url(domain, video)
|
|
106
|
+
path = self._build_output_path(video, discover=discover)
|
|
107
|
+
|
|
108
|
+
if Config.DRY_RUN:
|
|
109
|
+
logging.debug(f"DRY-RUN dl {url} -> {path}")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
if self._exists(path):
|
|
113
|
+
logging.warning("Video was already dl ! This should not happen")
|
|
114
|
+
self.progress.update(ignore=True) # update progress but not eta
|
|
115
|
+
|
|
116
|
+
result = ytdlp_subprocess(url, path)
|
|
117
|
+
if write_db:
|
|
118
|
+
self._update_db_status(result, video)
|
|
119
|
+
|
|
120
|
+
self.progress.update()
|
|
121
|
+
self.progress.display()
|
|
122
|
+
|
|
123
|
+
if result == 0:
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch_posts_by_tag(tag: str, max_page: int = Config.DEFAULT_MAX_PAGE) -> dict:
|
|
129
|
+
url = URL.get_posts_page_url_wo_param()
|
|
130
|
+
path = Config.cache_file(tag)
|
|
131
|
+
pf = PostsFetcher(url, str(path), max_page=max_page)
|
|
132
|
+
pf.request(continue_on_error=True, params={"tag": tag})
|
|
133
|
+
|
|
134
|
+
return load_json(path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fetch_posts_by_creator(creator: Creator) -> dict:
|
|
138
|
+
url = URL.get_creator_post_wo_param(creator)
|
|
139
|
+
path = Config.cache_file(f"{creator.creator_id}_{creator.service}")
|
|
140
|
+
pf = PostsFetcher(url, str(path))
|
|
141
|
+
pf.request()
|
|
142
|
+
|
|
143
|
+
return load_json(path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def refresh_creators_videos(creators: list[Creator]):
|
|
147
|
+
for creator in creators:
|
|
148
|
+
logging.info(
|
|
149
|
+
f"CREATOR {creator.creator_id} from {creator.service} on {creator.domain}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
posts = fetch_posts_by_creator(creator)
|
|
153
|
+
logging.info(
|
|
154
|
+
f"Found {len(posts)} posts from creator {creator.creator_id}({creator.service})"
|
|
155
|
+
)
|
|
156
|
+
posts_with_videos = parser.filter_posts_with_videos_from_json(
|
|
157
|
+
str(Config.cache_file(f"{creator.creator_id}_{creator.service}"))
|
|
158
|
+
)
|
|
159
|
+
logging.info(
|
|
160
|
+
f"Find {len(posts_with_videos)} posts with videos from creator {creator.creator_id}({creator.service})"
|
|
161
|
+
)
|
|
162
|
+
all_videos = parser.convert_posts_to_videos(posts_with_videos)
|
|
163
|
+
logging.info(f"Converted {len(posts_with_videos)} to {len(all_videos)} videos")
|
|
164
|
+
|
|
165
|
+
# put all videos in db
|
|
166
|
+
update_db(all_videos)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def download_videos_to_be_dl():
|
|
170
|
+
"""
|
|
171
|
+
Download videos of all creators in creators.json
|
|
172
|
+
"""
|
|
173
|
+
with DB() as d:
|
|
174
|
+
videos = d.get_pending_videos()
|
|
175
|
+
# VideoDownloader Engine
|
|
176
|
+
vd = VideoDownloader(total_dl=len(videos))
|
|
177
|
+
|
|
178
|
+
for video in videos:
|
|
179
|
+
creator = Creator(
|
|
180
|
+
creator_id=video.creator_id,
|
|
181
|
+
service=video.service,
|
|
182
|
+
domain=video.domain,
|
|
183
|
+
status=None,
|
|
184
|
+
)
|
|
185
|
+
succesful = vd.download(creator.domain, video)
|
|
186
|
+
if not succesful:
|
|
187
|
+
logging.warning("Fail to dl vid")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def discover(tag: str, max_page: int):
|
|
191
|
+
discover_creators(tag, max_page)
|
|
192
|
+
dl_video_from_discover_creators()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def discover_creators(tag: str, max_page: int):
|
|
196
|
+
# download posts with searched tags
|
|
197
|
+
posts = fetch_posts_by_tag(tag, max_page)
|
|
198
|
+
logging.info(f"Find {len(posts)} post")
|
|
199
|
+
|
|
200
|
+
path = str(Config.cache_file(tag))
|
|
201
|
+
posts_with_videos = parser.filter_posts_with_videos_from_json(path)
|
|
202
|
+
logging.info(f"Find {len(posts_with_videos)} posts with videos")
|
|
203
|
+
|
|
204
|
+
creators = parser.get_creators_from_posts(posts_with_videos)
|
|
205
|
+
|
|
206
|
+
# save to csv
|
|
207
|
+
file = os.path.join(Config.DISCOVER_DIR, "discover.csv")
|
|
208
|
+
with open(file, "w") as f:
|
|
209
|
+
for c in creators:
|
|
210
|
+
line = f"{c.creator_id};{c.service};{c.domain};{'to_be_treated'}\n"
|
|
211
|
+
f.write(line)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def dl_video_from_discover_creators():
|
|
215
|
+
# load csv
|
|
216
|
+
file = os.path.join(Config.DISCOVER_DIR, "discover.csv")
|
|
217
|
+
with open(file, "r") as f:
|
|
218
|
+
lines = f.readlines()
|
|
219
|
+
|
|
220
|
+
creators = []
|
|
221
|
+
for line in lines:
|
|
222
|
+
line = line.replace("\n", "").strip().split(";")
|
|
223
|
+
creators.append(
|
|
224
|
+
Creator(creator_id=line[0], service=line[1], domain=line[2], status=line[3])
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# get posts
|
|
228
|
+
for creator in creators:
|
|
229
|
+
response = requests.get(
|
|
230
|
+
URL.get_creator_post_wo_param(creator), headers=URL.get_headers()
|
|
231
|
+
)
|
|
232
|
+
if response.status_code != 200:
|
|
233
|
+
print(f"ERROR - Request {URL.get_creator_post_wo_param(creator)}")
|
|
234
|
+
response_posts = response.json()
|
|
235
|
+
posts = parser.filter_posts_with_videos_from_list(response_posts)
|
|
236
|
+
print(f"{len(posts)} found")
|
|
237
|
+
if len(posts) > 5:
|
|
238
|
+
posts = posts[0:5]
|
|
239
|
+
print("Limited posts to 5")
|
|
240
|
+
|
|
241
|
+
for post in posts:
|
|
242
|
+
urls = parser.extract_video_urls(post)
|
|
243
|
+
url = URL.get_url_from_file(creator.domain, urls[0])
|
|
244
|
+
filename = f"{post['user']}_{post['id']}.mp4"
|
|
245
|
+
filepath = os.path.join(Config.DISCOVER_DIR, filename)
|
|
246
|
+
ytdlp_subprocess(url, filepath)
|