diff --git a/.gitignore b/.gitignore index c619310..988fb45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ <<<<<<< HEAD build/bin node_modules -frontend/dist TODO.md ======= diff --git a/README.md b/README.md index 3a7185e..bd408b2 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,29 @@ - # Predator +

+ +

- -A simple desktop app for downloading YouTube videos and audio. Built with Go and Wails, using yt-dlp (go-ytdlp) under the hood. +A simple desktop app for grabbing videos and audio from YouTube and Instagram. Built with Go and Wails (web tech frontend), using yt-dlp under the hood to do the heavy lifting. ## What it does -- Download YouTube videos in any resolution from 144p up to 4K -- Extract just the audio in MP3, M4A, Opus, or WAV format -- Shows you file sizes before you download -- Track download progress with speed and ETA -- Cancel downloads if you change your mind -- Pick where you want files saved -- Keeps a history of what you've downloaded +- **Download videos** from YouTube in any resolution from 144p up to 4K +- **Grab audio only** in MP3, M4A, Opus, or WAV format +- **Works with Instagram too** - reels, posts, stories, TV videos +- **Playlist support** - Download entire YouTube playlists or pick specific videos +- **Shows file sizes** before you download so you know what you're getting into +- **Live progress tracking** - Watch speed, ETA, and percentage in real-time +- **Cancel anytime** - Change your mind? Hit cancel and it stops cleanly +- **Picks up where it left off** - Failed downloads auto-retry up to 3 times +- **Duplicate detection** - Warns if you already downloaded something +- **Choose where files go** - Set your download folder once, it remembers +- **Keeps history** - See what you've downloaded, filter by video/audio, open files directly +- **Dark and light themes** - Toggle with Ctrl+T ## Getting started -You'll need Go 1.24 or newer installed. +You'll need **Go 1.24 or newer** installed. ```bash git clone https://github.com/Aswanidev-vs/Predator.git @@ -25,41 +31,6 @@ cd Predator go mod download ``` -To run it: - -```bash -cd Predator -wails dev -``` - -To build: - -```bash -cd Predator -wails build -``` - -The first time you run it, it'll ask to download yt-dlp and ffmpeg automatically. Just say yes - it handles everything. - - - -## How to use - -1. Open the app -2. Click "Change Download Location" to pick where files go (or it'll use the current folder) -3. Paste a YouTube URL -4. Choose video or audio, pick your format -5. Hit download and wait - -That's it. The app shows progress in real-time and you can cancel anytime. - -## A few things to know - -- It tries to download the exact resolution you pick. If that's not available, it falls back to the next best thing. -- Videos get merged into a single MP4 file with H.264 codec for compatibility. -- Audio gets extracted in whatever format you chose. -- Download history is saved locally so you can find your files later. -======= # Predator - YouTube Downloader

@@ -172,7 +143,6 @@ Predator/ - **Debounced Input**: 600ms debounce on URL input to prevent excessive API calls - **Atomic Operations**: Thread-safe state management for concurrent operations - **Streaming Download**: Supports live progress updates during download ->>>>>>> 5c9da659bc26ab9faf682fb549dc7e0b599a7169 ## Disclaimer @@ -207,37 +177,3 @@ This application is provided "as-is" without warranty of any kind, express or im The authors assume no responsibility for any illegal activities or misuse of this tool. By using Predator, you accept all risks and responsibilities associated with your downloads. -<<<<<<< HEAD -======= -## License - -MIT License - -Copyright (c) 2025 Aswanidev-vs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -## Contributing - -Contributions are welcome! Feel free to submit issues and pull requests. - - - -**Note**: This application requires an active internet connection to download from YouTube and relies on the yt-dlp project to function. - diff --git a/app.go b/app.go index 6e9b3e9..acf0d9b 100644 --- a/app.go +++ b/app.go @@ -70,17 +70,17 @@ type DownloadTask struct { type VideoInfo struct { Title string `json:"title"` - Duration int `json:"duration"` + Duration float64 `json:"duration"` Resolutions []string `json:"resolutions"` IsPlaylist bool `json:"isPlaylist"` PlaylistID string `json:"playlistId,omitempty"` } type PlaylistVideo struct { - ID string `json:"id"` - Title string `json:"title"` - Duration int `json:"duration"` - URL string `json:"url"` + ID string `json:"id"` + Title string `json:"title"` + Duration float64 `json:"duration"` + URL string `json:"url"` } type PlaylistInfo struct { @@ -112,6 +112,13 @@ type DownloadHistory struct { Status string `json:"status"` } +// Settings represents the application settings +type Settings struct { + Theme string `json:"theme"` + OutputDir string `json:"outputDir"` + AutoUpdate bool `json:"autoUpdate"` +} + /* -------------------- URL Validation -------------------- */ var youtubeURLPatterns = []*regexp.Regexp{ @@ -122,6 +129,14 @@ var youtubeURLPatterns = []*regexp.Regexp{ regexp.MustCompile(`^(https?://)?(www\.)?youtube\.com/live/[\w-]+`), } +var instagramURLPatterns = []*regexp.Regexp{ + regexp.MustCompile(`^(https?://)?(www\.)?instagram\.com/p/[\w-]+`), + regexp.MustCompile(`^(https?://)?(www\.)?instagram\.com/reel/[\w-]+`), + regexp.MustCompile(`^(https?://)?(www\.)?instagram\.com/reels/[\w-]+`), + regexp.MustCompile(`^(https?://)?(www\.)?instagram\.com/tv/[\w-]+`), + regexp.MustCompile(`^(https?://)?(www\.)?instagram\.com/stories/[\w-]+`), +} + func (a *App) IsValidYouTubeURL(url string) bool { for _, pattern := range youtubeURLPatterns { if pattern.MatchString(url) { @@ -131,6 +146,20 @@ func (a *App) IsValidYouTubeURL(url string) bool { return false } +func (a *App) IsValidInstagramURL(url string) bool { + for _, pattern := range instagramURLPatterns { + if pattern.MatchString(url) { + return true + } + } + return false +} + +// IsValidURL checks if URL is valid for YouTube or Instagram +func (a *App) IsValidURL(url string) bool { + return a.IsValidYouTubeURL(url) || a.IsValidInstagramURL(url) +} + // IsPlaylistURL checks if the URL is a YouTube playlist func (a *App) IsPlaylistURL(url string) bool { // Check for playlist parameter in URL @@ -138,6 +167,11 @@ func (a *App) IsPlaylistURL(url string) bool { return playlistPattern.MatchString(url) } +// IsInstagramURL checks if the URL is an Instagram URL +func (a *App) IsInstagramURL(url string) bool { + return a.IsValidInstagramURL(url) +} + // FetchPlaylistInfo fetches all videos from a playlist func (a *App) FetchPlaylistInfo(url string) (*PlaylistInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout*2) // Longer timeout for playlists @@ -176,9 +210,9 @@ func (a *App) FetchPlaylistInfo(url string) (*PlaylistInfo, error) { Title string `json:"title"` ID string `json:"id"` Entries []struct { - ID string `json:"id"` - Title string `json:"title"` - Duration int `json:"duration"` + ID string `json:"id"` + Title string `json:"title"` + Duration float64 `json:"duration"` } `json:"entries"` } @@ -210,11 +244,11 @@ func (a *App) FetchPlaylistInfo(url string) (*PlaylistInfo, error) { // Subsequent lines are individual videos (flat playlist format) var entry struct { - ID string `json:"id"` - Title string `json:"title"` - Duration int `json:"duration"` - PlaylistID string `json:"playlist_id"` - PlaylistTitle string `json:"playlist_title"` + ID string `json:"id"` + Title string `json:"title"` + Duration float64 `json:"duration"` + PlaylistID string `json:"playlist_id"` + PlaylistTitle string `json:"playlist_title"` } if err := json.Unmarshal([]byte(line), &entry); err != nil { @@ -497,7 +531,87 @@ func (a *App) getHistoryFilePath() string { return filepath.Join(historyDir, "history.json") } +func (a *App) getSettingsFilePath() string { + homeDir, _ := os.UserHomeDir() + settingsDir := filepath.Join(homeDir, ".predator") + os.MkdirAll(settingsDir, 0755) + return filepath.Join(settingsDir, "settings.json") +} + +// GetSettings returns the current application settings +func (a *App) GetSettings() (*Settings, error) { + settingsFile := a.getSettingsFilePath() + data, err := os.ReadFile(settingsFile) + if err != nil { + if os.IsNotExist(err) { + // Return default settings if file doesn't exist + return a.GetDefaultSettings(), nil + } + return nil, err + } + + var settings Settings + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + return &settings, nil +} + +// SaveSettings saves the application settings to file +func (a *App) SaveSettings(settings Settings) error { + settingsFile := a.getSettingsFilePath() + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return os.WriteFile(settingsFile, data, 0644) +} + +// GetDefaultSettings returns the default application settings +func (a *App) GetDefaultSettings() *Settings { + return &Settings{ + Theme: "dark", + OutputDir: "", + AutoUpdate: true, + } +} + +// CheckDuplicate checks if a video has already been downloaded +func (a *App) CheckDuplicate(videoID string) (map[string]interface{}, error) { + history, err := a.GetDownloadHistory() + if err != nil { + return nil, err + } + + for _, item := range history { + // Extract video ID from the URL + itemVideoID := extractVideoID(item.URL) + if itemVideoID == videoID { + return map[string]interface{}{ + "isDuplicate": true, + "existingItem": item, + }, nil + } + } + + return map[string]interface{}{ + "isDuplicate": false, + "existingItem": nil, + }, nil +} + +// ShowNotification displays a system notification +func (a *App) ShowNotification(title, message string) error { + wailsRuntime.EventsEmit(a.ctx, "notification", map[string]string{ + "title": title, + "message": message, + }) + return nil +} + // GetDownloadHistory returns all download history + func (a *App) GetDownloadHistory() ([]DownloadHistory, error) { historyMu.RLock() defer historyMu.RUnlock() @@ -559,47 +673,109 @@ func (a *App) SaveToHistory(item DownloadHistory) error { func (a *App) OpenFolder(path string) error { log.Printf("OpenFolder called with path: %s", path) - // Clean the path - path = filepath.Clean(path) - log.Printf("Cleaned path: %s", path) + // If path is empty, return error + if path == "" { + return fmt.Errorf("path is empty") + } - // Check if path exists + // Check if the path exists as-is first info, err := os.Stat(path) + if err == nil { + // Path exists, determine if it's a file or directory + if info.IsDir() { + // It's a directory, just open it + log.Printf("Opening directory: %s", path) + return a.openDirectory(path) + } + // It's a file, open the containing folder and select the file + log.Printf("Opening file with /select,: %s", path) + return a.openDirectoryWithSelect(path) + } + + // Path might be relative or might not exist anymore + // Try to get absolute path + absPath, absErr := filepath.Abs(path) + if absErr != nil { + log.Printf("Failed to get absolute path: %v", absErr) + // Try opening the parent directory anyway + dir := filepath.Dir(path) + log.Printf("Trying to open parent directory: %s", dir) + return a.openDirectory(dir) + } + + log.Printf("Absolute path: %s", absPath) + + // Check if absolute path exists + info, err = os.Stat(absPath) if err != nil { - log.Printf("Path does not exist: %v", err) - return fmt.Errorf("path does not exist: %s", path) + log.Printf("Absolute path does not exist: %v", err) + // File might have been deleted, try to open the directory anyway + dir := filepath.Dir(absPath) + log.Printf("Trying to open directory: %s", dir) + // Check if directory exists + if _, dirErr := os.Stat(dir); dirErr != nil { + return fmt.Errorf("neither file nor directory exists: %s (tried: %s)", path, absPath) + } + return a.openDirectory(dir) } - log.Printf("Path exists, isDir: %v", info.IsDir()) + if info.IsDir() { + log.Printf("Opening absolute directory: %s", absPath) + return a.openDirectory(absPath) + } + + log.Printf("Opening absolute file with /select,: %s", absPath) + return a.openDirectoryWithSelect(absPath) +} +// openDirectory opens a directory using the system file explorer +func (a *App) openDirectory(dirPath string) error { var cmd *exec.Cmd switch runtime.GOOS { case "windows": - // Use full path to explorer.exe to avoid PATH issues explorerPath := `C:\Windows\explorer.exe` - // If path is a file, use /select, to highlight it - // If path is a directory, just open it - if info.IsDir() { - log.Printf("Opening directory: %s", path) - cmd = exec.Command(explorerPath, path) - } else { - // /select, opens the folder and highlights the file - log.Printf("Opening file with /select,: %s", path) - cmd = exec.Command(explorerPath, "/select,", path) - } + cmd = exec.Command(explorerPath, filepath.Clean(dirPath)) case "darwin": - cmd = exec.Command("open", path) + cmd = exec.Command("open", filepath.Clean(dirPath)) default: // linux - cmd = exec.Command("xdg-open", filepath.Dir(path)) + cmd = exec.Command("xdg-open", filepath.Clean(dirPath)) } - log.Printf("Executing command: %v", cmd) + log.Printf("Executing directory command: %v", cmd) + return cmd.Start() +} + +// openDirectoryWithSelect opens a directory and selects/highlights the specific file +func (a *App) openDirectoryWithSelect(filePath string) error { + var cmd *exec.Cmd + + // Ensure we have an absolute path for the file + absPath, err := filepath.Abs(filePath) + if err != nil { + absPath = filePath + } + + switch runtime.GOOS { + case "windows": + // /select, opens the folder and highlights the file + explorerPath := `C:\Windows\explorer.exe` + cmd = exec.Command(explorerPath, "/select,", absPath) + case "darwin": + // -R flag reveals (selects) the file in Finder + cmd = exec.Command("open", "-R", absPath) + default: // linux + // For linux, just open the parent directory + cmd = exec.Command("xdg-open", filepath.Dir(absPath)) + } + + log.Printf("Executing select command: %v", cmd) return cmd.Start() } // ClearHistory clears all download history func (a *App) ClearHistory() error { + historyMu.Lock() defer historyMu.Unlock() @@ -809,18 +985,20 @@ func (a *App) extractFFmpeg(zipPath, destDir string) error { continue } + // Derive a safe base name from the archive entry and validate it + baseName := filepath.Base(f.Name) + if baseName == "" || baseName == "." || baseName == ".." || strings.Contains(baseName, "..") || + strings.Contains(baseName, string(os.PathSeparator)) || strings.Contains(baseName, "/") { + // Skip potentially unsafe or malicious paths + continue + } + rc, err := f.Open() if err != nil { return err } - // Validate archive entry name to prevent directory traversal / Zip Slip - if strings.Contains(f.Name, "..") || filepath.IsAbs(f.Name) { - rc.Close() - continue - } - - destPath := filepath.Join(destDir, filepath.Base(f.Name)) + destPath := filepath.Join(destDir, baseName) out, err := os.Create(destPath) if err != nil { rc.Close() @@ -928,8 +1106,8 @@ func (a *App) FetchVideoInfo(url string) (*VideoInfo, error) { } var info struct { - Title string `json:"title"` - Duration int `json:"duration"` + Title string `json:"title"` + Duration float64 `json:"duration"` Formats []struct { Height *int `json:"height"` Width *int `json:"width"` @@ -1021,7 +1199,7 @@ func (a *App) AddToQueue(task DownloadTask) (string, error) { return taskID, nil } -// extractVideoID extracts the video ID from a YouTube URL +// extractVideoID extracts the video ID from a YouTube or Instagram URL func extractVideoID(url string) string { // Try to extract from youtube.com/watch?v=ID re1 := regexp.MustCompile(`[?&]v=([a-zA-Z0-9_-]{11})`) @@ -1041,6 +1219,24 @@ func extractVideoID(url string) string { return matches[1] } + // Try to extract from instagram.com/p/SHORTCODE + re4 := regexp.MustCompile(`^https?://(www\.)?instagram\.com/p/([a-zA-Z0-9_-]+)`) + if matches := re4.FindStringSubmatch(url); len(matches) > 2 { + return "ig_" + matches[2] + } + + // Try to extract from instagram.com/reel/SHORTCODE + re5 := regexp.MustCompile(`^https?://(www\.)?instagram\.com/reel/([a-zA-Z0-9_-]+)`) + if matches := re5.FindStringSubmatch(url); len(matches) > 2 { + return "ig_" + matches[2] + } + + // Try to extract from instagram.com/reels/SHORTCODE + re6 := regexp.MustCompile(`^https?://(www\.)?instagram\.com/reels/([a-zA-Z0-9_-]+)`) + if matches := re6.FindStringSubmatch(url); len(matches) > 2 { + return "ig_" + matches[2] + } + return "" } diff --git a/build/windows/installer/tmp/MicrosoftEdgeWebview2Setup.exe b/build/windows/installer/tmp/MicrosoftEdgeWebview2Setup.exe new file mode 100644 index 0000000..89a56ec Binary files /dev/null and b/build/windows/installer/tmp/MicrosoftEdgeWebview2Setup.exe differ diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh index 2f6d321..f9ff875 100644 --- a/build/windows/installer/wails_tools.nsh +++ b/build/windows/installer/wails_tools.nsh @@ -5,19 +5,19 @@ !include "FileFunc.nsh" !ifndef INFO_PROJECTNAME - !define INFO_PROJECTNAME "{{.Name}}" + !define INFO_PROJECTNAME "Predator" !endif !ifndef INFO_COMPANYNAME - !define INFO_COMPANYNAME "{{.Info.CompanyName}}" + !define INFO_COMPANYNAME "Predator" !endif !ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "{{.Info.ProductName}}" + !define INFO_PRODUCTNAME "Predator" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" + !define INFO_PRODUCTVERSION "1.0.0" !endif !ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "{{.Info.Copyright}}" + !define INFO_COPYRIGHT "Copyright........." !endif !ifndef PRODUCT_EXECUTABLE !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" @@ -203,20 +203,12 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macro wails.associateFiles ; Create file associations - {{range .Info.FileAssociations}} - !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - File "..\{{.IconName}}.ico" - {{end}} + !macroend !macro wails.unassociateFiles ; Delete app associations - {{range .Info.FileAssociations}} - !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" - - Delete "$INSTDIR\{{.IconName}}.ico" - {{end}} + !macroend !macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND @@ -235,15 +227,10 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macro wails.associateCustomProtocols ; Create custom protocols associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - {{end}} + !macroend !macro wails.unassociateCustomProtocols ; Delete app custom protocol associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" - {{end}} + !macroend diff --git a/main.go b/main.go index 8a9d1e8..4915a01 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" ) //go:embed all:frontend/dist @@ -31,6 +32,15 @@ func main() { Bind: []interface{}{ app, }, + Windows: &windows.Options{ + WebviewIsTransparent: false, + WindowIsTranslucent: false, + DisableWindowIcon: false, + WebviewUserDataPath: "", + }, + Debug: options.Debug{ + OpenInspectorOnStartup: false, + }, }) if err != nil { diff --git a/wails.json b/wails.json index a65d61a..cc59b72 100644 --- a/wails.json +++ b/wails.json @@ -5,6 +5,5 @@ "frontend:install": "npm install", "frontend:build": "npm run build", "frontend:dev:watcher": "npm run dev", - "frontend:dev:serverUrl": "auto", - + "frontend:dev:serverUrl": "auto" }