@@ -123,6 +123,23 @@ def file_response(self, full_path: Path, *args: Any, **kwargs: Any) -> Starlette
123123XML_PATH = DATA_DIR / "index.xml"
124124STATE_PATH = DATA_DIR / "state.json"
125125
126+ # CLI invocation mode constants
127+ CLI_MODE_ROOT_FLAGS = "root_flags"
128+ CLI_MODE_RUN_FLAGS = "run_flags"
129+ CLI_MODE_ROOT_DEFAULTS = "root_defaults"
130+ CLI_MODE_RUN_DEFAULTS = "run_defaults"
131+ VALID_CLI_MODES = {
132+ CLI_MODE_ROOT_FLAGS ,
133+ CLI_MODE_RUN_FLAGS ,
134+ CLI_MODE_ROOT_DEFAULTS ,
135+ CLI_MODE_RUN_DEFAULTS ,
136+ }
137+
138+ # CLI version probing mode constants
139+ CLI_VERSION_MODE_SUBCOMMAND = "subcommand"
140+ CLI_VERSION_MODE_FLAG = "flag"
141+ VALID_CLI_VERSION_MODES = {CLI_VERSION_MODE_SUBCOMMAND , CLI_VERSION_MODE_FLAG }
142+
126143
127144class CredentialsRotateRequest (BaseModel ):
128145 """Request body for rotating Xtreme credentials."""
@@ -598,6 +615,185 @@ def ensure_cli_exists() -> None:
598615 logger .info (f"CLI binary found and executable: { CLI_BIN } " )
599616
600617
618+ async def _run_cmd_capture (
619+ cmd : list [str ], * , cwd : str | None = None , timeout : int = 30
620+ ) -> tuple [int , str , str ]:
621+ """Run a command and return returncode/stdout/stderr text."""
622+ proc = await asyncio .create_subprocess_exec (
623+ * cmd ,
624+ cwd = cwd ,
625+ stdout = asyncio .subprocess .PIPE ,
626+ stderr = asyncio .subprocess .PIPE ,
627+ )
628+
629+ try :
630+ stdout , stderr = await asyncio .wait_for (proc .communicate (), timeout = timeout )
631+ except TimeoutError :
632+ proc .terminate ()
633+ try :
634+ await asyncio .wait_for (proc .wait (), timeout = 5 )
635+ except TimeoutError :
636+ proc .kill ()
637+ await proc .wait ()
638+ raise RuntimeError (f"Command timed out after { timeout } s" ) from None
639+
640+ return (
641+ proc .returncode ,
642+ stdout .decode (errors = "ignore" ),
643+ stderr .decode (errors = "ignore" ),
644+ )
645+
646+
647+ def _normalize_cli_mode (value : Any ) -> str | None :
648+ """Normalize a persisted/runtime CLI mode value."""
649+ if not isinstance (value , str ):
650+ return None
651+ normalized = value .strip ().lower ()
652+ return normalized if normalized in VALID_CLI_MODES else None
653+
654+
655+ def _normalize_cli_version_mode (value : Any ) -> str | None :
656+ """Normalize a persisted/runtime CLI version probe mode value."""
657+ if not isinstance (value , str ):
658+ return None
659+ normalized = value .strip ().lower ()
660+ return normalized if normalized in VALID_CLI_VERSION_MODES else None
661+
662+
663+ def _normalize_optional_bool (value : Any ) -> bool | None :
664+ """Normalize mixed-type bool configuration values."""
665+ if isinstance (value , bool ):
666+ return value
667+ if isinstance (value , str ):
668+ lowered = value .strip ().lower ()
669+ if lowered in {"1" , "true" , "yes" , "on" }:
670+ return True
671+ if lowered in {"0" , "false" , "no" , "off" }:
672+ return False
673+ return None
674+
675+
676+ def _cli_attempt_catalog () -> dict [str , tuple [list [str ], str | None ]]:
677+ """Return all supported command invocation patterns."""
678+ return {
679+ CLI_MODE_ROOT_FLAGS : (
680+ [str (CLI_BIN ), "-m" , str (M3U_PATH ), "-x" , str (XML_PATH )],
681+ None ,
682+ ),
683+ CLI_MODE_RUN_FLAGS : (
684+ [str (CLI_BIN ), "run" , "-m" , str (M3U_PATH ), "-x" , str (XML_PATH )],
685+ None ,
686+ ),
687+ CLI_MODE_ROOT_DEFAULTS : ([str (CLI_BIN )], str (DATA_DIR )),
688+ CLI_MODE_RUN_DEFAULTS : ([str (CLI_BIN ), "run" ], str (DATA_DIR )),
689+ }
690+
691+
692+ def _ordered_cli_attempt_modes (
693+ preferred_mode : str | None ,
694+ supports_run_subcommand : bool | None ,
695+ ) -> list [str ]:
696+ """Return invocation modes ordered by compatibility confidence."""
697+ if supports_run_subcommand is True :
698+ modes = [
699+ CLI_MODE_RUN_FLAGS ,
700+ CLI_MODE_RUN_DEFAULTS ,
701+ CLI_MODE_ROOT_FLAGS ,
702+ CLI_MODE_ROOT_DEFAULTS ,
703+ ]
704+ elif supports_run_subcommand is False :
705+ modes = [
706+ CLI_MODE_ROOT_FLAGS ,
707+ CLI_MODE_ROOT_DEFAULTS ,
708+ CLI_MODE_RUN_FLAGS ,
709+ CLI_MODE_RUN_DEFAULTS ,
710+ ]
711+ else :
712+ modes = [
713+ CLI_MODE_ROOT_FLAGS ,
714+ CLI_MODE_RUN_FLAGS ,
715+ CLI_MODE_ROOT_DEFAULTS ,
716+ CLI_MODE_RUN_DEFAULTS ,
717+ ]
718+
719+ normalized_preferred = _normalize_cli_mode (preferred_mode )
720+ if normalized_preferred and normalized_preferred in modes :
721+ modes .remove (normalized_preferred )
722+ modes .insert (0 , normalized_preferred )
723+ return modes
724+
725+
726+ def _ordered_cli_version_probe_modes (preferred_mode : str | None ) -> list [str ]:
727+ """Return version probe modes, preferring the last known working mode."""
728+ modes = [CLI_VERSION_MODE_SUBCOMMAND , CLI_VERSION_MODE_FLAG ]
729+ normalized_preferred = _normalize_cli_version_mode (preferred_mode )
730+ if normalized_preferred and normalized_preferred in modes :
731+ modes .remove (normalized_preferred )
732+ modes .insert (0 , normalized_preferred )
733+ return modes
734+
735+
736+ def _get_cached_cli_attempt_mode (state : dict [str , Any ]) -> str | None :
737+ """Read cached CLI invocation mode from runtime or persisted state."""
738+ runtime_mode = _normalize_cli_mode (getattr (app .state , "cli_last_success_mode" , None ))
739+ if runtime_mode :
740+ return runtime_mode
741+ persisted_mode = _normalize_cli_mode (state .get ("cli_last_success_mode" ))
742+ if persisted_mode :
743+ app .state .cli_last_success_mode = persisted_mode
744+ return persisted_mode
745+
746+
747+ def _get_cached_cli_version_mode (state : dict [str , Any ]) -> str | None :
748+ """Read cached CLI version probe mode from runtime or persisted state."""
749+ runtime_mode = _normalize_cli_version_mode (getattr (app .state , "cli_version_mode" , None ))
750+ if runtime_mode :
751+ return runtime_mode
752+ persisted_mode = _normalize_cli_version_mode (state .get ("cli_version_mode" ))
753+ if persisted_mode :
754+ app .state .cli_version_mode = persisted_mode
755+ return persisted_mode
756+
757+
758+ async def _detect_cli_supports_run_subcommand (state : dict [str , Any ]) -> bool | None :
759+ """Detect whether the CLI expects the `run` subcommand."""
760+ runtime_value = _normalize_optional_bool (
761+ getattr (app .state , "cli_supports_run_subcommand" , None )
762+ )
763+ if runtime_value is not None :
764+ return runtime_value
765+
766+ persisted_value = _normalize_optional_bool (state .get ("cli_supports_run_subcommand" ))
767+ if persisted_value is not None :
768+ app .state .cli_supports_run_subcommand = persisted_value
769+ return persisted_value
770+
771+ try :
772+ return_code , stdout , stderr = await _run_cmd_capture (
773+ [str (CLI_BIN ), "run" , "--help" ],
774+ timeout = 8 ,
775+ )
776+ except Exception as exc :
777+ logger .debug ("CLI run-subcommand probe failed: %s" , exc )
778+ return None
779+
780+ combined_output = f"{ stdout } \n { stderr } " .lower ()
781+ if return_code == 0 :
782+ app .state .cli_supports_run_subcommand = True
783+ return True
784+
785+ if (
786+ 'unknown command "run"' in combined_output
787+ or "unknown command: run" in combined_output
788+ or "is not a command" in combined_output
789+ ):
790+ app .state .cli_supports_run_subcommand = False
791+ return False
792+
793+ logger .debug ("CLI run-subcommand probe was inconclusive (rc=%s)" , return_code )
794+ return None
795+
796+
601797async def generate_files () -> dict [str , Any ]:
602798 """
603799 Generate M3U and XMLTV files using the toonamiaftermath-cli.
@@ -619,19 +815,21 @@ async def generate_files() -> dict[str, Any]:
619815 WEB_DIR ,
620816 os .environ .get ("CRON_SCHEDULE" , CRON_SCHEDULE ),
621817 )
622-
623- # Try multiple invocation strategies to support different CLI versions
624- attempts : list [ tuple [ list [ str ], str | None ]] = [
625- ([ str ( CLI_BIN ), "-m" , str ( M3U_PATH ), "-x" , str ( XML_PATH )], None ),
626- ([ str ( CLI_BIN ), "run" , "-m" , str ( M3U_PATH ), "-x" , str ( XML_PATH )], None ),
627- ([ str ( CLI_BIN )], str ( DATA_DIR )), # defaults to index.* in cwd
628- # some versions require subcommand
629- ([ str ( CLI_BIN ), "run" ], str ( DATA_DIR ) ),
630- ]
818+ state = read_state ()
819+ preferred_mode = _get_cached_cli_attempt_mode ( state )
820+ supports_run_subcommand = await _detect_cli_supports_run_subcommand ( state )
821+ attempt_catalog = _cli_attempt_catalog ()
822+ attempt_modes = _ordered_cli_attempt_modes ( preferred_mode , supports_run_subcommand )
823+ logger . info (
824+ "CLI invocation order resolved: %s" ,
825+ ", " . join ( attempt_modes ),
826+ )
631827
632828 success = False
633- for attempt_num , (cmd , cwd ) in enumerate (attempts , 1 ):
634- logger .info (f"Attempt { attempt_num } /{ len (attempts )} : { ' ' .join (cmd )} " )
829+ successful_mode : str | None = None
830+ for attempt_num , mode in enumerate (attempt_modes , 1 ):
831+ cmd , cwd = attempt_catalog [mode ]
832+ logger .info (f"Attempt { attempt_num } /{ len (attempt_modes )} : { ' ' .join (cmd )} " )
635833 attempt_started_at = time .time ()
636834
637835 try :
@@ -646,7 +844,7 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
646844 run_cli ,
647845 max_retries = 2 , # Fewer retries per command variant
648846 delay = 0.5 ,
649- operation_name = f"CLI execution: { cmd [0 ]} " ,
847+ operation_name = f"CLI execution ( { mode } ) : { cmd [0 ]} " ,
650848 )
651849
652850 except Exception as e :
@@ -662,6 +860,8 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
662860 # Check if files were generated successfully
663861 success = _verify_generated_files (generated_after = attempt_started_at )
664862 if success :
863+ successful_mode = mode
864+ app .state .cli_last_success_mode = mode
665865 break
666866 logger .warning (
667867 "Artifact validation failed after successful CLI exit; "
@@ -671,8 +871,14 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
671871 if not success :
672872 raise RuntimeError ("Failed to generate M3U/XML files after multiple attempts" )
673873
874+ inferred_supports_run = supports_run_subcommand
875+ if inferred_supports_run is None and successful_mode :
876+ inferred_supports_run = successful_mode in {
877+ CLI_MODE_RUN_FLAGS ,
878+ CLI_MODE_RUN_DEFAULTS ,
879+ }
880+
674881 # Update state
675- state = read_state ()
676882 state .update (
677883 {
678884 "last_update" : datetime .now (UTC ).isoformat (),
@@ -681,6 +887,12 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
681887 "last_failure_at" : None ,
682888 "last_failure_context" : None ,
683889 "consecutive_failures" : 0 ,
890+ "cli_last_success_mode" : successful_mode or preferred_mode ,
891+ "cli_supports_run_subcommand" : inferred_supports_run ,
892+ "cli_version_mode" : _normalize_cli_version_mode (
893+ getattr (app .state , "cli_version_mode" , None )
894+ )
895+ or _get_cached_cli_version_mode (state ),
684896 }
685897 )
686898 write_state (state )
@@ -774,24 +986,42 @@ async def get_cli_version() -> str | None:
774986 Returns:
775987 Optional[str]: Version string if available, None otherwise
776988 """
777- try :
778- cmd = [str (CLI_BIN ), "--version" ]
779- proc = await asyncio .create_subprocess_exec (
780- * cmd ,
781- stdout = asyncio .subprocess .PIPE ,
782- stderr = asyncio .subprocess .PIPE ,
783- )
784- out , err = await proc .communicate ()
989+ state = read_state ()
990+ preferred_mode = _get_cached_cli_version_mode (state )
991+ probe_modes = _ordered_cli_version_probe_modes (preferred_mode )
992+ probe_commands : dict [str , list [str ]] = {
993+ CLI_VERSION_MODE_SUBCOMMAND : [str (CLI_BIN ), "version" ],
994+ CLI_VERSION_MODE_FLAG : [str (CLI_BIN ), "--version" ],
995+ }
785996
786- if proc .returncode == 0 :
787- version = out .decode ().strip ()
788- return version if version else None
789- logger .warning (f"CLI version check failed with code { proc .returncode } : { err .decode ()} " )
790- return None
997+ last_failure_detail : str | None = None
791998
792- except Exception as e :
793- logger .warning (f"Failed to get CLI version: { e } " )
794- return None
999+ for mode in probe_modes :
1000+ cmd = probe_commands [mode ]
1001+ try :
1002+ return_code , stdout , stderr = await _run_cmd_capture (cmd , timeout = 10 )
1003+ except Exception as exc :
1004+ last_failure_detail = f"{ ' ' .join (cmd )} raised: { exc } "
1005+ continue
1006+
1007+ if return_code == 0 :
1008+ output = (stdout or stderr ).strip ()
1009+ if output :
1010+ app .state .cli_version_mode = mode
1011+ first_line = output .splitlines ()[0 ].strip ()
1012+ return first_line if first_line else output
1013+ app .state .cli_version_mode = mode
1014+ return None
1015+
1016+ error_preview = (stderr or stdout ).strip ().splitlines ()
1017+ if error_preview :
1018+ last_failure_detail = f"{ ' ' .join (cmd )} -> { error_preview [0 ][:180 ]} "
1019+ else :
1020+ last_failure_detail = f"{ ' ' .join (cmd )} -> exit code { return_code } "
1021+
1022+ if last_failure_detail :
1023+ logger .warning ("CLI version check failed: %s" , last_failure_detail )
1024+ return None
7951025
7961026
7971027def _parse_extinf (line : str ) -> tuple [str | None , str | None , str | None ]:
0 commit comments