325 lines
9 KiB
Go
325 lines
9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const retryDuration = 12 * time.Hour // Retry duration for unresponding piped instances
|
|
|
|
var (
|
|
preferredInstance string
|
|
pipedInstanceCacheFile string
|
|
pipedInstances = []string{}
|
|
disabledInstances = make(map[string]bool)
|
|
mu sync.Mutex
|
|
)
|
|
|
|
func initPipedInstances() {
|
|
if config.DriveCacheEnabled {
|
|
pipedInstanceCacheFile = filepath.Join(config.DriveCache.Path, "piped_instances.txt")
|
|
|
|
cached := loadCachedPipedInstances()
|
|
if len(cached) > 0 {
|
|
pipedInstances = cached
|
|
printInfo("Loaded %d cached Piped instances from disk.", len(cached))
|
|
} else {
|
|
pipedInstances = config.MetaSearch.Video
|
|
savePipedInstances(pipedInstances)
|
|
}
|
|
|
|
// load preferred
|
|
if pref := loadPreferredInstance(); pref != "" {
|
|
preferredInstance = pref
|
|
printInfo("Using cached preferred Piped instance: %s", pref)
|
|
}
|
|
} else {
|
|
pipedInstances = config.MetaSearch.Video
|
|
}
|
|
|
|
go checkDisabledInstancesPeriodically()
|
|
go selectInitialPipedInstance()
|
|
}
|
|
|
|
func savePreferredInstance(instance string) {
|
|
if pipedInstanceCacheFile == "" {
|
|
return
|
|
}
|
|
path := filepath.Join(filepath.Dir(pipedInstanceCacheFile), "piped_preferred.txt")
|
|
if err := os.WriteFile(path, []byte(instance), 0644); err != nil {
|
|
printWarn("Failed to write preferred Piped instance: %v", err)
|
|
}
|
|
}
|
|
|
|
func loadPreferredInstance() string {
|
|
if pipedInstanceCacheFile == "" {
|
|
return ""
|
|
}
|
|
path := filepath.Join(filepath.Dir(pipedInstanceCacheFile), "piped_preferred.txt")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func loadCachedPipedInstances() []string {
|
|
if pipedInstanceCacheFile == "" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(pipedInstanceCacheFile)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
var out []string
|
|
for _, l := range lines {
|
|
trimmed := strings.TrimSpace(l)
|
|
if trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func savePipedInstances(instances []string) {
|
|
if pipedInstanceCacheFile == "" {
|
|
return
|
|
}
|
|
content := strings.Join(instances, "\n")
|
|
if err := os.WriteFile(pipedInstanceCacheFile, []byte(content), 0644); err != nil {
|
|
printWarn("Failed to write piped instance list to cache: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 checkDisabledInstancesPeriodically() {
|
|
checkAndReactivateInstances() // Initial immediate check
|
|
ticker := time.NewTicker(retryDuration)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
checkAndReactivateInstances()
|
|
}
|
|
}
|
|
|
|
func selectInitialPipedInstance() {
|
|
for _, inst := range pipedInstances {
|
|
if testInstanceAvailability(inst) {
|
|
mu.Lock()
|
|
preferredInstance = inst
|
|
mu.Unlock()
|
|
printInfo("Selected preferred piped instance: %s", inst)
|
|
return
|
|
}
|
|
}
|
|
printWarn("No available piped instances found during initial scan.")
|
|
}
|
|
|
|
func disableInstance(inst string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
disabledInstances[inst] = true
|
|
if preferredInstance == inst {
|
|
preferredInstance = ""
|
|
}
|
|
}
|
|
|
|
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) {
|
|
mu.Lock()
|
|
instance := preferredInstance
|
|
mu.Unlock()
|
|
|
|
if instance != "" && !disabledInstances[instance] {
|
|
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 {
|
|
defer resp.Body.Close()
|
|
var apiResp VideoAPIResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err == nil {
|
|
return &apiResp, nil
|
|
}
|
|
}
|
|
printWarn("Preferred instance %s failed, falling back to other instances", instance)
|
|
disableInstance(instance)
|
|
}
|
|
|
|
// Fallback loop
|
|
var lastError error
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
for _, inst := range pipedInstances {
|
|
if disabledInstances[inst] {
|
|
continue
|
|
}
|
|
url := fmt.Sprintf("https://%s/search?q=%s&filter=all&safe=%s&lang=%s&page=%d",
|
|
inst, 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", inst, err)
|
|
disabledInstances[inst] = true
|
|
lastError = fmt.Errorf("error from %s: %w", inst, err)
|
|
continue
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
var apiResp VideoAPIResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
lastError = fmt.Errorf("decode error from %s: %w", inst, err)
|
|
continue
|
|
}
|
|
|
|
// Store new preferred instance
|
|
preferredInstance = inst
|
|
savePreferredInstance(inst)
|
|
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()
|
|
|
|
var results []VideoResult
|
|
if config.MetaSearchEnabled {
|
|
results = fetchVideoResults(query, settings.SafeSearch, settings.SearchLanguage, page)
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
// Prepare the data to pass to the template
|
|
data := map[string]interface{}{
|
|
"Results": results,
|
|
"Query": query,
|
|
"Fetched": FormatElapsedTime(elapsed),
|
|
"Page": page,
|
|
"HasPrevPage": page > 1,
|
|
"HasNextPage": len(results) > 0,
|
|
"NoResults": len(results) == 0,
|
|
"LanguageOptions": languageOptions,
|
|
"CurrentLang": settings.SearchLanguage,
|
|
"Theme": settings.Theme,
|
|
"Safe": settings.SafeSearch,
|
|
"IsThemeDark": settings.IsThemeDark,
|
|
}
|
|
|
|
// Render the template without measuring time
|
|
renderTemplate(w, "videos.html", data)
|
|
}
|
|
|
|
func fetchVideoResults(query, safe, lang string, page int) []VideoResult {
|
|
// Check if the crawler is enabled
|
|
if !config.MetaSearchEnabled {
|
|
printDebug("Crawler is disabled; skipping video search.")
|
|
return []VideoResult{}
|
|
}
|
|
|
|
// Proceed with Piped API request if MetaSearchEnabled
|
|
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
|
|
}
|