From a61f3468ee581edcb08dfa7e3b4d0498d46a229e Mon Sep 17 00:00:00 2001 From: blank X Date: Wed, 17 Nov 2021 22:38:18 +0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + constants.go | 10 +++ go.mod | 5 ++ go.sum | 2 + main.go | 153 ++++++++++++++++++++++++++++++++++++++++++++ structs.go | 105 +++++++++++++++++++++++++++++++ utils.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 450 insertions(+) create mode 100644 .gitignore create mode 100644 constants.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 structs.go create mode 100644 utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eec14f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +omordl diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..61e832e --- /dev/null +++ b/constants.go @@ -0,0 +1,10 @@ +package main + +const ( + CONFIG_PLACEHOLDER string = "https://old.reddit.com/prefs/apps" + USER_AGENT string = "linux:omordl:v0.1.0 (by u/the_blank_x)" + EXAMPLE_CONFIG string = `{ + "client_id": "https://old.reddit.com/prefs/apps", + "client_secret": "https://old.reddit.com/prefs/apps" +}` +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29a6d40 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module omordl + +go 1.17 + +require golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df90cf1 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c h1:DHcbWVXeY+0Y8HHKR+rbLwnoh2F4tNCY7rTiHJ30RmA= +golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..07ebf18 --- /dev/null +++ b/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "bufio" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "sort" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + submissionUrl, err := url.Parse(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error when parsing submission url: %s\n", err) + os.Exit(1) + } + submissionId := os.Args[1] + if submissionUrl.Hostname() == "redd.it" { + submissionId = submissionUrl.EscapedPath()[1:] + } else if submissionUrl.Hostname() != "" { + split := strings.SplitN(submissionUrl.EscapedPath(), "/", 6) + if len(split) < 5 { + fmt.Fprintln(os.Stderr, "URL passed does not have enough path seperators") + os.Exit(1) + } + submissionId = split[4] + } + if submissionId == "" { + fmt.Fprintln(os.Stderr, "Submission ID is empty") + os.Exit(1) + } + client := &http.Client{ + Timeout: time.Duration(30 * time.Second), + } + err = LoadConfigAndData() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config and data: %s\n", err) + os.Exit(1) + } + token, err := GetToken(client) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get token: %s\n", err) + os.Exit(1) + } + submission, err := GetSubmission(client, token, submissionId) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get submission: %s\n", err) + os.Exit(1) + } + filename := strings.ReplaceAll(submission.Title, "/", "_") + "-" + submissionId + if filename[0] == '.' { + filename = "_" + filename[1:] + } + files, err := os.ReadDir(".") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to list files: %s\n", err) + os.Exit(1) + } + stdin := bufio.NewReader(os.Stdin) + for _, i := range files { + if strings.HasPrefix(i.Name(), filename) { + fmt.Printf("A file that starts with %s exists (%s), potentially overwrite (y/N)? ", filename, i.Name()) + b, err := stdin.ReadByte() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when reading stdin: %s\n", err) + os.Exit(1) + } + if b != 'y' && b != 'Y' { + fmt.Println("Not overwriting") + os.Exit(1) + } + break + } + } + if submission.CrosspostParent != "" && len(submission.CrosspostParentList) > 0 { + submission, err = GetSubmission(client, token, submission.CrosspostParent[3:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get original submission: %s\n", err) + os.Exit(1) + } + } + if submission.IsSelf { + fmt.Fprintln(os.Stderr, "Cannot download selfposts") + os.Exit(1) + } + urls := make([]string, 1) + urls[0] = submission.Url + if submission.IsVideo { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to find ffmpeg: %s\n", err) + os.Exit(1) + } + ffmpegUrl := submission.SecureMedia.RedditVideo.HlsUrl + if ffmpegUrl == "" { + ffmpegUrl = submission.SecureMedia.RedditVideo.DashUrl + } + if ffmpegUrl != "" { + client.CloseIdleConnections() + err = unix.Exec(ffmpegPath, []string{"ffmpeg", "-nostdin", "-i", ffmpegUrl, "-c", "copy", "--", filename + ".mp4"}, os.Environ()) + fmt.Fprintf(os.Stderr, "Failed to exec as ffmpeg: %s\n", err) + os.Exit(1) + } + fallbackUrl := submission.SecureMedia.RedditVideo.FallbackUrl + if fallbackUrl != "" { + urls[0] = fallbackUrl + } + } else if submission.IsGallery { + var galleryKeys []string + if submission.GalleryData != nil { + sort.Sort(submission.GalleryData) + for _, i := range submission.GalleryData.Items { + galleryKeys = append(galleryKeys, i.MediaId) + } + } else { + for i, _ := range submission.MediaMetadata { + galleryKeys = append(galleryKeys, i) + } + } + urls = nil + for _, i := range galleryKeys { + mediaMetadataItem := submission.MediaMetadata[i] + if mediaMetadataItem.Status != "valid" { + continue + } + if mediaMetadataItem.S.U != "" { + urls = append(urls, mediaMetadataItem.S.U) + } else if mediaMetadataItem.S.Mp4 != "" { + urls = append(urls, mediaMetadataItem.S.Mp4) + } else if mediaMetadataItem.S.Gif != "" { + urls = append(urls, mediaMetadataItem.S.Gif) + } + } + } + fmt.Println(urls) + i, err := InteractivelyAskIndex(stdin, urls) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get index: %s\n", err) + os.Exit(1) + } + url := urls[i] + fmt.Println(url) +} diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..50dfac1 --- /dev/null +++ b/structs.go @@ -0,0 +1,105 @@ +package main + +type Config struct { + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +type Data struct { + AuthorizationHeader string `json:"authorization_header"` + AuthorizationExpiry int64 `json:"authorization_expiry"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +type RedditVideo struct { + HlsUrl string `json:"hls_url"` + DashUrl string `json:"dash_url"` + FallbackUrl string `json:"fallback_url"` +} + +type SecureMedia struct { + RedditVideo *RedditVideo `json:"reddit_video"` +} + +type GalleryDataItem struct { + Id int `json:"id"` + MediaId string `json:"media_id"` +} + +type GalleryData struct { + Items []GalleryDataItem `json:"items"` +} + +func (s GalleryData) Len() int { + return len(s.Items) +} +func (s GalleryData) Swap(i, j int) { + s.Items[i], s.Items[j] = s.Items[j], s.Items[i] +} +func (s GalleryData) Less(i, j int) bool { + return s.Items[i].Id < s.Items[j].Id +} + +// despite the name, it is not several items, but one +// ??? +type MediaMetadataItemS struct { + U string `json:"u"` + Mp4 string `json:"mp4"` + Gif string `json:"gif"` +} + +type MediaMetadataItem struct { + Status string `json:"status"` + S *MediaMetadataItemS `json:"s"` +} + +type PreviewSource struct { + Url string `json:"url"` +} + +type PreviewVariants struct { + Mp4 *PreviewSource `json:"mp4"` + Gif *PreviewSource `json:"gif"` +} + +type PreviewImage struct { + Variants *PreviewVariants `json:"variants"` +} + +type Preview struct { + Images []PreviewImage `json:"images"` + Source *PreviewSource `json:"source"` +} + +type Submission struct { + CrosspostParent string `json:"crosspost_parent"` + // we don't care about the list, just does it exist or not + CrosspostParentList []interface{} `json:"crosspost_parent_list"` + Url string `json:"url"` + IsVideo bool `json:"is_video"` + SecureMedia *SecureMedia `json:"secure_media"` + IsGallery bool `json:"is_gallery"` + GalleryData *GalleryData `json:"gallery_data"` + MediaMetadata map[string]MediaMetadataItem `json:"media_metadata"` + IsRedditMediaDomain bool `json:"is_reddit_media_domain"` + Preview *Preview `json:"preview"` + IsSelf bool `json:"is_self"` + Title string `json:"title"` +} + +type SubmissionChild struct { + Data *Submission `json:"data"` +} + +type SubmissionResponseItemData struct { + Children []SubmissionChild `json:"children"` +} + +type SubmissionResponseItem struct { + Data SubmissionResponseItemData `json:"data"` +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..1b0a1c1 --- /dev/null +++ b/utils.go @@ -0,0 +1,174 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +var ( + configDir string + config *Config + data *Data +) + +func LoadConfigAndData() error { + iHateBugs, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("Cannot determine config directory: %s", err) + } + configDir = filepath.Join(iHateBugs, "omordl") + err = os.MkdirAll(configDir, 0o700) + if err != nil { + return fmt.Errorf("Cannot create config directory: %s", err) + } + configFile, err := os.OpenFile(filepath.Join(configDir, "config.json"), os.O_RDONLY, 0o600) + if err == nil { + contents, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("Failed to read config file: %s", err) + } + err = json.Unmarshal(contents, &config) + if err != nil { + return fmt.Errorf("Failed to parse config file: %s", err) + } + } else if errors.Is(err, fs.ErrNotExist) { + configFile, err = os.OpenFile(filepath.Join(configDir, "config.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("Failed to open config file for writing default template: %s", err) + } + _, err = configFile.WriteString(EXAMPLE_CONFIG) + if err != nil { + return fmt.Errorf("Failed to write to config file: %s", err) + } + return fmt.Errorf("Please fill out the default template at %s", filepath.Join(configDir, "config.json")) + } else { + return fmt.Errorf("Failed to open config file: %s", err) + } + if config.ClientId == CONFIG_PLACEHOLDER || config.ClientSecret == CONFIG_PLACEHOLDER { + return fmt.Errorf("Please fill out the default template at %s", filepath.Join(configDir, "config.json")) + } + dataFile, err := os.OpenFile(filepath.Join(configDir, "data.json"), os.O_RDONLY, 0o600) + if err == nil { + contents, err := io.ReadAll(dataFile) + if err != nil { + return fmt.Errorf("Failed to read data file: %s", err) + } + err = json.Unmarshal(contents, &data) + if err != nil { + return fmt.Errorf("Failed to parse data file: %s", err) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("Failed to open data file: %s", err) + } + return nil +} + +func WriteData() error { + contents, err := json.Marshal(data) + if err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(configDir, "data.json"), os.O_WRONLY|os.O_CREATE, 0o600) + if err != nil { + return err + } + _, err = file.Write(contents) + return err +} + +func GetToken(client *http.Client) (string, error) { + if data.AuthorizationHeader != "" && data.AuthorizationExpiry != 0 && data.AuthorizationExpiry > time.Now().Unix() { + return data.AuthorizationHeader, nil + } + request, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token?grant_type=client_credentials", nil) + if err != nil { + return "", fmt.Errorf("Failed to create request: %s", err) + } + request.Header.Add("User-Agent", USER_AGENT) + request.SetBasicAuth(config.ClientId, config.ClientSecret) + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("Failed to get response: %s", err) + } + contents, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + return "", fmt.Errorf("Failed to read response body: %s", err) + } + if response.StatusCode != 200 { + return "", fmt.Errorf("Response returned status code %d, body: %s", response.StatusCode, contents) + } + var tokenResponse TokenResponse + err = json.Unmarshal(contents, &tokenResponse) + if err != nil { + return "", fmt.Errorf("Failed to parse response: %s", err) + } + s := []string{tokenResponse.TokenType, tokenResponse.AccessToken} + data.AuthorizationHeader = strings.Join(s, " ") + data.AuthorizationExpiry = time.Now().Unix() + tokenResponse.ExpiresIn - 5 + err = WriteData() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to save token to disk: %s\n", err) + } + return data.AuthorizationHeader, nil +} + +func GetSubmission(client *http.Client, token, submissionId string) (*Submission, error) { + request, err := http.NewRequest("GET", "https://oauth.reddit.com/comments/"+submissionId+"/?raw_json=1&limit=1", nil) + if err != nil { + return nil, fmt.Errorf("Failed to create request: %s", err) + } + request.Header.Add("User-Agent", USER_AGENT) + request.Header.Add("Authorization", data.AuthorizationHeader) + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("Failed to get response: %s", err) + } + contents, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + return nil, fmt.Errorf("Failed to read response body: %s", err) + } + if response.StatusCode != 200 { + return nil, fmt.Errorf("Response returned status code %d, body: %s", response.StatusCode, contents) + } + var submission []SubmissionResponseItem + err = json.Unmarshal(contents, &submission) + if err != nil { + return nil, fmt.Errorf("Failed to parse response: %s", err) + } + return submission[0].Data.Children[0].Data, nil +} + +func InteractivelyAskIndex(stdin *bufio.Reader, items []string) (int, error) { + if len(items) == 1 { + return 0, nil + } + fmt.Printf("Select an item (from 1 to %d): ", len(items)) + str, err := stdin.ReadString('\n') + if err != nil { + return 0, fmt.Errorf("Failed to read stdin: %s", err) + } + i, err := strconv.Atoi(strings.TrimSpace(str)) + if err != nil { + return 0, fmt.Errorf("Failed to parse stdin: %s", err) + } + i-- + if i < 0 { + return i, errors.New("Index is under or equal to 0") + } + if i >= len(items) { + return i, errors.New("Index is bigger than items available") + } + return i, nil +}