From 632a62018087b64c6caa5c45dd68424f7c79fcf6 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Sat, 22 Jun 2024 01:16:24 +0100 Subject: [PATCH] feat: view media with external applications This commit adds integration to external image viewers and video players to allow users to view image and video attachments. Enbas creates a cache directory where the media is downloaded to before opening the external program for viewing. Users can view one or more media attachments from a single status. --- cmd/enbas/main.go | 9 ++ internal/client/client.go | 40 +++++++- internal/client/media.go | 4 + internal/config/credentials.go | 6 +- internal/config/directory.go | 41 -------- internal/executor/show.go | 141 +++++++++++++++++++++++++-- internal/printer/media_attachment.go | 4 + internal/utilities/directories.go | 54 ++++++++++ internal/utilities/file.go | 13 +++ internal/utilities/utilities.go | 37 +++++++ 10 files changed, 297 insertions(+), 52 deletions(-) delete mode 100644 internal/config/directory.go create mode 100644 internal/utilities/directories.go create mode 100644 internal/utilities/utilities.go diff --git a/cmd/enbas/main.go b/cmd/enbas/main.go index c288602..60ac439 100644 --- a/cmd/enbas/main.go +++ b/cmd/enbas/main.go @@ -30,13 +30,19 @@ func main() { func run() error { var ( configDir string + cacheDir string pager string + imageViewer string + videoPlayer string maxTerminalWidth int noColor *bool ) flag.StringVar(&configDir, "config-dir", "", "Specify your config directory") + flag.StringVar(&cacheDir, "cache-dir", "", "Specify your cache directory") flag.StringVar(&pager, "pager", "", "Specify your preferred pager to page through long outputs. This is disabled by default.") + flag.StringVar(&imageViewer, "image-viewer", "", "Specify your favourite image viewer.") + flag.StringVar(&videoPlayer, "video-player", "", "Specify your favourite video player.") flag.IntVar(&maxTerminalWidth, "max-terminal-width", 80, "Specify the maximum terminal width when displaying resources on screen.") flag.BoolFunc("no-color", "Disable ANSI colour output when displaying text on screen", func(value string) error { @@ -170,6 +176,9 @@ func run() error { executor.CommandShow: executor.NewShowExecutor( printer, configDir, + cacheDir, + imageViewer, + videoPlayer, executor.CommandShow, executor.CommandSummaryLookup(executor.CommandShow), ), diff --git a/internal/client/client.go b/internal/client/client.go index ea006e7..27ece40 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "os" "time" "codeflow.dananglin.me.uk/apollo/enbas/internal" @@ -60,13 +61,50 @@ func (g *Client) AuthCodeURL() string { ) } +func (g *Client) DownloadMedia(url, path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("unable to create the HTTP request: %w", err) + } + + request.Header.Set("User-Agent", g.UserAgent) + + response, err := g.HTTPClient.Do(request) + if err != nil { + return fmt.Errorf("received an error after attempting the download: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf( + "did not receive an OK response from the GoToSocial server: got %d", + response.StatusCode, + ) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("unable to create %s: %w", path, err) + } + defer file.Close() + + if _, err = io.Copy(file, response.Body); err != nil { + return fmt.Errorf("unable to save the download to %s: %w", path, err) + } + + return nil +} + func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error { ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) defer cancel() request, err := http.NewRequestWithContext(ctx, method, url, requestBody) if err != nil { - return fmt.Errorf("unable to create the HTTP request, %w", err) + return fmt.Errorf("unable to create the HTTP request: %w", err) } request.Header.Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/client/media.go b/internal/client/media.go index 3be293d..bedf763 100644 --- a/internal/client/media.go +++ b/internal/client/media.go @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2024 Dan Anglin +// +// SPDX-License-Identifier: GPL-3.0-or-later + package client import ( diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 56bb077..47f6bef 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" "strings" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" ) const ( @@ -41,7 +43,7 @@ func (e CredentialsNotFoundError) Error() string { // directory. If the directory is not specified then the default directory is used. If the directory // is not present, it will be created. func SaveCredentials(configDir, username string, credentials Credentials) (string, error) { - if err := ensureConfigDir(calculateConfigDir(configDir)); err != nil { + if err := utilities.EnsureDirectory(utilities.CalculateConfigDir(configDir)); err != nil { return "", fmt.Errorf("unable to ensure the configuration directory: %w", err) } @@ -141,5 +143,5 @@ func saveCredentialsConfigFile(authConfig CredentialsConfig, configDir string) e } func credentialsConfigFile(configDir string) string { - return filepath.Join(calculateConfigDir(configDir), credentialsFileName) + return filepath.Join(utilities.CalculateConfigDir(configDir), credentialsFileName) } diff --git a/internal/config/directory.go b/internal/config/directory.go deleted file mode 100644 index 4cad91b..0000000 --- a/internal/config/directory.go +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Dan Anglin -// -// SPDX-License-Identifier: GPL-3.0-or-later - -package config - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "codeflow.dananglin.me.uk/apollo/enbas/internal" -) - -func calculateConfigDir(configDir string) string { - if configDir != "" { - return configDir - } - - rootDir, err := os.UserConfigDir() - if err != nil { - rootDir = "." - } - - return filepath.Join(rootDir, internal.ApplicationName) -} - -func ensureConfigDir(configDir string) error { - if _, err := os.Stat(configDir); err != nil { - if errors.Is(err, os.ErrNotExist) { - if err := os.MkdirAll(configDir, 0o750); err != nil { - return fmt.Errorf("unable to create %s: %w", configDir, err) - } - } else { - return fmt.Errorf("unknown error received after getting the config directory information: %w", err) - } - } - - return nil -} diff --git a/internal/executor/show.go b/internal/executor/show.go index 1c01162..2f09a5f 100644 --- a/internal/executor/show.go +++ b/internal/executor/show.go @@ -7,6 +7,8 @@ package executor import ( "flag" "fmt" + "path/filepath" + "strings" "codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/model" @@ -23,6 +25,7 @@ type ShowExecutor struct { showUserPreferences bool showInBrowser bool configDir string + cacheRoot string resourceType string accountName string statusID string @@ -30,16 +33,22 @@ type ShowExecutor struct { listID string tag string pollID string - attachmentID string + fromResourceType string + imageViewer string + videoPlayer string limit int + attachmentIDs MultiStringFlagValue } -func NewShowExecutor(printer *printer.Printer, configDir, name, summary string) *ShowExecutor { +func NewShowExecutor(printer *printer.Printer, configDir, cacheRoot, imageViewer, videoPlayer, name, summary string) *ShowExecutor { showExe := ShowExecutor{ FlagSet: flag.NewFlagSet(name, flag.ExitOnError), - printer: printer, - configDir: configDir, + printer: printer, + configDir: configDir, + cacheRoot: cacheRoot, + imageViewer: imageViewer, + videoPlayer: videoPlayer, } showExe.BoolVar(&showExe.myAccount, flagMyAccount, false, "Set to true to lookup your account") @@ -53,7 +62,8 @@ func NewShowExecutor(printer *printer.Printer, configDir, name, summary string) showExe.StringVar(&showExe.listID, flagListID, "", "Specify the ID of the list to display") showExe.StringVar(&showExe.tag, flagTag, "", "Specify the name of the tag to use") showExe.StringVar(&showExe.pollID, flagPollID, "", "Specify the ID of the poll to display") - showExe.StringVar(&showExe.attachmentID, flagAttachmentID, "", "Specify the ID of the media attachment to display") + showExe.Var(&showExe.attachmentIDs, flagAttachmentID, "Specify the ID of the media attachment to display") + showExe.StringVar(&showExe.fromResourceType, flagFrom, "", "Specify the resource type to view the target resource from (e.g. status for viewing media from, etc)") showExe.IntVar(&showExe.limit, flagLimit, 20, "Specify the limit of items to display") showExe.Usage = commandUsageFunc(name, summary, showExe.FlagSet) @@ -81,7 +91,7 @@ func (s *ShowExecutor) Execute() error { resourceFollowRequest: s.showFollowRequests, resourcePoll: s.showPoll, resourceMutedAccounts: s.showMutedAccounts, - resourceMedia: s.showMediaAttachment, + resourceMedia: s.showMedia, resourceMediaAttachment: s.showMediaAttachment, } @@ -408,11 +418,18 @@ func (s *ShowExecutor) showMutedAccounts(gtsClient *client.Client) error { } func (s *ShowExecutor) showMediaAttachment(gtsClient *client.Client) error { - if s.attachmentID == "" { + if len(s.attachmentIDs) == 0 { return FlagNotSetError{flagText: flagAttachmentID} } - attachment, err := gtsClient.GetMediaAttachment(s.attachmentID) + if len(s.attachmentIDs) != 1 { + return fmt.Errorf( + "unexpected number of attachment IDs received: want 1, got %d", + len(s.attachmentIDs), + ) + } + + attachment, err := gtsClient.GetMediaAttachment(s.attachmentIDs[0]) if err != nil { return fmt.Errorf("unable to retrieve the media attachment: %w", err) } @@ -421,3 +438,111 @@ func (s *ShowExecutor) showMediaAttachment(gtsClient *client.Client) error { return nil } + +func (s *ShowExecutor) showMedia(gtsClient *client.Client) error { + if s.fromResourceType == "" { + return FlagNotSetError{flagText: flagFrom} + } + + funcMap := map[string]func(*client.Client) error{ + resourceStatus: s.showMediaFromStatus, + } + + doFunc, ok := funcMap[s.fromResourceType] + if !ok { + return fmt.Errorf("do not support viewing media from %s", s.fromResourceType) + } + + return doFunc(gtsClient) +} + +func (s *ShowExecutor) showMediaFromStatus(gtsClient *client.Client) error { + if len(s.attachmentIDs) == 0 { + return FlagNotSetError{flagText: flagAttachmentID} + } + + if s.statusID == "" { + return FlagNotSetError{flagText: flagStatusID} + } + + status, err := gtsClient.GetStatus(s.statusID) + if err != nil { + return fmt.Errorf("unable to retrieve the status: %w", err) + } + + cacheDir := filepath.Join( + utilities.CalculateCacheDir(s.cacheRoot, utilities.GetFQDN(gtsClient.Authentication.Instance)), + "media", + ) + + if err := utilities.EnsureDirectory(cacheDir); err != nil { + return fmt.Errorf("unable to ensure the existence of the directory %q: %w", cacheDir, err) + } + + type media struct { + url string + mediaType string + } + + attachmentsHashMap := make(map[string]media) + imageFiles := make([]string, 0) + videoFiles := make([]string, 0) + + for _, statusAttachment := range status.MediaAttachments { + attachmentsHashMap[statusAttachment.ID] = media{ + url: statusAttachment.URL, + mediaType: statusAttachment.Type, + } + } + + for _, attachmentID := range s.attachmentIDs { + mediaObj, ok := attachmentsHashMap[attachmentID] + if !ok { + return fmt.Errorf("unknown media attachment: %s", attachmentID) + } + + split := strings.Split(mediaObj.url, "/") + filename := split[len(split)-1] + filePath := filepath.Join(cacheDir, filename) + + fileExists, err := utilities.FileExists(filePath) + if err != nil { + return fmt.Errorf( + "unable to check if the media file is already downloaded for %s: %w", + attachmentID, + err, + ) + } + + if !fileExists { + if err := gtsClient.DownloadMedia(mediaObj.url, filePath); err != nil { + return fmt.Errorf( + "unable to download the media attachment for %s: %w", + attachmentID, + err, + ) + } + } + + switch mediaObj.mediaType { + case "image": + imageFiles = append(imageFiles, filePath) + case "video": + videoFiles = append(videoFiles, filePath) + } + } + + if len(imageFiles) > 0 { + if err := utilities.OpenMedia(s.imageViewer, imageFiles); err != nil { + return fmt.Errorf("unable to open the image viewer: %w", err) + } + } + + if len(videoFiles) > 0 { + if err := utilities.OpenMedia(s.videoPlayer, videoFiles); err != nil { + return fmt.Errorf("unable to open the video player: %w", err) + } + } + + return nil +} diff --git a/internal/printer/media_attachment.go b/internal/printer/media_attachment.go index 0e0be83..bb72cd3 100644 --- a/internal/printer/media_attachment.go +++ b/internal/printer/media_attachment.go @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2024 Dan Anglin +// +// SPDX-License-Identifier: GPL-3.0-or-later + package printer import ( diff --git a/internal/utilities/directories.go b/internal/utilities/directories.go new file mode 100644 index 0000000..8a5d871 --- /dev/null +++ b/internal/utilities/directories.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2024 Dan Anglin +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package utilities + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" +) + +func CalculateConfigDir(configDir string) string { + if configDir != "" { + return configDir + } + + configRoot, err := os.UserConfigDir() + if err != nil { + configRoot = "." + } + + return filepath.Join(configRoot, internal.ApplicationName) +} + +func CalculateCacheDir(cacheDir, instanceFQDN string) string { + if cacheDir != "" { + return cacheDir + } + + cacheRoot, err := os.UserCacheDir() + if err != nil { + cacheRoot = "." + } + + return filepath.Join(cacheRoot, internal.ApplicationName, instanceFQDN) +} + +func EnsureDirectory(dir string) error { + if _, err := os.Stat(dir); err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("unable to create %s: %w", dir, err) + } + } else { + return fmt.Errorf("received an unknown error after getting the directory information: %w", err) + } + } + + return nil +} diff --git a/internal/utilities/file.go b/internal/utilities/file.go index 99fc67b..decce89 100644 --- a/internal/utilities/file.go +++ b/internal/utilities/file.go @@ -5,6 +5,7 @@ package utilities import ( + "errors" "fmt" "os" ) @@ -17,3 +18,15 @@ func ReadFile(path string) (string, error) { return string(data), nil } + +func FileExists(path string) (bool, error) { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("unable to check if the file exists: %w", err) + } + + return true, nil +} diff --git a/internal/utilities/utilities.go b/internal/utilities/utilities.go new file mode 100644 index 0000000..5d3766e --- /dev/null +++ b/internal/utilities/utilities.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Dan Anglin +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package utilities + +import ( + "fmt" + "os/exec" + "regexp" +) + +func GetFQDN(url string) string { + r := regexp.MustCompile(`http(s)?:\/\/`) + + return r.ReplaceAllString(url, "") +} + +type UnspecifiedProgramError struct{} + +func (e UnspecifiedProgramError) Error() string { + return "the program to view these files is unspecified" +} + +func OpenMedia(viewer string, paths []string) error { + if viewer == "" { + return UnspecifiedProgramError{} + } + + command := exec.Command(viewer, paths...) + + if err := command.Start(); err != nil { + return fmt.Errorf("received an error after starting the image viewer: %w", err) + } + + return nil +} -- 2.45.2