Get T5 products from the hub's catalogs
CI / build (push) Successful in 25s

The proxy keeps the person's library in a cache folder (hub-products in its data folder), one pack per
product, served like any other pack: the hub says which revision to load and which catalogs host it, each file
comes from a catalog and is checked against the hub's sha256 (files are kept by hash, so a new revision only
downloads what changed), and adult files are fetched with the hub's access token. Products someone wears in a
room that aren't here yet are fetched too, and put on them when they arrive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:56:26 -07:00
1 parent 93fa2f5d90
commit c281fb572c
6 files changed
+579 -3

No files matched your search

+95
View File
@@ -173,6 +173,8 @@ type Client struct {
// OnChat, if set, hears the other T5DE users in a chat the client is in, whenever that changes (and with none
// when the connection to the hub is lost).
OnChat func(chat string, avatars []Avatar)
// OnLibrary, if set, is called when connected and whenever the person's library changes.
OnLibrary func()
mu sync.Mutex
saved *Saved
@@ -283,6 +285,9 @@ func (c *Client) connect(ctx context.Context) error {
}
defer conn.CloseNow()
c.log.Printf("hub: connected to %s", c.saved.URL)
if c.OnLibrary != nil {
go c.OnLibrary()
}
errs := make(chan error, 1)
go func() {
@@ -306,6 +311,10 @@ func (c *Client) connect(ctx context.Context) error {
switch m.Type {
case "error":
c.log.Printf("hub: %s", m.Message)
case "library":
if c.OnLibrary != nil {
go c.OnLibrary()
}
case "chat":
c.mu.Lock()
_, in := c.chats[m.Chat]
@@ -396,3 +405,89 @@ func (c *Client) connect(ctx context.Context) error {
}
}
}
// Call makes a request to the hub's API as the signed-in person: path under the hub's URL, body (if any) sent as
// JSON, the answer decoded into out.
func (c *Client) Call(ctx context.Context, method, path string, body, out any) error {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = strings.NewReader(string(b))
}
req, err := http.NewRequestWithContext(ctx, method, c.saved.URL+path, r)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.saved.Token)
req.Header.Set("User-Agent", UserAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode/100 != 2 {
var e struct{ Error, Message string }
json.Unmarshal(data, &e)
return fmt.Errorf("the hub answered %d %s", resp.StatusCode, e.Error)
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
// FileEntry is one of a revision's files.
type FileEntry struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// Files is the revision of a product the person uses, and where to get it (GET /api/v1/products/:id/files).
type Files struct {
Product string `json:"product"`
Revision int `json:"revision"`
Rating string `json:"rating"`
Parent int64 `json:"parent"`
Name string `json:"name"`
Creator string `json:"creator"`
Files []FileEntry `json:"files"`
Catalogs []string `json:"catalogs"`
Token string `json:"token"`
}
// LibraryEntry is a product in the person's library.
type LibraryEntry struct {
Product struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"product"`
StayOn *int `json:"stayOn"`
}
// Library is the person's library.
func (c *Client) Library(ctx context.Context) ([]LibraryEntry, error) {
var out struct{ Library []LibraryEntry }
err := c.Call(ctx, "GET", "/api/v1/library", nil, &out)
return out.Library, err
}
// ProductFiles is the revision of a product to load.
func (c *Client) ProductFiles(ctx context.Context, id string) (*Files, error) {
var out Files
if err := c.Call(ctx, "GET", "/api/v1/products/"+id+"/files", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// AccessToken is a token for catalogs' adult files (an hour long).
func (c *Client) AccessToken(ctx context.Context) (string, error) {
var out struct{ Token string }
err := c.Call(ctx, "POST", "/api/v1/access", nil, &out)
return out.Token, err
}