Skip to content

Commit b7bdc51

Browse files
YangHanqingclaude
andcommitted
Seasons API, search filtering, and landing-page polish
Folds in the Seasons API + library search filtering, plus landing-page updates: a real hero photo, a new 使用指南/guide page, and an IPTV feature callout — all added since the previous local v0.9.4 commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5d47554 commit b7bdc51

14 files changed

Lines changed: 479 additions & 14 deletions

File tree

‎desktop-go/internal/emby/client.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ func (c *Client) Episodes(seriesID, seasonID string, start, limit int, sort stri
349349
return c.request("GET", "/Shows/"+seriesID+"/Episodes", q, nil)
350350
}
351351

352+
func (c *Client) Seasons(seriesID string) ([]byte, error) {
353+
q := url.Values{"UserId": {c.userID()}}
354+
return c.request("GET", "/Shows/"+seriesID+"/Seasons", q, nil)
355+
}
356+
352357
// ── Images ───────────────────────────────────────────────────────────────────
353358

354359
// ImageBytes returns the image content and its content-type, or nil if missing.

‎desktop-go/internal/plex/client.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,23 @@ func (c *Client) Episodes(series, season string, start, limit int, order string)
356356
return marshalItems(out[start:end], int64(total))
357357
}
358358

359+
func (c *Client) Seasons(seriesID string) ([]byte, error) {
360+
d, e := c.get("/library/metadata/"+url.PathEscape(seriesID)+"/children", nil)
361+
if e != nil {
362+
return nil, e
363+
}
364+
out := []map[string]any{}
365+
for _, m := range metadata(d) {
366+
if strings.EqualFold(stringValue(m["type"]), "season") {
367+
out = append(out, mapItem(m, false))
368+
}
369+
}
370+
sort.Slice(out, func(i, j int) bool {
371+
return integer(out[i]["IndexNumber"]) < integer(out[j]["IndexNumber"])
372+
})
373+
return marshalItems(out, int64(len(out)))
374+
}
375+
359376
func (c *Client) ImageBytes(id string, maxHeight int, imageType string) ([]byte, string) {
360377
d, e := c.get("/library/metadata/"+url.PathEscape(id), nil)
361378
if e != nil {

‎desktop-go/internal/provider/provider.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Media interface {
2323
Resume(limit int) ([]byte, error)
2424
ItemDetailRaw(id string) ([]byte, error)
2525
Episodes(series, season string, start, limit int, sort string) ([]byte, error)
26+
Seasons(seriesID string) ([]byte, error)
2627
ImageBytes(id string, maxHeight int, imageType string) ([]byte, string)
2728
BackdropBytes(id string, maxHeight, index int) ([]byte, string)
2829
ChoosePlayURL(id string) (PlayChoice, error)
@@ -47,6 +48,7 @@ func (m *embyMedia) ItemDetailRaw(a string) ([]byte, error) { return m.c.ItemDet
4748
func (m *embyMedia) Episodes(a, b string, c, d int, e string) ([]byte, error) {
4849
return m.c.Episodes(a, b, c, d, e)
4950
}
51+
func (m *embyMedia) Seasons(a string) ([]byte, error) { return m.c.Seasons(a) }
5052
func (m *embyMedia) ImageBytes(a string, b int, c string) ([]byte, string) {
5153
return m.c.ImageBytes(a, b, c)
5254
}
@@ -76,6 +78,7 @@ func (m *plexMedia) ItemDetailRaw(a string) ([]byte, error) { return m.c.ItemDet
7678
func (m *plexMedia) Episodes(a, b string, c, d int, e string) ([]byte, error) {
7779
return m.c.Episodes(a, b, c, d, e)
7880
}
81+
func (m *plexMedia) Seasons(a string) ([]byte, error) { return m.c.Seasons(a) }
7982
func (m *plexMedia) ImageBytes(a string, b int, c string) ([]byte, string) {
8083
return m.c.ImageBytes(a, b, c)
8184
}

‎desktop-go/internal/server/handlers.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,42 @@ func (s *Server) embyItems(w http.ResponseWriter, r *http.Request) {
416416
qInt(r, "limit", 60),
417417
search != "",
418418
)
419+
if err == nil && search != "" {
420+
body = dropUnmatchedEpisodes(body, search)
421+
}
419422
writeRaw(w, r, body, err)
420423
}
421424

425+
// dropUnmatchedEpisodes removes Episode results that only matched because the
426+
// server indexes an episode under its parent series name — otherwise a
427+
// series-title search floods the results with every episode of that show.
428+
// An episode stays only if its own title contains the search term.
429+
func dropUnmatchedEpisodes(body []byte, search string) []byte {
430+
var payload struct {
431+
Items []map[string]any `json:"Items"`
432+
TotalRecordCount int `json:"TotalRecordCount"`
433+
}
434+
if json.Unmarshal(body, &payload) != nil {
435+
return body
436+
}
437+
q := strings.ToLower(search)
438+
filtered := payload.Items[:0]
439+
for _, item := range payload.Items {
440+
if typ, _ := item["Type"].(string); typ == "Episode" {
441+
name, _ := item["Name"].(string)
442+
if !strings.Contains(strings.ToLower(name), q) {
443+
continue
444+
}
445+
}
446+
filtered = append(filtered, item)
447+
}
448+
payload.Items = filtered
449+
if out, err := json.Marshal(payload); err == nil {
450+
return out
451+
}
452+
return body
453+
}
454+
422455
func (s *Server) embyItemDetail(w http.ResponseWriter, r *http.Request) {
423456
c, err := provider.Active()
424457
if err != nil {
@@ -449,6 +482,16 @@ func (s *Server) embyEpisodes(w http.ResponseWriter, r *http.Request) {
449482
writeRaw(w, r, body, err)
450483
}
451484

485+
func (s *Server) embySeasons(w http.ResponseWriter, r *http.Request) {
486+
c, err := provider.Active()
487+
if err != nil {
488+
writeErr(w, r, err)
489+
return
490+
}
491+
body, err := c.Seasons(r.URL.Query().Get("series_id"))
492+
writeRaw(w, r, body, err)
493+
}
494+
452495
func (s *Server) embyImage(w http.ResponseWriter, r *http.Request) {
453496
c, err := provider.Active()
454497
if err != nil {

‎desktop-go/internal/server/server.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func (s *Server) Handler() http.Handler {
5757
mux.HandleFunc("GET /api/emby/items", s.embyItems)
5858
mux.HandleFunc("GET /api/emby/items/{item_id}", s.embyItemDetail)
5959
mux.HandleFunc("GET /api/emby/episodes", s.embyEpisodes)
60+
mux.HandleFunc("GET /api/emby/seasons", s.embySeasons)
6061
mux.HandleFunc("GET /api/emby/image/{item_id}", s.embyImage)
6162

6263
// ── File sources (local / SMB / WebDAV) ──

‎site/assets/hero.jpg‎

338 KB
Loading

‎site/assets/hero.png‎

-1.88 MB
Binary file not shown.

‎site/assets/site.css‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
}
1414

1515
* { box-sizing: border-box; }
16+
.hidden { display: none !important; }
1617
html { scroll-behavior: smooth; }
1718
body { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.6; }
1819
a { color: inherit; text-decoration: none; }
@@ -183,3 +184,61 @@ footer a:hover { color: white; }
183184
.modal-card h3 { margin-bottom: 10px; font-size: 22px; }
184185
.modal-card p { margin-bottom: 24px; color: var(--muted); font-size: 14px; }
185186
.modal-card .button { width: 100%; }
187+
188+
/* ---- Guide page ("使用指南") ---- */
189+
.guide-hero { padding: 170px 0 70px; }
190+
.guide-hero .back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 26px; color: #80938b; font-size: 13px; }
191+
.guide-hero .back-link:hover { color: white; }
192+
.guide-hero h1 { max-width: 780px; }
193+
.guide-hero .hero-lead { margin-bottom: 0; }
194+
195+
.path-nav { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; padding-bottom: 70px; }
196+
.path-nav a { display: block; padding: 26px 24px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255,255,255,.022); transition: transform .2s, background .2s, border-color .2s; }
197+
.path-nav a:hover { transform: translateY(-3px); background: rgba(255,255,255,.045); border-color: rgba(221,245,233,.3); }
198+
.path-nav span { display: block; margin-bottom: 14px; color: var(--mint); font: 11px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .06em; }
199+
.path-nav h3 { margin-bottom: 8px; font-size: 17px; letter-spacing: -.02em; }
200+
.path-nav p { margin: 0; color: #82948c; font-size: 13px; }
201+
202+
.guide-section { padding: 70px 0; border-top: 1px solid var(--line); }
203+
.guide-section .section-heading { max-width: 760px; margin-bottom: 40px; }
204+
.guide-section h2 { font-size: clamp(30px, 3.6vw, 44px); }
205+
.guide-section .section-body { max-width: 760px; color: #a9bab3; font-size: 17px; }
206+
207+
.callout { display: flex; gap: 18px; margin-top: 32px; padding: 28px 30px; border: 1px solid rgba(185,243,74,.25); border-radius: var(--radius); background: rgba(185,243,74,.055); }
208+
.callout .callout-mark { flex-shrink: 0; color: var(--acid); font-size: 20px; line-height: 1; }
209+
.callout h4 { margin-bottom: 8px; font-size: 15px; }
210+
.callout p { margin: 0; color: #a9bab3; font-size: 14px; }
211+
.callout p b { color: var(--ink); }
212+
213+
.os-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 36px; }
214+
.os-card { padding: 30px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255,255,255,.022); }
215+
.os-card > b { display: inline-block; margin-bottom: 20px; padding: 5px 14px; border-radius: 999px; background: var(--ink); color: var(--dark); font-size: 12px; font-weight: 800; }
216+
.os-card .os-step { padding: 16px 0; border-top: 1px solid var(--line); }
217+
.os-card .os-step:first-of-type { border-top: none; }
218+
.os-card .os-step h4 { margin-bottom: 6px; font-size: 14px; color: var(--mint); }
219+
.os-card .os-step p { margin: 0; color: #8fa198; font-size: 13.5px; }
220+
221+
.reco-card { display: flex; align-items: flex-start; gap: 26px; margin-top: 36px; padding: 34px; border-radius: var(--radius); background: var(--warm); color: #17201d; }
222+
.reco-card .reco-badge { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 108px; padding: 18px 10px; border-radius: 20px; background: #14251f; color: white; text-align: center; }
223+
.reco-card .reco-badge span { color: var(--acid); font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
224+
.reco-card .reco-badge strong { margin-top: 8px; font-size: 18px; }
225+
.reco-card p { margin: 0; color: #59655f; font-size: 14.5px; }
226+
227+
.guide-cta { margin: 40px 0 110px; padding: 70px 80px; display: flex; align-items: center; justify-content: space-between; gap: 40px; border-radius: 36px; background: var(--acid); color: var(--dark); }
228+
.guide-cta h2 { margin-bottom: 6px; font-size: clamp(26px, 3vw, 36px); }
229+
.guide-cta p { margin: 0; color: #3f5b1e; font-size: 14px; }
230+
231+
@media (max-width: 900px) {
232+
.path-nav { grid-template-columns: repeat(2, 1fr); }
233+
.os-grid { grid-template-columns: 1fr; }
234+
.reco-card { flex-direction: column; }
235+
.guide-cta { flex-direction: column; align-items: start; padding: 55px; }
236+
}
237+
238+
@media (max-width: 640px) {
239+
.guide-hero { padding: 125px 0 50px; }
240+
.path-nav { grid-template-columns: 1fr; padding-bottom: 45px; }
241+
.guide-section { padding: 50px 0; }
242+
.callout { flex-direction: column; padding: 24px; }
243+
.guide-cta { margin: 25px 0 70px; padding: 38px 26px; }
244+
}

0 commit comments

Comments
 (0)