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/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.downloader_subprocess import ffmpeg_concat
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
- def fuse_videos():
13
- """Fuse videos"""
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
- allowed_status = [
16
- VideoStatus.DOWNLOADED,
17
- VideoStatus.CONCAT_WIP,
18
- VideoStatus.CONCAT_FAILED,
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(VideoStatus.DOWNLOADED)
23
-
24
- # load db videos
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
- videos = db.query_videos(status=allowed_status, min_part_number=1)
27
-
28
- # get unique posts id
29
- posts_ids = set()
30
- for video in videos:
31
- posts_ids.add(video.post_id)
32
-
33
- with UI.progress_total_concat() as progress:
34
- task = progress.add_task("Total concat", total=len(posts_ids))
35
-
36
- for post_id in posts_ids:
37
- UI.info(f"Looking at post_id: {post_id}")
38
-
39
- # get all videos with same post_id
40
- with DB() as db:
41
- videos = db.query_videos(post_id=post_id)
42
- if not videos:
43
- UI.error("Query SQL Failed.")
44
- progress.update(task, advance=1)
45
- continue
46
-
47
- # check each videos of the same post is fully downloaded
48
- ok = True
49
- for video in videos:
50
- if video.status not in allowed_status:
51
- ok = False
52
- break
53
- if not ok:
54
- progress.update(task, advance=1)
55
- continue
56
-
57
- # sort by part number
58
- videos.sort(key=lambda v: int(v.part))
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
- # remove part video if concat OK
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 VideoStatus(Enum):
9
- NOT_DOWNLOADED = "not_downloaded"
10
- DOWNLOADED = "downloaded"
11
- FAILED = "failed"
12
- SKIPPED = "skipped"
13
- IGNORED = "ignored"
14
- REMOVED = "removed"
15
- CONCAT_WIP = "concat_wip" # concat in progress
16
- CONCAT_DONE = "concat_done"
17
- CONCAT_FAILED = "concat_failed"
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
- class DiscoverStatus(Enum):
21
- TO_BE_TREATED = "to_be_treated"
22
- DOWNLOADED = "downloaded"
23
- BLACKLISTED = "blacklisted"
24
- WHITELSITED = "whitelisted"
25
- DOWNLOAD_MORE = "download_more"
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 Creator:
30
- creator_id: str
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
- status: Optional[str]
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 Video:
38
- # important fields
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
- part: int = 0
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
- # status in cdl
55
- status: Optional[VideoStatus] = None
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()