Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a35ed83
utils/cacheutils: TTL-aware LRU cache with prefix-based invalidation
0SkillAllLuck May 7, 2026
5298381
utils/httputils/request: WithInMemoryCaching takes a TTL
0SkillAllLuck May 7, 2026
fa18bc2
utils/httputils/request: WithCacheKey for per-request cache key extras
0SkillAllLuck May 7, 2026
6e6b2b3
utils/httputils/request: dedupe concurrent in-flight requests
0SkillAllLuck May 7, 2026
3cfd8e3
provider/plex/base: cache policy and cached request helpers
0SkillAllLuck May 7, 2026
20e6812
provider/plex: cache policy option and playback invalidation API
0SkillAllLuck May 7, 2026
00545d2
provider/plex: cache read-only library, hubs, search, server, and pla…
0SkillAllLuck May 7, 2026
5359c8e
provider/plex: route auth and watchlist through the request builder
0SkillAllLuck May 7, 2026
b4bd57d
app/preference: add ShouldCacheLibraries and ShouldCacheMetadata acce…
0SkillAllLuck May 7, 2026
868be23
app/sources: wire Plex cache policy and playback invalidation
0SkillAllLuck May 7, 2026
841b24d
app/components/player: track parent ratingKeys for post-playback inva…
0SkillAllLuck May 7, 2026
85492cb
app/pages: use PlayerParamsForMetadata helper at play sites
0SkillAllLuck May 7, 2026
67f7fd2
app/pages: invalidate metadata cache after Scrobble/Unscrobble
0SkillAllLuck May 7, 2026
ba70b9a
ci: refresh flatpak modules and nix vendorHash for golang.org/x/sync
0SkillAllLuck May 7, 2026
5c775c5
utils/cacheutils,httputils: trim narrative comments
0SkillAllLuck May 7, 2026
786d5b0
provider/plex: remove unused SetCachePolicy mutator
0SkillAllLuck May 7, 2026
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
56 changes: 47 additions & 9 deletions app/components/player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,35 @@ type PlayerParams struct {
Source sources.Source // the source for this playback
ViewOffset int // resume position in milliseconds

// Parent / grandparent ratingKeys. For episodes: the season and show.
// Empty for movies. Used by post-playback cache invalidation to clear
// the season's children listing.
ParentRatingKey string
GrandparentRatingKey string

// NextEpisode is the pre-resolved next episode (nil for movies or last episode).
NextEpisode *NextEpisodeInfo
}

// PlayerParamsForMetadata builds PlayerParams for a metadata item that has at
// least one playable media part. Caller is responsible for verifying that
// meta.Media[0].Part[0] exists.
func PlayerParamsForMetadata(ctx context.Context, meta *sources.Metadata, src sources.Source, win *gtk.Window, nextEp *NextEpisodeInfo) PlayerParams {
return PlayerParams{
Ctx: ctx,
Title: meta.Title,
PartKey: meta.Media[0].Part[0].Key,
Window: win,
RatingKey: meta.RatingKey,
ParentRatingKey: meta.ParentRatingKey,
GrandparentRatingKey: meta.GrandparentRatingKey,
Media: meta.Media,
Source: src,
ViewOffset: meta.ViewOffset,
NextEpisode: nextEp,
}
}

// NewPlayer creates a video player with overlay controls.
// The player reuses the application's main window — there is no separate
// fullscreen modal. The user can still toggle fullscreen via the F/F11 key
Expand Down Expand Up @@ -553,20 +578,25 @@ func NewPlayer(params PlayerParams) {
playNextEpisode = func() {
// Resolve the next-next episode before closing (context still alive).
var nextNext *NextEpisodeInfo
var parent, grandparent string
if nextInfo.Metadata != nil {
nextNext = ResolveNextEpisode(ctx, src, nextInfo.Metadata)
parent = nextInfo.Metadata.ParentRatingKey
grandparent = nextInfo.Metadata.GrandparentRatingKey
}
closePlayer()
NewPlayer(PlayerParams{
Ctx: params.Ctx,
Title: nextInfo.Title,
PartKey: nextInfo.PartKey,
Window: params.Window,
RatingKey: nextInfo.RatingKey,
Media: nextInfo.Media,
Source: src,
ViewOffset: nextInfo.ViewOffset,
NextEpisode: nextNext,
Ctx: params.Ctx,
Title: nextInfo.Title,
PartKey: nextInfo.PartKey,
Window: params.Window,
RatingKey: nextInfo.RatingKey,
ParentRatingKey: parent,
GrandparentRatingKey: grandparent,
Media: nextInfo.Media,
Source: src,
ViewOffset: nextInfo.ViewOffset,
NextEpisode: nextNext,
})
}

Expand Down Expand Up @@ -816,18 +846,26 @@ func NewPlayer(params PlayerParams) {
pcore.Pause()
dur := currentDurationUs()
ts := currentTimestampUs()
progressReported := false
if dur > 0 {
timeMs := int(ts / 1000)
durationMs := int(dur / 1000)
if err := src.UpdateProgress(ctx, params.RatingKey, sources.StateStopped, timeMs, durationMs); err != nil {
slog.Error("failed to send final progress", "error", err)
} else {
progressReported = true
}
}
if dur > 0 && ts > 0 && float64(ts)/float64(dur) > 0.9 {
if err := src.Scrobble(ctx, params.RatingKey); err != nil {
slog.Error("failed to scrobble", "error", err)
} else {
progressReported = true
}
}
if progressReported {
src.InvalidateAfterPlayback(params.RatingKey, params.ParentRatingKey, params.GrandparentRatingKey)
}
// Detach paintable before pcore.Close — otherwise the picture
// may snapshot a sink whose state is being freed (SIGSEGV in
// gdk_paintable_snapshot). A typed-nil PaintableBase passes a
Expand Down
13 changes: 2 additions & 11 deletions app/pages/episode.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,7 @@ func Episode(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey
ConnectClicked(func(b gtk.Button) {
if len(meta.Media) > 0 && len(meta.Media[0].Part) > 0 {
nextEp := player.ResolveNextEpisode(ctx, src, meta)
player.NewPlayer(player.PlayerParams{
Ctx: ctx,
Title: meta.Title,
PartKey: meta.Media[0].Part[0].Key,
Window: appCtx.Window,
RatingKey: ratingKey,
Media: meta.Media,
Source: src,
ViewOffset: meta.ViewOffset,
NextEpisode: nextEp,
})
player.NewPlayer(player.PlayerParamsForMetadata(ctx, meta, src, appCtx.Window, nextEp))
}
}),
).
Expand Down Expand Up @@ -119,6 +109,7 @@ func Episode(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey
})
return
}
src.InvalidateAfterPlayback(ratingKey, meta.ParentRatingKey, meta.GrandparentRatingKey)
schwifty.OnMainThreadOncePure(func() {
b.SetSensitive(true)
if watched {
Expand Down
12 changes: 2 additions & 10 deletions app/pages/movie.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,7 @@ func Movie(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey s
WithCSSClass("pill").
ConnectClicked(func(b gtk.Button) {
if len(meta.Media) > 0 && len(meta.Media[0].Part) > 0 {
player.NewPlayer(player.PlayerParams{
Ctx: ctx,
Title: meta.Title,
PartKey: meta.Media[0].Part[0].Key,
Window: appCtx.Window,
RatingKey: ratingKey,
Media: meta.Media,
Source: src,
ViewOffset: meta.ViewOffset,
})
player.NewPlayer(player.PlayerParamsForMetadata(ctx, meta, src, appCtx.Window, nil))
}
}),
).
Expand Down Expand Up @@ -128,6 +119,7 @@ func Movie(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey s
})
return
}
src.InvalidateAfterPlayback(ratingKey, meta.ParentRatingKey, meta.GrandparentRatingKey)
schwifty.OnMainThreadOncePure(func() {
b.SetSensitive(true)
if watched {
Expand Down
12 changes: 1 addition & 11 deletions app/pages/season.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,7 @@ func Season(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey
WithCSSClass("pill").
ConnectClicked(func(b gtk.Button) {
nextEp := player.ResolveNextEpisode(ctx, src, ep)
player.NewPlayer(player.PlayerParams{
Ctx: ctx,
Title: ep.Title,
PartKey: ep.Media[0].Part[0].Key,
Window: appCtx.Window,
RatingKey: ep.RatingKey,
Media: ep.Media,
Source: src,
ViewOffset: ep.ViewOffset,
NextEpisode: nextEp,
})
player.NewPlayer(player.PlayerParamsForMetadata(ctx, ep, src, appCtx.Window, nextEp))
}),
)
}
Expand Down
12 changes: 1 addition & 11 deletions app/pages/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,7 @@ func Show(ctx context.Context, appCtx *appctx.AppContext, serverID, ratingKey st
WithCSSClass("pill").
ConnectClicked(func(b gtk.Button) {
nextEp := player.ResolveNextEpisode(ctx, src, ep)
player.NewPlayer(player.PlayerParams{
Ctx: ctx,
Title: ep.Title,
PartKey: ep.Media[0].Part[0].Key,
Window: appCtx.Window,
RatingKey: ep.RatingKey,
Media: ep.Media,
Source: src,
ViewOffset: ep.ViewOffset,
NextEpisode: nextEp,
})
player.NewPlayer(player.PlayerParamsForMetadata(ctx, ep, src, appCtx.Window, nextEp))
}),
)
}
Expand Down
8 changes: 8 additions & 0 deletions app/preference/performance.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,18 @@ func (p *PerformanceSettings) BindCacheLibraries(target *gobject.Object, propert
p.settings.Bind("cache-libraries", target, property, gio.GSettingsBindNoSensitivityValue)
}

func (p *PerformanceSettings) ShouldCacheLibraries() bool {
return p.settings.GetBoolean("cache-libraries")
}

func (p *PerformanceSettings) BindCacheMetadata(target *gobject.Object, property string) {
p.settings.Bind("cache-metadata", target, property, gio.GSettingsBindNoSensitivityValue)
}

func (p *PerformanceSettings) ShouldCacheMetadata() bool {
return p.settings.GetBoolean("cache-metadata")
}

// Navigation

func (p *PerformanceSettings) BindMaxRouterHistorySize(target *gobject.Object, property string) {
Expand Down
9 changes: 4 additions & 5 deletions app/sources/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

"github.com/0skillallluck/scanline/app/secrets"
"github.com/0skillallluck/scanline/internal/signals"
"github.com/0skillallluck/scanline/provider/plex"
"github.com/0skillallluck/scanline/provider/plex/auth"
"github.com/0skillallluck/scanline/provider/plex/watchlist"
"github.com/google/uuid"
Expand Down Expand Up @@ -55,7 +54,7 @@ func NewManager() *Manager {
// Assume reachable until a connection probe says otherwise
srv.Reachable = srv.URL != ""
if srv.Enabled && srv.URL != "" {
client := plex.NewClient(srv.URL, tokenForServer(token, srv), acct.ClientID)
client := newPlexClient(srv.URL, tokenForServer(token, srv), acct.ClientID)
m.sources[srv.ID] = NewPlexSource(srv.ID, srv.Name, client)
}
}
Expand Down Expand Up @@ -174,7 +173,7 @@ func (m *Manager) AddPlexAccount(ctx context.Context, token, username, clientID
m.accounts = append(m.accounts, acct)
for _, srv := range servers {
if srv.Enabled && srv.Reachable && srv.URL != "" {
client := plex.NewClient(srv.URL, tokenForServer(token, srv), clientID)
client := newPlexClient(srv.URL, tokenForServer(token, srv), clientID)
m.sources[srv.ID] = NewPlexSource(srv.ID, srv.Name, client)
}
}
Expand Down Expand Up @@ -240,7 +239,7 @@ func (m *Manager) SetServerEnabled(accountID, serverID string, enabled bool) {
slog.Warn("server URL not cached, need to refresh servers", "server", srv.Name)
}
if srv.URL != "" && token != "" {
client := plex.NewClient(srv.URL, tokenForServer(token, srv), acct.ClientID)
client := newPlexClient(srv.URL, tokenForServer(token, srv), acct.ClientID)
m.sources[srv.ID] = NewPlexSource(srv.ID, srv.Name, client)
}
} else {
Expand Down Expand Up @@ -355,7 +354,7 @@ func (m *Manager) RefreshServers(ctx context.Context) {
srv.Reachable = false
} else {
srv.URL = url
client := plex.NewClient(url, tokenForServer(token, srv), info.clientID)
client := newPlexClient(url, tokenForServer(token, srv), info.clientID)
newSources[srv.ID] = NewPlexSource(srv.ID, srv.Name, client)
}
}
Expand Down
15 changes: 15 additions & 0 deletions app/sources/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ import (
"context"
"net/url"

"github.com/0skillallluck/scanline/app/preference"
"github.com/0skillallluck/scanline/provider/plex"
)

// newPlexClient constructs a Plex client wired to the user's caching
// preferences. The two predicates are evaluated per request, so toggling the
// "Cache Libraries" / "Cache Metadata" switches takes effect immediately.
func newPlexClient(serverURL, token, clientID string) *plex.Client {
return plex.NewClient(serverURL, token, clientID, plex.WithCachePolicy(
preference.Performance().ShouldCacheLibraries,
preference.Performance().ShouldCacheMetadata,
))
}

// PlexSource adapts a plex.Client to the Source interface.
type PlexSource struct {
client *plex.Client
Expand Down Expand Up @@ -107,3 +118,7 @@ func (s *PlexSource) Unscrobble(ctx context.Context, ratingKey string) error {
func (s *PlexSource) UpdateProgress(ctx context.Context, ratingKey string, state PlaybackState, timeMs, durationMs int) error {
return s.client.Timeline.UpdateProgress(ctx, ratingKey, state, timeMs, durationMs)
}

func (s *PlexSource) InvalidateAfterPlayback(ratingKey, parentRatingKey, grandparentRatingKey string) {
s.client.InvalidateAfterPlayback(ratingKey, parentRatingKey, grandparentRatingKey)
}
7 changes: 7 additions & 0 deletions app/sources/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,11 @@ type Source interface {

// UpdateProgress reports playback position to the server.
UpdateProgress(ctx context.Context, ratingKey string, state PlaybackState, timeMs, durationMs int) error

// InvalidateAfterPlayback evicts cached metadata that may have changed
// after a playback action (Scrobble, Unscrobble, or playback stop).
// Pass empty strings for parent / grandparent if not known. Callers should
// invoke this after a successful Scrobble / Unscrobble / stop so that the
// next render sees fresh watch state.
InvalidateAfterPlayback(ratingKey, parentRatingKey, grandparentRatingKey string)
}
5 changes: 5 additions & 0 deletions assets/meta/go.mod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
strip-components: 5
type: archive
url: https://proxy.golang.org/github.com/yeqown/go-qrcode/writer/standard/@v/v1.3.0.zip
- dest: vendor/golang.org/x/sync
sha256: a35481e5ae73e51ef01cf42bcad09c3b73bb3a4abb67d495d4a575021541ed02
strip-components: 3
type: archive
url: https://proxy.golang.org/golang.org/x/sync/@v/v0.12.0.zip
- dest: vendor/codeberg.org/puregotk/purego
sha256: 0d5fb9e11934b151ac357a9c0660e4dcd287d2f93db0829356415f6e6a8ef84b
strip-components: 3
Expand Down
3 changes: 3 additions & 0 deletions assets/meta/modules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ golang.org/x/image/font
golang.org/x/image/font/basicfont
golang.org/x/image/math/f64
golang.org/x/image/math/fixed
# golang.org/x/sync v0.12.0
## explicit; go 1.23.0
golang.org/x/sync/singleflight
# golang.org/x/sys v0.43.0
## explicit; go 1.25.0
golang.org/x/sys/windows
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
pname = "scanline";
version = "0.4.0";
src = pkgs.lib.cleanSource ./.;
vendorHash = "sha256-VCILJElIVjGr3mZZvDcgA/HaEYFsDY272mv2+tFnZyY=";
vendorHash = "sha256-zp+DQoo5jwJJrvC6KGdWBAvWtR55+6jslVlAAkfcU1U=";

ldflags = [
"-X \"github.com/0skillallluck/scanline/app/dialogs/about.Commit=${
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/leonelquinteros/gotext v1.7.2
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
golang.org/x/sync v0.12.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWB
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
Loading