201 lines
6.4 KiB
Go
201 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// Search updates are newline-delimited JSON rather than one large JSON document. The first
|
|
// update is deliberately allowed to come from whichever upstream answers first.
|
|
func (s *Server) handleSearchStream(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
term := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if len([]rune(term)) < minSearchQueryRunes {
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}})
|
|
return
|
|
}
|
|
limit := queryInt(r, "limit", 40, 100)
|
|
s.recordSearchQuery(r.Context(), sess, term)
|
|
canRequest := s.requestAllowed(r, sess)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
flusher, _ := w.(http.Flusher)
|
|
enc := json.NewEncoder(w)
|
|
updates := make(chan []json.RawMessage, 3)
|
|
var wg sync.WaitGroup
|
|
start := func(fn func(context.Context) []json.RawMessage) {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); updates <- fn(ctx) }()
|
|
}
|
|
|
|
// Emby, Sonarr and Radarr are independent. In particular, an unavailable *arr must
|
|
// never hold back the library answer.
|
|
start(func(ctx context.Context) []json.RawMessage {
|
|
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
|
|
"SearchTerm": {term}, "IncludeItemTypes": {"Movie,Series,Episode"},
|
|
"Recursive": {"true"}, "Limit": {itoa(limit)},
|
|
}, fieldsRow+",ProviderIds"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
|
|
s.decorateItems(ctx, items)
|
|
return markSearchAvailable(items)
|
|
})
|
|
if s.sonarrEnabled(ctx) {
|
|
start(func(ctx context.Context) []json.RawMessage { return s.streamSonarr(ctx, term, limit, canRequest) })
|
|
}
|
|
if s.radarrEnabled(ctx) {
|
|
start(func(ctx context.Context) []json.RawMessage { return s.streamRadarr(ctx, term, limit, canRequest) })
|
|
}
|
|
go func() { wg.Wait(); close(updates) }()
|
|
|
|
merged := make([]json.RawMessage, 0, limit)
|
|
for update := range updates {
|
|
for _, item := range update {
|
|
merged = mergeSearchRaw(merged, item, limit)
|
|
}
|
|
if err := enc.Encode(map[string]any{"items": merged}); err != nil {
|
|
return
|
|
}
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func markSearchAvailable(items []json.RawMessage) []json.RawMessage {
|
|
for i, raw := range items {
|
|
var item map[string]any
|
|
if json.Unmarshal(raw, &item) != nil {
|
|
continue
|
|
}
|
|
item["MembySearchState"] = RequestStatusAvailable
|
|
if encoded, err := json.Marshal(item); err == nil {
|
|
items[i] = encoded
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (s *Server) streamSonarr(ctx context.Context, term string, limit int, canRequest bool) []json.RawMessage {
|
|
series, err := s.sonarr.Lookup(ctx, term)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
ids := make([]int, 0, len(series))
|
|
for _, v := range series {
|
|
if v.TVDBID > 0 {
|
|
ids = append(ids, v.TVDBID)
|
|
}
|
|
}
|
|
inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tvdb", ids)
|
|
items := make([]json.RawMessage, 0, limit)
|
|
for _, v := range series {
|
|
if v.TVDBID == 0 || len(items) >= limit {
|
|
continue
|
|
}
|
|
state := lookupStatusFor(RequestSubject{Tracked: v.ID > 0, InLibrary: inLibrary[v.TVDBID], Released: seriesReleased(v.Status, v.NextAiring, time.Now())}, false)
|
|
items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Series", "sonarr", state,
|
|
strconv.Itoa(v.TVDBID), sonarrCoverURL(v.Images, "poster"), canRequest))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (s *Server) streamRadarr(ctx context.Context, term string, limit int, canRequest bool) []json.RawMessage {
|
|
movies, err := s.radarr.Lookup(ctx, term)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
ids := make([]int, 0, len(movies))
|
|
for _, v := range movies {
|
|
if v.TMDBID > 0 {
|
|
ids = append(ids, v.TMDBID)
|
|
}
|
|
}
|
|
inLibrary, _ := s.store.LibraryContainsProviderIDs(ctx, "Tmdb", ids)
|
|
items := make([]json.RawMessage, 0, limit)
|
|
for _, v := range movies {
|
|
if v.TMDBID == 0 || len(items) >= limit {
|
|
continue
|
|
}
|
|
state := lookupStatusFor(RequestSubject{Tracked: v.ID > 0, HasFile: v.HasFile, InLibrary: inLibrary[v.TMDBID], Released: movieReleased(v.Status)}, false)
|
|
items = append(items, searchExternalItem(v.Title, v.Year, v.Overview, "Movie", "radarr", state,
|
|
strconv.Itoa(v.TMDBID), radarrCoverURL(v.Images, "poster"), canRequest))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func searchExternalItem(title string, year int, overview, itemType, source, state, providerID, poster string, canRequest bool) json.RawMessage {
|
|
item := map[string]any{"Id": source + ":" + providerID, "Name": title, "Type": itemType,
|
|
"ProductionYear": year, "Overview": overview, "MembySource": source,
|
|
"MembySearchState": state, "MembyPlayable": false, "MembyPosterURL": poster,
|
|
"MembyRequestable": canRequest && state == RequestStatusRequestable,
|
|
"ProviderIds": map[string]string{map[string]string{"sonarr": "Tvdb", "radarr": "Tmdb"}[source]: providerID}}
|
|
raw, _ := json.Marshal(item)
|
|
return raw
|
|
}
|
|
|
|
func mergeSearchRaw(existing []json.RawMessage, candidate json.RawMessage, limit int) []json.RawMessage {
|
|
key := searchRawKey(candidate)
|
|
for i, current := range existing {
|
|
if searchRawKey(current) != key {
|
|
continue
|
|
}
|
|
var currentItem map[string]any
|
|
_ = json.Unmarshal(current, ¤tItem)
|
|
// An Emby item is authoritative and stays playable even when an *arr later finds it.
|
|
if source, _ := currentItem["MembySource"].(string); source == "" {
|
|
var incoming map[string]any
|
|
_ = json.Unmarshal(candidate, &incoming)
|
|
if incoming["MembySource"] != nil {
|
|
return existing
|
|
}
|
|
return existing
|
|
}
|
|
var merged map[string]any
|
|
_ = json.Unmarshal(current, &merged)
|
|
var incoming map[string]any
|
|
_ = json.Unmarshal(candidate, &incoming)
|
|
if incoming["MembySource"] == nil {
|
|
existing[i] = candidate
|
|
return existing
|
|
}
|
|
for k, v := range incoming {
|
|
merged[k] = v
|
|
}
|
|
existing[i], _ = json.Marshal(merged)
|
|
return existing
|
|
}
|
|
if len(existing) < limit {
|
|
existing = append(existing, candidate)
|
|
}
|
|
return existing
|
|
}
|
|
|
|
func searchRawKey(raw json.RawMessage) string {
|
|
var item struct {
|
|
ProviderIDs map[string]string `json:"ProviderIds"`
|
|
Name, Type string
|
|
}
|
|
if json.Unmarshal(raw, &item) == nil {
|
|
for _, key := range []string{"Tmdb", "Tvdb", "Imdb"} {
|
|
if id := item.ProviderIDs[key]; id != "" {
|
|
return key + ":" + id
|
|
}
|
|
}
|
|
}
|
|
return strings.ToLower(item.Type + ":" + strings.TrimSpace(item.Name))
|
|
}
|