diff --git a/cache.go b/cache.go
index 5ba863a..f769066 100644
--- a/cache.go
+++ b/cache.go
@@ -62,6 +62,18 @@ type ForumSearchResult struct {
ThumbnailSrc string `json:"thumbnailSrc,omitempty"`
}
+type MusicResult struct {
+ URL string
+ Title string
+ Artist string
+ Description string
+ PublishedDate string
+ Thumbnail string
+ // AudioURL string
+ Source string
+ Duration string
+}
+
// GeocodeCachedItem represents a geocoding result stored in the cache.
type GeocodeCachedItem struct {
Latitude string
@@ -284,15 +296,23 @@ func convertToSearchResults(results interface{}) []SearchResult {
genericResults[i] = r
}
return genericResults
+ case []MusicResult:
+ genericResults := make([]SearchResult, len(res))
+ for i, r := range res {
+ genericResults[i] = r
+ }
+ return genericResults
}
return nil
}
-func convertToSpecificResults(results []SearchResult) ([]TextSearchResult, []TorrentResult, []ImageSearchResult, []ForumSearchResult) {
+func convertToSpecificResults(results []SearchResult) ([]TextSearchResult, []TorrentResult, []ImageSearchResult, []ForumSearchResult, []MusicResult) {
var textResults []TextSearchResult
var torrentResults []TorrentResult
var imageResults []ImageSearchResult
var forumResults []ForumSearchResult
+ var musicResults []MusicResult
+
for _, r := range results {
switch res := r.(type) {
case TextSearchResult:
@@ -303,7 +323,9 @@ func convertToSpecificResults(results []SearchResult) ([]TextSearchResult, []Tor
imageResults = append(imageResults, res)
case ForumSearchResult:
forumResults = append(forumResults, res)
+ case MusicResult:
+ musicResults = append(musicResults, res)
}
}
- return textResults, torrentResults, imageResults, forumResults
+ return textResults, torrentResults, imageResults, forumResults, musicResults
}
diff --git a/files.go b/files.go
index d2b4837..a93710d 100755
--- a/files.go
+++ b/files.go
@@ -111,7 +111,7 @@ func getFileResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string,
printDebug("Crawler disabled; skipping fetching.")
}
} else {
- _, torrentResults, _, _ := convertToSpecificResults(results)
+ _, torrentResults, _, _, _ := convertToSpecificResults(results)
combinedResults = torrentResults
}
case <-time.After(2 * time.Second):
diff --git a/images.go b/images.go
index 52d2e67..cc4d77e 100755
--- a/images.go
+++ b/images.go
@@ -107,7 +107,7 @@ func getImageResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string
printDebug("Crawler disabled; skipping fetching from image search engines.")
}
} else {
- _, _, imageResults, _ := convertToSpecificResults(results)
+ _, _, imageResults, _, _ := convertToSpecificResults(results)
combinedResults = filterValidImages(imageResults)
}
case <-time.After(2 * time.Second):
diff --git a/init.go b/init.go
index 6a079db..87dc0ce 100644
--- a/init.go
+++ b/init.go
@@ -81,6 +81,7 @@ func main() {
initImageEngines()
initFileEngines()
initPipedInstances()
+ initMusicEngines()
}
InitializeLanguage("en") // Initialize language before generating OpenSearch
diff --git a/lang/en/LC_MESSAGES/default.po b/lang/en/LC_MESSAGES/default.po
index eb0843d..c146dac 100644
--- a/lang/en/LC_MESSAGES/default.po
+++ b/lang/en/LC_MESSAGES/default.po
@@ -88,6 +88,9 @@ msgstr "Video"
msgid "videos"
msgstr "Videos"
+msgid "music"
+msgstr "Music"
+
msgid "forum"
msgstr "Forum"
diff --git a/main.go b/main.go
index c1b769d..5038b6c 100755
--- a/main.go
+++ b/main.go
@@ -164,6 +164,8 @@ func handleSearch(w http.ResponseWriter, r *http.Request) {
handleImageSearch(w, r, settings, query, page)
case "video":
handleVideoSearch(w, settings, query, page)
+ case "music":
+ handleMusicSearch(w, settings, query, page)
case "map":
handleMapSearch(w, settings, query)
case "forum":
diff --git a/music-bandcamp.go b/music-bandcamp.go
new file mode 100644
index 0000000..2c3210f
--- /dev/null
+++ b/music-bandcamp.go
@@ -0,0 +1,72 @@
+// music-bandcamp.go - Bandcamp specific implementation
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+func SearchBandcamp(query string, page int) ([]MusicResult, error) {
+ baseURL := "https://bandcamp.com/search?"
+ params := url.Values{
+ "q": []string{query},
+ "page": []string{fmt.Sprintf("%d", page)},
+ }
+
+ resp, err := http.Get(baseURL + params.Encode())
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ doc, err := goquery.NewDocumentFromReader(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse HTML: %v", err)
+ }
+
+ var results []MusicResult
+
+ doc.Find("li.searchresult").Each(func(i int, s *goquery.Selection) {
+ result := MusicResult{Source: "Bandcamp"}
+
+ // URL extraction
+ if urlSel := s.Find("div.itemurl a"); urlSel.Length() > 0 {
+ result.URL = strings.TrimSpace(urlSel.Text())
+ }
+
+ // Title extraction
+ if titleSel := s.Find("div.heading a"); titleSel.Length() > 0 {
+ result.Title = strings.TrimSpace(titleSel.Text())
+ }
+
+ // Artist extraction
+ if artistSel := s.Find("div.subhead"); artistSel.Length() > 0 {
+ result.Artist = strings.TrimSpace(artistSel.Text())
+ }
+
+ // Thumbnail extraction
+ if thumbSel := s.Find("div.art img"); thumbSel.Length() > 0 {
+ result.Thumbnail, _ = thumbSel.Attr("src")
+ }
+
+ // // Iframe URL construction
+ // if linkHref, exists := s.Find("div.itemurl a").Attr("href"); exists {
+ // if itemID := extractSearchItemID(linkHref); itemID != "" {
+ // itemType := strings.ToLower(strings.TrimSpace(s.Find("div.itemtype").Text()))
+ // result.IframeSrc = fmt.Sprintf(
+ // "https://bandcamp.com/EmbeddedPlayer/%s=%s/size=large/bgcol=000/linkcol=fff/artwork=small",
+ // itemType,
+ // itemID,
+ // )
+ // }
+ // }
+
+ results = append(results, result)
+ })
+
+ return results, nil
+}
diff --git a/music-soundcloud.go b/music-soundcloud.go
new file mode 100644
index 0000000..f8a7221
--- /dev/null
+++ b/music-soundcloud.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+type SoundCloudTrack struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Permalink string `json:"permalink"`
+ ArtworkURL string `json:"artwork_url"`
+ Duration int `json:"duration"`
+ User struct {
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ } `json:"user"`
+ Streams struct {
+ HTTPMP3128URL string `json:"http_mp3_128_url"`
+ } `json:"streams"`
+}
+
+func SearchSoundCloud(query string, page int) ([]MusicResult, error) {
+ clientID, err := extractClientID()
+ if err != nil {
+ return searchSoundCloudViaScraping(query, page)
+ }
+
+ apiResults, err := searchSoundCloudViaAPI(query, clientID, page)
+ if err == nil && len(apiResults) > 0 {
+ return convertSoundCloudResults(apiResults), nil
+ }
+
+ return searchSoundCloudViaScraping(query, page)
+}
+
+func searchSoundCloudViaAPI(query, clientID string, page int) ([]SoundCloudTrack, error) {
+ const limit = 10
+ offset := (page - 1) * limit
+
+ apiUrl := fmt.Sprintf(
+ "https://api-v2.soundcloud.com/search/tracks?q=%s&client_id=%s&limit=%d&offset=%d",
+ url.QueryEscape(query),
+ clientID,
+ limit,
+ offset,
+ )
+
+ resp, err := http.Get(apiUrl)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API request failed with status: %d", resp.StatusCode)
+ }
+
+ var response struct {
+ Collection []SoundCloudTrack `json:"collection"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return nil, err
+ }
+
+ return response.Collection, nil
+}
+
+func convertSoundCloudResults(tracks []SoundCloudTrack) []MusicResult {
+ var results []MusicResult
+
+ for _, track := range tracks {
+ thumbnail := strings.Replace(track.ArtworkURL, "large", "t500x500", 1)
+ trackURL := fmt.Sprintf("https://soundcloud.com/%s/%s",
+ track.User.Permalink,
+ track.Permalink,
+ )
+
+ results = append(results, MusicResult{
+ Title: track.Title,
+ Artist: track.User.Username,
+ URL: trackURL,
+ Thumbnail: thumbnail,
+ //AudioURL: track.Streams.HTTPMP3128URL,
+ Source: "SoundCloud",
+ Duration: fmt.Sprintf("%d", track.Duration/1000),
+ })
+ }
+ return results
+}
+
+func searchSoundCloudViaScraping(query string, page int) ([]MusicResult, error) {
+ searchUrl := fmt.Sprintf("https://soundcloud.com/search/sounds?q=%s", url.QueryEscape(query))
+ resp, err := http.Get(searchUrl)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ doc, err := goquery.NewDocumentFromReader(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var results []MusicResult
+ doc.Find("li.searchList__item").Each(func(i int, s *goquery.Selection) {
+ titleElem := s.Find("a.soundTitle__title")
+ artistElem := s.Find("a.soundTitle__username")
+ artworkElem := s.Find(".sound__coverArt")
+
+ title := strings.TrimSpace(titleElem.Text())
+ artist := strings.TrimSpace(artistElem.Text())
+ href, _ := titleElem.Attr("href")
+ thumbnail, _ := artworkElem.Find("span.sc-artwork").Attr("style")
+
+ if thumbnail != "" {
+ if matches := regexp.MustCompile(`url\((.*?)\)`).FindStringSubmatch(thumbnail); len(matches) > 1 {
+ thumbnail = strings.Trim(matches[1], `"`)
+ }
+ }
+
+ if title == "" || href == "" {
+ return
+ }
+
+ trackURL, err := url.Parse(href)
+ if err != nil {
+ return
+ }
+
+ if trackURL.Host == "" {
+ trackURL.Scheme = "https"
+ trackURL.Host = "soundcloud.com"
+ }
+
+ trackURL.Path = strings.ReplaceAll(trackURL.Path, "//", "/")
+ fullURL := trackURL.String()
+
+ results = append(results, MusicResult{
+ Title: title,
+ Artist: artist,
+ URL: fullURL,
+ Thumbnail: thumbnail,
+ Source: "SoundCloud",
+ })
+ })
+
+ return results, nil
+}
+
+func extractClientID() (string, error) {
+ resp, err := http.Get("https://soundcloud.com/")
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ doc, err := goquery.NewDocumentFromReader(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ var clientID string
+ doc.Find("script[src]").Each(func(i int, s *goquery.Selection) {
+ if clientID != "" {
+ return
+ }
+
+ src, _ := s.Attr("src")
+ if strings.Contains(src, "sndcdn.com/assets/") {
+ resp, err := http.Get(src)
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ re := regexp.MustCompile(`client_id:"([^"]+)"`)
+ matches := re.FindSubmatch(body)
+ if len(matches) > 1 {
+ clientID = string(matches[1])
+ }
+ }
+ })
+
+ if clientID == "" {
+ return "", fmt.Errorf("client_id not found")
+ }
+ return clientID, nil
+}
diff --git a/music-spotify.go b/music-spotify.go
new file mode 100644
index 0000000..d33e6a3
--- /dev/null
+++ b/music-spotify.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/PuerkitoBio/goquery"
+)
+
+func SearchSpotify(query string, page int) ([]MusicResult, error) {
+ searchUrl := fmt.Sprintf("https://open.spotify.com/search/%s", url.PathEscape(query))
+
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ req, err := http.NewRequest("GET", searchUrl, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %v", err)
+ }
+
+ // Set user agent ?
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("received non-200 status code: %d", resp.StatusCode)
+ }
+
+ doc, err := goquery.NewDocumentFromReader(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse document: %v", err)
+ }
+
+ var results []MusicResult
+
+ // Find track elements
+ doc.Find(`div[data-testid="tracklist-row"]`).Each(func(i int, s *goquery.Selection) {
+ // Extract title
+ title := s.Find(`div[data-testid="tracklist-row__title"] a`).Text()
+ title = strings.TrimSpace(title)
+
+ // Extract artist
+ artist := s.Find(`div[data-testid="tracklist-row__artist"] a`).First().Text()
+ artist = strings.TrimSpace(artist)
+
+ // Extract duration
+ duration := s.Find(`div[data-testid="tracklist-row__duration"]`).First().Text()
+ duration = strings.TrimSpace(duration)
+
+ // Extract URL
+ path, _ := s.Find(`div[data-testid="tracklist-row__title"] a`).Attr("href")
+ fullUrl := fmt.Sprintf("https://open.spotify.com%s", path)
+
+ // Extract thumbnail
+ thumbnail, _ := s.Find(`img[aria-hidden="false"]`).Attr("src")
+
+ if title != "" && artist != "" {
+ results = append(results, MusicResult{
+ Title: title,
+ Artist: artist,
+ URL: fullUrl,
+ Duration: duration,
+ Thumbnail: thumbnail,
+ Source: "Spotify",
+ })
+ }
+ })
+
+ return results, nil
+}
diff --git a/music-youtube.go b/music-youtube.go
new file mode 100644
index 0000000..698dc71
--- /dev/null
+++ b/music-youtube.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+type MusicAPIResponse struct {
+ Items []struct {
+ Title string `json:"title"`
+ UploaderName string `json:"uploaderName"`
+ Duration int `json:"duration"`
+ Thumbnail string `json:"thumbnail"`
+ URL string `json:"url"`
+ } `json:"items"` // Removed VideoID since we'll parse from URL
+}
+
+func SearchMusicViaPiped(query string, page int) ([]MusicResult, error) {
+ var lastError error
+ mu.Lock()
+ defer mu.Unlock()
+
+ for _, instance := range pipedInstances {
+ if disabledInstances[instance] {
+ continue
+ }
+
+ url := fmt.Sprintf(
+ "https://%s/search?q=%s&filter=music_songs&page=%d",
+ instance,
+ url.QueryEscape(query),
+ page,
+ )
+
+ resp, err := http.Get(url)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ printInfo("Disabling instance %s due to error: %v", instance, err)
+ disabledInstances[instance] = true
+ lastError = fmt.Errorf("request to %s failed: %w", instance, err)
+ continue
+ }
+
+ defer resp.Body.Close()
+ var apiResp MusicAPIResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
+ lastError = fmt.Errorf("failed to decode response from %s: %w", instance, err)
+ continue
+ }
+
+ return convertPipedToMusicResults(instance, apiResp), nil
+ }
+
+ return nil, fmt.Errorf("all Piped instances failed, last error: %v", lastError)
+}
+
+func convertPipedToMusicResults(instance string, resp MusicAPIResponse) []MusicResult {
+ seen := make(map[string]bool)
+ var results []MusicResult
+
+ for _, item := range resp.Items {
+ // Extract video ID from URL
+ u, err := url.Parse(item.URL)
+ if err != nil {
+ continue
+ }
+ videoID := u.Query().Get("v")
+ if videoID == "" || seen[videoID] {
+ continue
+ }
+ seen[videoID] = true
+
+ results = append(results, MusicResult{
+ Title: item.Title,
+ Artist: item.UploaderName,
+ URL: fmt.Sprintf("https://music.youtube.com%s", item.URL),
+ Duration: formatDuration(item.Duration),
+ Thumbnail: item.Thumbnail,
+ Source: "YouTube Music",
+ //AudioURL: fmt.Sprintf("https://%s/stream/%s", instance, videoID),
+ })
+ }
+ return results
+}
diff --git a/music.go b/music.go
new file mode 100644
index 0000000..34dd70d
--- /dev/null
+++ b/music.go
@@ -0,0 +1,178 @@
+// music.go - Central music search handler
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type MusicSearchEngine struct {
+ Name string
+ Func func(query string, page int) ([]MusicResult, error)
+}
+
+var (
+ musicSearchEngines []MusicSearchEngine
+ cacheMutex = &sync.Mutex{}
+)
+
+var allMusicSearchEngines = []MusicSearchEngine{
+ {Name: "SoundCloud", Func: SearchSoundCloud},
+ {Name: "YouTube", Func: SearchMusicViaPiped},
+ {Name: "Bandcamp", Func: SearchBandcamp},
+ //{Name: "Spotify", Func: SearchSpotify},
+}
+
+func initMusicEngines() {
+ // Initialize with all engines if no specific config
+ musicSearchEngines = allMusicSearchEngines
+}
+
+func handleMusicSearch(w http.ResponseWriter, settings UserSettings, query string, page int) {
+ start := time.Now()
+
+ cacheKey := CacheKey{
+ Query: query,
+ Page: page,
+ Type: "music",
+ Lang: settings.SearchLanguage,
+ Safe: settings.SafeSearch == "active",
+ }
+
+ var results []MusicResult
+
+ if cached, found := resultsCache.Get(cacheKey); found {
+ if musicResults, ok := convertCacheToMusicResults(cached); ok {
+ results = musicResults
+ }
+ }
+
+ if len(results) == 0 {
+ results = fetchMusicResults(query, page)
+ if len(results) > 0 {
+ resultsCache.Set(cacheKey, convertMusicResultsToCache(results))
+ }
+ }
+
+ go prefetchMusicPages(query, page)
+
+ elapsed := time.Since(start) // Calculate duration
+ fetched := fmt.Sprintf("%.2f %s", elapsed.Seconds(), Translate("seconds"))
+
+ data := map[string]interface{}{
+ "Results": results,
+ "Query": query,
+ "Page": page,
+ "HasPrevPage": page > 1,
+ "HasNextPage": len(results) >= 10, // Default page size
+ "MusicServices": getMusicServiceNames(),
+ "CurrentService": "all", // Default service
+ "Theme": settings.Theme,
+ "IsThemeDark": settings.IsThemeDark,
+ "Trans": Translate,
+ "Fetched": fetched,
+ }
+
+ renderTemplate(w, "music.html", data)
+}
+
+// Helper to get music service names
+func getMusicServiceNames() []string {
+ names := make([]string, len(allMusicSearchEngines))
+ for i, engine := range allMusicSearchEngines {
+ names[i] = engine.Name
+ }
+ return names
+}
+
+func convertMusicResultsToCache(results []MusicResult) []SearchResult {
+ cacheResults := make([]SearchResult, len(results))
+ for i, r := range results {
+ cacheResults[i] = r
+ }
+ return cacheResults
+}
+
+func convertCacheToMusicResults(cached []SearchResult) ([]MusicResult, bool) {
+ results := make([]MusicResult, 0, len(cached))
+ for _, item := range cached {
+ if musicResult, ok := item.(MusicResult); ok {
+ results = append(results, musicResult)
+ } else {
+ return nil, false
+ }
+ }
+ return results, true
+}
+
+func fetchMusicResults(query string, page int) []MusicResult {
+ var results []MusicResult
+ resultsChan := make(chan []MusicResult, len(musicSearchEngines))
+ var wg sync.WaitGroup
+
+ for _, engine := range musicSearchEngines {
+ wg.Add(1)
+ go func(e MusicSearchEngine) {
+ defer wg.Done()
+ res, err := e.Func(query, page)
+ if err == nil && len(res) > 0 {
+ resultsChan <- res
+ }
+ }(engine)
+ }
+
+ go func() {
+ wg.Wait()
+ close(resultsChan)
+ }()
+
+ for res := range resultsChan {
+ results = append(results, res...)
+ if len(results) >= 50 { // Default max results
+ break
+ }
+ }
+
+ return deduplicateResults(results)
+}
+
+func prefetchMusicPages(query string, currentPage int) {
+ for _, page := range []int{currentPage - 1, currentPage + 1} {
+ if page < 1 {
+ continue
+ }
+ cacheKey := CacheKey{
+ Query: query,
+ Page: page,
+ Type: "music",
+ }
+ if _, found := resultsCache.Get(cacheKey); !found {
+ go fetchMusicResults(query, page)
+ }
+ }
+}
+
+func deduplicateResults(results []MusicResult) []MusicResult {
+ seen := make(map[string]bool)
+ var unique []MusicResult
+
+ for _, res := range results {
+ if !seen[res.URL] {
+ seen[res.URL] = true
+ unique = append(unique, res)
+ }
+ }
+ return unique
+}
+
+// func generatePlayerHTML(result MusicResult) template.HTML {
+// if result.IframeSrc != "" {
+// return template.HTML(fmt.Sprintf(
+// ``,
+// result.IframeSrc,
+// ))
+// }
+// return template.HTML("")
+// }
diff --git a/static/css/style-music.css b/static/css/style-music.css
new file mode 100644
index 0000000..fccc9cd
--- /dev/null
+++ b/static/css/style-music.css
@@ -0,0 +1,119 @@
+/* Music Results Styling */
+.result-item.music-item {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 24px;
+ align-items: flex-start;
+}
+
+.music-thumbnail {
+ position: relative;
+ flex: 0 0 160px;
+ aspect-ratio: 1;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--placeholder-bg);
+}
+
+.music-thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.2s ease;
+}
+
+.music-thumbnail:hover img {
+ transform: scale(1.03);
+}
+
+.thumbnail-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--placeholder-bg);
+ color: var(--placeholder-icon);
+}
+
+.thumbnail-placeholder .material-icons-round {
+ font-size: 2.5rem;
+}
+
+.duration-overlay {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ backdrop-filter: blur(2px);
+}
+
+.music-info {
+ flex: 1;
+ min-width: 0;
+ padding-top: 4px;
+}
+
+.music-title {
+ margin: 0 0 8px 0;
+ font-size: 18px;
+ line-height: 1.3;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.music-title:hover {
+ text-decoration: underline;
+}
+
+.music-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.artist {
+ color: var(--accent-color);
+ font-weight: 500;
+}
+
+.meta-separator {
+ color: var(--border-color);
+ font-size: 12px;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .music-thumbnail {
+ flex-basis: 120px;
+ }
+
+ .music-title {
+ font-size: 16px;
+ }
+
+ .music-meta {
+ font-size: 13px;
+ gap: 6px;
+ }
+}
+
+@media (max-width: 480px) {
+ .music-thumbnail {
+ flex-basis: 100px;
+ }
+
+ .duration-overlay {
+ font-size: 11px;
+ padding: 3px 6px;
+ bottom: 6px;
+ right: 6px;
+ }
+}
\ No newline at end of file
diff --git a/static/fonts/MaterialIcons-Round.woff2 b/static/fonts/MaterialIcons-Round.woff2
new file mode 100644
index 0000000..f94dba5
Binary files /dev/null and b/static/fonts/MaterialIcons-Round.woff2 differ
diff --git a/static/fonts/material-icons-round-v108-latin-regular.woff2 b/static/fonts/material-icons-round-v108-latin-regular.woff2
index 6f6a973..c143837 100644
Binary files a/static/fonts/material-icons-round-v108-latin-regular.woff2 and b/static/fonts/material-icons-round-v108-latin-regular.woff2 differ
diff --git a/templates/files.html b/templates/files.html
index 7e39e29..94fc0c6 100755
--- a/templates/files.html
+++ b/templates/files.html
@@ -103,6 +103,10 @@
+
+
+
+
diff --git a/templates/forums.html b/templates/forums.html
index a8015cd..c8400bb 100755
--- a/templates/forums.html
+++ b/templates/forums.html
@@ -103,6 +103,10 @@
+
+
+
+
diff --git a/templates/images.html b/templates/images.html
index d58f015..5a44b0b 100755
--- a/templates/images.html
+++ b/templates/images.html
@@ -113,6 +113,10 @@
+
+
+
+
diff --git a/templates/map.html b/templates/map.html
index 054f910..3d6e5a3 100644
--- a/templates/map.html
+++ b/templates/map.html
@@ -118,6 +118,10 @@
+
+
+
+
diff --git a/templates/music.html b/templates/music.html
new file mode 100644
index 0000000..2ac7198
--- /dev/null
+++ b/templates/music.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+ {{ if .IsThemeDark }}
+
+ {{ end }}
+
{{ .Query }} - Music Search - {{ translate "site_name" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
QGato
+
A open-source private search engine.
+
+
+
+
+
+
+
+
+
+
{{ translate "fetched_in" .Fetched }}
+
+
+ {{if .Results}}
+ {{range .Results}}
+
+
+
+
{{.Title}}
+
+ {{.Artist}}
+ |
+ {{.Source}}
+
+
+
+ {{end}}
+ {{else if .NoResults}}
+
+ {{ translate "no_results_found" .Query }}
+ {{ translate "suggest_rephrase" }}
+
+ {{else}}
+
{{ translate "no_more_results" }}
+ {{end}}
+
+
+ {{ translate "searching_for_new_results" }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/search.html b/templates/search.html
index 44445fe..a6e0b12 100755
--- a/templates/search.html
+++ b/templates/search.html
@@ -121,6 +121,13 @@
{{ translate "videos" }}
+
+
+
+
+ {{ translate "music" }}
+
{{ translate "forums" }}
diff --git a/templates/videos.html b/templates/videos.html
index 1cda0b7..a011b2c 100644
--- a/templates/videos.html
+++ b/templates/videos.html
@@ -103,6 +103,10 @@
{{ translate "videos" }}
+
+
+ {{ translate "music" }}
+
{{ translate "forums" }}
diff --git a/text.go b/text.go
index b59d8bb..3422f84 100755
--- a/text.go
+++ b/text.go
@@ -92,7 +92,7 @@ func getTextResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string,
resultsCache.Set(cacheKey, convertToSearchResults(combinedResults))
}
} else {
- textResults, _, _, _ := convertToSpecificResults(results)
+ textResults, _, _, _, _ := convertToSpecificResults(results)
combinedResults = textResults
}
case <-time.After(2 * time.Second):