diff --git a/lms/services/canvas_studio.py b/lms/services/canvas_studio.py
index 87cd0434f3..439e3e9a3d 100644
--- a/lms/services/canvas_studio.py
+++ b/lms/services/canvas_studio.py
@@ -280,19 +280,29 @@ def get_canonical_video_url(self, media_id: str) -> str:
# Example: "https://hypothesis.instructuremedia.com/api/public/v1/media/4"
return self._api_url(f"v1/media/{media_id}")
- def get_video_download_url(self, media_id: str) -> str:
+ def get_video_download_url(self, media_id: str) -> str | None:
"""
Return temporary download URL for a video.
+ This may return `None` if the video is not available for download.
+ This can happen for videos imported into Canvas Studio from YouTube
+ or Vimeo.
+
Security: This method does not check whether the current user should
have access to this video. See `_admin_api_request`.
"""
- download_rsp = self._bare_api_request(
- f"v1/media/{media_id}/download", as_admin=True, allow_redirects=False
- )
- download_redirect = download_rsp.headers.get("Location")
+ try:
+ download_rsp = self._bare_api_request(
+ f"v1/media/{media_id}/download", as_admin=True, allow_redirects=False
+ )
+ except ExternalRequestError as err:
+ # Canvas Studio returns 422 if the video is not available for download.
+ if err.status_code == 422:
+ return None
+ raise
+ download_redirect = download_rsp.headers.get("Location")
if download_rsp.status_code != 302 or not download_redirect:
raise ExternalRequestError(
message="Media download did not return valid redirect",
diff --git a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx
index ce21c56858..385dc57754 100644
--- a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx
+++ b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx
@@ -320,6 +320,42 @@ export default function LaunchErrorDialog({
);
+ case 'canvas_studio_download_unavailable':
+ return (
+
+
+ Only videos uploaded directly to Canvas Studio can be used. Videos
+ hosted on YouTube or Vimeo cannot be used.
+
+ To use a video with Hypothesis, you must upload or generate captions
+ in Canvas Studio and publish them.
+
+
+ );
case 'blackboard_group_set_not_found':
return (
{
hasRetry: true,
withError: true,
},
+ {
+ errorState: 'canvas_studio_download_unavailable',
+ expectedText:
+ 'Only videos uploaded directly to Canvas Studio can be used. Videos hosted on YouTube or Vimeo cannot be used.',
+ expectedTitle: 'Unable to fetch Canvas Studio video',
+ hasRetry: false,
+ withError: true,
+ },
+ {
+ errorState: 'canvas_studio_media_not_found',
+ expectedText: '',
+ expectedTitle: 'Canvas Studio media not found',
+ hasRetry: false,
+ withError: true,
+ },
+ {
+ errorState: 'canvas_studio_transcript_unavailable',
+ expectedText:
+ 'To use a video with Hypothesis, you must upload or generate captions in Canvas Studio and publish them.',
+ expectedTitle: 'Video does not have a published transcript',
+ hasRetry: false,
+ withError: true,
+ },
{
errorState: 'd2l_file_not_found_in_course_instructor',
expectedText:
diff --git a/lms/static/scripts/frontend_apps/errors.ts b/lms/static/scripts/frontend_apps/errors.ts
index c94442f82c..fdcf625364 100644
--- a/lms/static/scripts/frontend_apps/errors.ts
+++ b/lms/static/scripts/frontend_apps/errors.ts
@@ -18,6 +18,9 @@ export type LTILaunchServerErrorCode =
| 'canvas_group_set_not_found'
| 'canvas_page_not_found_in_course'
| 'canvas_student_not_in_group'
+ | 'canvas_studio_download_unavailable'
+ | 'canvas_studio_transcript_unavailable'
+ | 'canvas_studio_media_not_found'
| 'd2l_file_not_found_in_course_instructor'
| 'd2l_file_not_found_in_course_student'
| 'd2l_group_set_empty'
@@ -162,6 +165,9 @@ export function isLTILaunchServerError(error: ErrorLike): error is APIError {
'canvas_group_set_not_found',
'canvas_group_set_empty',
'canvas_student_not_in_group',
+ 'canvas_studio_download_unavailable',
+ 'canvas_studio_transcript_unavailable',
+ 'canvas_studio_media_not_found',
'vitalsource_user_not_found',
'vitalsource_no_book_license',
'moodle_page_not_found_in_course',
diff --git a/lms/views/api/canvas_studio.py b/lms/views/api/canvas_studio.py
index fed348bb7c..91c6d055fd 100644
--- a/lms/views/api/canvas_studio.py
+++ b/lms/views/api/canvas_studio.py
@@ -4,16 +4,29 @@
See `CanvasStudioService` for more details.
"""
-from pyramid.httpexceptions import HTTPBadRequest, HTTPFound
+from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config
from lms.security import Permissions
from lms.services import CanvasStudioService
from lms.services.canvas_studio import replace_localhost_in_url
+from lms.services.exceptions import SerializableError
from lms.validation.authentication import OAuthCallbackSchema
from lms.views.helpers import via_video_url
+class CanvasStudioLaunchError(SerializableError):
+ """
+ An error occurred while launching a Canvas Studio assignment.
+
+ This exception is used for non-authorization errors that prevent a
+ Canvas Studio assignment from being launched.
+ """
+
+ def __init__(self, error_code: str, message: str):
+ super().__init__(error_code=error_code, message=message)
+
+
# View for authorization popup which redirects to Canvas Studio's OAuth
# authorization endpoint.
#
@@ -135,15 +148,31 @@ def via_url(request):
document_url = assignment.document_url
media_id = CanvasStudioService.media_id_from_url(document_url)
if not media_id:
- raise HTTPBadRequest("Unable to get Canvas Studio media ID")
+ raise CanvasStudioLaunchError(
+ "canvas_studio_media_not_found", "Unable to get Canvas Studio media ID"
+ )
svc = request.find_service(CanvasStudioService)
canonical_url = svc.get_canonical_video_url(media_id)
+
+ # Get the video download URL, then the transcript. We do things in this
+ # order because if the video cannot be used (eg. because it is a Vimeo
+ # upload), there is no point in the user uploading a transcript, if that is
+ # also missing.
+
download_url = svc.get_video_download_url(media_id)
- transcript_url = svc.get_transcript_url(media_id)
+ if not download_url:
+ raise CanvasStudioLaunchError(
+ "canvas_studio_download_unavailable",
+ "Hypothesis was unable to fetch the video",
+ )
+ transcript_url = svc.get_transcript_url(media_id)
if not transcript_url:
- raise HTTPBadRequest("This video does not have a published transcript")
+ raise CanvasStudioLaunchError(
+ "canvas_studio_transcript_unavailable",
+ "This video does not have a published transcript",
+ )
return {
"via_url": via_video_url(request, canonical_url, download_url, transcript_url)
diff --git a/tests/unit/lms/services/canvas_studio_test.py b/tests/unit/lms/services/canvas_studio_test.py
index 7223abe10c..4a10b4182d 100644
--- a/tests/unit/lms/services/canvas_studio_test.py
+++ b/tests/unit/lms/services/canvas_studio_test.py
@@ -191,6 +191,9 @@ def test_get_video_download_error(self, svc):
svc.get_video_download_url("456")
assert Any.instance_of(exc_info.value.response).with_attrs({"status_code": 404})
+ def test_get_video_download_url_returns_None_if_video_not_available(self, svc):
+ assert svc.get_video_download_url("800") is None
+
def test_get_video_download_url_fails_if_admin_email_not_set(
self, svc, pyramid_request
):
@@ -412,6 +415,11 @@ def handler(url, allow_redirects=True):
case "media/456/download":
status_code = 404
json_data = {}
+ case "media/800/download":
+ # Simulate response in case where video is hosted on
+ # YouTube or Vimeo, so not available for download.
+ status_code = 422
+ json_data = {}
case _: # pragma: nocover
raise ValueError(f"Unexpected URL {url}")
diff --git a/tests/unit/lms/views/api/canvas_studio_test.py b/tests/unit/lms/views/api/canvas_studio_test.py
index 4b561b4be8..30998321e7 100644
--- a/tests/unit/lms/views/api/canvas_studio_test.py
+++ b/tests/unit/lms/views/api/canvas_studio_test.py
@@ -2,7 +2,7 @@
import pytest
from h_matchers import Any
-from pyramid.httpexceptions import HTTPBadRequest, HTTPFound
+from pyramid.httpexceptions import HTTPFound
import lms.views.api.canvas_studio as views
@@ -104,7 +104,8 @@ def test_it_raises_if_transcript_not_available(
canvas_studio_service.get_transcript_url.return_value = None
with pytest.raises(
- HTTPBadRequest, match="This video does not have a published transcript"
+ views.CanvasStudioLaunchError,
+ match="This video does not have a published transcript",
):
views.via_url(pyramid_request)
@@ -115,7 +116,18 @@ def test_it_raises_if_document_url_not_valid(
"https://not-a-canvas-studio-url.com"
)
with pytest.raises(
- HTTPBadRequest, match="Unable to get Canvas Studio media ID"
+ views.CanvasStudioLaunchError, match="Unable to get Canvas Studio media ID"
+ ):
+ views.via_url(pyramid_request)
+
+ def test_it_raises_if_download_not_available(
+ self, canvas_studio_service, pyramid_request
+ ):
+ canvas_studio_service.get_video_download_url.return_value = None
+
+ with pytest.raises(
+ views.CanvasStudioLaunchError,
+ match="Hypothesis was unable to fetch the video",
):
views.via_url(pyramid_request)