-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmain.py
executable file
·999 lines (852 loc) · 39.3 KB
/
main.py
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
import sys
import os
import appdirs
import threading
import time
import signal
import logging
import platform
import psutil
import webbrowser
import socket
from datetime import datetime
# Import Windows-specific modules only on Windows
if platform.system() == 'Windows':
import win32gui
import win32con
# Existing imports
import shutil
import requests
import re
import subprocess
from settings import set_setting
from settings import get_setting
from logging_config import stop_global_profiling, start_global_profiling
import babelfish
if sys.platform.startswith('win'):
app_name = "cli_debrid" # Replace with your app's name
app_author = "cli_debrid" # Replace with your company name
base_path = appdirs.user_data_dir(app_name, app_author)
os.environ['USER_CONFIG'] = os.path.join(base_path, 'config')
os.environ['USER_LOGS'] = os.path.join(base_path, 'logs')
os.environ['USER_DB_CONTENT'] = os.path.join(base_path, 'db_content')
else:
os.environ.setdefault('USER_CONFIG', '/user/config')
os.environ.setdefault('USER_LOGS', '/user/logs')
os.environ.setdefault('USER_DB_CONTENT', '/user/db_content')
# Ensure directories exist
for dir_path in [os.environ['USER_CONFIG'], os.environ['USER_LOGS'], os.environ['USER_DB_CONTENT']]:
os.makedirs(dir_path, exist_ok=True)
print(f"USER_CONFIG: {os.environ['USER_CONFIG']}")
print(f"USER_LOGS: {os.environ['USER_LOGS']}")
print(f"USER_DB_CONTENT: {os.environ['USER_DB_CONTENT']}")
import logging
import shutil
import signal
import time
from api_tracker import api
from settings import get_setting
import requests
import re
from settings import set_setting
import subprocess
import threading
from logging_config import stop_global_profiling, start_global_profiling
import babelfish
# Global variables
program_runner = None
metadata_process = None
metadata_lock = threading.Lock()
def get_babelfish_data_dir():
return os.path.join(os.path.dirname(babelfish.__file__), 'data')
def setup_logging():
logging.getLogger('selector').setLevel(logging.WARNING)
logging.getLogger('asyncio').setLevel(logging.WARNING)
# Get log directory from environment variable with fallback
log_dir = os.environ.get('USER_LOGS', '/user/logs')
# Ensure logs directory exists
os.makedirs(log_dir, exist_ok=True)
# Ensure log files exist
for log_file in ['debug.log', 'info.log', 'queue.log']:
log_path = os.path.join(log_dir, log_file)
if not os.path.exists(log_path):
open(log_path, 'a').close()
import logging_config
logging_config.setup_logging()
def setup_directories():
# Get config directory from environment variable
config_dir = os.environ.get('USER_CONFIG', '/user/config')
log_dir = os.environ.get('USER_LOGS', '/user/logs')
db_content_dir = os.environ.get('USER_DB_CONTENT', '/user/db_content')
# Ensure directories exist
os.makedirs(config_dir, exist_ok=True)
os.makedirs(log_dir, exist_ok=True)
os.makedirs(db_content_dir, exist_ok=True)
def backup_config():
# Get config directory from environment variable with fallback
config_dir = os.environ.get('USER_CONFIG', '/user/config')
config_path = os.path.join(config_dir, 'config.json')
if os.path.exists(config_path):
backup_path = os.path.join(config_dir, 'config_backup.json')
shutil.copy2(config_path, backup_path)
logging.info(f"Backup of config.json created: {backup_path}")
else:
logging.warning("config.json not found, no backup created.")
def backup_database():
"""
Creates a backup of the media_items.db file with a timestamp.
Keeps only the two most recent backups.
"""
try:
# Get db_content directory from environment variable
db_content_dir = os.environ.get('USER_DB_CONTENT', '/user/db_content')
db_path = os.path.join(db_content_dir, 'media_items.db')
if not os.path.exists(db_path):
logging.warning("media_items.db not found, no backup created.")
return
# Create backup directory if it doesn't exist
backup_dir = os.path.join(db_content_dir, 'backups')
os.makedirs(backup_dir, exist_ok=True)
# Generate backup filename with timestamp
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = os.path.join(backup_dir, f'media_items_{timestamp}.db')
# Create the backup
shutil.copy2(db_path, backup_path)
logging.info(f"Backup of media_items.db created: {backup_path}")
# Get list of existing backups and sort by modification time
existing_backups = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)
if f.startswith('media_items_') and f.endswith('.db')]
existing_backups.sort(key=lambda x: os.path.getmtime(x), reverse=True)
# Remove older backups, keeping only the two most recent
for old_backup in existing_backups[2:]:
os.remove(old_backup)
logging.info(f"Removed old backup: {old_backup}")
except Exception as e:
logging.error(f"Error creating database backup: {str(e)}")
def get_version():
try:
# Get the application path based on whether we're frozen or not
if getattr(sys, 'frozen', False):
application_path = sys._MEIPASS
else:
application_path = os.path.dirname(os.path.abspath(__file__))
version_path = os.path.join(application_path, 'version.txt')
with open(version_path, 'r') as version_file:
# Read the exact contents and only strip whitespace
version = version_file.readline().strip()
return version # Return immediately to avoid any further processing
except FileNotFoundError:
logging.error("version.txt not found")
return "0.0.0"
except Exception as e:
logging.error(f"Error reading version: {e}")
return "0.0.0"
def signal_handler(signum, frame):
stop_program(from_signal=True)
# Exit directly when handling SIGINT
if signum == signal.SIGINT:
stop_global_profiling()
sys.exit(0)
def update_web_ui_state(state):
try:
port = int(os.environ.get('CLI_DEBRID_PORT', 5000))
api.post(f'http://localhost:{port}/api/update_program_state', json={'state': state})
except api.exceptions.RequestException:
logging.error("Failed to update web UI state")
def check_metadata_service():
grpc_url = get_setting('Metadata Battery', 'url')
battery_port = int(os.environ.get('CLI_DEBRID_BATTERY_PORT', 5001))
# Remove leading "http://" or "https://"
grpc_url = re.sub(r'^https?://', '', grpc_url)
# Remove any trailing port numbers and slashes
grpc_url = re.sub(r':\d+/?$', '', grpc_url)
# Append ":50051"
grpc_url += ':50051'
try:
channel = grpc.insecure_channel(grpc_url)
stub = metadata_service_pb2_grpc.MetadataServiceStub(channel)
# Try to make a simple call to check connectivity
stub.TMDbToIMDb(metadata_service_pb2.TMDbRequest(tmdb_id="1"), timeout=5)
logging.info(f"Successfully connected to metadata service at {grpc_url}")
return grpc_url
except grpc.RpcError:
logging.warning(f"Failed to connect to {grpc_url}, falling back to localhost")
fallback_urls = ['localhost:50051', 'cli_battery_app:50051']
for url in fallback_urls:
try:
channel = grpc.insecure_channel(url)
stub = metadata_service_pb2_grpc.MetadataServiceStub(channel)
stub.TMDbToIMDb(metadata_service_pb2.TMDbRequest(tmdb_id="1"), timeout=5)
logging.info(f"Successfully connected to metadata service at {url}")
return url
except grpc.RpcError:
logging.warning(f"Failed to connect to metadata service at {url}")
logging.error("Failed to connect to metadata service on all fallback options")
return None
def package_app():
try:
# Determine the path to version.txt and other resources
version_path = os.path.join(os.path.dirname(__file__), 'version.txt')
templates_path = os.path.join(os.path.dirname(__file__), 'templates')
cli_battery_path = os.path.join(os.path.dirname(__file__), 'cli_battery')
database_path = os.path.join(os.path.dirname(__file__), 'database')
content_checkers_path = os.path.join(os.path.dirname(__file__), 'content_checkers')
debrid_path = os.path.join(os.path.dirname(__file__), 'debrid')
metadata_path = os.path.join(os.path.dirname(__file__), 'metadata')
queues_path = os.path.join(os.path.dirname(__file__), 'queues')
routes_path = os.path.join(os.path.dirname(__file__), 'routes')
scraper_path = os.path.join(os.path.dirname(__file__), 'scraper')
static_path = os.path.join(os.path.dirname(__file__), 'static')
utilities_path = os.path.join(os.path.dirname(__file__), 'utilities')
icon_path = os.path.join(os.path.dirname(__file__), 'static', 'white-icon-32x32.png')
# Get babelfish data directory
babelfish_data = get_babelfish_data_dir()
# Add the path to tooltip_schema.json
tooltip_schema_path = os.path.join(os.path.dirname(__file__), 'tooltip_schema.json')
# Construct the PyInstaller command
command = [
"pyinstaller",
"--onefile",
#"--windowed", # Add this option to prevent the console window
"--icon", icon_path, # Use your icon for the application
"--add-data", f"{version_path};.",
"--add-data", f"{babelfish_data};babelfish/data",
"--add-data", f"{templates_path};templates",
"--add-data", f"{cli_battery_path};cli_battery",
"--add-data", f"{database_path};database",
"--add-data", f"{content_checkers_path};content_checkers",
"--add-data", f"{debrid_path};debrid",
"--add-data", f"{metadata_path};metadata",
"--add-data", f"{queues_path};queues",
"--add-data", f"{routes_path};routes",
"--add-data", f"{scraper_path};scraper",
"--add-data", f"{static_path};static",
"--add-data", f"{utilities_path};utilities",
"--add-data", f"{icon_path};static",
# Add the tooltip_schema.json file
"--add-data", f"{tooltip_schema_path};.",
"--additional-hooks-dir", "hooks",
"--hidden-import", "database",
"--hidden-import", "database.core",
"--hidden-import", "database.collected_items",
"--hidden-import", "database.blacklist",
"--hidden-import", "database.schema_management",
"--hidden-import", "database.poster_management",
"--hidden-import", "database.statistics",
"--hidden-import", "database.wanted_items",
"--hidden-import", "database.database_reading",
"--hidden-import", "database.database_writing",
"--hidden-import", ".MetaData",
"--hidden-import", ".config",
"--hidden-import", ".main",
"--hidden-import", "content_checkers.trakt",
"--hidden-import", "logging_config",
"--hidden-import", "main",
"--hidden-import", "metadata.Metadata",
"main.py"
]
# Run the PyInstaller command
subprocess.run(command, check=True)
print("App packaged successfully. Executable is in the 'dist' folder.")
except subprocess.CalledProcessError as e:
print(f"An error occurred while packaging the app: {e}")
# Function to check if running as a packaged executable
def is_frozen():
return getattr(sys, 'frozen', False)
# Modify the setup_tray_icon function
def setup_tray_icon():
# Only proceed if we're on Windows
if platform.system() != 'Windows':
logging.info("Tray icon is only supported on Windows")
return
logging.info("Starting setup_tray_icon function")
# Check for FFmpeg installation
def check_ffmpeg():
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5, check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return False
def install_ffmpeg():
try:
if platform.system() != 'Windows':
logging.warning("FFmpeg automatic installation is only supported on Windows")
return False
logging.info("Attempting to install FFmpeg using winget...")
# Check if winget is available first
try:
subprocess.run(['winget', '--version'], capture_output=True, timeout=5, check=True)
except (FileNotFoundError, subprocess.TimeoutExpired):
logging.info("Winget not available or not responding, attempting manual FFmpeg installation...")
try:
import requests
import zipfile
import winreg
# Create FFmpeg directory in AppData
appdata = os.path.join(os.environ['LOCALAPPDATA'], 'FFmpeg')
os.makedirs(appdata, exist_ok=True)
# Download FFmpeg
url = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
logging.info("Downloading FFmpeg...")
response = requests.get(url, stream=True, timeout=30)
zip_path = os.path.join(appdata, 'ffmpeg.zip')
with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# Extract FFmpeg
logging.info("Extracting FFmpeg...")
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(appdata)
# Find the bin directory in extracted contents
extracted_dir = next(d for d in os.listdir(appdata) if d.startswith('ffmpeg-'))
bin_path = os.path.join(appdata, extracted_dir, 'bin')
# Add to PATH
logging.info("Adding FFmpeg to PATH...")
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_ALL_ACCESS)
current_path = winreg.QueryValueEx(key, 'Path')[0]
if bin_path not in current_path:
new_path = current_path + ';' + bin_path
winreg.SetValueEx(key, 'Path', 0, winreg.REG_EXPAND_SZ, new_path)
winreg.CloseKey(key)
# Notify Windows of environment change
import win32gui, win32con
win32gui.SendMessage(win32con.HWND_BROADCAST, win32con.WM_SETTINGCHANGE, 0, 'Environment')
# Clean up zip file
os.remove(zip_path)
logging.info("FFmpeg installed successfully")
# Update current process environment
os.environ['PATH'] = new_path
return True
except Exception as e:
logging.error(f"Error during manual FFmpeg installation: {e}")
return False
# Install FFmpeg using winget with auto-accept
try:
# Install FFmpeg with auto-accept
logging.info("Installing FFmpeg (this may take a few minutes)...")
process = subprocess.Popen(
['winget', 'install', '--id', 'Gyan.FFmpeg', '--source', 'winget', '--accept-package-agreements'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=1 # Line buffered
)
# Print output in real-time
try:
while True:
output = process.stdout.readline()
if output:
output = output.strip()
if output: # Only log non-empty lines
logging.info(f"winget: {output}")
# If we see the download URL, we know it's starting
if "Downloading" in output:
logging.info("Download started - please wait...")
error = process.stderr.readline()
if error:
error = error.strip()
if error: # Only log non-empty lines
logging.error(f"winget error: {error}")
# If process has finished and no more output, break
if output == '' and error == '' and process.poll() is not None:
break
except KeyboardInterrupt:
logging.warning("Installation interrupted by user")
process.terminate()
return False
if process.returncode == 0:
logging.info("FFmpeg installed successfully")
return True
else:
logging.error(f"Failed to install FFmpeg with winget (exit code: {process.returncode})")
# Fallback to manual installation
logging.info("Falling back to manual installation...")
return install_ffmpeg() # Recursive call will try manual installation
except subprocess.TimeoutExpired:
logging.error("Winget installation timed out, falling back to manual installation...")
return install_ffmpeg() # Recursive call will try manual installation
except Exception as e:
logging.error(f"Error installing FFmpeg: {e}")
return False
# Check and install FFmpeg if needed
if not check_ffmpeg():
logging.info("FFmpeg not found on system, attempting to install...")
if not install_ffmpeg():
logging.warning("Failed to install FFmpeg. Some video processing features may not work.")
else:
logging.info("FFmpeg installation completed successfully")
else:
logging.info("FFmpeg is already installed")
import socket
ip_address = socket.gethostbyname(socket.gethostname())
# Launch browser after 2 seconds
def delayed_browser_launch():
time.sleep(2) # Wait for 2 seconds
try:
port = int(os.environ.get('CLI_DEBRID_PORT', 5000))
if check_localhost_binding(port):
webbrowser.open(f'http://localhost:{port}')
logging.info("Browser launched successfully")
else:
logging.error(f"Failed to bind to localhost:{port}")
except Exception as e:
logging.error(f"Failed to launch browser: {e}")
# Start browser launch in a separate thread
browser_thread = threading.Thread(target=delayed_browser_launch)
browser_thread.daemon = True
browser_thread.start()
# Import required modules
try:
import pystray
from pystray import MenuItem as item
from PIL import Image
import win32gui
import win32con
logging.info("Successfully imported pystray, PIL, and Windows modules")
except ImportError as e:
logging.error(f"Failed to import required modules: {e}")
return
def minimize_to_tray():
# Find and hide both the main window and console window
def enum_windows_callback(hwnd, _):
window_text = win32gui.GetWindowText(hwnd)
logging.debug(f"Found window: {window_text}")
if "cli_debrid" in window_text.lower() and window_text.lower().endswith(".exe"):
logging.info(f"Hiding window: {window_text}")
win32gui.ShowWindow(hwnd, win32con.SW_HIDE)
win32gui.EnumWindows(enum_windows_callback, None)
def restore_from_tray(icon):
# Show both the main window and console window
def enum_windows_callback(hwnd, _):
window_text = win32gui.GetWindowText(hwnd)
if "cli_debrid" in window_text.lower() and window_text.lower().endswith(".exe"):
win32gui.ShowWindow(hwnd, win32con.SW_SHOW)
win32gui.SetForegroundWindow(hwnd)
win32gui.EnumWindows(enum_windows_callback, None)
def restore_menu_action(icon, item):
restore_from_tray(icon)
def hide_to_tray(icon, item):
minimize_to_tray()
def on_exit(icon, item):
logging.info("Exit option selected from system tray")
icon.stop()
# Stop all processes
global program_runner, metadata_process
print("\nStopping the program...")
# Stop the main program runner
if 'program_runner' in globals() and program_runner:
program_runner.stop()
print("Main program stopped.")
# Terminate the metadata battery process
with metadata_lock:
if metadata_process and metadata_process.poll() is None:
print("Stopping metadata battery...")
metadata_process.terminate()
try:
metadata_process.wait(timeout=5)
except subprocess.TimeoutExpired:
metadata_process.kill()
print("Metadata battery stopped.")
# Find and terminate all related processes
current_process = psutil.Process()
children = current_process.children(recursive=True)
for child in children:
print(f"Terminating child process: {child.pid}")
child.terminate()
# Wait for all child processes to terminate
_, still_alive = psutil.wait_procs(children, timeout=5)
# If any processes are still alive, kill them
for p in still_alive:
print(f"Force killing process: {p.pid}")
p.kill()
print("All processes terminated.")
# Force kill all cli_debrid processes
if is_frozen():
exe_name = os.path.basename(sys.executable)
else:
exe_name = "cli_debrid-" + get_version() + ".exe"
subprocess.run(['taskkill', '/F', '/IM', exe_name], shell=True)
# Create the menu
menu = (
item('Show', restore_menu_action),
item('Hide', hide_to_tray),
item('Exit', on_exit),
)
# Get the icon path
if getattr(sys, 'frozen', False):
application_path = sys._MEIPASS
else:
application_path = os.path.dirname(os.path.abspath(__file__))
# List all windows to help with debugging
def list_windows():
def callback(hwnd, _):
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if title:
logging.info(f"Visible window: {title}")
win32gui.EnumWindows(callback, None)
logging.info("Listing all visible windows:")
list_windows()
icon_path = os.path.join(application_path, 'static', 'white-icon-32x32.png')
# If the icon doesn't exist in the frozen path, try the static directory
if not os.path.exists(icon_path):
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'white-icon-32x32.png')
logging.info(f"Using icon path: {icon_path}")
try:
image = Image.open(icon_path)
import socket
ip_address = socket.gethostbyname(socket.gethostname())
icon = pystray.Icon("CLI Debrid", image, f"CLI Debrid\nMain app: localhost:5000\nBattery: localhost:5001", menu)
# Set up double-click handler
icon.on_activate = restore_from_tray
# Minimize the window to tray when the icon is created
minimize_to_tray()
icon.run()
except Exception as e:
logging.error(f"Failed to create or run system tray icon: {e}")
return
def check_localhost_binding(port=5000):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', port))
sock.close()
return True
except socket.error:
logging.error(f"Failed to bind to localhost:{port}")
stop_program()
return False
# Modify the stop_program function
def stop_program(from_signal=False):
global program_runner, metadata_process
print("\nStopping the program...")
# Stop the main program runner
if 'program_runner' in globals() and program_runner:
program_runner.stop()
print("Main program stopped.")
# Terminate the metadata battery process
with metadata_lock:
if metadata_process and metadata_process.poll() is None:
print("Stopping metadata battery...")
metadata_process.terminate()
try:
metadata_process.wait(timeout=5)
except subprocess.TimeoutExpired:
metadata_process.kill()
print("Metadata battery stopped.")
# Find and terminate all related processes
try:
current_process = psutil.Process()
children = current_process.children(recursive=True)
for child in children:
print(f"Terminating child process: {child.pid}")
child.terminate()
# Wait for all child processes to terminate
_, still_alive = psutil.wait_procs(children, timeout=5)
# If any processes are still alive, kill them
for p in still_alive:
print(f"Force killing process: {p.pid}")
p.kill()
print("All processes terminated.")
except Exception as e:
print(f"Error while terminating processes: {e}")
# Only send the interrupt signal if not already handling a signal
if not from_signal:
os.kill(os.getpid(), signal.SIGINT)
def update_media_locations():
"""
Startup function that reviews database items and updates their location_on_disk field
by checking the zurg_all_folder location.
"""
import os
from database.core import get_db_connection
import logging
from time import time
import subprocess
import shlex
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
BATCH_SIZE = 1000
MAX_WORKERS = 4 # Number of parallel workers
def build_file_map(zurg_all_folder):
"""Build a map of filenames to their full paths using find"""
cmd = f"find {shlex.quote(zurg_all_folder)} -type f"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logging.error(f"Error running find command: {result.stderr}")
return {}
# Create a map of filename -> list of full paths
file_map = defaultdict(list)
for path in result.stdout.splitlines():
if path:
filename = os.path.basename(path)
file_map[filename].append(path)
logging.info(f"Built file map with {len(file_map)} unique filenames")
return file_map
def process_batch(items_batch, file_map, zurg_all_folder):
"""Process a batch of items and return updates"""
updates = []
for item_id, filled_by_file, media_type in items_batch:
if not filled_by_file:
continue
# Check if file exists in our map
if filled_by_file in file_map:
paths = file_map[filled_by_file]
if len(paths) == 1:
# Single match - use it
updates.append((paths[0], item_id))
else:
# Multiple matches - try to find best match
# First try exact folder match
direct_path = os.path.join(zurg_all_folder, filled_by_file, filled_by_file)
if direct_path in paths:
updates.append((direct_path, item_id))
else:
# Just use the first match
updates.append((paths[0], item_id))
if len(paths) > 1:
logging.debug(f"Multiple matches found for {filled_by_file}, using {paths[0]}")
else:
logging.error(f"Could not find location for item {item_id} with file {filled_by_file}")
return updates
zurg_all_folder = get_setting('File Management', 'zurg_all_folder')
if not zurg_all_folder:
logging.error("zurg_all_folder not set in settings")
return
conn = get_db_connection()
cursor = conn.cursor()
try:
# Build file map first
start_time = time()
file_map = build_file_map(zurg_all_folder)
logging.info(f"Built file map in {time() - start_time:.1f} seconds")
# Get all media items that need updating
cursor.execute("""
SELECT id, filled_by_file, type
FROM media_items
WHERE filled_by_file IS NOT NULL
AND (location_on_disk IS NULL OR location_on_disk = '')
AND type != 'movie'
""")
items = cursor.fetchall()
total_items = len(items)
logging.info(f"Found {total_items} items to process")
# Process items in parallel batches
start_time = time()
all_updates = []
# Split items into batches
batches = [items[i:i + BATCH_SIZE] for i in range(0, len(items), BATCH_SIZE)]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
# Submit all batches to the thread pool
future_to_batch = {
executor.submit(process_batch, batch, file_map, zurg_all_folder): batch
for batch in batches
}
# Process completed batches
for future in as_completed(future_to_batch):
batch = future_to_batch[future]
try:
updates = future.result()
if updates:
# Execute database updates
cursor.executemany(
"UPDATE media_items SET location_on_disk = ? WHERE id = ?",
updates
)
conn.commit()
processed = len(updates)
all_updates.extend(updates)
elapsed = time() - start_time
items_per_sec = len(all_updates) / elapsed if elapsed > 0 else 0
logging.info(f"Progress: {len(all_updates)}/{total_items} items ({items_per_sec:.1f} items/sec)")
except Exception as e:
logging.error(f"Error processing batch: {str(e)}")
elapsed = time() - start_time
items_per_sec = total_items / elapsed if elapsed > 0 else 0
logging.info(f"Finished updating media locations. Processed {total_items} items in {elapsed:.1f} seconds ({items_per_sec:.1f} items/sec)")
logging.info(f"Successfully updated {len(all_updates)} items")
except Exception as e:
logging.error(f"Error updating media locations: {str(e)}")
conn.rollback()
finally:
conn.close()
def open_log_file():
log_dir = os.environ.get('USER_LOGS', '/user/logs')
log_file_path = os.path.join(log_dir, 'debug.log')
if os.path.exists(log_file_path):
try:
if platform.system() == 'Windows':
os.startfile(log_file_path)
elif platform.system() == 'Darwin':
subprocess.call(['open', log_file_path])
else:
subprocess.call(['xdg-open', log_file_path])
except Exception as e:
logging.error(f"Failed to open log file: {e}")
else:
logging.error("Log file does not exist.")
def fix_notification_settings():
"""Check and fix notification settings during startup."""
try:
from settings import load_config, save_config
config = load_config()
needs_update = False
if 'Notifications' in config and config['Notifications']:
for notification_id, notification_config in config['Notifications'].items():
if notification_config is not None:
if 'notify_on' not in notification_config or not notification_config['notify_on']:
needs_update = True
break
if needs_update:
logging.info("Found notifications with missing or empty notify_on settings, fixing...")
port = int(os.environ.get('CLI_DEBRID_PORT', 5000))
try:
response = requests.post(f'http://localhost:{port}/notifications/update_defaults')
if response.status_code == 200:
logging.info("Successfully updated notification defaults")
else:
logging.error(f"Failed to update notification defaults: {response.text}")
except requests.RequestException as e:
logging.error(f"Error updating notification defaults: {e}")
except Exception as e:
logging.error(f"Error checking notification settings: {e}")
# Update the main function to use a single thread for the metadata battery
def main():
global program_runner, metadata_process
metadata_process = None
logging.info("Starting the program...")
setup_directories()
backup_config()
backup_database() # Add the database backup call here
from settings import ensure_settings_file, get_setting, set_setting
from database import verify_database
from database.statistics import get_cached_download_stats
# Add check for Hybrid uncached management setting
if get_setting('Scraping', 'uncached_content_handling') == 'Hybrid':
logging.info("Resetting 'Hybrid' uncached content handling setting to None")
set_setting('Scraping', 'uncached_content_handling', 'None')
if get_setting('Debrid Provider', 'provider') == 'Torbox':
logging.info("Resetting Torbox debrid provider to Real-Debrid")
set_setting('Debrid Provider', 'provider', 'RealDebrid')
# Get battery port from environment variable
battery_port = int(os.environ.get('CLI_DEBRID_BATTERY_PORT', '5001'))
# Set metadata battery URL with the correct port
set_setting('Metadata Battery', 'url', f'http://localhost:{battery_port}')
#logging.info(f"Set metadata battery URL to http://localhost:{battery_port}")
ensure_settings_file()
verify_database()
# Initialize download stats cache
try:
#logging.info("Initializing download stats cache...")
get_cached_download_stats()
#logging.info("Download stats cache initialized successfully")
except Exception as e:
logging.error(f"Error initializing download stats cache: {str(e)}")
# Add delay to ensure server is ready
time.sleep(2)
# Fix notification settings if needed
fix_notification_settings()
# Add the update_media_locations call here
# update_media_locations()
os.system('cls' if os.name == 'nt' else 'clear')
version = get_version()
# Display logo and web UI message
import socket
ip_address = socket.gethostbyname(socket.gethostname())
print(r"""
( ( ) (
)\ ( )\ ) ( ( /( ( ( )\ )
( ((_))\ (()/( ))\ )\()) )( )\ (()/(
)\ _ ((_) ((_))/((_)((_)\ (()\((_) ((_))
((_)| | (_) _| |(_)) | |(_) ((_)(_) _| |
/ _| | | | | / _` |/ -_) | '_ \| '_|| |/ _` |
\__| |_| |_|_____\__,_|\___| |_.__/|_| |_|\__,_|
|_____|
Version:
""")
print(f" {version}\n")
print(f"cli_debrid is initialized.")
port = int(os.environ.get('CLI_DEBRID_PORT', 5000))
print(f"The web UI is available at http://localhost:{port}")
print("Use the web UI to control the program.")
print("Press Ctrl+C to stop the program.")
# Start the system tray icon if running as a packaged Windows app
if is_frozen() and platform.system() == 'Windows':
# Import Windows-specific modules only on Windows
import win32gui
import win32con
# Start the system tray icon
tray_thread = threading.Thread(target=setup_tray_icon)
tray_thread.daemon = True
tray_thread.start()
# Run the metadata battery only on Windows
is_windows = platform.system() == 'Windows'
if is_windows:
# Start the metadata battery
print("Running on Windows. Starting metadata battery...")
else:
print("Running on a non-Windows system. Metadata battery will not be started.")
# Always print this message
print("Running in console mode.")
if get_setting('Debug', 'auto_run_program'):
# Add delay to ensure server is ready
time.sleep(2) # Wait for server to initialize
# Call the start_program route
try:
port = int(os.environ.get('CLI_DEBRID_PORT', 5000))
response = requests.post(f'http://localhost:{port}/program_operation/api/start_program')
if response.status_code == 200:
print("Program started successfully")
else:
print(f"Failed to start program. Status code: {response.status_code}")
print(f"Response: {response.text}")
except requests.RequestException as e:
print(f"Error calling start_program route: {e}")
# Set up signal handling
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Main loop
try:
while True:
time.sleep(5)
except KeyboardInterrupt:
from program_operation_routes import cleanup_port
cleanup_port()
stop_program()
stop_global_profiling()
print("Program stopped.")
def package_main():
setup_logging()
package_app()
def print_version():
try:
with open('version.txt', 'r') as f:
version = f.read().strip()
print(f"Version:\n\n\t {version}\n")
except Exception as e:
logging.error(f"Failed to read version: {e}")
print("Version: Unknown\n")
if __name__ == "__main__":
try:
# Choose whether to run the normal app or package it
if len(sys.argv) > 1 and sys.argv[1] == "--package":
package_main()
else:
setup_logging()
start_global_profiling()
from api_tracker import setup_api_logging
setup_api_logging()
from web_server import start_server
print_version()
print("\ncli_debrid is initialized.")
def run_flask():
if not start_server():
return False
return True
if not run_flask():
stop_program()
sys.exit(1)
print("The web UI is available at http://localhost:5000")
main()
except KeyboardInterrupt:
stop_global_profiling()
print("Program stopped.")