17 Commits

Author SHA1 Message Date
ef10711b6e Merge remote-tracking branch 'origin/main' into feat/videos-only-mode 2026-06-19 21:04:15 +02:00
25729ce492 fix: update Canvas OAuth flow 2026-06-19 21:00:02 +02:00
04e7d3e0f4 Merge pull request 'feat/videos-only-mode' (#5) from feat/videos-only-mode into main
Reviewed-on: #5
2026-05-16 22:55:48 +02:00
2776d057cd fix: revert ExternalUrl auth to DownloadVideo, remove dead debug code
DownloadVideo already handles the Panopto Login.aspx canvas auth form
correctly through its multi-step form submission logic. The real fix for
ExternalUrl Panopto folder links was the URL normalization (List.aspx?folderID=
→ List.aspx#folderID="...") already in DownloadVideo.

Remove DownloadExternalPanoptoURL (dead code) and all debug prints.
Update README with -vo flag documentation.
2026-05-16 22:53:11 +02:00
522a8b22f8 Downloader fixes with videos 2026-05-16 22:51:38 +02:00
ea9d4dc2dc fix: use item.HTMLURL (module_item_redirect) not item.URL for ExternalUrl auth
For ExternalUrl module items, the Canvas API returns url=null and
html_url=.../module_item_redirect/<id>. We were passing item.URL (empty)
causing the session_token call to return no session_url.
2026-05-16 22:37:45 +02:00
333e784ce9 fix: use module_item_redirect OAuth flow for ExternalUrl Panopto items
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.
2026-05-16 22:33:22 +02:00
43392a4132 fix: normalize List.aspx?folderID= to fragment form for yt-dlp
Canvas stores folder links as List.aspx?folderID=X (query param).
yt-dlp's PanoptoList extractor requires List.aspx#folderID="X"
(fragment with quoted ID) to scope the download to that folder.
Without the fragment form it downloaded the entire Panopto instance
(1806 items instead of 3).

Also drop --no-playlist for list URLs since they are intentional
playlists, and use title/%(title)s.%(ext)s output template for them.
2026-05-16 19:27:36 +02:00
13063c6cc5 add video-only mode 2026-05-16 17:33:40 +02:00
joren
14a71e7dca Update Readme 2026-03-11 20:12:08 +01:00
051b67de51 Merge pull request 'impl-me' (#4) from impl-me into main
Reviewed-on: #4
2026-03-11 20:10:55 +01:00
joren
9691ecd7a5 Fix course files 2026-03-11 20:06:46 +01:00
joren
b8e6180b35 add numbering 2026-03-11 19:56:37 +01:00
joren
8591ae283e Add support to download all the users courses 2026-03-11 19:36:54 +01:00
11d9867155 Merge pull request 'Prevent dupes in fo mode' (#3) from files-only into main
Reviewed-on: #3
2026-03-11 17:52:41 +01:00
9ffe1283c2 Merge branch 'main' into files-only 2026-03-11 17:52:31 +01:00
05ed4dd4ed Prevent dupes in fo mode 2026-03-11 17:51:34 +01:00
7 changed files with 299 additions and 63 deletions

View File

@@ -21,24 +21,39 @@ go build -o canvasarchiver ./cmd/canvasarchiver
./canvasarchiver
```
Or
Or for files-only mode (all files flat, no videos):
```bash
./canvasarchiver -fo
```
For files-only mode.
Or for videos-only mode (all Panopto videos flat, no files):
```bash
./canvasarchiver -vo
```
2. On first run, you'll be prompted to authenticate:
- Visit the provided OAuth URL
- Authorize the application
- Copy the authorization code back to the terminal
3. Enter your Course ID when prompted
3. Enter your Course ID when prompted (or use `-me` to download all enrolled courses)
4. The tool will download:
- Regular course files (to `Course Files/`)
- Module content (to `Modules/`)
- Panopto recordings (to `Recordings/`)
In `-vo` mode, only videos are downloaded — all into the course root directory (no subdirectories).
### Flags
| Flag | Description |
|------|-------------|
| `-fo` | Files only — download all files flat into one directory; skips videos and module structure |
| `-vo` | Videos only — scan recordings and all module video items, download everything flat into one directory; skips regular files |
| `-me` | Download all enrolled courses |
| `-n` | Prefix modules with order numbers `[1]`, `[2]`, etc. |
## Configuration

View File

@@ -14,6 +14,9 @@ import (
func main() {
filesOnly := flag.Bool("fo", false, "Files only mode - download all files to a single directory without module structure")
videosOnly := flag.Bool("vo", false, "Videos only mode - download only Panopto videos to a single directory")
me := flag.Bool("me", false, "Download all enrolled courses")
moduleNumbers := flag.Bool("n", false, "Prefix modules with order numbers [1], [2], etc.")
flag.Parse()
httpClient := &http.Client{}
@@ -25,11 +28,31 @@ func main() {
return
}
if *me {
canvasClient := canvas.NewClient(httpClient, accessToken, "", *filesOnly, *videosOnly, *moduleNumbers)
courses, err := canvasClient.GetEnrolledCourses()
if err != nil {
fmt.Printf("Error fetching courses: %v\n", err)
return
}
fmt.Printf("[+] Found %d enrolled courses\n", len(courses))
for _, course := range courses {
fmt.Printf(" -> Downloading: %s (ID: %d)\n", course.Name, course.ID)
downloadCourse(httpClient, accessToken, fmt.Sprintf("%d", course.ID), *filesOnly, *videosOnly, *moduleNumbers)
}
return
}
var courseID string
fmt.Print("Enter Course ID: ")
fmt.Scanln(&courseID)
canvasClient := canvas.NewClient(httpClient, accessToken, courseID, *filesOnly)
downloadCourse(httpClient, accessToken, courseID, *filesOnly, *videosOnly, *moduleNumbers)
}
func downloadCourse(httpClient *http.Client, accessToken, courseID string, filesOnly, videosOnly, moduleNumbers bool) {
canvasClient := canvas.NewClient(httpClient, accessToken, courseID, filesOnly, videosOnly, moduleNumbers)
if err := canvasClient.GetCourseInfo(); err != nil {
fmt.Printf("Error: %v\n", err)
@@ -44,7 +67,8 @@ func main() {
canvasClient.DownloadModules(courseRoot)
if !*filesOnly {
panopto.DownloadMainRecordings(httpClient, accessToken, courseID, courseRoot)
// Run recordings: always in -vo mode; skipped in -fo mode; normal otherwise.
if videosOnly || !filesOnly {
panopto.DownloadMainRecordings(httpClient, accessToken, courseID, courseRoot, videosOnly)
}
}

View File

@@ -1,11 +1,17 @@
package auth
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"git.directme.in/Joren/CanvasArchiver/internal/config"
"git.directme.in/Joren/CanvasArchiver/internal/models"
@@ -24,28 +30,7 @@ func NewAuthenticator(client *http.Client) *Authenticator {
func (a *Authenticator) GetAccessToken() (string, error) {
creds, err := LoadCredentials()
if err != nil {
fmt.Println("--- Initial Canvas Login Required ---")
fmt.Printf("Visit: %s/login/oauth2/auth?client_id=%s&response_type=code&redirect_uri=%s\n",
config.BaseURL, config.ClientID, url.QueryEscape(config.RedirectURI))
fmt.Print("Enter Code: ")
var code string
fmt.Scanln(&code)
tr, err := a.doTokenRequest(url.Values{
"grant_type": {"authorization_code"},
"client_id": {config.ClientID},
"client_secret": {config.ClientSecret},
"redirect_uri": {config.RedirectURI},
"code": {code},
})
if err != nil {
return "", err
}
SaveCredentials(&models.Credentials{RefreshToken: tr.RefreshToken})
fmt.Println("[+] Login successful.")
return tr.AccessToken, nil
return a.login()
}
fmt.Println("[*] Reusing saved refresh token...")
@@ -54,11 +39,49 @@ func (a *Authenticator) GetAccessToken() (string, error) {
"client_id": {config.ClientID},
"client_secret": {config.ClientSecret},
"refresh_token": {creds.RefreshToken},
"redirect_uri": {config.RedirectURI},
})
if err != nil {
fmt.Printf("[!] Refresh failed: %v\n", err)
return a.login()
}
fmt.Println("[+] Session refreshed.")
return tr.AccessToken, nil
}
func (a *Authenticator) login() (string, error) {
codeVerifier, codeChallenge, err := generatePKCEPair()
if err != nil {
return "", err
}
fmt.Println("--- Initial Canvas Login Required ---")
fmt.Printf("Visit: %s\n", buildAuthURL(codeChallenge))
fmt.Print("Enter Code or redirect URL: ")
input, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
code, err := extractCode(input)
if err != nil {
return "", err
}
tr, err := a.doTokenRequest(url.Values{
"grant_type": {"authorization_code"},
"client_id": {config.ClientID},
"client_secret": {config.ClientSecret},
"redirect_uri": {config.RedirectURI},
"code": {code},
"code_verifier": {codeVerifier},
})
if err != nil {
return "", err
}
fmt.Println("[+] Session refreshed.")
SaveCredentials(&models.Credentials{RefreshToken: tr.RefreshToken})
fmt.Println("[+] Login successful.")
return tr.AccessToken, nil
}
@@ -70,14 +93,75 @@ func (a *Authenticator) doTokenRequest(v url.Values) (*models.TokenResponse, err
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("token request failed: %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token request failed: %d: %s", resp.StatusCode, string(body))
}
var tr models.TokenResponse
json.NewDecoder(resp.Body).Decode(&tr)
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
return &tr, nil
}
func buildAuthURL(codeChallenge string) string {
u, _ := url.Parse(config.BaseURL + "/login/oauth2/auth")
q := u.Query()
q.Set("client_id", config.ClientID)
q.Set("response_type", "code")
q.Set("mobile", "1")
q.Set("purpose", config.OAuthPurpose)
q.Set("code_challenge", codeChallenge)
q.Set("code_challenge_method", "S256")
q.Set("redirect_uri", config.RedirectURI)
if config.AuthProvider != "" {
q.Set("authentication_provider", config.AuthProvider)
}
u.RawQuery = q.Encode()
return u.String()
}
func generatePKCEPair() (string, string, error) {
randomBytes := make([]byte, 64)
if _, err := rand.Read(randomBytes); err != nil {
return "", "", err
}
verifier := base64.RawURLEncoding.EncodeToString(randomBytes)
challengeBytes := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
return verifier, challenge, nil
}
func extractCode(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("empty authorization code")
}
for _, field := range strings.Fields(input) {
if strings.Contains(field, "code=") {
input = field
break
}
}
parsedURL, err := url.Parse(input)
if err == nil {
if code := parsedURL.Query().Get("code"); code != "" {
return code, nil
}
}
if values, err := url.ParseQuery(input); err == nil {
if code := values.Get("code"); code != "" {
return code, nil
}
}
return input, nil
}
func SaveCredentials(creds *models.Credentials) {
data, _ := json.MarshalIndent(creds, "", " ")
os.WriteFile(config.CredsFile, data, 0o644)

View File

@@ -17,19 +17,25 @@ import (
)
type Client struct {
HTTPClient *http.Client
AccessToken string
CourseID string
CourseName string
FilesOnly bool
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 bool) *Client {
func NewClient(httpClient *http.Client, accessToken, courseID string, filesOnly, videosOnly, moduleNumbers bool) *Client {
return &Client{
HTTPClient: httpClient,
AccessToken: accessToken,
CourseID: courseID,
FilesOnly: filesOnly,
HTTPClient: httpClient,
AccessToken: accessToken,
CourseID: courseID,
FilesOnly: filesOnly,
VideosOnly: videosOnly,
ModuleNumbers: moduleNumbers,
downloadedFiles: make(map[string]bool),
}
}
@@ -49,7 +55,25 @@ func (c *Client) GetCourseInfo() error {
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)
@@ -82,12 +106,23 @@ func (c *Client) DownloadCourseFiles(root string) {
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 {
subDir = filepath.Join(root, "Course Files", safeFolderPath)
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))
@@ -129,15 +164,25 @@ func (c *Client) DownloadModules(courseRoot string) {
json.NewDecoder(resp.Body).Decode(&modules)
resp.Body.Close()
for _, mod := range modules {
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 {
modBaseDir = filepath.Join(courseRoot, "Modules", utils.Sanitize(mod.Name))
if !c.FilesOnly && !c.VideosOnly {
modBaseDir = filepath.Join(courseRoot, "Modules", utils.Sanitize(modName))
}
os.MkdirAll(modBaseDir, 0o755)
if !c.FilesOnly {
fmt.Printf("\n[Module] %s\n", mod.Name)
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{}
@@ -145,15 +190,16 @@ func (c *Client) DownloadModules(courseRoot string) {
for _, item := range mod.Items {
// In videos-only mode always download to the flat courseRoot.
targetDir := modBaseDir
if len(subHeaderStack) > 0 && !c.FilesOnly {
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 {
if c.FilesOnly || c.VideosOnly {
continue
}
currentIndent := item.Indent
@@ -173,6 +219,9 @@ func (c *Client) DownloadModules(courseRoot string) {
fmt.Printf("%s--- %s ---\n", indent, item.Title)
case "File":
if c.VideosOnly {
continue
}
c.downloadModuleFile(item, targetDir)
case "ExternalTool":
@@ -223,11 +272,18 @@ func (c *Client) downloadModuleFile(item models.ModuleItem, dir string) {
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 !strings.EqualFold(origBase, item.Title) && item.Title != "" {
if !c.FilesOnly && !strings.EqualFold(origBase, item.Title) && item.Title != "" {
fileName = fmt.Sprintf("%s (%s)%s", origBase, item.Title, ext)
}

View File

@@ -4,7 +4,9 @@ const (
BaseURL = "https://canvas.vub.be"
ClientID = "170000000000044"
ClientSecret = "3sxR3NtgXRfT9KdpWGAFQygq6O9RzLN021h2lAzhHUZEeSQ5XGV41Ddi5iutwW6f"
RedirectURI = "urn:ietf:wg:oauth:2.0:oob"
RedirectURI = "https://sso.canvaslms.com/canvas/login"
OAuthPurpose = "CanvasArchiver"
AuthProvider = "microsoft"
CredsFile = "credentials.json"
PanoptoID = "15"
UserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36"

View File

@@ -10,6 +10,7 @@ type TokenResponse struct {
}
type Course struct {
ID int `json:"id"`
Name string `json:"name"`
}

View File

@@ -17,6 +17,27 @@ import (
"git.directme.in/Joren/CanvasArchiver/internal/utils"
)
// normalizePanoptoURL converts the query-param form that Canvas stores
// (List.aspx?folderID=X) to the fragment form that yt-dlp's PanoptoList
// extractor understands (List.aspx#folderID="X"). Without this yt-dlp
// ignores the folder filter and downloads the entire Panopto instance.
func normalizePanoptoURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
if strings.Contains(parsed.Path, "List.aspx") {
folderID := parsed.Query().Get("folderID")
if folderID != "" {
// Strip query, set fragment: List.aspx#folderID="<id>"
parsed.RawQuery = ""
parsed.Fragment = fmt.Sprintf(`folderID="%s"`, folderID)
return parsed.String()
}
}
return rawURL
}
func getYoutubeDLCommand() string {
exePath, err := os.Executable()
if err == nil {
@@ -192,13 +213,35 @@ func DownloadVideo(httpClient *http.Client, accessToken, courseID, modDir, input
fmt.Printf(" [*] Downloading video: %s\n", title)
ytCmd := getYoutubeDLCommand()
cmd := exec.Command(ytCmd,
"--no-playlist",
"--cookies", cookieFile,
"--referer", config.BaseURL+"/",
"-P", modDir,
"-o", utils.Sanitize(title)+".%(ext)s",
targetURL)
// Normalize folder URLs so yt-dlp scopes to the right folder.
normalizedURL := normalizePanoptoURL(targetURL)
// Folder/list URLs are intentional playlists; don't pass --no-playlist.
isList := strings.Contains(normalizedURL, "List.aspx")
var outputTpl string
var args []string
if isList {
outputTpl = utils.Sanitize(title) + "/%(title)s.%(ext)s"
args = []string{
"--cookies", cookieFile,
"--referer", config.BaseURL + "/",
"-P", modDir,
"-o", outputTpl,
normalizedURL,
}
} else {
outputTpl = utils.Sanitize(title) + ".%(ext)s"
args = []string{
"--no-playlist",
"--cookies", cookieFile,
"--referer", config.BaseURL + "/",
"-P", modDir,
"-o", outputTpl,
normalizedURL,
}
}
cmd := exec.Command(ytCmd, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -212,7 +255,7 @@ func DownloadVideo(httpClient *http.Client, accessToken, courseID, modDir, input
}
}
func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root string) {
func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root string, videosOnly bool) {
fmt.Println("\n[*] Checking for main Panopto recordings...")
jar, _ := cookiejar.New(nil)
@@ -300,8 +343,18 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root
}
os.WriteFile(cookieFile, []byte(cData), 0o644)
recordingsDir := filepath.Join(root, "Recordings")
os.MkdirAll(recordingsDir, 0o755)
var downloadDir string
var outputTemplate string
if videosOnly {
// Flat: all videos go directly into course root
downloadDir = root
outputTemplate = "%(title)s.%(ext)s"
} else {
downloadDir = filepath.Join(root, "Recordings")
os.MkdirAll(downloadDir, 0o755)
outputTemplate = "%(playlist_title)s/%(title)s.%(ext)s"
}
fmt.Println("[*] Starting yt-dlp download for main recordings...")
@@ -310,8 +363,8 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root
cmd := exec.Command(ytCmd,
"--cookies", cookieFile,
"--referer", config.BaseURL+"/",
"-P", recordingsDir,
"-o", "%(playlist_title)s/%(title)s.%(ext)s",
"-P", downloadDir,
"-o", outputTemplate,
decodedURL,
)
cmd.Stdout = os.Stdout
@@ -323,3 +376,4 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root
fmt.Println("[!] No main recordings available or handshake failed")
}
}