Recently added, shop sorting, and outfit cards with T5 products
CI / build (push) Successful in 34s

- The inventory's Recently Added puts T5 products added to the library in the last 30 days first.
- The shop's T5 listing follows its sort menu: Best Match ranks by name, Newest by upload date
  (oldest first ascending); ratings and prices (T5 products have neither) sort by newest.
- Outfit cards: outfit_products.php gets the outfit's community products added (not for sale),
  find_bundle.php is asked about IMVU's products only, and removing one in the card (remove_pids)
  keeps it from IMVU and forgets it in the outfit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 02:45:02 -07:00
1 parent af9158513b
commit 171e8173af
11 files changed
+462 -6

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
fetcher.OnLibrary = prods.RefreshInventory fetcher.OnLibrary = prods.RefreshInventory
prods.Want = fetcher.Want prods.Want = fetcher.Want
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
prods.Shop, prods.Fetch = client.Products, fetcher.Ensure prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library
prods.AddToLibrary = func(ctx context.Context, id string) error { prods.AddToLibrary = func(ctx context.Context, id string) error {
return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil) return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil)
} }
+9 -1
View File
@@ -47,6 +47,7 @@ type Fetcher struct {
mu sync.Mutex mu sync.Mutex
library map[string]bool // the person's library, as last read library map[string]bool // the person's library, as last read
entries []hub.LibraryEntry
inflight map[string]bool inflight map[string]bool
failed map[string]time.Time // products that failed lately (not retried for a while) failed map[string]time.Time // products that failed lately (not retried for a while)
access string access string
@@ -77,7 +78,7 @@ func (f *Fetcher) SyncLibrary(ctx context.Context) {
ids[e.Product.ID] = true ids[e.Product.ID] = true
} }
f.mu.Lock() f.mu.Lock()
f.library = ids f.library, f.entries = ids, lib
f.mu.Unlock() f.mu.Unlock()
defer func() { defer func() {
if f.OnLibrary != nil { if f.OnLibrary != nil {
@@ -313,3 +314,10 @@ func (f *Fetcher) Owned(id string) bool {
defer f.mu.Unlock() defer f.mu.Unlock()
return f.library[id] return f.library[id]
} }
// Library is the person's library, as last read.
func (f *Fetcher) Library() []hub.LibraryEntry {
f.mu.Lock()
defer f.mu.Unlock()
return append([]hub.LibraryEntry(nil), f.entries...)
}
+3 -4
View File
@@ -463,11 +463,9 @@ type Files struct {
// LibraryEntry is a product in the person's library. // LibraryEntry is a product in the person's library.
type LibraryEntry struct { type LibraryEntry struct {
Product struct { Product Product `json:"product"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"product"`
StayOn *int `json:"stayOn"` StayOn *int `json:"stayOn"`
AddedAt time.Time `json:"addedAt"`
} }
// Library is the person's library. // Library is the person's library.
@@ -503,6 +501,7 @@ type Product struct {
Parent int64 `json:"parent"` Parent int64 `json:"parent"`
Image string `json:"image"` Image string `json:"image"`
Categories []Category `json:"categories"` // the parent's IMVU category path, top first Categories []Category `json:"categories"` // the parent's IMVU category path, top first
CreatedAt time.Time `json:"createdAt"`
} }
// Category is an IMVU shop category. // Category is an IMVU shop category.
+21
View File
@@ -54,6 +54,12 @@ func (p *Products) Intercept(x *proxy.Exchange) error {
} else if len(x.Body) > 0 { } else if len(x.Body) > 0 {
return p.interceptProductInfo(x) return p.interceptProductInfo(x)
} }
case "/api/outfit_products.php":
return p.interceptOutfitProducts(x)
case "/api/find_bundle.php":
return p.interceptFindBundle(x)
case "/api/recently_added_inventory.php":
return p.interceptRecent(x)
case "/api/shop/search_st.php": case "/api/shop/search_st.php":
return p.interceptShopSearch(x) return p.interceptShopSearch(x)
case "/api/service/shop/svc_catalog_purchase.php": case "/api/service/shop/svc_catalog_purchase.php":
@@ -625,8 +631,20 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error {
if json.Unmarshal(x.Body, &save) != nil { if json.Unmarshal(x.Body, &save) != nil {
return nil return nil
} }
removed := p.outfitRemovals(save) // the outfit card's remove_pids
resave := func() error {
body, _ := json.Marshal(save)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
x.Body = body
return nil
}
var products string var products string
if json.Unmarshal(save["products"], &products) != nil { if json.Unmarshal(save["products"], &products) != nil {
if removed {
return resave()
}
return nil // not a save (e.g. set_default_outfit_id) return nil // not a save (e.g. set_default_outfit_id)
} }
var kept []string var kept []string
@@ -644,6 +662,9 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error {
kept = append(kept, f) kept = append(kept, f)
} }
if stripped == 0 { if stripped == 0 {
if removed {
return resave()
}
return nil return nil
} }
save["products"], _ = json.Marshal(strings.Join(kept, ",")) save["products"], _ = json.Marshal(strings.Join(kept, ","))
+175
View File
@@ -0,0 +1,175 @@
package products
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// The client's outfit card (dialogs/outfit_card) lists an outfit's products from api/outfit_products.php, asks
// api/find_bundle.php whether the outfit's products are a bundle on sale, and saves changes (removing products with
// remove_pids) to api/outfits.php. IMVU only has the outfit without its community products (see interceptOutfits),
// so the proxy adds them to the list, and keeps them out of the rest.
// interceptOutfitProducts adds an outfit's community products to its product list.
func (p *Products) interceptOutfitProducts(x *proxy.Exchange) error {
outfit := x.URL.Query().Get("outfit_id")
products := p.state.outfit(outfit)
if outfit == "" || len(products) == 0 {
return nil
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var r map[string]json.RawMessage
if json.Unmarshal(body, &r) != nil {
return body, nil
}
var list map[string]json.RawMessage
if json.Unmarshal(r["result"], &list) != nil {
if string(r["result"]) != "[]" { // PHP's empty object
p.logf("products: outfit_products.php: unexpected result")
return body, nil
}
list = map[string]json.RawMessage{}
}
for _, id := range products {
if row, ok := p.outfitCardRow(id); ok {
list[strconv.FormatInt(row.local, 10)], _ = json.Marshal(row.entry())
}
}
r["result"], _ = json.Marshal(list)
return json.Marshal(r)
}
return nil
}
type cardRow struct {
hub.Product
local int64
}
// entry is the outfit card's product entry. Community products can't be in a bundle for sale on IMVU.
func (r cardRow) entry() map[string]any {
category, categoryID := "", int64(0)
if n := len(r.Categories); n > 0 {
category, categoryID = categoryText(r.Categories[n-1].Name), r.Categories[n-1].ID
}
ap := 0
if r.Rating == "adult" {
ap = 1
}
return map[string]any{"name": plain(r.Name), "image": r.Image, "category_id": categoryID, "category": category,
"is_ap": ap, "is_ap_plus": 0, "is_ufi": 0, "is_bundleable": 0}
}
// outfitCardRow is a community product as the outfit card shows it: from the library or the shop's listing if the
// hub listed it, else from its pack.
func (p *Products) outfitCardRow(id string) (cardRow, bool) {
s := p.byProductID(id)
if s == nil {
return cardRow{}, false
}
if p.Library != nil {
for _, e := range p.Library() {
if e.Product.ID == id {
return cardRow{e.Product, s.local}, true
}
}
}
m := s.pack.Manifest
p.mu.Lock()
hp, ok := p.shopSeen[id]
p.mu.Unlock()
if !ok {
hp = hub.Product{ID: id, Name: m.Name, Creator: m.Creator, Rating: m.Rating}
}
return cardRow{hp, s.local}, true
}
// interceptFindBundle asks IMVU about the outfit's IMVU products only (find_bundle.php?pids=1,2,...).
func (p *Products) interceptFindBundle(x *proxy.Exchange) error {
parts := strings.Split(x.URL.RawQuery, "&")
changed := false
for i, part := range parts {
name, value, ok := strings.Cut(part, "=")
if !ok || name != "pids" {
continue
}
var kept []string
for _, f := range strings.Split(value, ",") {
if id, ok := parseID(f); ok && IsCommunity(id) {
changed = true
continue
}
kept = append(kept, f)
}
parts[i] = name + "=" + strings.Join(kept, ",")
}
if !changed {
return nil
}
query := strings.Join(parts, "&")
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, x.Body, query) {
return errNoKey
}
x.URL.RawQuery = query
x.Request.URL.RawQuery = query
return nil
}
// outfitRemovals takes community products out of an outfit card save's remove_pids, and out of the outfit as
// remembered here. It reports whether the body changed.
func (p *Products) outfitRemovals(save map[string]json.RawMessage) bool {
var remove []json.RawMessage
if json.Unmarshal(save["remove_pids"], &remove) != nil {
return false
}
var kept []json.RawMessage
var gone []string
for _, raw := range remove {
if id, ok := rawID(raw); ok && IsCommunity(id) {
if pid, ok := p.state.productID(id); ok {
gone = append(gone, pid)
}
continue
}
kept = append(kept, raw)
}
if len(kept) == len(remove) {
return false
}
if kept == nil {
kept = []json.RawMessage{}
}
save["remove_pids"], _ = json.Marshal(kept)
var outfit json.Number
if json.Unmarshal(save["outfit_id"], &outfit) != nil {
var s string
json.Unmarshal(save["outfit_id"], &s)
outfit = json.Number(s)
}
if outfit != "" && len(gone) > 0 {
var left []string
for _, pid := range p.state.outfit(outfit.String()) {
if !contains(gone, pid) {
left = append(left, pid)
}
}
if err := p.state.setOutfit(outfit.String(), left); err != nil {
p.logf("products: remembering an outfit: %v", err)
}
}
return true
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
+42
View File
@@ -0,0 +1,42 @@
package products
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func TestOutfitCard(t *testing.T) {
h, _ := newHarness(t)
local := h.p.byProductID("blue-coat").local
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d"}`, local))
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false)
// The product list has the community product too, not for sale.
_, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfit_products.php?outfit_id=555", nil, true, true)
var r struct{ Result map[string]map[string]any }
if json.Unmarshal(body, &r) != nil || len(r.Result) != 2 {
t.Fatalf("products %s", body)
}
if e := r.Result[fmt.Sprint(local)]; e == nil || e["name"] != "Blue Coat" || e["is_bundleable"] != float64(0) {
t.Fatalf("community entry %v", e)
}
// The bundle check is asked about IMVU's products only (re-signed).
h.do("GET", fmt.Sprintf("https://client-dynamic.imvu.com/api/find_bundle.php?pids=80,%d.0,1", local), nil, true, true)
if got := h.imvu.asked["find bundle"]; len(got) != 1 || got[0] != "pids=80,1" {
t.Fatalf("find_bundle: IMVU got %q", got)
}
// Removing it in the card: IMVU hears of IMVU's removals only, and the outfit forgets it.
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php",
[]byte(fmt.Sprintf(`{"outfit_id": "555", "outfit_name": "Mine", "remove_pids": [1, %d]}`, local)), true, false)
if got := h.imvu.asked["save outfit"]; len(got) != 2 || !strings.Contains(got[1], `"remove_pids":[1]`) {
t.Fatalf("IMVU got %q", got)
}
if left := h.p.state.outfit("555"); len(left) != 0 {
t.Fatalf("outfit still has %v", left)
}
}
+2
View File
@@ -64,6 +64,8 @@ type Products struct {
Shop func(ctx context.Context, query string) ([]hub.Product, error) Shop func(ctx context.Context, query string) ([]hub.Product, error)
Fetch func(ctx context.Context, id string) error Fetch func(ctx context.Context, id string) error
AddToLibrary func(ctx context.Context, id string) error AddToLibrary func(ctx context.Context, id string) error
// Library, if set, is the person's hub library as last read (for the inventory's Recently Added).
Library func() []hub.LibraryEntry
roomMu sync.Mutex roomMu sync.Mutex
room room room room
+8
View File
@@ -73,6 +73,14 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
return reply(`{"status": "success"}`) return reply(`{"status": "success"}`)
} }
switch r.URL.Path { switch r.URL.Path {
case "/api/outfit_products.php":
return reply(`{"result": {"80": {"pid": 80, "name": "Body", "is_bundleable": true}}, "bundle_id": 0}`)
case "/api/find_bundle.php":
checkSigned(true)
f.record("find bundle", []byte(r.URL.RawQuery))
return reply(`{"result": 0}`)
case "/api/recently_added_inventory.php":
return reply(`[31162856, 108363]`)
case "/api/shop/product.php": case "/api/shop/product.php":
checkSigned(false) checkSigned(false)
f.record("product info", body) f.record("product info", body)
+78
View File
@@ -0,0 +1,78 @@
package products
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// The inventory's Recently Added (api/recently_added_inventory.php?type=clothing|furni|scenes) is a list of product
// IDs from IMVU; T5 products the person added to their library lately go first, newest first.
// recentFor is how long a T5 product counts as recently added.
const recentFor = 30 * 24 * time.Hour
// IMVU's categories for furniture and rooms (the inventory's FURNI_CATEGORIES and ROOM_CATEGORIES, and rooms' 107).
var (
furniCategories = map[int64]bool{1027: true, 110: true}
roomCategories = map[int64]bool{107: true, 109: true, 366: true, 626: true, 2386: true}
)
// inventoryType is which Recently Added list a product belongs in: clothing (anything worn), furni or scenes.
func inventoryType(categories []hub.Category) string {
for _, c := range categories {
if furniCategories[c.ID] {
return "furni"
}
}
for _, c := range categories {
if roomCategories[c.ID] {
return "scenes"
}
}
return "clothing"
}
func (p *Products) interceptRecent(x *proxy.Exchange) error {
if p.Library == nil {
return nil
}
kind := x.URL.Query().Get("type")
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var ids []json.RawMessage
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &ids) != nil {
return body, nil
}
ours := p.recentLocals(kind, time.Now())
if len(ours) == 0 {
return body, nil
}
out := make([]json.RawMessage, 0, len(ours)+len(ids))
for _, local := range ours {
out = append(out, json.RawMessage(strconv.FormatInt(local, 10)))
}
return json.Marshal(append(out, ids...))
}
return nil
}
// recentLocals is the local IDs of T5 products of that kind added to the library within recentFor, newest first.
func (p *Products) recentLocals(kind string, now time.Time) []int64 {
lib := p.Library()
sort.SliceStable(lib, func(i, j int) bool { return lib[i].AddedAt.After(lib[j].AddedAt) })
var out []int64
for _, e := range lib {
if now.Sub(e.AddedAt) > recentFor || inventoryType(e.Product.Categories) != kind {
continue
}
if s := p.byProductID(e.Product.ID); s != nil && p.inHubDir(e.Product.ID) {
out = append(out, s.local)
}
}
return out
}
+84
View File
@@ -0,0 +1,84 @@
package products
import (
"encoding/json"
"fmt"
"testing"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
)
func TestRecentlyAdded(t *testing.T) {
h, _ := newHarness(t)
hubDir := t.TempDir()
for _, id := range []string{"coatcoat22", "sofasofa22", "oldoldold2", "hairhair22"} {
writePack(t, hubDir, id, 1)
}
h.p.dirs = append(h.p.dirs, hubDir)
h.p.HubDir = hubDir
h.p.Owned = func(string) bool { return true }
h.p.refresh(true)
now := time.Now()
clothes := []hub.Category{{ID: 106}, {ID: 40}, {ID: 716}}
h.p.Library = func() []hub.LibraryEntry {
return []hub.LibraryEntry{
{Product: hub.Product{ID: "coatcoat22", Categories: clothes}, AddedAt: now.Add(-2 * time.Hour)},
{Product: hub.Product{ID: "sofasofa22", Categories: []hub.Category{{ID: 1027}, {ID: 1056}}}, AddedAt: now.Add(-time.Hour)},
{Product: hub.Product{ID: "oldoldold2", Categories: clothes}, AddedAt: now.Add(-40 * 24 * time.Hour)},
{Product: hub.Product{ID: "hairhair22"}, AddedAt: now.Add(-time.Hour)}, // no categories known: worn
}
}
local := func(id string) int64 { return h.p.byProductID(id).local }
get := func(kind string) []int64 {
_, b := h.do("GET", "http://client-dynamic.imvu.com/api/recently_added_inventory.php?type="+kind, nil, false, false)
var ids []int64
if json.Unmarshal(b, &ids) != nil {
t.Fatalf("%s: %s", kind, b)
}
return ids
}
if got, want := fmt.Sprint(get("clothing")), fmt.Sprint([]int64{local("hairhair22"), local("coatcoat22"), 31162856, 108363}); got != want {
t.Errorf("clothing %s, want %s", got, want)
}
if got, want := fmt.Sprint(get("furni")), fmt.Sprint([]int64{local("sofasofa22"), 31162856, 108363}); got != want {
t.Errorf("furni %s, want %s", got, want)
}
if got, want := fmt.Sprint(get("scenes")), fmt.Sprint([]int64{31162856, 108363}); got != want {
t.Errorf("scenes %s, want %s", got, want)
}
}
func TestShopSorting(t *testing.T) {
day := func(n int) time.Time { return time.Date(2026, 10, n, 0, 0, 0, 0, time.UTC) }
list := func() []hub.Product {
return []hub.Product{
{ID: "a", Name: "Long Red Coat", CreatedAt: day(5)},
{ID: "b", Name: "Coat", CreatedAt: day(1)},
{ID: "c", Name: "Coats & Jackets Pack", CreatedAt: day(2)},
{ID: "d", Name: "Raincoat", CreatedAt: day(4)},
{ID: "e", Name: "Hat", Creator: "coat_maker", CreatedAt: day(3)},
}
}
order := func(l []hub.Product) string {
s := ""
for _, p := range l {
s += p.ID
}
return s
}
for _, c := range []struct{ query, field, dir, want string }{
{"coat", "conversion_score_b", "desc", "bcade"}, // same name, starts with, word, inside a word, creator
{"", "conversion_score_b", "desc", "adecb"}, // no search: newest
{"coat", "id", "desc", "adecb"}, // Newest Products
{"coat", "id", "asc", "bceda"},
{"coat", "price", "asc", "adecb"}, // all free: newest
{"coat", "user_rating", "desc", "adecb"},
} {
l := list()
sortShop(l, c.query, c.field, c.dir)
if got := order(l); got != c.want {
t.Errorf("%q %s %s: %s, want %s", c.query, c.field, c.dir, got, c.want)
}
}
}
+39
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -37,6 +38,8 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
Page json.Number `json:"page"` Page json.Number `json:"page"`
Skip json.Number `json:"skip"` Skip json.Number `json:"skip"`
PerPage json.Number `json:"results_per_page"` PerPage json.Number `json:"results_per_page"`
Sort string `json:"sort_field"`
Dir string `json:"sort_direction"`
Limit json.Number `json:"solr_limit"` Limit json.Number `json:"solr_limit"`
} }
if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating { if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating {
@@ -67,6 +70,7 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
} }
p.mu.Unlock() p.mu.Unlock()
list = inCategories(list, categoryIDs(req.Category)) list = inCategories(list, categoryIDs(req.Category))
sortShop(list, query, req.Sort, req.Dir)
p.fetchForShop(ctx, list) p.fetchForShop(ctx, list)
var rows []shopRow var rows []shopRow
@@ -244,6 +248,41 @@ func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte {
return b return b
} }
// sortShop orders products for the shop's sort menu. T5 products have no ratings or prices, so those sorts are by
// newest; Best Match (conversion_score_b, score) with a search puts the closest names first (the same name, then
// names starting with it, then words starting with it, then the rest), newest first among equals; Newest
// (id) is newest first, or oldest first ascending.
func sortShop(list []hub.Product, query, field, direction string) {
q := strings.ToLower(strings.TrimSpace(query))
rank := func(hp hub.Product) int {
if q == "" || (field != "conversion_score_b" && field != "score" && field != "") {
return 0
}
name := strings.ToLower(hp.Name)
switch {
case name == q:
return 0
case strings.HasPrefix(name, q):
return 1
case strings.Contains(" "+name, " "+q):
return 2
case strings.Contains(name, q):
return 3
}
return 4 // matched by creator
}
oldest := field == "id" && direction == "asc"
sort.SliceStable(list, func(i, j int) bool {
if ri, rj := rank(list[i]), rank(list[j]); ri != rj {
return ri < rj
}
if oldest {
return list[i].CreatedAt.Before(list[j].CreatedAt)
}
return list[i].CreatedAt.After(list[j].CreatedAt)
})
}
// categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all). // categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all).
func categoryIDs(raw json.RawMessage) map[int64]bool { func categoryIDs(raw json.RawMessage) map[int64]bool {
var s string var s string