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 +
+
+
@@ -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"
}