-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathirs.pyw
2535 lines (2187 loc) · 124 KB
/
irs.pyw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
''' NVIDIA Instant Replay auto-cutter 1/11/22 '''
from __future__ import annotations # 0.10mb / 13.45 https://stackoverflow.com/questions/33533148/how-do-i-type-hint-a-method-with-the-type-of-the-enclosing-class
import gc # 0.275mb / 13.625mb <- Heavy, but probably worth it
import os # 0.10mb / 13.45mb
import sys # 0.125mb / 13.475mb
import json
import time # 0.125mb / 13.475mb
import atexit
import ctypes
import hashlib
import logging # 0.65mb / 14.00mb
import subprocess # 0.125mb / 13.475mb
import tracemalloc # 0.125mb / 13.475mb
from threading import Thread # 0.125mb / 13.475mb
from datetime import datetime # 0.125mb / 13.475mb
from traceback import format_exc # 0.35mb / 13.70mb <- Heavy, but probably worth it
from contextlib import contextmanager
import pystray # 3.29mb / 16.64mb
import keyboard # 2.05mb / 15.40mb
import winsound # 0.21mb / 13.56mb ↓ https://stackoverflow.com/questions/3844430/how-to-get-the-duration-of-a-video-in-python
import pymediainfo # 3.75mb / 17.1mb https://stackoverflow.com/questions/15041103/get-total-length-of-videos-in-a-particular-directory-in-python
from pystray._util import win32
from win32_setctime import setctime
from configparsebetter import ConfigParseBetter
# Starts with roughly ~36.7mb of memory usage. Roughly 9.78mb combined from imports alone, without psutil and cv2/pymediainfo (9.63mb w/o tracemalloc).
tracemalloc.start() # start recording memory usage AFTER libraries have been imported
'''
TODO add arbitrary hotkeys/actions for moving videos to specified directories
TODO setting for limiting max title length in menu (truncate title with "...")
TODO use `util.foreground_is_fullscreen()` from pyplayer to selectively ignore non-recording hotkeys
- some sort of togglable setting, not related to the desktop-recording-mode setting
TODO auto-correct common json errors (like missing commas), save fixed version, then warn user
TODO custom ffmpeg commands to put into clip submenus
TODO add "clear duplicate entries" menu item
TODO extended backup system with more than 1 undo possible at a time
TODO extend multi-track recording options as a submenu
- remove microphone and/or system audio tracks
- boost microphone audio by preset amounts
- extract tracks as separate audio files
TODO show what "action" you can undo in the menu in some way (as a submenu?)
TODO ability to auto-rename folders using same system as aliases
TODO cropping ability -> pick crop region after saving instant replay OR before starting recording
- have ability to pick region before (?) and after recording
- this should be very lightweight and simple -> possible idea:
- use ffmpeg to extract a frame from a given clip (if done after recording)
- display frame fullscreen, then allow user to draw a region over the frame
TODO pystray subclass improvements
- add dynamic tooltips? (when hovering over icon)
- add double-click support? https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-lbuttondblclk
- left-clicks still get registered
- pystray's _mainloop is blocking
- an entire thread dedicated to handling double-clicks would likely be needed (unreasonable)
'''
# ---------------------------------
# --- Table of contents ---
# Aliases
# Base constants
# Logging
# Utility functions
# Temporary functions
# Video path
# Settings
# Registry settings
# Other constants & paths
# Custom Pystray class
# Custom keyboard listener
# Clip class
# Main class
# Clip helper methods
# Scanning for clips
# Clip actions
# Tray-icon functions
# Tray-icon setup
# ---------------------------------
# ---------------------
# Aliases
# ---------------------
parsemedia = pymediainfo.MediaInfo.parse
sep = os.sep
sepjoin = sep.join
pjoin = os.path.join
exists = os.path.exists
getstat = os.stat
getsize = os.path.getsize
basename = os.path.basename
dirname = os.path.dirname
abspath = os.path.abspath
splitext = os.path.splitext
splitpath = os.path.split
splitdrive = os.path.splitdrive
ismount = os.path.ismount
isdir = os.path.isdir
listdir = os.listdir
makedirs = os.makedirs
rename = os.rename
remove = os.remove
# ---------------------
# Base constants
# ---------------------
TITLE = 'Instant Replay Suite'
VERSION = '1.3.0'
REPOSITORY_URL = 'https://github.com/thisismy-github/instant-replay-suite'
# ---
IS_COMPILED = getattr(sys, 'frozen', False)
SCRIPT_START_TIME = time.time()
# current working directory
SCRIPT_PATH = sys.executable if IS_COMPILED else os.path.realpath(__file__)
CWD = dirname(SCRIPT_PATH)
os.chdir(CWD)
# other paths that will always be the same no matter what
RESOURCE_FOLDER = pjoin(CWD, 'resources')
BIN_FOLDER = pjoin(CWD, 'bin')
APPDATA_FOLDER = pjoin(os.path.expandvars('%LOCALAPPDATA%'), TITLE)
CONFIG_PATH = pjoin(CWD, 'config.settings.ini')
CUSTOM_MENU_PATH = pjoin(CWD, 'config.menu.ini')
SHADOWPLAY_REGISTRY_PATH = r'SOFTWARE\NVIDIA Corporation\Global\ShadowPlay\NVSPCAPS'
LOG_PATH = splitext(basename(SCRIPT_PATH))[0] + '.log'
MEDIAINFO_DLL_PATH = pjoin(BIN_FOLDER, 'MediaInfo.dll') if IS_COMPILED else None
# ---------------------
# Logging
# ---------------------
if os.path.exists(LOG_PATH): # backup existing log and delete outdated logs
max_logs = 10
log_path = LOG_PATH
temp_dir = f'{BIN_FOLDER}{sep}logs'
prefix, suffix = os.path.splitext(os.path.basename(log_path))
p_len = len(prefix)
s_len = len(suffix)
if os.path.isdir(temp_dir):
logs = [f for f in os.listdir(temp_dir) if f[:p_len] == prefix and f[-s_len:] == suffix]
for outdated_log in logs[:-(max_logs - 1)]:
try: os.remove(f'{temp_dir}{sep}{outdated_log}')
except: pass
new_name = f'{prefix}.{getstat(log_path).st_mtime_ns}{suffix}'
try: os.renames(log_path, f'{temp_dir}{sep}{new_name}')
except: pass
logging.basicConfig(
level=logging.INFO,
encoding='utf-16',
force=True,
format='{asctime} {lineno:<3} {levelname} {funcName}: {message}',
datefmt='%I:%M:%S%p',
style='{',
handlers=(logging.FileHandler(LOG_PATH, 'w', delay=False),
logging.StreamHandler()))
# ---------------------
# Utility functions
# ---------------------
#get_memory = lambda: psutil.Process().memory_info().rss / (1024 * 1024)
get_memory = lambda: tracemalloc.get_traced_memory()[0] / 1048576
def delete(path: str):
''' Robustly deletes a given `path`. '''
logging.info('Deleting: ' + path)
try:
if SEND_DELETED_FILES_TO_RECYCLE_BIN: send2trash.send2trash(path)
else: remove(path)
except:
logging.error(f'(!) Error while deleting file {path}: {format_exc()}')
play_alert('error')
def renames(old: str, new: str):
''' `os.py`'s super-rename, but without deleting empty directories. '''
head, tail = splitpath(new)
if head and tail and not exists(head):
makedirs(head)
rename(old, new)
def show_message(title: str, msg: str, flags: int = 0x00040030):
''' Displays a MessageBoxW on the screen with a `title` and
`msg`. Default `flags` are <!-symbol + stay on top>.
https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messageboxw '''
top_line = f'--- {title} ---'
bottom_line = '-' * len(top_line)
logging.info(f'Showing message box:\n\n{top_line}\n{msg}\n{bottom_line}\n')
return ctypes.windll.user32.MessageBoxW(None, msg, title, flags)
def play_alert(sound: str, default: str = None):
''' Plays a system-wide audio alert. `sound` is the filename of a WAV file
located within `RESOURCE_FOLDER`. Uses `default` if `sound` doesn't
exist. Falls back to a generic OS alert if `default` isn't provided
(or also doesn't exist). If `sound` or `default` is "error" and
"error.wav" doesn't exist, a generic OS error sound is used. '''
if not AUDIO: return
sound = str(sound).lower() # not actually necessary on Windows
path = pjoin(RESOURCE_FOLDER, f'{sound}.wav')
if not exists(path) and default is not None:
sound = str(default).lower()
path = pjoin(RESOURCE_FOLDER, f'{sound}.wav')
logging.info('Playing alert: ' + path)
if exists(path):
try:
winsound.PlaySound(path, winsound.SND_ASYNC)
except: # play OS error sound instead
winsound.MessageBeep(winsound.MB_ICONHAND)
logging.error(f'(!) Error while playing alert {path}: {format_exc()}')
else: # generic OS alert for missing file, OS error for actual errors
if sound == 'error': winsound.MessageBeep(winsound.MB_ICONHAND)
else: winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
logging.warning('(!) Alert doesn\'t exist at path ' + path)
def index_safe(log_message: str):
''' Decorator for handling methods that involve clip-access. Safely discards
IndexErrors and AssertionErrors, two "common" exceptions that can occur
while handling clips. Plays an error sound and Logs `log_message` for
other exceptions. Returns None for any type of exception. '''
def actual_decorator(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (IndexError, AssertionError):
return
except PermissionError:
play_alert('permission error')
logging.error(f'(!) Access denied {log_message}: {format_exc()}')
except Exception as error:
if str(error) == 'generator didn\'t yield':
return # happens if `edit()` fails before the yield
play_alert('error')
logging.error(f'(!) Error {log_message}: {format_exc()}')
return wrapper
return actual_decorator
@contextmanager
def edit(*clips: Clip, undo_action: str):
''' A context manager for editing an indeterminate number of `clips`.
Creates and yields a path (or list of paths) within `BACKUP_FOLDER`
that the given `Clip` objects have been moved to. `clips` are safely
restored to their original paths in the event of an error. After
exiting, an entry is written to the undo file with `undo_action` as
the suffix, while backups and our remaining `clips` are refreshed.
Usage: `with edit(clip1, undo_action='Trim') as backup_path:` '''
try: # enter code - creating backup paths and moving the clips
log_state = 'while preparing to perform edit'
timestamp = time.time_ns()
relative_backup_paths = []
absolute_backup_paths = []
for clip in clips: # construct relative/absolute paths and rename each clip
relative_backup_paths.append(pjoin(clip.game, f'{timestamp} {clip.name}'))
absolute_backup_paths.append(pjoin(BACKUP_FOLDER, relative_backup_paths[-1]))
renames(clip.path, absolute_backup_paths[-1])
# yield control - yielding the backup paths to the user
if len(absolute_backup_paths) == 1: yield absolute_backup_paths[0]
else: yield absolute_backup_paths
# exit code - writing to the undo file and refreshing clips
# NOTE: we DON'T want to do this after an error -> don't use a finally-statement
log_state = 'while cleaning up after performing edit'
with open(UNDO_LIST_PATH, 'w') as undo:
backups = ' -> '.join(relative_backup_paths)
original_paths = ' -> '.join(clip.path for clip in clips)
undo.write(f'{backups} -> {original_paths} -> {undo_action}\n')
cutter.refresh_backups(*absolute_backup_paths)
for clip in clips:
clip_path = clip.path
if exists(clip_path):
setctime(clip_path, clip.time) # retain original creation time
clip.refresh(clip_path)
# remove any new files created and restore clips to their original paths (unless it was a PermissionError)
except Exception as error:
if isinstance(error, PermissionError):
play_alert('permission error')
logging.error(f'(!) Access denied {log_state} "{undo_action}": {format_exc()}')
else:
play_alert('error')
logging.error(f'(!) Error {log_state} "{undo_action}": {format_exc()}')
for backup_path, clip in zip(absolute_backup_paths, clips):
clip_path = clip.path
try:
if exists(clip_path):
remove(clip_path)
renames(backup_path, clip_path)
except PermissionError:
pass
logging.info('Successfully restored clip(s) after error.')
def auto_rename_clip(path: str) -> tuple[str, str]:
''' Renames `path` according to `RENAME_FORMAT` and `RENAME_DATE_FORMAT`,
so long as `path` ends with a date formatted as `%Y.%m.%d - %H.%M.%S`,
which is found at the end of all ShadowPlay clip names. '''
try:
parts = basename(path).split()
parts[-1] = '.'.join(parts[-1].split('.')[:-3])
# do date first since that's the most likely thing to fail
date_string = ' '.join(parts[-3:])
date = datetime.strptime(date_string, '%Y.%m.%d - %H.%M.%S')
game = ' '.join(parts[:-3])
if game.lower() in GAME_ALIASES:
game = GAME_ALIASES[game.lower()]
folder = dirname(path)
template_base = RENAME_FORMAT.replace('?game', game).replace('?date', date.strftime(RENAME_DATE_FORMAT))
template_path = pjoin(folder, template_base) + '.mp4'
renamed_path = template_path
count_detected = '?count' in template_path
protected_paths = cutter.protected_paths # this is exactly what i want to avoid but i'm leaving it for now
if count_detected or exists(renamed_path) or renamed_path in protected_paths:
if not count_detected: # if forced to add number, use windows-style count: start from (2)
count = 2
template_path += ' (?count)'
else:
count = RENAME_COUNT_START_NUMBER
# users manually deleting/renaming clips results in disjointed clip numbering. to...
# ...fix this for any `RENAME_FORMAT`, we now track the latest count for the specific...
# ...template we're using (e.g. "l4d2 ?count 5.10.19.mp4")
count = TEMPLATE_COUNTS.setdefault(template_path, count)
while True:
count_string = str(count).zfill(RENAME_COUNT_PADDED_ZEROS + (1 if count >= 0 else 2))
renamed_path = template_path.replace('?count', count_string)
if not exists(renamed_path) and renamed_path not in protected_paths:
break
count += 1
# update the count for this specific template
# NOTE: DON'T add 1 here -> if we delete the latest clip or concat the latest two clips together, this...
# ...replicates the old behavior of "reusing" the same count for the next clip for those specific scenarios
TEMPLATE_COUNTS[template_path] = count
# check if we previously had a different template from this folder and remove it accordingly -> this way...
# ...we can keep `TEMPLATE_COUNTS` small even if `RENAME_FORMAT` is very specific (like down to the second)
if folder in TEMPLATE_FOLDERS:
old_template_path = TEMPLATE_FOLDERS[folder]
if old_template_path != template_path:
del TEMPLATE_COUNTS[old_template_path]
TEMPLATE_FOLDERS[folder] = template_path
renamed_base = basename(renamed_path)
logging.info(f'Renaming video to: {renamed_base}')
rename(path, renamed_path) # super-rename not needed
logging.info('Rename successful.') # use abspath to ensure consistent path formatting later on
return abspath(renamed_path), renamed_base
except Exception as error:
logging.warning(f'(!) Clip at {path} could not be renamed (maybe it was already renamed?): "{error}"')
return path, basename(path)
def ffmpeg(infile: str, cmd: str, outfile: str, copy_track_titles: bool = True):
''' Creates an FFmpeg `cmd` and waits for it to execute. If `infile` or
`outfile` are given, FFmpeg input/output parameters are generated and
added to `cmd` ("%in" may be included in `cmd` to manually specify
`infile`'s location, if desired). If `copy_track_titles` is True,
metadata parameters for transferring audio track titles from `infile`
(if provided) are generated and added to `cmd`. '''
if infile:
if '%in' not in cmd:
cmd = '%in ' + cmd
cmd = cmd.replace('%in', f'-i "{infile}"')
if outfile:
if not infile or not copy_track_titles:
cmd = f'{cmd} "{outfile}"'
else:
cmd = f'{cmd} {get_audio_track_title_cmd(infile)} "{outfile}"'
cmd = f'"{FFMPEG}" -y {cmd} -hide_banner -loglevel warning'
logging.info(cmd)
# NOTE: startupinfo is windows-only
STARTUPINFO = subprocess.STARTUPINFO()
STARTUPINFO.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.run(cmd, startupinfo=STARTUPINFO)
# ? -> https://stackoverflow.com/questions/10075176/python-native-library-to-read-metadata-from-videos
def get_video_duration(path: str) -> float:
''' Returns a precise duration for the video at `path`.
Returns 0 if `path` is corrupt or an invalid format. '''
# a `video_tracks` attribute exists but it's just a slower version of this
mediainfo = parsemedia(path, library_file=MEDIAINFO_DLL_PATH)
for track in mediainfo.tracks:
if track.track_type == 'Video':
return track.duration / 1000
return 0
def get_audio_track_count(path: str) -> int:
''' Returns the number of audio tracks in `path`. '''
# an `audio_tracks` attribute exists but it's just a slower version of this
audio_track_count = 0
mediainfo = parsemedia(path, library_file=MEDIAINFO_DLL_PATH)
for track in mediainfo.tracks:
if track.track_type == 'Audio':
audio_track_count += 1
return audio_track_count
def get_audio_track_title_cmd(*paths: str) -> str:
''' Creates arguments to insert into an FFmpeg command for setting the title
of each audio track in the command's output. Titles are taken from the
path in `paths` with the most audio tracks. '''
# get list of tracks for each path provided
audio_track_lists = {}
max_audio_track_count = -1
for path in paths:
mediainfo = parsemedia(path, library_file=MEDIAINFO_DLL_PATH)
audio_track_lists[path] = [t for t in mediainfo.tracks if t.track_type == 'Audio']
max_audio_track_count = max(max_audio_track_count, len(audio_track_lists[path]))
# generate generic track titles ("Track 2") as fallbacks
titles = [f'Track {index}' for index in range(1, max_audio_track_count + 1)]
# loop over every path that has the maximum number of tracks
for tracks in audio_track_lists.values():
if len(tracks) != max_audio_track_count:
continue
# strip 'SoundHandle / ' from the start of each track title
for index, track in enumerate(tracks):
title = track.title
if title[:11] == 'SoundHandle':
if title[11:14] == ' / ':
title = title[14:]
else:
title = title[11:]
# skip title it's empty
title = title.strip()
if not title:
continue
titles[index] = title
return ' '.join(
f'-metadata:s:a:{index} title="{title}"'
for index, title in enumerate(titles)
)
def check_for_updates(manual: bool = True):
''' Checks for updates and notifies user if one is found. If compiled,
user may download and install the update automatically, prompting
us to exit while it occurs. Also checks for `update_report.txt`,
left by a previous update. If `manual` is False, less cleanup is
done and if the `CHECK_FOR_UPDATES_ON_LAUNCH` setting is also
False, only the report is checked. '''
update_report = pjoin(CWD, 'update_report.txt')
update_report_exists = exists(update_report)
if manual or CHECK_FOR_UPDATES_ON_LAUNCH or update_report_exists:
import update
# set update constants here to avoid circular import
update.VERSION = VERSION
update.REPOSITORY_URL = REPOSITORY_URL
update.IS_COMPILED = IS_COMPILED
update.SCRIPT_PATH = SCRIPT_PATH
update.CWD = CWD
update.RESOURCE_FOLDER = RESOURCE_FOLDER
update.BIN_FOLDER = BIN_FOLDER
update.show_message = show_message
update.HYPERLINK = f'latest release on GitHub here:\n{REPOSITORY_URL}/releases/latest'
# validate previous update first if needed
if update_report_exists:
update.validate_update(update_report)
# check for updates and exit if we're installing a new version
if manual or CHECK_FOR_UPDATES_ON_LAUNCH:
if not IS_COMPILED:
update.check_for_update(manual)
else: # if compiled, override cacert.pem path...
import certifi.core # ...to get rid of a pointless folder
cacert_override_path = pjoin(BIN_FOLDER, 'cacert.pem')
os.environ["REQUESTS_CA_BUNDLE"] = cacert_override_path
certifi.core.where = lambda: cacert_override_path
# open a file that will only be closed when our script exits or we
# don't update -> this lets the updater tell when the script closes
lock_file = pjoin(BIN_FOLDER, f'{time.time_ns()}.updatecheck.txt')
with open(lock_file, 'w'):
exit_code = update.check_for_update(manual, lock_file=lock_file)
if exit_code is not None: # make sure we don't write a fresh...
if not manual: # ...config if we're about to update
atexit.unregister(cfg.write)
sys.exit(exit_code)
os.remove(lock_file) # remove lock file if we didn't close
def about():
''' Displays an "About" window with various information/statistics. '''
seconds_running = time.time() - SCRIPT_START_TIME
clip_count = len(cutter.last_clips)
if seconds_running < 60:
time_delta_string = 'Script has been running for less than a minute'
else:
d, seconds_running = divmod(seconds_running, 86400)
h, seconds_running = divmod(seconds_running, 3600)
m, seconds_running = divmod(seconds_running, 60)
suffix = f'{m:g} minute{"s" if m != 1 else ""}'
if h: suffix = f'{h:g} hour{"s" if h != 1 else ""}, {suffix}'
if d: suffix = f'{d:g} day{"s" if d != 1 else ""}, {suffix}'
time_delta_string = 'Script has been running for ' + suffix
msg = (f'» {TITLE} v{VERSION} «\n{REPOSITORY_URL}\n\n---\n'
f'{time_delta_string}\nScript is tracking '
f'{clip_count} clip{"s" if clip_count != 1 else ""}'
'\n---\n\n© thisismy-github 2022-2023')
show_message('About ' + TITLE, msg, 0x00010040) # i-symbol, set foreground
# ---------------------
# Temporary functions
# ---------------------
def abort_launch(code: int, title: str, msg: str, flags: int = 0x00040010):
''' Checks for updates, then displays/logs a `msg` with `title` using
`flags`, then exits with exit code `code`. Default `flags` are
<X-symbol + stay on top>. Only to be used during launch. '''
check_for_updates(manual=False)
show_message(title, msg, flags)
sys.exit(code)
def verify_ffmpeg() -> str:
''' Checks if FFmpeg exists. If it isn't in the script's folder, the
user's PATH system variable is checked. If still not found, the latest
build of `ffmpeg.exe` from FFmpeg-essentials may be downloaded and
extracted to `BIN_FOLDER`. Returns FFmpeg's final path. '''
logging.info('Verifying FFmpeg installation...')
if exists('ffmpeg.exe'): return pjoin(CWD, 'ffmpeg.exe')
for path in os.environ.get('PATH', '').split(';'):
try:
if 'ffmpeg.exe' in listdir(path):
return pjoin(path, 'ffmpeg.exe')
except:
pass
for root, dirs, files in os.walk(CWD):
if root == CWD:
continue
if 'ffmpeg.exe' in files:
return pjoin(root, 'ffmpeg.exe')
# ffmpeg not detected, download/extract it if we're compiled
msg = (f'FFmpeg was not detected. FFmpeg is required for {TITLE}\'s '
'editing features. Please ensure "ffmpeg.exe" is either in '
f'your install folder or your system PATH variable.')
if not IS_COMPILED: # don't write fresh config
if NO_CONFIG: atexit.unregister(cfg.write)
show_message('FFmpeg not detected', msg)
sys.exit(3)
else:
# ?-symbol, stay on top, Yes/No
msg += ('\n\nWould you like to automatically download the '
'latest build of FFmpeg to the "bin" folder?')
response = show_message('FFmpeg not detected', msg, 0x00040024)
if response == 6: # Yes
download_url = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
download_path = pjoin(CWD, f'ffmpeg.{time.time_ns()}.zip')
try:
# override cacert.pem path to get rid of a pointless folder
import certifi.core
cacert_override_path = pjoin(BIN_FOLDER, 'cacert.pem')
os.environ["REQUESTS_CA_BUNDLE"] = cacert_override_path
certifi.core.where = lambda: cacert_override_path
# download
import update
update.download(download_url, download_path)
# extract just "ffmpeg.exe" if we have enough space
from zipfile import ZipFile
with ZipFile(download_path, 'r') as zip:
for filename in zip.namelist():
if filename[-10:] == 'ffmpeg.exe':
size = zip.getinfo(filename).file_size
update.check_available_space(size, BIN_FOLDER)
zip.extract(filename, BIN_FOLDER)
break
# move ffmpeg.exe to bin folder
extracted_path = pjoin(BIN_FOLDER, filename)
final_path = pjoin(BIN_FOLDER, 'ffmpeg.exe')
logging.info(f'Moving {extracted_path} to {final_path}')
os.rename(extracted_path, final_path)
# delete excess ffmpeg folder that was created
import shutil
extracted_folder = pjoin(BIN_FOLDER, filename.split('/')[0])
logging.info(f'Deleting excess FFmpeg folder at {extracted_folder}')
try: shutil.rmtree(extracted_folder)
except: logging.warning('(!) Could not delete excess FFmpeg folder.')
# delete downloaded archive
logging.info('Deleting download path')
os.remove(download_path)
# flags are i-symbol, stay on top
msg = ('FFmpeg has been successfully downloaded. '
f'Size: {getsize(final_path) / 1048576:.1f}mb'
f'\n\n{final_path}')
show_message('FFmpeg download successful', msg, 0x00040040)
return final_path
except update.InsufficientSpaceError:
pass
except Exception as error:
# X-symbol, stay on top
msg = (f'FFmpeg download from "{download_url}" to '
f'"{download_path}" failed:\n\n{type(error)}: {error}')
show_message('FFmpeg download failed', msg, 0x00040010)
if exists(download_path):
try: os.remove(download_path)
except: logging.warning(f'(!) FFmpeg download at "{download_path}" could not be removed')
# exit if "No" is pressed or we errored out (and don't write a fresh config)
if NO_CONFIG:
atexit.unregister(cfg.write)
sys.exit(3)
def verify_config_files() -> str:
''' Displays a message if config and/or menu file is missing, then gives
user the option to create them immediately and quit or to continue
with a default menu/settings. Returns the config file's MD5 hash if
it existed, otherwise an empty string. '''
logging.info('Verifying config.settings and config.menu...')
if NO_CONFIG or NO_MENU:
if NO_CONFIG and NO_MENU: parts = ('config file or a menu file', 'them', 'files')
elif NO_CONFIG: parts = ('config file', 'it', 'file')
else: parts = ('menu file', 'it', 'file')
string1, string2, string3 = parts
msg = (f'You do not have a {string1}. Would you like to exit {TITLE} '
f'to create {string2} and review them now?\n\n'
'Press \'No\' if you want to continue with the default settings '
f'(the necessary {string3} will be created on exit).')
# ?-symbol, stay on top, Yes/No
response = show_message('Missing config/menu files', msg, 0x00040024)
if response == 6: # Yes
logging.info('"Yes" selected on missing config/menu dialog, closing...')
if NO_MENU: # create AFTER dialog is closed to avoid confusion
restore_menu_file()
sys.exit(1)
elif response == 7: # No
logging.info('"No" selected on missing config/menu dialog, using defaults.')
if NO_MENU: # create AFTER dialog is closed to avoid confusion
restore_menu_file()
# hash existing config file to see if it's been modified when we exit
if not NO_CONFIG:
with open(CONFIG_PATH, 'rb') as file:
return hashlib.md5(file.read()).hexdigest()
return ''
def sanitize_json(
path: str,
comment_prefix: str = '//',
allow_headless_lines: bool = True
) -> list[tuple(str, str)]:
''' Reads a JSON file at `path`, but fixes common errors/typos while
allowing comments and value-only lines (if `allow_headless_lines` is
True). Lines with `comment_prefix` are ignored. Returns the raw list
of key-value pairs before they're converted to a dictionary. Designed
for reading JSON files that are meant to be edited by users. '''
with open(path, 'r') as file:
striped = (line.strip() for line in file.readlines())
raw_json_lines = (line for line in striped if line and line[:2] != comment_prefix)
json_lines = []
for line in raw_json_lines:
# allow lines that only have a value with no key
if allow_headless_lines:
if ':' not in line and line not in ('{', '}', '},'):
line = '"": ' + line
# ensure all nested dictionaries have exactly one trailing comma
line = line.replace('}', '},')
while ' ,' in line: line = line.replace(' ,', ',')
while '},,' in line: line = line.replace('},,', '},')
json_lines.append(line)
# ensure final bracket exists and does not have a trailing comma
json_string = '\n'.join(json_lines).rstrip().rstrip(',').rstrip('}') + '}'
# remove trailing comma from final element of every dictionary
comma_index = json_string.find(',') # string ends with }, so we'll...
bracket_index = json_string.find('}') # ...run out of commas first
while comma_index != -1:
next_comma_index = json_string.find(',', comma_index + 1)
if next_comma_index > bracket_index or next_comma_index == -1:
quote_index = json_string.find('"', comma_index)
if quote_index > bracket_index or quote_index == -1:
start = json_string[:comma_index]
end = json_string[comma_index + 1:]
json_string = start + end
bracket_index = json_string.find('}', bracket_index + 1)
comma_index = next_comma_index
# our `object_pairs_hook` immediately returns the raw list of pairs
# instead of creating a dictionary, allowing us to use duplicate keys
return json.loads(json_string, object_pairs_hook=lambda pairs: pairs)
def load_menu() -> list[tuple(str, str)]:
''' Parse menu at `CUSTOM_MENU_PATH` and warn/exit if parsing fails. '''
try:
return sanitize_json(CUSTOM_MENU_PATH, '//')
except json.decoder.JSONDecodeError as error:
msg = ('Error while reading your custom menu file '
f'({CUSTOM_MENU_PATH}):\n\nJSONDecodeError - {error}'
'\n\nThe custom menu file follows JSON syntax. If you '
'need to reset your menu file, delete your existing '
f'one and restart {TITLE} to generate a fresh copy.')
show_message('Invalid Menu File', msg, 0x00040010)
sys.exit(2) # ^ X-symbol, stay on top
def restore_menu_file():
''' Creates a fresh menu file at `CUSTOM_MENU_PATH`. '''
logging.info(f'Creating fresh menu file at {CUSTOM_MENU_PATH}...')
with open(CUSTOM_MENU_PATH, 'w') as file:
file.write('''// --- CUSTOM TRAY MENU TUTORIAL ---
//
// This file defines a custom menu dictionary for your tray icon. It's JSON format,
// with some leeway in the formatting. Each item consists of a name-action pair,
// and actions may be named however you please. To create a submenu, add a nested
// dictionary as an action (see example below). Submenus work just like the base
// menu, and can be nested indefinitely. Actions without names will still be parsed,
// and will default to a blank name (see "Special tray actions" for exceptions).
//
// Normal tray actions:
// "open_log": Opens this program's log file.
// "open_video_folder": Opens the currently defined "Videos" folder.
// "open_install_folder": Opens this program's root folder.
// "open_backup_folder": Opens the currently defined backup folder.
// "open_settings": Opens "config.settings.ini".
// "open_menu_layout": Opens "config.menu.ini".
// "open_backup_folder": Opens the currently defined backup folder.
// "play_most_recent": Plays your most recent clip.
// "explore_most_recent": Opens your most recent clip in Explorer.
// "delete_most_recent": Deletes your most recent clip.
// "concatenate_last_two": Concatenates your two most recent clips.
// "undo": Undoes the last trim or concatenation.
// "clear_history": Clears your clip history.
// "refresh": Manually checks for new clips and refreshes existing ones.
// "check_for_updates": Checks for a new release on GitHub to install.
// "about": Shows an "About" window.
// "quit": Exits this program.
//
// Special tray actions:
// "separator": Adds a separator in the menu.
// - Cannot be named.
// ("separator") OR ("": "separator")
// "recent_clips": Displays your most recent clips.
// - Naming this item will place it within a submenu:
// ("Recent clips": "recent_clips")
// - Not naming this item will display all clips in the base menu:
// ("recent_clips") OR ("": "recent_clips")
// "memory": Displays current RAM usage.
// - This is somewhat misleading and not worth using.
// - Use "?memory" in the title to represent where the number will be:
// ("RAM: ?memory": "memory")
// - Not naming this item will default to "Memory usage: ?memorymb":
// ("memory") OR ("": "memory")
// - This item will be greyed out and is informational only.
// "cmd:": Adds a custom command to the menu.
// - Any command may be specified after the colon.
// - Use "?latest" to represent the path to the latest clip.
// - Quotes and backslashes must be escaped:
// ("cmd:echo ?latest > \".\\test.txt\")
//
// Submenu example:
// {
// "Quick actions": {
// "Play most recent clip": "play_most_recent",
// "View last clip in explorer": "explore_most_recent",
// "Concatenate last two clips": "concatenate_last_two",
// "Delete most recent clip": "delete_most_recent",
// "Open Notepad": "cmd:notepad.exe"
// },
// }
// ---------------------------------------------------------------------------
{
\t"Open...": {
\t\t"Open root": "open_install_folder",
\t\t"Open videos": "open_video_folder",
\t\t"Open backups": "open_backup_folder",
\t\t"Open settings": "open_settings",
\t\t"Customize menu": "open_menu_layout",
\t\t"separator",
\t\t"Update check": "check_for_updates",
\t\t"About...": "about",
\t},
\t"View log": "open_log",
\t"separator",
\t"Play last clip": "play_most_recent",
\t"Explore last clip": "explore_most_recent",
\t"Undo last action": "undo",
\t"separator",
\t"recent_clips",
\t"separator",
\t"Refresh clips": "refresh",
\t"Clear history": "clear_history",
\t"Exit": "quit",
}''')
# ---------------------
# Video path
# ---------------------
# We do this before reading config file to set a hint for whether or not
# `SAVE_BACKUPS_TO_VIDEO_FOLDER` should default to True or False later.
try: # gets ShadowPlay's video path from the registry
import winreg # NOTE: ShadowPlay settings are encoded in utf-16 and have a NULL character at the end
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, SHADOWPLAY_REGISTRY_PATH)
VIDEO_FOLDER = winreg.QueryValueEx(key, 'DefaultPathW')[0].decode('utf-16')[:-1]
except: # don't show message box until later
logging.warning('(!) Could not read video folder from registry: ' + format_exc())
VIDEO_FOLDER = None
# True if `VIDEO_FOLDER` and `CWD` are on different drives
BACKUP_FOLDER_HINT = (VIDEO_FOLDER and (splitdrive(VIDEO_FOLDER)[0] != splitdrive(CWD)[0]
or ismount(VIDEO_FOLDER) != ismount(CWD)))
# ---------------------
# Settings
# ---------------------
config_read_start = time.time()
cfg = ConfigParseBetter(
CONFIG_PATH,
caseSensitive=True,
comment_prefixes=('//',)
)
# --- Misc settings ---
cfg.setSection(' --- General --- ')
AUDIO = cfg.load('AUDIO', True)
MAX_BACKUPS = cfg.load('MAX_BACKUPS', 10)
TRAY_RECENT_CLIP_COUNT = cfg.load('MAX_CLIPS_VISIBLE_IN_MENU', 10)
CHECK_FOR_UPDATES_ON_LAUNCH = cfg.load('CHECK_FOR_UPDATES_ON_LAUNCH', True)
CHECK_FOR_NEW_CLIPS_ON_LAUNCH = cfg.load('CHECK_FOR_NEW_CLIPS_ON_LAUNCH', True)
SEND_DELETED_FILES_TO_RECYCLE_BIN = cfg.load('SEND_DELETED_FILES_TO_RECYCLE_BIN', True)
# --- Hotkeys ---
cfg.setSection(' --- Trim Hotkeys --- ')
cfg.comment('''Usage: <hotkey> = <trim length>
Use "custom" as the <trim length> to enter a length on your
keyboard in real-time after pressing the associated <hotkey>.
Press Esc to cancel a custom trim.''')
LENGTH_DICTIONARY = {}
for key, length in cfg.loadAllFromSection():
length = length.strip().strip('"\'`').lower()
if length == 'custom':
LENGTH_DICTIONARY[key] = length
else:
try: LENGTH_DICTIONARY[key] = int(float(length))
except: logging.warning(f'(!) Could not add trim length "{length}"')
if not LENGTH_DICTIONARY:
LENGTH_DICTIONARY = {
'alt + 1': 10,
'alt + 2': 20,
'alt + 3': 30,
'alt + 4': 40,
'alt + 5': 50,
'alt + 6': 60,
'alt + 7': 70,
'alt + 8': 80,
'alt + 9': 90,
'alt + 0': 'custom'
}
for name, alias in LENGTH_DICTIONARY.items():
cfg.load(name, alias)
cfg.setSection(' --- Other Hotkeys --- ')
cfg.comment('Leaving a hotkey blank will unbind it.')
CONCATENATE_HOTKEY = cfg.load('CONCATENATE', 'alt + c')
MERGE_TRACKS_HOTKEY = cfg.load('MERGE_AUDIO_TRACKS', 'alt + m')
DELETE_HOTKEY = cfg.load('DELETE', 'ctrl + alt + d')
UNDO_HOTKEY = cfg.load('UNDO', 'alt + u')
PLAY_HOTKEY = cfg.load('PLAY_MOST_RECENT', 'alt + p')
# --- Rename formatting ---
cfg.setSection(' --- Renaming Clips --- ')
cfg.comment('''NAME_FORMAT variables:
?game - The game being played. The game's name will be swapped
for an available alias if `USE_GAME_ALIASES` is True.
?date - The clip's timestamp. The timestamp's format is specified by
DATE_FORMAT - see https://strftime.org/ for date formatting.
?count - A count given to create unique clip names. Only increments
when the clip's name already exists. Best used when ?date
is absent or isn't very specific (by default, DATE_FORMAT
only saves the day, not the exact time of a clip).''')
RENAME = cfg.load('AUTO_RENAME_CLIPS', True)
USE_GAME_ALIASES = cfg.load('USE_GAME_ALIASES', True)
RENAME_FORMAT = cfg.load('NAME_FORMAT', '?game ?date #?count')
RENAME_DATE_FORMAT = cfg.load('DATE_FORMAT', '%y.%m.%d')
RENAME_COUNT_START_NUMBER = cfg.load('COUNT_START_NUMBER', 1)
RENAME_COUNT_PADDED_ZEROS = cfg.load('COUNT_PADDED_ZEROS', 1)
# --- Game aliases ---
cfg.setSection(' --- Game Aliases --- ')
cfg.comment('''This section defines aliases to use for ?game in `NAME_FORMAT` for renaming
clips. Not case-sensitive. || Usage: <ShadowPlay\'s name> = <custom name>''')
if USE_GAME_ALIASES: # lowercase and remove double-spaces from names
GAME_ALIASES = {' '.join(name.lower().split()): alias for name, alias in cfg.loadAllFromSection()}
if not GAME_ALIASES:
GAME_ALIASES = {
"Left 4 Dead": "L4D1",
"Left 4 Dead 2": "L4D2",
"Battlefield 4": "BF4",
"Dead by Daylight": "DBD",
"Counter-Strike Global Offensive": "CSGO",
"Counter-Strike 2": "CS2",
"The Binding of Isaac Rebirth": "TBOI",
"Team Fortress 2": "TF2",
"Tom Clancy's Rainbow Six Siege": "R6"
}
for name, alias in GAME_ALIASES.items():
cfg.load(name, alias)
else:
GAME_ALIASES = {}
# --- Paths ---
cfg.setSection(' --- Paths --- ')
ICON_PATH = cfg.load('CUSTOM_ICON', '' if IS_COMPILED else 'executable\\icon_main.ico', remove='"')
BACKUP_FOLDER = cfg.load('BACKUP_FOLDER', 'Backups', remove='"')
HISTORY_PATH = cfg.load('HISTORY', 'history.txt', remove='"')
UNDO_LIST_PATH = cfg.load('UNDO_LIST', 'undo.txt', remove='"')
OLD_COUNTS_PATH = cfg.load('COUNT_PATH', 'count.txt', remove='"')
cfg.setSection(' --- Special Folders --- ')
cfg.comment('''These only apply if the associated path
in [Paths] is not an absolute path.''')
SAVE_HISTORY_TO_APPDATA_FOLDER = cfg.load('SAVE_HISTORY_TO_APPDATA_FOLDER', False)
SAVE_UNDO_LIST_TO_APPDATA_FOLDER = cfg.load('SAVE_UNDO_LIST_TO_APPDATA_FOLDER', False)
SAVE_BACKUPS_TO_APPDATA_FOLDER = cfg.load('SAVE_BACKUPS_TO_APPDATA_FOLDER', False)
SAVE_BACKUPS_TO_VIDEO_FOLDER = cfg.load('SAVE_BACKUPS_TO_VIDEO_FOLDER', BACKUP_FOLDER_HINT)
cfg.setSection(' --- Ignored Folders --- ')
cfg.comment('''Subfolders in the video folder that will be ignored during scans.
Names must be enclosed in quotes and comma-separated. Base names
only, i.e. '"Other", "Movies"'. Not case-sensitive.''')
lines = cfg.load('IGNORED_FOLDERS').split(',')
cleaned = (folder.strip().strip('"').strip().lower() for folder in lines)
IGNORE_VIDEOS_IN_THESE_FOLDERS = tuple(folder for folder in cleaned if folder)
del lines
del cleaned
# --- Tray menu ---
cfg.setSection(' --- Tray Menu --- ')
cfg.comment(f'''If `USE_CUSTOM_MENU` is True, {basename(CUSTOM_MENU_PATH)} is used to
create a custom menu. See {basename(CUSTOM_MENU_PATH)} for more information.
If deleted, set this to True and restart {TITLE}.''')
TRAY_ADVANCED_MODE = cfg.load('USE_CUSTOM_MENU', True)
# --- Basic mode (TRAY_ADVANCED_MODE = False) only ---
cfg.comment('Only used if `USE_CUSTOM_MENU` is False:', before='\n')
TRAY_SHOW_QUICK_ACTIONS = cfg.load('SHOW_QUICK_ACTIONS', True)
TRAY_RECENT_CLIPS_IN_SUBMENU = cfg.load('PUT_RECENT_CLIPS_IN_SUBMENU', False)