diff --git a/internal/backend/characters.go b/internal/backend/characters.go index c23f26a..0a8cd73 100644 --- a/internal/backend/characters.go +++ b/internal/backend/characters.go @@ -37,6 +37,33 @@ func (c *CharacterService) List() ([]string, error) { return characters, nil } +// CharacterDetails is a character image with the time its file was last changed, so a +// client that caches images knows when to download one again. +type CharacterDetails struct { + Name string `json:"name" doc:"File name of the character image"` + Modified int64 `json:"modified" doc:"When the image was last changed, in Unix seconds"` +} + +// ListDetails returns all character images with the time each was last changed. +func (c *CharacterService) ListDetails() ([]CharacterDetails, error) { + entries, err := fs.ReadDir(c.root.FS(), ".") + if err != nil { + return nil, err + } + characters := []CharacterDetails{} + for _, entry := range entries { + if entry.IsDir() || !isImage(entry.Name()) { + continue + } + info, err := entry.Info() + if err != nil { + return nil, err + } + characters = append(characters, CharacterDetails{Name: entry.Name(), Modified: info.ModTime().Unix()}) + } + return characters, nil +} + // Open opens the character image with the given file name. func (c *CharacterService) Open(name string) (*os.File, error) { if name == "" || name != filepath.Base(name) || !isImage(name) { diff --git a/internal/backend/characters_test.go b/internal/backend/characters_test.go index e25a076..ec73209 100644 --- a/internal/backend/characters_test.go +++ b/internal/backend/characters_test.go @@ -4,6 +4,7 @@ import ( "io" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,6 +32,32 @@ func TestCharacterList(t *testing.T) { assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters) } +func TestCharacterListDetails(t *testing.T) { + c := newCharacterService(t) + changed := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + require.NoError(t, c.root.Chtimes("Adol.png", changed, changed)) + + characters, err := c.ListDetails() + require.NoError(t, err) + require.Len(t, characters, 2) + assert.Equal(t, CharacterDetails{Name: "Adol.png", Modified: changed.Unix()}, characters[0]) + assert.Equal(t, "Aloy.JPG", characters[1].Name) +} + +func TestCharacterListDetailsSeesChangedImage(t *testing.T) { + c := newCharacterService(t) + before, err := c.ListDetails() + require.NoError(t, err) + + later := time.Unix(before[0].Modified, 0).Add(time.Hour) + require.NoError(t, c.root.Chtimes("Adol.png", later, later)) + + after, err := c.ListDetails() + require.NoError(t, err) + assert.Greater(t, after[0].Modified, before[0].Modified) + assert.Equal(t, before[1], after[1], "unchanged images keep their time") +} + func TestCharacterOpen(t *testing.T) { c := newCharacterService(t) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index e613851..e5f8488 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -167,6 +167,16 @@ func TestCharacterEndpoints(t *testing.T) { require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) + var details []struct { + Name string `json:"name"` + Modified int64 `json:"modified"` + } + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details)) + require.Len(t, details, 2) + assert.Equal(t, "Adol.png", details[0].Name) + assert.Equal(t, "Aloy.jpg", details[1].Name) + assert.Positive(t, details[0].Modified) + rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) diff --git a/internal/server/misc_handler.go b/internal/server/misc_handler.go index ba92152..59f9a42 100644 --- a/internal/server/misc_handler.go +++ b/internal/server/misc_handler.go @@ -57,6 +57,12 @@ func (s *Server) registerMisc(a huma.API) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { return ok(s.characters.List()) }) + register(s, a, route{op: get("list-character-details", "/character/details", "characters", + "List characters with change times", + "Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.")}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.CharacterDetails], error) { + return ok(s.characters.ListDetails()) + }) image := get("get-character", "/character", "characters", "Character image", "Returns a character image.", http.StatusBadRequest, http.StatusNotFound) image.Responses = fileResponses("The image", "image/png", "image/jpeg") diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index 495e543..d37b7ba 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -76,6 +76,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { assert.Equal(t, []string{ "DELETE /api/v1/token", "GET /api/v1/character", + "GET /api/v1/character/details", "GET /api/v1/character/list", "GET /api/v1/download", "GET /api/v1/download/linux", @@ -207,6 +208,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) { // Characters, downloads, server info. v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/character/details", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest) v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)