@@ -269,6 +269,13 @@ def get_course_by_id(canvas: Canvas, course_id: int):
269269# per-institution and sometimes per-course.
270270_PANOPTO_TAB_TOOL_ID_FALLBACK = 128
271271
272+ # Stream-tag preference order. Can be overridden at runtime via
273+ # AUTONOTE_PREFER_STREAM or the --prefer-stream CLI flag. Default keeps the
274+ # existing screen-share-first behaviour, which is right for most lectures.
275+ _PREFER_STREAM_ORDER : tuple [str , ...] = tuple (
276+ os .environ .get ("AUTONOTE_PREFER_STREAM" , "SS,OBJECT,DV" ).split ("," )
277+ )
278+
272279# Per-course cache for resolved Panopto tool IDs.
273280_PANOPTO_TOOL_ID_CACHE : dict [int , int ] = {}
274281
@@ -781,33 +788,69 @@ def _download_authenticated(
781788 progress_cb (100 )
782789
783790
791+ def _get_stream_candidates (
792+ session_id : str ,
793+ cookies : list [dict ],
794+ bearer_token : str | None = None ,
795+ course_id : int | None = None ,
796+ ) -> list [tuple [str , dict , str ]]:
797+ """Return all HLS stream candidates for a Panopto session in preference
798+ order. Each item is (stream_url, auth_headers, stream_tag). Empty list on
799+ failure. download_video() probes each candidate for audio and uses the
800+ first one that has it."""
801+ singles = _get_stream_url (
802+ session_id , cookies ,
803+ bearer_token = bearer_token , course_id = course_id ,
804+ _return_all = True ,
805+ )
806+ return singles or []
807+
808+
784809def _get_stream_url (
785810 session_id : str ,
786811 cookies : list [dict ],
787812 bearer_token : str | None = None ,
788813 course_id : int | None = None ,
789- ) -> tuple [str , dict ] | None :
814+ _return_all : bool = False ,
815+ ) -> tuple [str , dict ] | list [tuple [str , dict , str ]] | None :
790816 """Get the HLS stream URL for a Panopto session.
791817
792818 Returns (stream_url, auth_headers, stream_tag) on success, None on failure.
819+ If _return_all=True, returns a list of all candidate tuples in preference
820+ order so callers can fall back (audio-less stream → try next).
821+
793822 auth_headers is always {} (HLS streams are CDN-public once the URL is known).
794823 stream_tag is the Panopto stream tag ("SS" for screen share, "DV" for camera, etc.).
795824
796825 Priority:
797826 1. DeliveryInfo.aspx POST with Bearer token → HLS master.m3u8
798827 2. Playwright LTI re-launch → navigate to Viewer.aspx → intercept DeliveryInfo
799828 """
800- def _extract_stream (body : dict ) -> tuple [str , str ] | None :
829+ def _extract_streams (body : dict ) -> list [tuple [str , str ]]:
830+ """Return a list of (stream_url, tag) candidates in preference order.
831+ Caller can try each until one produces a video with an audio track —
832+ some Panopto recordings store audio only in the DV stream while SS /
833+ OBJECT are video-only for screen recordings."""
801834 streams = (body .get ("Delivery" ) or {}).get ("Streams" ) or []
802- # Prefer screen content (SS > OBJECT) over camera (DV).
803- # OBJECT streams contain the lecturer's screen recording (slides),
804- # which is more valuable for note generation than the camera view.
805- for tag in ("SS" , "OBJECT" , "DV" , None ):
835+ # Preference: SS (screen-share) > OBJECT (screen recording) > DV (camera).
836+ # We still yield *all* streams so the caller can fall back if the
837+ # preferred one lacks audio.
838+ order = _PREFER_STREAM_ORDER
839+ out : list [tuple [str , str ]] = []
840+ seen_urls : set [str ] = set ()
841+ for tag in order + (None ,):
806842 for s in streams :
807843 surl = s .get ("StreamUrl" , "" )
808- if surl and (tag is None or s .get ("Tag" ) == tag ):
809- return surl , s .get ("Tag" , "unknown" )
810- return None
844+ if not surl or surl in seen_urls :
845+ continue
846+ if tag is None or s .get ("Tag" ) == tag :
847+ seen_urls .add (surl )
848+ out .append ((surl , s .get ("Tag" , "unknown" )))
849+ return out
850+
851+ def _extract_stream (body : dict ) -> tuple [str , str ] | None :
852+ streams = _extract_streams (body )
853+ return streams [0 ] if streams else None
811854
812855 sess = requests .Session ()
813856 for ck in cookies :
@@ -835,6 +878,10 @@ def _extract_stream(body: dict) -> tuple[str, str] | None:
835878 if r .status_code == 200 :
836879 body = r .json ()
837880 if not body .get ("ErrorCode" ):
881+ if _return_all :
882+ all_streams = _extract_streams (body )
883+ if all_streams :
884+ return [(u , {}, t ) for (u , t ) in all_streams ]
838885 result = _extract_stream (body )
839886 if result :
840887 surl , stag = result
@@ -874,10 +921,14 @@ def _on_response(resp) -> None:
874921 if "DeliveryInfo.aspx" in resp .url and not captured :
875922 try :
876923 body = resp .json ()
877- result = _extract_stream (body )
878- if result :
879- surl , stag = result
880- captured .append ((surl , {}, stag ))
924+ if _return_all :
925+ for surl , stag in _extract_streams (body ):
926+ captured .append ((surl , {}, stag ))
927+ else :
928+ result = _extract_stream (body )
929+ if result :
930+ surl , stag = result
931+ captured .append ((surl , {}, stag ))
881932 except Exception :
882933 pass
883934
@@ -892,6 +943,8 @@ def _on_response(resp) -> None:
892943 finally :
893944 browser .close ()
894945
946+ if _return_all :
947+ return list (captured ) if captured else []
895948 return captured [0 ] if captured else None
896949
897950
@@ -941,6 +994,34 @@ def _try_imageio() -> str | None:
941994 return _try_imageio ()
942995
943996
997+ def _hls_has_audio (stream_url : str , ff_bin : str | None = None ) -> bool :
998+ """Return True when an HLS master playlist references an audio track.
999+ Called before download so we can skip audio-less variants (some Panopto
1000+ recordings only pack audio in the DV stream, not OBJECT/SS)."""
1001+ if ff_bin is None :
1002+ ff_bin = _resolve_ffmpeg ()
1003+ if not ff_bin :
1004+ return True # can't check — assume yes and let caller try
1005+ ffprobe = ff_bin .replace ("/ffmpeg" , "/ffprobe" )
1006+ if not Path (ffprobe ).exists ():
1007+ # imageio-ffmpeg bundles only ffmpeg; probe via ffmpeg -i
1008+ probe = subprocess .run (
1009+ [ff_bin , "-hide_banner" , "-i" , stream_url ],
1010+ capture_output = True , text = True , timeout = 30 ,
1011+ )
1012+ return "Audio:" in (probe .stderr or "" )
1013+ try :
1014+ r = subprocess .run (
1015+ [ffprobe , "-v" , "error" , "-select_streams" , "a" ,
1016+ "-show_entries" , "stream=codec_name" ,
1017+ "-of" , "default=nw=1:nk=1" , stream_url ],
1018+ capture_output = True , text = True , timeout = 30 ,
1019+ )
1020+ return bool ((r .stdout or "" ).strip ())
1021+ except Exception :
1022+ return True # don't block download on probe failure
1023+
1024+
9441025def _run_ffmpeg_hls (stream_url : str , out_path : Path , progress_cb ) -> None :
9451026 """Download an HLS master.m3u8 stream to *out_path* via ffmpeg.
9461027
@@ -1066,16 +1147,46 @@ def download_video(video: dict, manifest: dict, base_dir: Path) -> bool | None:
10661147 manifest [key ] = {"status" : "error" , "title" : title }
10671148 return False
10681149
1069- result = _get_stream_url (
1150+ candidates = _get_stream_candidates (
10701151 session_id , cookies ,
10711152 bearer_token = bearer_token ,
10721153 course_id = course_id if viewer_url else None ,
10731154 )
1074- if not result :
1155+ if not candidates :
10751156 tqdm .write (f" [error] Could not get stream URL" )
10761157 manifest [key ] = {"status" : "error" , "title" : title }
10771158 return False
1078- stream_url , dl_headers , stream_tag = result
1159+
1160+ # Record which tags existed so the frame extractor can tell a true camera
1161+ # recording from a DV-with-audio fallback whose OBJECT/SS screen stream
1162+ # was only rejected for lacking an audio track.
1163+ available_tags = [ct for (_ , _ , ct ) in candidates ]
1164+ has_screen_stream = any (t in ("SS" , "OBJECT" ) for t in available_tags )
1165+
1166+ # Probe each candidate for an audio track. Panopto screen-recording
1167+ # streams (SS/OBJECT) sometimes pack audio only in the DV variant, so
1168+ # we fall back through all available streams rather than trusting the
1169+ # first by tag.
1170+ stream_url = dl_headers = stream_tag = None
1171+ tried = []
1172+ for (cu , ch , ct ) in candidates :
1173+ tried .append (ct )
1174+ # Only HLS master playlists are audio-probable from here. Direct
1175+ # authenticated URLs (non-m3u8) go straight through.
1176+ if "master.m3u8" not in cu or _hls_has_audio (cu ):
1177+ stream_url , dl_headers , stream_tag = cu , ch , ct
1178+ break
1179+ tqdm .write (f" Stream '{ ct } ' has no audio — trying next candidate" )
1180+ if stream_url is None :
1181+ tqdm .write (
1182+ f" [error] No stream with audio found for { title } "
1183+ f"(tried: { ', ' .join (tried )} ) — skipping; the recording itself "
1184+ f"appears to be video-only." )
1185+ manifest [key ] = {
1186+ "status" : "error" , "title" : title ,
1187+ "error" : "no-audio-track" ,
1188+ }
1189+ raise RuntimeError (f"No audio track in any Panopto stream for '{ title } '" )
10791190 tqdm .write (f" Stream type: { stream_tag } " )
10801191
10811192 bar = tqdm (total = 100 , desc = f" { title [:50 ]} " , unit = "%" ,
@@ -1107,6 +1218,8 @@ def progress_cb(pct: float) -> None:
11071218 "status" : "done" , "path" : str (out_path ),
11081219 "title" : title , "session_id" : session_id ,
11091220 "stream_tag" : stream_tag ,
1221+ "has_screen_stream" : has_screen_stream ,
1222+ "available_tags" : available_tags ,
11101223 }
11111224 return True
11121225 except Exception as e :
@@ -1588,7 +1701,12 @@ def main() -> None:
15881701
15891702 for i , video in enumerate (pending ):
15901703 print (f"\n [{ i + 1 } /{ len (pending )} ] { video ['title' ]} " )
1591- result = download_video (video , manifest , base_dir )
1704+ try :
1705+ result = download_video (video , manifest , base_dir )
1706+ except RuntimeError as _exc :
1707+ tqdm .write (f" [skip] { _exc } " )
1708+ _save_json (MANIFEST_FILE , manifest )
1709+ continue
15921710 _save_json (MANIFEST_FILE , manifest )
15931711 if args .transcribe and result is True :
15941712 _spawn_transcribe (manifest [str (video ["item_id" ])]["path" ])
0 commit comments