QTube 2.1.1__py3-none-any.whl → 2.3.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.
- QTube/scripts/qtube.py +261 -56
- QTube/utils/checks.py +28 -7
- QTube/utils/helpers.py +64 -6
- QTube/utils/parsing.py +56 -5
- QTube/utils/youtube/captions.py +3 -1
- QTube/utils/youtube/channels.py +10 -4
- QTube/utils/youtube/playlists.py +30 -4
- QTube/utils/youtube/videos.py +197 -11
- {QTube-2.1.1.dist-info → QTube-2.3.0.dist-info}/METADATA +27 -1
- QTube-2.3.0.dist-info/RECORD +17 -0
- QTube-2.1.1.dist-info/RECORD +0 -17
- {QTube-2.1.1.dist-info → QTube-2.3.0.dist-info}/LICENSE.txt +0 -0
- {QTube-2.1.1.dist-info → QTube-2.3.0.dist-info}/WHEEL +0 -0
- {QTube-2.1.1.dist-info → QTube-2.3.0.dist-info}/entry_points.txt +0 -0
- {QTube-2.1.1.dist-info → QTube-2.3.0.dist-info}/top_level.txt +0 -0
QTube/scripts/qtube.py
CHANGED
|
@@ -23,31 +23,6 @@ import QTube.utils.youtube.videos
|
|
|
23
23
|
|
|
24
24
|
def main():
|
|
25
25
|
"""Checks Youtube for new videos and add a selection of these videos to a playlist, based on user defined parameters."""
|
|
26
|
-
### Software version checking
|
|
27
|
-
version, latest_release = QTube.utils.checks.check_version()
|
|
28
|
-
latest_url = "https://github.com/Killian42/QTube/releases/latest"
|
|
29
|
-
|
|
30
|
-
if latest_release is None:
|
|
31
|
-
print("Failed to check the latest release version:\n")
|
|
32
|
-
else:
|
|
33
|
-
comp = QTube.utils.checks.compare_software_versions(version, latest_release)
|
|
34
|
-
if comp == "same":
|
|
35
|
-
print(
|
|
36
|
-
f"The latest stable version of the software, v{version}, is currently runnning.\n"
|
|
37
|
-
)
|
|
38
|
-
elif comp == "older":
|
|
39
|
-
print(
|
|
40
|
-
f"You are currently running version v{version}.\nConsider upgrading to the latest stable release (v{latest_release}) at {latest_url}.\n"
|
|
41
|
-
)
|
|
42
|
-
elif comp == "newer":
|
|
43
|
-
print(
|
|
44
|
-
f"You are currently running version {version}.\nThis version is not a stable release. Consider installing the latest stable release ({latest_release}) at {latest_url}.\n"
|
|
45
|
-
)
|
|
46
|
-
elif comp == "pre-release":
|
|
47
|
-
print(
|
|
48
|
-
f"You are currently running version v{version}.\nThis is a pre-release version. Consider installing the latest stable release (v{latest_release}) at {latest_url}.\n"
|
|
49
|
-
)
|
|
50
|
-
|
|
51
26
|
### User parameters loading
|
|
52
27
|
## JSON parameters file opening
|
|
53
28
|
try:
|
|
@@ -74,14 +49,90 @@ def main():
|
|
|
74
49
|
|
|
75
50
|
## Parameters checking
|
|
76
51
|
if QTube.utils.checks.check_user_params(user_params_dict) is not True:
|
|
77
|
-
print(
|
|
52
|
+
print(
|
|
53
|
+
"User defined parameters are not correctly formatted. Check the template and retry."
|
|
54
|
+
)
|
|
78
55
|
sys.exit()
|
|
79
|
-
else:
|
|
80
|
-
print("The user defined parameters are correctly formatted.\n")
|
|
81
56
|
|
|
82
|
-
## Verbosity options
|
|
57
|
+
## Verbosity and fancy text options loading
|
|
58
|
+
fancy = user_params_dict["fancy_mode"]
|
|
83
59
|
verb = user_params_dict["verbosity"]
|
|
84
|
-
|
|
60
|
+
|
|
61
|
+
### Software version checking
|
|
62
|
+
version, latest_release = QTube.utils.checks.check_version()
|
|
63
|
+
latest_url = "https://github.com/Killian42/QTube/releases/latest"
|
|
64
|
+
|
|
65
|
+
QTube.utils.helpers.print2(
|
|
66
|
+
f"QTube v{version}\n", fancy, "info", ["internal"], ["internal"]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if latest_release is None:
|
|
70
|
+
QTube.utils.helpers.print2(
|
|
71
|
+
"Failed to check the latest release version:\n",
|
|
72
|
+
fancy,
|
|
73
|
+
"fail",
|
|
74
|
+
["internal"],
|
|
75
|
+
["internal"],
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
comp = QTube.utils.checks.compare_software_versions(version, latest_release)
|
|
79
|
+
if comp == "same":
|
|
80
|
+
QTube.utils.helpers.print2(
|
|
81
|
+
f"The latest stable version of the software, v{version}, is currently runnning.\n",
|
|
82
|
+
fancy,
|
|
83
|
+
"success",
|
|
84
|
+
["internal"],
|
|
85
|
+
["internal"],
|
|
86
|
+
)
|
|
87
|
+
elif comp == "older":
|
|
88
|
+
QTube.utils.helpers.print2(
|
|
89
|
+
f"You are currently running version v{version}.\nConsider upgrading to the latest stable release (v{latest_release}) at {latest_url}.\n",
|
|
90
|
+
fancy,
|
|
91
|
+
"warning",
|
|
92
|
+
["internal"],
|
|
93
|
+
["internal"],
|
|
94
|
+
)
|
|
95
|
+
elif comp == "newer":
|
|
96
|
+
QTube.utils.helpers.print2(
|
|
97
|
+
f"You are currently running version {version}.\nThis version is not a stable release. Consider installing the latest stable release ({latest_release}) at {latest_url}.\n",
|
|
98
|
+
fancy,
|
|
99
|
+
"warning",
|
|
100
|
+
["internal"],
|
|
101
|
+
["internal"],
|
|
102
|
+
)
|
|
103
|
+
elif comp == "pre-release":
|
|
104
|
+
QTube.utils.helpers.print2(
|
|
105
|
+
f"You are currently running version v{version}.\nThis is a pre-release version. Consider installing the latest stable release (v{latest_release}) at {latest_url}.\n",
|
|
106
|
+
fancy,
|
|
107
|
+
"warning",
|
|
108
|
+
["internal"],
|
|
109
|
+
["internal"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
## Verbosity and fancy text options displaying
|
|
113
|
+
if fancy:
|
|
114
|
+
QTube.utils.helpers.print2(
|
|
115
|
+
f"The fancy text option is enabled.",
|
|
116
|
+
fancy,
|
|
117
|
+
"info",
|
|
118
|
+
["internal"],
|
|
119
|
+
["internal"],
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
QTube.utils.helpers.print2(
|
|
123
|
+
f"The fancy text option is disabled.",
|
|
124
|
+
fancy,
|
|
125
|
+
"info",
|
|
126
|
+
["internal"],
|
|
127
|
+
["internal"],
|
|
128
|
+
)
|
|
129
|
+
QTube.utils.helpers.print2(
|
|
130
|
+
f"The following verbosity options are enabled: {verb}.\n",
|
|
131
|
+
fancy,
|
|
132
|
+
"info",
|
|
133
|
+
["internal"],
|
|
134
|
+
["internal"],
|
|
135
|
+
)
|
|
85
136
|
|
|
86
137
|
### Youtube API login
|
|
87
138
|
credentials = None
|
|
@@ -89,30 +140,46 @@ def main():
|
|
|
89
140
|
## token.pickle stores the user's credentials from previously successful logins
|
|
90
141
|
if os.path.exists("token.pickle"):
|
|
91
142
|
QTube.utils.helpers.print2(
|
|
92
|
-
"Loading credentials from pickle file...",
|
|
143
|
+
"Loading credentials from pickle file...",
|
|
144
|
+
fancy,
|
|
145
|
+
"info",
|
|
146
|
+
["all", "credentials"],
|
|
147
|
+
verb,
|
|
93
148
|
)
|
|
94
149
|
|
|
95
150
|
with open("token.pickle", "rb") as token:
|
|
96
151
|
credentials = pickle.load(token)
|
|
97
152
|
|
|
98
153
|
QTube.utils.helpers.print2(
|
|
99
|
-
"Credentials loaded from pickle file",
|
|
154
|
+
"Credentials loaded from pickle file",
|
|
155
|
+
fancy,
|
|
156
|
+
"success",
|
|
157
|
+
["all", "credentials"],
|
|
158
|
+
verb,
|
|
100
159
|
)
|
|
101
160
|
|
|
102
161
|
## If there are no valid credentials available, then either refresh the token or log in.
|
|
103
162
|
if not credentials or not credentials.valid:
|
|
104
163
|
if credentials and credentials.expired and credentials.refresh_token:
|
|
105
164
|
QTube.utils.helpers.print2(
|
|
106
|
-
"Refreshing access token...",
|
|
165
|
+
"Refreshing access token...",
|
|
166
|
+
fancy,
|
|
167
|
+
"info",
|
|
168
|
+
["all", "credentials"],
|
|
169
|
+
verb,
|
|
107
170
|
)
|
|
108
171
|
|
|
109
172
|
credentials.refresh(Request())
|
|
110
173
|
QTube.utils.helpers.print2(
|
|
111
|
-
"Access token refreshed\n",
|
|
174
|
+
"Access token refreshed\n",
|
|
175
|
+
fancy,
|
|
176
|
+
"success",
|
|
177
|
+
["all", "credentials"],
|
|
178
|
+
verb,
|
|
112
179
|
)
|
|
113
180
|
else:
|
|
114
181
|
QTube.utils.helpers.print2(
|
|
115
|
-
"Fetching New Tokens...", ["all", "credentials"], verb
|
|
182
|
+
"Fetching New Tokens...", fancy, "info", ["all", "credentials"], verb
|
|
116
183
|
)
|
|
117
184
|
flow = InstalledAppFlow.from_client_secrets_file(
|
|
118
185
|
"client_secrets.json",
|
|
@@ -129,18 +196,26 @@ def main():
|
|
|
129
196
|
credentials = flow.credentials
|
|
130
197
|
|
|
131
198
|
QTube.utils.helpers.print2(
|
|
132
|
-
"New token fetched\n", ["all", "credentials"], verb
|
|
199
|
+
"New token fetched\n", fancy, "success", ["all", "credentials"], verb
|
|
133
200
|
)
|
|
134
201
|
|
|
135
202
|
# Save the credentials for the next run
|
|
136
203
|
with open("token.pickle", "wb") as f:
|
|
137
204
|
QTube.utils.helpers.print2(
|
|
138
|
-
"Saving Credentials for Future Use...",
|
|
205
|
+
"Saving Credentials for Future Use...",
|
|
206
|
+
fancy,
|
|
207
|
+
"info",
|
|
208
|
+
["all", "credentials"],
|
|
209
|
+
verb,
|
|
139
210
|
)
|
|
140
211
|
|
|
141
212
|
pickle.dump(credentials, f)
|
|
142
213
|
QTube.utils.helpers.print2(
|
|
143
|
-
"Credentials saved\n",
|
|
214
|
+
"Credentials saved\n",
|
|
215
|
+
fancy,
|
|
216
|
+
"success",
|
|
217
|
+
["all", "credentials"],
|
|
218
|
+
verb,
|
|
144
219
|
)
|
|
145
220
|
|
|
146
221
|
### Building API resource
|
|
@@ -151,16 +226,21 @@ def main():
|
|
|
151
226
|
## Checking the playlist ID
|
|
152
227
|
playlist_ID = user_params_dict["upload_playlist_ID"]
|
|
153
228
|
user_info = QTube.utils.helpers.handle_http_errors(
|
|
154
|
-
verb, QTube.utils.youtube.channels.get_user_info, youtube
|
|
229
|
+
verb, fancy, QTube.utils.youtube.channels.get_user_info, youtube
|
|
155
230
|
)
|
|
156
231
|
if not QTube.utils.helpers.handle_http_errors(
|
|
157
|
-
verb,
|
|
232
|
+
verb,
|
|
233
|
+
fancy,
|
|
234
|
+
QTube.utils.checks.check_playlist_id,
|
|
235
|
+
youtube,
|
|
236
|
+
user_info,
|
|
237
|
+
playlist_ID,
|
|
158
238
|
):
|
|
159
239
|
sys.exit()
|
|
160
240
|
|
|
161
241
|
## Dictionnary of subscribed channels names and IDs
|
|
162
242
|
subbed_channels_info = QTube.utils.helpers.handle_http_errors(
|
|
163
|
-
verb, QTube.utils.youtube.channels.get_subscriptions, youtube
|
|
243
|
+
verb, fancy, QTube.utils.youtube.channels.get_subscriptions, youtube
|
|
164
244
|
)
|
|
165
245
|
|
|
166
246
|
## Dictionnary of extra channels names and IDs
|
|
@@ -219,6 +299,7 @@ def main():
|
|
|
219
299
|
for sub_dict in split_channels:
|
|
220
300
|
partial = QTube.utils.helpers.handle_http_errors(
|
|
221
301
|
verb,
|
|
302
|
+
fancy,
|
|
222
303
|
QTube.utils.youtube.channels.get_uploads_playlists,
|
|
223
304
|
youtube,
|
|
224
305
|
list(sub_dict.values()),
|
|
@@ -230,12 +311,20 @@ def main():
|
|
|
230
311
|
recent_videos = {}
|
|
231
312
|
for ch_name, playlist_Id in wanted_channels_upload_playlists.items():
|
|
232
313
|
latest_partial = QTube.utils.helpers.handle_http_errors(
|
|
233
|
-
verb,
|
|
314
|
+
verb,
|
|
315
|
+
fancy,
|
|
316
|
+
QTube.utils.youtube.playlists.get_recent_videos,
|
|
317
|
+
youtube,
|
|
318
|
+
playlist_Id,
|
|
234
319
|
)
|
|
235
320
|
|
|
236
321
|
if latest_partial == "ignore":
|
|
237
322
|
QTube.utils.helpers.print2(
|
|
238
|
-
f"Channel {ch_name} has no public videos.",
|
|
323
|
+
f"Channel {ch_name} has no public videos.",
|
|
324
|
+
fancy,
|
|
325
|
+
"warning",
|
|
326
|
+
["all", "func"],
|
|
327
|
+
verb,
|
|
239
328
|
)
|
|
240
329
|
continue
|
|
241
330
|
|
|
@@ -282,6 +371,7 @@ def main():
|
|
|
282
371
|
for sub_dict in split_videos:
|
|
283
372
|
partial = QTube.utils.helpers.handle_http_errors(
|
|
284
373
|
verb,
|
|
374
|
+
fancy,
|
|
285
375
|
QTube.utils.youtube.videos.make_video_requests,
|
|
286
376
|
youtube,
|
|
287
377
|
sub_dict.keys(),
|
|
@@ -320,6 +410,25 @@ def main():
|
|
|
320
410
|
# Live status retrieving
|
|
321
411
|
live_statuses = QTube.utils.youtube.videos.is_live(response=responses)
|
|
322
412
|
|
|
413
|
+
# View counts retrieving
|
|
414
|
+
view_counts = QTube.utils.youtube.videos.get_view_counts(response=responses)
|
|
415
|
+
|
|
416
|
+
# Like counts retrieving
|
|
417
|
+
like_counts = QTube.utils.youtube.videos.get_like_counts(response=responses)
|
|
418
|
+
|
|
419
|
+
# Comment counts retrieving
|
|
420
|
+
comment_counts = QTube.utils.youtube.videos.get_comment_counts(response=responses)
|
|
421
|
+
|
|
422
|
+
# Likes/views ratio retrieving
|
|
423
|
+
likes_to_views_ratio = QTube.utils.youtube.videos.get_likes_to_views_ratio(
|
|
424
|
+
like_counts, view_counts
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Comments/views ratio retrieving
|
|
428
|
+
comments_to_views_ratio = QTube.utils.youtube.videos.get_likes_to_views_ratio(
|
|
429
|
+
comment_counts, view_counts
|
|
430
|
+
)
|
|
431
|
+
|
|
323
432
|
# Resolutions retrieving (does not use YT API)
|
|
324
433
|
lowest_resolution = user_params_dict.get("lowest_resolution")
|
|
325
434
|
if lowest_resolution is not None:
|
|
@@ -339,6 +448,7 @@ def main():
|
|
|
339
448
|
if need_captions:
|
|
340
449
|
captions_responses = QTube.utils.helpers.handle_http_errors(
|
|
341
450
|
verb,
|
|
451
|
+
fancy,
|
|
342
452
|
QTube.utils.youtube.captions.make_caption_requests,
|
|
343
453
|
youtube,
|
|
344
454
|
videos.keys(),
|
|
@@ -378,6 +488,21 @@ def main():
|
|
|
378
488
|
# Live statuses
|
|
379
489
|
vid_info.update({"live status": live_statuses[index]})
|
|
380
490
|
|
|
491
|
+
# Views
|
|
492
|
+
vid_info.update({"views": view_counts[index]})
|
|
493
|
+
|
|
494
|
+
# Likes
|
|
495
|
+
vid_info.update({"likes": like_counts[index]})
|
|
496
|
+
|
|
497
|
+
# Comments
|
|
498
|
+
vid_info.update({"comments": comment_counts[index]})
|
|
499
|
+
|
|
500
|
+
# Likes/views
|
|
501
|
+
vid_info.update({"likes_to_views_ratio": likes_to_views_ratio[index]})
|
|
502
|
+
|
|
503
|
+
# Comments/views
|
|
504
|
+
vid_info.update({"comments_to_views_ratio": comments_to_views_ratio[index]})
|
|
505
|
+
|
|
381
506
|
# Resolutions
|
|
382
507
|
if lowest_resolution is not None:
|
|
383
508
|
vid_info.update({"resolutions": resolutions[index]})
|
|
@@ -452,6 +577,12 @@ def main():
|
|
|
452
577
|
lowest_definition = user_params_dict.get("lowest_definition")
|
|
453
578
|
preferred_dimensions = user_params_dict.get("preferred_dimensions")
|
|
454
579
|
|
|
580
|
+
views_threshold = user_params_dict.get("views_threshold")
|
|
581
|
+
likes_threshold = user_params_dict.get("likes_threshold")
|
|
582
|
+
comments_threshold = user_params_dict.get("comments_threshold")
|
|
583
|
+
likes_to_views_ratio_threshold = user_params_dict.get("likes_to_views_ratio")
|
|
584
|
+
comments_to_views_ratio_threshold = user_params_dict.get("comments_to_views_ratio")
|
|
585
|
+
|
|
455
586
|
# Duration filtering
|
|
456
587
|
if min_max_durations is not None:
|
|
457
588
|
for vid_info in videos.values():
|
|
@@ -680,6 +811,43 @@ def main():
|
|
|
680
811
|
):
|
|
681
812
|
vid_info.update({"to add": False})
|
|
682
813
|
|
|
814
|
+
# Views filtering
|
|
815
|
+
if views_threshold > 0:
|
|
816
|
+
for vid_ID, vid_info in videos.items():
|
|
817
|
+
if vid_info["to add"] and vid_info["views"] < views_threshold:
|
|
818
|
+
vid_info.update({"to add": False})
|
|
819
|
+
|
|
820
|
+
# Likes Filtering
|
|
821
|
+
if likes_threshold > 0:
|
|
822
|
+
for vid_ID, vid_info in videos.items():
|
|
823
|
+
if vid_info["to add"] and vid_info["likes"] < likes_threshold:
|
|
824
|
+
vid_info.update({"to add": False})
|
|
825
|
+
|
|
826
|
+
# Comments filtering
|
|
827
|
+
if comments_threshold > 0:
|
|
828
|
+
for vid_ID, vid_info in videos.items():
|
|
829
|
+
if vid_info["to add"] and vid_info["comments"] < comments_threshold:
|
|
830
|
+
vid_info.update({"to add": False})
|
|
831
|
+
|
|
832
|
+
# Likes/views ratio filtering
|
|
833
|
+
if likes_to_views_ratio_threshold > 0:
|
|
834
|
+
for vid_ID, vid_info in videos.items():
|
|
835
|
+
if (
|
|
836
|
+
vid_info["to add"]
|
|
837
|
+
and vid_info["likes_to_views_ratio"] < likes_to_views_ratio_threshold
|
|
838
|
+
):
|
|
839
|
+
vid_info.update({"to add": False})
|
|
840
|
+
|
|
841
|
+
# Comments/views ratio filtering
|
|
842
|
+
if comments_to_views_ratio_threshold > 0:
|
|
843
|
+
for vid_ID, vid_info in videos.items():
|
|
844
|
+
if (
|
|
845
|
+
vid_info["to add"]
|
|
846
|
+
and vid_info["comments_to_views_ratio"]
|
|
847
|
+
< comments_to_views_ratio_threshold
|
|
848
|
+
):
|
|
849
|
+
vid_info.update({"to add": False})
|
|
850
|
+
|
|
683
851
|
## Selecting correct videos
|
|
684
852
|
videos_to_add = {
|
|
685
853
|
vid_ID: vid_info for vid_ID, vid_info in videos.items() if vid_info["to add"]
|
|
@@ -689,29 +857,66 @@ def main():
|
|
|
689
857
|
playlist_title = QTube.utils.youtube.playlists.get_playlists_titles(
|
|
690
858
|
youtube, [playlist_ID]
|
|
691
859
|
)[0]
|
|
860
|
+
playlist_video_count = QTube.utils.youtube.playlists.get_playlists_video_counts(
|
|
861
|
+
youtube, [playlist_ID]
|
|
862
|
+
)[0]
|
|
863
|
+
|
|
692
864
|
if len(videos_to_add) != 0: # Checks if there are actually videos to add
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
865
|
+
if (
|
|
866
|
+
playlist_video_count + len(videos_to_add) > 5000
|
|
867
|
+
): # Checks if current video count + new videos would exceed 5k (YT playlist size limit)
|
|
868
|
+
QTube.utils.helpers.print2(
|
|
869
|
+
f"The {playlist_title} playlist would reach or exceed the 5000 size limit if the following videos were added to it:",
|
|
870
|
+
fancy,
|
|
871
|
+
"fail",
|
|
872
|
+
["all", "videos"],
|
|
700
873
|
verb,
|
|
701
|
-
QTube.utils.youtube.playlists.add_to_playlist,
|
|
702
|
-
youtube,
|
|
703
|
-
playlist_ID,
|
|
704
|
-
vid_ID,
|
|
705
874
|
)
|
|
706
|
-
|
|
875
|
+
for vid_ID, vid_info in videos_to_add.items():
|
|
876
|
+
QTube.utils.helpers.print2(
|
|
877
|
+
f"From {vid_info['channel name']}, the video named: {vid_info['original title']} would have been added.\n It is available at: https://www.youtube.com/watch?v={vid_ID}",
|
|
878
|
+
fancy,
|
|
879
|
+
"video",
|
|
880
|
+
["all", "videos"],
|
|
881
|
+
verb,
|
|
882
|
+
)
|
|
707
883
|
QTube.utils.helpers.print2(
|
|
708
|
-
f"
|
|
884
|
+
f"Remove at least {len(videos_to_add)} videos from the {playlist_title} playlist so that the new one(s) can be added.",
|
|
885
|
+
fancy,
|
|
886
|
+
"warning",
|
|
709
887
|
["all", "videos"],
|
|
710
888
|
verb,
|
|
711
889
|
)
|
|
890
|
+
else:
|
|
891
|
+
QTube.utils.helpers.print2(
|
|
892
|
+
f"The following videos will be added to the {playlist_title} playlist:",
|
|
893
|
+
fancy,
|
|
894
|
+
"info",
|
|
895
|
+
["all", "videos"],
|
|
896
|
+
verb,
|
|
897
|
+
)
|
|
898
|
+
for vid_ID, vid_info in videos_to_add.items():
|
|
899
|
+
QTube.utils.helpers.handle_http_errors(
|
|
900
|
+
verb,
|
|
901
|
+
fancy,
|
|
902
|
+
QTube.utils.youtube.playlists.add_to_playlist,
|
|
903
|
+
youtube,
|
|
904
|
+
playlist_ID,
|
|
905
|
+
vid_ID,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
QTube.utils.helpers.print2(
|
|
909
|
+
f"From {vid_info['channel name']}, the video named: {vid_info['original title']} has been added.",
|
|
910
|
+
fancy,
|
|
911
|
+
"video",
|
|
912
|
+
["all", "videos"],
|
|
913
|
+
verb,
|
|
914
|
+
)
|
|
712
915
|
else:
|
|
713
916
|
QTube.utils.helpers.print2(
|
|
714
917
|
f"No new videos to add to the {playlist_title} playlist.",
|
|
918
|
+
fancy,
|
|
919
|
+
"info",
|
|
715
920
|
["all", "videos"],
|
|
716
921
|
verb,
|
|
717
922
|
)
|
QTube/utils/checks.py
CHANGED
|
@@ -332,6 +332,23 @@ def check_user_params(params_dict: dict) -> bool:
|
|
|
332
332
|
isinstance(params_dict.get("ignore_livestreams"), bool),
|
|
333
333
|
# Premieres
|
|
334
334
|
isinstance(params_dict.get("ignore_premieres"), bool),
|
|
335
|
+
# Fancy text
|
|
336
|
+
isinstance(params_dict.get("fancy_mode"), bool),
|
|
337
|
+
# Views
|
|
338
|
+
isinstance(params_dict.get("views_threshold"), int)
|
|
339
|
+
and params_dict.get("views_threshold") >= 0,
|
|
340
|
+
# Likes
|
|
341
|
+
isinstance(params_dict.get("likes_threshold"), int)
|
|
342
|
+
and params_dict.get("likes_threshold") >= 0,
|
|
343
|
+
# Comments
|
|
344
|
+
isinstance(params_dict.get("comments_threshold"), int)
|
|
345
|
+
and params_dict.get("comments_threshold") >= 0,
|
|
346
|
+
# Likes/views ratio
|
|
347
|
+
isinstance(params_dict.get("likes_to_views_ratio"), (int, float))
|
|
348
|
+
and 0 <= params_dict.get("likes_to_views_ratio") <= 1,
|
|
349
|
+
# Comments/views ratio
|
|
350
|
+
isinstance(params_dict.get("comments_to_views_ratio"), (int, float))
|
|
351
|
+
and 0 <= params_dict.get("comments_to_views_ratio") <= 1,
|
|
335
352
|
]
|
|
336
353
|
|
|
337
354
|
ok = all(checks)
|
|
@@ -352,7 +369,11 @@ def check_playlist_id(youtube, user_info: dict, test_playlist_ID: str) -> bool:
|
|
|
352
369
|
"""
|
|
353
370
|
user_channel_ID = user_info["items"][0]["id"]
|
|
354
371
|
|
|
355
|
-
response =
|
|
372
|
+
response = (
|
|
373
|
+
youtube.playlists()
|
|
374
|
+
.list(part="snippet", id=test_playlist_ID)
|
|
375
|
+
.execute(num_retries=5)
|
|
376
|
+
)
|
|
356
377
|
|
|
357
378
|
if "items" in response and len(response["items"]) > 0:
|
|
358
379
|
playlist_owner = response["items"][0]["snippet"]["channelId"]
|
|
@@ -408,14 +429,14 @@ def compare_software_versions(version1, version2):
|
|
|
408
429
|
Returns:
|
|
409
430
|
(str): A comment on version1's relationship to version2 (i.e., older, newer, same or pre-release).
|
|
410
431
|
"""
|
|
411
|
-
#Split version number and pre-release version
|
|
412
|
-
split_version1 = version1.split(
|
|
413
|
-
split_version2 = version2.split(
|
|
432
|
+
# Split version number and pre-release version
|
|
433
|
+
split_version1 = version1.split("-")
|
|
434
|
+
split_version2 = version2.split("-")
|
|
414
435
|
|
|
415
|
-
ver_nb1,ver_rel1 = split_version1[0],split_version1[-1]
|
|
416
|
-
ver_nb2,ver_rel2 = split_version2[0],split_version2[-1]
|
|
436
|
+
ver_nb1, ver_rel1 = split_version1[0], split_version1[-1]
|
|
437
|
+
ver_nb2, ver_rel2 = split_version2[0], split_version2[-1]
|
|
417
438
|
|
|
418
|
-
if ver_nb1!=ver_rel1:
|
|
439
|
+
if ver_nb1 != ver_rel1:
|
|
419
440
|
return "pre-release"
|
|
420
441
|
|
|
421
442
|
arr1 = list(map(int, ver_nb1.split(".")))
|
QTube/utils/helpers.py
CHANGED
|
@@ -4,14 +4,16 @@ import sys
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
from googleapiclient.errors import HttpError
|
|
7
|
+
from colorama import Fore, Style
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
def handle_http_errors(verbosity: list[str], func, *args, **kwargs):
|
|
10
|
+
def handle_http_errors(verbosity: list[str], fancy, func, *args, **kwargs):
|
|
10
11
|
"""Handles http errors when making API queries.
|
|
11
12
|
If after 5 tries, the function could not be executed, it shuts the program down.
|
|
12
13
|
|
|
13
14
|
Args:
|
|
14
15
|
verbosity (list[str]): User defined verbosity.
|
|
16
|
+
fancy (bool): Determines wether the text is fancyfied (emoji+color).
|
|
15
17
|
func (function): Function to be executed, with its arguments and keyword arguments.
|
|
16
18
|
args (any): Arguments of func.
|
|
17
19
|
kwargs (any): Keyword arguments of func.
|
|
@@ -25,7 +27,11 @@ def handle_http_errors(verbosity: list[str], func, *args, **kwargs):
|
|
|
25
27
|
try:
|
|
26
28
|
res = func(*args, **kwargs)
|
|
27
29
|
print2(
|
|
28
|
-
f"{func.__name__} successfully executed.",
|
|
30
|
+
f"{func.__name__} successfully executed.",
|
|
31
|
+
fancy,
|
|
32
|
+
"success",
|
|
33
|
+
["all", "func"],
|
|
34
|
+
verbosity,
|
|
29
35
|
)
|
|
30
36
|
return res # Return the response if no error occurs
|
|
31
37
|
except HttpError as err:
|
|
@@ -59,20 +65,54 @@ def handle_http_errors(verbosity: list[str], func, *args, **kwargs):
|
|
|
59
65
|
sys.exit() # Exit the program after 5 retries
|
|
60
66
|
|
|
61
67
|
|
|
62
|
-
def
|
|
63
|
-
"""
|
|
68
|
+
def fancify_text(text, color, style, emoji):
|
|
69
|
+
"""Modifies the color and content of a string.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
text (str): Original string.
|
|
73
|
+
color (colorama color): Colorama color to be applied to the text.
|
|
74
|
+
style (colorama style): Colorama style to be applied to the text.
|
|
75
|
+
emoji (str): Emoji to add at the beginning of the text.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
fancy_text (str): Fancified text.
|
|
79
|
+
"""
|
|
80
|
+
fancy_text = f"{emoji}{style}{' - '}{color}{text}{Style.RESET_ALL}"
|
|
81
|
+
|
|
82
|
+
return fancy_text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print2(
|
|
86
|
+
message: str, fancy: bool, fancy_type: None, verb_level: list, verbosity: list
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Prints text in the terminal depending on the choosen verbosity and fancy type.
|
|
64
89
|
|
|
65
90
|
Args:
|
|
66
91
|
message (str): Text to be printed in the terminal.
|
|
92
|
+
fancy (bool): Determines wether the text is fancyfied (emoji+color).
|
|
93
|
+
fancy_type (str): Determines how the message is fancified (success, fail, warning, info or video).
|
|
67
94
|
verb_level (list[str]): Verbosity associated to the text.
|
|
68
95
|
verbosity (list[str]): User defined verbosity.
|
|
69
96
|
|
|
70
97
|
Returns:
|
|
71
98
|
None
|
|
72
99
|
"""
|
|
73
|
-
|
|
74
100
|
if any(v in verb_level for v in verbosity):
|
|
75
|
-
|
|
101
|
+
if fancy:
|
|
102
|
+
if fancy_type == "success":
|
|
103
|
+
print(fancify_text(message, Fore.GREEN, Style.BRIGHT, "✅"))
|
|
104
|
+
elif fancy_type == "fail":
|
|
105
|
+
print(fancify_text(message, Fore.RED, Style.BRIGHT, "❌"))
|
|
106
|
+
elif fancy_type == "warning":
|
|
107
|
+
print(fancify_text(message, Fore.YELLOW, Style.BRIGHT, "⚠️"))
|
|
108
|
+
elif fancy_type == "info":
|
|
109
|
+
print(fancify_text(message, Fore.WHITE, Style.BRIGHT, "📢"))
|
|
110
|
+
elif fancy_type == "video":
|
|
111
|
+
print(fancify_text(message, Fore.BLUE, Style.BRIGHT, "🎞️"))
|
|
112
|
+
else:
|
|
113
|
+
print(message)
|
|
114
|
+
else:
|
|
115
|
+
print(message)
|
|
76
116
|
|
|
77
117
|
|
|
78
118
|
def split_list(input_list: list, chunk_size: int) -> list:
|
|
@@ -187,3 +227,21 @@ def remove_multiple_spaces(text):
|
|
|
187
227
|
(str): Same text string, but with multiple spaces replaced by a single space.
|
|
188
228
|
"""
|
|
189
229
|
return re.sub(" +", " ", text)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def divide_lists(list1, list2, percentage: False):
|
|
233
|
+
"""Divides two python lists element-wise.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
list1 (lst[int|float]): Dividend list.
|
|
237
|
+
list2 (lst[inf|float]): Divisor list.
|
|
238
|
+
percentage (bool): Determines if the result of the division is expressed as a percentage.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
res (lst[int|float]): List containing the results of the element-wise division.
|
|
242
|
+
"""
|
|
243
|
+
if percentage:
|
|
244
|
+
res = [(a / b) * 100 if b != 0 else None for a, b in zip(list1, list2)]
|
|
245
|
+
else:
|
|
246
|
+
res = [a / b if b != 0 else None for a, b in zip(list1, list2)]
|
|
247
|
+
return res
|
QTube/utils/parsing.py
CHANGED
|
@@ -217,6 +217,46 @@ def parse_arguments():
|
|
|
217
217
|
help="Projection the videos need to be in. Default: None",
|
|
218
218
|
)
|
|
219
219
|
|
|
220
|
+
parser.add_argument(
|
|
221
|
+
"-vt",
|
|
222
|
+
"--views_threshold",
|
|
223
|
+
metavar="",
|
|
224
|
+
type=int,
|
|
225
|
+
help="Minimum number of views. Default: 0",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"-lt",
|
|
230
|
+
"--likes_threshold",
|
|
231
|
+
metavar="",
|
|
232
|
+
type=int,
|
|
233
|
+
help="Minimum number of likes. Default: 0",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"-ct",
|
|
238
|
+
"--comments_threshold",
|
|
239
|
+
metavar="",
|
|
240
|
+
type=int,
|
|
241
|
+
help="Minimum number of comments. Default: 0",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"-lvr",
|
|
246
|
+
"--likes_to_views_ratio",
|
|
247
|
+
metavar="",
|
|
248
|
+
type=float,
|
|
249
|
+
help="Minimum ratio of likes to views. Default: 0",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"-cvr",
|
|
254
|
+
"--comments_to_views_ratio",
|
|
255
|
+
metavar="",
|
|
256
|
+
type=float,
|
|
257
|
+
help="Minimum ratio of comments to views. Default: 0",
|
|
258
|
+
)
|
|
259
|
+
|
|
220
260
|
parser.add_argument(
|
|
221
261
|
"-rf",
|
|
222
262
|
"--run_frequency",
|
|
@@ -248,6 +288,14 @@ def parse_arguments():
|
|
|
248
288
|
help="ID of the playlist the videos will be added to. Default: None",
|
|
249
289
|
)
|
|
250
290
|
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"-fm",
|
|
293
|
+
"--fancy_mode",
|
|
294
|
+
metavar="",
|
|
295
|
+
type=str,
|
|
296
|
+
help="Enables fancy mode (colors and emojis) for terminal output. Default: True",
|
|
297
|
+
)
|
|
298
|
+
|
|
251
299
|
parser.add_argument(
|
|
252
300
|
"-v",
|
|
253
301
|
"--verbosity",
|
|
@@ -259,6 +307,7 @@ def parse_arguments():
|
|
|
259
307
|
|
|
260
308
|
return vars(parser.parse_args())
|
|
261
309
|
|
|
310
|
+
|
|
262
311
|
def format_arguments(args_dict):
|
|
263
312
|
"""Formats the parsed command line arguments (written with the help of AI, regex is witchcraft to me).
|
|
264
313
|
|
|
@@ -270,15 +319,17 @@ def format_arguments(args_dict):
|
|
|
270
319
|
"""
|
|
271
320
|
if co_str := args_dict.get("caption_options"):
|
|
272
321
|
# Define a regex to match lists and split the values inside the brackets
|
|
273
|
-
co_str2 = re.sub(
|
|
274
|
-
|
|
275
|
-
|
|
322
|
+
co_str2 = re.sub(
|
|
323
|
+
r"(\w+):\s*\[([^\]]*)\]",
|
|
324
|
+
lambda m: f'"{m.group(1)}": ["' + '", "'.join(m.group(2).split(",")) + '"]',
|
|
325
|
+
co_str,
|
|
326
|
+
)
|
|
276
327
|
|
|
277
328
|
# Match booleans
|
|
278
|
-
co_str2 = re.sub(r
|
|
329
|
+
co_str2 = re.sub(r"(\w+):\s*(True|False)", r'"\1": \2', co_str2)
|
|
279
330
|
|
|
280
331
|
# Match other key-value pairs
|
|
281
|
-
co_str2 = re.sub(r
|
|
332
|
+
co_str2 = re.sub(r"(\w+):\s*(\w+)", r'"\1": "\2"', co_str2)
|
|
282
333
|
|
|
283
334
|
# Replace single quotes with double quotes to comply with JSON format
|
|
284
335
|
co_str2 = co_str2.replace("'", '"')
|
QTube/utils/youtube/captions.py
CHANGED
|
@@ -9,7 +9,9 @@ def make_caption_requests(youtube, video_IDs: list[str]) -> dict[dict]:
|
|
|
9
9
|
responses_dict (dict[dict]): Dictionary with video IDs as keys and YT API caption responses as values.
|
|
10
10
|
"""
|
|
11
11
|
responses_dict = {
|
|
12
|
-
video_ID: youtube.captions()
|
|
12
|
+
video_ID: youtube.captions()
|
|
13
|
+
.list(part="snippet", videoId=video_ID)
|
|
14
|
+
.execute(num_retries=5)
|
|
13
15
|
for video_ID in video_IDs
|
|
14
16
|
}
|
|
15
17
|
|
QTube/utils/youtube/channels.py
CHANGED
|
@@ -20,7 +20,7 @@ def get_subscriptions(youtube, next_page_token=None) -> dict:
|
|
|
20
20
|
order="alphabetical",
|
|
21
21
|
pageToken=next_page_token,
|
|
22
22
|
)
|
|
23
|
-
.execute()
|
|
23
|
+
.execute(num_retries=5)
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
for item in response.get("items", []):
|
|
@@ -48,7 +48,11 @@ def get_channel_info(youtube, handle: str):
|
|
|
48
48
|
"""
|
|
49
49
|
channel = {}
|
|
50
50
|
|
|
51
|
-
response =
|
|
51
|
+
response = (
|
|
52
|
+
youtube.channels()
|
|
53
|
+
.list(part="snippet", forHandle=handle)
|
|
54
|
+
.execute(num_retries=5)
|
|
55
|
+
)
|
|
52
56
|
|
|
53
57
|
if "items" in response.keys():
|
|
54
58
|
title = response["items"][0]["snippet"]["title"]
|
|
@@ -74,7 +78,9 @@ def get_uploads_playlists(youtube, channel_IDs: list[str]) -> list[str]:
|
|
|
74
78
|
"""
|
|
75
79
|
channel_IDs_str = ",".join(channel_IDs)
|
|
76
80
|
response = (
|
|
77
|
-
youtube.channels()
|
|
81
|
+
youtube.channels()
|
|
82
|
+
.list(part="contentDetails", id=channel_IDs_str)
|
|
83
|
+
.execute(num_retries=5)
|
|
78
84
|
)
|
|
79
85
|
# Create a dictionary to store the mapping between channel IDs and upload playlist IDs
|
|
80
86
|
channel_to_upload_map = {
|
|
@@ -100,7 +106,7 @@ def get_user_info(youtube) -> dict:
|
|
|
100
106
|
response = (
|
|
101
107
|
youtube.channels()
|
|
102
108
|
.list(part="snippet,contentDetails,statistics", mine=True)
|
|
103
|
-
.execute()
|
|
109
|
+
.execute(num_retries=5)
|
|
104
110
|
)
|
|
105
111
|
|
|
106
112
|
return response
|
QTube/utils/youtube/playlists.py
CHANGED
|
@@ -14,7 +14,7 @@ def get_recent_videos(youtube, playlist_ID: str) -> dict:
|
|
|
14
14
|
response = (
|
|
15
15
|
youtube.playlistItems()
|
|
16
16
|
.list(part="contentDetails", playlistId=playlist_ID, maxResults=5)
|
|
17
|
-
.execute()
|
|
17
|
+
.execute(num_retries=5)
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
recent_vids = {
|
|
@@ -50,7 +50,7 @@ def get_playlist_content(youtube, playlist_ID: str) -> list[str]:
|
|
|
50
50
|
maxResults=50,
|
|
51
51
|
pageToken=next_page_token,
|
|
52
52
|
)
|
|
53
|
-
.execute()
|
|
53
|
+
.execute(num_retries=5)
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
temp_videos_IDs = [
|
|
@@ -77,13 +77,39 @@ def get_playlists_titles(youtube=None, playlist_IDs: list[str] = None):
|
|
|
77
77
|
titles (list[str]): List of YT playlist titles.
|
|
78
78
|
"""
|
|
79
79
|
playlist_IDs_str = ",".join(playlist_IDs)
|
|
80
|
-
response =
|
|
80
|
+
response = (
|
|
81
|
+
youtube.playlists()
|
|
82
|
+
.list(part="snippet", id=playlist_IDs_str)
|
|
83
|
+
.execute(num_retries=5)
|
|
84
|
+
)
|
|
81
85
|
|
|
82
86
|
titles = [playlist["snippet"]["title"] for playlist in response["items"]]
|
|
83
87
|
|
|
84
88
|
return titles
|
|
85
89
|
|
|
86
90
|
|
|
91
|
+
def get_playlists_video_counts(youtube=None, playlist_IDs: list[str] = None):
|
|
92
|
+
"""Retrieves the number of videos of a list of YT playlists.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
youtube (Resource): YT API resource.
|
|
96
|
+
playlists_IDs (list[str]): List of playlist IDs.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
counts (list[int]): List of YT playlist video counts.
|
|
100
|
+
"""
|
|
101
|
+
playlist_IDs_str = ",".join(playlist_IDs)
|
|
102
|
+
response = (
|
|
103
|
+
youtube.playlists()
|
|
104
|
+
.list(part="contentDetails", id=playlist_IDs_str)
|
|
105
|
+
.execute(num_retries=5)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
counts = [playlist["contentDetails"]["itemCount"] for playlist in response["items"]]
|
|
109
|
+
|
|
110
|
+
return counts
|
|
111
|
+
|
|
112
|
+
|
|
87
113
|
def add_to_playlist(youtube, playlist_ID: str, video_ID: str) -> None:
|
|
88
114
|
"""Adds a YT video to the YT playlist.
|
|
89
115
|
|
|
@@ -106,6 +132,6 @@ def add_to_playlist(youtube, playlist_ID: str, video_ID: str) -> None:
|
|
|
106
132
|
}
|
|
107
133
|
},
|
|
108
134
|
)
|
|
109
|
-
.execute()
|
|
135
|
+
.execute(num_retries=5)
|
|
110
136
|
)
|
|
111
137
|
return
|
QTube/utils/youtube/videos.py
CHANGED
|
@@ -2,6 +2,8 @@ import isodate
|
|
|
2
2
|
|
|
3
3
|
from pytube import YouTube
|
|
4
4
|
|
|
5
|
+
from QTube.utils import helpers
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
def make_video_requests(youtube, video_IDs: list[str]) -> dict:
|
|
7
9
|
"""Retrieves information on a list of YT videos.
|
|
@@ -15,7 +17,9 @@ def make_video_requests(youtube, video_IDs: list[str]) -> dict:
|
|
|
15
17
|
"""
|
|
16
18
|
video_IDs_str = ",".join(video_IDs)
|
|
17
19
|
response = (
|
|
18
|
-
youtube.videos()
|
|
20
|
+
youtube.videos()
|
|
21
|
+
.list(part="snippet,contentDetails,statistics", id=video_IDs_str)
|
|
22
|
+
.execute(num_retries=5)
|
|
19
23
|
)
|
|
20
24
|
return response
|
|
21
25
|
|
|
@@ -39,7 +43,11 @@ def get_titles(
|
|
|
39
43
|
"""
|
|
40
44
|
if use_API:
|
|
41
45
|
video_IDs_str = ",".join(video_IDs)
|
|
42
|
-
response =
|
|
46
|
+
response = (
|
|
47
|
+
youtube.videos()
|
|
48
|
+
.list(part="snippet", id=video_IDs_str)
|
|
49
|
+
.execute(num_retries=5)
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
titles = [vid["snippet"]["title"] for vid in response["items"]]
|
|
45
53
|
|
|
@@ -65,7 +73,11 @@ def get_tags(
|
|
|
65
73
|
"""
|
|
66
74
|
if use_API:
|
|
67
75
|
video_IDs_str = ",".join(video_IDs)
|
|
68
|
-
response =
|
|
76
|
+
response = (
|
|
77
|
+
youtube.videos()
|
|
78
|
+
.list(part="snippet", id=video_IDs_str)
|
|
79
|
+
.execute(num_retries=5)
|
|
80
|
+
)
|
|
69
81
|
|
|
70
82
|
tags = [
|
|
71
83
|
vid["snippet"]["tags"] if "tags" in vid["snippet"] else None
|
|
@@ -94,7 +106,11 @@ def get_descriptions(
|
|
|
94
106
|
"""
|
|
95
107
|
if use_API:
|
|
96
108
|
video_IDs_str = ",".join(video_IDs) # Join the video IDs with commas
|
|
97
|
-
response =
|
|
109
|
+
response = (
|
|
110
|
+
youtube.videos()
|
|
111
|
+
.list(part="snippet", id=video_IDs_str)
|
|
112
|
+
.execute(num_retries=5)
|
|
113
|
+
)
|
|
98
114
|
|
|
99
115
|
descriptions = [vid["snippet"]["description"] for vid in response.get("items", [])]
|
|
100
116
|
return descriptions
|
|
@@ -120,7 +136,9 @@ def get_durations(
|
|
|
120
136
|
if use_API:
|
|
121
137
|
video_IDs_str = ",".join(video_IDs)
|
|
122
138
|
response = (
|
|
123
|
-
youtube.videos()
|
|
139
|
+
youtube.videos()
|
|
140
|
+
.list(part="contentDetails", id=video_IDs_str)
|
|
141
|
+
.execute(num_retries=5)
|
|
124
142
|
)
|
|
125
143
|
|
|
126
144
|
durations_iso = [
|
|
@@ -150,7 +168,11 @@ def get_languages(
|
|
|
150
168
|
"""
|
|
151
169
|
if use_API:
|
|
152
170
|
video_IDs_str = ",".join(video_IDs)
|
|
153
|
-
response =
|
|
171
|
+
response = (
|
|
172
|
+
youtube.videos()
|
|
173
|
+
.list(part="snippet", id=video_IDs_str)
|
|
174
|
+
.execute(num_retries=5)
|
|
175
|
+
)
|
|
154
176
|
|
|
155
177
|
languages = [
|
|
156
178
|
(
|
|
@@ -190,7 +212,9 @@ def get_dimensions(
|
|
|
190
212
|
if use_API:
|
|
191
213
|
video_IDs_str = ",".join(video_IDs)
|
|
192
214
|
response = (
|
|
193
|
-
youtube.videos()
|
|
215
|
+
youtube.videos()
|
|
216
|
+
.list(part="contentDetails", id=video_IDs_str)
|
|
217
|
+
.execute(num_retries=5)
|
|
194
218
|
)
|
|
195
219
|
|
|
196
220
|
dimensions = [vid["contentDetails"]["dimension"] for vid in response["items"]]
|
|
@@ -217,7 +241,9 @@ def get_definitions(
|
|
|
217
241
|
if use_API:
|
|
218
242
|
video_IDs_str = ",".join(video_IDs)
|
|
219
243
|
response = (
|
|
220
|
-
youtube.videos()
|
|
244
|
+
youtube.videos()
|
|
245
|
+
.list(part="contentDetails", id=video_IDs_str)
|
|
246
|
+
.execute(num_retries=5)
|
|
221
247
|
)
|
|
222
248
|
|
|
223
249
|
definitions = [vid["contentDetails"]["definition"] for vid in response["items"]]
|
|
@@ -305,13 +331,167 @@ def get_projections(
|
|
|
305
331
|
if use_API:
|
|
306
332
|
video_IDs_str = ",".join(video_IDs)
|
|
307
333
|
response = (
|
|
308
|
-
youtube.videos()
|
|
334
|
+
youtube.videos()
|
|
335
|
+
.list(part="contentDetails", id=video_IDs_str)
|
|
336
|
+
.execute(num_retries=5)
|
|
309
337
|
)
|
|
310
338
|
|
|
311
339
|
projections = [vid["contentDetails"]["projection"] for vid in response["items"]]
|
|
312
340
|
return projections
|
|
313
341
|
|
|
314
342
|
|
|
343
|
+
def get_view_counts(
|
|
344
|
+
youtube=None,
|
|
345
|
+
response: dict = None,
|
|
346
|
+
video_IDs: list[str] = None,
|
|
347
|
+
use_API: bool = False,
|
|
348
|
+
) -> list[str]:
|
|
349
|
+
"""Retrieves the number of views of a list of YT videos.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
youtube (Resource): YT API resource.
|
|
353
|
+
response (dict[dict]): YT API response from the make_video_request function.
|
|
354
|
+
video_IDs (list[str]): List of video IDs.
|
|
355
|
+
use_API (bool): Determines if a new API request is made or if the response dictionary is used.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
views (list[int]): List of YT videos views.
|
|
359
|
+
"""
|
|
360
|
+
if use_API:
|
|
361
|
+
video_IDs_str = ",".join(video_IDs)
|
|
362
|
+
response = (
|
|
363
|
+
youtube.videos()
|
|
364
|
+
.list(part="statistics", id=video_IDs_str)
|
|
365
|
+
.execute(num_retries=5)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
views = [int(vid["statistics"]["viewCount"]) for vid in response["items"]]
|
|
369
|
+
|
|
370
|
+
return views
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def get_like_counts(
|
|
374
|
+
youtube=None,
|
|
375
|
+
response: dict = None,
|
|
376
|
+
video_IDs: list[str] = None,
|
|
377
|
+
use_API: bool = False,
|
|
378
|
+
) -> list[str]:
|
|
379
|
+
"""Retrieves the number of likes of a list of YT videos.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
youtube (Resource): YT API resource.
|
|
383
|
+
response (dict[dict]): YT API response from the make_video_request function.
|
|
384
|
+
video_IDs (list[str]): List of video IDs.
|
|
385
|
+
use_API (bool): Determines if a new API request is made or if the response dictionary is used.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
likes (list[int]): List of YT videos likes.
|
|
389
|
+
"""
|
|
390
|
+
if use_API:
|
|
391
|
+
video_IDs_str = ",".join(video_IDs)
|
|
392
|
+
response = (
|
|
393
|
+
youtube.videos()
|
|
394
|
+
.list(part="statistics", id=video_IDs_str)
|
|
395
|
+
.execute(num_retries=5)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
likes = [int(vid["statistics"]["likeCount"]) for vid in response["items"]]
|
|
399
|
+
|
|
400
|
+
return likes
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def get_comment_counts(
|
|
404
|
+
youtube=None,
|
|
405
|
+
response: dict = None,
|
|
406
|
+
video_IDs: list[str] = None,
|
|
407
|
+
use_API: bool = False,
|
|
408
|
+
) -> list[str]:
|
|
409
|
+
"""Retrieves the number of comments of a list of YT videos.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
youtube (Resource): YT API resource.
|
|
413
|
+
response (dict[dict]): YT API response from the make_video_request function.
|
|
414
|
+
video_IDs (list[str]): List of video IDs.
|
|
415
|
+
use_API (bool): Determines if a new API request is made or if the response dictionary is used.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
comment_counts (list[int]): List of YT videos comment counts.
|
|
419
|
+
"""
|
|
420
|
+
if use_API:
|
|
421
|
+
video_IDs_str = ",".join(video_IDs)
|
|
422
|
+
response = (
|
|
423
|
+
youtube.videos()
|
|
424
|
+
.list(part="statistics", id=video_IDs_str)
|
|
425
|
+
.execute(num_retries=5)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
comment_counts = [
|
|
429
|
+
int(vid["statistics"]["commentCount"]) for vid in response["items"]
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
return comment_counts
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def get_likes_to_views_ratio(
|
|
436
|
+
likes,
|
|
437
|
+
views,
|
|
438
|
+
youtube=None,
|
|
439
|
+
response: dict = None,
|
|
440
|
+
video_IDs: list[str] = None,
|
|
441
|
+
use_API: bool = False,
|
|
442
|
+
) -> list[str]:
|
|
443
|
+
"""Retrieves the likes to views ratio of a list of YT videos.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
likes (list[int]): List of the number of likes.
|
|
447
|
+
views (list[int]): List of the number of views.
|
|
448
|
+
youtube (Resource): YT API resource.
|
|
449
|
+
response (dict[dict]): YT API response from the make_video_request function.
|
|
450
|
+
video_IDs (list[str]): List of video IDs.
|
|
451
|
+
use_API (bool): Determines if a new API request is made or if the response dictionary is used.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
ratio (list[int|float]): List of YT videos' likes to views ratios.
|
|
455
|
+
"""
|
|
456
|
+
if use_API:
|
|
457
|
+
views = get_view_counts(youtube, response, video_IDs, use_API)
|
|
458
|
+
likes = get_like_counts(youtube, response, video_IDs, use_API)
|
|
459
|
+
|
|
460
|
+
ratio = helpers.divide_lists(likes, views, False)
|
|
461
|
+
|
|
462
|
+
return ratio
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def get_comments_to_views_ratio(
|
|
466
|
+
likes,
|
|
467
|
+
views,
|
|
468
|
+
youtube=None,
|
|
469
|
+
response: dict = None,
|
|
470
|
+
video_IDs: list[str] = None,
|
|
471
|
+
use_API: bool = False,
|
|
472
|
+
) -> list[str]:
|
|
473
|
+
"""Retrieves the comments to views ratio of a list of YT videos.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
comments (list[int]): List of the number of comments.
|
|
477
|
+
views (list[int]): List of the number of views.
|
|
478
|
+
youtube (Resource): YT API resource.
|
|
479
|
+
response (dict[dict]): YT API response from the make_video_request function.
|
|
480
|
+
video_IDs (list[str]): List of video IDs.
|
|
481
|
+
use_API (bool): Determines if a new API request is made or if the response dictionary is used.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
views (list[int|float]): List of YT videos' comments to views ratios.
|
|
485
|
+
"""
|
|
486
|
+
if use_API:
|
|
487
|
+
views = get_view_counts(youtube, response, video_IDs, use_API)
|
|
488
|
+
comments = get_comment_counts(youtube, response, video_IDs, use_API)
|
|
489
|
+
|
|
490
|
+
ratio = helpers.divide_lists(comments, views, False)
|
|
491
|
+
|
|
492
|
+
return ratio
|
|
493
|
+
|
|
494
|
+
|
|
315
495
|
def has_captions(
|
|
316
496
|
youtube=None,
|
|
317
497
|
response: dict = None,
|
|
@@ -332,7 +512,9 @@ def has_captions(
|
|
|
332
512
|
if use_API:
|
|
333
513
|
video_IDs_str = ",".join(video_IDs)
|
|
334
514
|
response = (
|
|
335
|
-
youtube.videos()
|
|
515
|
+
youtube.videos()
|
|
516
|
+
.list(part="contentDetails", id=video_IDs_str)
|
|
517
|
+
.execute(num_retries=5)
|
|
336
518
|
)
|
|
337
519
|
|
|
338
520
|
captions = [vid["contentDetails"]["caption"] for vid in response["items"]]
|
|
@@ -382,7 +564,11 @@ def is_live(
|
|
|
382
564
|
"""
|
|
383
565
|
if use_API:
|
|
384
566
|
video_IDs_str = ",".join(video_IDs)
|
|
385
|
-
response =
|
|
567
|
+
response = (
|
|
568
|
+
youtube.videos()
|
|
569
|
+
.list(part="snippet", id=video_IDs_str)
|
|
570
|
+
.execute(num_retries=5)
|
|
571
|
+
)
|
|
386
572
|
|
|
387
573
|
live_statuses = [
|
|
388
574
|
vid["snippet"]["liveBroadcastContent"] for vid in response["items"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: QTube
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Automatically add Youtube videos to a playlist.
|
|
5
5
|
Home-page: https://github.com/Killian42/QTube
|
|
6
6
|
Author: Killian Lebreton
|
|
@@ -44,6 +44,7 @@ Classifier: Topic :: Multimedia :: Video
|
|
|
44
44
|
Requires-Python: >=3.8
|
|
45
45
|
Description-Content-Type: text/markdown
|
|
46
46
|
License-File: LICENSE.txt
|
|
47
|
+
Requires-Dist: colorama >=0.4.6
|
|
47
48
|
Requires-Dist: google-api-python-client >=2.119.0
|
|
48
49
|
Requires-Dist: google-auth-oauthlib >=1.0.0
|
|
49
50
|
Requires-Dist: isodate >=0.6.1
|
|
@@ -97,6 +98,7 @@ Each of these rules is based on putting some kind of constraint on video propert
|
|
|
97
98
|
* Language filtering
|
|
98
99
|
* Caption filtering
|
|
99
100
|
* Duration filtering
|
|
101
|
+
* Views, likes & comments counts filtering
|
|
100
102
|
* Livestream filtering
|
|
101
103
|
* Premiere filtering
|
|
102
104
|
* Quality filtering
|
|
@@ -144,11 +146,17 @@ For more versatile uses, you can also use command line arguments with the [qtube
|
|
|
144
146
|
|`lowest_framerate`|Yes|Minimum framerate. Videos with framerates stricly lower than this value will not be added.|Positive integer|
|
|
145
147
|
|`preferred_dimensions`|Yes|Dimension the videos need to be in.|*2D*, *3D* or both|
|
|
146
148
|
|`preferred_projections`|Yes|Projection the videos need to be in.|*rectangular*, *360* or both|
|
|
149
|
+
|`views_threshold`|No|Minimum number of times videos have been viewed.|Positive integer|
|
|
150
|
+
|`likes_threshold`|No|Minimum number of times videos have been liked.|Positive integer|
|
|
151
|
+
|`comments_threshold`|No|Minimum number of times videos have been commented on.|Positive integer|
|
|
152
|
+
|`likes_to_views_ratio`|No|Minimum likes to views ratio.|Positive float between 0 & 1|
|
|
153
|
+
|`comments_to_views_ratio`|No|Minimum comments to views ratio.|Positive float between 0 & 1|
|
|
147
154
|
|`run_frequency`|No|Defines the duration, in days, of the timeframe considered by the software. Can be interpreted as the frequency the program should be run.|*daily*, *weekly*, *monthly* or any positive integer|
|
|
148
155
|
|`keep_shorts`|No|Determines whether to add shorts.|boolean|
|
|
149
156
|
|`keep_duplicates`|No|Determines whether to add videos that are already in the playlist.|boolean|
|
|
150
157
|
|`upload_playlist_ID`|No|ID of the playlist the videos will be added to. Playlist IDs are found at the end of their URL: `https://www.youtube.com/playlist?list=*playlist_ID*`|Playlist ID|
|
|
151
158
|
|`override_json`|No|Allow command line arguments to override user_params.json parameters.|boolean|
|
|
159
|
+
|`fancy_mode`|No|Enables fancy mode (colors and emojis) for terminal output. |boolean|
|
|
152
160
|
|`verbosity`|No|Controls how much information is shown in the terminal. Options can be combined, so that selecting each option gives the same result as selecting *all*. <br>1: Everything is shown.<br>2: Nothing is shown.<br>3: Only information regarding function execution is shown.<br>4: Only information regarding credentials is shown (loading, retrieving and saving).<br>5: Only information regarding added videos is shown (number, channel names and video titles).|<br>*all*<sup> 1 </sup>, <br>*none*<sup> 2 </sup> , <br>*func*<sup> 3 </sup>, <br>*credentials*<sup> 4 </sup> ,<br>*videos*<sup> 5 </sup>.|
|
|
153
161
|
|
|
154
162
|
All parameters are case-sensitive by default and if you do not want to use an optional parameter, replace its value with *null* or delete the entry.
|
|
@@ -191,11 +199,17 @@ The following *user_params.json* file would add every new videos from channels y
|
|
|
191
199
|
"lowest_framerate": null,
|
|
192
200
|
"preferred_dimensions": null,
|
|
193
201
|
"preferred_projections": null,
|
|
202
|
+
"views_threshold": 0,
|
|
203
|
+
"likes_threshold": 0,
|
|
204
|
+
"comments_threshold": 0,
|
|
205
|
+
"likes_to_views_ratio": 0,
|
|
206
|
+
"comments_to_views_ratio": 0,
|
|
194
207
|
"run_frequency":"daily",
|
|
195
208
|
"keep_shorts": true,
|
|
196
209
|
"keep_duplicates": false,
|
|
197
210
|
"upload_playlist_ID": "your_playlist_ID",
|
|
198
211
|
"override_json":false,
|
|
212
|
+
"fancy_mode":true,
|
|
199
213
|
"verbosity": ["credentials","videos"]
|
|
200
214
|
}
|
|
201
215
|
```
|
|
@@ -227,11 +241,17 @@ The following *user_params.json* file would only add videos with good quality.
|
|
|
227
241
|
"lowest_framerate": 60,
|
|
228
242
|
"preferred_dimensions": ["2D"],
|
|
229
243
|
"preferred_projections": ["rectangular"],
|
|
244
|
+
"views_threshold": 0,
|
|
245
|
+
"likes_threshold": 0,
|
|
246
|
+
"comments_threshold": 0,
|
|
247
|
+
"likes_to_views_ratio": 0,
|
|
248
|
+
"comments_to_views_ratio": 0,
|
|
230
249
|
"run_frequency":"daily",
|
|
231
250
|
"keep_shorts": true,
|
|
232
251
|
"keep_duplicates": false,
|
|
233
252
|
"upload_playlist_ID": "your_playlist_ID",
|
|
234
253
|
"override_json":false,
|
|
254
|
+
"fancy_mode":true,
|
|
235
255
|
"verbosity": ["credentials","videos"]
|
|
236
256
|
}
|
|
237
257
|
```
|
|
@@ -263,11 +283,17 @@ The following *user_params.json* file would only add the *$1 vs.* MrBeast videos
|
|
|
263
283
|
"lowest_framerate": null,
|
|
264
284
|
"preferred_dimensions": ["2D"],
|
|
265
285
|
"preferred_projections": ["rectangular"],
|
|
286
|
+
"views_threshold": 0,
|
|
287
|
+
"likes_threshold": 0,
|
|
288
|
+
"comments_threshold": 0,
|
|
289
|
+
"likes_to_views_ratio": 0,
|
|
290
|
+
"comments_to_views_ratio": 0,
|
|
266
291
|
"run_frequency":"daily",
|
|
267
292
|
"keep_shorts": false,
|
|
268
293
|
"keep_duplicates": false,
|
|
269
294
|
"upload_playlist_ID": "your_playlist_ID",
|
|
270
295
|
"override_json":false,
|
|
296
|
+
"fancy_mode":true,
|
|
271
297
|
"verbosity": ["credentials","videos"]
|
|
272
298
|
}
|
|
273
299
|
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
QTube/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
QTube/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
QTube/scripts/qtube.py,sha256=wzEkhjGgmRFtN9kNBZfqd_6rklX11hwzadh_DAQGZpM,33916
|
|
4
|
+
QTube/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
QTube/utils/checks.py,sha256=wq__Xj_NEZa9tjliRTh-kZcHHbll0rqdjBXwIfZso9E,12569
|
|
6
|
+
QTube/utils/helpers.py,sha256=cOhy4hauguO5-eJK9pK7NvPOtF5ZWp4zfFipA-DY1QM,9131
|
|
7
|
+
QTube/utils/parsing.py,sha256=Q9GnSZSKwX7-AVQDlIoXlQ4xIgQIwMZfOIKXZWi7xSk,10359
|
|
8
|
+
QTube/utils/youtube/captions.py,sha256=0jUs8SH4L4d2RTS4QHJ5J2Zd9qe3SOHf9VZW966NuY8,1591
|
|
9
|
+
QTube/utils/youtube/channels.py,sha256=8K-5_Ff8zHyKhEM02cdwMEV6HLU14MngxI1v2yJh6iI,3343
|
|
10
|
+
QTube/utils/youtube/playlists.py,sha256=Cdohhb2M4bxKiTnZ-R-0xNXPV050nhhI6hepvtDVpIM,3840
|
|
11
|
+
QTube/utils/youtube/videos.py,sha256=lHl-MrloJODCbGD38lwrPQmyEE8t-ZvLA0B4gfMSG0c,18159
|
|
12
|
+
QTube-2.3.0.dist-info/LICENSE.txt,sha256=cctyZoZUseENxkeq5p_C5oCFYpK0-ECku_sCZmWbL0E,1098
|
|
13
|
+
QTube-2.3.0.dist-info/METADATA,sha256=Ftm4t0X_hgyJelTS1v46YehDTPbZglWR5Hp4d3iMIio,17265
|
|
14
|
+
QTube-2.3.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
15
|
+
QTube-2.3.0.dist-info/entry_points.txt,sha256=Q3SLDyRahuzrNnHC3UOkMH5gM_Um3eCLiLG4vSTPjqE,51
|
|
16
|
+
QTube-2.3.0.dist-info/top_level.txt,sha256=z6oXrT8BiTTZuygjsbfe-NE4rmkHlDK8euAL9m6BT4A,6
|
|
17
|
+
QTube-2.3.0.dist-info/RECORD,,
|
QTube-2.1.1.dist-info/RECORD
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
QTube/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
QTube/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
QTube/scripts/qtube.py,sha256=02FnIYdGsOzLAUqtnzy-8XCHQF7W0CyoPrRspvgdtRE,27150
|
|
4
|
-
QTube/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
QTube/utils/checks.py,sha256=6EGmI37koeyLOEOWtE7g2RgEceMEjzgXPCK0eV_bnOc,11682
|
|
6
|
-
QTube/utils/helpers.py,sha256=JPftVRBpOoV8PrwTDdaqCHFhHNaEGQAudHZQgfRq1fo,6833
|
|
7
|
-
QTube/utils/parsing.py,sha256=SAVrm-LK_wZQ4HmWNe-b3rxdSdac-i7KTzQxsQhLT_E,9237
|
|
8
|
-
QTube/utils/youtube/captions.py,sha256=9p5VcGESY3KPS3aDvoVyHLfQhiYnDdfkvJWOxcYv7ZM,1558
|
|
9
|
-
QTube/utils/youtube/channels.py,sha256=TJi2aasZuCgKKjPSBC5Hki8fK0wlWqwSTyyMF7ctVNU,3233
|
|
10
|
-
QTube/utils/youtube/playlists.py,sha256=gu9pMTzMI0Z6UyCZkjoxA1TiBfhlqySLJXcTeBOAh2g,3090
|
|
11
|
-
QTube/utils/youtube/videos.py,sha256=lepukLiX5pP8t2q9XI66_oPXDbu4Nxf0pDm9dxwbP-U,12709
|
|
12
|
-
QTube-2.1.1.dist-info/LICENSE.txt,sha256=cctyZoZUseENxkeq5p_C5oCFYpK0-ECku_sCZmWbL0E,1098
|
|
13
|
-
QTube-2.1.1.dist-info/METADATA,sha256=4Fdf2p-zsX0MC2HGGpiXZwED9Wq1oys-glWX3DhT4mA,16187
|
|
14
|
-
QTube-2.1.1.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
15
|
-
QTube-2.1.1.dist-info/entry_points.txt,sha256=Q3SLDyRahuzrNnHC3UOkMH5gM_Um3eCLiLG4vSTPjqE,51
|
|
16
|
-
QTube-2.1.1.dist-info/top_level.txt,sha256=z6oXrT8BiTTZuygjsbfe-NE4rmkHlDK8euAL9m6BT4A,6
|
|
17
|
-
QTube-2.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|