The Canvas app authenticates ExternalUrl items via: GET session_token?return_to=<module_item_redirect>?display=borderless → GET session_url → OAuth2 confirm → POST /login/oauth2/accept → Panopto Login.aspx?code= → CookieCheck.aspx (sets Panopto cookies) Our previous code used sessionless_launch (the course-level Panopto tool) for direct Panopto links, which gave wrong/incomplete Panopto cookies. Added DownloadExternalPanoptoURL() that replicates the exact app flow. Falls back to DownloadVideo if no Panopto cookies are obtained. Both List.aspx (folder playlists) and Viewer.aspx (single videos) are handled with the correct yt-dlp flags and output templates.
327 lines
8.8 KiB
Go
327 lines
8.8 KiB
Go
package canvas
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.directme.in/Joren/CanvasArchiver/internal/config"
|
|
"git.directme.in/Joren/CanvasArchiver/internal/models"
|
|
"git.directme.in/Joren/CanvasArchiver/internal/panopto"
|
|
"git.directme.in/Joren/CanvasArchiver/internal/utils"
|
|
)
|
|
|
|
type Client struct {
|
|
HTTPClient *http.Client
|
|
AccessToken string
|
|
CourseID string
|
|
CourseName string
|
|
FilesOnly bool
|
|
VideosOnly bool
|
|
ModuleNumbers bool
|
|
downloadedFiles map[string]bool
|
|
}
|
|
|
|
func NewClient(httpClient *http.Client, accessToken, courseID string, filesOnly, videosOnly, moduleNumbers bool) *Client {
|
|
return &Client{
|
|
HTTPClient: httpClient,
|
|
AccessToken: accessToken,
|
|
CourseID: courseID,
|
|
FilesOnly: filesOnly,
|
|
VideosOnly: videosOnly,
|
|
ModuleNumbers: moduleNumbers,
|
|
downloadedFiles: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetCourseInfo() error {
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s", config.BaseURL, c.CourseID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var course models.Course
|
|
json.NewDecoder(resp.Body).Decode(&course)
|
|
|
|
c.CourseName = fmt.Sprintf("%s [%s]", course.Name, c.CourseID)
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetEnrolledCourses() ([]models.Course, error) {
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses?enrollment_state=active&per_page=100", config.BaseURL), nil)
|
|
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var courses []models.Course
|
|
json.NewDecoder(resp.Body).Decode(&courses)
|
|
return courses, nil
|
|
}
|
|
|
|
func (c *Client) DownloadCourseFiles(root string) {
|
|
if c.VideosOnly {
|
|
fmt.Println("\n[*] Skipping regular course files (videos only mode)")
|
|
return
|
|
}
|
|
fmt.Println("\n[*] Fetching regular course files...")
|
|
|
|
fReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s/folders?per_page=100", config.BaseURL, c.CourseID), nil)
|
|
fReq.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
fResp, err := c.HTTPClient.Do(fReq)
|
|
if err != nil {
|
|
fmt.Printf("[!] Error fetching folders: %v\n", err)
|
|
return
|
|
}
|
|
var folders []models.Folder
|
|
json.NewDecoder(fResp.Body).Decode(&folders)
|
|
fResp.Body.Close()
|
|
|
|
folderMap := make(map[int]string)
|
|
for _, f := range folders {
|
|
folderMap[f.ID] = f.FullName
|
|
}
|
|
|
|
fileReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s/files?per_page=100", config.BaseURL, c.CourseID), nil)
|
|
fileReq.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
fileResp, err := c.HTTPClient.Do(fileReq)
|
|
if err != nil {
|
|
fmt.Printf("[!] Error fetching files: %v\n", err)
|
|
return
|
|
}
|
|
var files []models.File
|
|
json.NewDecoder(fileResp.Body).Decode(&files)
|
|
fileResp.Body.Close()
|
|
|
|
fileCount := 0
|
|
for _, file := range files {
|
|
|
|
if c.FilesOnly {
|
|
if c.downloadedFiles[file.DisplayName] {
|
|
continue
|
|
}
|
|
c.downloadedFiles[file.DisplayName] = true
|
|
}
|
|
|
|
rawFolderPath := folderMap[file.FolderID]
|
|
safeFolderPath := utils.SanitizePath(rawFolderPath)
|
|
|
|
subDir := root
|
|
if !c.FilesOnly {
|
|
if safeFolderPath != "" && strings.ToLower(safeFolderPath) != "course files" {
|
|
subDir = filepath.Join(root, "Course Files", safeFolderPath)
|
|
} else {
|
|
subDir = filepath.Join(root, "Course Files")
|
|
}
|
|
}
|
|
os.MkdirAll(subDir, 0o755)
|
|
path := filepath.Join(subDir, utils.Sanitize(file.DisplayName))
|
|
|
|
if _, err := os.Stat(path); err == nil {
|
|
continue
|
|
}
|
|
|
|
fmt.Printf(" -> Downloading: %s\n", file.DisplayName)
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
fmt.Printf(" [!] Error creating file: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
fData, err := c.HTTPClient.Get(file.URL)
|
|
if err != nil {
|
|
fmt.Printf(" [!] Error downloading file: %v\n", err)
|
|
out.Close()
|
|
continue
|
|
}
|
|
|
|
io.Copy(out, fData.Body)
|
|
fData.Body.Close()
|
|
out.Close()
|
|
fileCount++
|
|
}
|
|
fmt.Printf("[+] Downloaded %d regular files\n", fileCount)
|
|
}
|
|
|
|
func (c *Client) DownloadModules(courseRoot string) {
|
|
fmt.Println("\n[*] Fetching course modules...")
|
|
apiURL := fmt.Sprintf("%s/api/v1/courses/%s/modules?include[]=items&per_page=9999", config.BaseURL, c.CourseID)
|
|
|
|
req, _ := http.NewRequest("GET", apiURL, nil)
|
|
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
resp, _ := c.HTTPClient.Do(req)
|
|
var modules []models.Module
|
|
json.NewDecoder(resp.Body).Decode(&modules)
|
|
resp.Body.Close()
|
|
|
|
for i, mod := range modules {
|
|
modName := mod.Name
|
|
if c.ModuleNumbers {
|
|
modName = fmt.Sprintf("[%d] %s", i+1, mod.Name)
|
|
}
|
|
|
|
// In videos-only mode everything goes flat into courseRoot.
|
|
// In files-only mode everything goes flat into courseRoot.
|
|
// Otherwise use the structured Modules/<name> path.
|
|
modBaseDir := courseRoot
|
|
if !c.FilesOnly && !c.VideosOnly {
|
|
modBaseDir = filepath.Join(courseRoot, "Modules", utils.Sanitize(modName))
|
|
}
|
|
os.MkdirAll(modBaseDir, 0o755)
|
|
|
|
if !c.FilesOnly && !c.VideosOnly {
|
|
fmt.Printf("\n[Module] %s\n", modName)
|
|
} else if c.VideosOnly {
|
|
fmt.Printf("\n[Module] %s (scanning for videos)\n", modName)
|
|
}
|
|
|
|
subHeaderStack := []string{}
|
|
lastIndent := 0
|
|
|
|
for _, item := range mod.Items {
|
|
|
|
// In videos-only mode always download to the flat courseRoot.
|
|
targetDir := modBaseDir
|
|
if len(subHeaderStack) > 0 && !c.FilesOnly && !c.VideosOnly {
|
|
targetDir = filepath.Join(modBaseDir, filepath.Join(subHeaderStack...))
|
|
}
|
|
os.MkdirAll(targetDir, 0o755)
|
|
|
|
switch item.Type {
|
|
case "SubHeader":
|
|
if c.FilesOnly || c.VideosOnly {
|
|
continue
|
|
}
|
|
currentIndent := item.Indent
|
|
if currentIndent <= lastIndent && len(subHeaderStack) > 0 {
|
|
levelsToKeep := currentIndent
|
|
if levelsToKeep < 0 {
|
|
levelsToKeep = 0
|
|
}
|
|
if levelsToKeep < len(subHeaderStack) {
|
|
subHeaderStack = subHeaderStack[:levelsToKeep]
|
|
}
|
|
}
|
|
subHeaderStack = append(subHeaderStack, utils.Sanitize(item.Title))
|
|
lastIndent = currentIndent
|
|
|
|
indent := strings.Repeat(" ", len(subHeaderStack))
|
|
fmt.Printf("%s--- %s ---\n", indent, item.Title)
|
|
|
|
case "File":
|
|
if c.VideosOnly {
|
|
continue
|
|
}
|
|
c.downloadModuleFile(item, targetDir)
|
|
|
|
case "ExternalTool":
|
|
if c.FilesOnly {
|
|
continue
|
|
}
|
|
indent := strings.Repeat(" ", len(subHeaderStack)+1)
|
|
fmt.Printf("%s- Found video tool: %s\n", indent, item.Title)
|
|
panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, targetDir, item.URL, item.Title)
|
|
|
|
case "ExternalUrl":
|
|
if c.FilesOnly {
|
|
continue
|
|
}
|
|
if strings.Contains(item.ExternalURL, "panopto.eu") {
|
|
indent := strings.Repeat(" ", len(subHeaderStack)+1)
|
|
fmt.Printf("%s- Found direct video link: %s\n", indent, item.Title)
|
|
|
|
panopto.DownloadExternalPanoptoURL(c.HTTPClient, c.AccessToken, item.URL, item.ExternalURL, targetDir, item.Title)
|
|
}
|
|
|
|
case "Page":
|
|
if c.FilesOnly {
|
|
continue
|
|
}
|
|
c.searchPageForVideos(item, targetDir)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) downloadModuleFile(item models.ModuleItem, dir string) {
|
|
req, _ := http.NewRequest("GET", item.URL, nil)
|
|
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var fileMeta struct {
|
|
URL string `json:"url"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&fileMeta)
|
|
|
|
if fileMeta.URL == "" {
|
|
return
|
|
}
|
|
|
|
if c.FilesOnly {
|
|
if c.downloadedFiles[fileMeta.DisplayName] {
|
|
return
|
|
}
|
|
c.downloadedFiles[fileMeta.DisplayName] = true
|
|
}
|
|
|
|
ext := filepath.Ext(fileMeta.DisplayName)
|
|
origBase := strings.TrimSuffix(fileMeta.DisplayName, ext)
|
|
|
|
fileName := fileMeta.DisplayName
|
|
if !c.FilesOnly && !strings.EqualFold(origBase, item.Title) && item.Title != "" {
|
|
fileName = fmt.Sprintf("%s (%s)%s", origBase, item.Title, ext)
|
|
}
|
|
|
|
path := filepath.Join(dir, utils.Sanitize(fileName))
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" -> File: %s\n", fileName)
|
|
out, _ := os.Create(path)
|
|
fData, _ := c.HTTPClient.Get(fileMeta.URL)
|
|
io.Copy(out, fData.Body)
|
|
fData.Body.Close()
|
|
out.Close()
|
|
}
|
|
|
|
func (c *Client) searchPageForVideos(item models.ModuleItem, dir string) {
|
|
req, _ := http.NewRequest("GET", item.URL, nil)
|
|
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var page struct {
|
|
Body string `json:"body"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&page)
|
|
|
|
re := regexp.MustCompile(`https?://vub\.cloud\.panopto\.eu/Panopto/Pages/[^"']+id=([a-f0-9-]{36})`)
|
|
matches := re.FindAllString(page.Body, -1)
|
|
|
|
if len(matches) > 0 {
|
|
fmt.Printf(" - Scanning Page '%s' for embedded videos...\n", item.Title)
|
|
for _, videoURL := range matches {
|
|
panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, dir, videoURL, item.Title)
|
|
}
|
|
}
|
|
}
|