From 74f8fa3d9fd80da7378d058731496fa1c1b2be88 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:22:27 +0200 Subject: [PATCH 1/6] Pre-size posters --- app/components/cards/poster.go | 26 ++++++++++++++++++++++---- app/pages/cast.go | 5 ++--- app/pages/genre.go | 5 ++--- app/pages/home.go | 4 +--- app/pages/library.go | 5 ++--- app/pages/movie.go | 4 +--- app/pages/search.go | 7 +++---- app/pages/show.go | 6 ++---- app/pages/watchlist.go | 5 +++-- 9 files changed, 38 insertions(+), 29 deletions(-) diff --git a/app/components/cards/poster.go b/app/components/cards/poster.go index 010ded5..2de3cbe 100644 --- a/app/components/cards/poster.go +++ b/app/components/cards/poster.go @@ -7,34 +7,52 @@ import ( "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/preference" + "github.com/0skillallluck/scanline/app/sources" "github.com/0skillallluck/scanline/utils/imageutils" ) +// Poster card image dimensions. Pre-sized cover URLs (via PosterCoverURL) +// match these so the server returns ready-to-render JPEGs and no local +// scaling is needed. +const ( + PosterWidth = 180 + PosterHeight = 270 +) + +// PosterCoverURL returns a thumb→URL builder that asks src for cover art +// at the poster's exact display dimensions. Pages building horizontal +// poster lists pass the returned closure into lists.RenderHub etc. +func PosterCoverURL(src sources.Source) func(thumb string) string { + return func(thumb string) string { + return src.PhotoTranscodeURL(thumb, PosterWidth, PosterHeight) + } +} + func poster[T any](title string, subTitle schwifty.Widgetable[T], coverURL string) schwifty.Button { return posterWithProgress(title, subTitle, coverURL, 0) } func posterWithProgress[T any](title string, subTitle schwifty.Widgetable[T], coverURL string, progress float64) schwifty.Button { picture := Picture(). - SizeRequest(180, 270). + SizeRequest(PosterWidth, PosterHeight). FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if preference.Performance().AllowPreviewImages() { - imageutils.LoadIntoPictureScaled(coverURL, 180, 270, gtk.PictureNewFromInternalPtr(w.Ptr)) + imageutils.LoadIntoPictureScaled(coverURL, PosterWidth, PosterHeight, gtk.PictureNewFromInternalPtr(w.Ptr)) } }) var image any if progress > 0 { progressBar := Box(gtk.OrientationHorizontalValue). - SizeRequest(int32(180*progress), 4). + SizeRequest(int32(PosterWidth*progress), 4). VAlign(gtk.AlignEndValue). HAlign(gtk.AlignStartValue). CSS("box { background-color: @accent_bg_color; }") image = Bin(). Child(Overlay(picture).AddOverlay(progressBar)). - SizeRequest(180, 270). + SizeRequest(PosterWidth, PosterHeight). CornerRadius(10). Overflow(gtk.OverflowHiddenValue) } else { diff --git a/app/pages/cast.go b/app/pages/cast.go index 494d75c..261eb06 100644 --- a/app/pages/cast.go +++ b/app/pages/cast.go @@ -10,6 +10,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/appctx" + "github.com/0skillallluck/scanline/app/components/cards" "github.com/0skillallluck/scanline/app/components/lists" "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/app/router" @@ -32,9 +33,7 @@ func Cast(ctx context.Context, appCtx *appctx.AppContext, serverID, tagID string return router.FromError(gettext.Get("Cast"), err) } - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) var allContent []sources.Metadata var personName, personThumb string diff --git a/app/pages/genre.go b/app/pages/genre.go index 76a397e..4073cb7 100644 --- a/app/pages/genre.go +++ b/app/pages/genre.go @@ -10,6 +10,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/appctx" + "github.com/0skillallluck/scanline/app/components/cards" "github.com/0skillallluck/scanline/app/components/lists" "github.com/0skillallluck/scanline/app/router" "github.com/0skillallluck/scanline/app/sources" @@ -30,9 +31,7 @@ func Genre(ctx context.Context, appCtx *appctx.AppContext, serverID, genreID str return router.FromError(gettext.Get("Genre"), err) } - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) var allContent []sources.Metadata seen := make(map[string]bool) diff --git a/app/pages/home.go b/app/pages/home.go index 1925536..cbf0277 100644 --- a/app/pages/home.go +++ b/app/pages/home.go @@ -29,9 +29,7 @@ func home(ctx context.Context, appCtx *appctx.AppContext) *router.Response { } serverID := src.ID() - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) for i := range hubList { hub := &hubList[i] diff --git a/app/pages/library.go b/app/pages/library.go index e101d74..7792810 100644 --- a/app/pages/library.go +++ b/app/pages/library.go @@ -7,6 +7,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/adw" "codeberg.org/puregotk/puregotk/v4/gtk" "github.com/0skillallluck/scanline/app/appctx" + "github.com/0skillallluck/scanline/app/components/cards" "github.com/0skillallluck/scanline/app/components/lists" "github.com/0skillallluck/scanline/app/router" "github.com/0skillallluck/scanline/app/sources" @@ -32,9 +33,7 @@ func Library(ctx context.Context, appCtx *appctx.AppContext, serverID, sectionID return router.FromError(section.Title, err) } - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) body := WrapBox(). ConnectConstruct(func(w *adw.WrapBox) { diff --git a/app/pages/movie.go b/app/pages/movie.go index 4928023..6f5bb48 100644 --- a/app/pages/movie.go +++ b/app/pages/movie.go @@ -180,9 +180,7 @@ func Movie(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey s } // Related section - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) for i := range relatedHubs { hub := &relatedHubs[i] list := lists.NewHorizontalList(hub.Title) diff --git a/app/pages/search.go b/app/pages/search.go index 4614f1a..53c63d0 100644 --- a/app/pages/search.go +++ b/app/pages/search.go @@ -10,6 +10,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/gtk" "github.com/0skillallluck/scanline/app/appctx" + "github.com/0skillallluck/scanline/app/components/cards" "github.com/0skillallluck/scanline/app/pages/search" "github.com/0skillallluck/scanline/app/router" "github.com/0skillallluck/scanline/app/sources" @@ -76,10 +77,8 @@ var SearchRoute = router.NewRoute("search", func(ctx context.Context, appCtx *ap } srcID := src.ID() newCached = append(newCached, cachedSource{ - hubs: hubs, - coverURL: func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - }, + hubs: hubs, + coverURL: cards.PosterCoverURL(src), serverID: srcID, }) } diff --git a/app/pages/show.go b/app/pages/show.go index c5d1c75..784997b 100644 --- a/app/pages/show.go +++ b/app/pages/show.go @@ -106,7 +106,7 @@ func Show(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey st seasonList := lists.NewHorizontalList(gettext.Get("Seasons")) for i := range seasons { s := &seasons[i] - seasonList.Append(cards.NewSeasonPoster(s, src.PhotoTranscodeURL(s.Thumb, 240, 360), serverID)) + seasonList.Append(cards.NewSeasonPoster(s, src.PhotoTranscodeURL(s.Thumb, cards.PosterWidth, cards.PosterHeight), serverID)) } body = body.Append(seasonList.SetPageMargin(0)) } @@ -121,9 +121,7 @@ func Show(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey st } // Related section - coverURL := func(thumb string) string { - return src.PhotoTranscodeURL(thumb, 240, 360) - } + coverURL := cards.PosterCoverURL(src) for i := range relatedHubs { hub := &relatedHubs[i] list := lists.NewHorizontalList(hub.Title) diff --git a/app/pages/watchlist.go b/app/pages/watchlist.go index d8f7255..cd46025 100644 --- a/app/pages/watchlist.go +++ b/app/pages/watchlist.go @@ -11,6 +11,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/appctx" + "github.com/0skillallluck/scanline/app/components/cards" "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/app/router" "github.com/0skillallluck/scanline/app/sources" @@ -82,11 +83,11 @@ func watchlistPoster(title, subtitle, thumbURL string, match *sources.WatchlistM Child( VStack( Picture(). - SizeRequest(180, 270). + SizeRequest(cards.PosterWidth, cards.PosterHeight). FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if thumbURL != "" && preference.Performance().AllowPreviewImages() { - imageutils.LoadIntoPictureScaled(thumbURL, 180, 270, gtk.PictureNewFromInternalPtr(w.Ptr)) + imageutils.LoadIntoPictureScaled(thumbURL, cards.PosterWidth, cards.PosterHeight, gtk.PictureNewFromInternalPtr(w.Ptr)) } }). CornerRadius(10). From 6409962cf6756c9466867b616f20a51ff1b463c7 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:22:55 +0200 Subject: [PATCH 2/6] Use posterWithProgress only with progress --- app/components/cards/poster.go | 47 ++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/app/components/cards/poster.go b/app/components/cards/poster.go index 2de3cbe..6fa476c 100644 --- a/app/components/cards/poster.go +++ b/app/components/cards/poster.go @@ -29,11 +29,35 @@ func PosterCoverURL(src sources.Source) func(thumb string) string { } func poster[T any](title string, subTitle schwifty.Widgetable[T], coverURL string) schwifty.Button { - return posterWithProgress(title, subTitle, coverURL, 0) + image := posterPicture(coverURL). + CornerRadius(10). + Overflow(gtk.OverflowHiddenValue) + + return posterButton(title, subTitle, image) } func posterWithProgress[T any](title string, subTitle schwifty.Widgetable[T], coverURL string, progress float64) schwifty.Button { - picture := Picture(). + if progress <= 0 { + return poster(title, subTitle, coverURL) + } + + progressBar := Box(gtk.OrientationHorizontalValue). + SizeRequest(int32(PosterWidth*progress), 4). + VAlign(gtk.AlignEndValue). + HAlign(gtk.AlignStartValue). + CSS("box { background-color: @accent_bg_color; }") + + image := Bin(). + Child(Overlay(posterPicture(coverURL)).AddOverlay(progressBar)). + SizeRequest(PosterWidth, PosterHeight). + CornerRadius(10). + Overflow(gtk.OverflowHiddenValue) + + return posterButton(title, subTitle, image) +} + +func posterPicture(coverURL string) schwifty.Picture { + return Picture(). SizeRequest(PosterWidth, PosterHeight). FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { @@ -41,24 +65,9 @@ func posterWithProgress[T any](title string, subTitle schwifty.Widgetable[T], co imageutils.LoadIntoPictureScaled(coverURL, PosterWidth, PosterHeight, gtk.PictureNewFromInternalPtr(w.Ptr)) } }) +} - var image any - if progress > 0 { - progressBar := Box(gtk.OrientationHorizontalValue). - SizeRequest(int32(PosterWidth*progress), 4). - VAlign(gtk.AlignEndValue). - HAlign(gtk.AlignStartValue). - CSS("box { background-color: @accent_bg_color; }") - - image = Bin(). - Child(Overlay(picture).AddOverlay(progressBar)). - SizeRequest(PosterWidth, PosterHeight). - CornerRadius(10). - Overflow(gtk.OverflowHiddenValue) - } else { - image = picture.CornerRadius(10).Overflow(gtk.OverflowHiddenValue) - } - +func posterButton[T any](title string, subTitle schwifty.Widgetable[T], image any) schwifty.Button { return Button(). Child( VStack( From 8fff7a224803622df57d6f5d3e3872ed2129389a Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:24:43 +0200 Subject: [PATCH 3/6] Cache textures --- app/components/cards/poster.go | 4 +-- app/components/cards/preview.go | 4 +-- app/components/cards/season_episode.go | 4 +-- app/components/widgets/hero.go | 4 +-- app/components/widgets/ratings.go | 5 ++- app/pages/watchlist.go | 4 +-- utils/textures/textures.go | 46 ++++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 utils/textures/textures.go diff --git a/app/components/cards/poster.go b/app/components/cards/poster.go index 6fa476c..d20ca33 100644 --- a/app/components/cards/poster.go +++ b/app/components/cards/poster.go @@ -3,12 +3,12 @@ package cards import ( "codeberg.org/dergs/tonearm/pkg/schwifty" . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" - "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/app/sources" "github.com/0skillallluck/scanline/utils/imageutils" + "github.com/0skillallluck/scanline/utils/textures" ) // Poster card image dimensions. Pre-sized cover URLs (via PosterCoverURL) @@ -59,7 +59,7 @@ func posterWithProgress[T any](title string, subTitle schwifty.Widgetable[T], co func posterPicture(coverURL string) schwifty.Picture { return Picture(). SizeRequest(PosterWidth, PosterHeight). - FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). + FromPaintable(textures.FromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if preference.Performance().AllowPreviewImages() { imageutils.LoadIntoPictureScaled(coverURL, PosterWidth, PosterHeight, gtk.PictureNewFromInternalPtr(w.Ptr)) diff --git a/app/components/cards/preview.go b/app/components/cards/preview.go index c06fc2c..2f1824a 100644 --- a/app/components/cards/preview.go +++ b/app/components/cards/preview.go @@ -3,11 +3,11 @@ package cards import ( "codeberg.org/dergs/tonearm/pkg/schwifty" . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" - "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/utils/imageutils" + "github.com/0skillallluck/scanline/utils/textures" ) func previewCard[T any](title string, subTitle schwifty.Widgetable[T], artURL string, progress float64) schwifty.Button { @@ -17,7 +17,7 @@ func previewCard[T any](title string, subTitle schwifty.Widgetable[T], artURL st // Create the picture widget picture := Picture(). SizeRequest(480, 270). - FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). + FromPaintable(textures.FromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ContentFit(gtk.ContentFitCoverValue). ConnectRealize(func(w gtk.Widget) { if preference.Performance().AllowPreviewImages() { diff --git a/app/components/cards/season_episode.go b/app/components/cards/season_episode.go index 7e9552d..fbae9ae 100644 --- a/app/components/cards/season_episode.go +++ b/app/components/cards/season_episode.go @@ -3,7 +3,6 @@ package cards import ( "codeberg.org/dergs/tonearm/pkg/schwifty" . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" - "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/glib" "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" @@ -11,6 +10,7 @@ import ( "github.com/0skillallluck/scanline/app/sources" "github.com/0skillallluck/scanline/internal/gettext" "github.com/0skillallluck/scanline/utils/imageutils" + "github.com/0skillallluck/scanline/utils/textures" ) func NewSeasonEpisode(metadata *sources.Metadata, coverURL, serverID string) schwifty.Button { @@ -26,7 +26,7 @@ func NewSeasonEpisode(metadata *sources.Metadata, coverURL, serverID string) sch picture := Picture(). SizeRequest(320, 180). ContentFit(gtk.ContentFitCoverValue). - FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). + FromPaintable(textures.FromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if preference.Performance().AllowPreviewImages() { imageutils.LoadIntoPictureScaled(coverURL, 320, 180, gtk.PictureNewFromInternalPtr(w.Ptr)) diff --git a/app/components/widgets/hero.go b/app/components/widgets/hero.go index 6fee399..69d01ce 100644 --- a/app/components/widgets/hero.go +++ b/app/components/widgets/hero.go @@ -6,7 +6,6 @@ import ( "codeberg.org/dergs/tonearm/pkg/schwifty" . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" - "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/glib" "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" @@ -14,6 +13,7 @@ import ( "github.com/0skillallluck/scanline/app/sources" "github.com/0skillallluck/scanline/internal/gettext" "github.com/0skillallluck/scanline/utils/imageutils" + "github.com/0skillallluck/scanline/utils/textures" ) // linkButtonCSS strips button chrome so it looks like inline text. @@ -85,7 +85,7 @@ func HeroSection(poster HeroPosterParams, content schwifty.Box) schwifty.Box { Picture(). SizeRequest(poster.Width, poster.Height). ContentFit(gtk.ContentFitCoverValue). - FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). + FromPaintable(textures.FromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if preference.Performance().AllowPreviewImages() { imageutils.LoadIntoPictureScaled(poster.ImageURL, poster.Width, poster.Height, gtk.PictureNewFromInternalPtr(w.Ptr)) diff --git a/app/components/widgets/ratings.go b/app/components/widgets/ratings.go index 3420278..f6669d3 100644 --- a/app/components/widgets/ratings.go +++ b/app/components/widgets/ratings.go @@ -6,8 +6,8 @@ import ( "codeberg.org/dergs/tonearm/pkg/schwifty" . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" - "codeberg.org/puregotk/puregotk/v4/gdk" "github.com/0skillallluck/scanline/app/sources" + "github.com/0skillallluck/scanline/utils/textures" ) // RatingsParams contains the rating values for the Ratings component. @@ -49,8 +49,7 @@ func Ratings(params RatingsParams) schwifty.Box { iconPath := ratingIconPath(r.Image) var icon schwifty.Image if iconPath != "" { - texture := gdk.NewTextureFromResource(iconPath) - icon = Image().FromPaintable(texture).PixelSize(16) + icon = Image().FromPaintable(textures.FromResource(iconPath)).PixelSize(16) } else { // Fallback icons based on type if r.Type == "critic" { diff --git a/app/pages/watchlist.go b/app/pages/watchlist.go index cd46025..f4d8062 100644 --- a/app/pages/watchlist.go +++ b/app/pages/watchlist.go @@ -6,7 +6,6 @@ import ( . "codeberg.org/dergs/tonearm/pkg/schwifty/syntax" "codeberg.org/puregotk/puregotk/v4/adw" - "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/glib" "codeberg.org/puregotk/puregotk/v4/gtk" "codeberg.org/puregotk/puregotk/v4/pango" @@ -17,6 +16,7 @@ import ( "github.com/0skillallluck/scanline/app/sources" "github.com/0skillallluck/scanline/internal/gettext" "github.com/0skillallluck/scanline/utils/imageutils" + "github.com/0skillallluck/scanline/utils/textures" ) var WatchlistRoute = router.NewRoute("watchlist", watchlist) @@ -84,7 +84,7 @@ func watchlistPoster(title, subtitle, thumbURL string, match *sources.WatchlistM VStack( Picture(). SizeRequest(cards.PosterWidth, cards.PosterHeight). - FromPaintable(gdk.NewTextureFromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). + FromPaintable(textures.FromResource("/dev/skillless/Scanline/icons/scalable/state/missing-album.svg")). ConnectRealize(func(w gtk.Widget) { if thumbURL != "" && preference.Performance().AllowPreviewImages() { imageutils.LoadIntoPictureScaled(thumbURL, cards.PosterWidth, cards.PosterHeight, gtk.PictureNewFromInternalPtr(w.Ptr)) diff --git a/utils/textures/textures.go b/utils/textures/textures.go new file mode 100644 index 0000000..6b12067 --- /dev/null +++ b/utils/textures/textures.go @@ -0,0 +1,46 @@ +// Package textures provides a process-wide cache for gdk.Texture instances +// loaded from GResource paths. +// +// The same resource (e.g. "missing-album.svg") is used by every poster card +// in the UI; without caching, each card construction crosses the CGO boundary +// to decode the SVG anew. Profiling showed NewTextureFromResource at ~5% of +// navigation CPU. This cache makes the second-and-later loads free. +package textures + +import ( + "sync" + + "codeberg.org/puregotk/puregotk/v4/gdk" +) + +var ( + cacheMu sync.RWMutex + cache = make(map[string]*gdk.Texture) +) + +// FromResource returns the gdk.Texture for the given GResource path, caching +// the result. The returned texture is reference-counted by GDK; callers must +// not Unref it (the cache owns the only Go-side reference, and GTK will keep +// its own references for any widget that uses the texture as a paintable). +// +// Texture-from-resource is safe to call from any thread because the underlying +// GResource lookup is read-only and the GDK texture is immutable after +// creation. +func FromResource(path string) *gdk.Texture { + cacheMu.RLock() + if t, ok := cache[path]; ok { + cacheMu.RUnlock() + return t + } + cacheMu.RUnlock() + + cacheMu.Lock() + defer cacheMu.Unlock() + if t, ok := cache[path]; ok { + return t + } + + t := gdk.NewTextureFromResource(path) + cache[path] = t + return t +} From f35ecd73d4d58c58970f53d2e0b296d6f94366a6 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:24:54 +0200 Subject: [PATCH 4/6] Tune HTTP pools --- utils/httputils/request/client.go | 12 +++++++++--- utils/imageutils/fetch.go | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/utils/httputils/request/client.go b/utils/httputils/request/client.go index c0561f4..3b823c0 100644 --- a/utils/httputils/request/client.go +++ b/utils/httputils/request/client.go @@ -41,11 +41,17 @@ func DefaultClient() *http.Client { Timeout: 30 * time.Second, // Connection establishment timeout KeepAlive: 30 * time.Second, }).DialContext, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, + // Sized for image-heavy pages: a library view fans out 20-30 + // parallel poster fetches and we want each one to reuse a + // kept-alive TLS session. Idle conns cost ~16 KiB. + MaxIdleConns: 512, + MaxIdleConnsPerHost: 256, IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, // TLS handshake timeout + TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, + // Explicit so a future TLSClientConfig override doesn't silently + // disable the stdlib default. + ForceAttemptHTTP2: true, }, } diff --git a/utils/imageutils/fetch.go b/utils/imageutils/fetch.go index fd4b4c8..e7e70ff 100644 --- a/utils/imageutils/fetch.go +++ b/utils/imageutils/fetch.go @@ -7,6 +7,7 @@ import ( "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/utils/cacheutils" + "github.com/0skillallluck/scanline/utils/httputils/request" ) // Fetch returns the bytes for an image URL, layered through the disk + @@ -24,7 +25,7 @@ func fetch(url string) ([]byte, error) { } } - resp, err := http.Get(url) + resp, err := request.DefaultClient().Get(url) if err != nil { return nil, err } From 827ea9c647080bd84252d23f64647a6e32a75120 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:25:24 +0200 Subject: [PATCH 5/6] Change cache to binary encoding --- utils/cacheutils/file.go | 131 +++++++++++++++---- utils/cacheutils/file_test.go | 233 ++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 utils/cacheutils/file_test.go diff --git a/utils/cacheutils/file.go b/utils/cacheutils/file.go index 72aa16d..2498817 100644 --- a/utils/cacheutils/file.go +++ b/utils/cacheutils/file.go @@ -1,18 +1,40 @@ package cacheutils import ( - "encoding/json" + "encoding/binary" + "errors" "os" "path/filepath" "time" ) -var fileCacheDir = "" +// File-cache binary format. A small fixed header is followed by raw data +// bytes — no base64 or JSON wrapping, so binary payloads are stored +// verbatim and expiry can be checked from the first 16 bytes alone. +// +// offset size field +// 0 4 magic = 'S','C','L','1' (Scanline cache v1) +// 4 1 version +// 5 3 reserved (zero) +// 8 8 expiresAtUnixNano int64 LE (0 = no expiry) +// 16 N data +// +// Files use the .cache extension. Any leftover .json artefacts from an +// older format are ignored on read and removed by Clear(). +const ( + cacheFileMagic = "SCL1" + cacheFileVersion = 1 + cacheHeaderSize = 16 + cacheExtension = ".cache" +) -type fileCachedEntry struct { - Data []byte `json:"data"` - ExpiresAt time.Time `json:"expires_at"` -} +var ( + errCacheBadMagic = errors.New("cacheutils: file does not start with expected magic") + errCacheBadVersion = errors.New("cacheutils: unsupported file-cache version") + errCacheTruncated = errors.New("cacheutils: file shorter than header") +) + +var fileCacheDir = "" // SetFileCacheDir overrides the file cache directory (useful for tests). func SetFileCacheDir(dir string) { @@ -38,60 +60,115 @@ func getFileCacheDir() string { return fileCacheDir } +func cacheFilePath(hashedKey string) string { + return filepath.Join(getFileCacheDir(), hashedKey+cacheExtension) +} + +// getFromFile reads a cache entry from disk. ttl > 0 enforces the embedded +// expiry; ttl == 0 returns the entry regardless of staleness. +// +// The returned slice aliases the on-disk bytes (no copy). Callers that +// retain it past the immediate call must copy; the Layered Get path is +// safe because storeInMemory copies via copyBytes before caching. func getFromFile(hashedKey string, ttl int) ([]byte, bool) { - cacheDir := getFileCacheDir() - filePath := filepath.Join(cacheDir, hashedKey+".json") + filePath := cacheFilePath(hashedKey) raw, err := os.ReadFile(filePath) if err != nil { return nil, false } - var cached fileCachedEntry - if err := json.Unmarshal(raw, &cached); err != nil { + expiresAt, payload, err := parseCacheFile(raw) + if err != nil { + // Malformed / unknown-version file — drop it so the next write + // can replace it cleanly. _ = os.Remove(filePath) return nil, false } - if ttl > 0 && time.Now().After(cached.ExpiresAt) { + if ttl > 0 && !expiresAt.IsZero() && time.Now().After(expiresAt) { _ = os.Remove(filePath) return nil, false } - return cached.Data, true + return payload, true } +// storeInFile writes a cache entry atomically: the payload is staged in a +// sibling temp file and then renamed into place. Without this, a concurrent +// reader could observe a partial write whose header validates but whose +// payload is truncated, and that corrupt entry would keep returning as a +// "valid" hit until evicted. Skipping fsync is deliberate — for a cache, +// the read-side bad-magic check self-heals after a crash and the per-write +// flush cost would dominate the Store path. func storeInFile(hashedKey string, data []byte, ttl int) error { cacheDir := getFileCacheDir() if err := os.MkdirAll(cacheDir, 0755); err != nil { return err } - var expiresAt time.Time - if ttl == 0 { - expiresAt = time.Now().AddDate(100, 0, 0) - } else { - expiresAt = time.Now().Add(time.Duration(ttl) * time.Second) + var expiresAtNano int64 + if ttl > 0 { + expiresAtNano = time.Now().Add(time.Duration(ttl) * time.Second).UnixNano() } - cached := fileCachedEntry{ - Data: data, - ExpiresAt: expiresAt, - } + buf := make([]byte, cacheHeaderSize+len(data)) + copy(buf[0:4], cacheFileMagic) + buf[4] = cacheFileVersion + // buf[5:8] is reserved and already zero from make(). + binary.LittleEndian.PutUint64(buf[8:16], uint64(expiresAtNano)) + copy(buf[cacheHeaderSize:], data) - raw, err := json.Marshal(cached) + tmp, err := os.CreateTemp(cacheDir, "_tmp-*"+cacheExtension) if err != nil { return err } + tmpPath := tmp.Name() + + committed := false + defer func() { + if !committed { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(buf); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } - filePath := filepath.Join(cacheDir, hashedKey+".json") - return os.WriteFile(filePath, raw, 0644) + if err := os.Rename(tmpPath, cacheFilePath(hashedKey)); err != nil { + return err + } + committed = true + return nil } func deleteFromFile(hashedKey string) { - cacheDir := getFileCacheDir() - filePath := filepath.Join(cacheDir, hashedKey+".json") - _ = os.Remove(filePath) + _ = os.Remove(cacheFilePath(hashedKey)) +} + +// parseCacheFile validates the header and returns the embedded expiry plus +// the payload slice. The payload aliases raw — no copy is made. +func parseCacheFile(raw []byte) (time.Time, []byte, error) { + if len(raw) < cacheHeaderSize { + return time.Time{}, nil, errCacheTruncated + } + if string(raw[0:4]) != cacheFileMagic { + return time.Time{}, nil, errCacheBadMagic + } + if raw[4] != cacheFileVersion { + return time.Time{}, nil, errCacheBadVersion + } + expiresAtNano := int64(binary.LittleEndian.Uint64(raw[8:16])) + var expiresAt time.Time + if expiresAtNano != 0 { + expiresAt = time.Unix(0, expiresAtNano) + } + return expiresAt, raw[cacheHeaderSize:], nil } func clearFileDir() error { diff --git a/utils/cacheutils/file_test.go b/utils/cacheutils/file_test.go new file mode 100644 index 0000000..4873091 --- /dev/null +++ b/utils/cacheutils/file_test.go @@ -0,0 +1,233 @@ +package cacheutils + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// TestFile_BinaryFormat verifies that storeInFile lays out the documented +// header (SCL1 magic + version + reserved + expiry) followed by raw, +// unmodified payload bytes — no base64, no JSON wrapper. +func TestFile_BinaryFormat(t *testing.T) { + SetFileCacheDir(t.TempDir()) + Clear() //nolint:errcheck + + key := "https://server/photo/abc" + // Use bytes that would be base64-mangled by the previous JSON format. + payload := []byte{0x00, 0xFF, 0x89, 'S', 'C', 'L', '1', 0xAB} + + if err := Store(key, payload, Layered, 0); err != nil { + t.Fatalf("Store: %v", err) + } + + raw, err := os.ReadFile(cacheFilePath(hashKey(key))) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + if len(raw) != cacheHeaderSize+len(payload) { + t.Fatalf("on-disk size %d, want header (%d) + payload (%d)", len(raw), cacheHeaderSize, len(payload)) + } + if string(raw[0:4]) != cacheFileMagic { + t.Errorf("magic = %q, want %q", raw[0:4], cacheFileMagic) + } + if raw[4] != cacheFileVersion { + t.Errorf("version = %d, want %d", raw[4], cacheFileVersion) + } + // reserved bytes zero + for i := 5; i < 8; i++ { + if raw[i] != 0 { + t.Errorf("reserved byte %d = %d, want 0", i, raw[i]) + } + } + // ttl=0 → embedded expiry is zero + if got := int64(binary.LittleEndian.Uint64(raw[8:16])); got != 0 { + t.Errorf("expiresAt for ttl=0 = %d, want 0", got) + } + // Payload byte-for-byte identical to input + if string(raw[cacheHeaderSize:]) != string(payload) { + t.Errorf("payload tampered: got %v, want %v", raw[cacheHeaderSize:], payload) + } +} + +// TestFile_TtlEncodedAndEnforced ensures non-zero ttl writes the expiry +// into the header and that reads honour it. +func TestFile_TtlEncodedAndEnforced(t *testing.T) { + SetFileCacheDir(t.TempDir()) + Clear() //nolint:errcheck + + key := "ttl-key" + if err := Store(key, []byte("payload"), Layered, 1); err != nil { + t.Fatalf("Store: %v", err) + } + + raw, err := os.ReadFile(cacheFilePath(hashKey(key))) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + expiresAt := time.Unix(0, int64(binary.LittleEndian.Uint64(raw[8:16]))) + if expiresAt.Before(time.Now()) || expiresAt.After(time.Now().Add(2*time.Second)) { + t.Errorf("expiresAt %v not within (now, now+2s)", expiresAt) + } + + // Immediate read with positive ttl: hit. + if _, ok := Get(key, Layered, 1); !ok { + t.Fatal("expected fresh entry to be readable") + } + + // Wait past expiry, then read with positive ttl: miss, and file gone. + time.Sleep(1100 * time.Millisecond) + clearMemory() // bypass the memory layer to force a file read + if _, ok := Get(key, Layered, 1); ok { + t.Error("expired entry should not be returned when ttl > 0") + } + if _, err := os.Stat(cacheFilePath(hashKey(key))); !os.IsNotExist(err) { + t.Errorf("expired file should have been removed, stat err = %v", err) + } +} + +// TestFile_LegacyEntriesIgnored simulates migration from the old JSON- +// wrapped .json files. Pre-existing legacy files must not be misread as +// the new format, and Clear() must still remove them so the cache dir +// stays tidy. +func TestFile_LegacyEntriesIgnored(t *testing.T) { + dir := t.TempDir() + SetFileCacheDir(dir) + Clear() //nolint:errcheck + + // Drop an artefact resembling the old JSON wrapper. + legacyPath := filepath.Join(dir, "deadbeef.json") + if err := os.WriteFile(legacyPath, []byte(`{"data":"AAAA","expires_at":"2030-01-01T00:00:00Z"}`), 0644); err != nil { + t.Fatalf("seed legacy file: %v", err) + } + + // A Get against any key should miss (we don't read .json any more) and + // must not crash on the legacy artefact. + if _, ok := Get("anything", Layered, 0); ok { + t.Error("Get on unrelated key returned a hit") + } + + // Legacy file still on disk (we don't proactively scan-and-delete). + if _, err := os.Stat(legacyPath); err != nil { + t.Errorf("legacy file should still exist until Clear(): %v", err) + } + + // Clear() wipes every non-dir file regardless of extension. + if err := Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Errorf("legacy file should be removed after Clear, stat err = %v", err) + } +} + +// TestFile_CorruptHeaderDropped verifies that a file with bad magic is +// removed rather than being returned as a "hit". +func TestFile_CorruptHeaderDropped(t *testing.T) { + SetFileCacheDir(t.TempDir()) + Clear() //nolint:errcheck + + hashed := hashKey("corrupt-key") + corrupt := make([]byte, cacheHeaderSize+8) + copy(corrupt[0:4], "XXXX") // bad magic + if err := os.WriteFile(cacheFilePath(hashed), corrupt, 0644); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, ok := getFromFile(hashed, 0); ok { + t.Error("getFromFile returned a hit for a corrupt-magic file") + } + if _, err := os.Stat(cacheFilePath(hashed)); !os.IsNotExist(err) { + t.Errorf("corrupt file should be removed on read, stat err = %v", err) + } +} + +// TestFile_AtomicWrite_NoLeftoverTemp ensures successful Store doesn't +// leave the staging temp file behind in the cache directory. +func TestFile_AtomicWrite_NoLeftoverTemp(t *testing.T) { + dir := t.TempDir() + SetFileCacheDir(dir) + Clear() //nolint:errcheck + + if err := Store("k", []byte("payload"), Layered, 0); err != nil { + t.Fatalf("Store: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), "_tmp-") { + t.Errorf("leftover staging file: %s", e.Name()) + } + } +} + +// TestFile_AtomicWrite_ConcurrentReadSeesAllOrNothing verifies that a +// reader running while a writer is mid-Store never observes a partial +// payload. Pre-atomicity, an os.WriteFile would O_TRUNC the file and a +// concurrent reader could read 16+ bytes (valid header, truncated data), +// poisoning the memory cache with a corrupt entry that looks legitimate. +func TestFile_AtomicWrite_ConcurrentReadSeesAllOrNothing(t *testing.T) { + dir := t.TempDir() + SetFileCacheDir(dir) + Clear() //nolint:errcheck + + const key = "concurrent-key" + oldPayload := bytes.Repeat([]byte{'O'}, 64*1024) + newPayload := bytes.Repeat([]byte{'N'}, 64*1024) + + // Seed with the old payload so readers have something to find. + if err := Store(key, oldPayload, Layered, 0); err != nil { + t.Fatalf("seed Store: %v", err) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // Reader: continuously bypasses the memory layer and reads from disk. + // Each successful read must return either oldPayload or newPayload in + // full — never a truncated mix. + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + clearMemory() + got, ok := Get(key, Layered, 0) + if !ok { + continue + } + if !bytes.Equal(got, oldPayload) && !bytes.Equal(got, newPayload) { + t.Errorf("partial read: len=%d first=%q last=%q", len(got), got[:1], got[len(got)-1:]) + return + } + } + }() + + // Writer: alternate between the two payloads to maximise the chance + // of catching a concurrent partial read. + for i := 0; i < 200; i++ { + payload := oldPayload + if i%2 == 1 { + payload = newPayload + } + if err := Store(key, payload, Layered, 0); err != nil { + t.Fatalf("Store iter %d: %v", i, err) + } + } + + close(stop) + wg.Wait() +} From 291fdec6818b868e4414715c620cfd6f3c429846 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Tue, 12 May 2026 11:25:36 +0200 Subject: [PATCH 6/6] Increase memory cap --- utils/cacheutils/memory.go | 2 +- utils/cacheutils/memory_test.go | 43 ++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/utils/cacheutils/memory.go b/utils/cacheutils/memory.go index dd83848..2f394d2 100644 --- a/utils/cacheutils/memory.go +++ b/utils/cacheutils/memory.go @@ -8,7 +8,7 @@ import ( // MemoryCacheMaxBytes is the upper bound on bytes held in the in-memory cache. // When exceeded, least-recently-used entries are evicted until the total fits. -const MemoryCacheMaxBytes = 64 * 1024 * 1024 // 64 MiB +const MemoryCacheMaxBytes = 256 * 1024 * 1024 // 256 MiB type memoryEntry struct { key string diff --git a/utils/cacheutils/memory_test.go b/utils/cacheutils/memory_test.go index 0132368..f0b15e3 100644 --- a/utils/cacheutils/memory_test.go +++ b/utils/cacheutils/memory_test.go @@ -38,11 +38,13 @@ func TestMemoryEntry_LRUEvictionRespectsByteCap(t *testing.T) { clearMemory() t.Cleanup(clearMemory) - // Insert 65 entries of 1 MiB each — total 65 MiB > 64 MiB cap. The - // oldest entries should be evicted. - const oneMiB = 1024 * 1024 - payload := make([]byte, oneMiB) - for i := byte(0); i < 65; i++ { + // Size each entry so 64 entries exactly fill the cap and the 65th + // triggers eviction of the oldest. Derived from MemoryCacheMaxBytes + // so the test stays correct if the cap changes. + const fillCount = 64 + entrySize := MemoryCacheMaxBytes / fillCount + payload := make([]byte, entrySize) + for i := byte(0); i < fillCount+1; i++ { payload[0] = i // make each payload distinguishable storeInMemory(string([]byte{'k', i}), payload, 0, false) } @@ -57,7 +59,7 @@ func TestMemoryEntry_LRUEvictionRespectsByteCap(t *testing.T) { } // Most recent key should still be present. - if _, ok := getFromMemory(string([]byte{'k', 64})); !ok { + if _, ok := getFromMemory(string([]byte{'k', fillCount})); !ok { t.Error("most recent entry should still be present") } } @@ -66,10 +68,11 @@ func TestMemoryEntry_GetMovesToFront(t *testing.T) { clearMemory() t.Cleanup(clearMemory) - // Fill the cache to capacity with 64 entries of 1 MiB each. - const oneMiB = 1024 * 1024 - payload := make([]byte, oneMiB) - for i := byte(0); i < 64; i++ { + // Fill the cache to capacity with fillCount entries sized to MemoryCacheMaxBytes/fillCount. + const fillCount = 64 + entrySize := MemoryCacheMaxBytes / fillCount + payload := make([]byte, entrySize) + for i := byte(0); i < fillCount; i++ { storeInMemory(string([]byte{'k', i}), payload, 0, false) } @@ -79,8 +82,8 @@ func TestMemoryEntry_GetMovesToFront(t *testing.T) { t.Fatal("entry 0 should be present") } - // Insert one more entry to push us over the cap by 1 MiB. The LRU - // (entry 1, since 0 was promoted) should be evicted. + // Insert one more entry to push us over the cap. The LRU (entry 1, + // since 0 was promoted) should be evicted. storeInMemory("new", payload, 0, false) if _, ok := getFromMemory(string([]byte{'k', 0})); !ok { @@ -149,11 +152,12 @@ func TestLRUEviction_CleansRawKeyIndex(t *testing.T) { SetFileCacheDir(t.TempDir()) Clear() //nolint:errcheck - const oneMiB = 1024 * 1024 - payload := make([]byte, oneMiB) + const fillCount = 64 + entrySize := MemoryCacheMaxBytes / fillCount + payload := make([]byte, entrySize) // Fill exactly to capacity then push one over so a single eviction occurs. - for i := byte(0); i < 64; i++ { + for i := byte(0); i < fillCount; i++ { _ = Store("key-"+string([]byte{i}), payload, MemoryOnly, 0) } _ = Store("key-evictor", payload, MemoryOnly, 0) @@ -163,7 +167,7 @@ func TestLRUEviction_CleansRawKeyIndex(t *testing.T) { hashedCount := len(hashedToRawKey) rawKeyMu.RUnlock() - if rawCount > 64 { + if rawCount > fillCount { t.Errorf("rawKeyIndex should not retain entries past LRU eviction: got %d", rawCount) } if rawCount != hashedCount { @@ -217,9 +221,10 @@ func TestLayeredEviction_PreservesInvalidation(t *testing.T) { SetFileCacheDir(t.TempDir()) Clear() //nolint:errcheck - const oneMiB = 1024 * 1024 + const fillCount = 64 + entrySize := MemoryCacheMaxBytes / fillCount smallPayload := []byte("v") - bigPayload := make([]byte, oneMiB) + bigPayload := make([]byte, entrySize) target := "https://server/library/metadata/42" if err := Store(target, smallPayload, Layered, 600); err != nil { @@ -227,7 +232,7 @@ func TestLayeredEviction_PreservesInvalidation(t *testing.T) { } // Push enough big entries through to evict the target from memory. - for i := byte(0); i < 65; i++ { + for i := byte(0); i < fillCount+1; i++ { _ = Store("filler-"+string([]byte{i}), bigPayload, MemoryOnly, 0) }