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
4 changes: 4 additions & 0 deletions Changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.1.0 TBD

* :sparkles: `photo.Descriptor` now carries `ImageURL` and `DownloadLocation`, and the Unsplash source fills them in from the photo it already fetches. `ImageURL` is the hotlink to display the photo from, taken from the API's `urls` (preferring `raw`, which carries no size preset, and falling back to `full` then `regular`). `DownloadLocation` is the endpoint to call when the photo is actually used. Unsplash's API guidelines require consumers to display photos from these URLs rather than from a self-hosted copy, and to trigger a download only on use; keeping the two apart lets a consumer do each at the right moment. Both fields are omitted from the serialized form when empty, so existing photo metadata still loads unchanged.

## 1.0.0 2026-08-05

* :sparkles: First stable release. The reference, text, photo, and OpenScripture.Today APIs are settled enough to commit to.
Expand Down
2 changes: 1 addition & 1 deletion cmd/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
1.1.0
12 changes: 12 additions & 0 deletions pkg/photo/photo.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ type Descriptor struct {
Color string `yaml:"color,omitempty" json:"color,omitempty"`
Creator Creator `yaml:"creator" json:"creator"`

// ImageURL is the source's own hotlink for the image, suitable for use as an
// img src or a CSS background. Unsplash requires that consumers display
// photos from these URLs rather than from a copy they host themselves, so
// that views are attributed to the photographer.
ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"`

// DownloadLocation is the endpoint to call when the image is actually used,
// which is how Unsplash counts a download. It is deliberately separate from
// ImageURL: displaying a photo is not a download, and the two are meant to
// be triggered at different moments.
DownloadLocation string `yaml:"download_location,omitempty" json:"download_location,omitempty"`

images map[string]ImageComplete
}

Expand Down
21 changes: 21 additions & 0 deletions pkg/photo/unsplash/photo.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ func urlValueString(u *unsplash.URL) string {
return u.String()
}

// hotlinkURL picks the URL to display the photo from. Unsplash asks that photos
// be shown from the URLs it returns under "urls" rather than from a copy the
// consumer hosts. Raw is preferred because it carries no size preset, leaving
// the caller free to append its own Imgix parameters; full and regular stand in
// when a response omits it.
func hotlinkURL(image *unsplash.Photo) string {
if image.Urls == nil {
return ""
}

for _, u := range []*unsplash.URL{image.Urls.Raw, image.Urls.Full, image.Urls.Regular} {
if s := urlValueString(u); s != "" {
return s
}
}

return ""
}

// IDFromURL extracts the photo ID from a URL.
func IDFromURL(s string) (string, error) {
u, err := url.Parse(s)
Expand Down Expand Up @@ -62,6 +81,8 @@ func (u *Source) Photo(
Name: stringValue(image.Photographer.Name),
Link: urlValueString(image.Photographer.Links.HTML),
},
ImageURL: hotlinkURL(image),
DownloadLocation: urlValueString(image.Links.DownloadLocation),
}

filename, err := IDFromURL(urlValueString(image.Links.Download))
Expand Down
100 changes: 98 additions & 2 deletions pkg/photo/unsplash/photo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ func testServer() *httptest.Server {
j := map[string]any{
"id": "abc123-_XYZ",
"links": map[string]any{
"html": baseUrl + "/photos/a-test-photo-with-title-that-does-not-matter-abc123-_XYZ",
"download": baseUrl + "/photos/abc123-_XYZ/download",
"html": baseUrl + "/photos/a-test-photo-with-title-that-does-not-matter-abc123-_XYZ",
"download": baseUrl + "/photos/abc123-_XYZ/download",
"download_location": baseUrl + "/photos/abc123-_XYZ/download",
},
"urls": map[string]any{
"raw": baseUrl + "/img/photo-abc123?ixid=raw",
"full": baseUrl + "/img/photo-abc123?ixid=full",
"regular": baseUrl + "/img/photo-abc123?ixid=regular",
},
"user": map[string]any{
"name": "Test User",
Expand Down Expand Up @@ -103,6 +109,8 @@ func TestSource(t *testing.T) { //nolint:paralleltest // unsplash client has glo
Name: "Test User",
Link: u.String() + "/testuser",
},
ImageURL: u.String() + "/img/photo-abc123?ixid=raw",
DownloadLocation: u.String() + "/photos/abc123-_XYZ/download",
}, d)

item := d.GetImage(photo.Original)
Expand All @@ -115,3 +123,91 @@ func TestSource(t *testing.T) { //nolint:paralleltest // unsplash client has glo

assert.Equal(t, "YZ/download", item.Filename())
}

// hotlinkVariantServer serves a photo whose "urls" block contains only the
// variants given, so the preference order can be exercised. The variant values
// are opaque to the code under test, so they need not point anywhere real.
func hotlinkVariantServer(urls map[string]any) *httptest.Server {
baseUrl := ""
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// Source.Photo also resolves the download link, so that endpoint
// has to answer even though this test only inspects the hotlink.
if r.URL.Path == "/photos/abc123-_XYZ/download" {
if err := json.NewEncoder(w).Encode(map[string]any{
"url": baseUrl + "/photos/abc123-_XYZ/download/actual-file",
}); err != nil {
w.WriteHeader(500)
}
return
}

if r.URL.Path != "/photos/abc123-_XYZ" {
w.WriteHeader(404)
return
}

j := map[string]any{
"id": "abc123-_XYZ",
"links": map[string]any{
"html": baseUrl + "/photos/a-test-photo-abc123-_XYZ",
"download": baseUrl + "/photos/abc123-_XYZ/download",
},
"user": map[string]any{
"name": "Test User",
"links": map[string]any{"html": baseUrl + "/testuser"},
},
}
if urls != nil {
j["urls"] = urls
}

if err := json.NewEncoder(w).Encode(j); err != nil {
w.WriteHeader(500)
}
},
))
baseUrl = ts.URL
return ts
}

// Unsplash asks that photos be displayed from the URLs under "urls". Raw is
// preferred because it carries no size preset, but a response omitting it must
// still yield a usable hotlink rather than none.
func TestSourcePhotoHotlinkURL(t *testing.T) { //nolint:paralleltest // unsplash client has globals that have to be set
const (
raw = "https://images.example/photo-abc123?ixid=raw"
full = "https://images.example/photo-abc123?ixid=full"
regular = "https://images.example/photo-abc123?ixid=regular"
)

tests := []struct {
name string
urls map[string]any
want string
}{
{"prefers raw", map[string]any{"raw": raw, "full": full, "regular": regular}, raw},
{"falls back to full", map[string]any{"full": full, "regular": regular}, full},
{"falls back to regular", map[string]any{"regular": regular}, regular},
{"no urls block at all", nil, ""},
{"empty urls block", map[string]any{}, ""},
}

//nolint:paralleltest // each case sets the client's global base URL
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ts := hotlinkVariantServer(tc.urls)
defer ts.Close()

u, err := url.Parse(ts.URL)
require.NoError(t, err)
unsp.SetupBaseUrl(u.String() + "/")

src := &unsplash.Source{Client: unsp.New(ts.Client())}
d, err := src.Photo(context.Background(), "https://unsplash.com/photos/a-test-photo-abc123-_XYZ")
require.NoError(t, err)

assert.Equal(t, tc.want, d.ImageURL)
})
}
}