package main import ( "encoding/json" "fmt" "html/template" "net/http" "net/url" "sync" "time" ) const retryDuration = 12 * time.Hour // Retry duration for unresponding piped instances var ( pipedInstances = []string{ "api.piped.yt", "pipedapi.moomoo.me", "pipedapi.darkness.services", "pipedapi.kavin.rocks", "piped-api.hostux.net", "pipedapi.syncpundit.io", "piped-api.cfe.re", "pipedapi.in.projectsegfau.lt", "piapi.ggtyler.dev", "piped-api.codespace.cz", "pipedapi.coldforge.xyz", "pipedapi.osphost.fi", } disabledInstances = make(map[string]bool) mu sync.Mutex videoResultsChan = make(chan []VideoResult) // Channel to receive video results from other nodes ) // VideoAPIResponse matches the structure of the JSON response from the Piped API type VideoAPIResponse struct { Items []struct { URL string `json:"url"` Title string `json:"title"` UploaderName string `json:"uploaderName"` Views int `json:"views"` Thumbnail string `json:"thumbnail"` Duration int `json:"duration"` UploadedDate string `json:"uploadedDate"` Type string `json:"type"` } `json:"items"` } // Function to format views similarly to the Python code func formatViews(views int) string { switch { case views >= 1_000_000_000: return fmt.Sprintf("%.1fB views", float64(views)/1_000_000_000) case views >= 1_000_000: return fmt.Sprintf("%.1fM views", float64(views)/1_000_000) case views >= 10_000: return fmt.Sprintf("%.1fK views", float64(views)/1_000) case views == 1: return fmt.Sprintf("%d view", views) default: return fmt.Sprintf("%d views", views) } } // formatDuration formats video duration as done in the Python code func formatDuration(seconds int) string { if 0 > seconds { return "Live" } hours := seconds / 3600 minutes := (seconds % 3600) / 60 seconds = seconds % 60 if hours > 0 { return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } return fmt.Sprintf("%02d:%02d", minutes, seconds) } func init() { go checkDisabledInstancesPeriodically() } func checkDisabledInstancesPeriodically() { checkAndReactivateInstances() // Initial immediate check ticker := time.NewTicker(retryDuration) defer ticker.Stop() for range ticker.C { checkAndReactivateInstances() } } func checkAndReactivateInstances() { mu.Lock() defer mu.Unlock() for instance, isDisabled := range disabledInstances { if isDisabled { // Check if the instance is available again if testInstanceAvailability(instance) { printInfo("Instance %s is now available and reactivated.", instance) delete(disabledInstances, instance) } else { printInfo("Instance %s is still not available.", instance) } } } } func testInstanceAvailability(instance string) bool { resp, err := http.Get(fmt.Sprintf("https://%s/search?q=%s&filter=all", instance, url.QueryEscape("test"))) if err != nil || resp.StatusCode != http.StatusOK { return false } return true } func makeHTMLRequest(query, safe, lang string, page int) (*VideoAPIResponse, error) { var lastError error mu.Lock() defer mu.Unlock() for _, instance := range pipedInstances { if disabledInstances[instance] { continue // Skip this instance because it's still disabled } url := fmt.Sprintf("https://%s/search?q=%s&filter=all&safe=%s&lang=%s&page=%d", instance, url.QueryEscape(query), safe, lang, page) resp, err := http.Get(url) if err != nil || resp.StatusCode != http.StatusOK { printInfo("Disabling instance %s due to error or status code: %v", instance, err) disabledInstances[instance] = true lastError = fmt.Errorf("error making request to %s: %w", instance, err) continue } defer resp.Body.Close() var apiResp VideoAPIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { lastError = fmt.Errorf("error decoding response from %s: %w", instance, err) continue } return &apiResp, nil } return nil, fmt.Errorf("all instances failed, last error: %v", lastError) } // handleVideoSearch adapted from the Python `videoResults`, handles video search requests func handleVideoSearch(w http.ResponseWriter, settings UserSettings, query string, page int) { start := time.Now() results := fetchVideoResults(query, settings.SafeSearch, settings.SearchLanguage, page) if len(results) == 0 { printWarn("No results from primary search, trying other nodes") results = tryOtherNodesForVideoSearch(query, settings.SafeSearch, settings.SearchLanguage, page, []string{hostID}) } elapsed := time.Since(start) tmpl, err := template.New("videos.html").Funcs(funcs).ParseFiles("templates/videos.html") if err != nil { printErr("Error parsing template: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } err = tmpl.Execute(w, map[string]interface{}{ "Results": results, "Query": query, "Fetched": fmt.Sprintf("%.2f seconds", elapsed.Seconds()), "Page": page, "HasPrevPage": page > 1, "HasNextPage": len(results) > 0, "LanguageOptions": languageOptions, "CurrentLang": settings.SearchLanguage, "Theme": settings.Theme, "Safe": settings.SafeSearch, "IsThemeDark": settings.IsThemeDark, }) if err != nil { printErr("Error executing template: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } func fetchVideoResults(query, safe, lang string, page int) []VideoResult { apiResp, err := makeHTMLRequest(query, safe, lang, page) if err != nil { printWarn("Error fetching video results: %v", err) return nil } var results []VideoResult for _, item := range apiResp.Items { if item.Type == "channel" || item.Type == "playlist" { continue } if item.UploadedDate == "" { item.UploadedDate = "Now" } results = append(results, VideoResult{ Href: fmt.Sprintf("https://youtube.com%s", item.URL), Title: item.Title, Date: item.UploadedDate, Views: formatViews(item.Views), Creator: item.UploaderName, Publisher: "Piped", Image: item.Thumbnail, //fmt.Sprintf("/img_proxy?url=%s", url.QueryEscape(item.Thumbnail)), // Using image proxy is not working, but its not needed here as piped is proxy anyway Duration: formatDuration(item.Duration), }) } return results }