Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/api-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func main() {
Jellyfin: jellyfin.AsAuthAdapter(jfClient),
SignKey: cfg.JWTSigningKey,
})
libSvc := media.NewService(jfClient, cfg.JellyfinURL, cfg.ProxyBaseURL)
libSvc := media.NewService(jfClient, cfg.JellyfinURL, cfg.ProxyBaseURL, logger)

srv := apihttp.NewServer(authSvc, libSvc, cfg.JellyfinURL, logger)
httpSrv := &stdhttp.Server{
Expand Down
8 changes: 6 additions & 2 deletions internal/clients/jellyfin/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
)

type AuthResult struct {
Expand All @@ -26,8 +27,11 @@ func IsUpstreamUnavailable(err error) bool { return errors.Is(err, ErrUpstreamUn

func (c *Client) AuthenticateByName(ctx context.Context, username, password string) (*AuthResult, error) {
body, _ := json.Marshal(map[string]string{"Username": username, "Pw": password})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
raw, err := url.JoinPath(c.baseURL, "Users", "AuthenticateByName")
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, raw, bytes.NewReader(body))
if err != nil {
return nil, err
}
Expand Down
33 changes: 20 additions & 13 deletions internal/clients/jellyfin/items.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)

type ItemType string
Expand Down Expand Up @@ -65,8 +66,11 @@ type GetItemsOpts struct {
}

func (c *Client) GetItem(ctx context.Context, userID, itemID string) (*Item, error) {
url := fmt.Sprintf("%s/Users/%s/Items/%s", c.baseURL, userID, itemID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
raw, err := url.JoinPath(c.baseURL, "Users", userID, "Items", itemID)
if err != nil {
return nil, fmt.Errorf("jellyfin GetItem: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
Expand All @@ -86,16 +90,19 @@ func (c *Client) GetItem(ctx context.Context, userID, itemID string) (*Item, err
return nil, fmt.Errorf("jellyfin GetItem: unexpected status %d", resp.StatusCode)
}

var raw jfItemResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
var decoded jfItemResponse
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, fmt.Errorf("jellyfin GetItem: decode: %w", err)
}
return raw.toItem(), nil
return decoded.toItem(), nil
}

func (c *Client) GetItems(ctx context.Context, userID string, opts GetItemsOpts) (*ItemsResult, error) {
url := fmt.Sprintf("%s/Users/%s/Items", c.baseURL, userID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
raw, err := url.JoinPath(c.baseURL, "Users", userID, "Items")
if err != nil {
return nil, fmt.Errorf("jellyfin GetItems: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -140,23 +147,23 @@ func (c *Client) GetItems(ctx context.Context, userID string, opts GetItemsOpts)
return nil, fmt.Errorf("jellyfin GetItems: unexpected status %d", resp.StatusCode)
}

var raw struct {
var decoded struct {
Items []jfItemResponse `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, fmt.Errorf("jellyfin GetItems: decode: %w", err)
}

items := make([]Item, len(raw.Items))
for i, r := range raw.Items {
items := make([]Item, len(decoded.Items))
for i, r := range decoded.Items {
items[i] = *r.toItem()
}
return &ItemsResult{
Items: items,
TotalCount: raw.TotalRecordCount,
StartIndex: raw.StartIndex,
TotalCount: decoded.TotalRecordCount,
StartIndex: decoded.StartIndex,
}, nil
}

Expand Down
21 changes: 17 additions & 4 deletions internal/clients/jellyfin/quickconnect.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ type QuickConnectInitiation struct {
}

func (c *Client) QuickConnectInitiate(ctx context.Context) (*QuickConnectInitiation, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/QuickConnect/Initiate", nil)
raw, err := url.JoinPath(c.baseURL, "QuickConnect", "Initiate")
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, raw, nil)
if err != nil {
return nil, err
}
Expand All @@ -45,8 +48,18 @@ func (c *Client) QuickConnectInitiate(ctx context.Context) (*QuickConnectInitiat
}

func (c *Client) QuickConnectAuthenticate(ctx context.Context, secret string) (*AuthResult, error) {
u := c.baseURL + "/QuickConnect/Authenticate?secret=" + url.QueryEscape(secret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
raw, err := url.JoinPath(c.baseURL, "QuickConnect", "Authenticate")
if err != nil {
return nil, err
}
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("secret", secret)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
if err != nil {
return nil, err
}
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
)

Expand Down Expand Up @@ -52,6 +53,13 @@ func Load(override envMap) (*Config, error) {
return nil, errors.New("JWT_SIGNING_KEY must be at least 32 bytes")
}

if err := validateURL("JELLYFIN_URL", get("JELLYFIN_URL")); err != nil {
return nil, err
}
if err := validateURL("PROXY_BASE_URL", get("PROXY_BASE_URL")); err != nil {
return nil, err
}

return &Config{
JellyfinURL: get("JELLYFIN_URL"),
JellyfinAPIKey: get("JELLYFIN_API_KEY"),
Expand All @@ -62,5 +70,13 @@ func Load(override envMap) (*Config, error) {
}, nil
}

func validateURL(name, raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%s must be an absolute URL, got %q", name, raw)
}
return nil
}

// LoadFromEnv reads the real process environment.
func LoadFromEnv() (*Config, error) { return Load(nil) }
36 changes: 36 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,39 @@ func TestLoad_JWTKeyTooShort(t *testing.T) {
t.Fatal("expected error for short JWT_SIGNING_KEY")
}
}

func TestLoad_InvalidURLs(t *testing.T) {
base := envMap{
"JELLYFIN_API_KEY": "abc",
"JWT_SIGNING_KEY": "0123456789abcdef0123456789abcdef",
"DB_PATH": "/tmp/api-proxy.sqlite",
"LISTEN_ADDR": ":8080",
}

cases := []struct {
name string
key string
val string
}{
{"jellyfin relative path", "JELLYFIN_URL", "/not/absolute"},
{"jellyfin no scheme", "JELLYFIN_URL", "jellyfin:8096"},
{"proxy relative path", "PROXY_BASE_URL", "/not/absolute"},
{"proxy no scheme", "PROXY_BASE_URL", "api.stoganet.com"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env := envMap{}
for k, v := range base {
env[k] = v
}
env["JELLYFIN_URL"] = "http://jellyfin:8096"
env["PROXY_BASE_URL"] = "https://api.stoganet.com"
env[tc.key] = tc.val

if _, err := Load(env); err == nil {
t.Fatalf("expected error for %s=%q", tc.key, tc.val)
}
})
}
}
25 changes: 17 additions & 8 deletions internal/media/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ package media

import (
"fmt"
"net/url"

"github.com/Stoganet/api-proxy/internal/clients/jellyfin"
)

func joinURL(base string, parts ...string) string {
u, err := url.JoinPath(base, parts...)
if err != nil {
return ""
}
return u
}

const (
ticksPerMinute = 600_000_000
ticksPerMS = 10_000
Expand All @@ -17,7 +26,7 @@ func toItem(jf jellyfin.Item, baseURL string) Item {
Title: jf.Name,
Year: jf.Year,
Type: itemType(jf.Type),
Poster: fmt.Sprintf("%s/Items/%s/Images/Primary", baseURL, jf.ID),
Poster: joinURL(baseURL, "Items", jf.ID, "Images", "Primary"),
Backdrop: backdrop(jf, baseURL),
Overview: jf.Overview,
State: StatePlayable,
Expand All @@ -39,7 +48,7 @@ func toDetail(jf jellyfin.Item, jellyfinBaseURL, proxyBaseURL string) Detail {
Runtime: runtime,
Cast: cast,
Seasons: []Season{},
Play: &PlayInfo{StreamURL: proxyBaseURL + "/stream/" + jf.ID},
Play: &PlayInfo{StreamURL: joinURL(proxyBaseURL, "stream", jf.ID)},
Progress: toWatchProgress(jf.UserData),
}
}
Expand Down Expand Up @@ -78,7 +87,7 @@ func toSeriesDetail(jf jellyfin.Item, jfSeasons []jellyfin.Season, nextUp *jelly
func toSeason(jf jellyfin.Season, jellyfinBaseURL string) Season {
poster := ""
if jf.PrimaryImageTag != "" {
poster = fmt.Sprintf("%s/Items/%s/Images/Primary", jellyfinBaseURL, jf.ID)
poster = joinURL(jellyfinBaseURL, "Items", jf.ID, "Images", "Primary")
}
return Season{
Number: jf.Number,
Expand All @@ -97,7 +106,7 @@ func toEpisode(jf jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) Episod
}
thumbnail := ""
if jf.PrimaryImageTag != "" {
thumbnail = fmt.Sprintf("%s/Items/%s/Images/Primary", jellyfinBaseURL, jf.ID)
thumbnail = joinURL(jellyfinBaseURL, "Items", jf.ID, "Images", "Primary")
}
return Episode{
ID: "jf:" + jf.ID,
Expand All @@ -108,7 +117,7 @@ func toEpisode(jf jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) Episod
Runtime: runtime,
Thumbnail: thumbnail,
State: StatePlayable,
Play: &PlayInfo{StreamURL: proxyBaseURL + "/stream/" + jf.ID},
Play: &PlayInfo{StreamURL: joinURL(proxyBaseURL, "stream", jf.ID)},
Progress: toWatchProgress(jf.UserData),
}
}
Expand All @@ -126,7 +135,7 @@ func toWatchProgress(ud jellyfin.UserData) *WatchProgress {
func toResumeInfo(jf jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) ResumeInfo {
thumbnail := ""
if jf.PrimaryImageTag != "" {
thumbnail = fmt.Sprintf("%s/Items/%s/Images/Primary", jellyfinBaseURL, jf.ID)
thumbnail = joinURL(jellyfinBaseURL, "Items", jf.ID, "Images", "Primary")
}
progress := toWatchProgress(jf.UserData)
var wp WatchProgress
Expand All @@ -139,7 +148,7 @@ func toResumeInfo(jf jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) Res
EpisodeID: "jf:" + jf.ID,
Title: jf.Name,
Thumbnail: thumbnail,
Play: PlayInfo{StreamURL: proxyBaseURL + "/stream/" + jf.ID},
Play: PlayInfo{StreamURL: joinURL(proxyBaseURL, "stream", jf.ID)},
Progress: wp,
}
}
Expand All @@ -162,5 +171,5 @@ func backdrop(jf jellyfin.Item, baseURL string) string {
if len(jf.BackdropTags) == 0 {
return ""
}
return fmt.Sprintf("%s/Items/%s/Images/Backdrop/0", baseURL, jf.ID)
return joinURL(baseURL, "Items", jf.ID, "Images", "Backdrop", "0")
}
14 changes: 9 additions & 5 deletions internal/media/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"

Expand All @@ -25,10 +26,11 @@ type Service struct {
jf JellyfinClient
baseURL string
proxyBaseURL string
logger *slog.Logger
}

func NewService(jf JellyfinClient, jellyfinBaseURL, proxyBaseURL string) *Service {
return &Service{jf: jf, baseURL: jellyfinBaseURL, proxyBaseURL: proxyBaseURL}
func NewService(jf JellyfinClient, jellyfinBaseURL, proxyBaseURL string, logger *slog.Logger) *Service {
return &Service{jf: jf, baseURL: jellyfinBaseURL, proxyBaseURL: proxyBaseURL, logger: logger}
}

func (s *Service) GetItem(ctx context.Context, jfUserID, catalogID string) (*Detail, error) {
Expand Down Expand Up @@ -185,10 +187,12 @@ func (s *Service) Home(ctx context.Context, jfUserID string) (*HomeResult, error
wg.Wait()

sections := make([]HomeSection, 0, len(homeSections))
for _, r := range results {
if r.err == nil {
sections = append(sections, r.section)
for i, r := range results {
if r.err != nil {
s.logger.Warn("home: section failed", "section", homeSections[i].id, "err", r.err)
continue
}
sections = append(sections, r.section)
}
if len(sections) == 0 && len(homeSections) > 0 {
return nil, fmt.Errorf("home: all sections failed")
Expand Down
Loading