Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,38 @@ func New() cli.Command {

// Action is the equivalent of the main except that all flags/configs
// have already been parsed and sanitized.
func Action(ctx context.Context, cmd *cli.Command) error {
// Handle Ctrl+C gracefully
func Action(cliCtx context.Context, cmd *cli.Command) error {
// Create a cancellable context for the application's lifecycle
appCtx, cancel := context.WithCancel(cliCtx)
defer cancel() // Ensure cancel is called if function exits normally

// Handle Ctrl+C gracefully by canceling the context
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
// Newline for clean display after ^C
fmt.Println()
fmt.Println("\033[1;33mExiting...\033[0m")
os.Exit(0)
// Only print a message if the appCtx hasn't been cancelled yet
select {
case <-appCtx.Done():
// Context already cancelled, likely exiting. Do nothing further.
default:
fmt.Println("\033[1;33mReturning to previous menu... (Press Ctrl+C again to exit)\033[0m")
cancel() // Cancel the context to signal return/exit
}
}()

for {
// Check if context was cancelled (e.g., by Ctrl+C)
select {
case <-appCtx.Done():
fmt.Println("\033[1;33mExiting GopherTube.\033[0m") // Final exit message
return nil // Exit the application
default:
// Continue
}

mainMenu := []string{"Search YouTube", "Search Downloads"}

// Check if fzf is installed
Expand All @@ -55,27 +75,41 @@ func Action(ctx context.Context, cmd *cli.Command) error {
return nil
}
var choice string
action := exec.CommandContext(ctx, path, "--prompt=Select mode: ")
// Pass appCtx to fzf command to allow cancellation
action := exec.CommandContext(appCtx, path, "--prompt=Select mode: ")
action.Stdin = strings.NewReader(strings.Join(mainMenu, "\n"))
out, err := action.Output()

if err != nil {
// ESC/cancel or fzf error: exit app
// If fzf was cancelled by context (Ctrl+C), or if user pressed ESC from main menu
if appCtx.Err() != nil {
// Context was cancelled (e.g., by Ctrl+C). The signal handler already printed a message.
// The select at the top of the loop will catch appCtx.Done() and exit.
continue // Continue to let the outer select handle the exit
}
// If fzf error is due to ESC or other fzf-internal cancellation (not Ctrl+C context cancellation),
// and we are in the main menu, it means the user wants to exit.
fmt.Println("\033[1;33mExiting GopherTube.\033[0m")
return nil
}
choice = strings.TrimSpace(string(out))
if choice == "" {
// Empty selection (e.g., ESC): exit app
// Empty selection (e.g., ESC): if in main menu, exit app
fmt.Println("\033[1;33mExiting GopherTube.\033[0m")
return nil
}

switch choice {
case "Search YouTube":
gophertubeYouTubeMode(cmd)
// Pass appCtx to sub-modes
gophertubeYouTubeMode(appCtx, cmd)
case "Search Downloads":
gophertubeDownloadsMode(cmd)
// Pass appCtx to sub-modes
gophertubeDownloadsMode(appCtx, cmd)
default:
// Unknown/empty selection: continue loop and ask again
continue
}
}
}
return nil
}
2 changes: 1 addition & 1 deletion internal/app/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"errors"
"os"

"github.com/urfave/cli-altsrc/v3"
altsrc "github.com/urfave/cli-altsrc/v3"
toml "github.com/urfave/cli-altsrc/v3/toml"
"github.com/urfave/cli/v3"
)
Expand Down
63 changes: 60 additions & 3 deletions internal/app/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package app

import (
"bytes"
"context"
"encoding/json"
"fmt"
"gophertube/internal/services"
"gophertube/internal/types"
"io"
"net/http"
"os"
"os/exec"
"strings"
Expand All @@ -31,7 +34,7 @@ func buildSearchHeader(resultCount int, query string) string {
// It renders the thumbnail via chafa, pads to place the cursor below the image,
// then prints colored metadata.
func buildSearchPreview() string {
tpl := `sh -c 'thumbfile="$1"; title="$2"; w=$((FZF_PREVIEW_COLUMNS * %d / %d)); h=$((FZF_PREVIEW_LINES * %d / %d)); if [ -s "$thumbfile" ] && [ -f "$thumbfile" ]; then chafa --size=${w}x${h} "$thumbfile" 2>/dev/null; else echo "No image preview available"; fi; pad=$((FZF_PREVIEW_LINES - h - 1)); i=0; while [ $i -gt -1 ] && [ $i -lt $pad ]; do echo; i=$((i+1)); done; printf "%s%%s%s\n" "$title"; printf "%sDuration:%s %%s\n" "$3"; printf "%sPublished:%s %%s\n" "$4"; printf "%sAuthor:%s %%s\n" "$5"; printf "%sViews:%s %%s\n" "$6"' sh {3} {2} {4} {8} {5} {6}`
tpl := `sh -c 'thumbfile="$1"; title="$2"; w=$(expr $FZF_PREVIEW_COLUMNS \* %d / %d); h=$(expr $FZF_PREVIEW_LINES \* %d / %d); if [ -s "$thumbfile" ] && [ -f "$thumbfile" ]; then chafa --size=${w}x${h} "$thumbfile" 2>/dev/null; else echo "No image preview available"; fi; pad=$(expr $FZF_PREVIEW_LINES - $h - 1); i=0; while [ $i -gt -1 ] && [ $i -lt $pad ]; do echo; i=$((i+1)); done; printf "%s%%s%s\n" "$title"; printf "%sDuration:%s %%s\n" "$3"; printf "%sPublished:%s %%s\n" "$4"; printf "%sAuthor:%s %%s\n" "$5"; printf "%sViews:%s %%s\n" "$6"' sh {3} {2} {4} {8} {5} {6}`
return fmt.Sprintf(
tpl,
previewWidthNum, previewWidthDen,
Expand Down Expand Up @@ -197,7 +200,7 @@ func readQuery() (string, bool) {
return string(query), false
}

func runFzf(videos []types.Video, searchLimit int, query string) int {
func runFzf(ctx context.Context, videos []types.Video, searchLimit int, query string) int {
limit := searchLimit
filter := ""
for {
Expand All @@ -223,7 +226,7 @@ func runFzf(videos []types.Video, searchLimit int, query string) int {
if filter != "" {
fzfArgs = append(fzfArgs, "--query="+filter)
}
cmd := exec.Command("fzf", fzfArgs...)
cmd := exec.CommandContext(ctx, "fzf", fzfArgs...)
cmd.Stdin = &input
pr, pw, _ := os.Pipe()
cmd.Stdout = pw
Expand Down Expand Up @@ -264,3 +267,57 @@ func runFzf(videos []types.Video, searchLimit int, query string) int {
return idx
}
}

type TerminalCompatibility struct {
HasSixel bool
HasCaca bool
}

func checkTerminalCompatibility() TerminalCompatibility {
_, sixelErr := exec.LookPath("sixel-encode")
_, cacaErr := exec.LookPath("cacafire")

return TerminalCompatibility{
HasSixel: sixelErr == nil,
HasCaca: cacaErr == nil,
}
}

func checkYTdlpVersion() {
// Get current version
cmd := exec.Command("yt-dlp", "--version")
output, err := cmd.Output()
if err != nil {
// yt-dlp not found, do nothing
return
}
currentVersion := strings.TrimSpace(string(output))

// Get latest version from github
resp, err := http.Get("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest")
if err != nil {
return
}
defer resp.Body.Close()

var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return
}
latestVersion := release.TagName

if currentVersion != latestVersion {
fmt.Printf("\n %sWarning: Your yt-dlp version (%s) is outdated.The latest version is %s.%s\n", colorYellow, currentVersion, latestVersion, colorReset)
fmt.Printf(" %sSome features might not work properly.%s\n", colorRed, colorReset)
fmt.Printf(" %sPlease update yt-dlp by running: yt-dlp -U or using your package manager.%s\n\n", colorCyan, colorReset)
}

// Check for terminal compatibility
compat := checkTerminalCompatibility()
if !compat.HasSixel && !compat.HasCaca {
fmt.Printf("\n %sWarning: In-terminal video playback may not be supported.%s\n", colorYellow, colorReset)
fmt.Printf(" %sFor best results, please install 'libsixel-bin' or 'caca-utils'.%s\n\n", colorCyan, colorReset)
}
}
Loading