Skip to content

Commit 9fce3b1

Browse files
Merge development into master
2 parents 007c604 + 4224e7d commit 9fce3b1

92 files changed

Lines changed: 1479 additions & 1454 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ If you need something that is not already part of Bazarr, feel free to create a
8383
- SubsRo
8484
- Subsunacs.net
8585
- SubSynchro
86+
- Subtis
8687
- Subtitrari-noi.ro
8788
- subtitri.id.lv
8889
- Subtitulamos.tv

bazarr.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def check_python_version():
2727
print("Python " + minimum_py3_str + " or greater required. "
2828
"Current version is " + platform.python_version() + ". Please upgrade Python.")
2929
exit_program(EXIT_PYTHON_UPGRADE_NEEDED)
30-
elif int(python_version[0]) == 3 and int(python_version[1]) > 12:
31-
print("Python version greater than 3.12.x is unsupported. Current version is " + platform.python_version() +
30+
elif int(python_version[0]) == 3 and int(python_version[1]) > 13:
31+
print("Python version greater than 3.13.x is unsupported. Current version is " + platform.python_version() +
3232
". Keep in mind that even if it works, you're on your own.")
3333
elif (int(python_version[0]) == minimum_py3_tuple[0] and int(python_version[1]) < minimum_py3_tuple[1]) or \
3434
(int(python_version[0]) != minimum_py3_tuple[0]):

bazarr/api/plex/oauth.py

Lines changed: 109 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ def get(self):
505505
# Collect all connections for parallel testing
506506
connection_candidates = []
507507
connections = []
508+
all_device_connection_uris = [] # Store ALL URIs before testing
508509
for conn in device.get('connections', []):
509510
connection_data = {
510511
'uri': conn['uri'],
@@ -514,6 +515,7 @@ def get(self):
514515
'local': conn.get('local', False)
515516
}
516517
connection_candidates.append(connection_data)
518+
all_device_connection_uris.append(conn['uri']) # Store ALL URIs
517519

518520
# Test all connections in parallel using threads
519521
if connection_candidates:
@@ -545,15 +547,28 @@ def test_connection_wrapper(conn_data):
545547
connections.sort(key=lambda x: x.get('latency', float('inf')))
546548
bestConnection = connections[0] if connections else None
547549

548-
servers.append({
550+
server_data = {
549551
'name': device['name'],
550552
'machineIdentifier': device['clientIdentifier'],
551553
'connections': connections,
552554
'bestConnection': bestConnection,
553555
'version': device.get('productVersion'),
554556
'platform': device.get('platform'),
555557
'device': device.get('device')
556-
})
558+
}
559+
servers.append(server_data)
560+
561+
# Update stored connections if this is the currently selected server
562+
selected_machine_id = settings.plex.get('server_machine_id')
563+
if selected_machine_id and device['clientIdentifier'] == selected_machine_id:
564+
# Store ALL connection URIs (not just the working ones) for round-robin fallback
565+
settings.plex.server_connections = all_device_connection_uris
566+
# Update best connection if it changed
567+
if bestConnection:
568+
settings.plex.server_url = bestConnection['uri']
569+
settings.plex.server_local = bestConnection.get('local', False)
570+
write_config()
571+
logger.debug(f"Auto-updated connections for server {device['name']}: {len(all_device_connection_uris)} total, {len(connections)} available")
557572

558573
return {'data': servers}
559574

@@ -571,69 +586,99 @@ def get(self):
571586
try:
572587
decrypted_token = get_decrypted_token()
573588
if not decrypted_token:
574-
logger.warning("No decrypted token available for Plex library fetching")
589+
logger.debug("No decrypted token available for Plex library fetching")
575590
return {'data': []}
576591

577-
# Get the selected server URL
578-
server_url = settings.plex.get('server_url')
579-
if not server_url:
580-
logger.warning("No Plex server selected")
592+
# Get all stored server connections for round-robin fallback
593+
primary_url = settings.plex.get('server_url')
594+
all_connections = settings.plex.get('server_connections', [])
595+
596+
# Build connection list: primary URL first, then others as fallback
597+
server_connections = []
598+
if primary_url:
599+
server_connections.append(primary_url)
600+
# Add other connections as fallback (skip duplicates)
601+
server_connections.extend([url for url in all_connections if url != primary_url])
602+
elif all_connections:
603+
server_connections = all_connections
604+
else:
605+
logger.debug("No Plex server connections available")
581606
return {'data': []}
582607

583-
logger.debug(f"Fetching Plex libraries from server: {sanitize_server_url(server_url)}")
608+
logger.debug(f"Fetching Plex libraries for server: {settings.plex.get('server_name', 'Unknown')}")
584609

585610
headers = {
586611
'X-Plex-Token': decrypted_token,
587612
'Accept': 'application/json'
588613
}
589614

590-
# Get libraries from the selected server
591-
response = requests.get(
592-
f"{server_url}/library/sections",
593-
headers=headers,
594-
timeout=10,
595-
verify=False
596-
)
615+
# Try each connection in order until one succeeds (round-robin)
616+
sections = []
617+
successful_server_url = None
618+
619+
for idx, server_url in enumerate(server_connections, 1):
620+
try:
621+
logger.debug(f"Attempting to fetch libraries from connection {idx}/{len(server_connections)}: {sanitize_server_url(server_url)}")
622+
623+
# Get libraries from this server URL
624+
lib_response = requests.get(
625+
f"{server_url}/library/sections",
626+
headers=headers,
627+
timeout=10,
628+
verify=False
629+
)
630+
631+
if lib_response.status_code in (401, 403):
632+
logger.debug(f"Connection {idx}: Authentication failed ({lib_response.status_code})")
633+
continue
634+
elif lib_response.status_code != 200:
635+
logger.debug(f"Connection {idx}: HTTP {lib_response.status_code}")
636+
continue
597637

598-
if response.status_code in (401, 403):
599-
logger.warning(f"Plex authentication failed: {response.status_code}")
600-
return {'data': []}
601-
elif response.status_code != 200:
602-
logger.error(f"Plex API error: {response.status_code}")
603-
raise PlexConnectionError(f"Failed to get libraries: HTTP {response.status_code}")
638+
# Parse the response
639+
lib_content_type = lib_response.headers.get('content-type', '')
640+
641+
if 'application/json' in lib_content_type:
642+
data = lib_response.json()
643+
if 'MediaContainer' in data and 'Directory' in data['MediaContainer']:
644+
sections = data['MediaContainer']['Directory']
645+
elif 'application/xml' in lib_content_type or 'text/xml' in lib_content_type:
646+
import xml.etree.ElementTree as ET
647+
root = ET.fromstring(lib_response.text)
648+
sections = []
649+
for directory in root.findall('Directory'):
650+
sections.append({
651+
'key': directory.get('key'),
652+
'title': directory.get('title'),
653+
'type': directory.get('type'),
654+
'count': int(directory.get('count', 0)),
655+
'agent': directory.get('agent', ''),
656+
'scanner': directory.get('scanner', ''),
657+
'language': directory.get('language', ''),
658+
'uuid': directory.get('uuid', ''),
659+
'updatedAt': int(directory.get('updatedAt', 0)),
660+
'createdAt': int(directory.get('createdAt', 0))
661+
})
662+
663+
# If we got sections, this connection worked
664+
if sections:
665+
successful_server_url = server_url
666+
logger.debug(f"Successfully fetched libraries from connection {idx}/{len(server_connections)}")
667+
break
668+
else:
669+
logger.debug(f"Connection {idx}: No sections returned")
670+
671+
except requests.exceptions.RequestException as e:
672+
logger.debug(f"Connection {idx} failed: {type(e).__name__}: {str(e)}")
673+
continue
674+
except Exception as e:
675+
logger.debug(f"Connection {idx} error: {type(e).__name__}: {str(e)}")
676+
continue
604677

605-
response.raise_for_status()
606-
607-
# Parse the response - it could be JSON or XML depending on the server
608-
content_type = response.headers.get('content-type', '')
609-
logger.debug(f"Plex libraries response content-type: {content_type}")
610-
611-
if 'application/json' in content_type:
612-
data = response.json()
613-
logger.debug(f"Plex libraries JSON response: {data}")
614-
if 'MediaContainer' in data and 'Directory' in data['MediaContainer']:
615-
sections = data['MediaContainer']['Directory']
616-
else:
617-
sections = []
618-
elif 'application/xml' in content_type or 'text/xml' in content_type:
619-
import xml.etree.ElementTree as ET
620-
root = ET.fromstring(response.text)
621-
sections = []
622-
for directory in root.findall('Directory'):
623-
sections.append({
624-
'key': directory.get('key'),
625-
'title': directory.get('title'),
626-
'type': directory.get('type'),
627-
'count': int(directory.get('count', 0)),
628-
'agent': directory.get('agent', ''),
629-
'scanner': directory.get('scanner', ''),
630-
'language': directory.get('language', ''),
631-
'uuid': directory.get('uuid', ''),
632-
'updatedAt': int(directory.get('updatedAt', 0)),
633-
'createdAt': int(directory.get('createdAt', 0))
634-
})
635-
else:
636-
raise PlexConnectionError(f"Unexpected response format: {content_type}")
678+
# If no connection succeeded, return empty
679+
if not successful_server_url or not sections:
680+
logger.warning(f"Failed to fetch libraries from all {len(server_connections)} connection(s)")
681+
return {'data': []}
637682

638683
# Filter and format libraries for movie and show types only
639684
libraries = []
@@ -643,7 +688,7 @@ def get(self):
643688
try:
644689
section_key = section.get('key')
645690
count_response = requests.get(
646-
f"{server_url}/library/sections/{section_key}/all",
691+
f"{successful_server_url}/library/sections/{section_key}/all",
647692
headers={'X-Plex-Token': decrypted_token, 'Accept': 'application/json'},
648693
timeout=5,
649694
verify=False
@@ -674,10 +719,10 @@ def get(self):
674719
'uuid': section.get('uuid', ''),
675720
'updatedAt': int(section.get('updatedAt', 0)),
676721
'createdAt': int(section.get('createdAt', 0)),
677-
'locations': _get_library_locations(server_url, section_key, decrypted_token)
722+
'locations': _get_library_locations(successful_server_url, section_key, decrypted_token)
678723
})
679724

680-
logger.debug(f"Filtered Plex libraries: {libraries}")
725+
logger.debug(f"Successfully retrieved {len(libraries)} movie/show libraries from Plex")
681726
return {'data': libraries}
682727

683728
except requests.exceptions.RequestException as e:
@@ -836,6 +881,7 @@ def get(self):
836881
post_request_parser.add_argument('name', type=str, required=True, help='Server name')
837882
post_request_parser.add_argument('uri', type=str, required=True, help='Connection URI')
838883
post_request_parser.add_argument('local', type=str, required=False, default='false', help='Is local connection')
884+
post_request_parser.add_argument('connections', type=list, location='json', required=False, help='All available connection URIs')
839885

840886
@api_ns_plex.doc(parser=post_request_parser)
841887
def post(self):
@@ -844,11 +890,14 @@ def post(self):
844890
name = args.get('name')
845891
connection_uri = args.get('uri')
846892
connection_local = args.get('local', 'false').lower() == 'true'
893+
connections = args.get('connections', [])
847894

848895
settings.plex.server_machine_id = machine_identifier
849896
settings.plex.server_name = name
850897
settings.plex.server_url = connection_uri
851898
settings.plex.server_local = connection_local
899+
# Store all connection URIs for round-robin fallback
900+
settings.plex.server_connections = connections if connections else [connection_uri]
852901
write_config()
853902

854903
return {
@@ -904,13 +953,12 @@ def post(self):
904953
instance_name = settings.general.get('instance_name', 'Bazarr')
905954
instance_param = quote_plus(instance_name)
906955

956+
scheme = 'https' if request.is_secure else 'http'
957+
host = request.host
907958
if configured_base_url:
908-
webhook_url = f"{configured_base_url}/api/webhooks/plex?apikey={apikey}&instance={instance_param}"
909-
logger.info(f"Using configured base URL for webhook: {configured_base_url}/api/webhooks/plex (instance: {instance_name})")
959+
webhook_url = f"{scheme}://{host}{configured_base_url}/api/webhooks/plex?apikey={apikey}&instance={instance_param}"
960+
logger.info(f"Using configured base URL for webhook: {scheme}://{host}{configured_base_url}/api/webhooks/plex (instance: {instance_name})")
910961
else:
911-
# Fall back to using the current request's host
912-
scheme = 'https' if request.is_secure else 'http'
913-
host = request.host
914962
webhook_url = f"{scheme}://{host}/api/webhooks/plex?apikey={apikey}&instance={instance_param}"
915963
logger.info(f"Using request host for webhook (no base URL configured): {scheme}://{host}/api/webhooks/plex (instance: {instance_name})")
916964
logger.info("Note: If Bazarr is behind a reverse proxy, configure Base URL in General Settings for better reliability")

bazarr/api/system/jobs.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ..utils import authenticate
88

9-
api_ns_system_jobs = Namespace('System Jobs', description='List or delete jobs from the queue')
9+
api_ns_system_jobs = Namespace('System Jobs', description='List, force start, move or delete jobs from the queue')
1010

1111

1212
@api_ns_system_jobs.route('system/jobs')
@@ -40,6 +40,28 @@ def get(self):
4040
return marshal(jobs_queue.list_jobs_from_queue(job_id=job_id, status=status), self.get_response_model,
4141
envelope='data')
4242

43+
post_request_parser = reqparse.RequestParser()
44+
post_request_parser.add_argument('id', type=int, required=True, help='Job ID act onto')
45+
post_request_parser.add_argument('action', type=str, required=True,
46+
help='Action to perform from ["force_start", "move_top", "move_bottom"]')
47+
48+
@authenticate
49+
@api_ns_system_jobs.doc(parser=post_request_parser)
50+
@api_ns_system_jobs.response(204, 'Success')
51+
@api_ns_system_jobs.response(401, 'Not Authenticated')
52+
def post(self):
53+
"""Force start, move to top or move to bottom of the queue a specific job"""
54+
args = self.post_request_parser.parse_args()
55+
job_id = args.get('id')
56+
action = args.get('action')
57+
if action == "force_start":
58+
jobs_queue.force_start_pending_job(job_id=job_id)
59+
elif action == "move_top":
60+
jobs_queue.move_job_in_pending_queue(job_id=job_id, move_destination="top")
61+
elif action == "move_bottom":
62+
jobs_queue.move_job_in_pending_queue(job_id=job_id, move_destination="bottom")
63+
return '', 204
64+
4365
patch_request_parser = reqparse.RequestParser()
4466
patch_request_parser.add_argument('queueName', type=str, required=True, help='Jobs queue name to empty',
4567
choices=['pending', 'failed', 'completed'])

bazarr/api/system/status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,6 @@ def get(self):
6262
system_status.update({'bazarr_config_directory': args.config_dir})
6363
system_status.update({'start_time': startTime})
6464
system_status.update({'timezone': timezone})
65+
system_status.update({'cpu_cores': os.cpu_count()})
6566

6667
return {'data': system_status}

bazarr/app/announcements.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ def get_announcements_to_file(job_id=None, startup=False):
5656

5757
try:
5858
r = requests.get(
59-
url="https://cdn.statically.io/gh/morpheus65535/bazarr-binaries@refs/heads/master/announcements.json",
59+
url="https://cdn.jsdelivr.net/gh/morpheus65535/bazarr-binaries@latest/announcements.json",
6060
timeout=30
6161
)
6262
except Exception:
6363
try:
64-
logging.exception("Error trying to get announcements from Statically, falling back to Github.")
64+
logging.exception("Error trying to get announcements from jsdelivr.net, falling back to Github.")
6565
r = requests.get(
6666
url="https://raw.githubusercontent.com/morpheus65535/bazarr-binaries/refs/heads/master/announcements.json",
6767
timeout=30
@@ -79,7 +79,7 @@ def get_announcements_to_file(job_id=None, startup=False):
7979

8080
def get_online_announcements():
8181
try:
82-
with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'r') as f:
82+
with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'r', encoding='utf-8') as f:
8383
data = json.load(f)
8484
except (OSError, json.JSONDecodeError):
8585
return []
@@ -96,17 +96,6 @@ def get_online_announcements():
9696
def get_local_announcements():
9797
announcements = []
9898

99-
# opensubtitles.org end-of-life
100-
enabled_providers = get_enabled_providers()
101-
if enabled_providers and 'opensubtitles' in enabled_providers and not settings.opensubtitles.vip:
102-
announcements.append({
103-
'text': 'Opensubtitles.org is deprecated for non-VIP users, migrate to Opensubtitles.com ASAP and disable '
104-
'this provider to remove this announcement.',
105-
'link': 'https://wiki.bazarr.media/Troubleshooting/OpenSubtitles-migration/',
106-
'dismissible': False,
107-
'timestamp': 1676236978,
108-
})
109-
11099
# deprecated Sonarr and Radarr versions
111100
if get_sonarr_info.is_deprecated():
112101
announcements.append({

0 commit comments

Comments
 (0)