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 +5 -0
- rcdl/__main__.py +15 -3
- rcdl/core/__init__.py +0 -0
- rcdl/core/adapters.py +241 -0
- rcdl/core/api.py +31 -9
- rcdl/core/config.py +133 -14
- rcdl/core/db.py +239 -191
- rcdl/core/db_queries.py +75 -44
- rcdl/core/downloader.py +184 -142
- rcdl/core/downloader_subprocess.py +261 -85
- rcdl/core/file_io.py +13 -6
- rcdl/core/fuse.py +115 -106
- rcdl/core/models.py +83 -34
- rcdl/core/opti.py +90 -0
- rcdl/core/parser.py +80 -78
- rcdl/gui/__init__.py +0 -0
- rcdl/gui/__main__.py +5 -0
- rcdl/gui/db_viewer.py +41 -0
- rcdl/gui/gui.py +54 -0
- rcdl/gui/video_manager.py +178 -0
- rcdl/interface/__init__.py +0 -0
- rcdl/interface/cli.py +100 -20
- rcdl/interface/ui.py +117 -116
- rcdl/utils.py +174 -5
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/METADATA +48 -15
- rcdl-3.0.0b23.dist-info/RECORD +28 -0
- rcdl/scripts/migrate_creators_json_txt.py +0 -37
- rcdl/scripts/migrate_old_format_to_db.py +0 -188
- rcdl/scripts/upload_pypi.py +0 -98
- rcdl-2.2.2.dist-info/RECORD +0 -22
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/WHEEL +0 -0
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/entry_points.txt +0 -0
rcdl/core/fuse.py
CHANGED
|
@@ -1,118 +1,127 @@
|
|
|
1
1
|
# core/fuse.py
|
|
2
2
|
|
|
3
|
+
"""Handle merging videos from a same post"""
|
|
4
|
+
|
|
3
5
|
import os
|
|
6
|
+
import subprocess
|
|
4
7
|
|
|
5
|
-
from rcdl.interface.ui import UI
|
|
6
|
-
from rcdl.core.db import DB
|
|
7
|
-
from rcdl.core.models import VideoStatus
|
|
8
8
|
from rcdl.core.config import Config
|
|
9
|
-
from rcdl.core.
|
|
10
|
-
|
|
9
|
+
from rcdl.core.db import DB
|
|
10
|
+
from rcdl.core.models import FusedStatus, Status, FusedMedia, Media, Post
|
|
11
|
+
from rcdl.interface.ui import UI, NestedProgress
|
|
12
|
+
import rcdl.core.downloader_subprocess as dls
|
|
13
|
+
from rcdl.utils import get_media_metadata, get_date_now
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def update_db(fuse: FusedMedia, medias: list[Media], user: str, result):
|
|
17
|
+
"""Update DB depending on subprocess result (SUCESS/FAILURE)"""
|
|
18
|
+
if result == 0:
|
|
19
|
+
path = os.path.join(Config.creator_folder(user), fuse.file_path)
|
|
20
|
+
duration, file_size, checksum = get_media_metadata(path)
|
|
21
|
+
fuse.duration = duration
|
|
22
|
+
fuse.status = FusedStatus.FUSED
|
|
23
|
+
fuse.checksum = checksum
|
|
24
|
+
fuse.created_at = get_date_now()
|
|
25
|
+
fuse.file_size = file_size
|
|
26
|
+
for media in medias:
|
|
27
|
+
media.status = Status.FUSED
|
|
28
|
+
else:
|
|
29
|
+
fuse.fail_count += 1
|
|
30
|
+
with DB() as db:
|
|
31
|
+
db.update_fuse(fuse)
|
|
32
|
+
for media in medias:
|
|
33
|
+
db.update_media(media)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_medias_and_post(
|
|
37
|
+
post_id: str, total_parts: int
|
|
38
|
+
) -> tuple[None, None] | tuple[list[Media], Post]:
|
|
39
|
+
"""Get medias and post related to a fuse group.
|
|
40
|
+
Return a list[Media] and a Post
|
|
41
|
+
Handle Errors, return None, None"""
|
|
42
|
+
# get associated post
|
|
43
|
+
with DB() as db:
|
|
44
|
+
post = db.query_post_by_id(post_id)
|
|
45
|
+
if post is None:
|
|
46
|
+
UI.error(f"Could not match fuses post id {post_id} to a post in post tables")
|
|
47
|
+
return None, None
|
|
11
48
|
|
|
12
|
-
|
|
13
|
-
|
|
49
|
+
# get all videos of a post
|
|
50
|
+
with DB() as db:
|
|
51
|
+
medias = db.query_media_by_post_id(post_id)
|
|
14
52
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
]
|
|
53
|
+
# check number of media in db match total part expected in fused media
|
|
54
|
+
if len(medias) != total_parts:
|
|
55
|
+
UI.error(f"Found {len(medias)} videos part. Expected {total_parts}")
|
|
56
|
+
return None, None
|
|
20
57
|
|
|
58
|
+
# check all video are downloaded
|
|
59
|
+
allowed_status = [Status.DOWNLOADED, Status.OPTIMIZED]
|
|
21
60
|
if Config.DEBUG:
|
|
22
|
-
allowed_status.append(
|
|
23
|
-
|
|
24
|
-
|
|
61
|
+
allowed_status.append(Status.FUSED)
|
|
62
|
+
ok = True
|
|
63
|
+
for media in medias:
|
|
64
|
+
if media.status not in allowed_status:
|
|
65
|
+
ok = False
|
|
66
|
+
break
|
|
67
|
+
if not ok:
|
|
68
|
+
return None, None
|
|
69
|
+
|
|
70
|
+
# sort medias list
|
|
71
|
+
sorted_medias = sorted(medias, key=lambda m: m.sequence)
|
|
72
|
+
return sorted_medias, post
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fuse_medias():
|
|
76
|
+
"""Fuse all media part of a fuse group with status PENDING in DB fuses"""
|
|
77
|
+
# get all fused media
|
|
25
78
|
with DB() as db:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
ok = True
|
|
61
|
-
for video in videos:
|
|
62
|
-
# make sure video exist
|
|
63
|
-
path = os.path.join(
|
|
64
|
-
Config.creator_folder(video.creator_id), video.relative_path
|
|
65
|
-
)
|
|
66
|
-
if not os.path.exists(path):
|
|
67
|
-
UI.error(f"Video @ {path} does not exists")
|
|
68
|
-
ok = False
|
|
69
|
-
|
|
70
|
-
# if status is concat WIP, these should not be possible
|
|
71
|
-
if video.status == VideoStatus.CONCAT_WIP:
|
|
72
|
-
UI.warning(
|
|
73
|
-
f"Video '{video.relative_path}' has status 'CONCAT_WIP'. This is a bug and should not be possible."
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
# update videos status in db to CONCAT_WIP
|
|
77
|
-
# in case of problems in the scripts, we will know
|
|
78
|
-
with DB() as db:
|
|
79
|
-
for video in videos:
|
|
80
|
-
db.set_status(video, VideoStatus.CONCAT_WIP)
|
|
81
|
-
|
|
82
|
-
result = 1
|
|
83
|
-
try:
|
|
84
|
-
result = ffmpeg_concat(videos)
|
|
85
|
-
except Exception as e:
|
|
86
|
-
UI.error(f"Failed concat due to: {e}")
|
|
87
|
-
|
|
88
|
-
# concat failed
|
|
89
|
-
if not result == 0:
|
|
90
|
-
with DB() as db:
|
|
91
|
-
for video in videos:
|
|
92
|
-
db.set_status(video, VideoStatus.CONCAT_FAILED)
|
|
93
|
-
continue
|
|
94
|
-
|
|
95
|
-
# concat succeeded
|
|
96
|
-
with DB() as db:
|
|
97
|
-
for video in videos:
|
|
98
|
-
db.set_status(video, VideoStatus.CONCAT_DONE)
|
|
99
|
-
|
|
100
|
-
# update progress bar
|
|
101
|
-
progress.update(
|
|
102
|
-
task,
|
|
103
|
-
advance=1,
|
|
104
|
-
description=f"Concated videos for post id {post_id}",
|
|
79
|
+
fuses = db.query_fuses_by_status(FusedStatus.PENDING)
|
|
80
|
+
if Config.DEBUG:
|
|
81
|
+
with DB() as db:
|
|
82
|
+
ok_fuses = db.query_fuses_by_status(FusedStatus.FUSED)
|
|
83
|
+
fuses.extend(ok_fuses)
|
|
84
|
+
|
|
85
|
+
progress = NestedProgress(UI.console)
|
|
86
|
+
progress.start(
|
|
87
|
+
total=len(fuses), total_label="Fusing videos", current_label="Current fuse"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for fm in fuses:
|
|
91
|
+
medias, post = get_medias_and_post(fm.id, fm.total_parts)
|
|
92
|
+
if medias is None or post is None:
|
|
93
|
+
progress.advance_total()
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# concat medias
|
|
97
|
+
result = 1
|
|
98
|
+
try:
|
|
99
|
+
result = dls.ffmpeg_concat(medias, post, progress)
|
|
100
|
+
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
|
101
|
+
UI.error(f"Failed to concat video (id:{post.id}) due to: {e}")
|
|
102
|
+
|
|
103
|
+
# update db
|
|
104
|
+
update_db(fm, medias, post.user, result)
|
|
105
|
+
|
|
106
|
+
progress.advance_total()
|
|
107
|
+
|
|
108
|
+
# remove part file
|
|
109
|
+
for media in medias:
|
|
110
|
+
media_full_path = os.path.join(
|
|
111
|
+
Config.creator_folder(post.user), media.file_path
|
|
105
112
|
)
|
|
113
|
+
try:
|
|
114
|
+
if Config.DEBUG:
|
|
115
|
+
UI.info(f"Skipped '{media_full_path}' removal")
|
|
116
|
+
continue
|
|
117
|
+
os.remove(media_full_path)
|
|
118
|
+
UI.info(f"Removed file '{media_full_path}'")
|
|
119
|
+
except (FileNotFoundError, PermissionError) as e:
|
|
120
|
+
UI.error(
|
|
121
|
+
f"FileNotFound/PermissionError: Failed to "
|
|
122
|
+
f"remove media '{media_full_path}' due to: {e}"
|
|
123
|
+
)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
UI.error(f"Failed to remove media '{media_full_path}' due to: {e}")
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
with DB() as db:
|
|
109
|
-
for video in videos:
|
|
110
|
-
path = os.path.join(
|
|
111
|
-
Config.creator_folder(video.creator_id), video.relative_path
|
|
112
|
-
)
|
|
113
|
-
try:
|
|
114
|
-
os.remove(path)
|
|
115
|
-
UI.info(f"Removed {path}")
|
|
116
|
-
db.set_status(video, VideoStatus.REMOVED)
|
|
117
|
-
except Exception as e:
|
|
118
|
-
UI.error(f"Failed to remove {path} due to error: {e}")
|
|
127
|
+
progress.close()
|
rcdl/core/models.py
CHANGED
|
@@ -1,56 +1,105 @@
|
|
|
1
1
|
# core/models.py
|
|
2
2
|
|
|
3
|
+
"""Hold all dataclass models and enums"""
|
|
4
|
+
|
|
3
5
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Optional
|
|
5
6
|
from enum import Enum
|
|
6
7
|
|
|
7
8
|
|
|
8
|
-
class
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
class Status(Enum):
|
|
10
|
+
"""Status for media"""
|
|
11
|
+
|
|
12
|
+
PENDING = "pending" # to be downloaded
|
|
13
|
+
DOWNLOADED = "downloaded" # video has been downloaded
|
|
14
|
+
FUSED = "fused" # video has been fused, and impliitly removed
|
|
15
|
+
TO_BE_DELETED = "to_be_delete" # video has been marked for delete
|
|
16
|
+
DELETED = "deleted" # video has been deleted
|
|
17
|
+
OPTIMIZED = "optimized" # video has been optimized (reduce file size)
|
|
18
|
+
|
|
18
19
|
|
|
20
|
+
class FusedStatus(Enum):
|
|
21
|
+
"""Status for fused group"""
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
PENDING = "pending"
|
|
24
|
+
FUSED = "fused"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CreatorStatus(Enum):
|
|
28
|
+
FAVORITED = "FAVORITED"
|
|
29
|
+
NA = "NA"
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
@dataclass
|
|
29
|
-
class
|
|
30
|
-
|
|
33
|
+
class Post:
|
|
34
|
+
"""Post model that shadow post dict response of request
|
|
35
|
+
Partially used in posts db (check db_queries.py)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
user: str
|
|
31
40
|
service: str
|
|
32
41
|
domain: str
|
|
33
|
-
|
|
42
|
+
title: str
|
|
43
|
+
substring: str
|
|
44
|
+
published: str
|
|
45
|
+
file: dict
|
|
46
|
+
attachments: list
|
|
47
|
+
json_hash: str
|
|
48
|
+
raw_json: str
|
|
49
|
+
fetched_at: str
|
|
34
50
|
|
|
35
51
|
|
|
36
52
|
@dataclass
|
|
37
|
-
class
|
|
38
|
-
|
|
53
|
+
class Media:
|
|
54
|
+
"""Media model: use in medias DB"""
|
|
55
|
+
|
|
39
56
|
post_id: str
|
|
40
|
-
creator_id: str
|
|
41
57
|
service: str
|
|
42
|
-
domain: str
|
|
43
|
-
relative_path: str
|
|
44
58
|
url: str
|
|
45
|
-
|
|
59
|
+
duration: float
|
|
60
|
+
sequence: int
|
|
61
|
+
status: Status
|
|
62
|
+
checksum: str
|
|
63
|
+
file_path: str
|
|
64
|
+
created_at: str
|
|
65
|
+
updated_at: str
|
|
66
|
+
file_size: int
|
|
67
|
+
fail_count: int = 0
|
|
46
68
|
|
|
47
|
-
# metadata
|
|
48
|
-
published: Optional[str] = None
|
|
49
|
-
title: Optional[str] = None
|
|
50
|
-
substring: Optional[str] = None
|
|
51
|
-
downloaded_at: Optional[str] = None
|
|
52
|
-
file_size: Optional[float] = None
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
@dataclass
|
|
71
|
+
class FusedMedia:
|
|
72
|
+
"""Fuses group models.
|
|
73
|
+
Used in fuses db."""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
duration: int
|
|
77
|
+
total_parts: int
|
|
78
|
+
status: FusedStatus
|
|
79
|
+
checksum: str
|
|
80
|
+
file_path: str
|
|
81
|
+
created_at: str
|
|
82
|
+
updated_at: str
|
|
83
|
+
file_size: int
|
|
56
84
|
fail_count: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Creator:
|
|
89
|
+
"""Creator model"""
|
|
90
|
+
|
|
91
|
+
id: str
|
|
92
|
+
name: str
|
|
93
|
+
service: str
|
|
94
|
+
domain: str
|
|
95
|
+
indexed: str
|
|
96
|
+
updated: str
|
|
97
|
+
favorited: int
|
|
98
|
+
|
|
99
|
+
status: CreatorStatus
|
|
100
|
+
|
|
101
|
+
# param
|
|
102
|
+
max_size: int
|
|
103
|
+
max_posts: int
|
|
104
|
+
min_date: str
|
|
105
|
+
max_date: str
|
rcdl/core/opti.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# core/opti.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Optimize media to reduce disk storage utilisation
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from rcdl.core.config import Config
|
|
10
|
+
from rcdl.core.models import Status, Media
|
|
11
|
+
from rcdl.core.db import DB
|
|
12
|
+
from rcdl.core.downloader_subprocess import handbrake_optimized
|
|
13
|
+
from rcdl.interface.ui import UI, NestedProgress
|
|
14
|
+
from rcdl.utils import get_media_metadata, get_date_now
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def update_db(media: Media, user: str, result: int):
|
|
18
|
+
"""Update DB if optimisation succesfful with new file_size, etc..."""
|
|
19
|
+
if result == 0:
|
|
20
|
+
path = os.path.join(Config.creator_folder(user), media.file_path)
|
|
21
|
+
_, file_size, checksum = get_media_metadata(path)
|
|
22
|
+
media.status = Status.OPTIMIZED
|
|
23
|
+
media.checksum = checksum
|
|
24
|
+
media.created_at = get_date_now()
|
|
25
|
+
media.file_size = file_size
|
|
26
|
+
|
|
27
|
+
with DB() as db:
|
|
28
|
+
db.update_media(media)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def optimize():
|
|
32
|
+
"""Optimize all medias in DB with DOWNLOADED
|
|
33
|
+
status that are not part of a fuse group"""
|
|
34
|
+
# get all video to opti
|
|
35
|
+
with DB() as db:
|
|
36
|
+
medias = db.query_media_by_status(Status.DOWNLOADED)
|
|
37
|
+
if Config.DEBUG:
|
|
38
|
+
medias.extend(db.query_media_by_status(Status.OPTIMIZED))
|
|
39
|
+
|
|
40
|
+
# progress
|
|
41
|
+
progress = NestedProgress(UI.console)
|
|
42
|
+
progress.start(
|
|
43
|
+
total=len(medias),
|
|
44
|
+
total_label="Optimizing videos",
|
|
45
|
+
current_label="Current video",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for media in medias:
|
|
49
|
+
# check media is not in a fuse group
|
|
50
|
+
with DB() as db:
|
|
51
|
+
fuse = db.query_fuses_by_id(media.post_id)
|
|
52
|
+
if fuse is not None:
|
|
53
|
+
progress.advance_total()
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# get post info
|
|
57
|
+
with DB() as db:
|
|
58
|
+
post = db.query_post_by_id(media.post_id)
|
|
59
|
+
if post is None:
|
|
60
|
+
UI.error(f"Could not match media {media.post_id} to a post by id")
|
|
61
|
+
progress.advance_total()
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
result = handbrake_optimized(media, post.user, progress)
|
|
65
|
+
|
|
66
|
+
folder_path = Config.creator_folder(post.user)
|
|
67
|
+
video_path = os.path.join(folder_path, media.file_path)
|
|
68
|
+
output_path = video_path + ".opti.mp4"
|
|
69
|
+
|
|
70
|
+
if result == 0:
|
|
71
|
+
try:
|
|
72
|
+
os.replace(output_path, video_path)
|
|
73
|
+
update_db(media, post.user, result)
|
|
74
|
+
except FileNotFoundError as e:
|
|
75
|
+
UI.error(
|
|
76
|
+
f"FileNotFoundError: Could not replace {video_path} "
|
|
77
|
+
f"with {output_path} due to: {e}"
|
|
78
|
+
)
|
|
79
|
+
except PermissionError as e:
|
|
80
|
+
UI.error(
|
|
81
|
+
f"PermissionError: Could not replace {video_path} "
|
|
82
|
+
f"with {output_path} due to: {e}"
|
|
83
|
+
)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
UI.error(
|
|
86
|
+
f"OSError: Failed to replace {video_path} with {output_path} due to: {e}"
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
progress.advance_total()
|
|
90
|
+
progress.close()
|