library-operator

InputUnitCoveredTotalPercent
Gostatements6938721596.2%
Rust (media-browser)lines157891608998.1%

Go

6938 of 7215 statements, 96.2%.

FileCovered statementsTotal statementsPercent
api.go454697.8%
apiclient.go15516593.9%
arrival.go273187.1%
arrivalfact.go495490.7%
artanswer.go3838100.0%
artfacts.go4040100.0%
artimages.go3232100.0%
artrole.go828596.5%
attempts.go747796.1%
availability.go1010100.0%
batch.go576095.0%
bus.go10010892.6%
catalog.go9696100.0%
catalogclaim.go1818100.0%
catalogpod.go3535100.0%
catalogquery.go12913694.9%
catalogreconcile.go586293.5%
catalogupdates.go272896.4%
cleanup.go344477.3%
cleanupjob.go505198.0%
contributorfacts.go16016696.4%
contributorrows.go899395.7%
contributors.go646598.5%
depart.go596295.2%
endpoints.go565896.6%
enrich.go2727100.0%
enrichjob.go464797.9%
enrichrun.go253669.4%
enrichschedule.go9393100.0%
enrichsync.go585998.3%
enrichworker.go4646100.0%
factrows.go11813885.5%
factrowscatalog.go4747100.0%
factsrole.go162661.5%
fanart.go2121100.0%
fanartart.go5454100.0%
files.go15716098.1%
franchiseart.go727694.7%
franchisefile.go125125100.0%
franchiserows.go4242100.0%
franchises.go6868100.0%
franchisescan.go202195.2%
genres.go2929100.0%
identity.go3939100.0%
identityladder.go13013199.2%
identityrole.go949796.9%
librarysweep.go252696.2%
likenledger.go666995.7%
main.go0150.0%
metadataprovider.go283093.3%
movies.go858896.6%
mqtt.go8080100.0%
names.go18218399.5%
namespacecatalog.go2121100.0%
nfo.go959699.0%
nfoanswer.go949598.9%
nfoedit.go959699.0%
nfoepisode.go1818100.0%
nfofacts.go1818100.0%
nfofactsrole.go15816894.0%
nforatings.go1616100.0%
nfowrite.go5757100.0%
objects.go1717100.0%
omdb.go2828100.0%
omdbfacts.go666798.5%
operate.go14015391.5%
operatorimages.go363894.7%
playrequest.go101101100.0%
pod.go2626100.0%
probe.go777898.7%
providercheck.go919496.8%
providerenv.go3232100.0%
providerhttp.go656994.2%
providertable.go1919100.0%
prune.go19920398.0%
reconcile.go889988.9%
report.go2323100.0%
reporter.go15516395.1%
rows.go505198.0%
runs.go8181100.0%
scan.go24326791.0%
scanjob.go596196.7%
scanwrite.go2424100.0%
screenclaim.go3333100.0%
screenpod.go828398.8%
series.go16216995.9%
service.go2727100.0%
sets.go747894.9%
status.go9810197.0%
tmdb.go4040100.0%
tmdbart.go3030100.0%
tmdbfacts.go12112398.4%
tmdbperson.go1616100.0%
topics.go3737100.0%
trickplay.go757896.2%
trickplaysheets.go5252100.0%
tvmaze.go2424100.0%
tvmazeart.go2121100.0%
tvmazefacts.go5151100.0%
volumewrite.go14214995.3%
walk.go7878100.0%
watch.go9710394.2%
webhook.go5353100.0%
webhookserver.go565798.2%
api.go 97.8%
1package main23// The wire types are hand-written, the way liken and the sibling4// operators write theirs. The Kubernetes API is HTTPS that serves5// JSON, and importing client-go for a dozen structs brings informers,6// work queues, and a release cadence this program does not use. Each7// type carries only the fields this operator reads or writes; the API8// server fills in the rest.910import (11	"slices"12	"time"13)1415// The group this operator serves, and the core group it writes16// into: a Library becomes an ordinary CronJob and its Jobs ordinary17// pods, so any tool that reads them reads what a Library became.18const (19	libraryAPIVersion = "library.liken.sh/v1alpha1"20	podAPIVersion     = "v1"21)2223// The group media-operator serves. This operator reads Players from it24// and writes none, so the group appears here for the read path alone.25const playerAPIVersion = "media.liken.sh/v1alpha1"2627// The finalizer this operator holds on every Library. It keeps a28// deleted Library open until the departure in depart.go has swept29// the library's rows out of every surviving agent's catalog. The30// name is in this operator's own group, so the finalizer says which31// controller answers for it.32const libraryFinalizer = "library.liken.sh/cleanup-library"3334// Release 2026.08.31-004 named the finalizer sweep. The operator35// still reads that name so a Library which adopted it under that36// release swaps to the current name on a pass, and deletes instead37// of sticking on a finalizer nothing releases.38const formerLibraryFinalizer = "library.liken.sh/sweep"3940// ObjectMeta carries what this operator reads or writes: name and41// namespace for the URL, resourceVersion for the conditional write,42// uid with ownerReferences so the garbage collector takes a scanner43// pod with its Library, and labels so one watch selects every scanner44// pod in the cluster.45//46// Annotations carry the template hash the operator stamps on a scanner47// pod, which is how a pass tells a live pod from the pod it would48// build now. deletionTimestamp is set by the API server on an object49// on its way out, and a pod with one set is left alone until the50// delete completes. finalizers is here because the operator holds a51// Library open past the delete request, and that window is where the52// departure in depart.go sweeps the catalog. generateName is the prefix53// the API server mints a name from; a Play carries it in place of a54// name, because every start of a title is its own Play and the operator55// keeps no record of what it created.56type ObjectMeta struct {57	Name              string            `json:"name,omitempty"`58	GenerateName      string            `json:"generateName,omitempty"`59	Namespace         string            `json:"namespace,omitempty"`60	UID               string            `json:"uid,omitempty"`61	Generation        int64             `json:"generation,omitempty"`62	ResourceVersion   string            `json:"resourceVersion,omitempty"`63	Labels            map[string]string `json:"labels,omitempty"`64	Annotations       map[string]string `json:"annotations,omitempty"`65	Finalizers        []string          `json:"finalizers,omitempty"`66	DeletionTimestamp string            `json:"deletionTimestamp,omitempty"`67	OwnerReferences   []OwnerReference  `json:"ownerReferences,omitempty"`68}6970// Deleting reports that somebody asked for this object's deletion.71// The API server does not remove an object that carries a finalizer;72// it writes the deletion timestamp instead, and that window is where73// the departure runs.74func (m ObjectMeta) deleting() bool { return m.DeletionTimestamp != "" }7576// Holds reports whether this object carries the named finalizer.77func (m ObjectMeta) holds(finalizer string) bool {78	return slices.Contains(m.Finalizers, finalizer)79}8081// With answers the finalizer list with one added, and without82// answers it with one removed. Both answer a new slice, because the83// caller's copy is the object as the server has it, and a patch that84// fails must leave that copy alone.85func (m ObjectMeta) with(finalizer string) []string {86	if m.holds(finalizer) {87		return m.Finalizers88	}89	return append(slices.Clone(m.Finalizers), finalizer)90}9192func (m ObjectMeta) without(finalizers ...string) []string {93	kept := []string{}94	for _, held := range m.Finalizers {95		if !slices.Contains(finalizers, held) {96			kept = append(kept, held)97		}98	}99	return kept100}101102// An ownerReference ties an object's life to its owner's: the garbage103// collector deletes the owned object when the owner goes, which is104// this operator's whole teardown. Controller is true because exactly105// one thing manages each owned object; there is no blockOwnerDeletion,106// because nothing here needs the owner to wait.107type OwnerReference struct {108	APIVersion string `json:"apiVersion"`109	Kind       string `json:"kind"`110	Name       string `json:"name"`111	UID        string `json:"uid"`112	Controller bool   `json:"controller"`113}114115// A list's own resourceVersion is the revision of the whole116// collection, which is what a watch resumes from.117type ListMeta struct {118	ResourceVersion string `json:"resourceVersion,omitempty"`119}120121// A Library is a volume of media of one kind. The operator reads the122// spec and writes the status: the spec is what a person declared, and123// the status is what the volume resolved to and what the scanner124// reports.125type Library struct {126	APIVersion string        `json:"apiVersion,omitempty"`127	Kind       string        `json:"kind,omitempty"`128	Metadata   ObjectMeta    `json:"metadata"`129	Spec       LibrarySpec   `json:"spec"`130	Status     LibraryStatus `json:"status"`131}132133type LibraryList struct {134	Metadata ListMeta  `json:"metadata"`135	Items    []Library `json:"items"`136}137138// A Library names its storage, its kind, and the settings for that139// kind. The settings blocks are pointers because their presence is the140// declaration: the block that matches the kind is there and no other,141// and the CRD's own rule refuses any other combination.142type LibrarySpec struct {143	Storage LibraryStorage   `json:"storage"`144	Kind    string           `json:"kind"`145	Movies  *LibrarySettings `json:"movies,omitempty"`146	Series  *LibrarySettings `json:"series,omitempty"`147	// Franchises is the settings block of the franchises kind: the image148	// every block carries, and the art claim that this kind alone names.149	Franchises *LibraryFranchises `json:"franchises,omitempty"`150151	// The metadata providers to ask about a title, in the order they152	// are asked. Enrichment reads the list; nothing acts on it yet.153	Sources []string `json:"sources,omitempty"`154155	// The path components the walk skips, wherever they sit under the156	// library root. An owner names the junk their storage keeps, such as157	// a recycle bin or a staging folder, because no fixed list can158	// anticipate every volume's layout.159	Ignore []string `json:"ignore,omitempty"`160161	// One time per fact, by the names status.gaps uses: an attempt162	// this fact made before that time does not count, so every title163	// is in that fact's gap again and the fact asks a provider again164	// and rewrites its own files and rows in place.165	// Nothing is deleted, and a fact this map does not name is166	// untouched.167	Refresh map[string]time.Time `json:"refresh,omitempty"`168169	// How often the full walk runs.170	Scan LibraryScan `json:"scan,omitzero"`171172	// Whether this library builds the thumbnail sheets a scrub bar reads, which173	// costs hours of CPU over a whole library on the first run.174	Trickplay LibraryTrickplay `json:"trickplay,omitzero"`175}176177// The trickplay block of the spec, off unless the owner turns it on.178type LibraryTrickplay struct {179	Enabled bool `json:"enabled,omitempty"`180}181182// The schedule the Library's full walk runs on, as the cron183// expression a CronJob takes.184type LibraryScan struct {185	Schedule string `json:"schedule,omitempty"`186}187188// Once an hour, which is the interval a library with a webhook189// needs as a backstop and a library with none can live on.190const defaultScanSchedule = "0 * * * *"191192// The schedule the CronJob takes: the Library's own, or the193// default when it names none. The CRD defaults the field, so a Library194// from the API server always carries one, and this answers for the ones195// built in this program.196func (s LibrarySpec) scanSchedule() string {197	if s.Scan.Schedule != "" {198		return s.Scan.Schedule199	}200	return defaultScanSchedule201}202203// The kinds of media a Library holds. Each one names a settings block204// on the spec and a scanner to run, and a new kind is a new block205// beside the ones here.206const (207	libraryKindMovies = "movies"208	libraryKindSeries = "series"209	// A franchises library walks the franchise.yaml files on its storage210	// claim, and writes the art it downloads into the second claim that211	// its settings block names.212	libraryKindFranchises = "franchises"213)214215// Settings is the block that matches the kind, which is the block the216// scanner receives. Keeping the choice here keeps the Go side and the217// CRD's rule saying the same thing, so a new kind is one more case218// here and one more clause there.219func (s LibrarySpec) settings() *LibrarySettings {220	switch s.Kind {221	case libraryKindMovies:222		return s.Movies223	case libraryKindSeries:224		return s.Series225	case libraryKindFranchises:226		if s.Franchises == nil {227			return nil228		}229		return &s.Franchises.LibrarySettings230	}231	return nil232}233234// artClaim is the claim a franchises scan writes its art into, and empty235// for every other kind. The checkout is read-only, so the art has to land236// on a claim of its own.237func (s LibrarySpec) artClaim() string {238	if s.Kind != libraryKindFranchises || s.Franchises == nil {239		return ""240	}241	return s.Franchises.Art.Claim242}243244// screenClaim is the claim that holds the files a screen reads: the art245// claim of a franchises library, and the storage claim of every other246// kind. The screen pod, the play request, and the enrich Job all ask it,247// so no one of them decides the question on its own.248func (s LibrarySpec) screenClaim() string {249	if claim := s.artClaim(); claim != "" {250		return claim251	}252	return s.Storage.Claim253}254255// screenRoot is the directory inside the screen's claim that holds this256// library's files. The storage root applies to the storage claim alone,257// and the art of a franchises library lands at the root of its own claim,258// so a screen and a play reference read the art from there.259func (s LibrarySpec) screenRoot() string {260	if s.artClaim() != "" {261		return "/"262	}263	return s.Storage.Root264}265266// LibraryStorage is the volume and the directory inside it. Every kind267// mounts the claim read-only, a franchises library included, because the268// claim holds the truth the scan walks and the scan writes nothing to it.269// The operator reads the PersistentVolume behind the claim for the270// volume's kind and address.271type LibraryStorage struct {272	Claim string `json:"claim,omitempty"`273274	// The directory inside the claim this library starts at, always an275	// absolute path from the root of the volume. The CRD defaults it276	// to / and refuses a relative path, so the scanner takes this277	// field as it stands.278	Root string `json:"root,omitempty"`279}280281// LibrarySettings is the one setting every kind's block carries. The282// movies and series blocks are this struct alone, and the franchises block283// embeds it beside the art claim.284type LibrarySettings struct {285	// The scanner image to run in place of the one the project ships286	// for the kind, which is how a person supplies a scanner of their287	// own. Empty means the operator's own image.288	Image string `json:"image,omitempty"`289}290291// LibraryFranchises is the settings block of the franchises kind: the292// image every block carries, and the art claim that this kind alone names.293// The CRD requires the art claim, because a franchises scan always294// downloads art and the checkout cannot take it.295type LibraryFranchises struct {296	LibrarySettings297	Art LibraryArt `json:"art,omitzero"`298}299300// LibraryArt names the writable claim, in the Library's namespace, that a301// franchises scan downloads the art into. It is the claim a screen mounts302// for this library, because the art is what a screen reads of a franchise.303type LibraryArt struct {304	Claim string `json:"claim,omitempty"`305}306307// LibraryStatus is what the operator reports on a Library: the volume308// the claim resolved to, the scanner's report, the pod that runs the309// scanner, and the conditions.310//311// The counts carry no omitempty. A library of zero titles is a312// real answer, and a column that reads 0 says it, where an omitted313// field reads as nothing at all. The conditions say whether a report314// arrived.315//316// RemovedLastSweep is the count of catalog rows the scanner's last full317// sweep removed, folded from the bus report. A partial walk that pruned318// too much shows here, so a mass delete is visible without a shell.319type LibraryStatus struct {320	Volume *LibraryVolume `json:"volume,omitempty"`321	// Phase says what the scanner is doing, in one word a person reads at322	// a glance: one of the four values below. Where Ready is the condition a323	// program matches on, Phase is the sentence a person reads.324	Phase        string `json:"phase,omitempty"`325	Titles       int    `json:"titles"`326	Unidentified int    `json:"unidentified"`327	// Items is how many item rows the catalog holds for this library,328	// across the movies, series, and episodes tables, and Files how many329	// file rows. Both are the catalog's own counts, read after the prune,330	// so they describe what a screen can read and not what one walk saw.331	Items            int       `json:"items"`332	Files            int       `json:"files"`333	RemovedLastSweep int       `json:"removedLastSweep"`334	LastWalk         time.Time `json:"lastWalk,omitzero"`335	LastChange       time.Time `json:"lastChange,omitzero"`336	// One entry per worker, from the reporter: the Job that ran337	// last for that worker and when it finished.338	Runs []libraryRun `json:"runs,omitempty"`339	// Gaps is one count per fact of the rows that fact has left to fill.340	// Waiting is the titles whose identity ended in candidates for a person to341	// choose from, and Unresolved the titles no provider could name. All three342	// are the reporter's own numbers.343	Gaps       map[string]int `json:"gaps,omitempty"`344	Waiting    int            `json:"waiting"`345	Unresolved int            `json:"unresolved"`346	// The titles a fact left because another writer holds the element group it347	// writes. The repair is to stop that writer for this library.348	Fights int `json:"fights"`349	// Webhook is the URL of this Library's webhook endpoint on the350	// operator, the address a person gives to Radarr, Sonarr, or351	// Jellyfin.352	Webhook    string      `json:"webhook,omitempty"`353	Conditions []Condition `json:"conditions,omitempty"`354}355356// LibraryVolume is the PersistentVolume the claim is bound to,357// reported here so that whoever plays a title from this library reads358// the volume without a second request for the claim. Type is the name of the volume's359// source key, and the NFS pair is filled only for an NFS volume.360type LibraryVolume struct {361	Name   string `json:"name"`362	Type   string `json:"type,omitempty"`363	Server string `json:"server,omitempty"`364	Path   string `json:"path,omitempty"`365}366367// A Player is media-operator's unit of equipment. This operator reads368// one for its status.idle alone: the block media-operator publishes for the controller it369// delegated the idle screen to. Nothing here is written back, so the type370// carries no spec.371type Player struct {372	APIVersion string       `json:"apiVersion,omitempty"`373	Kind       string       `json:"kind,omitempty"`374	Metadata   ObjectMeta   `json:"metadata"`375	Status     PlayerStatus `json:"status"`376}377378// The idle block the Player carries, and an empty one for a Player that379// carries none. The empty block names no controller and no claim, so a Player380// with no idle status is a Player this operator stands nothing for.381func (p *Player) idle() PlayerIdleStatus {382	if p.Status.Idle == nil {383		return PlayerIdleStatus{}384	}385	return *p.Status.Idle386}387388// Whether this operator stands the Player's idle screen. The pass acts389// on status.idle and never on spec.idle: the spec can inherit its controller390// from MediaPreferences, and media-operator alone resolves those tiers.391func (p *Player) delegated() bool {392	return p.idle().Controller == screenController393}394395// The collection ListPlayers answers. Its resourceVersion is where the396// player watch begins.397type PlayerList struct {398	Metadata ListMeta `json:"metadata"`399	Items    []Player `json:"items"`400}401402// The one name a MediaPreferences may take. media-operator's CRD pins it,403// and this operator reads the singleton by this name.404const mediaPreferencesName = "default"405406// The household defaults media-operator owns, read here for one field:407// the wall-clock zone every screen shows. Nothing is written back, so408// the type carries that field alone.409type MediaPreferences struct {410	APIVersion string               `json:"apiVersion,omitempty"`411	Kind       string               `json:"kind,omitempty"`412	Metadata   ObjectMeta           `json:"metadata"`413	Spec       MediaPreferencesSpec `json:"spec"`414}415416// The one field of the household defaults this operator reads.417type MediaPreferencesSpec struct {418	// The household wall-clock zone, an IANA name like America/New_York.419	// The browser pod reads it as TZ, so its clock and its day's draw420	// follow the house and not UTC.421	TimeZone string `json:"timeZone,omitempty"`422}423424// The collection ListMediaPreferences answers. Its resourceVersion is425// where the watch begins.426type MediaPreferencesList struct {427	Metadata ListMeta           `json:"metadata"`428	Items    []MediaPreferences `json:"items"`429}430431// The household zone the list holds: the default MediaPreferences' own,432// or nothing where the cluster states none. A pod with no TZ reads UTC,433// the way media-operator's own pods do.434func householdZone(list *MediaPreferencesList) string {435	for _, preferences := range list.Items {436		if preferences.Metadata.Name == mediaPreferencesName {437			return preferences.Spec.TimeZone438		}439	}440	return ""441}442443// The half of a Player's status this operator acts on. The idle block444// is absent on a Player that stands no idle screen.445type PlayerStatus struct {446	Idle *PlayerIdleStatus `json:"idle,omitempty"`447}448449// What media-operator publishes for the idle controller it delegated450// to. Controller names that controller, and this operator acts only on its own451// name. Claim is the ResourceClaim media-operator stood for the screen, in the452// Player's namespace, and Requests names the requests in it the browser453// container states. The requests are media-operator's own list: render is454// there only for a Player whose display claim holds one.455//456// FadeAfterSeconds and OffAfterSeconds are the seconds before the457// screen fades and the seconds before the panel goes dark.458// media-operator resolves both and always writes them, because zero is459// a policy and an absent field is not one. The browser holds the460// timers, through the media-screen crate, so media-operator settles the461// policy and the client runs it.462type PlayerIdleStatus struct {463	Controller string   `json:"controller"`464	Claim      string   `json:"claim,omitempty"`465	Requests   []string `json:"requests,omitempty"`466467	FadeAfterSeconds int64 `json:"fadeAfterSeconds"`468	OffAfterSeconds  int64 `json:"offAfterSeconds"`469470	Bus *PlayerIdleBus `json:"bus,omitempty"`471}472473// PlayerIdleBus is the broker and every topic a delegate's client reads474// or writes: the retained status, the level, the commands topic that475// carries the re-present, the panel topic the client states the panel476// desire on, and the unit's controllers. VolumeTopic is empty for a477// unit with no sinks, which is the speaker gate. An older478// media-operator publishes no block, and the browser then takes the479// keyboard alone.480type PlayerIdleBus struct {481	Address       string             `json:"address"`482	StatusTopic   string             `json:"statusTopic"`483	VolumeTopic   string             `json:"volumeTopic,omitempty"`484	CommandsTopic string             `json:"commandsTopic"`485	PanelTopic    string             `json:"panelTopic"`486	Remotes       []PlayerIdleRemote `json:"remotes,omitempty"`487}488489// PlayerIdleRemote is one of the unit's controllers as a client reads490// it: the topic its presses arrive on and the topic its focus mark is491// on. The list is in spec.remotes order, because that position is the492// index a focus moment carries.493type PlayerIdleRemote struct {494	Events string `json:"events"`495	Focus  string `json:"focus"`496}497498// Play is media-operator's unit of playback: the Players it plays on499// and the items it plays in order. This operator creates Plays and500// reads none, so the type carries no status.501type Play struct {502	APIVersion string     `json:"apiVersion,omitempty"`503	Kind       string     `json:"kind,omitempty"`504	Metadata   ObjectMeta `json:"metadata"`505	Spec       PlaySpec   `json:"spec"`506}507508// PlaySpec names the Players and the items. A request from one screen509// names one Player.510type PlaySpec struct {511	Players []string   `json:"players,omitempty"`512	Items   []PlayItem `json:"items,omitempty"`513}514515// PlayItem is one item: the media reference the Player accepts, and516// the words the film's own display shows. The reference is a claim517// URI, which mounts the library's claim read-only on the playback pod.518type PlayItem struct {519	URI          string            `json:"uri"`520	Presentation *PlayPresentation `json:"presentation,omitempty"`521}522523// PlayPresentation is what the display shows about one item, in524// media-operator's own field names. Every field is optional, because525// the catalog holds what the volume holds and no more.526type PlayPresentation struct {527	Type         string `json:"type,omitempty"`528	Hint         string `json:"hint,omitempty"`529	Title        string `json:"title,omitempty"`530	Series       string `json:"series,omitempty"`531	Season       int    `json:"season,omitempty"`532	Episode      int    `json:"episode,omitempty"`533	EpisodeTitle string `json:"episodeTitle,omitempty"`534	Year         int    `json:"year,omitempty"`535	Date         string `json:"date,omitempty"`536	Art          string `json:"art,omitempty"`537	Trickplay    string `json:"trickplay,omitempty"`538}539540// The condition types this operator publishes. Bound reports the541// storage and Ready reports the scanner. A library whose claim never542// binds shows the cause in Bound, and Ready reads NotBound beside it.543// Departing reports the teardown of a deleted Library: it is the one544// condition here whose True states work in progress rather than a545// healthy fact, and its reason says how far the teardown reached.546const (547	conditionBound     = "Bound"548	conditionReady     = "Ready"549	conditionDeparting = "Departing"550)551552// The reasons each condition takes. A reason is one CamelCase word a553// program matches on, and the message beside it is the sentence.554const (555	reasonBound          = "Bound"556	reasonClaimNotFound  = "ClaimNotFound"557	reasonClaimUnbound   = "ClaimUnbound"558	reasonVolumeNotFound = "VolumeNotFound"559560	reasonReady        = "Ready"561	reasonNotBound     = "NotBound"562	reasonNoCatalog    = "NoCatalog"563	reasonManyCatalogs = "ManyCatalogs"564	reasonNoReport     = "NoReport"565	// The namespace's catalog pod is not up, the schedule does566	// not stand yet, and the namespace's reporter has left the bus.567	reasonCatalogPending = "CatalogPending"568	reasonScanPending    = "ScanPending"569	reasonOffline        = "Offline"570571	// The Departing condition's reasons, in the order depart.go572	// reaches them: a scan Job of this library is still running, the573	// cleanup Job is deleting the rows, the Job finished and the574	// reporter has not echoed it yet, and the cleanup cannot run at575	// all. Blocked covers a cleanup Job that failed and a namespace576	// with more than one Catalog.577	reasonScanRunning  = "ScanRunning"578	reasonSweeping     = "Sweeping"579	reasonAwaitingEcho = "AwaitingEcho"580	reasonBlocked      = "Blocked"581	// An enricher Job of this library is still running. It writes onto the582	// volume and into the catalog the sweep is emptying.583	reasonEnrichRunning = "EnrichRunning"584)585586// The values status.phase takes. libraryPhase in status.go derives587// the first four from the Ready condition, the scanner's588// availability on the bus, and the newest report. Departing is not589// derived: depart.go writes it on a Library with a deletion590// timestamp, for as long as the finalizer holds the object open.591const (592	phasePending   = "Pending"593	phaseOffline   = "Offline"594	phaseScanning  = "Scanning"595	phaseEnriching = "Enriching"596	phaseIdle      = "Idle"597	phaseDeparting = "Departing"598	// Failed means the last scan of this library failed and wrote no599	// rows. The tables hold what the last good scan left.600	phaseFailed = "Failed"601)602603// ConditionStatus is a condition's verdict. It is a string rather than604// a bool because there is a third state: an operator must be able to605// say when it cannot tell yet.606type ConditionStatus string607608const (609	ConditionTrue    ConditionStatus = "True"610	ConditionFalse   ConditionStatus = "False"611	ConditionUnknown ConditionStatus = "Unknown"612)613614// Condition mirrors metav1.Condition, the shape Kubernetes uses615// everywhere, and liken's own. Anyone who reads kubectl describe616// output on a Pod already knows how to read one of these.617//618// ObservedGeneration records which metadata.generation the condition619// judged. Generation counts spec edits, so a reader can tell "Ready,620// for the spec as it stands" from "Ready, but for a spec two edits621// ago".622type Condition struct {623	Type               string          `json:"type"`624	Status             ConditionStatus `json:"status"`625	ObservedGeneration int64           `json:"observedGeneration,omitempty"`626	Reason             string          `json:"reason,omitempty"`627	Message            string          `json:"message,omitempty"`628	LastTransitionTime time.Time       `json:"lastTransitionTime"`629}630631// SetCondition adds or updates a condition by type. It keeps the632// Kubernetes rule that makes lastTransitionTime meaningful: the time633// moves only when Status flips, not on every write. That is what lets634// kubectl get answer "how long has this library been Ready?" instead635// of only "when did the operator last say so?".636func SetCondition(conditions []Condition, condition Condition, now time.Time) []Condition {637	condition.LastTransitionTime = now638	for i, existing := range conditions {639		if existing.Type != condition.Type {640			continue641		}642		if existing.Status == condition.Status {643			condition.LastTransitionTime = existing.LastTransitionTime644		}645		conditions[i] = condition646		return conditions647	}648	return append(conditions, condition)649}
apiclient.go 93.9%
1package main23// This is a Kubernetes client written straight against the HTTP API,4// following liken's own (kubernetes/apiclient.go) and the media5// operator's, for the same reason: the API is HTTPS that serves6// JSON, and client-go would bring informers, work queues, and7// generated types this program does not use.8//9// Every pod already holds what it needs to reach the API server.10// Kubernetes injects two environment variables that name the11// server's in-cluster address, and the kubelet mounts a CA12// certificate and a ServiceAccount token at a known path.1314import (15	"bytes"16	"context"17	"crypto/tls"18	"crypto/x509"19	"encoding/json"20	"errors"21	"fmt"22	"io"23	"net"24	"net/http"25	"os"26	"time"27)2829// The path the kubelet mounts the ServiceAccount credentials on.30const defaultServiceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"3132// ServiceAccountDir is a variable so a test points it at a directory33// it controls.34var serviceAccountDir = defaultServiceAccountDir3536// These two answers are values, not failures. An absent object is the37// normal state the caller answers by creating it, and a conflict is38// the normal state under optimistic concurrency that the caller39// answers by reading again.40var (41	ErrNotFound = errors.New("not found")42	ErrConflict = errors.New("conflict: something else wrote this object first")43)4445type Client struct {46	base        string47	http        *http.Client48	credentials string49}5051// NewClient builds a client from its three parts. InClusterClient52// reads them from the pod's environment; a test hands in an53// httptest server's base and no credentials.54func NewClient(base string, httpClient *http.Client, credentials string) *Client {55	return &Client{base: base, http: httpClient, credentials: credentials}56}5758func InClusterClient() (*Client, error) {59	host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")60	if host == "" || port == "" {61		return nil, fmt.Errorf("not running in a cluster: KUBERNETES_SERVICE_HOST unset")62	}6364	// The client trusts the cluster's own CA and not the system65	// store, so it accepts this API server and no other server that66	// answers on the address.67	caPEM, err := os.ReadFile(serviceAccountDir + "/ca.crt")68	if err != nil {69		return nil, fmt.Errorf("reading service account CA: %w", err)70	}71	roots := x509.NewCertPool()72	if !roots.AppendCertsFromPEM(caPEM) {73		return nil, fmt.Errorf("service account CA contains no certificates")74	}7576	return NewClient("https://"+host+":"+port, &http.Client{77		Transport: &http.Transport{78			TLSClientConfig: &tls.Config{RootCAs: roots},79			// Each timeout bounds the same failure: a server that80			// stops answering without sending anything. There is no81			// overall client timeout, because a watch is a request82			// whose response never ends, and a whole-request deadline83			// would cut every stream on schedule.84			DialContext: (&net.Dialer{85				Timeout:   5 * time.Second,86				KeepAlive: 10 * time.Second,87			}).DialContext,88			ResponseHeaderTimeout: 10 * time.Second,89			IdleConnTimeout:       30 * time.Second,90		},91	}, serviceAccountDir), nil92}9394// RequestJSON sends one request and decodes the answer, turning every95// non-2xx status into an error that carries the server's own message.96func (c *Client) RequestJSON(ctx context.Context, method, path string, body []byte, out any) error {97	return c.RequestWithType(ctx, method, path, jsonContentType, body, out)98}99100// The two content types this client sends. A PATCH needs its own,101// because the API server reads which patch dialect a request speaks102// from the Content-Type header alone.103const (104	jsonContentType = "application/json"105	mergePatchType  = "application/merge-patch+json"106)107108// RequestWithType is RequestJSON with the request's own content type109// stated, which is what a merge patch needs.110func (c *Client) RequestWithType(ctx context.Context, method, path, contentType string, body []byte, out any) error {111	resp, err := c.do(ctx, method, path, contentType, body)112	if err != nil {113		return err114	}115	defer drain(resp.Body)116117	if resp.StatusCode == http.StatusNotFound {118		return ErrNotFound119	}120	if resp.StatusCode == http.StatusConflict {121		return ErrConflict122	}123	if resp.StatusCode < 200 || resp.StatusCode > 299 {124		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))125		return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, message)126	}127	if out == nil {128		return nil129	}130	return json.NewDecoder(resp.Body).Decode(out)131}132133// Do sends one request and hands back the open response, which is134// what a watch needs and what RequestJSON is built on.135//136// The context is the caller's, so a pass that ends takes its requests137// with it, and a watch runs for as long as its own context does.138func (c *Client) Do(ctx context.Context, method, path string, body []byte) (*http.Response, error) {139	return c.do(ctx, method, path, jsonContentType, body)140}141142// Do is Do with the body's content type stated, so one request path143// sends both a JSON write and a merge patch.144func (c *Client) do(ctx context.Context, method, path, contentType string, body []byte) (*http.Response, error) {145	var reader io.Reader146	if body != nil {147		reader = bytes.NewReader(body)148	}149	req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader)150	if err != nil {151		return nil, err152	}153	// The token is read from disk on every request. The mounted154	// token is short-lived and the kubelet refreshes the file as155	// each one nears expiry, so a client that held one in memory156	// would start getting 401s.157	if c.credentials != "" {158		token, err := os.ReadFile(c.credentials + "/token")159		if err != nil {160			return nil, fmt.Errorf("reading service account token: %w", err)161		}162		req.Header.Set("Authorization", "Bearer "+string(token))163	}164	req.Header.Set("Accept", "application/json")165	if body != nil {166		req.Header.Set("Content-Type", contentType)167	}168	return c.http.Do(req)169}170171// Drain reads whatever the caller left in the body, then closes it.172// Go returns a connection to its pool only when the body reaches173// EOF, so an early close costs a fresh connection and TLS handshake,174// and reaches the server as a hang-up on a request it answered.175const maxDrain = 4 << 20176177func drain(body io.ReadCloser) {178	_, _ = io.Copy(io.Discard, io.LimitReader(body, maxDrain))179	_ = body.Close()180}181182// Every API server answers /version, and the answer needs no RBAC183// rule, so it is the cheapest proof that the client reached the184// server it was configured for.185const versionPath = "/version"186187// Version holds the one field of /version the operator reports.188type Version struct {189	GitVersion string `json:"gitVersion"`190}191192func ServerVersion(ctx context.Context, client *Client) (Version, error) {193	var version Version194	if err := client.RequestJSON(ctx, http.MethodGet, versionPath, nil, &version); err != nil {195		return Version{}, err196	}197	return version, nil198}199200// The collection paths. Libraries are listed and watched across every201// namespace and written back per namespace. The storage and the pods202// are ordinary core-group objects: a claim and a pod are namespaced,203// and a volume is not.204const (205	librariesPath = "/apis/" + libraryAPIVersion + "/libraries"206	// The Catalogs, listed and watched across every namespace and207	// written back per namespace, the same shape as the Libraries.208	catalogsPath = "/apis/" + libraryAPIVersion + "/catalogs"209	// The Players, listed and watched across every namespace,210	// read-only. A Player is media-operator's object, and this operator211	// reads the collection to find the screens delegated to it.212	playersPath = "/apis/" + playerAPIVersion + "/players"213	// The MediaPreferences, cluster-scoped and read-only here, for the214	// household zone the screen pods carry.215	mediaPreferencesPath = "/apis/" + playerAPIVersion + "/mediapreferences"216217	libraryPrefix = "/apis/" + libraryAPIVersion + "/namespaces/"218	corePrefix    = "/api/v1/namespaces/"219	volumesPath   = "/api/v1/persistentvolumes"220	podsAllPath   = "/api/v1/pods"221222	// The slices behind the catalog Services, one in every namespace that223	// holds a Library.224	endpointSlicePrefix = "/apis/" + endpointSliceAPIVersion + "/namespaces/"225)226227// CatalogMemberQuery narrows a pod list or a pod watch to the228// pods that hold a catalog agent, whatever kind of pod they are: the229// catalog pod, a running Job's pod, and a screen pod. The equals sign230// inside the selector is percent-encoded, so the server reads one231// parameter and not two.232const catalogMemberQuery = "labelSelector=" + memberLabelKey + "%3D" + memberLabelValue233234// The same narrowing for the screen pods, which carry a name label of235// their own. The two selectors keep the two kinds of pod apart, so a list of236// one never answers with the other.237const screenPodsQuery = "labelSelector=" + scannerLabelKey + "%3D" + screenLabelValue238239func libraryPath(namespace, name string) string {240	return libraryPrefix + namespace + "/libraries/" + name241}242243func catalogPath(namespace, name string) string {244	return libraryPrefix + namespace + "/catalogs/" + name245}246247func claimPath(namespace, name string) string {248	return corePrefix + namespace + "/persistentvolumeclaims/" + name249}250251func claimsPath(namespace string) string {252	return corePrefix + namespace + "/persistentvolumeclaims"253}254255// playsPath is the plays collection of one namespace. A Play is256// created in the Player's namespace, which is the Library's namespace257// as well, so no reference this operator makes crosses a namespace.258func playsPath(namespace string) string {259	return "/apis/" + playerAPIVersion + "/namespaces/" + namespace + "/plays"260}261262func podsPath(namespace string) string {263	return corePrefix + namespace + "/pods"264}265266func endpointSlicesPath(namespace string) string {267	return endpointSlicePrefix + namespace + "/endpointslices"268}269270func servicesPath(namespace string) string {271	return corePrefix + namespace + "/services"272}273274// ListLibraries answers a whole pass with one request, and the list's275// resourceVersion is where the libraries watch resumes from.276func ListLibraries(ctx context.Context, c *Client) (*LibraryList, error) {277	list := &LibraryList{}278	if err := c.RequestJSON(ctx, http.MethodGet, librariesPath, nil, list); err != nil {279		return nil, err280	}281	return list, nil282}283284// PutLibraryStatus writes through the status subresource, which is its285// own write path: this request can never touch a spec. The286// resourceVersion in the body is what makes the write conditional, so287// a status written over a Library that changed underneath answers288// ErrConflict and the next pass reads it again.289func PutLibraryStatus(ctx context.Context, c *Client, library *Library) (*Library, error) {290	body, err := json.Marshal(library)291	if err != nil {292		return nil, err293	}294	written := &Library{}295	path := libraryPath(library.Metadata.Namespace, library.Metadata.Name) + "/status"296	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {297		return nil, err298	}299	return written, nil300}301302// PatchLibraryFinalizers writes a Library's finalizer list and303// answers with the resourceVersion the write produced, which a304// caller needs before it writes the same object again in one pass.305//306// It is a merge patch and not a replace, because a replace sends307// every field this program models and drops every field it does not,308// which would take a person's own labels and annotations off the309// Library. The resourceVersion inside the patch makes the write310// conditional the same way a replace is: a write that raced another311// answers ErrConflict instead of clobbering it.312func PatchLibraryFinalizers(ctx context.Context, c *Client, namespace, name, resourceVersion string, finalizers []string) (string, error) {313	body, err := json.Marshal(map[string]any{314		"metadata": map[string]any{315			"resourceVersion": resourceVersion,316			"finalizers":      finalizers,317		},318	})319	if err != nil {320		return "", err321	}322	var patched struct {323		Metadata ObjectMeta `json:"metadata"`324	}325	path := libraryPath(namespace, name)326	if err := c.RequestWithType(ctx, http.MethodPatch, path, mergePatchType, body, &patched); err != nil {327		return "", err328	}329	return patched.Metadata.ResourceVersion, nil330}331332// ListPlayers reads every Player in the cluster with one request, the333// way the pass reads the Libraries. The list's resourceVersion is where the334// player watch resumes from. A cluster with no media-operator serves no such335// collection, and the failure is the caller's to report and carry on from.336func ListPlayers(ctx context.Context, c *Client) (*PlayerList, error) {337	list := &PlayerList{}338	if err := c.RequestJSON(ctx, http.MethodGet, playersPath, nil, list); err != nil {339		return nil, err340	}341	return list, nil342}343344// ListMediaPreferences reads the household defaults with one request, on345// the same terms as the Players: a cluster with no media-operator serves no346// such collection, and the failure is the caller's to report and carry on347// from.348func ListMediaPreferences(ctx context.Context, c *Client) (*MediaPreferencesList, error) {349	list := &MediaPreferencesList{}350	if err := c.RequestJSON(ctx, http.MethodGet, mediaPreferencesPath, nil, list); err != nil {351		return nil, err352	}353	return list, nil354}355356// ListCatalogs answers a whole pass with one request, and the list's357// resourceVersion is where the catalogs watch resumes from.358func ListCatalogs(ctx context.Context, c *Client) (*CatalogList, error) {359	list := &CatalogList{}360	if err := c.RequestJSON(ctx, http.MethodGet, catalogsPath, nil, list); err != nil {361		return nil, err362	}363	return list, nil364}365366// PutCatalogStatus writes through the status subresource, so this367// request can never touch a spec. The resourceVersion in the body makes368// the write conditional, the same as PutLibraryStatus.369func PutCatalogStatus(ctx context.Context, c *Client, catalog *NamespaceCatalog) (*NamespaceCatalog, error) {370	body, err := json.Marshal(catalog)371	if err != nil {372		return nil, err373	}374	written := &NamespaceCatalog{}375	path := catalogPath(catalog.Metadata.Namespace, catalog.Metadata.Name) + "/status"376	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {377		return nil, err378	}379	return written, nil380}381382// GetPersistentVolumeClaim reads the claim a Library names, for two383// answers: whether it is bound, and which volume it is bound to. An384// absent claim is ErrNotFound, which the pass reports as the385// ClaimNotFound reason rather than as a failure. It also reads the386// catalog claim the operator provisions, to tell an existing one from387// none.388func GetPersistentVolumeClaim(ctx context.Context, c *Client, namespace, name string) (*PersistentVolumeClaim, error) {389	claim := &PersistentVolumeClaim{}390	if err := c.RequestJSON(ctx, http.MethodGet, claimPath(namespace, name), nil, claim); err != nil {391		return nil, err392	}393	return claim, nil394}395396// CreatePersistentVolumeClaim provisions a catalog claim: a Library's,397// the catalog pod's, or a screen's. The operator creates one once and never398// updates it, because a claim's spec is immutable once it binds.399func CreatePersistentVolumeClaim(ctx context.Context, c *Client, claim *PersistentVolumeClaim) (*PersistentVolumeClaim, error) {400	body, err := json.Marshal(claim)401	if err != nil {402		return nil, err403	}404	created := &PersistentVolumeClaim{}405	if err := c.RequestJSON(ctx, http.MethodPost, claimsPath(claim.Metadata.Namespace), body, created); err != nil {406		return nil, err407	}408	return created, nil409}410411// DeletePersistentVolumeClaim removes the catalog claim of a screen412// the scheduler cannot place. It is the one claim this operator deletes. An413// already-absent claim is success, because the operator deletes the claim to414// replace it and a delete that races another pass must not fail.415func DeletePersistentVolumeClaim(ctx context.Context, c *Client, namespace, name string) error {416	err := c.RequestJSON(ctx, http.MethodDelete, claimPath(namespace, name), nil, nil)417	if errors.Is(err, ErrNotFound) {418		return nil419	}420	return err421}422423// GetPersistentVolume reads the volume behind a bound claim, for what424// serves it. A PersistentVolume is cluster-scoped, so the path carries425// no namespace.426func GetPersistentVolume(ctx context.Context, c *Client, name string) (*PersistentVolume, error) {427	volume := &PersistentVolume{}428	if err := c.RequestJSON(ctx, http.MethodGet, volumesPath+"/"+name, nil, volume); err != nil {429		return nil, err430	}431	return volume, nil432}433434// ListCatalogMemberPods reads every pod that holds a catalog435// agent across every namespace, because a Catalog is in whatever436// namespace its Libraries do. The list's resourceVersion is where the437// pod watch begins.438func ListCatalogMemberPods(ctx context.Context, c *Client) (*PodList, error) {439	list := &PodList{}440	if err := c.RequestJSON(ctx, http.MethodGet, podsAllPath+"?"+catalogMemberQuery, nil, list); err != nil {441		return nil, err442	}443	return list, nil444}445446// ListScreenPods reads this operator's screen pods across every447// namespace, on the same terms, so the pass knows which screens stand448// before it deletes one. They reach the catalog EndpointSlice through449// the member label like every other agent.450func ListScreenPods(ctx context.Context, c *Client) (*PodList, error) {451	list := &PodList{}452	if err := c.RequestJSON(ctx, http.MethodGet, podsAllPath+"?"+screenPodsQuery, nil, list); err != nil {453		return nil, err454	}455	return list, nil456}457458func GetPod(ctx context.Context, c *Client, namespace, name string) (*Pod, error) {459	pod := &Pod{}460	if err := c.RequestJSON(ctx, http.MethodGet, podsPath(namespace)+"/"+name, nil, pod); err != nil {461		return nil, err462	}463	return pod, nil464}465466func CreatePod(ctx context.Context, c *Client, pod *Pod) (*Pod, error) {467	body, err := json.Marshal(pod)468	if err != nil {469		return nil, err470	}471	created := &Pod{}472	if err := c.RequestJSON(ctx, http.MethodPost, podsPath(pod.Metadata.Namespace), body, created); err != nil {473		return nil, err474	}475	return created, nil476}477478// CreatePlay posts the Play one play request became. The API server479// mints the name from the prefix, because a person may start the same480// title twice and each start is its own Play.481func CreatePlay(ctx context.Context, c *Client, play *Play) (*Play, error) {482	body, err := json.Marshal(play)483	if err != nil {484		return nil, err485	}486	created := &Play{}487	if err := c.RequestJSON(ctx, http.MethodPost, playsPath(play.Metadata.Namespace), body, created); err != nil {488		return nil, err489	}490	return created, nil491}492493// DeletePod removes one pod this operator stands. An494// already-absent pod is success, because the operator deletes a pod to495// replace it and a delete that races another pass must not fail.496func DeletePod(ctx context.Context, c *Client, namespace, name string) error {497	err := c.RequestJSON(ctx, http.MethodDelete, podsPath(namespace)+"/"+name, nil, nil)498	if errors.Is(err, ErrNotFound) {499		return nil500	}501	return err502}503504// GetService reads the live catalog Service of one namespace. The505// read answers the fields the operator compares, and it answers the506// resourceVersion and the addresses the API server assigned, which507// the update carries back unchanged.508func GetService(ctx context.Context, c *Client, namespace, name string) (*Service, error) {509	service := &Service{}510	if err := c.RequestJSON(ctx, http.MethodGet, servicesPath(namespace)+"/"+name, nil, service); err != nil {511		return nil, err512	}513	return service, nil514}515516func CreateService(ctx context.Context, c *Client, service *Service) (*Service, error) {517	body, err := json.Marshal(service)518	if err != nil {519		return nil, err520	}521	created := &Service{}522	path := servicesPath(service.Metadata.Namespace)523	if err := c.RequestJSON(ctx, http.MethodPost, path, body, created); err != nil {524		return nil, err525	}526	return created, nil527}528529// UpdateService writes the whole Service back. The resourceVersion in530// the body makes the write conditional, so a Service that changed531// underneath answers ErrConflict, and the next pass reads it again.532func UpdateService(ctx context.Context, c *Client, service *Service) (*Service, error) {533	body, err := json.Marshal(service)534	if err != nil {535		return nil, err536	}537	written := &Service{}538	path := servicesPath(service.Metadata.Namespace) + "/" + service.Metadata.Name539	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {540		return nil, err541	}542	return written, nil543}
arrival.go 87.1%
1package main23// arrival.go is the arrival ledger as the walk reads it: the time a video4// file arrived, kept in .liken/arrival.yaml beside the files by the arrival5// fact in arrivalfact.go. The time is the inode change time and never the6// modification time, because an importer rewrites the modification time to7// the release date, and user space cannot set the change time, so the8// importer's rewrite stamps it with the real moment of import. The walk9// reads and never writes, because the scan Job mounts the volume read-only.10// This file holds the ledger's shape, its read, the per-folder read the11// walk makes, the change time, and the earliest-of fold.1213import (14	"errors"15	"os"16	"path/filepath"17	"syscall"18	"time"19)2021// The ledger file's name is the arrival fact's own, likenLedgerName of22// factArrival, so the fact's entries and its attempts are one file with one23// writer.24const arrivalLedgerName = "arrival.yaml"2526// The ledger's shape: one entry per video file the folder holds.27type arrivalLedger struct {28	Files []arrivalEntry `yaml:"files"`29}3031// One entry: the file's path relative to the folder, and the time the file32// arrived, RFC 3339 in UTC.33type arrivalEntry struct {34	Path string    `yaml:"path"`35	At   time.Time `yaml:"at"`36}3738// The walk's read of the entries alone, through the fact ledger reader, so39// the attempts beside them are read by the .liken pass and not here. A40// folder with no ledger reads as an empty ledger.41func readArrivalLedger(folder string) (arrivalLedger, error) {42	ledger, err := readLikenLedger(folder, factArrival)43	if err != nil {44		return arrivalLedger{}, err45	}46	return arrivalLedger{Files: ledger.Files}, nil47}4849// What the walk reads for one video. added is the ledger's time, or the50// change time where the ledger holds none. arrived is the ledger's time51// alone, and zero where it holds none, which is what the arrival fact's gap52// reads.53type fileArrival struct {54	added   int6455	arrived int6456}5758// The arrival of each video in a folder, read and never written. A ledger59// that cannot be read is an error the caller marks the pass incomplete60// with, and the change times stand for that pass. The change time is read61// only for a file with no entry, so a re-walk of a settled folder stats62// nothing here.63func folderArrivals(dir string, videos []string) (map[string]fileArrival, error) {64	ledger, readErr := readArrivalLedger(dir)65	held := map[string]int64{}66	for _, entry := range ledger.Files {67		held[entry.Path] = entry.At.Unix()68	}69	arrivals := map[string]fileArrival{}70	var statErr error71	for _, video := range videos {72		if at, known := held[video]; known {73			arrivals[video] = fileArrival{added: at, arrived: at}74			continue75		}76		at, err := changeTime(filepath.Join(dir, video))77		if err != nil {78			statErr = errors.Join(statErr, err)79			continue80		}81		arrivals[video] = fileArrival{added: at}82	}83	if readErr != nil {84		return arrivals, readErr85	}86	return arrivals, statErr87}8889// The inode change time, which is the real moment of import. User space90// cannot set it, so an importer that rewrites the modification time leaves it91// alone. This is the Linux stat, which is where the scanner runs.92func changeTime(path string) (int64, error) {93	var stat syscall.Stat_t94	if err := syscall.Stat(path, &stat); err != nil {95		return 0, &os.PathError{Op: "stat", Path: path, Err: err}96	}97	return stat.Ctim.Sec, nil98}99100// The earlier of two arrivals, where zero means none is known. A series or a101// set takes the first arrival among its members, and a member with none does102// not pull it to zero.103func earliestArrival(held, candidate int64) int64 {104	if candidate == 0 {105		return held106	}107	if held == 0 || candidate < held {108		return candidate109	}110	return held111}
arrivalfact.go 90.7%
1package main23// arrivalfact.go is the arrival fact: the enricher concern that writes the4// arrival ledger. It is a fact and not part of the walk because the scan Job5// mounts the volume read-only and the enrich Jobs mount it read-write. It6// asks no provider, because the file's own change time is the answer. It7// never rewrites an entry that exists, because the ledger is what makes the8// first sighting durable against every later sweep of the volume.910import (11	"context"12	"path/filepath"13	"time"14)1516// The name of the container that runs this fact.17const arrivalContainerName = "arrival"1819// The gap: a present video file whose files.arrived is zero, which is what20// the walk writes for a file the ledger holds no entry for, outside the21// attempt window. The fact's own row write and the next walk both close it.22func arrivalGapSQL() string {23	return `SELECT path FROM files ` +24		`WHERE library = ?1 AND type = '` + fileTypeVideo + `' AND present = 1 ` +25		`AND ` + gapClause(factArrival, "path", `arrived = 0`)26}2728// One folder's work: the folder whose .liken directory holds the ledger, and29// the entry path of each gap file under it, in the gap's order.30type arrivalWork struct {31	folder  string32	entries []string33}3435// The whole run. A catalog read that fails ends the container, because the36// gap list is the work. The gap is grouped by folder, because one folder is37// one file on the volume and one write, however many videos it holds.38func (e *enricher) arrivalFact(ctx context.Context) error {39	paths, err := e.gaps(ctx, factArrival, time.Now().UTC())40	if err != nil {41		return err42	}43	stamped := 044	for _, work := range e.arrivalWork(paths) {45		if err := ctx.Err(); err != nil {46			return err47		}48		stamped += e.stampArrivals(ctx, work)49	}50	e.logf("stamped %d of the %d files with no arrival", stamped, len(paths))51	return nil52}5354// The gap grouped by the folder that holds each file's ledger, in the order55// the gap named the folders. The files this Job's scope does not cover are56// left out.57func (e *enricher) arrivalWork(paths []string) []arrivalWork {58	var works []arrivalWork59	index := map[string]int{}60	for _, path := range paths {61		if !e.inScope(path) {62			continue63		}64		folder, entry := likenFolderFor(e.kind, filepath.Join(e.root, path))65		at, held := index[folder]66		if !held {67			at = len(works)68			index[folder] = at69			works = append(works, arrivalWork{folder: folder})70		}71		works[at].entries = append(works[at].entries, entry)72	}73	return works74}7576// One folder: an entry with the change time for every gap file the ledger77// holds none for, an attempt per file, and one write of the file. An entry78// that exists is kept as it is. Then the rows: files.arrived and the79// folder's item rows, so added follows. A volume that refuses the write is80// an error attempt written straight to the catalog, because the ledger is81// the one file this fact may write and a refused volume cannot hold the82// attempt. That row stands until the next walk sweeps it, so a refused83// volume costs one try per walk and never one per run. The answer is how84// many files gained an entry.85func (e *enricher) stampArrivals(ctx context.Context, work arrivalWork) int {86	now := time.Now().UTC()87	stamped := 088	err := e.writer.updateLikenLedger(work.folder, factArrival, func(ledger *likenLedger) {89		held := map[string]bool{}90		for _, entry := range ledger.Files {91			held[entry.Path] = true92		}93		for _, entry := range work.entries {94			result := attemptFound95			if !held[entry] {96				at, err := changeTime(filepath.Join(work.folder, entry))97				if err != nil {98					e.logf("could not read the change time of %s: %v", entry, err)99					result = attemptError100				} else {101					ledger.Files = append(ledger.Files, arrivalEntry{Path: entry, At: time.Unix(at, 0).UTC()})102					held[entry] = true103					stamped++104				}105			}106			ledger.noteAttempt(likenAttempt{Path: entry, At: now, Result: result})107		}108	})109	if err != nil {110		e.logf("could not write the arrival ledger at %s: %v", relativePath(e.root, work.folder), err)111		e.writeArrivalErrors(ctx, work, now)112		return 0113	}114	e.writeRows(factArrival, work.folder, true)115	return stamped116}117118// The error attempt rows of a folder the volume refused, one per gap file,119// keyed the way the walk keys a file fact's attempt.120func (e *enricher) writeArrivalErrors(ctx context.Context, work arrivalWork, at time.Time) {121	if e.catalog == nil {122		return123	}124	var rows []attemptRow125	for _, entry := range work.entries {126		rows = append(rows, attemptRow{127			Library: e.library,128			Item:    relativePath(e.root, filepath.Join(work.folder, entry)),129			Fact:    factArrival,130			At:      at.Unix(),131			Result:  attemptError,132		})133	}134	if _, err := e.catalog.UpsertAttempts(ctx, rows); err != nil {135		e.logf("could not write the %s attempt rows of %s: %v", factArrival, relativePath(e.root, work.folder), err)136	}137}
artanswer.go 100.0%
1package main23// The seam between one art fact and the providers that can answer it. The art4// group follows one rule: art is a single value, so the first block in the5// Library's sources that answers with an image wins, and no merge follows,6// where the nfo facts of a set take a union.78import (9	"context"10)1112// One image a provider offers for one art fact: the address the fetch reads,13// the language of the text in the image, and the count of votes behind it.14// The votes are TMDb's score or Fanart.tv's likes, so they order one15// provider's list and are never read across two providers.16type artCandidate struct {17	URL      string18	Language string19	Votes    float6420}2122// One provider block, asked for one gap and one art fact. It answers the23// images it holds, and a provider that holds none for that title answers no24// image and no error. The download is the provider's own, so a file takes the25// same retry rule the provider's calls take.26type artAnswerer interface {27	providerBlock() string28	serves(fact string) bool29	candidates(ctx context.Context, fact string, gap artGap, title titleRef) ([]artCandidate, error)30	fetchFile(ctx context.Context, address string) ([]byte, error)31}3233// The answerers the art container can ask, in order.34type artLine struct {35	answerers []artAnswerer36}3738// The line is built in the order LIBRARY_SOURCES names the blocks, which is39// the Library's own spec.sources order, and the rule for who answers reads40// that order. A block this image has no answerer for yet, and a block whose41// key did not reach the container, are both skipped with no error.42func newArtLine(blocks []string, value func(string) string) *artLine {43	line := &artLine{}44	for _, block := range blocks {45		token := value(providerTokenVariable(block))46		switch {47		case block == providerBlockTMDb && token != "":48			line.answerers = append(line.answerers, newTMDbArtAnswerer(newTMDbClient(tmdbAPIBase, token)))49		case block == providerBlockFanart && token != "":50			line.answerers = append(line.answerers, fanartArtAnswerer{client: newFanartClient(fanartAPIBase, token)})51		case block == providerBlockTVmaze:52			line.answerers = append(line.answerers, newTVmazeArtAnswerer(newTVmazeClient(tvmazeAPIBase)))53		}54	}55	return line56}5758// A fact with no answerer left has nothing to ask, so the titles that remain59// keep their gaps for the next run.60func (l *artLine) live(fact string) bool {61	for _, one := range l.answerers {62		if one.serves(fact) {63			return true64		}65	}66	return false67}6869// One gap's ask: every answerer that serves the fact, in order, until one of70// them holds an image, and that answerer is the one the download and the71// ledger name. An error does not end the ask, because a provider that is down72// leaves the blocks behind it their answer; the error is reported only where73// no block answered at all.74func (l *artLine) ask(ctx context.Context, fact string, gap artGap,75	title titleRef) (artAnswerer, []artCandidate, error) {76	var failure error77	for _, one := range l.answerers {78		if !one.serves(fact) {79			continue80		}81		candidates, err := one.candidates(ctx, fact, gap, title)82		if err != nil {83			if failure == nil {84				failure = err85			}86			continue87		}88		if len(candidates) > 0 {89			return one, candidates, nil90		}91	}92	return nil, nil, failure93}9495// The choice: the highest-voted image in the library's own language, then the96// highest-voted image with no language, then the highest-voted image of any97// language. Art with the title's own text is what a person reads on the98// screen, and art with no text reads in every language.99func chooseArt(candidates []artCandidate, language string) (artCandidate, bool) {100	for _, want := range []string{language, ""} {101		if candidate, held := bestArt(candidates, func(candidate artCandidate) bool {102			return candidate.Language == want103		}); held {104			return candidate, true105		}106	}107	return bestArt(candidates, func(artCandidate) bool { return true })108}109110// The highest-voted image the test admits. The first of two equal votes wins,111// which keeps the provider's own order.112func bestArt(candidates []artCandidate, admits func(artCandidate) bool) (artCandidate, bool) {113	best := artCandidate{}114	held := false115	for _, candidate := range candidates {116		if candidate.URL == "" || !admits(candidate) {117			continue118		}119		if !held || candidate.Votes > best.Votes {120			best, held = candidate, true121		}122	}123	return best, held124}
artfacts.go 100.0%
1package main23// The art phase's own table: the local name each fact writes, the TMDb list4// it reads, the size it fetches, and the gap query that says which files the5// library has none of. The fact names are in factnames.go, and the maps in6// enrich.go, factsrole.go, and attempts.go name these facts as they name7// every other.89import (10	"context"11	"fmt"12	"path/filepath"13	"strings"14)1516// The art facts this image can fill, in the order the art container names17// them in LIBRARY_FACTS: the title's own art first, then the season's, then18// the episode's.19var artFactNames = []string{20	factPoster, factBackdrop, factLogo, factClearart, factBanner,21	factLandscape, factDiscart, factSeasonPoster, factSeasonBanner, factEpisodeThumb,22}2324// The art facts the Library's own sources serve, in the order the group runs25// them. A Library whose sources hold one provider asks for what that provider26// serves and no more.27func servedArtFacts(library *Library, providers providerSet) []string {28	var served []string29	for _, fact := range artFactNames {30		if !artTypes[fact].holds(library.Spec.Kind) {31			continue32		}33		if providers.serving(library.Metadata.Namespace, library.Spec.Sources, fact) != nil {34			served = append(served, fact)35		}36	}37	return served38}3940// One art type. The file is the name Kodi and Jellyfin read beside the title,41// from https://kodi.wiki/view/Artwork and42// https://jellyfin.org/docs/general/server/media/movies/, both read on43// 2026-09-03. For the two season facts the file is the last part of the name44// alone, because the season number leads it, and the episode thumbnail carries45// no file at all, because it is named for its episode file. The kind is the46// one kind of library that carries this art, and empty for the art both kinds47// carry. The list is the TMDb array the fact reads, and the size is the one it48// fetches.49type artType struct {50	fact string51	file string52	kind string53	list string54	size string55}5657// Whether a library of this kind carries this art at all. A disc is a58// movie's, so a series is never a discart gap, and a series library never59// names the fact in its container.60func (t artType) holds(kind string) bool {61	return t.kind == "" || t.kind == kind62}6364// The ten types, with the sizes this project fetches from TMDb. The five65// names this wave adds come from the Kodi forum's artwork naming thread at66// https://forum.kodi.tv/showthread.php?tid=248825 and from67// https://kodi.wiki/view/Artwork/Season, both read on 2026-09-03. These sizes68// and not the original, because the browser of plan 22 draws on a 1080p panel69// and holds every decoded image in memory, and a raster over 2 MiB draws in70// bands. The sizes are the open decision plan 30 lists under what is not71// decided.72var artTypes = map[string]artType{73	factPoster:       {fact: factPoster, file: "poster.jpg", list: tmdbPosters, size: "w780"},74	factBackdrop:     {fact: factBackdrop, file: "fanart.jpg", list: tmdbBackdrops, size: "w1280"},75	factLogo:         {fact: factLogo, file: "clearlogo.png", list: tmdbLogos, size: "w500"},76	factClearart:     {fact: factClearart, file: "clearart.png"},77	factBanner:       {fact: factBanner, file: "banner.jpg"},78	factLandscape:    {fact: factLandscape, file: "landscape.jpg"},79	factDiscart:      {fact: factDiscart, file: "disc.png", kind: libraryKindMovies},80	factSeasonPoster: {fact: factSeasonPoster, file: "poster.jpg", list: tmdbPosters, size: "w780"},81	factSeasonBanner: {fact: factSeasonBanner, file: "banner.jpg"},82	factEpisodeThumb: {fact: factEpisodeThumb, list: tmdbStills, size: "w300"},83}8485// The start of the name Kodi reads for the art of season zero, which holds86// the specials.87const specialsSeasonPrefix = "season-specials-"8889// The name of one season's art. Kodi reads it in the series folder beside90// tvshow.nfo, not in the season folder.91func seasonArtName(season int, suffix string) string {92	if season == 0 {93		return specialsSeasonPrefix + suffix94	}95	return fmt.Sprintf("season%02d-%s", season, suffix)96}9798// The name of one episode's thumbnail: the episode file's own name with the99// extension replaced, which is what both players read.100func episodeThumbName(video string) string {101	base := filepath.Base(video)102	return strings.TrimSuffix(base, filepath.Ext(base)) + "-thumb.jpg"103}104105// The file one gap writes, relative to the folder that holds it. The facts of106// a title key on the file name itself, the two season facts key on the season107// number, and the episode thumbnail keys on the episode file it goes beside.108func (t artType) fileFor(gap artGap) string {109	switch t.fact {110	case factSeasonPoster, factSeasonBanner:111		return seasonArtName(gap.season, t.file)112	case factEpisodeThumb:113		return episodeThumbName(gap.key)114	default:115		return t.file116	}117}118119// How much memory the art container may take. It is above the scanner's120// because this container holds one image in memory while it writes it, where121// every other container holds one row at a time.122const artMemoryLimit = "256Mi"123124// The name of the container that runs the art facts.125const artContainerName = "art"126127// What the enricher records as the answer for a file another tool already128// wrote. The ledger says so, and the file is never opened.129const artProviderExisting = "existing"130131// The language the choice prefers. A Library declares no language yet, so132// this is the one the project's own libraries hold. The field belongs on the133// Library.134const artLanguage = "en"135136// One gap: the key the attempt is recorded under, the TMDb id of the title,137// and the season and episode numbers where the fact needs them. The key is138// the path of the file the fact writes, or the episode file the thumbnail139// goes beside, so a gap that is already filled leaves the list.140type artGap struct {141	key     string142	tmdb    string143	season  int144	episode int145}146147// The folder the file lands in and the entry the ledger keys on. Both come148// off the key, so one rule places the file, the ledger, and the attempt.149func (g artGap) folder() string {150	return filepath.Dir(g.key)151}152153func (g artGap) entry() string {154	return filepath.Base(g.key)155}156157// One fact's run, bound to its name, so every art fact runs the same loop158// over its own gap.159func artFactRun(fact string) factRun {160	return func(ctx context.Context, e *enricher) error { return e.artFact(ctx, fact) }161}162163// The gap query of the title's own art, over the movies and the series of one164// library. A gap is a title with a TMDb id and no file of that name in the165// catalog, outside the retry window. The query asks for the id because a gap166// the enricher cannot close would schedule a Job every pass for ever, so a167// title no provider can name is no gap. The join onto aliases is what reads168// the TMDb id of a series or a movie whose own id is under another scheme.169func titleArtGapSQL(fact string) string {170	art := artTypes[fact]171	branch := func(table, scope string) string {172		return `SELECT t.library AS library, t.path || '/` + art.file + `' AS file, ` +173			`substr(a.alias, length('` + scope + `:tmdb:') + 1) AS tmdb ` +174			`FROM ` + table + ` AS t JOIN aliases AS a ` +175			`ON a.library = t.library AND a.item = t.id AND a.alias LIKE '` + scope + `:tmdb:%'`176	}177	// The branches are the kinds this art belongs to, so the disc art reads178	// the movies and never the series.179	branches := []string{}180	if art.holds(libraryKindMovies) {181		branches = append(branches, branch("movies", scopeMovie))182	}183	if art.holds(libraryKindSeries) {184		branches = append(branches, branch("series", scopeSeries))185	}186	return `SELECT file, tmdb, 0, 0 FROM (` + strings.Join(branches, ` UNION ALL `) +187		`) AS wanted WHERE library = ?1 AND ` + gapClause(fact, "file", artFileClause())188}189190// The name of one season's art file, as a query builds it from the series191// path and the episode's season number. The gap query and the release date of192// a season both key on this name, so one expression writes it.193func seasonArtFileSQL(suffix string) string {194	return `s.path || '/' || CASE WHEN e.season = 0 THEN '` + specialsSeasonPrefix + suffix + `' ` +195		`ELSE printf('season%02d-` + suffix + `', e.season) END`196}197198// One row per season a library holds episodes of, because the catalog keeps199// no season item. The art lands in the series folder under Kodi's own name.200func seasonArtGapSQL(fact string) string {201	suffix := artTypes[fact].file202	return `SELECT file, tmdb, season, 0 FROM (` +203		`SELECT DISTINCT s.library AS library, ` +204		seasonArtFileSQL(suffix) + ` AS file, ` +205		`substr(a.alias, length('` + scopeSeries + `:tmdb:') + 1) AS tmdb, e.season AS season ` +206		`FROM episodes AS e ` +207		`JOIN series AS s ON s.library = e.library AND s.id = e.series ` +208		`JOIN aliases AS a ON a.library = s.library AND a.item = s.id ` +209		`AND a.alias LIKE '` + scopeSeries + `:tmdb:%'` +210		`) AS wanted WHERE library = ?1 AND ` + gapClause(fact, "file", artFileClause())211}212213// One row per episode with no image of its own. The episode keys on its own214// file, because the thumbnail is named for that file. The check reads the215// link table, so an image another tool named differently still counts as the216// episode's.217// The episodes that hold an image are one list built once from the library's218// files, never a subquery that reads the outer row, because that form walked219// every file of the library once per episode and took forty seconds.220func episodeThumbGapSQL() string {221	return `SELECT video, tmdb, season, episode FROM (` +222		`SELECT e.library AS library, e.path AS video, e.id AS item, ` +223		`substr(a.alias, length('` + scopeSeries + `:tmdb:') + 1) AS tmdb, ` +224		`e.season AS season, e.episode AS episode ` +225		`FROM episodes AS e ` +226		`JOIN series AS s ON s.library = e.library AND s.id = e.series ` +227		`JOIN aliases AS a ON a.library = s.library AND a.item = s.id ` +228		`AND a.alias LIKE '` + scopeSeries + `:tmdb:%'` +229		`) AS wanted WHERE library = ?1 AND ` + gapClause(factEpisodeThumb, "video",230		`item NOT IN (SELECT fi.item FROM file_items AS fi `+231			`JOIN files AS f ON f.library = fi.library AND f.path = fi.path `+232			`WHERE fi.library = ?1 `+233			`AND f.type = '`+fileTypeImage+`' `+234			`AND f.role IN ('`+fileRoleThumb+`', '`+fileRoleStill+`'))`)235}236237// Where each art fact's items carry their release date. A title's art reads238// the title's own, a season's art reads the earliest episode of that season,239// which is the day the season starts, and the episode thumbnail reads the240// episode. A season whose episodes carry no date at all reads as no date,241// because min ignores the null the empty column becomes.242func artReleaseDates(fact string) string {243	art := artTypes[fact]244	switch fact {245	case factEpisodeThumb:246		return `SELECT library, path AS item, released FROM episodes WHERE library = ?1`247	case factSeasonPoster, factSeasonBanner:248		return `SELECT e.library AS library, ` + seasonArtFileSQL(art.file) + ` AS item, ` +249			`min(nullif(e.released, '')) AS released FROM episodes AS e ` +250			`JOIN series AS s ON s.library = e.library AND s.id = e.series ` +251			`WHERE e.library = ?1 GROUP BY e.library, s.path, e.season`252	default:253		return titleReleaseDates(`path || '/` + art.file + `'`)254	}255}256257// The file the fact would write is not in the catalog. This is the whole of258// "written where none exists" as the catalog can answer it, and the container259// checks the volume itself before it writes. The library is the first bound260// parameter by number, never the derived table's own column, because a261// subquery that reads the outer row is run again for every title, and this262// one over a library's files took seven seconds a fact.263func artFileClause() string {264	return `file NOT IN (SELECT path FROM files WHERE files.library = ?1)`265}
artimages.go 100.0%
1package main23// What the art facts ask TMDb: the image lists of a title, a season, and an4// episode, and the configuration that names the image host and the sizes.5// The answerer in tmdbart.go turns them into candidates, and the choice among6// those candidates is in artanswer.go. The download itself is the shared one in providerhttp.go, so a 429 from the7// image host waits the way a 429 from the API does.89import (10	"context"11	"fmt"12	"strconv"13	"strings"14)1516// The arrays TMDb answers images in. A movie and a series answer posters,17// backdrops, and logos, a season answers posters, and an episode answers18// stills.19const (20	tmdbPosters   = "posters"21	tmdbBackdrops = "backdrops"22	tmdbLogos     = "logos"23	tmdbStills    = "stills"24)2526// One image as TMDb states it. The language is null for an image with no text27// in it, which is why the choice below reads a null language as its second28// preference and never as a miss.29type tmdbImage struct {30	FilePath    string  `json:"file_path"`31	Language    string  `json:"iso_639_1"`32	VoteAverage float64 `json:"vote_average"`33	VoteCount   int     `json:"vote_count"`34}3536// One images answer, with every array the four endpoints return, so one type37// reads a movie, a series, a season, and an episode.38type tmdbImageAnswer struct {39	Posters   []tmdbImage `json:"posters"`40	Backdrops []tmdbImage `json:"backdrops"`41	Logos     []tmdbImage `json:"logos"`42	Stills    []tmdbImage `json:"stills"`43}4445func (a tmdbImageAnswer) list(name string) []tmdbImage {46	switch name {47	case tmdbPosters:48		return a.Posters49	case tmdbBackdrops:50		return a.Backdrops51	case tmdbLogos:52		return a.Logos53	default:54		return a.Stills55	}56}5758// What /configuration says about images: the host every file path hangs off,59// and the sizes each kind of image is served in. The enricher reads it once60// per fact and never asks for a size TMDb does not serve.61type tmdbConfiguration struct {62	Images struct {63		SecureBaseURL string   `json:"secure_base_url"`64		PosterSizes   []string `json:"poster_sizes"`65		BackdropSizes []string `json:"backdrop_sizes"`66		LogoSizes     []string `json:"logo_sizes"`67		StillSizes    []string `json:"still_sizes"`68	} `json:"images"`69}7071// The sizes of one list, as the configuration names them.72func (c tmdbConfiguration) sizes(list string) []string {73	switch list {74	case tmdbPosters:75		return c.Images.PosterSizes76	case tmdbBackdrops:77		return c.Images.BackdropSizes78	case tmdbLogos:79		return c.Images.LogoSizes80	default:81		return c.Images.StillSizes82	}83}8485// The size the fetch asks for: the type's own size where TMDb serves it, and86// the original where it does not, because every kind of image is served in87// the original.88const tmdbOriginalSize = "original"8990func (c tmdbConfiguration) sizeFor(art artType) string {91	for _, size := range c.sizes(art.list) {92		if size == art.size {93			return art.size94		}95	}96	return tmdbOriginalSize97}9899// The address of one image: the host, the size, and the path TMDb gave.100func (c tmdbConfiguration) imageURL(size, filePath string) string {101	return strings.TrimSuffix(c.Images.SecureBaseURL, "/") + "/" + size + filePath102}103104// The one read of the provider's own settings.105func (c *tmdbClient) configuration(ctx context.Context) (tmdbConfiguration, error) {106	var answer tmdbConfiguration107	if err := c.get(ctx, tmdbConfigurationPath, nil, &answer); err != nil {108		return tmdbConfiguration{}, err109	}110	if answer.Images.SecureBaseURL == "" {111		return tmdbConfiguration{}, fmt.Errorf("tmdb %s: the answer names no image host", tmdbConfigurationPath)112	}113	return answer, nil114}115116// The images of one gap, from the endpoint its fact reads: a movie, a series,117// one of its seasons, or one of its episodes.118func (c *tmdbClient) images(ctx context.Context, kind, fact string, gap artGap) (tmdbImageAnswer, error) {119	var answer tmdbImageAnswer120	if err := c.get(ctx, tmdbImagesPath(kind, fact, gap), nil, &answer); err != nil {121		return tmdbImageAnswer{}, err122	}123	return answer, nil124}125126// Which endpoint one fact reads. The season and the episode hang off the127// series, and the title's own art follows the kind of the library.128func tmdbImagesPath(kind, fact string, gap artGap) string {129	series := "/3/tv/" + gap.tmdb130	switch fact {131	case factSeasonPoster:132		return series + "/season/" + strconv.Itoa(gap.season) + "/images"133	case factEpisodeThumb:134		return series + "/season/" + strconv.Itoa(gap.season) +135			"/episode/" + strconv.Itoa(gap.episode) + "/images"136	}137	if kind == libraryKindSeries {138		return series + "/images"139	}140	return "/3/movie/" + gap.tmdb + "/images"141}
artrole.go 96.5%
1package main23// The art container's run: what it reads out of the catalog for one art fact,4// how it answers a file that already exists, and what it writes where none5// does. An art fact never opens a file another tool wrote, so a poster6// Jellyfin wrote stays as it is, and the ledger records that the file was7// already there.89import (10	"context"11	"fmt"12	"os"13	"path/filepath"14	"time"15)1617// The line is built once for the container, so the settings one provider18// states are read once for every art fact the container runs. A container with19// no answerer at all is a manifest to repair, because the operator creates it20// only where a source serves one of its facts.21func (e *enricher) artFact(ctx context.Context, fact string) error {22	if e.art == nil {23		e.art = newArtLine(commaNames(os.Getenv(librarySourcesVariable)), os.Getenv)24	}25	if len(e.art.answerers) == 0 {26		return fmt.Errorf("no provider key reached this container, and the %s fact cannot ask without one", fact)27	}28	return e.artGap(ctx, fact, e.art)29}3031// A catalog read that fails ends the container, because the gap list is the32// work. A fact no answerer in the line serves reads no gap at all, so a33// Library whose sources hold one provider costs nothing for the art that34// provider does not serve. A provider that refuses one image records an error35// attempt, and the run carries on to the next.36func (e *enricher) artGap(ctx context.Context, fact string, line *artLine) error {37	if !line.live(fact) {38		return nil39	}40	gaps, err := e.catalog.artGaps(ctx, e.library, fact, time.Now().UTC(), e.refresh[fact])41	if err != nil {42		return err43	}44	written := 045	for _, gap := range gaps {46		if err := ctx.Err(); err != nil {47			return err48		}49		if !e.inScope(gap.key) {50			continue51		}52		if e.artOne(ctx, line, artTypes[fact], gap) {53			written++54		}55	}56	e.logf("wrote %d of the %d %s files the library had none of", written, len(gaps), fact)57	return nil58}5960// One gap. The volume is read before the provider is asked, because a file61// that landed since the last walk is the answer already and costs no call.62// Then one image is chosen, downloaded, and created, and the ledger records63// which provider answered.64func (e *enricher) artOne(ctx context.Context, line *artLine, art artType, gap artGap) bool {65	folder := filepath.Join(e.root, gap.folder())66	target := filepath.Join(folder, art.fileFor(gap))67	if held, err := fileExists(target); err != nil {68		e.logf("could not read %s: %v", target, err)69		e.recordArt(folder, art.fact, gap.entry(), "", attemptError)70		return false71	} else if held {72		e.recordArt(folder, art.fact, gap.entry(), artProviderExisting, attemptFound)73		return false74	}7576	answerer, candidates, err := line.ask(ctx, art.fact, gap, e.artTitle(gap))77	if err != nil {78		e.logf("could not read the %s of %s: %v", art.fact, gap.key, err)79		e.recordArt(folder, art.fact, gap.entry(), "", attemptError)80		return false81	}82	image, held := chooseArt(candidates, artLanguage)83	if answerer == nil || !held {84		e.logf("no provider holds the %s of %s", art.fact, gap.key)85		e.recordArt(folder, art.fact, gap.entry(), "", attemptNothing)86		return false87	}88	return e.writeArt(ctx, answerer, art, gap, folder, target, image)89}9091// The ids a fact asks with come off the sidecar itself, which is where the92// identity fact wrote every one of them. The art phase reads them because the93// gap carries the TMDb id alone and two of the three providers key on another94// id. A folder with no sidecar carries no id, which leaves those two providers95// no answer and is not an error.96func (e *enricher) artTitle(gap artGap) titleRef {97	sidecar, _ := identitySidecar(e.kind, filepath.Join(e.root, gap.folder()))98	document, err := os.ReadFile(sidecar)99	if err != nil {100		return titleRef{kind: e.kind}101	}102	return titleRef{kind: e.kind, ids: sidecarIDs(document)}103}104105// The download and the write. The bytes live from the answer to the rename106// and no longer. A create that finds the file there answers as the read above107// does, because another writer reached it first.108func (e *enricher) writeArt(ctx context.Context, answerer artAnswerer, art artType, gap artGap,109	folder, target string, image artCandidate) bool {110	data, err := answerer.fetchFile(ctx, image.URL)111	if err != nil {112		e.logf("could not read %s: %v", image.URL, err)113		e.recordArt(folder, art.fact, gap.entry(), "", attemptError)114		return false115	}116	written, err := e.writer.createOnce(target, data)117	if err != nil {118		e.logf("could not write %s: %v", target, err)119		e.recordArt(folder, art.fact, gap.entry(), "", attemptError)120		return false121	}122	if !written {123		e.recordArt(folder, art.fact, gap.entry(), artProviderExisting, attemptFound)124		return false125	}126	e.logf("wrote the %s of %s from %s", art.fact, gap.key, answerer.providerBlock())127	e.recordArt(folder, art.fact, gap.entry(), answerer.providerBlock(), attemptFound)128	return true129}130131// The item entry and the attempt are one write of one file, so a reader never132// sees an answer without its attempt. A provider name of nothing is a miss or133// an error, which the attempt itself states.134func (e *enricher) recordArt(folder, fact, entry, provider, result string) {135	now := time.Now().UTC()136	err := e.writer.updateLikenLedger(folder, fact, func(ledger *likenLedger) {137		if provider != "" {138			item := likenItem{Path: entry, Provider: providerNames{provider}}139			if provider != artProviderExisting {140				item.Written = now141			}142			ledger.noteItem(item)143		}144		ledger.noteAttempt(likenAttempt{Path: entry, At: now, Result: result})145	})146	if err != nil {147		e.logf("could not record the %s attempt at %s: %v", fact, entry, err)148	}149	e.writeRows(fact, folder, result == attemptFound)150}151152// One art fact's work list, out of the local copy of the catalog, with the153// same query the reporter counts the gap with. Every row names the file to154// write, the TMDb id to ask for, and the season and episode where the fact155// needs them.156func (c *Catalog) artGaps(ctx context.Context, library, fact string,157	now, refresh time.Time) ([]artGap, error) {158	var gaps []artGap159	err := c.stream(ctx, gapQueries[fact], gapParams(fact, library, now, refresh), func(cells []any) error {160		if len(cells) < 4 {161			return nil162		}163		key, _ := cells[0].(string)164		id, _ := cells[1].(string)165		if key == "" || id == "" {166			return nil167		}168		gaps = append(gaps, artGap{169			key:     key,170			tmdb:    id,171			season:  int(cellNumber(cells[2])),172			episode: int(cellNumber(cells[3])),173		})174		return nil175	})176	if err != nil {177		return nil, fmt.Errorf("reading the %s gap of %s: %w", fact, library, err)178	}179	return gaps, nil180}
attempts.go 96.1%
1package main23// attempts.go is the attempts table's Go side: the row, the writes, and how4// the scanner lifts a folder's .liken files into the rows the gap queries5// read. The rows are derived from the volume, so a lost catalog gets them6// back on the next walk.78import (9	"context"10	"path/filepath"11	"strings"12	"time"13)1415// The column of the attempts table that holds the fact. Corrosion applies the16// difference between the schema file and the database on every start. It adds17// tables, columns, and indexes, and it refuses to remove a column. This18// column is also part of the primary key, and Corrosion refuses to add a19// primary key column to a table that exists. So the column keeps the name it20// was created with, every Go name is fact, and this constant is the one place21// the two meet.22const attemptFactColumn = "concern"2324// One enricher's last attempt at one item, as the attempts table holds it.25// For a file fact the item is the file's path under the library root.26type attemptRow struct {27	Library string28	Item    string29	Fact    string30	At      int6431	Result  string32	// The provider block that answered, empty for a fact that asks no provider.33	// A set fact joins the blocks it took the union of with commas.34	Provider string35}3637// One attempts row, by the two key columns that follow the library.38type attemptKey struct {39	Item string40	Fact string41}4243// A repeat write updates the row in place, because one item and one fact44// hold one attempt, the latest. The update names no key column, so the row's45// identity never moves.46func (c *Catalog) UpsertAttempts(ctx context.Context, rows []attemptRow) (int, error) {47	statements := make([]statement, len(rows))48	for i, row := range rows {49		statements[i] = statement{50			sql: `INSERT INTO attempts (library, item, ` + attemptFactColumn + `, at, result, provider) VALUES (?, ?, ?, ?, ?, ?) ` +51				`ON CONFLICT (library, item, ` + attemptFactColumn + `) DO UPDATE SET at = excluded.at, ` +52				`result = excluded.result, provider = excluded.provider`,53			params: []any{row.Library, row.Item, row.Fact, row.At, row.Result, row.Provider},54		}55	}56	return c.apply(ctx, statements)57}5859// The delete names all three key columns, as the link table's delete does, so60// a sweep takes exactly the rows it marked.61func (c *Catalog) DeleteAttempts(ctx context.Context, library string, keys []attemptKey) (int, error) {62	statements := make([]statement, len(keys))63	for i, key := range keys {64		statements[i] = statement{65			sql:    `DELETE FROM attempts WHERE library = ? AND item = ? AND ` + attemptFactColumn + ` = ?`,66			params: []any{library, key.Item, key.Fact},67		}68	}69	return c.apply(ctx, statements)70}7172// The two key columns travel as one string through a sweep, joined by a73// separator no path or id holds, so the sweep's mark table keeps one column74// for every table it covers.75func attemptKeys(keys []string) []attemptKey {76	out := make([]attemptKey, len(keys))77	for i, key := range keys {78		item, fact, _ := strings.Cut(key, linkKeySeparator)79		out[i] = attemptKey{Item: item, Fact: fact}80	}81	return out82}8384func attemptSeenKey(row attemptRow) string {85	return row.Item + linkKeySeparator + row.Fact86}8788// Reads the attempts this library holds that the current epoch did not mark,89// one bounded batch, with the two key columns joined the way the mark joined90// them.91func attemptPruneSQL() string {92	return `SELECT item || char(31) || ` + attemptFactColumn + ` FROM attempts` +93		` WHERE library = ?` +94		` AND '` + seenAttempt + `' || item || char(31) || ` + attemptFactColumn +95		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +96		` AND at < ?` +97		` LIMIT ?`98}99100// How a rescan reaches one folder's attempts: a file fact keys on a path101// under the folder, and an item fact keys on the id of an item the folder102// holds.103func scopedAttemptPruneSQL() string {104	scope := func(table string) string {105		return `SELECT id FROM ` + table + ` WHERE library = ? AND ` + pathScopeClause("path")106	}107	return `SELECT item || char(31) || ` + attemptFactColumn + ` FROM attempts` +108		` WHERE library = ?` +109		` AND '` + seenAttempt + `' || item || char(31) || ` + attemptFactColumn +110		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +111		` AND (` + pathScopeClause("item") +112		` OR item IN (` + scope("movies") + ` UNION ` + scope("series") + ` UNION ` + scope("episodes") + `))` +113		` AND at < ?` +114		` LIMIT ?`115}116117func scopedAttemptPruneParams(library, folder string, epoch int64) []any {118	params := []any{library, epoch}119	params = append(params, pathScopeParams(folder)...)120	for range 3 {121		params = append(params, library)122		params = append(params, pathScopeParams(folder)...)123	}124	return append(params, walkStart(epoch), pruneBatch)125}126127// What one folder's .liken directory means to the scanner: which item the128// folder's own entry names, and which item each file under it names.129type likenSidecar struct {130	root    string131	dir     string132	library string133	item    string134	items   map[string]string135	// The facts whose ledgers this folder can hold. A title folder holds the136	// title's own, which is the list below, and a person's directory holds the137	// three contributor facts and no other.138	facts []string139}140141// The facts this folder is read for, which is the title list where the caller142// names none.143func (s likenSidecar) ledgerFacts() []string {144	if s.facts != nil {145		return s.facts146	}147	return likenFacts148}149150// The facts the scanner lifts out of a folder. A file fact keys on a151// path, because it works per file, and the identity fact keys on an item152// id, because it works per title.153var likenFacts = []string{factProbe, factArrival, factTrickplay, factIdentity,154	factOverview, factCertification,155	factRatingTMDb, factRatingIMDb, factRatingRottenTomatoes, factRatingMetacritic,156	factCredits,157	factPoster, factBackdrop, factLogo, factClearart, factBanner,158	factLandscape, factDiscart, factSeasonPoster, factSeasonBanner, factEpisodeThumb}159160// Reads every .liken file the folder holds into attempts rows. A folder that161// holds none reads as no rows and not as an error, because most folders hold162// none.163// One pass answers for both kinds of row, because the credits ledger the164// credits fact wrote is one of the files this pass already opens.165func (s likenSidecar) read() ([]attemptRow, []creditRow, error) {166	var rows []attemptRow167	var credits []creditRow168	for _, fact := range s.ledgerFacts() {169		ledger, err := readLikenLedger(s.dir, fact)170		if err != nil {171			return rows, credits, err172		}173		if fact == factCredits {174			credits = append(credits, creditRows(s.library, s.item, ledger.Credits)...)175		}176		for _, attempt := range ledger.Attempts {177			item := s.itemOf(fact, attempt.Path)178			if item == "" || attempt.Result == "" {179				continue180			}181			rows = append(rows, attemptRow{182				Library:  s.library,183				Item:     item,184				Fact:     fact,185				At:       attempt.At.Unix(),186				Result:   attempt.Result,187				Provider: strings.Join(attempt.Provider, ","),188			})189		}190	}191	return rows, credits, nil192}193194// How an entry's path resolves: a file fact names the file itself, and an195// item fact names the title the folder holds.196func (s likenSidecar) itemOf(fact, path string) string {197	if _, art := artTypes[fact]; fact == factProbe || fact == factArrival || fact == factTrickplay || art {198		return relativePath(s.root, filepath.Join(s.dir, path))199	}200	if path == likenSelfPath || path == "" {201		return s.item202	}203	return s.items[path]204}205206// A folder whose .liken files cannot be read marks the pass incomplete, the207// way an unreadable sidecar does, so the sweep never removes rows the volume208// still holds.209func readLikenSidecar(sidecar likenSidecar, result *walkResult) {210	rows, credits, err := sidecar.read()211	result.noteReadError(err)212	result.attempts = append(result.attempts, rows...)213	result.credits = append(result.credits, credits...)214}215216// The reporter counts a gap with the same query the container works from, so217// the number the operator schedules on and the rows the container finds are218// one set.219//220// The reporter runs in the catalog pod, which reads no Library, so it221// counts with no refresh time and publishes the oldest attempt of each222// fact beside the counts. The operator holds the Library and reads the223// two together.224func (c *Catalog) gapCounts(ctx context.Context, library string, now time.Time) (map[string]int, error) {225	counts := map[string]int{}226	for fact, query := range gapQueries {227		count, err := c.queryInt(ctx, `SELECT count(*) FROM (`+query+`)`,228			gapParams(fact, library, now, time.Time{}))229		if err != nil {230			return nil, err231		}232		counts[fact] = count233	}234	return counts, nil235}236237// The oldest attempt one library holds for each fact, which is what238// says whether a refresh time has work left: an attempt older than the refresh239// is a title that fact asks about again.240// A fact with no attempt at all has no entry.241const oldestAttemptQuery = `SELECT ` + attemptFactColumn + `, min(at) FROM attempts ` +242	`WHERE library = ? GROUP BY ` + attemptFactColumn243244// The oldest attempt per fact, read with one statement, because a245// statement per fact is one round trip per fact on every report.246func (c *Catalog) oldestAttempts(ctx context.Context,247	library string) (map[string]time.Time, error) {248	oldest := map[string]time.Time{}249	err := c.stream(ctx, oldestAttemptQuery, []any{library}, func(cells []any) error {250		if len(cells) < 2 {251			return nil252		}253		fact, _ := cells[0].(string)254		if fact == "" {255			return nil256		}257		oldest[fact] = time.Unix(int64(cellNumber(cells[1])), 0).UTC()258		return nil259	})260	if err != nil {261		return nil, err262	}263	return oldest, nil264}265266// The fights of one library: the attempts that found an element group another267// writer had changed, over every fact. A person reads it on the Library, and268// the repair is to stop the other writer.269func (c *Catalog) fightCount(ctx context.Context, library string) (int, error) {270	return c.queryInt(ctx, fightsQuery, []any{library})271}272273// The two counts a person reads on the Library beside the gaps: the titles274// that wait for a person, and the titles no provider could name.275func (c *Catalog) identityCounts(ctx context.Context, library string) (int, int, error) {276	waiting, err := c.queryInt(ctx, waitingQuery, []any{library})277	if err != nil {278		return 0, 0, err279	}280	unresolved, err := c.queryInt(ctx, unresolvedQuery, []any{library})281	if err != nil {282		return 0, 0, err283	}284	return waiting, unresolved, nil285}
availability.go 100.0%
1package main23// One namespace's reporter is either on the bus or it is not,4// and that one signal stands for every Library of the namespace,5// because the catalog pod is the one process that reports them all.67import "sync"89// The desk that holds the last availability the bus carried for10// each namespace's reporter, keyed by namespace, with the loop's own11// wake beside it.12type reporters struct {13	mutex  sync.Mutex14	online map[string]bool15	wake   chan<- struct{}16}1718func newReporters(wake chan<- struct{}) *reporters {19	return &reporters{online: map[string]bool{}, wake: wake}20}2122// A change of the flag wakes the loop, so the pass that answers23// it reads the Libraries beside the reporter that just arrived or left;24// a repeat wakes nothing, because a reconnecting reporter republishes25// online on every session and the pass has already run.26func (r *reporters) mark(namespace string, online bool) {27	r.mutex.Lock()28	previous, had := r.online[namespace]29	r.online[namespace] = online30	r.mutex.Unlock()31	if !had || previous != online {32		poke(r.wake)33	}34}3536// A namespace the desk holds nothing for reads offline, which is37// the state before its catalog pod first connects.38func (r *reporters) onlineFor(namespace string) bool {39	r.mutex.Lock()40	defer r.mutex.Unlock()41	return r.online[namespace]42}
batch.go 95.0%
1package main23// The batch objects this operator writes and the requests it4// makes for them, hand-written in the same form as the core objects in5// objects.go and reached through the same client. Every worker of a6// namespace is a Job: a scan runs on a schedule from a CronJob, a7// folder scan runs once from a Job the webhook creates, and a departure8// runs once from a Job of its own.910import (11	"context"12	"encoding/json"13	"errors"14	"fmt"15	"net/http"16	"os"17	"time"18)1920// The group the Job and the CronJob belong to.21const batchAPIVersion = "batch/v1"2223// The label Kubernetes stamps on every pod a Job creates, whose24// value is the Job's own name; the scanner reads it through the25// downward API and writes it into the runs row.26const jobNameLabel = "batch.kubernetes.io/job-name"2728// The field path the downward API reads that label from.29const jobNameFieldPath = "metadata.labels['" + jobNameLabel + "']"3031// One run of one worker. The operator writes the spec and reads32// the status, which is the count of pods in each state.33type Job struct {34	APIVersion string     `json:"apiVersion,omitempty"`35	Kind       string     `json:"kind,omitempty"`36	Metadata   ObjectMeta `json:"metadata"`37	Spec       JobSpec    `json:"spec"`38	Status     JobStatus  `json:"status"`39}4041// The collection ListWorkerJobs answers.42type JobList struct {43	Metadata ListMeta `json:"metadata"`44	Items    []Job    `json:"items"`45}4647// BackoffLimit is how many times Kubernetes replaces a failed48// pod before the Job itself fails, and TTLSecondsAfterFinished is how49// long a finished Job stays for a person to read its logs.50type JobSpec struct {51	BackoffLimit            *int32          `json:"backoffLimit,omitempty"`52	TTLSecondsAfterFinished *int32          `json:"ttlSecondsAfterFinished,omitempty"`53	Template                PodTemplateSpec `json:"template"`54}5556// JobStatus is what the Job controller reports: the counts of pods in each57// state, the time it ended the Job, and the conditions, which are its verdict58// on the whole Job. The counts alone cannot say whether a Job is over. A Job59// between the pods of its backoff counts no active pod and is not over.60//61// The completion time is the controller's own stamp on a Job whose pod exited62// zero. The early delete below measures its grace from that stamp, not from63// the pod's exit, so the two clocks are the controller's.64type JobStatus struct {65	Active         int            `json:"active,omitempty"`66	Succeeded      int            `json:"succeeded,omitempty"`67	Failed         int            `json:"failed,omitempty"`68	CompletionTime time.Time      `json:"completionTime,omitzero"`69	Conditions     []JobCondition `json:"conditions,omitempty"`70}7172// JobCondition is one verdict of the Job controller, in the shape73// batch/v1 writes it.74type JobCondition struct {75	Type   string          `json:"type"`76	Status ConditionStatus `json:"status"`77	Reason string          `json:"reason,omitempty"`78}7980// The two condition types that end a Job, one for each way it ends.81const (82	jobComplete = "Complete"83	jobFailed   = "Failed"84)8586// A Job is still doing its work while it has a pod running.87func (j *Job) active() bool { return j.Status.Active > 0 }8889// finished is true when the controller has ended the Job, and not90// before. A Job that waits out its backoff with no pod is unfinished.91func (j *Job) finished() bool {92	return j.holds(jobComplete) || j.holds(jobFailed)93}9495// succeeded is true when the controller ended the Job on a pod that exited96// zero. That is the one outcome the operator deletes early. A failed Job97// stays for its TTL, because a person reads its logs.98func (j *Job) succeeded() bool { return j.holds(jobComplete) }99100// holds is true when the Job carries one condition of the given type with101// status True. That is how batch/v1 writes a verdict it stands behind. A102// condition with status False or Unknown is not a verdict.103func (j *Job) holds(conditionType string) bool {104	for _, condition := range j.Status.Conditions {105		if condition.Type == conditionType && condition.Status == ConditionTrue {106			return true107		}108	}109	return false110}111112// The pod one Job or CronJob creates, as the metadata and spec113// the controller stamps onto it.114type PodTemplateSpec struct {115	Metadata ObjectMeta `json:"metadata"`116	Spec     PodSpec    `json:"spec"`117}118119// The schedule one Library's full walk runs on. The operator120// writes it and reads nothing back but the resourceVersion, because the121// Jobs it creates are read through the Job list.122type CronJob struct {123	APIVersion string      `json:"apiVersion,omitempty"`124	Kind       string      `json:"kind,omitempty"`125	Metadata   ObjectMeta  `json:"metadata"`126	Spec       CronJobSpec `json:"spec"`127}128129// ConcurrencyPolicy is Forbid, so a walk that runs past its next130// turn skips that turn rather than starting a second walk on a claim131// that admits one writer.132type CronJobSpec struct {133	Schedule                   string          `json:"schedule"`134	ConcurrencyPolicy          string          `json:"concurrencyPolicy,omitempty"`135	SuccessfulJobsHistoryLimit *int32          `json:"successfulJobsHistoryLimit,omitempty"`136	FailedJobsHistoryLimit     *int32          `json:"failedJobsHistoryLimit,omitempty"`137	JobTemplate                JobTemplateSpec `json:"jobTemplate"`138}139140// The Job one turn of the schedule creates.141type JobTemplateSpec struct {142	Metadata ObjectMeta `json:"metadata"`143	Spec     JobSpec    `json:"spec"`144}145146// The batch collections: the Jobs of every namespace read with one147// request, and the Jobs and CronJobs of one namespace written per148// namespace.149const (150	jobsAllPath = "/apis/" + batchAPIVersion + "/jobs"151	batchPrefix = "/apis/" + batchAPIVersion + "/namespaces/"152)153154func jobsPath(namespace string) string {155	return batchPrefix + namespace + "/jobs"156}157158func cronJobsPath(namespace string) string {159	return batchPrefix + namespace + "/cronjobs"160}161162// The narrowing that keeps a Job list to this operator's own163// workers, by the name label every Job it creates carries. The equals164// sign is percent-encoded, so the server reads one parameter.165const workerJobsQuery = "labelSelector=" + scannerLabelKey + "%3D" + workerLabelValue166167// A delete of a Job removes the pods under it as well, which the168// default policy of orphaning would leave behind holding the claim.169const backgroundDeletion = "?propagationPolicy=Background"170171// ListWorkerJobs reads this operator's Jobs across every namespace,172// because a Library is in whatever namespace its claim is.173func ListWorkerJobs(ctx context.Context, c *Client) (*JobList, error) {174	list := &JobList{}175	if err := c.RequestJSON(ctx, http.MethodGet, jobsAllPath+"?"+workerJobsQuery, nil, list); err != nil {176		return nil, err177	}178	return list, nil179}180181func CreateJob(ctx context.Context, c *Client, job *Job) (*Job, error) {182	body, err := json.Marshal(job)183	if err != nil {184		return nil, err185	}186	created := &Job{}187	if err := c.RequestJSON(ctx, http.MethodPost, jobsPath(job.Metadata.Namespace), body, created); err != nil {188		return nil, err189	}190	return created, nil191}192193// DeleteJob removes one Job and the pods under it. An already-absent194// Job is success, the rule DeletePod follows.195func DeleteJob(ctx context.Context, c *Client, namespace, name string) error {196	path := jobsPath(namespace) + "/" + name + backgroundDeletion197	err := c.RequestJSON(ctx, http.MethodDelete, path, nil, nil)198	if errors.Is(err, ErrNotFound) {199		return nil200	}201	return err202}203204// How long a succeeded worker Job and its pod stay before the pass deletes205// them. The Job's own TTL is the hour a failed Job keeps for a person to206// read, and it is the backstop when the operator is down.207const succeededJobGrace = 5 * time.Minute208209// retireSucceededJobs deletes every worker Job that exited zero longer than210// the grace ago. The pass acts on the list it already read, so a Job it211// deletes still decides this pass and is gone from the next one. A Job of a212// webhook chain stays for its TTL, whatever its stage: the chain reads its213// stages out of the Job list, and a rescan deleted while its enrich Job214// stands would be created again every pass. A delete that fails is reported215// and the pass carries on, because the next pass reads the Job again.216func (o *operator) retireSucceededJobs(ctx context.Context, jobs []Job, now time.Time) {217	cutoff := now.Add(-succeededJobGrace)218	for index := range jobs {219		job := &jobs[index]220		if !job.succeeded() || job.Status.CompletionTime.IsZero() ||221			!job.Status.CompletionTime.Before(cutoff) {222			continue223		}224		if job.Metadata.Annotations[chainAnnotation] != "" {225			continue226		}227		if err := DeleteJob(ctx, o.client, job.Metadata.Namespace, job.Metadata.Name); err != nil {228			fmt.Fprintf(os.Stderr, "retiring the finished job %s/%s: %v\n",229				job.Metadata.Namespace, job.Metadata.Name, err)230		}231	}232}233234func GetCronJob(ctx context.Context, c *Client, namespace, name string) (*CronJob, error) {235	cronJob := &CronJob{}236	path := cronJobsPath(namespace) + "/" + name237	if err := c.RequestJSON(ctx, http.MethodGet, path, nil, cronJob); err != nil {238		return nil, err239	}240	return cronJob, nil241}242243func CreateCronJob(ctx context.Context, c *Client, cronJob *CronJob) (*CronJob, error) {244	body, err := json.Marshal(cronJob)245	if err != nil {246		return nil, err247	}248	created := &CronJob{}249	path := cronJobsPath(cronJob.Metadata.Namespace)250	if err := c.RequestJSON(ctx, http.MethodPost, path, body, created); err != nil {251		return nil, err252	}253	return created, nil254}255256// UpdateCronJob writes the whole CronJob back. The resourceVersion in257// the body makes the write conditional, so a CronJob that changed258// underneath answers ErrConflict and the next pass reads it again.259func UpdateCronJob(ctx context.Context, c *Client, cronJob *CronJob) (*CronJob, error) {260	body, err := json.Marshal(cronJob)261	if err != nil {262		return nil, err263	}264	written := &CronJob{}265	path := cronJobsPath(cronJob.Metadata.Namespace) + "/" + cronJob.Metadata.Name266	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {267		return nil, err268	}269	return written, nil270}271272// DeleteCronJob removes one Library's schedule. An already-absent273// CronJob is success, the rule DeleteJob follows.274func DeleteCronJob(ctx context.Context, c *Client, namespace, name string) error {275	path := cronJobsPath(namespace) + "/" + name + backgroundDeletion276	err := c.RequestJSON(ctx, http.MethodDelete, path, nil, nil)277	if errors.Is(err, ErrNotFound) {278		return nil279	}280	return err281}
bus.go 92.6%
1package main23// The bus client is one live TCP connection to the broker, with a4// reader, a single writer, and a keepalive timer, and it reconnects5// with backoff whenever any of the three fails. Every mode of this6// operator that speaks to the broker holds one Bus and lets it manage7// the connection, so no caller writes the socket directly.8//9// All writes go through one goroutine and one channel, because two10// goroutines writing one TCP connection would interleave their bytes11// and corrupt a packet. A publish or a subscribe from any goroutine12// enqueues a finished frame, and the writer sends it.1314import (15	"bufio"16	"context"17	"fmt"18	"net"19	"os"20	"sort"21	"sync"22	"time"23)2425// The keepalive the client asks the broker for, and the queue the26// writer drains. The client sends a PINGREQ once the connection has27// been idle longer than half the keepalive, so the broker never28// reaches the keepalive without hearing from the client. The queue is29// bounded, and a publish that would overflow it is dropped, which is30// correct at QoS 0.31const (32	busKeepalive  = 3033	busQueueDepth = 6434)3536// The reconnect backoff bounds. The client waits busMinBackoff after37// the first failure and doubles the wait up to busMaxBackoff, so a38// broker that is down does not become a tight reconnect loop. Both are39// variables so a test drives a reconnect in milliseconds.40var (41	busMinBackoff = time.Second42	busMaxBackoff = 30 * time.Second43)4445// busHandler receives one inbound message's topic and payload. The Bus46// calls it on the reader goroutine, so a handler that blocks holds up47// every later message on the connection.48type busHandler func(topic string, payload []byte)4950// busWill is the MQTT Last Will the client names at connect time. The51// broker publishes it on any disconnect the client does not make52// cleanly, which is how a killed pod's status is marked offline.53type busWill struct {54	Topic    string55	Payload  []byte56	Retained bool57}5859// Bus holds the connection's parts and the state that outlives one60// connection: the remembered subscriptions and the packet identifier61// counter. out is the current connection's write queue, or nil while62// disconnected, and the mutex guards both it and the fields around it.63type Bus struct {64	clientID  string65	will      *busWill66	onConnect func(*Bus)67	handler   busHandler68	dial      func(context.Context) (net.Conn, error)6970	mutex    sync.Mutex71	filters  map[string]struct{}72	out      chan []byte73	packetID uint1674}7576// newBus builds a client that dials the address over TCP. The address,77// the client identifier, the will, the connect callback, and the78// inbound handler are fixed for the client's life; the connection they79// drive is not.80func newBus(address, clientID string, will *busWill, onConnect func(*Bus), handler busHandler) *Bus {81	bus := &Bus{82		clientID:  clientID,83		will:      will,84		onConnect: onConnect,85		handler:   handler,86		filters:   map[string]struct{}{},87	}88	bus.dial = func(ctx context.Context) (net.Conn, error) {89		dialer := &net.Dialer{}90		return dialer.DialContext(ctx, "tcp", address)91	}92	return bus93}9495// Run holds the connection open until ctx ends. It dials, connects,96// and serves one session, then waits a backoff and dials again. A97// session that reached a CONNACK resets the backoff to its floor, so a98// connection that drops after an hour reconnects at once, while a99// broker that never answers is retried ever more slowly.100func (b *Bus) Run(ctx context.Context) {101	backoff := busMinBackoff102	for ctx.Err() == nil {103		connected := b.runSession(ctx)104		if ctx.Err() != nil {105			return106		}107		if connected {108			backoff = busMinBackoff109		}110		select {111		case <-ctx.Done():112			return113		case <-time.After(backoff):114		}115		if !connected {116			backoff *= 2117			if backoff > busMaxBackoff {118				backoff = busMaxBackoff119			}120		}121	}122}123124// runSession dials, completes the CONNECT handshake, and serves the125// connection until its reader or writer fails. It returns whether the126// handshake reached a CONNACK, which is what tells Run to reset the127// backoff.128func (b *Bus) runSession(parent context.Context) (connected bool) {129	conn, err := b.dial(parent)130	if err != nil {131		return false132	}133	defer conn.Close()134135	if _, err := conn.Write(encodeConnect(b.clientID, busKeepalive, b.will)); err != nil {136		return false137	}138	reader := bufio.NewReader(conn)139	first, body, err := readPacket(reader)140	if err != nil || first&0xF0 != mqttConnack {141		return false142	}143	if err := parseConnack(body); err != nil {144		return false145	}146147	ctx, cancel := context.WithCancel(parent)148	defer cancel()149	// The reader blocks in Read until the broker writes or the150	// connection closes. Closing the connection when the session ends151	// is what unblocks a reader waiting on a silent broker.152	defer context.AfterFunc(ctx, func() { conn.Close() })()153154	out := make(chan []byte, busQueueDepth)155	b.mutex.Lock()156	b.out = out157	b.mutex.Unlock()158	defer func() {159		b.mutex.Lock()160		b.out = nil161		b.mutex.Unlock()162	}()163164	var writing sync.WaitGroup165	writing.Add(1)166	go func() {167		defer writing.Done()168		// A write failure ends the session, so the reader stops too.169		defer cancel()170		b.writeLoop(ctx, conn, out)171	}()172173	// The remembered subscriptions go out first, so the broker is174	// delivering again before onConnect re-publishes any retained175	// state.176	b.resubscribe(out)177	if b.onConnect != nil {178		b.onConnect(b)179	}180181	b.readLoop(reader)182	cancel()183	writing.Wait()184	return true185}186187// writeLoop is the one goroutine that writes the connection. It sends188// each queued frame and, when no frame has gone out for half the189// keepalive, sends a PINGREQ so the broker hears from the client190// before the keepalive elapses.191func (b *Bus) writeLoop(ctx context.Context, conn net.Conn, out <-chan []byte) {192	idle := time.Duration(busKeepalive) * time.Second / 2193	ticker := time.NewTicker(idle)194	defer ticker.Stop()195	last := time.Now()196	for {197		select {198		case <-ctx.Done():199			return200		case frame := <-out:201			if _, err := conn.Write(frame); err != nil {202				return203			}204			last = time.Now()205		case <-ticker.C:206			if time.Since(last) >= idle {207				if _, err := conn.Write(encodePingreq()); err != nil {208					return209				}210				last = time.Now()211			}212		}213	}214}215216// readLoop reads whole packets and delivers each inbound PUBLISH to the217// handler. Any read error ends the loop and the session.218//219// A PINGRESP is read and dropped, because it only proves the broker is220// alive, and a successful read already shows that. A SUBACK is checked: a221// refused subscription is a reader that receives nothing for the life of222// the session, and the line in the pod log is the only sign of it.223func (b *Bus) readLoop(reader *bufio.Reader) {224	for {225		first, body, err := readPacket(reader)226		if err != nil {227			return228		}229		switch first & 0xF0 {230		case mqttPublish:231			topic, payload, ok := parsePublish(body)232			if ok && b.handler != nil {233				b.handler(topic, payload)234			}235		case mqttSuback:236			if err := parseSuback(body); err != nil {237				fmt.Fprintf(os.Stderr, "%v\n", err)238			}239		}240	}241}242243// Publish enqueues a QoS 0 PUBLISH from any goroutine. While the client244// is disconnected the queue does not exist and the publish is dropped,245// which is correct at QoS 0: a caller re-publishes its retained state246// from onConnect, so the broker holds the current value again within247// the reconnect.248func (b *Bus) Publish(topic string, payload []byte, retained bool) {249	b.mutex.Lock()250	out := b.out251	b.mutex.Unlock()252	if out == nil {253		return254	}255	select {256	case out <- encodePublish(topic, payload, retained):257	default:258	}259}260261// Subscribe remembers the filter and sends it if the client is262// connected. The remembered set is what runSession re-sends on every263// reconnect, so a subscription outlives the connection it was made on.264func (b *Bus) Subscribe(filter string) {265	b.mutex.Lock()266	b.filters[filter] = struct{}{}267	out := b.out268	frame := encodeSubscribe(b.nextPacketID(), filter)269	b.mutex.Unlock()270	if out == nil {271		return272	}273	select {274	case out <- frame:275	default:276	}277}278279// resubscribe sends every remembered filter on a fresh connection. The280// filters go out in sorted order so the frames a reconnect writes are281// the same every time, which keeps a test deterministic.282func (b *Bus) resubscribe(out chan<- []byte) {283	b.mutex.Lock()284	filters := make([]string, 0, len(b.filters))285	for filter := range b.filters {286		filters = append(filters, filter)287	}288	frames := make([][]byte, 0, len(filters))289	sort.Strings(filters)290	for _, filter := range filters {291		frames = append(frames, encodeSubscribe(b.nextPacketID(), filter))292	}293	b.mutex.Unlock()294	for _, frame := range frames {295		select {296		case out <- frame:297		default:298		}299	}300}301302// nextPacketID hands out the identifier a SUBSCRIBE carries and its303// SUBACK echoes. It skips zero, which the protocol reserves. The caller304// holds the mutex.305func (b *Bus) nextPacketID() uint16 {306	b.packetID++307	if b.packetID == 0 {308		b.packetID = 1309	}310	return b.packetID311}
catalog.go 100.0%
1package main23// catalog.go is the write side of the catalog: a client that posts rows to its4// own sidecar's Corrosion agent over /v1/transactions. Reads come from the5// SQLite file directly and never through this client. Nothing writes the file6// outside the agent, because a write outside it corrupts the CRDT clocks, so7// every write is a batch of statements posted here.89import (10	"bytes"11	"context"12	"encoding/json"13	"fmt"14	"io"15	"net/http"16)1718// The transactions endpoint every write posts to.19const transactionsPath = "/v1/transactions"2021// The batch ceiling the proof of concept measured: 500 statements per request22// seeded 5000 titles in under half a second.23const maxBatch = 5002425// Catalog writes to one Corrosion agent. base is the agent's API address, bound26// to localhost in the pod.27type Catalog struct {28	base string29	http *http.Client30}3132// NewCatalog builds a client from the agent's base address and an HTTP client.33// A test hands in an httptest server's base and its client.34func NewCatalog(base string, httpClient *http.Client) *Catalog {35	return &Catalog{base: base, http: httpClient}36}3738// statement is one SQL statement and its bound parameters. Every value is a39// parameter and never concatenated into the SQL, so a title with a quote in it40// is data and not syntax.41type statement struct {42	sql    string43	params []any44}4546// transactionResponse is the agent's answer: one result per statement, each47// with the rows it changed or the error that stopped it.48type transactionResponse struct {49	Results []transactionResult `json:"results"`50}5152type transactionResult struct {53	RowsAffected int    `json:"rows_affected"`54	Error        string `json:"error"`55}5657// apply chunks the statements into requests of at most maxBatch and posts each.58// It returns the rows applied so far and stops at the first failure, so a caller59// sees both what landed and what broke.60func (c *Catalog) apply(ctx context.Context, statements []statement) (int, error) {61	applied := 062	for start := 0; start < len(statements); start += maxBatch {63		end := min(start+maxBatch, len(statements))64		n, err := c.post(ctx, statements[start:end])65		applied += n66		if err != nil {67			return applied, err68		}69	}70	return applied, nil71}7273// post sends one batch as a JSON array of [sql, [params...]] entries and reads74// the count applied. A non-2xx status or a per-statement error is a failure the75// caller sees.76func (c *Catalog) post(ctx context.Context, statements []statement) (int, error) {77	body := make([]any, len(statements))78	for i, s := range statements {79		params := s.params80		if params == nil {81			params = []any{}82		}83		body[i] = []any{s.sql, params}84	}85	// The payload is strings, numbers, and slices of them, so it always86	// marshals, and there is no failure here for a caller to answer.87	payload, _ := json.Marshal(body)8889	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+transactionsPath, bytes.NewReader(payload))90	if err != nil {91		return 0, err92	}93	req.Header.Set("Content-Type", "application/json")94	req.Header.Set("Accept", "application/json")9596	resp, err := c.http.Do(req)97	if err != nil {98		return 0, err99	}100	defer drain(resp.Body)101102	if resp.StatusCode < 200 || resp.StatusCode > 299 {103		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))104		return 0, fmt.Errorf("catalog transaction: %s: %s", resp.Status, message)105	}106107	var result transactionResponse108	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {109		return 0, fmt.Errorf("catalog transaction: decoding response: %w", err)110	}111	// One result per statement is the contract, so a short answer is a112	// failure and never a batch that applied.113	if len(result.Results) != len(statements) {114		return 0, fmt.Errorf("catalog transaction: %d results for %d statements",115			len(result.Results), len(statements))116	}117	applied := 0118	for _, r := range result.Results {119		if r.Error != "" {120			return applied, fmt.Errorf("catalog transaction: %s", r.Error)121		}122		applied += r.RowsAffected123	}124	return applied, nil125}126127// plainItemUpsert is the upsert for an item table that carries only the shared128// header and the slug, which series and sets do. Movies and episodes each129// carry a column beyond the header and have an upsert of their own. table is130// a constant this package names and never input, so naming it in the SQL text131// carries no injection.132//133// The conflict target is the whole primary key, (library, id), and the134// update names no primary-key column, because cr-sqlite reads a change135// to a key column as a delete and a create.136func plainItemUpsert(table string, params []any) statement {137	return statement{138		sql: `INSERT INTO ` + table + ` (library, id, kind, path, title, sort_key, released, added, art, duration, body, slug) ` +139			`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +140			`ON CONFLICT (library, id) DO UPDATE SET ` +141			`kind = excluded.kind, path = excluded.path, title = excluded.title, ` +142			`sort_key = excluded.sort_key, released = excluded.released, added = excluded.added, art = excluded.art, ` +143			`duration = excluded.duration, body = excluded.body, slug = excluded.slug`,144		params: params,145	}146}147148// itemParams marshals a body and lays the header out in column order.149//150// The library is the first parameter of every statement this file151// writes, because it leads every key.152func itemParams(library, id, kind, path, title, sortKey, released string, added int64, art string, duration int64, body any, slug string) []any {153	payload, _ := json.Marshal(body)154	return []any{library, id, kind, path, title, sortKey, released, added, art, duration, string(payload), slug}155}156157// The arts column: the list as JSON, and an empty list for an item with none,158// so a reader always parses a list.159func artsParam(arts []string) string {160	if len(arts) == 0 {161		return "[]"162	}163	payload, _ := json.Marshal(arts)164	return string(payload)165}166167// UpsertMovies writes movie rows in place, so a re-walk updates a title rather168// than dropping and recreating it. The movies table adds set_id after the169// header, so this upsert names its own columns.170func (c *Catalog) UpsertMovies(ctx context.Context, rows []movieRow) (int, error) {171	statements := make([]statement, len(rows))172	for i, row := range rows {173		params := itemParams(row.Library, row.Id, row.Kind, row.Path, row.Title, row.SortKey, row.Released, row.Added, row.Art, row.Duration, row.Body, row.Slug)174		statements[i] = statement{175			sql: `INSERT INTO movies (library, id, kind, path, title, sort_key, released, added, art, duration, body, slug, set_id, nfo_facts, arts) ` +176				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +177				`ON CONFLICT (library, id) DO UPDATE SET ` +178				`kind = excluded.kind, path = excluded.path, title = excluded.title, ` +179				`sort_key = excluded.sort_key, released = excluded.released, added = excluded.added, art = excluded.art, ` +180				`duration = excluded.duration, body = excluded.body, slug = excluded.slug, ` +181				`set_id = excluded.set_id, nfo_facts = excluded.nfo_facts, arts = excluded.arts`,182			params: append(params, row.SetID, row.NFOFacts, artsParam(row.Arts)),183		}184	}185	return c.apply(ctx, statements)186}187188// UpsertSets writes the derived set rows in place, so a walk that reads a new189// member updates the set rather than dropping and recreating it.190func (c *Catalog) UpsertSets(ctx context.Context, rows []setRow) (int, error) {191	statements := make([]statement, len(rows))192	for i, row := range rows {193		params := itemParams(row.Library, row.Id, row.Kind, row.Path, row.Title, row.SortKey, row.Released, row.Added, row.Art, row.Duration, row.Body, row.Slug)194		statements[i] = plainItemUpsert("sets", params)195	}196	return c.apply(ctx, statements)197}198199// UpsertSeries writes series rows in place. The series table adds nfo_facts200// after the header, so this upsert names its own columns, as the movies one201// does.202func (c *Catalog) UpsertSeries(ctx context.Context, rows []seriesRow) (int, error) {203	statements := make([]statement, len(rows))204	for i, row := range rows {205		params := itemParams(row.Library, row.Id, row.Kind, row.Path, row.Title, row.SortKey, row.Released, row.Added, row.Art, row.Duration, row.Body, row.Slug)206		statements[i] = statement{207			sql: `INSERT INTO series (library, id, kind, path, title, sort_key, released, added, art, duration, body, slug, nfo_facts, arts) ` +208				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +209				`ON CONFLICT (library, id) DO UPDATE SET ` +210				`kind = excluded.kind, path = excluded.path, title = excluded.title, ` +211				`sort_key = excluded.sort_key, released = excluded.released, added = excluded.added, art = excluded.art, ` +212				`duration = excluded.duration, body = excluded.body, slug = excluded.slug, ` +213				`nfo_facts = excluded.nfo_facts, arts = excluded.arts`,214			params: append(params, row.NFOFacts, artsParam(row.Arts)),215		}216	}217	return c.apply(ctx, statements)218}219220// UpsertEpisodes writes episode rows in place, with the three columns that place221// an episode under its series.222func (c *Catalog) UpsertEpisodes(ctx context.Context, rows []episodeRow) (int, error) {223	statements := make([]statement, len(rows))224	for i, row := range rows {225		payload, _ := json.Marshal(row.Body)226		statements[i] = statement{227			sql: `INSERT INTO episodes (library, id, kind, path, title, sort_key, released, added, art, duration, body, slug, series, season, episode, arts) ` +228				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +229				`ON CONFLICT (library, id) DO UPDATE SET ` +230				`kind = excluded.kind, path = excluded.path, title = excluded.title, ` +231				`sort_key = excluded.sort_key, released = excluded.released, added = excluded.added, art = excluded.art, ` +232				`duration = excluded.duration, body = excluded.body, slug = excluded.slug, ` +233				`series = excluded.series, season = excluded.season, episode = excluded.episode, arts = excluded.arts`,234			params: []any{row.Library, row.Id, row.Kind, row.Path, row.Title, row.SortKey, row.Released, row.Added, row.Art, row.Duration, string(payload), row.Slug, row.Series, row.Season, row.Episode, artsParam(row.Arts)},235		}236	}237	return c.apply(ctx, statements)238}239240// UpsertFiles writes file rows in place. present is carried as 1 or 0, because241// the column is an integer.242func (c *Catalog) UpsertFiles(ctx context.Context, rows []fileRow) (int, error) {243	statements := make([]statement, len(rows))244	for i, row := range rows {245		present := 0246		if row.Present {247			present = 1248		}249		statements[i] = statement{250			sql: `INSERT INTO files (library, path, container, video_codec, audio_codec, width, height, size_bytes, duration_ms, trickplay, present, type, role, language, modified, arrived) ` +251				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +252				`ON CONFLICT (library, path) DO UPDATE SET ` +253				`container = excluded.container, video_codec = excluded.video_codec, ` +254				`audio_codec = excluded.audio_codec, width = excluded.width, height = excluded.height, ` +255				`size_bytes = excluded.size_bytes, duration_ms = excluded.duration_ms, trickplay = excluded.trickplay, present = excluded.present, ` +256				`type = excluded.type, role = excluded.role, language = excluded.language, modified = excluded.modified, arrived = excluded.arrived`,257			params: []any{row.Library, row.Path, row.Container, row.VideoCodec, row.AudioCodec, row.Width, row.Height, row.SizeBytes, row.DurationMs, row.Trickplay, present, row.Type, row.Role, row.Language, row.Modified, row.Arrived},258		}259	}260	return c.apply(ctx, statements)261}262263// UpsertFileItems writes the many-to-many link, one row per (file, item) pair a264// file carries.265//266// Every column of file_items is a primary-key column, so the row267// carries nothing to update and the upsert does nothing on a conflict.268// A repeat write changes no row and broadcasts nothing.269func (c *Catalog) UpsertFileItems(ctx context.Context, rows []fileRow) (int, error) {270	var statements []statement271	for _, row := range rows {272		for _, item := range row.Items {273			statements = append(statements, statement{274				sql:    `INSERT INTO file_items (library, path, item) VALUES (?, ?, ?) ON CONFLICT (library, path, item) DO NOTHING`,275				params: []any{row.Library, row.Path, item},276			})277		}278	}279	return c.apply(ctx, statements)280}281282// UpsertAliases writes alias rows in place, so every provider id and the folder283// key resolve to the item.284func (c *Catalog) UpsertAliases(ctx context.Context, rows []aliasRow) (int, error) {285	statements := make([]statement, len(rows))286	for i, row := range rows {287		statements[i] = statement{288			sql:    `INSERT INTO aliases (library, alias, item, source) VALUES (?, ?, ?, ?) ON CONFLICT (library, alias) DO UPDATE SET item = excluded.item, source = excluded.source`,289			params: []any{row.Library, row.Alias, row.Item, row.Source},290		}291	}292	return c.apply(ctx, statements)293}294295// deleteByKey builds one delete per key against a two-column primary296// key, the library and the table's own key, so a delete reaches one297// library's row and never another library's row of the same name.298// table and column are constants this package names and never input.299func deleteByKey(table, column, library string, keys []string) []statement {300	statements := make([]statement, len(keys))301	for i, key := range keys {302		statements[i] = statement{303			sql:    `DELETE FROM ` + table + ` WHERE library = ? AND ` + column + ` = ?`,304			params: []any{library, key},305		}306	}307	return statements308}309310// DeleteMovies removes movie rows whose titles left the volume.311func (c *Catalog) DeleteMovies(ctx context.Context, library string, ids []string) (int, error) {312	return c.apply(ctx, deleteByKey("movies", "id", library, ids))313}314315// DeleteSets removes set rows whose last member left the library.316func (c *Catalog) DeleteSets(ctx context.Context, library string, ids []string) (int, error) {317	return c.apply(ctx, deleteByKey("sets", "id", library, ids))318}319320// DeleteSeries removes series rows whose titles left the volume.321func (c *Catalog) DeleteSeries(ctx context.Context, library string, ids []string) (int, error) {322	return c.apply(ctx, deleteByKey("series", "id", library, ids))323}324325// DeleteEpisodes removes episode rows whose files left the volume.326func (c *Catalog) DeleteEpisodes(ctx context.Context, library string, ids []string) (int, error) {327	return c.apply(ctx, deleteByKey("episodes", "id", library, ids))328}329330// DeleteFiles removes file rows whose files left the volume.331func (c *Catalog) DeleteFiles(ctx context.Context, library string, paths []string) (int, error) {332	return c.apply(ctx, deleteByKey("files", "path", library, paths))333}334335// DeleteAliases removes alias rows a re-walk no longer produces.336func (c *Catalog) DeleteAliases(ctx context.Context, library string, aliases []string) (int, error) {337	return c.apply(ctx, deleteByKey("aliases", "alias", library, aliases))338}339340// The replicated tables of the schema: the set the read of every341// library the catalog holds covers. A table added to the schema is one342// entry here. The runs table is one of them, so a library that has lost343// every item row but whose last Job wrote a run is still a library the344// reporter reports on.345var catalogTables = []string{"aliases", "movies", "sets", "series", "episodes", "file_items", "files", "runs", "attempts",346	"contributors", "contributor_aliases", "credits", "genres",347	"franchises", "franchise_members", "franchise_runs"}348349// DeleteFileItems names all three columns of the link row, because all350// three are the primary key. A delete by fewer would take every other351// link the library, the file, or the item holds with it.352func (c *Catalog) DeleteFileItems(ctx context.Context, library string, links []fileItemKey) (int, error) {353	statements := make([]statement, len(links))354	for i, link := range links {355		statements[i] = statement{356			sql:    `DELETE FROM file_items WHERE library = ? AND path = ? AND item = ?`,357			params: []any{library, link.Path, link.Item},358		}359	}360	return c.apply(ctx, statements)361}
catalogclaim.go 100.0%
1package main23// The durable catalog volumes. There is one per Library, which4// its worker Jobs mount in turn, and one per namespace, which the5// catalog pod holds. Both are ReadWriteOnce, because one agent writes6// one SQLite database, and both are sized from the namespace Catalog,7// because every agent holds the whole namespace's catalog.89import (10	"context"11	"errors"12)1314// scannerCatalogClaimName is the durable catalog volume one Library's15// worker Jobs mount. It is derived from the Library name, so every pass16// names the same claim and the operator keeps no record of it. Its17// ReadWriteOnce is what serializes one library's Jobs.18//19// The claim must hold a database whose schema matches this release.20// Corrosion refuses to change the primary key of a database it already21// holds, and an old database started against a new schema starts22// quietly stale: it logs the refusal, serves the old tables, and fails23// every write of the new shape, one request at a time. The catalog is24// derived, so the cure is cheap: delete the claims when a release25// changes a primary key, and the next pass provisions fresh ones that26// one full walk refills.27func scannerCatalogClaimName(library string) string {28	return library + "-catalog"29}3031// buildCatalogClaim writes the catalog claim one Library's workers take.32// It is ReadWriteOnce, because one agent writes one SQLite database. It is33// sized from the namespace Catalog, because each agent holds the whole34// namespace's catalog. It is owned by the Library, so it survives a pod roll35// and is collected with the Library. An empty StorageClassName is omitted,36// so the cluster's default StorageClass binds it.37func buildCatalogClaim(library *Library, catalog *NamespaceCatalog) *PersistentVolumeClaim {38	return &PersistentVolumeClaim{39		APIVersion: claimAPIVersion,40		Kind:       "PersistentVolumeClaim",41		Metadata: ObjectMeta{42			Name:            scannerCatalogClaimName(library.Metadata.Name),43			Namespace:       library.Metadata.Namespace,44			Labels:          libraryLabels(library.Metadata.Name),45			OwnerReferences: []OwnerReference{libraryOwner(library)},46		},47		Spec: PersistentVolumeClaimSpec{48			AccessModes: []string{accessModeReadWriteOnce},49			Resources: VolumeResourceRequirements{50				Requests: map[string]string{"storage": catalogStorageSize(catalog)},51			},52			StorageClassName: catalog.Spec.Storage.StorageClassName,53		},54	}55}5657// standCatalogClaim creates the catalog claim when there is none and leaves58// an existing one alone. A PersistentVolumeClaim's spec is immutable once it59// binds, so the operator provisions the claim rather than reconciling it. A60// size a later Catalog grows to reaches a new claim, not this one. A conflict61// on the create means another writer got there first, which is success.62func (o *operator) standCatalogClaim(ctx context.Context, library *Library, catalog *NamespaceCatalog) error {63	namespace := library.Metadata.Namespace64	name := scannerCatalogClaimName(library.Metadata.Name)6566	_, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)67	if err == nil {68		return nil69	}70	if !errors.Is(err, ErrNotFound) {71		return err72	}7374	_, err = CreatePersistentVolumeClaim(ctx, o.client, buildCatalogClaim(library, catalog))75	if errors.Is(err, ErrConflict) {76		return nil77	}78	return err79}8081// The durable catalog volume the namespace's catalog pod holds,82// named from the Catalog, unless the Catalog names a claim of its own.83func catalogPodClaimName(catalog string) string {84	return catalog + "-catalog"85}8687// The claim the catalog pod mounts: the one the Catalog names,88// or the one the operator provisions when it names none.89func catalogClaimFor(catalog *NamespaceCatalog) string {90	if catalog.Spec.Storage.ClaimName != "" {91		return catalog.Spec.Storage.ClaimName92	}93	return catalogPodClaimName(catalog.Metadata.Name)94}9596// The catalog pod's own claim, owned by the Catalog, so the97// garbage collector takes it with the Catalog and the standing catalog98// survives every roll of the pod.99func buildCatalogPodClaim(catalog *NamespaceCatalog) *PersistentVolumeClaim {100	return &PersistentVolumeClaim{101		APIVersion: claimAPIVersion,102		Kind:       "PersistentVolumeClaim",103		Metadata: ObjectMeta{104			Name:            catalogPodClaimName(catalog.Metadata.Name),105			Namespace:       catalog.Metadata.Namespace,106			Labels:          catalogPodLabels(),107			OwnerReferences: []OwnerReference{catalogObjectOwner(catalog)},108		},109		Spec: PersistentVolumeClaimSpec{110			AccessModes: []string{accessModeReadWriteOnce},111			Resources: VolumeResourceRequirements{112				Requests: map[string]string{"storage": catalogStorageSize(catalog)},113			},114			StorageClassName: catalog.Spec.Storage.StorageClassName,115		},116	}117}
catalogpod.go 100.0%
1package main23// The catalog pod is what a Catalog becomes at run time: one4// standing pod per namespace, owned by the Catalog, holding the5// namespace's durable catalog on a claim and reporting what it holds6// over the bus. It is the standing member of the gossip cluster, and7// every worker Job joins that cluster for the length of its run. It8// answers on no port: the agent's API is loopback only, and the9// reporter reads it from inside the pod.1011import (12	"context"13	"errors"14)1516// The pod one Catalog becomes, named from the Catalog, so every17// pass names the same pod and the operator keeps no record of it.18func catalogPodName(catalog string) string {19	return catalog + "-catalog"20}2122// The label pair the catalog pod carries: the name label that23// tells it from a Job's pod and a screen pod, and the member label the24// namespace's EndpointSlice is written over.25func catalogPodLabels() map[string]string {26	return withMemberLabel(map[string]string{scannerLabelKey: catalogLabelValue})27}2829// The pod the Catalog stands. It is a function of the Catalog30// and the operator's own settings alone, so two passes over an31// unchanged Catalog build the same pod, which is what makes the32// template hash mean anything.33func buildCatalogPod(catalog *NamespaceCatalog, scannerImage, corrosionImage, busAddress, topicBase string) *Pod {34	grace := int64(scannerGracePeriod)35	// The reporter holds no Kubernetes credential; it publishes over36	// the bus, and the operator alone writes a status.37	noToken := false38	return &Pod{39		APIVersion: podAPIVersion,40		Kind:       "Pod",41		Metadata: ObjectMeta{42			Name:            catalogPodName(catalog.Metadata.Name),43			Namespace:       catalog.Metadata.Namespace,44			Labels:          catalogPodLabels(),45			OwnerReferences: []OwnerReference{catalogObjectOwner(catalog)},46		},47		Spec: PodSpec{48			// The catalog pod is a standing service and not a run to49			// completion, so the kubelet restarts a container that50			// exits rather than letting the pod end.51			RestartPolicy:                 "Always",52			TerminationGracePeriodSeconds: &grace,53			AutomountServiceAccountToken:  &noToken,54			InitContainers: []Container{55				catalogSidecar(corrosionImage),56			},57			Containers: []Container{58				reporterSidecar(catalog, scannerImage, busAddress, topicBase),59			},60			Volumes: []Volume{61				{Name: catalogVolumeName, PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{62					ClaimName: catalogClaimFor(catalog),63				}},64			},65		},66	}67}6869// The container that reads the loopback catalog API and70// publishes each library's report over the bus. It runs this operator's71// own image in its report role, and it learns the namespace it reports72// on from its environment alone, because it holds no API credential.73func reporterSidecar(catalog *NamespaceCatalog, image, busAddress, topicBase string) Container {74	return Container{75		Name:    reporterContainer,76		Image:   image,77		Command: []string{"/library-operator", reportMode},78		Env: []EnvVar{79			{Name: libraryNamespaceVariable, Value: catalog.Metadata.Namespace},80			{Name: busAddressVariable, Value: busAddress},81			{Name: topicBaseVariable, Value: topicBase},82			{Name: catalogAPIVariable, Value: defaultCatalogAPI},83		},84		Resources: ResourceRequirements{85			Requests: map[string]string{"cpu": scannerCPURequest, "memory": scannerMemoryRequest},86			Limits:   map[string]string{"memory": scannerMemoryLimit},87		},88		SecurityContext: unprivileged(),89	}90}9192// The pod that stands for one Catalog after this pass, on the93// same terms as every other pod this operator stands: the live pod when94// it matches the template, the created pod when there was none, and nil95// when this pass deleted a stale one.96func (o *operator) standCatalogPod(ctx context.Context, catalog *NamespaceCatalog) (*Pod, error) {97	if err := o.standCatalogPodClaim(ctx, catalog); err != nil {98		return nil, err99	}100	desired := buildCatalogPod(catalog, o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase)101	return o.standPod(ctx, desired)102}103104// The pod that holds the namespace's catalog, out of the pods105// the pass listed, or nil when it does not stand yet.106func catalogPodOf(catalog *NamespaceCatalog, pods []Pod) *Pod {107	if catalog == nil {108		return nil109	}110	name := catalogPodName(catalog.Metadata.Name)111	for index := range pods {112		pod := &pods[index]113		if pod.Metadata.Namespace == catalog.Metadata.Namespace && pod.Metadata.Name == name {114			return pod115		}116	}117	return nil118}119120// The reason and message the Catalog's Ready condition carries121// while its pod is not up, so a person reads one object to find what122// the namespace's catalog waits on.123func catalogPodBlocker(pod *Pod) (string, string) {124	switch {125	case pod == nil:126		return catalogReasonPodPending, "there is no catalog pod yet"127	case pod.Status.Phase == podFailed:128		return catalogReasonPodFailed, podFailureMessage(pod)129	case pod.Status.Phase != podRunning || !everyContainerReady(pod):130		return catalogReasonPodPending, podPendingMessage(pod)131	}132	return "", ""133}134135// An absent claim is created and an existing one is left alone,136// the rule standCatalogClaim follows, because a claim's spec is137// immutable once it binds. A Catalog that names a claim of its own138// creates none: the claim is the person's, and the operator mounts it.139func (o *operator) standCatalogPodClaim(ctx context.Context, catalog *NamespaceCatalog) error {140	if catalog.Spec.Storage.ClaimName != "" {141		return nil142	}143	namespace, name := catalog.Metadata.Namespace, catalogPodClaimName(catalog.Metadata.Name)144145	_, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)146	if err == nil {147		return nil148	}149	if !errors.Is(err, ErrNotFound) {150		return err151	}152	_, err = CreatePersistentVolumeClaim(ctx, o.client, buildCatalogPodClaim(catalog))153	if errors.Is(err, ErrConflict) {154		return nil155	}156	return err157}
catalogquery.go 94.9%
1package main23// catalogquery.go is the read side of the catalog, over the agent's4// /v1/queries endpoint. The write client in catalog.go does not cover it.5// The prune reads the ids a walk did not mark through this side. The6// endpoint answers a query as a stream of newline-delimited JSON events,7// so the reader holds one row at a time and never the whole result set.89import (10	"bufio"11	"bytes"12	"context"13	"encoding/json"14	"fmt"15	"io"16	"net/http"17	"strings"18)1920// The queries endpoint every read posts to.21const queriesPath = "/v1/queries"2223// queryReadLimit bounds the buffer the streaming reader grows for one24// event line, so a single event cannot grow the reader without end.25const queryReadLimit = 1 << 202627// LibraryKeys reads the sorted set of libraries this agent's own28// catalog holds rows for. It is the departure signal in plan 21: a29// survivor whose set no longer names a departed library has applied30// the deletes. The set needs no LIMIT, because it is bounded by the31// namespace's count of Libraries and not by its count of rows.32//33// A UNION rather than six reads, because UNION drops the duplicates,34// so one request answers the whole set, and every branch reads only35// the library-leading primary key its table already has.36func (c *Catalog) LibraryKeys(ctx context.Context) ([]string, error) {37	return c.queryStrings(ctx, libraryKeysSQL(), nil)38}3940// libraryKeysSQL builds the read from the same table list the sweep41// deletes from, so a table added to the schema reaches both.42func libraryKeysSQL() string {43	branches := make([]string, len(catalogTables))44	for i, table := range catalogTables {45		branches[i] = `SELECT library FROM ` + table46	}47	return strings.Join(branches, " UNION ") + ` ORDER BY 1`48}4950// queryStrings runs a read query and returns the first column of every51// row as a string. Every caller's query bounds its own answer, by a52// LIMIT or by a set that is small by nature, so the slice never holds53// a whole table.54func (c *Catalog) queryStrings(ctx context.Context, sql string, params []any) ([]string, error) {55	var out []string56	err := c.stream(ctx, sql, params, func(cells []any) error {57		if len(cells) == 0 {58			return nil59		}60		if value, ok := cells[0].(string); ok {61			out = append(out, value)62		}63		return nil64	})65	return out, err66}6768// queryInt runs a read query and returns the first column of the first69// row as an integer, or zero where the query returned no row.70func (c *Catalog) queryInt(ctx context.Context, sql string, params []any) (int, error) {71	value := 072	found := false73	err := c.stream(ctx, sql, params, func(cells []any) error {74		if found || len(cells) == 0 {75			return nil76		}77		// A SqliteValue integer arrives as a JSON number, which decodes to78		// float64, so the count reads back through float64.79		if number, ok := cells[0].(float64); ok {80			value = int(number)81			found = true82		}83		return nil84	})85	return value, err86}8788// stream posts one read statement and calls onRow for every row event89// the agent streams back. It reads the body line by line, so it holds90// one event at a time and never buffers the whole result.91func (c *Catalog) stream(ctx context.Context, sql string, params []any, onRow func(cells []any) error) error {92	var statement any = sql93	if len(params) > 0 {94		statement = []any{sql, params}95	}96	payload, _ := json.Marshal(statement)9798	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+queriesPath, bytes.NewReader(payload))99	if err != nil {100		return err101	}102	req.Header.Set("Content-Type", "application/json")103	req.Header.Set("Accept", "application/json")104105	resp, err := c.http.Do(req)106	if err != nil {107		return err108	}109	defer drain(resp.Body)110111	if resp.StatusCode < 200 || resp.StatusCode > 299 {112		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))113		return fmt.Errorf("catalog query: %s: %s", resp.Status, message)114	}115116	scanner := bufio.NewScanner(resp.Body)117	scanner.Buffer(make([]byte, 0, 64*1024), queryReadLimit)118	for scanner.Scan() {119		line := scanner.Bytes()120		if len(line) == 0 {121			continue122		}123		cells, isError, message, err := decodeQueryEvent(line)124		if err != nil {125			return err126		}127		if isError {128			return fmt.Errorf("catalog query: %s", message)129		}130		if cells == nil {131			continue132		}133		if err := onRow(cells); err != nil {134			return err135		}136	}137	return scanner.Err()138}139140// Reads how many titles the catalog holds for this library: the movie141// rows, the series rows, and the franchise rows, which are the folders a142// walk reads, and never the episodes under a series.143func (c *Catalog) countTitles(ctx context.Context, library string) (int, error) {144	return c.queryInt(ctx, `SELECT `+145		`(SELECT count(*) FROM movies WHERE library = ?) + `+146		`(SELECT count(*) FROM series WHERE library = ?) + `+147		`(SELECT count(*) FROM franchises WHERE library = ?)`,148		[]any{library, library, library})149}150151// The subscriptions endpoint, which answers one statement with the152// rows it holds now and then every later change to them.153const subscriptionsPath = "/v1/subscriptions"154155// Posts one statement and calls onRow for every row of the opening156// snapshot and every change after it, with the column names the stream157// opened with, because the agent's matcher prepends the primary key to158// the projection and a reader that counts cells would read the wrong one.159// onReady is called once the snapshot ends. The call returns when the160// stream ends, which a cancelled context is one way to do.161func (c *Catalog) subscribe(ctx context.Context, sql string, onReady func(), onRow func(columns []string, cells []any)) error {162	payload, _ := json.Marshal(sql)163164	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+subscriptionsPath, bytes.NewReader(payload))165	if err != nil {166		return err167	}168	req.Header.Set("Content-Type", "application/json")169	req.Header.Set("Accept", "application/json")170171	resp, err := c.http.Do(req)172	if err != nil {173		return err174	}175	defer drain(resp.Body)176177	if resp.StatusCode < 200 || resp.StatusCode > 299 {178		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))179		return fmt.Errorf("catalog subscription: %s: %s", resp.Status, message)180	}181182	var columns []string183	scanner := bufio.NewScanner(resp.Body)184	scanner.Buffer(make([]byte, 0, 64*1024), queryReadLimit)185	for scanner.Scan() {186		line := scanner.Bytes()187		if len(line) == 0 {188			continue189		}190		event, err := decodeSubscriptionEvent(line)191		if err != nil {192			return err193		}194		switch event.kind {195		case subscriptionColumns:196			columns = event.columns197		case subscriptionRow:198			onRow(columns, event.cells)199		case subscriptionEnd:200			onReady()201		case subscriptionError:202			return fmt.Errorf("catalog subscription: %s", event.message)203		}204	}205	return scanner.Err()206}207208// The four events a subscription stream carries that the reader209// acts on; every other event reads as a skipped one.210const (211	subscriptionColumns = "columns"212	subscriptionRow     = "row"213	subscriptionEnd     = "eoq"214	subscriptionError   = "error"215)216217// One decoded event of a subscription stream.218type subscriptionEvent struct {219	kind    string220	columns []string221	cells   []any222	message string223}224225// Reads one streamed subscription event. A row event is226// [rowid, [cells]] and a change event is [kind, rowid, [cells], id], so227// both carry their cells and both read as a row here.228func decodeSubscriptionEvent(line []byte) (subscriptionEvent, error) {229	var event map[string]json.RawMessage230	if err := json.Unmarshal(line, &event); err != nil {231		return subscriptionEvent{}, err232	}233	if raw, held := event[subscriptionError]; held {234		var message string235		_ = json.Unmarshal(raw, &message)236		return subscriptionEvent{kind: subscriptionError, message: message}, nil237	}238	if raw, held := event[subscriptionColumns]; held {239		var columns []string240		if err := json.Unmarshal(raw, &columns); err != nil {241			return subscriptionEvent{}, err242		}243		return subscriptionEvent{kind: subscriptionColumns, columns: columns}, nil244	}245	if _, held := event[subscriptionEnd]; held {246		return subscriptionEvent{kind: subscriptionEnd}, nil247	}248	raw, held := event[subscriptionRow]249	at := 1250	if !held {251		raw, held = event["change"]252		at = 2253	}254	if !held {255		return subscriptionEvent{}, nil256	}257	var parts []json.RawMessage258	if err := json.Unmarshal(raw, &parts); err != nil {259		return subscriptionEvent{}, err260	}261	if len(parts) <= at {262		return subscriptionEvent{}, nil263	}264	var cells []any265	if err := json.Unmarshal(parts[at], &cells); err != nil {266		return subscriptionEvent{}, err267	}268	return subscriptionEvent{kind: subscriptionRow, cells: cells}, nil269}270271// Reads one named column out of a streamed row.272func cellNamed(columns []string, cells []any, name string) (any, bool) {273	for at, column := range columns {274		if column == name && at < len(cells) {275			return cells[at], true276		}277	}278	return nil, false279}280281// decodeQueryEvent reads one streamed query event. A row event carries282// the row's cells, an error event carries a message, and the columns and283// end-of-query events carry neither, so they read as a skipped event.284func decodeQueryEvent(line []byte) (cells []any, isError bool, message string, err error) {285	var event map[string]json.RawMessage286	if err := json.Unmarshal(line, &event); err != nil {287		return nil, false, "", err288	}289	if raw, ok := event["error"]; ok {290		_ = json.Unmarshal(raw, &message)291		return nil, true, message, nil292	}293	raw, ok := event["row"]294	if !ok {295		return nil, false, "", nil296	}297	// A row event is [rowid, [cells...]], so the cells are the second298	// element of the pair.299	var pair []json.RawMessage300	if err := json.Unmarshal(raw, &pair); err != nil {301		return nil, false, "", err302	}303	if len(pair) != 2 {304		return nil, false, "", nil305	}306	if err := json.Unmarshal(pair[1], &cells); err != nil {307		return nil, false, "", err308	}309	return cells, false, "", nil310}
catalogreconcile.go 93.5%
1package main23// Catalogreconcile.go stands the namespace catalog. A pass reconciles each4// namespace's one Catalog into the catalog Service, the EndpointSlice, and5// the Catalog's own status. A namespace with more than one Catalog stands6// nothing new this pass: every Catalog in it is marked Blocked, and the7// Service and the slice that already stand are left as they are.89import (10	"context"11	"encoding/json"12	"errors"13	"fmt"14	"maps"15	"os"16	"slices"17	"sort"18	"time"19)2021// ReconcileCatalogs stands each namespace's catalog cluster from22// its one Catalog: the pod that holds the durable catalog and reports23// it, the claim under that pod, and the Service and EndpointSlice the24// agents find each other through. All four are owned by the Catalog,25// which is their real owner: they describe the namespace's one26// Corrosion cluster. A namespace with more than one Catalog marks every27// Catalog in it Blocked and stands nothing new. A failure in one28// namespace is reported, and the rest still stand.29//30// The members the pass hands in are every pod that holds a catalog31// agent, read once for the whole pass: the catalog pod, the pods of the32// Jobs that are running, and the screen pods.33func (o *operator) reconcileCatalogs(ctx context.Context, byNamespace map[string][]*NamespaceCatalog, members []Pod, now time.Time) {34	for _, namespace := range slices.Sorted(maps.Keys(byNamespace)) {35		catalogs := byNamespace[namespace]36		if len(catalogs) != 1 {37			for _, catalog := range catalogs {38				if err := o.writeCatalogStatus(ctx, catalog, blockedCatalogStatus(catalog, catalogs, now)); err != nil {39					fmt.Fprintf(os.Stderr, "marking the catalog %s/%s blocked: %v\n",40						catalog.Metadata.Namespace, catalog.Metadata.Name, err)41				}42			}43			continue44		}45		catalog := catalogs[0]46		owners := []OwnerReference{catalogObjectOwner(catalog)}47		// The pod is stood before the status is written, so a Catalog48		// that has just been created reports its own pod on the pass49		// that made it rather than one tick later.50		pod, err := o.standCatalogPod(ctx, catalog)51		if err != nil {52			fmt.Fprintf(os.Stderr, "standing the catalog pod in %s: %v\n", namespace, err)53		}54		if err := o.standCatalogService(ctx, namespace, owners); err != nil {55			fmt.Fprintf(os.Stderr, "standing the catalog service in %s: %v\n", namespace, err)56		}57		if err := o.standCatalogEndpoints(ctx, namespace, owners, members); err != nil {58			fmt.Fprintf(os.Stderr, "standing the catalog endpoints in %s: %v\n", namespace, err)59		}60		if err := o.writeCatalogStatus(ctx, catalog, standingCatalogStatus(catalog, pod, members, now)); err != nil {61			fmt.Fprintf(os.Stderr, "writing the catalog status in %s: %v\n", namespace, err)62		}63	}64}6566// CatalogObjectOwner is the ownerReference the catalog Service and67// EndpointSlice carry. One Catalog per namespace owns both, so it is the68// controller, and the garbage collector removes them when the Catalog is69// deleted.70func catalogObjectOwner(catalog *NamespaceCatalog) OwnerReference {71	return OwnerReference{72		APIVersion: catalogAPIVersion,73		Kind:       "Catalog",74		Name:       catalog.Metadata.Name,75		UID:        catalog.Metadata.UID,76		Controller: true,77	}78}7980// StandingCatalogStatus reports the cluster the Catalog stands: every81// member agent pod of the namespace, the storage size the agents were given,82// and one entry per screen pod with the claim its agent runs on.83//84// Ready follows the catalog pod alone, because that85// pod is what holds the durable catalog and reports it; a Job's pod86// comes and goes and a screen pod holds a copy, so neither decides87// whether the namespace's catalog stands.88func standingCatalogStatus(catalog *NamespaceCatalog, pod *Pod, pods []Pod, now time.Time) CatalogStatus {89	members := catalogMembers(catalog.Metadata.Namespace, pods)90	condition := Condition{91		Type:               catalogConditionReady,92		Status:             ConditionTrue,93		ObservedGeneration: catalog.Metadata.Generation,94		Reason:             catalogReasonStanding,95		Message:            fmt.Sprintf("the namespace catalog stands with %d member agents", len(members)),96	}97	if reason, message := catalogPodBlocker(pod); reason != "" {98		condition.Status = ConditionFalse99		condition.Reason = reason100		condition.Message = message101	}102	return CatalogStatus{103		Members:     members,104		StorageSize: catalogStorageSize(catalog),105		Screens:     catalogScreens(catalog.Metadata.Namespace, pods),106		Conditions:  SetCondition(slices.Clone(catalog.Status.Conditions), condition, now),107	}108}109110// The screen pods of the namespace, in Player order, out of the member111// pods the pass read. A screen carries the name label of its own kind, so the112// catalog pod and a Job's pod are not screens.113func catalogScreens(namespace string, pods []Pod) []CatalogScreen {114	screens := []CatalogScreen{}115	for index := range pods {116		pod := &pods[index]117		if pod.Metadata.Namespace != namespace ||118			pod.Metadata.Labels[scannerLabelKey] != screenLabelValue {119			continue120		}121		screens = append(screens, CatalogScreen{122			Player: pod.Metadata.Labels[playerLabelKey],123			Claim:  screenClaimOf(pod),124			Node:   pod.Spec.NodeName,125			Phase:  pod.Status.Phase,126		})127	}128	sort.Slice(screens, func(one, other int) bool {129		return screens[one].Player < screens[other].Player130	})131	return screens132}133134// The claim a screen pod's catalog agent runs on, read off the pod135// itself, because the pod is what states which volume the agent has. A pod136// on an emptyDir names none.137func screenClaimOf(pod *Pod) string {138	for _, volume := range pod.Spec.Volumes {139		if volume.Name == catalogVolumeName && volume.PersistentVolumeClaim != nil {140			return volume.PersistentVolumeClaim.ClaimName141		}142	}143	return ""144}145146// BlockedCatalogStatus marks a Catalog Blocked when its namespace holds more147// than one, with a condition that names the conflict. The Catalog stands no148// cluster, so it reports no members.149func blockedCatalogStatus(catalog *NamespaceCatalog, catalogs []*NamespaceCatalog, now time.Time) CatalogStatus {150	condition := Condition{151		Type:               catalogConditionReady,152		Status:             ConditionFalse,153		ObservedGeneration: catalog.Metadata.Generation,154		Reason:             catalogReasonManyCatalogs,155		Message:            manyCatalogsMessage(catalogs),156	}157	return CatalogStatus{158		StorageSize: catalogStorageSize(catalog),159		Conditions:  SetCondition(slices.Clone(catalog.Status.Conditions), condition, now),160	}161}162163// CatalogMembers is the member agent pods of the namespace's164// cluster: the catalog pod, the pods of the Jobs that are running, and165// the screen pods, by name, sorted so two passes read the same list.166func catalogMembers(namespace string, pods []Pod) []string {167	members := []string{}168	for index := range pods {169		if pods[index].Metadata.Namespace == namespace {170			members = append(members, pods[index].Metadata.Name)171		}172	}173	sort.Strings(members)174	return members175}176177// WriteCatalogStatus writes only a status that differs from the one the178// Catalog carries, the rule writeLibraryStatus also follows, so a write on179// every pass does not wake the catalogs watch that wakes the pass. A conflict180// means another writer got there first, which the next pass reads.181func (o *operator) writeCatalogStatus(ctx context.Context, catalog *NamespaceCatalog, desired CatalogStatus) error {182	same, err := sameCatalogStatus(catalog.Status, desired)183	if err != nil || same {184		return err185	}186	catalog.Status = desired187	_, err = PutCatalogStatus(ctx, o.client, catalog)188	if errors.Is(err, ErrConflict) {189		return nil190	}191	return err192}193194// SameCatalogStatus compares the marshaled form, because that is what the195// API server stores and what each field's omitempty decides.196func sameCatalogStatus(current, desired CatalogStatus) (bool, error) {197	was, err := json.Marshal(current)198	if err != nil {199		return false, err200	}201	wants, err := json.Marshal(desired)202	if err != nil {203		return false, err204	}205	return string(was) == string(wants), nil206}
catalogupdates.go 96.4%
1package main23// The per-table update stream of the agent's loopback API. An event4// says that a row of the table changed, and nothing more. That is5// enough for the reporter, which reads the table's counts again on6// any change and needs no values from the stream.78import (9	"bufio"10	"context"11	"encoding/json"12	"fmt"13	"io"14	"net/http"15)1617// The update endpoint, one path per replicated table.18const updatesPath = "/v1/updates/"1920// The event a table's update stream sends for every row that changed.21// An insert arrives as an update, and a delete as a delete.22const updateNotify = "notify"2324// Follows one table's update stream until it ends. onOpen is25// called once the agent accepts the stream, and onChange for every row26// event, which carries the row's primary key and never its library.27func (c *Catalog) followUpdates(ctx context.Context, table string, onOpen func(), onChange func()) error {28	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+updatesPath+table, nil)29	if err != nil {30		return err31	}32	req.Header.Set("Accept", "application/json")3334	resp, err := c.http.Do(req)35	if err != nil {36		return err37	}38	defer drain(resp.Body)3940	if resp.StatusCode < 200 || resp.StatusCode > 299 {41		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))42		return fmt.Errorf("catalog updates of %s: %s: %s", table, resp.Status, message)43	}44	onOpen()4546	scanner := bufio.NewScanner(resp.Body)47	scanner.Buffer(make([]byte, 0, 64*1024), queryReadLimit)48	for scanner.Scan() {49		line := scanner.Bytes()50		if len(line) == 0 {51			continue52		}53		var event map[string]json.RawMessage54		if err := json.Unmarshal(line, &event); err != nil {55			return err56		}57		if raw, held := event[subscriptionError]; held {58			var message string59			_ = json.Unmarshal(raw, &message)60			return fmt.Errorf("catalog updates of %s: %s", table, message)61		}62		if _, held := event[updateNotify]; held {63			onChange()64		}65	}66	return scanner.Err()67}
cleanup.go 77.3%
1package main23// The cleanup role is the container of one cleanup Job. It deletes4// one departed library's rows out of the namespace's catalog, through its5// own agent's loopback API, and replication carries the deletes to every6// peer. The API binds loopback alone, which is why a pod does this work7// and the operator cannot.8//9// The sweep is one pass, not a loop. The Job writes its own runs10// row as its last write and waits for the namespace's reporter to publish11// that row back, because an agent that receives SIGTERM drops whatever12// broadcasts it still holds. The echo is what says the standing pod holds13// the deletes.1415import (16	"context"17	"fmt"18	"io"19	"net/http"20	"os"21	"os/signal"22	"syscall"23	"time"24)2526// The argument that selects this role, the way scanMode selects the27// scanner. The operator writes it over the image's entrypoint.28const cleanupMode = "cleanup"2930// cleanupTimeout bounds each request of one sweep, so an agent that31// stops answering cannot hold a sweep open forever.32var cleanupTimeout = 2 * time.Minute3334// One cleanup container: the library it deletes, the catalog it35// deletes through, the bus it hears the echo on, and the log it writes.36type sweeper struct {37	library     string38	job         string39	catalog     *Catalog40	bus         *Bus41	echo        *echoWaiter42	echoTimeout time.Duration43	log         io.Writer44}4546// The role's whole program: read the environment, sweep once, and47// end the process with what the Job left. A failure is a non-zero exit,48// so the Job fails, its rows stay on its own claim, and the retry carries49// them.50func runCleanup() {51	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)52	defer stop()5354	sweep, err := newSweeper(os.Stdout)55	if err != nil {56		stop()57		os.Exit(1)58	}59	if err := sweep.runJob(stopped); err != nil {60		fmt.Fprintf(sweep.log, "library.liken.sh: the cleanup job failed: %v\n", err)61		stop()62		os.Exit(1)63	}64}6566// newSweeper reads the library to sweep from the environment, the67// only place a container with no API credential can learn it, and68// says so in the pod log before the first sweep.69//70// It refuses to build a sweeper when the environment names no broker,71// before anything is written.72func newSweeper(log io.Writer) (*sweeper, error) {73	address, err := echoBusAddress(log)74	if err != nil {75		return nil, err76	}77	namespace := os.Getenv(libraryNamespaceVariable)78	name := os.Getenv(libraryNameVariable)79	base := os.Getenv(topicBaseVariable)80	if base == "" {81		base = defaultTopicBase82	}83	api := os.Getenv(catalogAPIVariable)84	if api == "" {85		api = defaultCatalogAPI86	}8788	fmt.Fprintf(log, "library.liken.sh: sweeping %s/%s out of the catalog\n", namespace, name)8990	sweep := &sweeper{91		library:     libraryKey(namespace, name),92		job:         os.Getenv(jobNameVariable),93		catalog:     NewCatalog(api, &http.Client{Timeout: cleanupTimeout}),94		echoTimeout: echoTimeout(os.Getenv(echoTimeoutVariable)),95		log:         log,96	}97	sweep.echo = newEchoWaiter(libraryStatusTopic(base, namespace, name), workerCleanup, sweep.job)98	sweep.bus = newBus(address, "cleanup-"+namespace+"-"+name, nil, nil, sweep.echo.note)99	return sweep, nil100}101102// The whole of a cleanup Job: take every row the library holds,103// including the runs of every other worker, then write its own run as the104// last row the agent has to broadcast, and wait for the reporter to105// publish it back.106func (s *sweeper) runJob(ctx context.Context) error {107	started := time.Now().UTC()108	if err := s.sweep(ctx); err != nil {109		return err110	}111112	run := libraryRun{Worker: workerCleanup, Job: s.job, Started: started, Finished: time.Now().UTC()}113	if err := s.catalog.UpsertRun(ctx, s.library, run); err != nil {114		return fmt.Errorf("writing the run of %s: %w", s.library, err)115	}116117	// The sweep left the library with no item and no file, so a118	// report that still counts either is one whose deletes have not119	// landed.120	s.echo.expect(0, 0)121	return s.echo.wait(ctx, s.bus, s.echoTimeout)122}123124// Deletes every row of the library in every table, the runs of125// every other worker with them, so the only row this library holds after126// the sweep is the one the Job writes next.127func (s *sweeper) sweep(ctx context.Context) error {128	removed, err := s.catalog.SweepLibrary(ctx, s.library)129	if err != nil {130		fmt.Fprintf(s.log, "library.liken.sh: could not sweep %s: %v\n", s.library, err)131		return fmt.Errorf("sweeping %s: %w", s.library, err)132	}133	runs, err := s.catalog.DeleteRuns(ctx, s.library)134	if err != nil {135		fmt.Fprintf(s.log, "library.liken.sh: could not sweep the runs of %s: %v\n", s.library, err)136		return fmt.Errorf("sweeping the runs of %s: %w", s.library, err)137	}138	fmt.Fprintf(s.log, "library.liken.sh: swept %s: %d rows removed\n", s.library, removed+runs)139	return nil140}
cleanupjob.go 98.0%
1package main23// The cleanup Job is what a deleting Library becomes on its way4// out: the scan pod with the walk taken off it. It runs the same image5// in its cleanup role, beside the same Corrosion agent on the departing6// library's own catalog claim, and it mounts no media volume, because7// it reads no media. That claim already holds every row of the8// namespace's catalog, so the agent starts with nothing to sync and the9// sweep acts on local rows at once.1011import (12	"context"13	"errors"14	"fmt"15	"time"16)1718// The Job one departure becomes, named from the Library, so19// every pass names the same Job and the report's echo names it back.20func cleanupJobName(library string) string {21	return library + "-cleanup"22}2324// The Job that sweeps one library's rows, built from the Library25// and the operator's own settings alone.26func buildCleanupJob(library *Library, scannerImage, corrosionImage, busAddress, topicBase string) *Job {27	backoff, ttl := int32(scanBackoffLimit), int32(scanJobTTL)28	return &Job{29		APIVersion: batchAPIVersion,30		Kind:       "Job",31		Metadata: ObjectMeta{32			Name:            cleanupJobName(library.Metadata.Name),33			Namespace:       library.Metadata.Namespace,34			Labels:          workerLabels(library.Metadata.Name, workerCleanup),35			OwnerReferences: []OwnerReference{libraryOwner(library)},36		},37		Spec: JobSpec{38			BackoffLimit:            &backoff,39			TTLSecondsAfterFinished: &ttl,40			Template: workerPodTemplate(library, workerCleanup,41				cleanupSidecar(library, scannerImage, busAddress, topicBase), corrosionImage),42		},43	}44}4546// The container that runs the sweep. It learns its library from47// its environment alone, it reads the catalog API at the same loopback48// address the scanner reads, and the Job's own name reaches it through49// the downward API, because it writes that name into the runs row.50//51// It carries the broker address and the topic base as well, because it52// waits on the bus for the reporter to publish its run back.53func cleanupSidecar(library *Library, image, busAddress, topicBase string) Container {54	return Container{55		Name:    cleanupContainer,56		Image:   image,57		Command: []string{"/library-operator", cleanupMode},58		Env: []EnvVar{59			{Name: libraryNamespaceVariable, Value: library.Metadata.Namespace},60			{Name: libraryNameVariable, Value: library.Metadata.Name},61			{Name: busAddressVariable, Value: busAddress},62			{Name: topicBaseVariable, Value: topicBase},63			{Name: catalogAPIVariable, Value: defaultCatalogAPI},64			{Name: jobNameVariable, ValueFrom: &EnvVarSource{65				FieldRef: &ObjectFieldSelector{FieldPath: jobNameFieldPath},66			}},67		},68		Resources: ResourceRequirements{69			Requests: map[string]string{"cpu": scannerCPURequest, "memory": scannerMemoryRequest},70			Limits:   map[string]string{"memory": scannerMemoryLimit},71		},72		SecurityContext: unprivileged(),73	}74}7576// The cleanup Job that stands after this pass. There is no77// template hash here, the one departure from every other object this78// operator stands: the Job exists for one teardown, and a rebuild would79// restart the sweep it is running. A Job that failed is deleted on a80// backoff and created again, because the claim admits one holder at a81// time and the failed Job's pod is that holder until it goes.82func (o *operator) standCleanupJob(ctx context.Context, library *Library, jobs []Job) (*Job, error) {83	namespace, name := library.Metadata.Namespace, library.Metadata.Name84	live := cleanupJobOf(jobs, namespace, name)85	if live == nil {86		if !o.mayStandCleanup(libraryKey(namespace, name)) {87			return nil, nil88		}89		created, err := CreateJob(ctx, o.client, buildCleanupJob(library, o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase))90		if errors.Is(err, ErrConflict) {91			// Another writer created it first, which is the state this92			// create was for; the next pass reads it.93			return nil, nil94		}95		if err != nil {96			return nil, err97		}98		return created, nil99	}100	if live.Metadata.DeletionTimestamp != "" {101		return live, nil102	}103	if cleanupFailed(live) {104		if err := DeleteJob(ctx, o.client, namespace, cleanupJobName(name)); err != nil {105			return nil, err106		}107	}108	return live, nil109}110111// The cleanup Job of one Library out of the Jobs the pass112// listed, or nil when none stands.113func cleanupJobOf(jobs []Job, namespace, library string) *Job {114	held := jobsOf(jobs, namespace, library, workerCleanup)115	if len(held) == 0 {116		return nil117	}118	return &held[0]119}120121// A Job with no pod running and a failed pod behind it has given122// up, because Kubernetes replaced that pod up to the backoff limit123// before it stopped.124func cleanupFailed(job *Job) bool {125	return !job.active() && job.Status.Succeeded == 0 && job.Status.Failed > 0126}127128// The sentence a person acts on when a cleanup Job will not129// finish, in the cluster's own counts.130func cleanupBlocker(job *Job) string {131	if job == nil || !cleanupFailed(job) {132		return ""133	}134	return fmt.Sprintf("the cleanup job %s failed after %d attempts",135		job.Metadata.Name, job.Status.Failed)136}137138// The recreate backoff for a cleanup Job that keeps failing. The139// first stand is immediate, and each later one waits double the last,140// up to the cap, the same shape the kubelet's own crash backoff has.141var (142	cleanupBackoffBase = 10 * time.Second143	cleanupBackoffCap  = 5 * time.Minute144)145146// cleanupStand is one departing library's stand count and the147// earliest time it may stand its cleanup Job again.148type cleanupStand struct {149	count int150	next  time.Time151}152153// mayStandCleanup never answers no forever: the wait grows to the154// cap and stops there, so the pass keeps trying for as long as the155// Library is deleting.156func (o *operator) mayStandCleanup(key string) bool {157	now := time.Now()158	state := o.cleanupStands[key]159	if now.Before(state.next) {160		return false161	}162	state.count++163	state.next = now.Add(cleanupBackoffDelay(state.count))164	o.cleanupStands[key] = state165	return true166}167168// cleanupBackoffDelay is the base doubled once per stand, capped, so169// a long run of failures never overflows the duration.170func cleanupBackoffDelay(count int) time.Duration {171	delay := cleanupBackoffBase172	for range count - 1 {173		delay *= 2174		if delay >= cleanupBackoffCap {175			return cleanupBackoffCap176		}177	}178	return delay179}180181// Whether the reporter has echoed this cleanup Job. The Job182// writes its runs row last and the catalog pod publishes the row back183// in the library's report, so a run that names this Job with a time on184// it is the proof that the deletes reached the standing catalog.185func cleanupEchoed(latest *libraryReport, job string) bool {186	if latest == nil {187		return false188	}189	for _, run := range latest.Runs {190		if run.Worker == workerCleanup && run.Job == job && !run.Finished.IsZero() {191			return true192		}193	}194	return false195}196197// The departing Library's rows are gone once its cleanup Job198// exited zero and the reporter echoed that same Job.199func cleanupComplete(job *Job, latest *libraryReport) bool {200	return job != nil && job.Status.Succeeded > 0 && cleanupEchoed(latest, job.Metadata.Name)201}202203// The cleanup Job of a released Library goes with its pods, so204// nothing is left holding the claim the garbage collector removes next.205func (o *operator) retireCleanupJob(ctx context.Context, namespace, library string) error {206	return DeleteJob(ctx, o.client, namespace, cleanupJobName(library))207}
contributorfacts.go 96.4%
1package main23// The people phase. The three facts that fill one person's entry in4// .contributors/, the gap query each of them works from, and the container5// that runs them. Every fact keys on the person's own directory, so its6// ledger, its attempt, and its files sit together under that directory.78import (9	"bytes"10	"context"11	"fmt"12	"os"13	"path/filepath"14	"strings"15	"time"16)1718// The facts the contributors container names in LIBRARY_FACTS, in the order it19// runs them. The ids run first, because the ids are what a later provider of a20// biography or a headshot keys on.21var contributorFactNames = []string{factContributorIDs, factContributorBiography, factContributorHeadshot}2223// The name of the container that runs the people facts.24const contributorsContainerName = "contributors"2526// The provider one Library's sources hold for its people: the first of them27// that is Ready and serves any contributor fact.28func (s providerSet) servingContributors(namespace string, sources []string) *MetadataProvider {29	return s.servingAny(namespace, sources, contributorFactNames)30}3132// One fact's run, bound to its name, so every contributor fact runs the same33// loop over its own gap.34func contributorFactRun(fact string) factRun {35	return func(ctx context.Context, e *enricher) error { return e.contributorFact(ctx, fact) }36}3738// A container with no key fails before it writes anything, so the Job says39// what the pod is missing.40func (e *enricher) contributorFact(ctx context.Context, fact string) error {41	token := os.Getenv(tmdbTokenVariable)42	if token == "" {43		return fmt.Errorf("%s is empty, and the %s fact cannot ask a provider without it",44			tmdbTokenVariable, fact)45	}46	return e.contributorGap(ctx, fact, newTMDbClient(tmdbAPIBase, token))47}4849// A catalog read that fails ends the container, because the gap list is the50// work. One person the provider refuses records an error attempt, and the run51// carries on to the next.52func (e *enricher) contributorGap(ctx context.Context, fact string, client *tmdbClient) error {53	gaps, err := e.catalog.contributorGaps(ctx, e.library, fact, time.Now().UTC(), e.refresh[fact])54	if err != nil {55		return err56	}57	written := 058	for _, gap := range gaps {59		if err := ctx.Err(); err != nil {60			return err61		}62		if !e.inScope(gap.path) {63			continue64		}65		if e.fillContributor(ctx, client, fact, gap) {66			written++67		}68	}69	e.logf("wrote the %s of %d of the %d people that lacked it", fact, written, len(gaps))70	return nil71}7273// One person's gap: the directory the person's files sit in, relative to the74// library root, and the TMDb id the calls key on.75type contributorGap struct {76	path string77	tmdb string78}7980func (e *enricher) fillContributor(ctx context.Context, client *tmdbClient, fact string, gap contributorGap) bool {81	folder := filepath.Join(e.root, gap.path)82	switch fact {83	case factContributorIDs:84		return e.fillContributorIDs(ctx, client, folder, gap)85	case factContributorBiography:86		return e.fillContributorBiography(ctx, client, folder, gap)87	case factContributorHeadshot:88		return e.fillContributorHeadshot(ctx, client, folder, gap)89	}90	return false91}9293// The ids fact. One entry of a YAML file cannot be edited in place, so the94// whole file is read, changed, and written again through the write door. The95// hash the ledger keeps is what makes that safe: a file whose bytes are not96// the ones this fact left is a file a person edited, and the fact stops for97// that person and says so.98func (e *enricher) fillContributorIDs(ctx context.Context, client *tmdbClient,99	folder string, gap contributorGap) bool {100	held, data, err := readContributorFile(filepath.Join(folder, contributorFileName))101	if err != nil {102		e.logf("could not read the entry of %s: %v", gap.path, err)103		e.recordContributor(folder, factContributorIDs, "", attemptError, "")104		return false105	}106	if data == nil {107		e.logf("the entry of %s is not on the volume", gap.path)108		e.recordContributor(folder, factContributorIDs, "", attemptError, "")109		return false110	}111	fought, err := e.contributorHeldByAnother(folder, data)112	if err != nil {113		e.logf("could not read the ledger of %s: %v", gap.path, err)114		e.recordContributor(folder, factContributorIDs, "", attemptError, "")115		return false116	}117	if fought {118		e.logf("another writer holds the entry of %s, so this run left it", gap.path)119		e.recordContributor(folder, factContributorIDs, "", attemptFight, "")120		return false121	}122123	person, err := client.person(ctx, gap.tmdb)124	if err != nil {125		e.logf("could not read the person of %s: %v", gap.path, err)126		e.recordContributor(folder, factContributorIDs, "", attemptError, "")127		return false128	}129	ids, err := client.personIDs(ctx, gap.tmdb)130	if err != nil {131		e.logf("could not read the ids of %s: %v", gap.path, err)132		e.recordContributor(folder, factContributorIDs, "", attemptError, "")133		return false134	}135	return e.writeContributorIDs(folder, gap, held, data, person, ids)136}137138// The write. An id the file already carries stands, because the ids of a139// person are a set every provider adds to, and a date the provider does not140// hold leaves the one the file has. A file the provider's answer does not141// change is left as it is, and the ledger still records the answer.142func (e *enricher) writeContributorIDs(folder string, gap contributorGap,143	held contributorFile, data []byte, person tmdbPerson, ids providerIDs) bool {144	if len(ids) == 0 && person.Birthday == "" && person.Deathday == "" {145		e.logf("the provider holds no ids or dates for %s", gap.path)146		e.recordContributor(folder, factContributorIDs, "", attemptNothing, "")147		return false148	}149	written := marshalContributorFile(filledContributor(held, gap.tmdb, person, ids))150	if bytes.Equal(written, data) {151		e.recordContributor(folder, factContributorIDs, providerBlockTMDb, attemptFound, contentHash(written))152		return false153	}154	if err := e.writer.write(filepath.Join(folder, contributorFileName), written); err != nil {155		e.logf("could not write the entry of %s: %v", gap.path, err)156		e.recordContributor(folder, factContributorIDs, "", attemptError, "")157		return false158	}159	e.logf("wrote the ids of %s from %s", gap.path, providerBlockTMDb)160	e.recordContributor(folder, factContributorIDs, providerBlockTMDb, attemptFound, contentHash(written))161	return true162}163164// The entry the ids fact leaves: every scheme the file and the provider hold165// together, with the file's own id winning where both name one, and the dates166// the provider stated.167func filledContributor(held contributorFile, tmdb string, person tmdbPerson, ids providerIDs) contributorFile {168	filled := held169	filled.IDs = providerIDs{contributorTMDbScheme: tmdb}170	for scheme, id := range ids {171		filled.IDs[scheme] = id172	}173	for scheme, id := range held.IDs {174		if id != "" {175			filled.IDs[scheme] = id176		}177	}178	if born := strings.TrimSpace(person.Birthday); born != "" {179		filled.Born = born180	}181	if died := strings.TrimSpace(person.Deathday); died != "" {182		filled.Died = died183	}184	return filled185}186187// The fight check. The ledger holds the hash of the file this fact last left,188// and a fact with no entry in its ledger has written nothing yet, so whatever189// the file holds is the credits fact's own and this fact takes it over.190func (e *enricher) contributorHeldByAnother(folder string, data []byte) (bool, error) {191	ledger, err := readLikenLedger(folder, factContributorIDs)192	if err != nil {193		return false, err194	}195	held, wrote := ledger.itemAt(likenSelfPath)196	if !wrote || held.Wrote == "" {197		return false, nil198	}199	return contentHash(data) != held.Wrote, nil200}201202// The biography fact. The text lands beside the entry where no file of that203// name exists, so a biography a person wrote by hand stays.204func (e *enricher) fillContributorBiography(ctx context.Context, client *tmdbClient,205	folder string, gap contributorGap) bool {206	if e.contributorFileHeld(folder, contributorBiographyName, factContributorBiography, gap) {207		return false208	}209	person, err := client.person(ctx, gap.tmdb)210	if err != nil {211		e.logf("could not read the person of %s: %v", gap.path, err)212		e.recordContributor(folder, factContributorBiography, "", attemptError, "")213		return false214	}215	text := strings.TrimSpace(person.Biography)216	if text == "" {217		e.logf("the provider holds no biography of %s", gap.path)218		e.recordContributor(folder, factContributorBiography, "", attemptNothing, "")219		return false220	}221	return e.createContributorFile(folder, contributorBiographyName,222		factContributorBiography, gap, []byte(text+"\n"))223}224225// The headshot fact. The image is downloaded and created where no file of that226// name exists, the way an art fact writes a poster.227func (e *enricher) fillContributorHeadshot(ctx context.Context, client *tmdbClient,228	folder string, gap contributorGap) bool {229	if e.contributorFileHeld(folder, contributorHeadshotName, factContributorHeadshot, gap) {230		return false231	}232	person, err := client.person(ctx, gap.tmdb)233	if err != nil {234		e.logf("could not read the person of %s: %v", gap.path, err)235		e.recordContributor(folder, factContributorHeadshot, "", attemptError, "")236		return false237	}238	address := tmdbImageURL(tmdbHeadshotSize, person.ProfilePath)239	if address == "" {240		e.logf("the provider holds no headshot of %s", gap.path)241		e.recordContributor(folder, factContributorHeadshot, "", attemptNothing, "")242		return false243	}244	data, err := client.fetchFile(ctx, address)245	if err != nil {246		e.logf("could not read %s: %v", address, err)247		e.recordContributor(folder, factContributorHeadshot, "", attemptError, "")248		return false249	}250	return e.createContributorFile(folder, contributorHeadshotName, factContributorHeadshot, gap, data)251}252253// The volume is read before the provider is asked, because a file that landed254// since the last walk is the answer already and costs no call. The ledger255// records that the file was already there.256func (e *enricher) contributorFileHeld(folder, name, fact string, gap contributorGap) bool {257	held, err := fileExists(filepath.Join(folder, name))258	if err != nil {259		e.logf("could not read the %s of %s: %v", name, gap.path, err)260		e.recordContributor(folder, fact, "", attemptError, "")261		return true262	}263	if held {264		e.recordContributor(folder, fact, artProviderExisting, attemptFound, "")265	}266	return held267}268269// The create that never lands on a file that exists. A file that arrived270// between the read and the write is kept, and the ledger says so.271func (e *enricher) createContributorFile(folder, name, fact string, gap contributorGap, data []byte) bool {272	written, err := e.writer.createInto(folder, name, data)273	if err != nil {274		e.logf("could not write the %s of %s: %v", name, gap.path, err)275		e.recordContributor(folder, fact, "", attemptError, "")276		return false277	}278	if !written {279		e.recordContributor(folder, fact, artProviderExisting, attemptFound, "")280		return false281	}282	e.logf("wrote the %s of %s from %s", name, gap.path, providerBlockTMDb)283	e.recordContributor(folder, fact, providerBlockTMDb, attemptFound, "")284	return true285}286287// The item entry and the attempt are one write of one file, as every other288// fact records them, so a reader never sees an answer without its attempt. A289// person's ledger sits in the .liken directory of the person's own directory,290// and its one entry is the person.291func (e *enricher) recordContributor(folder, fact, provider, result, wrote string) {292	now := time.Now().UTC()293	err := e.writer.updateLikenLedger(folder, fact, func(ledger *likenLedger) {294		if provider != "" {295			item := likenItem{Path: likenSelfPath, Provider: providerNames{provider}, Wrote: wrote}296			if provider != artProviderExisting {297				item.Written = now298			}299			ledger.noteItem(item)300		}301		ledger.noteAttempt(likenAttempt{Path: likenSelfPath, At: now, Result: result})302	})303	if err != nil {304		e.logf("could not record the %s attempt at %s: %v", fact, folder, err)305	}306	e.writeRows(fact, folder, result == attemptFound)307}308309// One fact's work list, out of the local copy of the catalog, with the same310// query the reporter counts the gap with. Every row names the person's311// directory and the TMDb id to ask for.312func (c *Catalog) contributorGaps(ctx context.Context, library, fact string,313	now, refresh time.Time) ([]contributorGap, error) {314	var gaps []contributorGap315	err := c.stream(ctx, gapQueries[fact], gapParams(fact, library, now, refresh), func(cells []any) error {316		if len(cells) < 2 {317			return nil318		}319		path, _ := cells[0].(string)320		id, _ := cells[1].(string)321		if path == "" || id == "" {322			return nil323		}324		gaps = append(gaps, contributorGap{path: path, tmdb: id})325		return nil326	})327	if err != nil {328		return nil, fmt.Errorf("reading the %s gap of %s: %w", fact, library, err)329	}330	return gaps, nil331}332333// The gap query of one contributor fact. A person with no TMDb id is no gap,334// because every call this image makes keys on that id, and the join is what335// reads it. The query excludes a person with an attempt inside that attempt's336// own window.337func contributorGapSQL(fact, condition string) string {338	return `SELECT c.path, a.id FROM contributors AS c ` +339		`JOIN contributor_aliases AS a ON a.library = c.library AND a.path = c.path ` +340		`AND a.scheme = '` + contributorTMDbScheme + `' ` +341		`WHERE c.library = ?1 AND ` + gapClause(fact, "c.path", condition)342}343344// The ids gap: a person with no birth date, or with no id under any scheme but345// TMDb's own. Both are what the ids fact fills, and either one alone is work.346func contributorIDsGapSQL() string {347	return contributorGapSQL(factContributorIDs,348		`c.born = '' OR NOT EXISTS (SELECT 1 FROM contributor_aliases AS o `+349			`WHERE o.library = c.library AND o.path = c.path `+350			`AND o.scheme != '`+contributorTMDbScheme+`')`)351}352353// The gap of a fact that writes one file: the column the scanner sets where354// the file is beside the entry.355func contributorFileGapSQL(fact, column string) string {356	return contributorGapSQL(fact, `c.`+column+` = 0`)357}
contributorrows.go 95.7%
1package main23// The people the catalog holds, derived from the volume alone: the walk of4// .contributors/, the three tables its files become, and the writes and5// deletes that keep them. A lost catalog gets every one of these rows back6// from the volume on the next walk.78import (9	"context"10	"errors"11	"io/fs"12	"iter"13	"os"14	"path/filepath"15	"strconv"16	"strings"17)1819// One person as the contributors table holds them: the directory that names20// them, relative to the library root, the name a person reads, the two dates,21// and whether the two files are beside the entry. The gap query of each22// contributor fact reads those two marks.23type contributorRow struct {24	Library   string25	Path      string26	Name      string27	Born      string28	Died      string29	Biography bool30	Headshot  bool31}3233// One id of one person, in the shape the item aliases take: the scheme and the34// id name the person, and the path resolves to the row. One person joins35// across libraries by any id two of them share.36type contributorAliasRow struct {37	Library string38	Scheme  string39	ID      string40	Path    string41}4243// One credited person on one title, as credits.yaml states them. The billing44// order is the key beside the item, because it is the one thing a title gives45// each of its people exactly once, and a person with no entry in46// .contributors/ still holds a row with a name and a part.47type creditRow struct {48	Library     string49	Item        string50	Contributor string51	Name        string52	Part        string53	Role        string54	Billing     int55}5657// The ledger files a person's own directory holds. They are not in likenFacts,58// because a title folder holds none of them, and a walk that read three files59// per title that are never there would cost a round trip each on a network60// volume.61var contributorLedgerFacts = []string{62	factContributorIDs, factContributorBiography, factContributorHeadshot,63}6465// The walk of one library's .contributors/ store, one person per result. The66// walk of the titles skips every dot directory, and this one is the exception,67// read after the titles and read only. The full walk feeds each person through68// the same buffer as the titles, because a store holds tens of thousands of69// people and their rows do not fit the scanner's memory at once. A store that70// is not there is no error, because a library whose credits fact has not run71// yet holds none.72func walkContributors(root, library string) iter.Seq[*walkResult] {73	return func(yield func(*walkResult) bool) {74		store := filepath.Join(root, contributorsDirectory)75		letters, err := os.ReadDir(store)76		if errors.Is(err, fs.ErrNotExist) {77			return78		}79		if err != nil {80			yield(readFailed(err))81			return82		}83		for _, letter := range letters {84			if !letter.IsDir() {85				continue86			}87			people, err := os.ReadDir(filepath.Join(store, letter.Name()))88			if err != nil && !yield(readFailed(err)) {89				return90			}91			for _, person := range people {92				if !person.IsDir() {93					continue94				}95				result := &walkResult{}96				readContributorFolder(root, library, filepath.Join(store, letter.Name(), person.Name()), result)97				if !yield(result) {98					return99				}100			}101		}102	}103}104105// readFailed is the result of a directory the walk could not read: no rows,106// and the incomplete mark.107func readFailed(err error) *walkResult {108	result := &walkResult{}109	result.noteReadError(err)110	return result111}112113// One person's directory into its rows. A directory with no contributor.yaml114// is not a person and writes no row, so a stray directory under the store is115// left out of the catalog.116func readContributorFolder(root, library, dir string, result *walkResult) {117	held, data, err := readContributorFile(filepath.Join(dir, contributorFileName))118	result.noteReadError(err)119	if err != nil || data == nil {120		return121	}122	path := relativePath(root, dir)123	biography, err := fileExists(filepath.Join(dir, contributorBiographyName))124	result.noteReadError(err)125	headshot, err := fileExists(filepath.Join(dir, contributorHeadshotName))126	result.noteReadError(err)127128	result.contributors = append(result.contributors, contributorRow{129		Library: library, Path: path, Name: held.Name,130		Born: held.Born, Died: held.Died,131		Biography: biography, Headshot: headshot,132	})133	for _, scheme := range sortedKeys(held.IDs) {134		if id := held.IDs[scheme]; id != "" {135			result.contributorAliases = append(result.contributorAliases, contributorAliasRow{136				Library: library, Scheme: scheme, ID: id, Path: path,137			})138		}139	}140	readLikenSidecar(likenSidecar{141		root: root, dir: dir, library: library, item: path, facts: contributorLedgerFacts,142	}, result)143}144145// The credits of one title, lifted out of the ledger the credits fact wrote.146// The row carries the person's directory as the fact recorded it, so the join147// to the contributors table is one column against one column.148func creditRows(library, item string, credits []creditEntry) []creditRow {149	rows := make([]creditRow, 0, len(credits))150	for _, credit := range credits {151		if strings.TrimSpace(credit.Name) == "" {152			continue153		}154		rows = append(rows, creditRow{155			Library: library, Item: item, Contributor: credit.Contributor,156			Name: credit.Name, Part: credit.Part, Role: credit.Role, Billing: credit.Order,157		})158	}159	return rows160}161162// The write of one person's row, in place, so a re-walk updates the person163// rather than dropping and creating them.164func (c *Catalog) UpsertContributors(ctx context.Context, rows []contributorRow) (int, error) {165	statements := make([]statement, len(rows))166	for i, row := range rows {167		statements[i] = statement{168			sql: `INSERT INTO contributors (library, path, name, born, died, biography, headshot) ` +169				`VALUES (?, ?, ?, ?, ?, ?, ?) ` +170				`ON CONFLICT (library, path) DO UPDATE SET ` +171				`name = excluded.name, born = excluded.born, died = excluded.died, ` +172				`biography = excluded.biography, headshot = excluded.headshot`,173			params: []any{row.Library, row.Path, row.Name, row.Born, row.Died,174				presentValue(row.Biography), presentValue(row.Headshot)},175		}176	}177	return c.apply(ctx, statements)178}179180// A mark the catalog holds as an integer, because the column is one.181func presentValue(held bool) int {182	if held {183		return 1184	}185	return 0186}187188// The write of one id, in place. The scheme and the id are the key beside the189// library, so an id that moves to another person resolves to the person who190// holds it now.191func (c *Catalog) UpsertContributorAliases(ctx context.Context, rows []contributorAliasRow) (int, error) {192	statements := make([]statement, len(rows))193	for i, row := range rows {194		statements[i] = statement{195			sql: `INSERT INTO contributor_aliases (library, scheme, id, path) VALUES (?, ?, ?, ?) ` +196				`ON CONFLICT (library, scheme, id) DO UPDATE SET path = excluded.path`,197			params: []any{row.Library, row.Scheme, row.ID, row.Path},198		}199	}200	return c.apply(ctx, statements)201}202203// The write of one credit, in place, keyed by the title and the billing order,204// so a re-cast of one slot updates the row.205func (c *Catalog) UpsertCredits(ctx context.Context, rows []creditRow) (int, error) {206	statements := make([]statement, len(rows))207	for i, row := range rows {208		statements[i] = statement{209			sql: `INSERT INTO credits (library, item, billing, contributor, name, part, role) ` +210				`VALUES (?, ?, ?, ?, ?, ?, ?) ` +211				`ON CONFLICT (library, item, billing) DO UPDATE SET ` +212				`contributor = excluded.contributor, name = excluded.name, ` +213				`part = excluded.part, role = excluded.role`,214			params: []any{row.Library, row.Item, row.Billing, row.Contributor, row.Name,215				row.Part, row.Role},216		}217	}218	return c.apply(ctx, statements)219}220221// The removes the sweep makes, each naming every key column of its own table,222// so a delete reaches one row and never another library's.223func (c *Catalog) DeleteContributors(ctx context.Context, library string, paths []string) (int, error) {224	return c.apply(ctx, deleteByKey("contributors", "path", library, paths))225}226227func (c *Catalog) DeleteContributorAliases(ctx context.Context, library string, keys []contributorAliasKey) (int, error) {228	statements := make([]statement, len(keys))229	for i, key := range keys {230		statements[i] = statement{231			sql:    `DELETE FROM contributor_aliases WHERE library = ? AND scheme = ? AND id = ?`,232			params: []any{library, key.Scheme, key.ID},233		}234	}235	return c.apply(ctx, statements)236}237238func (c *Catalog) DeleteCredits(ctx context.Context, library string, keys []creditKey) (int, error) {239	statements := make([]statement, len(keys))240	for i, key := range keys {241		statements[i] = statement{242			sql:    `DELETE FROM credits WHERE library = ? AND item = ? AND billing = ?`,243			params: []any{library, key.Item, key.Billing},244		}245	}246	return c.apply(ctx, statements)247}248249// The two composite keys the sweeps read back, each of them the row's own key250// columns after the library.251type contributorAliasKey struct {252	Scheme string253	ID     string254}255256type creditKey struct {257	Item    string258	Billing int259}260261// The keys travel through the sweep as one string, joined by the separator no262// path, scheme, or id holds, the way a link key does.263func contributorAliasSeenKey(row contributorAliasRow) string {264	return row.Scheme + linkKeySeparator + row.ID265}266267func creditSeenKey(row creditRow) string {268	return row.Item + linkKeySeparator + strconv.Itoa(row.Billing)269}270271func contributorAliasKeys(keys []string) []contributorAliasKey {272	out := make([]contributorAliasKey, len(keys))273	for i, key := range keys {274		scheme, id, _ := strings.Cut(key, linkKeySeparator)275		out[i] = contributorAliasKey{Scheme: scheme, ID: id}276	}277	return out278}279280func creditKeys(keys []string) []creditKey {281	out := make([]creditKey, len(keys))282	for i, key := range keys {283		item, billing, _ := strings.Cut(key, linkKeySeparator)284		number, _ := strconv.Atoi(billing)285		out[i] = creditKey{Item: item, Billing: number}286	}287	return out288}289290// The reads of the rows this library holds that the current epoch did not291// mark, one bounded batch, with the two key columns joined the way the mark292// joined them. SQL rebuilds the identical string with char(31).293func contributorAliasPruneSQL() string {294	return `SELECT scheme || char(31) || id FROM contributor_aliases` +295		` WHERE library = ?` +296		` AND '` + seenContributorAlias + `' || scheme || char(31) || id` +297		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +298		` LIMIT ?`299}300301func creditPruneSQL() string {302	return `SELECT item || char(31) || billing FROM credits` +303		` WHERE library = ?` +304		` AND '` + seenCredit + `' || item || char(31) || billing` +305		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +306		` LIMIT ?`307}308309// scopedCreditPruneSQL selects the credits of one title folder that the310// current epoch did not mark. A rescan reaches them through the title311// row the folder holds, so this sweep runs before the item sweeps take312// that row, the way the genre sweep does. A sidecar that lists fewer313// people than before leaves its higher billings unmarked, and they leave314// here.315func scopedCreditPruneSQL() string {316	scope := func(table string) string {317		return `SELECT id FROM ` + table + ` WHERE library = ? AND ` + pathScopeClause("path")318	}319	return `SELECT item || char(31) || billing FROM credits` +320		` WHERE library = ?` +321		` AND '` + seenCredit + `' || item || char(31) || billing` +322		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +323		` AND item IN (` + scope("movies") + ` UNION ` + scope("series") + `)` +324		` LIMIT ?`325}326327func scopedCreditPruneParams(library, folder string, epoch int64) []any {328	params := []any{library, epoch}329	for range 2 {330		params = append(params, library)331		params = append(params, pathScopeParams(folder)...)332	}333	return append(params, pruneBatch)334}335336// One bounded batch of one library's credits, and one of its contributor337// aliases, for the whole-library sweep. Each joins its two key columns338// the way the prune reads join them.339func librarySweepCreditSQL() string {340	return `SELECT item || char(31) || billing FROM credits WHERE library = ? LIMIT ?`341}342343func librarySweepContributorAliasSQL() string {344	return `SELECT scheme || char(31) || id FROM contributor_aliases WHERE library = ? LIMIT ?`345}
contributors.go 98.5%
1package main23// The .contributors/ store at a library root. What names a person's directory,4// what contributor.yaml holds, and how the credits fact creates an entry where5// none exists. The three contributor facts fill the entry, and6// contributorfacts.go holds them.78import (9	"crypto/sha256"10	"encoding/hex"11	"errors"12	"fmt"13	"io/fs"14	"os"15	"path"16	"path/filepath"1718	"gopkg.in/yaml.v3"19)2021// The directory at the library root that holds one directory per person, and22// the three files an entry holds. The store is a dot name, so every ecosystem23// player skips it, and the walk reads it as an exception.24const (25	contributorsDirectory    = ".contributors"26	contributorFileName      = "contributor.yaml"27	contributorBiographyName = "biography.txt"28	contributorHeadshotName  = "headshot.jpg"29)3031// The schemes a slug may take its suffix from, in the order the suffix prefers32// them, and the scheme every contributor gap keys on.33const contributorTMDbScheme = "tmdb"3435var contributorSchemes = []string{contributorTMDbScheme, "imdb"}3637// Contributor.yaml, the file the credits fact creates and the contributor.ids38// fact fills. The ids carry every scheme the providers gave, so one person39// joins across libraries by any of them.40type contributorFile struct {41	Name string      `yaml:"name"`42	IDs  providerIDs `yaml:"ids,omitempty"`43	Born string      `yaml:"born,omitempty"`44	Died string      `yaml:"died,omitempty"`45}4647// The part a person took on the title. The part tells the cast from the crew,48// and the role, which only an actor carries, is the character they played.49const (50	creditPartActor    = "actor"51	creditPartDirector = "director"52	creditPartWriter   = "writer"53)5455// One line of credits.yaml: the person, the part, the billing order, and the56// directory in .contributors/ the person's own files are in. The path is57// relative to the library root, which is the form the contributors table58// holds, so a reader joins the two with no rewriting.59type creditEntry struct {60	Name        string `yaml:"name"`61	Part        string `yaml:"part,omitempty"`62	Role        string `yaml:"role,omitempty"`63	Order       int    `yaml:"order"`64	Contributor string `yaml:"contributor,omitempty"`65}6667// The slug that names a person's directory: the name in natural order, lower-68// cased, folded to ASCII, with every run of other characters becoming one69// hyphen. It is slug's own folding, the one the item slugs take, because a70// person reads the file tree and both names read the same way. A name that71// folds away to nothing keeps the person's provider id alone.72func contributorSlug(name string, ids providerIDs) string {73	if key := slug(name, 0); key != "" {74		return key75	}76	return contributorIDMark(ids)77}7879// The suffix that tells two people of one slug apart: the scheme and the id,80// as in tmdb-31. The first scheme the person carries wins, and a person with81// no id at all carries no mark.82func contributorIDMark(ids providerIDs) string {83	for _, scheme := range contributorSchemes {84		if id := ids[scheme]; id != "" {85			return scheme + "-" + id86		}87	}88	return ""89}9091// Where one slug's directory sits: under the first two characters of the92// slug. First letters of names bunch up, and one letter would hold thousands93// of the people a large library credits; two characters keep the biggest94// bucket near a thousand at thirty thousand people, at the cost of one95// directory read to reach a person. A slug of one character is its own96// bucket, and a hyphen in the second place stays, because the bucket is a97// prefix of the slug and nothing else.98func contributorDirectory(slug string) string {99	if slug == "" {100		return ""101	}102	return path.Join(contributorsDirectory, slug[:min(2, len(slug))], slug)103}104105// Reads one contributor.yaml, with the bytes it read, which the ids fact106// hashes. A file that is not there is no error, because the credits fact107// creates it and every other fact reads it after.108func readContributorFile(file string) (contributorFile, []byte, error) {109	data, err := os.ReadFile(file)110	if errors.Is(err, fs.ErrNotExist) {111		return contributorFile{}, nil, nil112	}113	if err != nil {114		return contributorFile{}, nil, err115	}116	var held contributorFile117	if err := yaml.Unmarshal(data, &held); err != nil {118		return contributorFile{}, nil, fmt.Errorf("reading %s: %w", file, err)119	}120	return held, data, nil121}122123// The bytes of one entry, and the hash the ids fact records for them. Every124// writer of contributor.yaml marshals it the same way, so a file this operator125// wrote hashes to what its ledger holds, and a hand edit does not.126func marshalContributorFile(file contributorFile) []byte {127	// The marshal of this shape cannot fail, because every field of it is a128	// string or the ids map, and the ids marshal by hand into a flow mapping.129	data, _ := yaml.Marshal(file)130	return data131}132133func contentHash(data []byte) string {134	sum := sha256.Sum256(data)135	return hex.EncodeToString(sum[:])136}137138// Whether the entry at a slug is this person. The first scheme both carry139// decides it. An entry that carries no id under a scheme this credit holds is140// the same person, because nothing tells them apart and a store that split141// them would hold one person twice.142func (f contributorFile) isPerson(ids providerIDs) bool {143	for _, scheme := range contributorSchemes {144		held, mine := f.IDs[scheme], ids[scheme]145		if held == "" || mine == "" {146			continue147		}148		return held == mine149	}150	return true151}152153// The directory one credited person's files sit in, created with154// contributor.yaml where none exists. The plain slug belongs to the first155// person written under it, and a second person of the same name takes the slug156// with the id suffix. The read of the entry that is already there is what157// tells the two apart, so the answer is the same whichever title reaches the158// person first, and a run over a library that already holds the person writes159// nothing.160func (e *enricher) contributorFor(person creditedPerson) (string, error) {161	slug := contributorSlug(person.Name, person.IDs)162	if slug == "" {163		return "", nil164	}165	candidates := []string{slug}166	if mark := contributorIDMark(person.IDs); mark != "" && mark != slug {167		candidates = append(candidates, slug+"-"+mark)168	}169	for _, candidate := range candidates {170		directory := contributorDirectory(candidate)171		held, data, err := readContributorFile(filepath.Join(e.root, directory, contributorFileName))172		if err != nil {173			return "", err174		}175		if data == nil {176			if err := e.createContributor(directory, person); err != nil {177				return directory, err178			}179			e.writePersonRows(directory)180			return directory, nil181		}182		if held.isPerson(person.IDs) {183			return directory, nil184		}185	}186	return "", nil187}188189// The entry the credits fact creates: the name, and every id the provider gave190// at credit time. The create never lands on a file that exists, so a person191// another title wrote, or a person edited by hand, is left as it is.192func (e *enricher) createContributor(directory string, person creditedPerson) error {193	data := marshalContributorFile(contributorFile{Name: person.Name, IDs: person.IDs})194	_, err := e.writer.createInto(filepath.Join(e.root, directory), contributorFileName, data)195	return err196}197198// The credits fact's second write: credits.yaml in the title's own .liken199// directory, which is the fact's ledger file, so the credits, the answer, and200// the attempts are one file with one writer. Every person named here has an201// entry in .contributors/ by the time it lands.202func (e *enricher) writeCredits(folder string, answer factAnswer) {203	entries := make([]creditEntry, 0, len(answer.Cast)+len(answer.Directors)+len(answer.Writers))204	for _, actor := range answer.Cast {205		entries = append(entries, creditEntry{206			Name: actor.Name, Part: creditPartActor, Role: actor.Role, Order: actor.Order,207			Contributor: e.contributorPath(actor.person()),208		})209	}210	// The crew take the orders after the cast. The order is the key of a credit211	// beside the title, and the cast holds the orders from zero up to its own212	// length, so a director who also acts holds two credits with two orders.213	for _, crew := range []struct {214		part   string215		people []creditedPerson216	}{217		{part: creditPartDirector, people: answer.Directors},218		{part: creditPartWriter, people: answer.Writers},219	} {220		for _, person := range crew.people {221			entries = append(entries, creditEntry{222				Name: person.Name, Part: crew.part, Order: len(entries),223				Contributor: e.contributorPath(person),224			})225		}226	}227	err := e.writer.updateLikenLedger(folder, factCredits, func(ledger *likenLedger) {228		ledger.Credits = entries229	})230	if err != nil {231		e.logf("could not write the credits at %s: %v", folder, err)232	}233}234235// The directory of one credited person. Where the store cannot be read or236// written, the credit keeps the name and no directory, and the run goes on:237// the credit is the fact, and the directory is a link to it.238func (e *enricher) contributorPath(person creditedPerson) string {239	directory, err := e.contributorFor(person)240	if err != nil {241		e.logf("could not write the entry of %s: %v", person.Name, err)242	}243	return directory244}
depart.go 95.2%
1package main23// A deleted Library takes its rows with it. The catalog4// replicates to every agent in the namespace, and each scan prunes only5// its own library, so a deleted Library's items, files, links, and6// aliases would stay in the standing catalog forever. The operator7// holds a finalizer on every Library, and the deletion window the8// finalizer opens is where a cleanup Job deletes those rows and9// replication carries the deletes to the catalog pod.10//11// The departure is a ladder, read from the top on every pass. The12// schedule goes first, then the departure waits out any scan that is13// still running, because a scan rewrites the rows the sweep deletes and14// holds the ReadWriteOnce claim the cleanup Job needs. The finalizer15// goes only when the cleanup Job exited zero and the reporter echoed16// that same Job back over the bus.17//18// A finalizer's classic cost is an object stuck deleting forever. The19// operator never gives up on a timer: while something blocks the20// departure, it retries and reports the blocker in the Departing21// condition. A namespace with no Catalog releases at once, because22// nothing there holds the rows any more, and that one rule also answers23// a namespace that is itself being deleted.2425import (26	"context"27	"errors"28	"slices"29	"time"30)3132// departure is what one pass decided about a deleting Library:33// whether the finalizer may go, and while it may not, the reason and34// message the Departing condition reports.35type departure struct {36	clear   bool37	reason  string38	message string39}4041// depart runs one pass over a deleting Library. A Library that does42// not hold this operator's finalizer is the API server's to remove,43// and the pass leaves it alone.44func (o *operator) depart(ctx context.Context, library *Library, choice catalogChoice, jobs []Job) error {45	if !library.Metadata.holds(libraryFinalizer) && !library.Metadata.holds(formerLibraryFinalizer) {46		return nil47	}4849	stage, err := o.departureStage(ctx, library, choice, jobs)50	if err != nil {51		return err52	}53	if stage.clear {54		return o.releaseLibrary(ctx, library)55	}56	return writeLibraryStatus(ctx, o.client, library,57		departingStatus(library, stage, time.Now().UTC()))58}5960// departureStage reads every release rule before it acts, so a61// departure that is already complete stands nothing, and a release62// that failed repeats on the next pass without more churn.63func (o *operator) departureStage(ctx context.Context, library *Library, choice catalogChoice, jobs []Job) (departure, error) {64	namespace, name := library.Metadata.Namespace, library.Metadata.Name6566	// A namespace with no Catalog holds no catalog to sweep, so there is67	// nothing to delete and nothing to wait for. This one rule also68	// answers a namespace under deletion, where the Catalog goes with69	// everything else and no new Job can start.70	if choice.catalog == nil {71		if choice.reason == reasonNoCatalog {72			return departure{clear: true}, nil73		}74		// More than one Catalog: the sweep cannot tell which cluster it75		// would be deleting from, so the departure waits for a person.76		return departure{reason: reasonBlocked, message: choice.message}, nil77	}7879	// The schedule goes first, so no new walk starts behind the sweep.80	if err := o.stopScanCronJob(ctx, library); err != nil {81		return departure{}, err82	}8384	// A scan that is still running rewrites the rows the sweep deletes,85	// and it holds the ReadWriteOnce claim the cleanup Job needs. The86	// guard reads the Job and not its pods, because a scan Job between87	// the pods of its backoff has no pod running, and its next pod writes88	// the rows the sweep deleted.89	if scanUnfinished(jobs, namespace, name) {90		return departure{91			reason:  reasonScanRunning,92			message: "a scan job of this library is still running",93		}, nil94	}9596	// An enricher that is still running writes onto the volume, and it writes97	// its own runs row into the catalog the sweep is emptying, so a sweep beside98	// it leaves rows behind. The Job list and the reporter's runs are both read,99	// because a Job between the pods of its backoff has no pod running, and a100	// run in flight can outlive the list this pass read.101	report := o.reports.latestFor(namespace, name)102	if enrichUnfinished(jobs, namespace, name) || (report != nil && enrichInFlight(report.Runs)) {103		return departure{104			reason:  reasonEnrichRunning,105			message: "an enricher job of this library is still running",106		}, nil107	}108109	blocker, err := o.standDepartureClaim(ctx, library, choice)110	if err != nil {111		return departure{}, err112	}113	if blocker != "" {114		return departure{reason: reasonBlocked, message: blocker}, nil115	}116117	job, err := o.standCleanupJob(ctx, library, jobs)118	if err != nil {119		return departure{}, err120	}121	if blocker := cleanupBlocker(job); blocker != "" {122		return departure{reason: reasonBlocked, message: blocker}, nil123	}124	if cleanupComplete(job, report) {125		return departure{clear: true}, nil126	}127	if job != nil && job.Status.Succeeded > 0 {128		return departure{129			reason:  reasonAwaitingEcho,130			message: "the sweep is done and the namespace's reporter has not echoed it yet",131		}, nil132	}133	return departure{134		reason:  reasonSweeping,135		message: "the cleanup job is deleting this library's rows from the catalog",136	}, nil137}138139// standDepartureClaim gives the cleanup Job a volume to mount, and140// answers with the sentence a person has to act on, or empty when141// the claim stands. A fresh empty claim is enough: the agent joins142// the namespace's cluster, the rows arrive over gossip, and the sweep143// deletes what has arrived, so the release still waits on the144// reporter's own echo.145func (o *operator) standDepartureClaim(ctx context.Context, library *Library, choice catalogChoice) (string, error) {146	namespace := library.Metadata.Namespace147	name := scannerCatalogClaimName(library.Metadata.Name)148149	_, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)150	if err == nil {151		return "", nil152	}153	if !errors.Is(err, ErrNotFound) {154		return "", err155	}156	return "", o.standCatalogClaim(ctx, library, choice.catalog)157}158159// releaseLibrary lets a swept Library go: it retires the cleanup Job,160// drops the library's retained messages from the bus, and takes the161// finalizer off last, so the act that releases the object is the final162// one. The garbage collector then takes the catalog claim and the163// CronJob with the Library.164func (o *operator) releaseLibrary(ctx context.Context, library *Library) error {165	namespace, name := library.Metadata.Namespace, library.Metadata.Name166167	if err := o.retireCleanupJob(ctx, namespace, name); err != nil {168		return err169	}170	o.clearLibraryTopics(namespace, name)171172	_, err := PatchLibraryFinalizers(ctx, o.client, namespace, name,173		library.Metadata.ResourceVersion,174		library.Metadata.without(libraryFinalizer, formerLibraryFinalizer))175	if errors.Is(err, ErrNotFound) {176		// An object that is already gone is the state this release177		// was for.178		return nil179	}180	if errors.Is(err, ErrConflict) {181		// A write between the list and this patch wakes the libraries182		// watch, and the next pass releases again.183		return nil184	}185	if err != nil {186		return err187	}188	delete(o.cleanupStands, libraryKey(namespace, name))189	return nil190}191192// clearLibraryTopics publishes an empty retained payload on the193// departed library's two topics, which is how MQTT drops a retained194// message, so a subscriber that arrives later reads nothing for a195// Library that is gone.196func (o *operator) clearLibraryTopics(namespace, name string) {197	o.bus.Publish(libraryStatusTopic(o.topicBase, namespace, name), nil, true)198	o.bus.Publish(libraryAvailabilityTopic(o.topicBase, namespace, name), nil, true)199}200201// departingStatus keeps the counts and the volume as the last true202// observation of the library. Only the phase and the Departing203// condition change, and they say how far the teardown reached.204func departingStatus(library *Library, stage departure, now time.Time) LibraryStatus {205	status := library.Status206	status.Phase = phaseDeparting207	status.Conditions = SetCondition(slices.Clone(library.Status.Conditions), Condition{208		Type:               conditionDeparting,209		Status:             ConditionTrue,210		ObservedGeneration: library.Metadata.Generation,211		Reason:             stage.reason,212		Message:            stage.message,213	}, now)214	return status215}
endpoints.go 96.6%
1package main23// Every Corrosion sidecar finds its peers through the short name4// catalog. That name is in the sidecar image's configuration file,5// because Corrosion reads no bootstrap list from the environment. The6// pod's own search path resolves the name to the Service in the pod's7// namespace, in service.go.8//9// That Service names no selector, so this operator writes the10// slice behind it, over the pods of that namespace that carry the11// member label and no others. So an agent joins its namespace's cluster12// and no other.1314import (15	"context"16	"encoding/json"17	"errors"18	"net/http"19	"reflect"20	"slices"21	"strings"22)2324// The API group the slice belongs to, and the address family it25// holds. The address type is one field of the slice, and every address26// in the slice shares it.27const (28	endpointSliceAPIVersion  = "discovery.k8s.io/v1"29	endpointSliceAddressType = "IPv4"30)3132// The Service the slice belongs to, and the port the agents gossip33// on. The protocol is UDP because Corrosion gossips over QUIC. The34// Service in service.go is built from these same constants, so the35// port names cannot drift apart.36const (37	catalogServiceName  = "catalog"38	catalogPortName     = "gossip"39	catalogPortProtocol = "UDP"40	catalogPort         = 878741)4243// The two labels the slice carries. The service-name label is how a44// Service's slices are found, and it is the whole tie between this45// slice and the Service, which names no selector. The managed-by46// label names this operator, which keeps the slice controllers in47// kube-controller-manager from rewriting or deleting the slice.48const (49	serviceNameLabel     = "kubernetes.io/service-name"50	managedByLabel       = "endpointslice.kubernetes.io/managed-by"51	endpointSliceManager = "library-operator"52)5354// The EndpointSlice, in the same hand-written form as the other55// objects. Endpoints and ports carry no omitempty, because an empty56// endpoints list is the state of a namespace with no member pod, and it57// must reach the API server as a list and not as null.58type EndpointSlice struct {59	APIVersion  string         `json:"apiVersion,omitempty"`60	Kind        string         `json:"kind,omitempty"`61	Metadata    ObjectMeta     `json:"metadata"`62	AddressType string         `json:"addressType"`63	Endpoints   []Endpoint     `json:"endpoints"`64	Ports       []EndpointPort `json:"ports"`65}6667// One endpoint is one pod. Addresses holds the pod's own address.68// NodeName lets a reader find the endpoints local to a node. TargetRef69// names the pod the address belongs to, so kubectl describe reports70// the pod and not the address alone.71type Endpoint struct {72	Addresses  []string           `json:"addresses"`73	Conditions EndpointConditions `json:"conditions"`74	NodeName   string             `json:"nodeName,omitempty"`75	TargetRef  *ObjectReference   `json:"targetRef,omitempty"`76}7778// Ready is the only condition this operator states, and it carries the79// kubelet's own verdict on the pod. The Service publishes not-ready80// addresses too, so an agent that is starting is still a peer to gossip81// with, and the slice still says which peers are up.82type EndpointConditions struct {83	Ready bool `json:"ready"`84}8586// An ObjectReference names one object. These are the four fields the87// targetRef of a pod endpoint carries.88type ObjectReference struct {89	Kind      string `json:"kind,omitempty"`90	Namespace string `json:"namespace,omitempty"`91	Name      string `json:"name,omitempty"`92	UID       string `json:"uid,omitempty"`93}9495// One port of the Service. The name ties this port to the port of the96// same name on the Service.97type EndpointPort struct {98	Name     string `json:"name"`99	Protocol string `json:"protocol"`100	Port     int32  `json:"port"`101}102103// BuildCatalogEndpoints builds the slice for one namespace. It104// is a function of the namespace, the owners, and the pods alone, so105// two passes over the same cluster build the same object. The pass106// hands in every pod in the cluster that carries the member label, and107// this reads only the ones in its namespace, which is what keeps one108// namespace's agents out of another's cluster. Every kind is a peer:109// the standing catalog pod, a Job's pod for the length of its run, and110// a screen pod. A pod with no address is not a peer yet, a pod with a111// deletion timestamp is a peer no longer, and a pod that has finished112// gossips no more. The endpoints sort by address, so the order the list113// arrived in never counts as a divergence. The namespace's one Catalog114// owns the slice, so the garbage collector removes it with that115// Catalog.116func buildCatalogEndpoints(namespace string, owners []OwnerReference, members []Pod) *EndpointSlice {117	endpoints := []Endpoint{}118	for index := range members {119		pod := &members[index]120		if pod.Metadata.Namespace != namespace {121			continue122		}123		if pod.Status.PodIP == "" || pod.Metadata.DeletionTimestamp != "" {124			continue125		}126		if pod.Status.Phase == podSucceeded || pod.Status.Phase == podFailed {127			continue128		}129		endpoints = append(endpoints, Endpoint{130			Addresses:  []string{pod.Status.PodIP},131			Conditions: EndpointConditions{Ready: everyContainerReady(pod)},132			NodeName:   pod.Spec.NodeName,133			TargetRef: &ObjectReference{134				Kind:      "Pod",135				Namespace: pod.Metadata.Namespace,136				Name:      pod.Metadata.Name,137				UID:       pod.Metadata.UID,138			},139		})140	}141	slices.SortFunc(endpoints, func(one, other Endpoint) int {142		return strings.Compare(one.Addresses[0], other.Addresses[0])143	})144	return &EndpointSlice{145		APIVersion: endpointSliceAPIVersion,146		Kind:       "EndpointSlice",147		Metadata: ObjectMeta{148			Name:      catalogServiceName,149			Namespace: namespace,150			Labels: map[string]string{151				serviceNameLabel: catalogServiceName,152				managedByLabel:   endpointSliceManager,153			},154			OwnerReferences: owners,155		},156		AddressType: endpointSliceAddressType,157		Endpoints:   endpoints,158		Ports: []EndpointPort{159			{Name: catalogPortName, Protocol: catalogPortProtocol, Port: catalogPort},160		},161	}162}163164// StandCatalogEndpoints brings the live slice of one namespace into165// line with the one this pass built. It writes on divergence only: it166// reads the live slice, compares the owners, the endpoints, and the167// ports, and writes only when they differ. That is this project's rule168// for an object a pass rebuilds every ten seconds, because an169// unconditional write wakes every watcher of the object for nothing.170//171// The live slice's resourceVersion makes the write conditional, so a172// slice that something else changed underneath answers a conflict173// instead of being overwritten. A conflict on the create means another174// writer got there first, which is success: the next pass reads what175// that writer wrote.176func (o *operator) standCatalogEndpoints(ctx context.Context, namespace string, owners []OwnerReference, members []Pod) error {177	desired := buildCatalogEndpoints(namespace, owners, members)178179	live, err := GetEndpointSlice(ctx, o.client, namespace, catalogServiceName)180	if errors.Is(err, ErrNotFound) {181		_, err := CreateEndpointSlice(ctx, o.client, desired)182		if errors.Is(err, ErrConflict) {183			return nil184		}185		return err186	}187	if err != nil {188		return err189	}190191	if sameEndpoints(live, desired) {192		return nil193	}194	desired.Metadata.ResourceVersion = live.Metadata.ResourceVersion195	_, err = UpdateEndpointSlice(ctx, o.client, desired)196	return err197}198199// SameEndpoints compares only what this operator states: the owners,200// the endpoints, and the ports. It compares the counts first, so an201// absent list and an empty list read as the same thing. A namespace202// with no member pod leaves an absent list behind, and without this203// rule it would be rewritten every pass.204func sameEndpoints(live, desired *EndpointSlice) bool {205	if !slices.Equal(live.Metadata.OwnerReferences, desired.Metadata.OwnerReferences) {206		return false207	}208	if len(live.Endpoints) != len(desired.Endpoints) || len(live.Ports) != len(desired.Ports) {209		return false210	}211	for index := range desired.Endpoints {212		if !reflect.DeepEqual(live.Endpoints[index], desired.Endpoints[index]) {213			return false214		}215	}216	for index := range desired.Ports {217		if live.Ports[index] != desired.Ports[index] {218			return false219		}220	}221	return true222}223224// GetEndpointSlice reads the live catalog slice of one namespace, for225// the owners and endpoints it holds now and for the resourceVersion226// the write is made conditional on. An absent slice is ErrNotFound,227// which the pass answers by creating one.228func GetEndpointSlice(ctx context.Context, c *Client, namespace, name string) (*EndpointSlice, error) {229	slice := &EndpointSlice{}230	if err := c.RequestJSON(ctx, http.MethodGet, endpointSlicesPath(namespace)+"/"+name, nil, slice); err != nil {231		return nil, err232	}233	return slice, nil234}235236func CreateEndpointSlice(ctx context.Context, c *Client, slice *EndpointSlice) (*EndpointSlice, error) {237	body, err := json.Marshal(slice)238	if err != nil {239		return nil, err240	}241	created := &EndpointSlice{}242	path := endpointSlicesPath(slice.Metadata.Namespace)243	if err := c.RequestJSON(ctx, http.MethodPost, path, body, created); err != nil {244		return nil, err245	}246	return created, nil247}248249// UpdateEndpointSlice writes the whole slice back. The resourceVersion250// in the body makes the write conditional, so a slice that changed251// underneath answers ErrConflict, and the next pass reads it again.252func UpdateEndpointSlice(ctx context.Context, c *Client, slice *EndpointSlice) (*EndpointSlice, error) {253	body, err := json.Marshal(slice)254	if err != nil {255		return nil, err256	}257	written := &EndpointSlice{}258	path := endpointSlicesPath(slice.Metadata.Namespace) + "/" + slice.Metadata.Name259	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {260		return nil, err261	}262	return written, nil263}
enrich.go 100.0%
1package main23// enrich.go is the seam between the enricher Job's containers and the4// operator that creates the Job. Plan 29 builds both sides on the names5// here: the roles the binary runs, the facts those roles fill, the6// results an attempt can record, and the queries that say how much work7// each fact has left. The reporter counts a gap with the same query a8// container works from, so the count the operator schedules on and the9// rows the container finds are one set.1011import (12	"encoding/json"13	"slices"14	"time"15)1617// The roles the enricher Job runs. Every fact container runs facts, which18// runs the facts its container names, in order, in one process. The one19// regular container runs enrich, which writes the runs row last and waits for20// the echo.21const (22	factsMode  = "facts"23	enrichMode = "enrich"24)2526// The variable a facts container reads its work from: the facts it runs, by27// name, separated by commas, in the order it runs them. The container's own28// name is the phase, so kubectl get pod reads as the sequence.29const libraryFactsVariable = "LIBRARY_FACTS"3031// The variable every enricher container reads the refresh times from:32// The Library's spec.refresh as one JSON object, fact name to an33// RFC 3339 time. The container holds no API credential, so the34// environment is where it reads a Library's field, as it reads the35// ignore list.36const libraryRefreshVariable = "LIBRARY_REFRESH"3738// The refresh time of each fact a Library named, and the zero time for39// every fact it did not.40type refreshTimes map[string]time.Time4142// A value this image cannot read is no refresh at all, because a43// container that read a bad value as a refresh would ask a provider44// about every title of the library.45func parseRefresh(raw string) refreshTimes {46	times := refreshTimes{}47	if raw == "" {48		return times49	}50	read := refreshTimes{}51	if err := json.Unmarshal([]byte(raw), &read); err != nil {52		return times53	}54	return read55}5657// The worker name the enricher Job's runs row carries. One row per58// Library, whatever the Job's scope, so the operator reads one entry to59// know whether an enrich run is in flight.60const workerEnrich = "enrich"6162// The environment an identity container reads its TMDb key from. The63// operator fills it from the Secret a MetadataProvider names, through a64// secretKeyRef, so the container never reads the API server.65const tmdbTokenVariable = "TMDB_TOKEN"6667// The facts this image fills, in the order they run. A fact is one gap query,68// one name in a container's LIBRARY_FACTS, and one ledger file in .liken/. A69// container runs one fact or several.70const (71	factProbe    = "probe"72	factIdentity = "identity"73)7475// What one attempt left behind. found, candidates, nothing, and fight are76// facts with a date, and the retry interval applies to them. A fight is a77// fact that read its element group on disk, found bytes it did not write, and78// left them; Library status counts it. An error is a provider that was down,79// a key that was refused, or a file that would not open. An error stands for80// its own shorter window, so a fault that lasts is tried again the next day81// and not on every run.82const (83	attemptFound      = "found"84	attemptCandidates = "candidates"85	attemptNothing    = "nothing"86	attemptError      = "error"87	attemptFight      = "fight"88)8990// How long an attempt stands before the fact that wrote it asks again. A91// dated fact stands for thirty days, the guess plan 27 records, because92// providers gain ids and art over time. An error stands for a day, so a93// provider that is down or a volume that will not read is asked again the94// next day, and a fault that stands costs one try a day.95// An attempt an item's release date has outlived stands for neither96// window. beforeReleaseClause carries that rule.97const (98	defaultRetryInterval = 30 * 24 * time.Hour99	errorRetryInterval   = 24 * time.Hour100)101102// The four parameters every gap query binds by number: the library, the103// cutoff a dated attempt stands until, the cutoff an error stands104// until, and the refresh time this fact carries.105// A fact the Library's spec.refresh does not name binds a refresh of106// zero, which every attempt is later than, so the query reads as it did107// before the field existed.108//109// A fact whose items carry a release date binds today as a fifth, in the110// form the released column holds, because an attempt made before an item111// was released stands only until that date. A fact whose items carry112// none names no fifth parameter, so it binds none.113func gapParams(fact, library string, now, refresh time.Time) []any {114	params := []any{library,115		now.Add(-defaultRetryInterval).Unix(),116		now.Add(-errorRetryInterval).Unix(),117		refreshSeconds(refresh, now)}118	if releaseDates(fact) == "" {119		return params120	}121	return append(params, now.UTC().Format(time.DateOnly))122}123124// The refresh time in Unix seconds, and zero where the Library names none or125// names a time that has not come, because a zero time is a large negative126// second that reads as a refresh of the far past. A refresh in the future127// waits for its time and then runs once, where a bound future time would128// reopen the gap on every run until the clock passed it.129func refreshSeconds(refresh, now time.Time) int64 {130	if refresh.IsZero() || refresh.After(now) {131		return 0132	}133	return refresh.Unix()134}135136// The attempt window every gap query carries. An item whose last attempt is a137// dated fact inside the retry window is no gap, and an item whose last138// attempt is an error is no gap until the error window has passed. The139// library is bound by number and never read off the outer row, because a140// subquery that reads the outer row runs again for every item.141//142// An attempt this fact made before the refresh time does not count, so143// it closes no gap.144func attemptClause(fact, column string) string {145	return column + ` NOT IN (SELECT item FROM attempts WHERE attempts.library = ?1 ` +146		`AND ` + attemptFactColumn + ` = '` + fact + `' AND at >= ?4 ` +147		`AND ((result != '` + attemptError + `' AND at >= ?2) ` +148		`OR (result = '` + attemptError + `' AND at >= ?3)))`149}150151// The items this fact attempted before the refresh time. They are a gap152// again whatever the fact's own condition says, because the file the153// fact wrote and the rows it made are still there and the point of a154// refresh is to write them again.155func refreshedClause(fact, column string) string {156	return column + ` IN (SELECT item FROM attempts WHERE attempts.library = ?1 ` +157		`AND ` + attemptFactColumn + ` = '` + fact + `' AND at < ?4)`158}159160// The items this fact attempted before the item was released, where the161// release date has arrived. A provider that held nothing for a title162// before its release day can hold something after it, so an attempt from163// before the release date stands only until that date, and an attempt164// from on or after it stands for the window its own kind carries.165//166// Both sides of the comparison are text, and both sort as dates: the167// catalog holds a release as an ISO date or as a year alone, and a year168// alone reads as the first day of that year. The subquery reads the169// release date off a join by item, never off the outer row, so SQLite170// builds it once.171func beforeReleaseClause(fact, column string) string {172	releases := releaseDates(fact)173	if releases == "" {174		return ""175	}176	return ` OR ` + column + ` IN (SELECT a.item FROM attempts AS a ` +177		`JOIN (` + releases + `) AS r ON r.library = a.library AND r.item = a.item ` +178		`WHERE a.library = ?1 AND a.` + attemptFactColumn + ` = '` + fact + `' ` +179		`AND r.released != '' AND date(a.at, 'unixepoch') < r.released ` +180		`AND ?5 >= r.released)`181}182183// Where the items of one fact carry their release date, as the library,184// the item the fact's attempts key on, and the released column. A fact185// whose items have no release date in the catalog reads as an empty186// string, and the attempt window alone holds its items.187func releaseDates(fact string) string {188	if _, art := artTypes[fact]; art {189		return artReleaseDates(fact)190	}191	if fact == factIdentity || fact == factCredits || slices.Contains(nfoFacts, fact) {192		return titleReleaseDates("id")193	}194	return ""195}196197// The release date of every movie and every series of one library, keyed198// on the item the fact names: the title's own id, or the file the title's199// art lands in.200func titleReleaseDates(item string) string {201	return `SELECT library, ` + item + ` AS item, released FROM movies WHERE library = ?1 ` +202		`UNION ALL SELECT library, ` + item + `, released FROM series WHERE library = ?1`203}204205// The whole tail of a gap query: the fact's own condition for an item206// it has not filled, the refresh that opens an item it did fill, and207// the attempt window that holds an item it tried lately, less the208// attempts the item's own release date has outlived.209// No subquery reads the outer row, so SQLite builds each one once210// for the whole query.211func gapClause(fact, column, missing string) string {212	return `(` + missing + ` OR ` + refreshedClause(fact, column) + `) ` +213		`AND (` + attemptClause(fact, column) + beforeReleaseClause(fact, column) + `)`214}215216// The gap query per fact, keyed by fact. Every query binds the library as ?1,217// the cutoff of a dated attempt in Unix seconds as ?2, and the cutoff of an218// error as ?3, and selects the key of each row that needs work, so a count(*)219// over it is the reporter's number and the rows are the container's work list.220//221// Every query binds the fact's refresh time as ?4. A query whose items222// carry a release date binds today's date as ?5, and no other query223// names a fifth parameter.224//225// A probe gap is a present video file with no duration, which is what a file226// with no streamdetails in its sidecar looks like in the catalog. An identity227// gap is an item whose id is its folder key, so no provider named it. Both228// exclude an item with an attempt inside that attempt's own window. A probe229// whose details landed closes its gap through the duration on the next scan,230// and a probe whose details landed nowhere the scanner reads is tried again231// after the window.232var gapQueries = map[string]string{233	// A video with a length and no tiles beside it.234	factTrickplay: trickplayGapSQL(),235	// A present video the arrival ledger holds no entry for.236	factArrival: arrivalGapSQL(),237	factProbe: `SELECT path FROM files ` +238		`WHERE library = ?1 AND type = 'video' AND present = 1 ` +239		`AND ` + gapClause(factProbe, "path", `duration_ms = 0`),240	// The folder key stays in the outer condition, not the source, so a241	// refresh opens a title a provider has already named.242	factIdentity: `SELECT id FROM (` +243		`SELECT library, id FROM movies ` +244		`UNION ALL SELECT library, id FROM series) AS items ` +245		`WHERE library = ?1 AND ` + gapClause(factIdentity, "id",246		`id LIKE 'movie:path:%' OR id LIKE 'series:path:%'`),247	factOverview:             nfoGapQuery(factOverview),248	factCertification:        nfoGapQuery(factCertification),249	factRatingTMDb:           nfoGapQuery(factRatingTMDb),250	factRatingIMDb:           nfoGapQuery(factRatingIMDb),251	factRatingRottenTomatoes: nfoGapQuery(factRatingRottenTomatoes),252	factRatingMetacritic:     nfoGapQuery(factRatingMetacritic),253	factCredits:              creditsGapQuery(),254	factPoster:               titleArtGapSQL(factPoster),255	factBackdrop:             titleArtGapSQL(factBackdrop),256	factLogo:                 titleArtGapSQL(factLogo),257	factClearart:             titleArtGapSQL(factClearart),258	factBanner:               titleArtGapSQL(factBanner),259	factLandscape:            titleArtGapSQL(factLandscape),260	factDiscart:              titleArtGapSQL(factDiscart),261	factSeasonPoster:         seasonArtGapSQL(factSeasonPoster),262	factSeasonBanner:         seasonArtGapSQL(factSeasonBanner),263	factEpisodeThumb:         episodeThumbGapSQL(),264265	factContributorIDs:       contributorIDsGapSQL(),266	factContributorBiography: contributorFileGapSQL(factContributorBiography, "biography"),267	factContributorHeadshot:  contributorFileGapSQL(factContributorHeadshot, "headshot"),268}269270// The two counts a person reads on the Library beside the gaps. Waiting271// is the identity attempts that ended in candidates for an item still272// unidentified, the titles that need a person. Unresolved is the attempts273// that ended in nothing for an item still unidentified, the titles no274// provider could name.275const (276	waitingQuery = `SELECT count(*) FROM attempts WHERE library = ? AND ` + attemptFactColumn + ` = 'identity' AND result = 'candidates' ` +277		`AND (item LIKE 'movie:path:%' OR item LIKE 'series:path:%')`278	unresolvedQuery = `SELECT count(*) FROM attempts WHERE library = ? AND ` + attemptFactColumn + ` = 'identity' AND result = 'nothing' ` +279		`AND (item LIKE 'movie:path:%' OR item LIKE 'series:path:%')`280)
enrichjob.go 97.9%
1package main23// enrichjob.go builds the enricher Job of one Library and stands the claim4// its catalog agent runs on. The order of the facts is the order of the5// containers in the pod, so a person reads it with kubectl get pod, and the6// operator holds no order of its own.78import (9	"context"10	"encoding/json"11	"errors"12	"strings"13)1415// The fixed part of every enricher Job's name, and the claim its catalog16// agent runs on. A standing Job and a chain Job each add their own suffix,17// and the claim keeps the fixed name, so one Library has one claim.18func enrichJobName(library string) string {19	return library + "-enrich"20}2122func enrichCatalogClaimName(library string) string {23	return enrichJobName(library) + "-catalog"24}2526// The volume the enricher's agent runs on. It is separate from the scan Jobs'27// claim, so a folder enrich never waits on the ReadWriteOnce a scan holds,28// and it keeps the agent's actor id and rows between runs, so a run syncs a29// delta.30func buildEnrichClaim(library *Library, catalog *NamespaceCatalog) *PersistentVolumeClaim {31	return &PersistentVolumeClaim{32		APIVersion: claimAPIVersion,33		Kind:       "PersistentVolumeClaim",34		Metadata: ObjectMeta{35			Name:            enrichCatalogClaimName(library.Metadata.Name),36			Namespace:       library.Metadata.Namespace,37			Labels:          libraryLabels(library.Metadata.Name),38			OwnerReferences: []OwnerReference{libraryOwner(library)},39		},40		Spec: PersistentVolumeClaimSpec{41			AccessModes: []string{accessModeReadWriteOnce},42			Resources: VolumeResourceRequirements{43				Requests: map[string]string{"storage": catalogStorageSize(catalog)},44			},45			StorageClassName: catalog.Spec.Storage.StorageClassName,46		},47	}48}4950// The claim is provisioned once and never rewritten, the rule51// standCatalogClaim follows, because a claim's spec is immutable once it52// binds.53func (o *operator) standEnrichClaim(ctx context.Context, library *Library, catalog *NamespaceCatalog) error {54	namespace := library.Metadata.Namespace55	name := enrichCatalogClaimName(library.Metadata.Name)5657	_, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)58	if err == nil {59		return nil60	}61	if !errors.Is(err, ErrNotFound) {62		return err63	}64	_, err = CreatePersistentVolumeClaim(ctx, o.client, buildEnrichClaim(library, catalog))65	if errors.Is(err, ErrConflict) {66		return nil67	}68	return err69}7071// The enricher Job. The name is the caller's, because a Library runs the72// standing enricher under the walk's name and a webhook's folder runs under73// the chain's. Sources that reach no Ready provider of the identity fact omit74// the identity container, which is how a Library with no Ready provider still75// runs the probe.76func buildEnrichJob(library *Library, providers providerSet, name, path string,77	scannerImage, corrosionImage, busAddress, topicBase string) *Job {78	backoff, ttl := int32(scanBackoffLimit), int32(scanJobTTL)79	return &Job{80		APIVersion: batchAPIVersion,81		Kind:       "Job",82		Metadata: ObjectMeta{83			Name:            name,84			Namespace:       library.Metadata.Namespace,85			Labels:          workerLabels(library.Metadata.Name, workerEnrich),86			OwnerReferences: []OwnerReference{libraryOwner(library)},87		},88		Spec: JobSpec{89			BackoffLimit:            &backoff,90			TTLSecondsAfterFinished: &ttl,91			Template: enrichPodTemplate(library, providers, path,92				scannerImage, corrosionImage, busAddress, topicBase),93		},94	}95}9697// The pod the enricher Job runs. The facts that must run in order are init98// containers, and the enrich container is the one regular container: it99// writes the runs row last and waits for the echo.100func enrichPodTemplate(library *Library, providers providerSet, path string,101	scannerImage, corrosionImage, busAddress, topicBase string) PodTemplateSpec {102	grace := int64(scannerGracePeriod)103	// An enricher holds no Kubernetes credential. It reads its work through the104	// agent beside it and takes the provider key through a secretKeyRef, so105	// nothing in this pod reads the API server.106	noToken := false107108	// The agent starts first, and the facts run in order behind it, because109	// the kubelet starts an init container only when the one before it is up.110	// The facts here edit the same sidecar file, so they must never run at111	// once.112	facts := []Container{113		factsContainer(library, factProbe, []string{factProbe}, path, scannerImage, busAddress, topicBase),114		// The arrival container runs on every Library, because the fact asks no115		// provider. It runs after the probe, because the probe container writes the116		// run's started mark.117		factsContainer(library, arrivalContainerName, []string{factArrival}, path, scannerImage, busAddress, topicBase),118	}119	if providers.serving(library.Metadata.Namespace, library.Spec.Sources, factIdentity) != nil {120		facts = append(facts, factsContainer(library, factIdentity, []string{factIdentity}, path,121			scannerImage, busAddress, topicBase))122	}123	// The nfo container: one phase that runs every fact of the nfo group in124	// order, each fact reading the .nfo and writing its own element group. It125	// names the facts the Library's own sources serve. It runs before the art126	// container because a plot costs one call and an image costs a download, so127	// the cheap facts land first.128	if served := servedNFOFacts(library, providers); len(served) > 0 {129		facts = append(facts, factsContainer(library, nfoContainerName, served, path,130			scannerImage, busAddress, topicBase))131	}132	// The art container. It runs where a Ready provider of the Library's sources133	// serves one of the art facts, and it takes a memory line of its own because134	// it holds an image while it writes it. It is an init container because the135	// enrich container must run last, and a regular container beside it would136	// let the run end before the art is written. Plan 30 makes it a regular137	// container once a second fan-out container exists.138	if served := servedArtFacts(library, providers); len(served) > 0 {139		images := factsContainer(library, artContainerName, served, path,140			scannerImage, busAddress, topicBase)141		images.Resources.Limits = map[string]string{"memory": artMemoryLimit}142		facts = append(facts, images)143	}144	// The contributors container, which fills the people the credits fact named.145	// It runs after the art container, and it is an init container for the same146	// reason the art container is: the enrich container must run last. Plan 30147	// makes both of them regular containers that run at once.148	if providers.servingContributors(library.Metadata.Namespace, library.Spec.Sources) != nil {149		facts = append(facts, factsContainer(library, contributorsContainerName, contributorFactNames,150			path, scannerImage, busAddress, topicBase))151	}152	// The trickplay container. It runs only where the Library turns the fact on,153	// because a first pass over a whole library is hours of CPU. It asks no154	// provider: the file alone answers it. It takes a memory line and a CPU155	// request of its own because it decodes a video where every other container156	// reads rows. It is an init container for the reason the art container is:157	// The enrich container must run last. Plan 30 makes it a regular container158	// once the fan-out exists.159	if library.Spec.Trickplay.Enabled {160		tiles := factsContainer(library, trickplayContainerName, []string{factTrickplay},161			path, scannerImage, busAddress, topicBase)162		tiles.Resources.Requests["cpu"] = trickplayCPURequest163		tiles.Resources.Limits = map[string]string{"memory": trickplayMemoryLimit}164		facts = append(facts, tiles)165	}166	// The same environment carries the source order, so a container asks its167	// providers in the order spec.sources names them.168	keys := providerEnv(library, providers)169	for index := range facts {170		facts[index].Env = append(facts[index].Env, keys...)171	}172	sequence := append([]Container{catalogSidecar(corrosionImage)}, facts...)173174	return PodTemplateSpec{175		Metadata: ObjectMeta{176			Labels: withMemberLabel(workerLabels(library.Metadata.Name, workerEnrich)),177		},178		Spec: PodSpec{179			RestartPolicy:                 "Never",180			TerminationGracePeriodSeconds: &grace,181			AutomountServiceAccountToken:  &noToken,182			InitContainers:                sequence,183			Containers: []Container{184				enrichContainer(library, enrichMode, enrichMode, path, scannerImage, busAddress, topicBase),185			},186			Volumes: []Volume{187				{Name: catalogVolumeName, PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{188					ClaimName: enrichCatalogClaimName(library.Metadata.Name),189				}},190				// The enricher mounts the volume read-write, where every scan Job mounts191				// it read-only, because the facts it fills in are files beside the media.192				// The volume is the claim a screen reads, so an enricher of a franchises193				// library writes beside the art and never into the checkout.194				{Name: libraryVolumeName, PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{195					ClaimName: library.Spec.screenClaim(),196				}},197			},198		},199	}200}201202// One phase's container. Its name is the phase and its command is the role,203// and it learns everything else from the environment, because it holds no204// credential to look a Library up with. The kind's own image is the scanner's205// alone: a scanner a person supplies is not an enricher.206func enrichContainer(library *Library, name, role, path, image, busAddress, topicBase string) Container {207	return Container{208		Name:    name,209		Image:   image,210		Command: []string{"/library-operator", role},211		Env: []EnvVar{212			{Name: libraryNamespaceVariable, Value: library.Metadata.Namespace},213			{Name: libraryNameVariable, Value: library.Metadata.Name},214			{Name: libraryKindVariable, Value: library.Spec.Kind},215			{Name: libraryRootVariable, Value: library.Spec.Storage.Root},216			{Name: busAddressVariable, Value: busAddress},217			{Name: topicBaseVariable, Value: topicBase},218			{Name: catalogAPIVariable, Value: defaultCatalogAPI},219			{Name: libraryIgnoreVariable, Value: ignoreValue(library)},220			{Name: libraryRefreshVariable, Value: refreshValue(library)},221			{Name: scanPathVariable, Value: path},222			{Name: echoTimeoutVariable, Value: defaultEchoTimeout.String()},223			{Name: syncTimeoutVariable, Value: defaultSyncTimeout.String()},224			{Name: jobNameVariable, ValueFrom: &EnvVarSource{225				FieldRef: &ObjectFieldSelector{FieldPath: jobNameFieldPath},226			}},227		},228		VolumeMounts: []VolumeMount{229			{Name: libraryVolumeName, MountPath: libraryMountPath},230		},231		Resources: ResourceRequirements{232			Requests: map[string]string{"cpu": scannerCPURequest, "memory": scannerMemoryRequest},233			Limits:   map[string]string{"memory": scannerMemoryLimit},234		},235		SecurityContext: unprivileged(),236	}237}238239// A container that runs facts. Its name is the phase, and LIBRARY_FACTS names240// the facts it runs in order, so the pod reads as the sequence and one241// container fills more than one gap.242func factsContainer(library *Library, name string, facts []string,243	path, image, busAddress, topicBase string) Container {244	container := enrichContainer(library, name, factsMode, path, image, busAddress, topicBase)245	container.Env = append(container.Env,246		EnvVar{Name: libraryFactsVariable, Value: strings.Join(facts, ",")})247	return container248}249250// The refresh times travel as one JSON value, the way the ignore list251// does, so a fact of any name reaches the container whole. A Library252// that names none writes an empty value.253func refreshValue(library *Library) string {254	if len(library.Spec.Refresh) == 0 {255		return ""256	}257	refresh, _ := json.Marshal(library.Spec.Refresh)258	return string(refresh)259}
enrichrun.go 69.4%
1package main23// enrichrun.go is the enricher Job's one regular container. It writes the4// runs row and waits for the standing pod to echo it, which is what proves5// the rows the init containers left have reached the catalog.67import (8	"context"9	"fmt"10	"io"11	"os"12	"os/signal"13	"syscall"14	"time"15)1617// The container that closes an enricher Job: the enricher's own environment,18// and the bus the echo arrives on.19type enrichRun struct {20	*enricher21	bus         *Bus22	echo        *echoWaiter23	echoTimeout time.Duration24}2526// The role's whole program. A Job that never hears its echo fails, so its27// rows stay on its claim and the retry carries them.28func runEnrich() {29	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)30	defer stop()3132	run, err := newEnrichRun(os.Stdout)33	if err != nil {34		stop()35		os.Exit(1)36	}37	if err := run.runJob(stopped); err != nil {38		run.logf("the enrich job failed: %v", err)39		stop()40		os.Exit(1)41	}42}4344// A container with no broker refuses to start, before it writes anything,45// because it could never hear its echo.46func newEnrichRun(log io.Writer) (*enrichRun, error) {47	address, err := echoBusAddress(log)48	if err != nil {49		return nil, err50	}51	namespace := os.Getenv(libraryNamespaceVariable)52	name := os.Getenv(libraryNameVariable)5354	run := &enrichRun{55		enricher:    newEnricher(log),56		echoTimeout: echoTimeout(os.Getenv(echoTimeoutVariable)),57	}58	run.echo = newEchoWaiter(run.statusTopic, workerEnrich, run.job)59	run.bus = newBus(address, "enrich-"+namespace+"-"+name, nil, nil, run.echo.note)60	return run, nil61}6263// The counts this container expects are the ones its own agent holds now. An64// enricher Job writes to the volume and changes no item or file row, so the65// numbers stand where the last scan left them.66func (r *enrichRun) runJob(ctx context.Context) error {67	counts, err := r.catalog.countsOf(ctx, r.library)68	if err != nil {69		return fmt.Errorf("counting the catalog of %s: %w", r.library, err)70	}7172	run := libraryRun{73		Worker:   workerEnrich,74		Job:      r.job,75		Started:  r.startedAt(ctx),76		Finished: time.Now().UTC(),77	}78	if err := r.catalog.UpsertRun(ctx, r.library, run); err != nil {79		return fmt.Errorf("writing the finished run of %s: %w", r.library, err)80	}8182	r.echo.expect(counts.items, counts.files)83	return r.echo.wait(ctx, r.bus, r.echoTimeout)84}8586// The start time comes off the row the probe container wrote. A row that87// names another Job is a run that never finished, and this container takes88// its own start instead.89func (r *enrichRun) startedAt(ctx context.Context) time.Time {90	now := time.Now().UTC()91	runs, err := r.catalog.Runs(ctx)92	if err != nil {93		r.logf("could not read the run this job started: %v", err)94		return now95	}96	held, found := runOf(runs[r.library], workerEnrich)97	if !found || held.Job != r.job || held.Started.IsZero() {98		return now99	}100	return held.Started101}
enrichschedule.go 100.0%
1package main23// enrichschedule.go decides when an enricher runs. It creates the standing4// enricher Job of a Library when a gap is open and nothing else runs, and it5// carries a webhook's folder through its chain: the folder scan, the folder6// enrich, and the folder rescan that reads what the enricher wrote. The7// operator is the only scheduler. It reads every decision off the Job list8// and the reporter's report, so it keeps no state of its own.910import (11	"context"12	"errors"13	"fmt"14	"hash/fnv"15	"maps"16	"slices"17	"strconv"18	"time"19)2021// A chain Job carries three marks. The pass reads every chain out of the Job22// list on every turn and keeps no record of its own, so a restarted operator23// carries on a chain it did not start.24const (25	chainAnnotation      = "library.liken.sh/chain"26	chainPathAnnotation  = "library.liken.sh/chain-path"27	chainStageAnnotation = "library.liken.sh/chain-stage"28)2930// The stages of a chain, in the order they run. Each stage is the worker its31// Job runs, so a person reads the stage and the runs row as one word.32const (33	chainStageScan   = workerScan34	chainStageEnrich = workerEnrich35	chainStageRescan = workerRescan36)3738// The chain's name. Every Job of the chain carries it and takes its own name39// from it. The hash covers the path and the time, so two webhooks for one40// folder are two chains, and no create collides with a Job that still runs.41func newChain(path string, now time.Time) string {42	sum := fnv.New64a()43	_, _ = sum.Write([]byte(path))44	_, _ = sum.Write([]byte(strconv.FormatInt(now.UnixNano(), 10)))45	return strconv.FormatUint(sum.Sum64(), 36)46}4748// One Job of one chain, named from the Library, the stage, and the chain.49// Every pass names the same Job, so a create that races another pass50// conflicts instead of running the stage twice.51func chainJobName(library, stage, chain string) string {52	return library + "-" + stage + "-" + chain53}5455// The marks one stage's Job carries.56func chainMarks(chain, path, stage string) map[string]string {57	return map[string]string{58		chainAnnotation:      chain,59		chainPathAnnotation:  path,60		chainStageAnnotation: stage,61	}62}6364// The standing enricher of one Library, named from the walk it answers. One65// walk yields one enricher however many passes read it, and the walk after it66// names a new Job at once instead of waiting out the TTL of the finished one.67func standingEnrichJobName(library string, runs []libraryRun) string {68	return chainJobName(library, chainStageEnrich,69		strconv.FormatInt(lastScanFinish(runs).Unix(), 36))70}7172// One chain as the cluster holds it: the folder it covers and the Job of each73// stage that has run.74type chainRun struct {75	id     string76	path   string77	stages map[string]*Job78}7980// The chains of one Library, read out of the Job list alone and returned in81// chain order, so every pass reads them the same way. A Job whose TTL has82// taken it leaves its stage empty, which is what ends a chain that has run83// its course.84func chainsOf(jobs []Job, namespace, library string) []chainRun {85	held := map[string]*chainRun{}86	for index := range jobs {87		job := &jobs[index]88		if job.Metadata.Namespace != namespace || job.Metadata.Labels[libraryLabelKey] != library {89			continue90		}91		id := job.Metadata.Annotations[chainAnnotation]92		if id == "" {93			continue94		}95		chain, known := held[id]96		if !known {97			chain = &chainRun{id: id, path: job.Metadata.Annotations[chainPathAnnotation],98				stages: map[string]*Job{}}99			held[id] = chain100		}101		chain.stages[job.Metadata.Annotations[chainStageAnnotation]] = job102	}103	chains := []chainRun{}104	for _, id := range slices.Sorted(maps.Keys(held)) {105		chains = append(chains, *held[id])106	}107	return chains108}109110// The enrichment step of one Library's pass. It creates at most one Job,111// because every enricher of a Library runs on the one claim its agent keeps.112func (o *operator) enrich(ctx context.Context, library *Library, catalog *NamespaceCatalog,113	report *libraryReport, jobs []Job, providers providerSet) error {114	if report == nil {115		return nil116	}117	namespace, name := library.Metadata.Namespace, library.Metadata.Name118	if libraryBusy(report, jobs, namespace, name) {119		return nil120	}121	served, err := o.serveChain(ctx, library, catalog, report, jobs, providers)122	if err != nil || served {123		return err124	}125	if !scanFollowedEnrich(report.Runs) || !gapOpen(library, report, providers) {126		return nil127	}128	return o.createEnrichJob(ctx, library, catalog, providers,129		standingEnrichJobName(name, report.Runs), "", nil)130}131132// The next stage of the first chain that has one, or false when every chain133// has run its course. Nothing of this Library runs while this is called,134// because the caller has already checked.135func (o *operator) serveChain(ctx context.Context, library *Library, catalog *NamespaceCatalog,136	report *libraryReport, jobs []Job, providers providerSet) (bool, error) {137	namespace, name := library.Metadata.Namespace, library.Metadata.Name138	for _, chain := range chainsOf(jobs, namespace, name) {139		if chain.stages[chainStageRescan] != nil {140			continue141		}142		if chain.stages[chainStageEnrich] != nil {143			return true, o.createChainScan(ctx, library, chain)144		}145		if chain.stages[chainStageScan] == nil || !gapOpen(library, report, providers) {146			continue147		}148		return true, o.createEnrichJob(ctx, library, catalog, providers,149			chainJobName(name, chainStageEnrich, chain.id), chain.path,150			chainMarks(chain.id, chain.path, chainStageEnrich))151	}152	return false, nil153}154155// The enricher Job and the claim it runs on. The claim stands first, because156// a pod that names a claim nothing has created waits Pending until the next157// pass.158func (o *operator) createEnrichJob(ctx context.Context, library *Library, catalog *NamespaceCatalog,159	providers providerSet, name, path string, marks map[string]string) error {160	if err := o.standEnrichClaim(ctx, library, catalog); err != nil {161		return err162	}163	job := buildEnrichJob(library, providers, name, path,164		o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase)165	job.Metadata.Annotations = marks166167	if _, err := CreateJob(ctx, o.client, job); err != nil && !errors.Is(err, ErrConflict) {168		return fmt.Errorf("creating the enrich job %s: %w", name, err)169	}170	return nil171}172173// The last stage of a chain: a scan of the same folder, which reads into the174// catalog what the enricher wrote onto the volume.175func (o *operator) createChainScan(ctx context.Context, library *Library, chain chainRun) error {176	job := buildChainScanJob(library, chain, o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase)177	if _, err := CreateJob(ctx, o.client, job); err != nil && !errors.Is(err, ErrConflict) {178		return fmt.Errorf("creating the rescan job for %s: %w", chain.path, err)179	}180	return nil181}182183// The chain's rescan Job. It runs the scanner the webhook's own Job ran, on184// the same folder, and it carries the marks that say the chain is over.185func buildChainScanJob(library *Library, chain chainRun,186	scannerImage, corrosionImage, busAddress, topicBase string) *Job {187	return &Job{188		APIVersion: batchAPIVersion,189		Kind:       "Job",190		Metadata: ObjectMeta{191			Name:            chainJobName(library.Metadata.Name, chainStageRescan, chain.id),192			Namespace:       library.Metadata.Namespace,193			Labels:          workerLabels(library.Metadata.Name, workerScan),194			Annotations:     chainMarks(chain.id, chain.path, chainStageRescan),195			OwnerReferences: []OwnerReference{libraryOwner(library)},196		},197		Spec: scanJobSpec(library, chain.path, scannerImage, corrosionImage, busAddress, topicBase),198	}199}200201// Whether any work of this Library is in flight, by the Jobs the pass listed202// and by the runs the reporter published. Both are read, because a Job the203// controller has not started has written no run, and a run in flight can204// outlive the Job list the pass read.205func libraryBusy(report *libraryReport, jobs []Job, namespace, library string) bool {206	if scanUnfinished(jobs, namespace, library) || enrichUnfinished(jobs, namespace, library) {207		return true208	}209	for _, worker := range []string{workerScan, workerRescan, workerEnrich} {210		if run, held := runOf(report.Runs, worker); held && run.Finished.IsZero() {211			return true212		}213	}214	return false215}216217// Whether any enricher Job of this Library is still open, by the rule218// scanUnfinished applies to a scan.219func enrichUnfinished(jobs []Job, namespace, library string) bool {220	for _, job := range jobsOf(jobs, namespace, library, workerEnrich) {221		if !job.finished() {222			return true223		}224	}225	return false226}227228// Whether a walk has finished since the last enrich run, so that run's writes229// have become rows and the gap counts are current. A library no walk has230// finished for has no counts to schedule on. A library that has never231// enriched has nothing else to wait for.232func scanFollowedEnrich(runs []libraryRun) bool {233	walked := lastScanFinish(runs)234	if walked.IsZero() {235		return false236	}237	enrich, held := runOf(runs, workerEnrich)238	return !held || walked.After(enrich.Finished)239}240241// When a walk of this library last finished, whether it was the full walk or242// one folder.243func lastScanFinish(runs []libraryRun) time.Time {244	latest := time.Time{}245	for _, worker := range []string{workerScan, workerRescan} {246		if run, held := runOf(runs, worker); held && run.Finished.After(latest) {247			latest = run.Finished248		}249	}250	return latest251}252253// Whether any fact has work left that this Library can do. A fact that254// needs a provider counts only where the Library's sources name one that is255// Ready and serves it, so a library with no key never runs a Job that has256// nothing to ask.257func gapOpen(library *Library, report *libraryReport, providers providerSet) bool {258	for fact, count := range report.Gaps {259		if count <= 0 && !refreshHasWork(library, report, fact) {260			continue261		}262		// The trickplay gap counts only where the Library turned the fact on,263		// because a library that leaves it off never closes that gap and would264		// schedule a Job every pass for ever.265		if fact == factTrickplay {266			if library.Spec.Trickplay.Enabled {267				return true268			}269			continue270		}271		if fact != factProbe && fact != factArrival &&272			providers.serving(library.Metadata.Namespace, library.Spec.Sources, fact) == nil {273			continue274		}275		return true276	}277	return false278}279280// Whether one fact's refresh time has titles left to ask about. The281// reporter counts a gap with no refresh, so a title whose file and rows282// are there counts as filled; the oldest attempt of the fact is what283// says the refresh still has work, and the fact's own run moves that284// attempt past the refresh, which is what ends the work.285func refreshHasWork(library *Library, report *libraryReport, fact string) bool {286	refresh, named := library.Spec.Refresh[fact]287	if !named {288		return false289	}290	oldest, held := report.OldestAttempts[fact]291	return held && refresh.After(oldest) && !refresh.After(time.Now())292}
enrichsync.go 98.3%
1package main23// enrichsync.go is the wait every fact container makes before it reads its4// gap. A fresh or stale claim answers SELECT 1 long before the standing pod's5// rows have reached it, and a gap query against an empty copy reports work6// that is not there. On the first drill the probe read zero files where the7// reporter counted 24.8//9// The copy is synced when it holds the counts the report carries and the10// walk the report names. The counts alone are not enough: a walk that11// changes no item and no file, such as the one after a refresh, leaves the12// counts as they were while its attempts and rows are still on their way,13// and a container that read its gap then found most of the work missing.14// The runs row is the walk's last write, so a copy that holds it holds15// what came before it.1617import (18	"context"19	"encoding/json"20	"fmt"21	"strings"22	"sync"23	"time"24)2526// The bound on the wait, as an environment variable. Ten minutes is the27// default because a first sync of a whole library onto a fresh claim takes28// minutes on the testbed.29const (30	syncTimeoutVariable = "SYNC_TIMEOUT"31	defaultSyncTimeout  = 10 * time.Minute32)3334// The poll of the local copy is a variable so a test drives it in35// milliseconds.36var catalogSyncInterval = time.Second3738// An empty, unreadable, or negative value takes the default, the rule39// echoTimeout follows, because the wait is a bound and not a fact.40func syncTimeout(raw string) time.Duration {41	if raw == "" {42		return defaultSyncTimeout43	}44	timeout, err := time.ParseDuration(raw)45	if err != nil || timeout <= 0 {46		return defaultSyncTimeout47	}48	return timeout49}5051// What a catalogSync holds: the topic the standing pod's report arrives on,52// and what the newest report said the copy must hold.53type catalogSync struct {54	topic string5556	// The mutex covers the target, because the bus handler runs on the bus57	// reader's goroutine and the poll runs on the caller's.58	mutex    sync.Mutex59	reported *syncTarget60}6162// What a report says the copy must hold: its counts, and the finish of63// the last walk, zero where the report names no finished walk.64type syncTarget struct {65	counts libraryCounts66	walked time.Time67}6869func newCatalogSync(topic string) *catalogSync {70	return &catalogSync{topic: topic}71}7273// The newest report replaces the last one, because a report is a whole74// observation of the standing pod's copy.75func (s *catalogSync) note(topic string, payload []byte) {76	if topic != s.topic {77		return78	}79	var report libraryReport80	if json.Unmarshal(payload, &report) != nil {81		return82	}83	s.mutex.Lock()84	s.reported = &syncTarget{85		counts: libraryCounts{items: report.Items, files: report.Files},86		walked: lastScanFinish(report.Runs),87	}88	s.mutex.Unlock()89}9091// A copy is synced when its own counts equal the ones the report carries92// and its own runs hold a walk that finished no earlier than the one the93// report names. A container that has heard no report yet is not synced.94func (s *catalogSync) synced(ctx context.Context, catalog *Catalog, library string) (bool, error) {95	s.mutex.Lock()96	reported := s.reported97	s.mutex.Unlock()98	if reported == nil {99		return false, nil100	}101	counts, err := catalog.countsOf(ctx, library)102	if err != nil {103		return false, err104	}105	if counts != reported.counts {106		return false, nil107	}108	if reported.walked.IsZero() {109		return true, nil110	}111	runs, err := catalog.Runs(ctx)112	if err != nil {113		return false, err114	}115	return !lastScanFinish(runs[library]).Before(reported.walked), nil116}117118// The wait runs the bus, subscribes to the retained report, and polls the119// local copy until it matches. The timeout is a failure exit, so the Job120// retries instead of working from a short list.121func (s *catalogSync) wait(ctx context.Context, bus *Bus, catalog *Catalog,122	library string, timeout time.Duration) error {123	running, stop := context.WithCancel(ctx)124	done := make(chan struct{})125	go func() {126		defer close(done)127		bus.Run(running)128	}()129	defer func() {130		stop()131		<-done132	}()133134	bus.Subscribe(s.topic)135136	deadline := time.NewTimer(timeout)137	defer deadline.Stop()138	ticker := time.NewTicker(catalogSyncInterval)139	defer ticker.Stop()140	for {141		synced, err := s.synced(ctx, catalog, library)142		if err != nil {143			return err144		}145		if synced {146			return nil147		}148		select {149		case <-ticker.C:150		case <-deadline.C:151			return fmt.Errorf("the catalog did not sync onto the claim of %s within %s", library, timeout)152		case <-ctx.Done():153			return ctx.Err()154		}155	}156}157158// Every fact container makes this wait before its gap read. A container159// with no broker refuses to start, because it could never hear the report.160func (e *enricher) awaitCatalogSync(ctx context.Context, fact string) error {161	address, err := echoBusAddress(e.log)162	if err != nil {163		return err164	}165	sync := newCatalogSync(e.statusTopic)166	client := fact + "-sync-" + strings.ReplaceAll(e.library, "/", "-")167	return sync.wait(ctx, newBus(address, client, nil, nil, sync.note),168		e.catalog, e.library, e.syncTimeout)169}
enrichworker.go 100.0%
1package main23// enrichworker.go is what the probe and identity containers share: the4// environment they read, the gap query they work from, and where each of them5// records what it did.67import (8	"context"9	"fmt"10	"io"11	"net/http"12	"os"13	"path"14	"path/filepath"15	"strings"16	"time"17)1819// One enricher container: the Library it serves, the volume it writes, and20// the catalog it reads its gap out of.21type enricher struct {22	library  string23	kind     string24	root     string25	scanPath string26	job      string27	catalog  *Catalog28	writer   *volumeWriter29	log      io.Writer30	// The folder names the walk skips. A fact reads its folder through the31	// scan's reader after each write, and that reader takes the same set.32	ignore ignoreSet33	// The refresh time of every fact the Library named, which the gap34	// query of that fact binds.35	refresh refreshTimes36	// The folder this Job was narrowed to, relative to the library root, and37	// empty where the Job covers the whole library.38	scope string39	// The status topic and the bound are what the wait for the synced copy40	// needs: the topic the standing pod reports the library on, and how long a41	// container waits for its own copy to hold what that report counts.42	statusTopic string43	syncTimeout time.Duration44	// The providers a container can ask, built once and held here, so a provider45	// that spends its day in one fact is not asked again in the next fact of the46	// same container.47	providers *answerLine48	// The providers the art container can ask, built once and held here, so49	// the settings one of them states are read once for the whole container.50	art *artLine51}5253// A container with no API credential learns everything from its environment,54// as the scanner does.55func newEnricher(log io.Writer) *enricher {56	namespace := os.Getenv(libraryNamespaceVariable)57	name := os.Getenv(libraryNameVariable)58	root := os.Getenv(libraryRootVariable)59	if root == "" {60		root = "/"61	}62	api := os.Getenv(catalogAPIVariable)63	if api == "" {64		api = defaultCatalogAPI65	}66	mountRoot := path.Join(libraryMountPath, root)67	job := os.Getenv(jobNameVariable)68	base := os.Getenv(topicBaseVariable)69	if base == "" {70		base = defaultTopicBase71	}7273	work := &enricher{74		library:     libraryKey(namespace, name),75		kind:        os.Getenv(libraryKindVariable),76		root:        mountRoot,77		scanPath:    os.Getenv(scanPathVariable),78		job:         job,79		catalog:     NewCatalog(api, &http.Client{Timeout: catalogWriteTimeout}),80		writer:      newVolumeWriter(job),81		log:         log,82		ignore:      parseIgnore(os.Getenv(libraryIgnoreVariable)),83		refresh:     parseRefresh(os.Getenv(libraryRefreshVariable)),84		statusTopic: libraryStatusTopic(base, namespace, name),85		syncTimeout: syncTimeout(os.Getenv(syncTimeoutVariable)),86	}87	work.scope = work.narrowedScope()88	return work89}9091// A Job that names a folder the volume does not hold covers the whole library92// and not nothing, because a folder that moved still has gaps somewhere.93func (e *enricher) narrowedScope() string {94	if e.scanPath == "" {95		return ""96	}97	absolute := resolveVolumePath(e.root, e.scanPath)98	if absolute == "" {99		e.logf("could not map %s onto the volume, working over the whole library", e.scanPath)100		return ""101	}102	return relativePath(e.root, absolute)103}104105// How a narrowed Job tells a path it owns from one it does not: the path is106// the scope or sits under it.107func (e *enricher) inScope(relative string) bool {108	if e.scope == "" || e.scope == "." {109		return true110	}111	return relative == e.scope || strings.HasPrefix(relative, e.scope+string(filepath.Separator))112}113114// Reads one fact's work list out of the local copy of the catalog, with115// the same query the reporter counts the gap with.116func (e *enricher) gaps(ctx context.Context, fact string, now time.Time) ([]string, error) {117	keys, err := e.catalog.queryStrings(ctx, gapQueries[fact],118		gapParams(fact, e.library, now, e.refresh[fact]))119	if err != nil {120		return nil, fmt.Errorf("reading the %s gap of %s: %w", fact, e.library, err)121	}122	return keys, nil123}124125// The probe container writes the started mark for the whole Job. It is the126// first container to run, and the operator reads a run in flight off a start127// with no finish beside it. Only the last container would leave the Job128// looking idle until the end.129func (e *enricher) markRunStarted(ctx context.Context) error {130	run := libraryRun{Worker: workerEnrich, Job: e.job, Started: time.Now().UTC()}131	if err := e.catalog.UpsertRun(ctx, e.library, run); err != nil {132		return fmt.Errorf("writing the run of %s: %w", e.library, err)133	}134	return nil135}136137// An attempt is recorded whatever the outcome, so a miss is a fact with a138// date and never a hole a fact falls into every run. The entry path is139// relative to the folder that holds the .liken directory, which is how the140// scanner keys it.141func (e *enricher) recordAttempt(folder, fact, entryPath, result string, at time.Time) {142	err := e.writer.updateLikenLedger(folder, fact, func(ledger *likenLedger) {143		ledger.noteAttempt(likenAttempt{Path: entryPath, At: at, Result: result})144	})145	if err != nil {146		e.logf("could not record the %s attempt at %s: %v", fact, entryPath, err)147	}148	e.writeRows(fact, folder, result == attemptFound)149}150151func (e *enricher) logf(format string, args ...any) {152	if e.log == nil {153		return154	}155	fmt.Fprintf(e.log, "library.liken.sh: "+format+"\n", args...)156}157158// The folder whose .liken directory records a file fact's attempt: the159// folder the walk reads a sidecar from, which is the title folder even where160// the file sits in a movie's extras.161func likenFolderFor(kind, absolute string) (string, string) {162	dir := filepath.Dir(absolute)163	if kind == libraryKindMovies && extrasFolderName(filepath.Base(dir)) != "" {164		return filepath.Dir(dir), filepath.Join(filepath.Base(dir), filepath.Base(absolute))165	}166	return dir, filepath.Base(absolute)167}
factrows.go 85.5%
1package main23// A fact writes the rows for what it wrote. After a fact writes a folder, it4// reads that folder into rows through the reader the scan uses and writes5// only the columns it owns, so the catalog holds the fact minutes after the6// file does and no reader waits for the next walk. The files stay the truth.7// These rows are the projection the scan makes of them, made sooner, and the8// next walk writes the same values again.910import (11	"context"12	"errors"13	"io/fs"14	"os"15	"path"16	"path/filepath"17	"strings"18)1920// The reader's view of this library: what the scan's per-folder readers21// take beside the folder. A fact's re-read reads the arrival ledger the way22// the walk does, so the added column it writes back is the ledger's.23func (e *enricher) folderScan() folderScan {24	return folderScan{root: e.root, library: e.library, kind: e.kind, ignore: e.ignore}25}2627// The rows of one folder, through the reader the walk uses. A contributor28// fact reads a person's directory; every other fact reads the title folder29// that holds the path it wrote. A container with no catalog, which a test30// builds, reads nothing.31func (e *enricher) rowsOf(fact, folder string) *walkResult {32	if _, person := contributorFactSet[fact]; person {33		result := &walkResult{}34		readContributorFolder(e.root, e.library, folder, result)35		return result36	}37	return readFolder(e.folderScan(), e.titleFolder(folder))38}3940var contributorFactSet = map[string]bool{41	factContributorIDs: true, factContributorBiography: true, factContributorHeadshot: true,42}4344// The rows one fact owns, written after its ledger. Every fact writes its own45// attempt row. A fact that wrote a file also writes the columns it owns, from46// the folder as it stands now. Where the catalog refuses a write, the run47// goes on, because the files hold the truth and the next walk writes the same48// rows.49func (e *enricher) writeRows(fact, folder string, wrote bool) {50	if e.catalog == nil {51		return52	}53	result := e.rowsOf(fact, folder)54	if result == nil {55		return56	}57	ctx := context.Background()58	if wrote {59		if err := e.writeOwnedRows(ctx, fact, result); err != nil {60			e.logf("could not write the %s rows of %s: %v", fact, relativePath(e.root, folder), err)61		}62	}63	var attempts []attemptRow64	for _, attempt := range result.attempts {65		if attempt.Fact == fact {66			attempts = append(attempts, attempt)67		}68	}69	if _, err := e.catalog.UpsertAttempts(ctx, attempts); err != nil {70		e.logf("could not write the %s attempt row of %s: %v", fact, relativePath(e.root, folder), err)71	}72}7374// Which columns each fact owns, plan 34's table, as the statements that write75// them.76func (e *enricher) writeOwnedRows(ctx context.Context, fact string, result *walkResult) error {77	_, art := artTypes[fact]78	switch {79	case fact == factProbe:80		return e.writeProbeRows(ctx, result)81	case fact == factTrickplay:82		_, err := e.catalog.UpdateFileTrickplay(ctx, filesOfType(result.files, fileTypeVideo))83		return err84	case fact == factArrival:85		return e.writeArrivalRows(ctx, result)86	case fact == factCredits:87		if err := e.writeBodyRows(ctx, result); err != nil {88			return err89		}90		return e.writeCreditRows(ctx, result)91	case nfoFactSet[fact]:92		return e.writeBodyRows(ctx, result)93	case art:94		return e.writeArtRows(ctx, result)95	case contributorFactSet[fact]:96		if _, err := e.catalog.UpdateContributorFacts(ctx, result.contributors); err != nil {97			return err98		}99		_, err := e.catalog.UpsertContributorAliases(ctx, result.contributorAliases)100		return err101	}102	return nil103}104105var nfoFactSet = map[string]bool{106	factOverview: true, factCertification: true, factRatingTMDb: true, factRatingIMDb: true,107	factRatingRottenTomatoes: true, factRatingMetacritic: true,108}109110func filesOfType(files []fileRow, kind string) []fileRow {111	var held []fileRow112	for _, file := range files {113		if file.Type == kind {114			held = append(held, file)115		}116	}117	return held118}119120// The probe owns the stream columns of every video and the duration of the121// items whose sidecar states no runtime of its own.122func (e *enricher) writeProbeRows(ctx context.Context, result *walkResult) error {123	if _, err := e.catalog.UpdateFileStreams(ctx, filesOfType(result.files, fileTypeVideo)); err != nil {124		return err125	}126	var movies, episodes []itemUpdate127	for _, row := range result.movies {128		movies = append(movies, itemUpdate{Library: row.Library, Id: row.Id, Values: []any{row.Duration}})129	}130	for _, row := range result.episodes {131		episodes = append(episodes, itemUpdate{Library: row.Library, Id: row.Id, Values: []any{row.Duration}})132	}133	if _, err := e.catalog.UpdateItemDurations(ctx, "movies", movies); err != nil {134		return err135	}136	_, err := e.catalog.UpdateItemDurations(ctx, "episodes", episodes)137	return err138}139140// The arrival fact owns the arrived column of every video and the added141// column of every item the folder holds, because added derives from the142// ledger the fact wrote. A set's added derives from its members at the next143// walk, because the fold that makes a set is the walk's.144func (e *enricher) writeArrivalRows(ctx context.Context, result *walkResult) error {145	if _, err := e.catalog.UpdateFileArrived(ctx, filesOfType(result.files, fileTypeVideo)); err != nil {146		return err147	}148	var titles, episodes []itemUpdate149	for _, row := range result.movies {150		titles = append(titles, itemUpdate{Library: row.Library, Id: row.Id, Values: []any{row.Added}})151	}152	for _, row := range result.series {153		titles = append(titles, itemUpdate{Library: row.Library, Id: row.Id, Values: []any{row.Added}})154	}155	for _, row := range result.episodes {156		episodes = append(episodes, itemUpdate{Library: row.Library, Id: row.Id, Values: []any{row.Added}})157	}158	if _, err := e.catalog.UpdateItemAdded(ctx, itemTable(e.kind), titles); err != nil {159		return err160	}161	_, err := e.catalog.UpdateItemAdded(ctx, "episodes", episodes)162	return err163}164165// The nfo phase owns the body and the nfo_facts of the title itself. The166// phase edits no episode sidecar, so the episode rows stay as they are.167func (e *enricher) writeBodyRows(ctx context.Context, result *walkResult) error {168	var rows []itemUpdate169	for _, row := range result.movies {170		rows = append(rows, bodyUpdate(row.Library, row.Id, row.Body, row.NFOFacts))171	}172	for _, row := range result.series {173		rows = append(rows, bodyUpdate(row.Library, row.Id, row.Body, row.NFOFacts))174	}175	_, err := e.catalog.UpdateItemBodies(ctx, itemTable(e.kind), rows)176	return err177}178179// The credits fact owns the credits rows of the title, as one set per item.180func (e *enricher) writeCreditRows(ctx context.Context, result *walkResult) error {181	byItem := map[string][]creditRow{}182	for _, row := range result.credits {183		byItem[row.Item] = append(byItem[row.Item], row)184	}185	for _, row := range result.movies {186		if _, err := e.catalog.ReplaceCredits(ctx, row.Library, row.Id, byItem[row.Id]); err != nil {187			return err188		}189	}190	for _, row := range result.series {191		if _, err := e.catalog.ReplaceCredits(ctx, row.Library, row.Id, byItem[row.Id]); err != nil {192			return err193		}194	}195	return nil196}197198// The art phase owns the image files' own rows and links, and the art columns199// of every item the folder holds: a title's poster changes the title's art,200// and a season poster or an episode thumbnail changes an episode's.201func (e *enricher) writeArtRows(ctx context.Context, result *walkResult) error {202	images := filesOfType(result.files, fileTypeImage)203	if _, err := e.catalog.UpsertFiles(ctx, images); err != nil {204		return err205	}206	if _, err := e.catalog.UpsertFileItems(ctx, images); err != nil {207		return err208	}209	var titles, episodes []itemUpdate210	for _, row := range result.movies {211		titles = append(titles, artUpdate(row.Library, row.Id, row.Art, row.Arts))212	}213	for _, row := range result.series {214		titles = append(titles, artUpdate(row.Library, row.Id, row.Art, row.Arts))215	}216	for _, row := range result.episodes {217		episodes = append(episodes, artUpdate(row.Library, row.Id, row.Art, row.Arts))218	}219	if _, err := e.catalog.UpdateItemArt(ctx, itemTable(e.kind), titles); err != nil {220		return err221	}222	_, err := e.catalog.UpdateItemArt(ctx, "episodes", episodes)223	return err224}225226// The person's row and ids, written the moment the credits fact creates the227// entry, so the contributors phase in the same run finds the person in its228// gap. The row lands only where none exists. The contributors phase owns its229// columns from then on.230func (e *enricher) writePersonRows(directory string) {231	if e.catalog == nil {232		return233	}234	result := &walkResult{}235	readContributorFolder(e.root, e.library, filepath.Join(e.root, directory), result)236	ctx := context.Background()237	if _, err := e.catalog.InsertContributors(ctx, result.contributors); err != nil {238		e.logf("could not write the row of %s: %v", directory, err)239		return240	}241	if _, err := e.catalog.UpsertContributorAliases(ctx, result.contributorAliases); err != nil {242		e.logf("could not write the ids of %s: %v", directory, err)243	}244}245246// The identity fact rescans its folder rather than updating a column, because247// the id it wrote is the key of every other row of the title. The scan's own248// single-folder path reads the rows, writes them, and prunes the rows under249// the old key.250func (e *enricher) rescanTitle(folder string) {251	if e.catalog == nil {252		return253	}254	title := e.titleFolder(folder)255	if _, _, err := rescanFolder(context.Background(), e.catalog, e.folderScan(), title); err != nil {256		e.logf("could not rescan %s after its identity: %v", relativePath(e.root, folder), err)257	}258}259260// The title or series folder that holds a path on the volume, which is the261// folder the walk reads as one unit, and whether one was found. A series262// folder is a child of the root. A movie folder is the first level down that263// is a title folder, no deeper than the walk's grouping cap. A level that264// left the volume is that folder, because a rescan of it takes its rows.265func titleFolderOf(root, kind, absolute string) (string, bool) {266	relative := relativePath(root, absolute)267	// The guard names the climb itself and not every name that opens with268	// two dots, so a title such as "...And Justice for All (1979)" is a269	// title folder and never a path that left the root.270	if relative == absolute || relative == "." ||271		relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {272		return "", false273	}274	parts := splitPath(relative)275	if len(parts) == 0 {276		return "", false277	}278	if kind == libraryKindSeries {279		return path.Join(root, parts[0]), true280	}281	folder := root282	for depth, part := range parts {283		if depth > movieGroupingDepth {284			return "", false285		}286		folder = path.Join(folder, part)287		info, err := os.Stat(folder)288		if errors.Is(err, fs.ErrNotExist) {289			return folder, true290		}291		if err != nil || !info.IsDir() {292			return "", false293		}294		if isMovieTitleFolder(folder) {295			return folder, true296		}297	}298	return "", false299}300301// The folder a fact reads after its write: the title folder that holds the302// path, or the path itself where no title folder holds it, so a fact always303// reads what it was given.304func (e *enricher) titleFolder(absolute string) string {305	if folder, held := titleFolderOf(e.root, e.kind, absolute); held {306		return folder307	}308	return absolute309}
factrowscatalog.go 100.0%
1package main23// The statements a fact writes its own columns with. Every column has one4// owner, plan 34's table, and each statement here names that owner's columns5// and no other, so two phases that write one row at the same time never race6// on a column. Corrosion merges a row per column, last writer wins, which is7// what makes that safe. The scan alone writes whole rows.89import (10	"context"11	"encoding/json"12)1314// The item table a kind's titles are in.15func itemTable(kind string) string {16	if kind == libraryKindSeries {17		return "series"18	}19	return "movies"20}2122// The probe's columns of a file: the streams ffprobe read.23func (c *Catalog) UpdateFileStreams(ctx context.Context, rows []fileRow) (int, error) {24	statements := make([]statement, 0, len(rows))25	for _, row := range rows {26		statements = append(statements, statement{27			sql: `UPDATE files SET video_codec = ?, audio_codec = ?, width = ?, height = ?, duration_ms = ? ` +28				`WHERE library = ? AND path = ?`,29			params: []any{row.VideoCodec, row.AudioCodec, row.Width, row.Height, row.DurationMs, row.Library, row.Path},30		})31	}32	return c.apply(ctx, statements)33}3435// The trickplay column of a file, the one column the trickplay fact owns.36func (c *Catalog) UpdateFileTrickplay(ctx context.Context, rows []fileRow) (int, error) {37	statements := make([]statement, 0, len(rows))38	for _, row := range rows {39		statements = append(statements, statement{40			sql:    `UPDATE files SET trickplay = ? WHERE library = ? AND path = ?`,41			params: []any{row.Trickplay, row.Library, row.Path},42		})43	}44	return c.apply(ctx, statements)45}4647// The arrived column, the arrival fact's own, from the ledger the fact48// wrote.49func (c *Catalog) UpdateFileArrived(ctx context.Context, rows []fileRow) (int, error) {50	statements := make([]statement, 0, len(rows))51	for _, row := range rows {52		statements = append(statements, statement{53			sql:    `UPDATE files SET arrived = ? WHERE library = ? AND path = ?`,54			params: []any{row.Arrived, row.Library, row.Path},55		})56	}57	return c.apply(ctx, statements)58}5960// One item's update: the key, and one value per column the caller names.61type itemUpdate struct {62	Library string63	Id      string64	Values  []any65}6667// One UPDATE per item on the named columns of one item table. The table and68// the columns are constants this package names and never input, so naming69// them in the SQL text carries no injection.70func (c *Catalog) updateItems(ctx context.Context, table string, columns []string, rows []itemUpdate) (int, error) {71	set := ""72	for i, column := range columns {73		if i > 0 {74			set += ", "75		}76		set += column + " = ?"77	}78	statements := make([]statement, 0, len(rows))79	for _, row := range rows {80		params := append(append([]any{}, row.Values...), row.Library, row.Id)81		statements = append(statements, statement{82			sql:    `UPDATE ` + table + ` SET ` + set + ` WHERE library = ? AND id = ?`,83			params: params,84		})85	}86	return c.apply(ctx, statements)87}8889// The duration of an item, which the probe owns where the sidecar states no90// runtime of its own.91func (c *Catalog) UpdateItemDurations(ctx context.Context, table string, rows []itemUpdate) (int, error) {92	return c.updateItems(ctx, table, []string{"duration"}, rows)93}9495// The added column of the items a folder holds, the arrival fact's column,96// from a re-read of the ledger it wrote.97func (c *Catalog) UpdateItemAdded(ctx context.Context, table string, rows []itemUpdate) (int, error) {98	return c.updateItems(ctx, table, []string{"added"}, rows)99}100101// The body and the nfo_facts of a title, the nfo phase's columns, from a102// re-parse of the sidecar it wrote.103func (c *Catalog) UpdateItemBodies(ctx context.Context, table string, rows []itemUpdate) (int, error) {104	return c.updateItems(ctx, table, []string{"body", "nfo_facts"}, rows)105}106107// The primary art and the art list of an item, the art phase's columns.108func (c *Catalog) UpdateItemArt(ctx context.Context, table string, rows []itemUpdate) (int, error) {109	return c.updateItems(ctx, table, []string{"art", "arts"}, rows)110}111112func bodyUpdate(library, id string, body any, facts string) itemUpdate {113	payload, _ := json.Marshal(body)114	return itemUpdate{Library: library, Id: id, Values: []any{string(payload), facts}}115}116117func artUpdate(library, id, art string, arts []string) itemUpdate {118	return itemUpdate{Library: library, Id: id, Values: []any{art, artsParam(arts)}}119}120121// The credits of one item as a set: the old rows leave and the new ones land122// in one apply. The billing order is the key, and a person who moved in the123// order would otherwise hold two rows.124func (c *Catalog) ReplaceCredits(ctx context.Context, library, item string, rows []creditRow) (int, error) {125	statements := []statement{{126		sql:    `DELETE FROM credits WHERE library = ? AND item = ?`,127		params: []any{library, item},128	}}129	for _, row := range rows {130		statements = append(statements, statement{131			sql: `INSERT INTO credits (library, item, billing, contributor, name, part, role) ` +132				`VALUES (?, ?, ?, ?, ?, ?, ?)`,133			params: []any{row.Library, row.Item, row.Billing, row.Contributor, row.Name, row.Part, row.Role},134		})135	}136	return c.apply(ctx, statements)137}138139// A person's row as the credits fact creates it, and no write where the row140// exists, because the contributors phase owns the columns from then on.141func (c *Catalog) InsertContributors(ctx context.Context, rows []contributorRow) (int, error) {142	statements := make([]statement, 0, len(rows))143	for _, row := range rows {144		statements = append(statements, statement{145			sql: `INSERT INTO contributors (library, path, name, born, died, biography, headshot) ` +146				`VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (library, path) DO NOTHING`,147			params: []any{row.Library, row.Path, row.Name, row.Born, row.Died, flag(row.Biography), flag(row.Headshot)},148		})149	}150	return c.apply(ctx, statements)151}152153// The contributors phase's columns of a person: what the ids fact and the two154// file facts fill.155func (c *Catalog) UpdateContributorFacts(ctx context.Context, rows []contributorRow) (int, error) {156	statements := make([]statement, 0, len(rows))157	for _, row := range rows {158		statements = append(statements, statement{159			sql: `UPDATE contributors SET born = ?, died = ?, biography = ?, headshot = ? ` +160				`WHERE library = ? AND path = ?`,161			params: []any{row.Born, row.Died, flag(row.Biography), flag(row.Headshot), row.Library, row.Path},162		})163	}164	return c.apply(ctx, statements)165}166167func flag(held bool) int {168	if held {169		return 1170	}171	return 0172}
factsrole.go 61.5%
1package main23// The facts container of the enricher Job. LIBRARY_FACTS names the facts it4// runs, in order, and the container waits once for its synced copy of the5// catalog before the first of them. The pod names the facts, so the operator6// holds no order of its own, and a container is one phase of the run.78import (9	"context"10	"fmt"11	"os"12	"os/signal"13	"syscall"14	"time"15)1617// One fact's whole run against one Library. A fact reads its own gap out of18// the local copy, does its work, and records an attempt per item.19type factRun func(ctx context.Context, e *enricher) error2021// Every fact this image runs, by the name a container puts in LIBRARY_FACTS.22// A name this map does not hold ends the container, because a pod that asks23// for work this image cannot do is a manifest to repair.24var factRuns = map[string]factRun{25	factProbe:     func(ctx context.Context, e *enricher) error { return e.probeFact(ctx) },26	factArrival:   func(ctx context.Context, e *enricher) error { return e.arrivalFact(ctx) },27	factIdentity:  func(ctx context.Context, e *enricher) error { return e.identityFact(ctx) },28	factTrickplay: func(ctx context.Context, e *enricher) error { return e.trickplayFact(ctx) },2930	factOverview:             nfoFactRun(factOverview),31	factCertification:        nfoFactRun(factCertification),32	factRatingTMDb:           nfoFactRun(factRatingTMDb),33	factRatingIMDb:           nfoFactRun(factRatingIMDb),34	factRatingRottenTomatoes: nfoFactRun(factRatingRottenTomatoes),35	factRatingMetacritic:     nfoFactRun(factRatingMetacritic),36	factCredits:              nfoFactRun(factCredits),3738	factPoster:       artFactRun(factPoster),39	factBackdrop:     artFactRun(factBackdrop),40	factLogo:         artFactRun(factLogo),41	factClearart:     artFactRun(factClearart),42	factBanner:       artFactRun(factBanner),43	factLandscape:    artFactRun(factLandscape),44	factDiscart:      artFactRun(factDiscart),45	factSeasonPoster: artFactRun(factSeasonPoster),46	factSeasonBanner: artFactRun(factSeasonBanner),47	factEpisodeThumb: artFactRun(factEpisodeThumb),4849	factContributorIDs:       contributorFactRun(factContributorIDs),50	factContributorBiography: contributorFactRun(factContributorBiography),51	factContributorHeadshot:  contributorFactRun(factContributorHeadshot),52}5354// The role's whole program. A failure is a non-zero exit, so the Job fails55// and Kubernetes retries it.56func runFacts() {57	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)58	defer stop()5960	work := newEnricher(os.Stdout)61	if err := work.runFacts(stopped, namedFacts(os.Getenv(libraryFactsVariable))); err != nil {62		work.logf("the facts container failed: %v", err)63		stop()64		os.Exit(1)65	}66}6768// The facts a container runs, in the order its list names them. An empty name69// is dropped, so a trailing comma or a space around a name names no fact.70func namedFacts(list string) []string {71	return commaNames(list)72}7374// Every name is checked before the first fact runs, so a container that names75// a fact this image cannot run fails before it writes to the volume. Then the76// container waits once for its own copy of the catalog to hold what the77// standing pod reports, because a gap query against a copy that has not78// synced names a fraction of the work. One wait covers every fact in the79// container, because no fact reads a write that another fact in the same80// container made.81func (e *enricher) runFacts(ctx context.Context, facts []string) error {82	if len(facts) == 0 {83		return fmt.Errorf("%s names no fact", libraryFactsVariable)84	}85	for _, name := range facts {86		if _, held := factRuns[name]; !held {87			return fmt.Errorf("%s names %s, which this image does not run", libraryFactsVariable, name)88		}89	}90	// The wait is silent otherwise, and it can run for minutes on a fresh91	// claim, so its two ends are logged.92	e.logf("waiting for the catalog to sync onto this claim")93	started := time.Now()94	if err := e.awaitCatalogSync(ctx, facts[0]); err != nil {95		return err96	}97	e.logf("the catalog synced in %s", time.Since(started).Round(time.Second))98	for _, name := range facts {99		if err := factRuns[name](ctx, e); err != nil {100			return fmt.Errorf("the %s fact failed: %w", name, err)101		}102	}103	return nil104}
fanart.go 100.0%
1package main23// What the art facts ask Fanart.tv: one call per title that answers every art4// type at once, keyed by the TMDb id of a movie and by the TheTVDB id of a5// series. Fanart.tv is the only provider of the clearart, the landscape, the6// discart, and the season banner.78import (9	"context"10	"net/http"11)1213// The provider's own address, which only a test replaces.14var fanartAPIBase = "https://webservice.fanart.tv"1516// The two paths, and the parameter the key travels in. The check calls a17// movie every Fanart.tv account can read, so a key that answers it is a key18// that works.19const (20	fanartMoviePath     = "/v3/movies/"21	fanartSeriesPath    = "/v3/tv/"22	fanartAPIKeyParam   = "api_key"23	fanartCheckPath     = fanartMoviePath + "550"24	fanartSeasonAllMark = "all"25)2627// One account with Fanart.tv, which is the project key alone. A personal key28// on top of it earns fresher art, and the block holds no field for one yet.29type fanartClient struct {30	providerRequests31	key string32}3334func newFanartClient(base, key string) *fanartClient {35	client := &fanartClient{key: key}36	client.providerRequests = newProviderRequests(providerBlockFanart, base,37		func(request *http.Request) { queryKey(fanartAPIKeyParam, client.key)(request) })38	return client39}4041// One image, as every type list holds it. The season field is on the season-42// scoped types alone, and it carries a season number or the word all. The43// likes are a count of the votes the image took.44type fanartImage struct {45	ID       string `json:"id"`46	URL      string `json:"url"`47	Lang     string `json:"lang"`48	Likes    string `json:"likes"`49	Season   string `json:"season,omitempty"`50	Disc     string `json:"disc,omitempty"`51	DiscType string `json:"disc_type,omitempty"`52}5354// The art of one movie, one list per type. The names are Fanart.tv's own, and55// each art fact reads the list of the type it writes.56type fanartMovie struct {57	Name        string        `json:"name"`58	TMDbID      string        `json:"tmdb_id"`59	IMDbID      string        `json:"imdb_id"`60	Posters     []fanartImage `json:"movieposter"`61	Backgrounds []fanartImage `json:"moviebackground"`62	HDLogos     []fanartImage `json:"hdmovielogo"`63	Logos       []fanartImage `json:"movielogo"`64	HDClearart  []fanartImage `json:"hdmovieclearart"`65	Clearart    []fanartImage `json:"movieart"`66	Banners     []fanartImage `json:"moviebanner"`67	Thumbs      []fanartImage `json:"moviethumb"`68	Discs       []fanartImage `json:"moviedisc"`69}7071// The art of one series, with the season-scoped lists beside the ones that72// cover the whole show.73type fanartSeries struct {74	Name          string        `json:"name"`75	TheTVDBID     string        `json:"thetvdb_id"`76	Posters       []fanartImage `json:"tvposter"`77	Backgrounds   []fanartImage `json:"showbackground"`78	HDLogos       []fanartImage `json:"hdtvlogo"`79	Clearlogos    []fanartImage `json:"clearlogo"`80	HDClearart    []fanartImage `json:"hdclearart"`81	Clearart      []fanartImage `json:"clearart"`82	Banners       []fanartImage `json:"tvbanner"`83	Thumbs        []fanartImage `json:"tvthumb"`84	SeasonPosters []fanartImage `json:"seasonposter"`85	SeasonBanners []fanartImage `json:"seasonbanner"`86	SeasonThumbs  []fanartImage `json:"seasonthumb"`87	Characterart  []fanartImage `json:"characterart"`88}8990// The art of one movie, by its TMDb id or its IMDb id, which Fanart.tv reads91// as one lookup key. A title Fanart.tv does not hold answers 404, which is a92// miss and not an error.93func (c *fanartClient) movie(ctx context.Context, id string) (*fanartMovie, error) {94	answer := &fanartMovie{}95	if err := c.get(ctx, fanartMoviePath+id, nil, answer); err != nil {96		if answeredWith(err, http.StatusNotFound) {97			return nil, nil98		}99		return nil, err100	}101	return answer, nil102}103104// The art of one series, by its TheTVDB id. Fanart.tv's series endpoint reads105// that id alone, which is why the identity fact writes every id it can into106// the .nfo.107func (c *fanartClient) series(ctx context.Context, thetvdbID string) (*fanartSeries, error) {108	answer := &fanartSeries{}109	if err := c.get(ctx, fanartSeriesPath+thetvdbID, nil, answer); err != nil {110		if answeredWith(err, http.StatusNotFound) {111			return nil, nil112		}113		return nil, err114	}115	return answer, nil116}117118// The images of one season: the season-scoped list narrowed to that number.119// Fanart.tv marks an image that covers every season with the word all, and120// that image answers for any season.121func seasonImages(images []fanartImage, season string) []fanartImage {122	held := []fanartImage{}123	for _, image := range images {124		if image.Season == season || image.Season == fanartSeasonAllMark {125			held = append(held, image)126		}127	}128	return held129}
fanartart.go 100.0%
1package main23// The art answerer Fanart.tv serves through. Its two lookup keys are the4// whole reason the identity fact writes every id it can: a movie reads on its5// TMDb id, and a series reads on its TheTVDB id, which the sidecar carries.67import (8	"context"9	"slices"10	"strconv"11	"strings"12)1314// Fanart.tv's art answerer, which is one project key and nothing else.15type fanartArtAnswerer struct {16	client *fanartClient17}1819func (a fanartArtAnswerer) providerBlock() string { return providerBlockFanart }2021func (a fanartArtAnswerer) serves(fact string) bool {22	return slices.Contains(providerFacts[providerBlockFanart], fact)23}2425func (a fanartArtAnswerer) fetchFile(ctx context.Context, address string) ([]byte, error) {26	return a.client.fetchFile(ctx, address)27}2829// One call answers every art type of one title, and the fact reads the list30// of the type it writes. The kind of the library picks the endpoint, because31// Fanart.tv keeps a movie and a series apart.32func (a fanartArtAnswerer) candidates(ctx context.Context, fact string, gap artGap,33	title titleRef) ([]artCandidate, error) {34	if title.kind == libraryKindSeries {35		return a.seriesCandidates(ctx, fact, gap, title)36	}37	return a.movieCandidates(ctx, fact, gap, title)38}3940// A movie reads on the TMDb id the gap carries, and on the IMDb id where the41// sidecar holds one and the gap holds no TMDb id, because Fanart.tv reads42// both as one lookup key.43func (a fanartArtAnswerer) movieCandidates(ctx context.Context, fact string, gap artGap,44	title titleRef) ([]artCandidate, error) {45	id := fanartMovieKey(gap, title)46	if id == "" {47		return nil, nil48	}49	movie, err := a.client.movie(ctx, id)50	if err != nil || movie == nil {51		return nil, err52	}53	return fanartCandidates(fanartMovieImages(movie, fact)), nil54}5556func fanartMovieKey(gap artGap, title titleRef) string {57	for _, id := range []string{gap.tmdb, title.ids["tmdb"], title.ids["imdb"]} {58		if id = strings.TrimSpace(id); id != "" {59			return id60		}61	}62	return ""63}6465// A series reads on the TheTVDB id alone, which is what the identity fact66// wrote into the sidecar. A series with none is no answer and not an error,67// because a title this provider cannot be asked about is the ordinary case.68func (a fanartArtAnswerer) seriesCandidates(ctx context.Context, fact string, gap artGap,69	title titleRef) ([]artCandidate, error) {70	id := strings.TrimSpace(title.ids["tvdb"])71	if id == "" {72		return nil, nil73	}74	series, err := a.client.series(ctx, id)75	if err != nil || series == nil {76		return nil, err77	}78	return fanartCandidates(fanartSeriesImages(series, fact, gap.season)), nil79}8081// Which of the movie lists each art fact reads. The logo and the clearart82// name two lists because Fanart.tv keeps the high-definition art in a list of83// its own, and this project takes it where the provider holds any.84func fanartMovieImages(movie *fanartMovie, fact string) []fanartImage {85	switch fact {86	case factPoster:87		return movie.Posters88	case factBackdrop:89		return movie.Backgrounds90	case factLogo:91		return firstHeldImages(movie.HDLogos, movie.Logos)92	case factClearart:93		return firstHeldImages(movie.HDClearart, movie.Clearart)94	case factBanner:95		return movie.Banners96	case factLandscape:97		return movie.Thumbs98	case factDiscart:99		return movie.Discs100	}101	return nil102}103104// Which of the series lists each art fact reads. The season art is narrowed105// to the season the gap names. The disc art has no series list, because a106// series carries no disc.107func fanartSeriesImages(series *fanartSeries, fact string, season int) []fanartImage {108	switch fact {109	case factPoster:110		return series.Posters111	case factBackdrop:112		return series.Backgrounds113	case factLogo:114		return firstHeldImages(series.HDLogos, series.Clearlogos)115	case factClearart:116		return firstHeldImages(series.HDClearart, series.Clearart)117	case factBanner:118		return series.Banners119	case factLandscape:120		return series.Thumbs121	case factSeasonPoster:122		return seasonImages(series.SeasonPosters, strconv.Itoa(season))123	case factSeasonBanner:124		return seasonImages(series.SeasonBanners, strconv.Itoa(season))125	}126	return nil127}128129// The first list the provider holds any image in.130func firstHeldImages(lists ...[]fanartImage) []fanartImage {131	for _, list := range lists {132		if len(list) > 0 {133			return list134		}135	}136	return nil137}138139// One list as the choice reads it. The likes are the votes and the lang is140// the language. Fanart.tv states the likes as a string, so a count it did not141// state reads as no votes.142func fanartCandidates(images []fanartImage) []artCandidate {143	candidates := []artCandidate{}144	for _, image := range images {145		if strings.TrimSpace(image.URL) == "" {146			continue147		}148		likes, _ := strconv.Atoi(strings.TrimSpace(image.Likes))149		candidates = append(candidates, artCandidate{150			URL:      image.URL,151			Language: image.Lang,152			Votes:    float64(likes),153		})154	}155	return candidates156}
files.go 98.1%
1package main23// files.go classifies every file a title folder carries: the sidecars, the4// art, the subtitles, the trickplay tiles, and the extras beside the video.5// The classification reads a file's name and the place that holds it, and6// opens no media file, so a re-walk classifies a file the same way every time7// and a large library costs one stat per file. A video row also reads the8// sidecar beside it, for the stream details the probe wrote there.910import (11	"errors"12	"io/fs"13	"os"14	"path/filepath"15	"strings"16)1718// The categories a file falls into. The set is closed, so the media browser19// switches on it, and a file that fits none of them is other rather than a new20// word.21const (22	fileTypeVideo     = "video"23	fileTypeAudio     = "audio"24	fileTypeSubtitle  = "subtitle"25	fileTypeImage     = "image"26	fileTypeMetadata  = "metadata"27	fileTypeTrickplay = "trickplay"28	fileTypeOther     = "other"29)3031// The roles, which say which one of its kind a file is. The words are32// Jellyfin's and Kodi's, because those are the tools that wrote the files.33const (34	fileRolePrimary    = "primary"35	fileRoleTrailer    = "trailer"36	fileRoleExtra      = "extra"37	fileRoleTheme      = "theme"38	fileRoleSample     = "sample"39	fileRoleTrack      = "track"40	fileRoleFull       = "full"41	fileRoleForced     = "forced"42	fileRoleSDH        = "sdh"43	fileRolePoster     = "poster"44	fileRoleBackdrop   = "backdrop"45	fileRoleLogo       = "logo"46	fileRoleBanner     = "banner"47	fileRoleThumb      = "thumb"48	fileRoleDisc       = "disc"49	fileRoleClearart   = "clearart"50	fileRoleStill      = "still"51	fileRoleMovie      = "movie"52	fileRoleTVShow     = "tvshow"53	fileRoleEpisode    = "episode"54	fileRoleSeason     = "season"55	fileRoleCollection = "collection"56	fileRoleTiles      = "tiles"57)5859// The extensions that decide a category, beside the video extensions in60// names.go. Each set is closed, so a re-walk reads the same category off the61// same name.62var (63	audioExtensions = map[string]bool{64		".mp3": true, ".flac": true, ".m4a": true, ".m4b": true, ".aac": true,65		".ogg": true, ".oga": true, ".opus": true, ".wav": true, ".wma": true,66		".ape": true, ".aiff": true, ".alac": true,67	}68	subtitleExtensions = map[string]bool{69		".srt": true, ".ass": true, ".ssa": true, ".sub": true, ".idx": true,70		".vtt": true, ".sup": true, ".smi": true, ".sbv": true, ".ttml": true,71	}72	imageExtensions = map[string]bool{73		".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".bmp": true,74		".gif": true, ".tbn": true, ".avif": true,75	}76)7778// metadataExtension is the sidecar Jellyfin, Kodi, and the *arr tools write.79const metadataExtension = ".nfo"8081// trickplayExtension names the directory of thumbnail tiles Jellyfin writes82// beside a video file.83const trickplayExtension = ".trickplay"8485// The files a desktop or a storage appliance leaves behind. They belong to no86// title, and the walk leaves them out with the dotfiles.87var junkNames = map[string]bool{"thumbs.db": true, "desktop.ini": true}8889// The service and trash directories a filesystem or a storage appliance90// keeps beside the media. They hold no title. A Synology share root carries91// #recycle, which is root-owned and mode 000, so a scanner that reads it marks92// every pass incomplete and the prune never runs. The list is closed: no93// patterns and no configuration, because the operator's own ignore list is94// where a volume's other folders belong.95var serviceNames = map[string]bool{96	"#recycle": true, "@eadir": true, "$recycle.bin": true,97	"lost+found": true, "system volume information": true,98}99100// skipName reports whether the walk leaves an entry out: a name that101// starts with a dot, the junk names above, and the service directories above.102// The two name lists are matched without regard to case, the way the103// appliances that write them vary it.104func skipName(name string) bool {105	lower := strings.ToLower(name)106	return strings.HasPrefix(name, ".") || junkNames[lower] || serviceNames[lower]107}108109// The extras folders Jellyfin writes beside a feature, and the one of them110// whose videos are trailers rather than ordinary extras. The set is fixed,111// because a folder the walk does not name here is a folder it does not read.112const extrasTrailers = "trailers"113114var extrasFolderNames = map[string]bool{115	"extras": true, "featurettes": true, extrasTrailers: true,116	"behind the scenes": true, "deleted scenes": true, "interviews": true,117	"scenes": true, "shorts": true, "clips": true, "other": true,118}119120// extrasFolderName reads the extras-folder name a directory carries, lowercased,121// or the empty string where the directory is not one.122func extrasFolderName(name string) string {123	lower := strings.ToLower(strings.TrimSpace(name))124	if extrasFolderNames[lower] {125		return lower126	}127	return ""128}129130// filePlace is what a role depends on beyond the file's own name: the131// library's kind, whether the directory is a season folder, and the extras132// folder that holds the file.133type filePlace struct {134	kind   string135	season bool136	extras string137}138139// fileClass is what one file is: its category, which one of its kind it is, and140// the language its name carries.141type fileClass struct {142	Type     string143	Role     string144	Language string145}146147// classifyFile reads a file's category off its extension, its role off its name148// and its place, and its language off its name. It opens nothing.149func classifyFile(name string, place filePlace) fileClass {150	category := fileTypeOf(name)151	class := fileClass{Type: category, Role: fileRoleOf(category, name, place)}152	if category == fileTypeSubtitle || category == fileTypeAudio {153		class.Language = fileLanguage(name)154	}155	return class156}157158// fileTypeOf reads a file's category off its extension.159func fileTypeOf(name string) string {160	extension := strings.ToLower(filepath.Ext(name))161	switch {162	case videoExtensions[extension]:163		return fileTypeVideo164	case audioExtensions[extension]:165		return fileTypeAudio166	case subtitleExtensions[extension]:167		return fileTypeSubtitle168	case imageExtensions[extension]:169		return fileTypeImage170	case extension == metadataExtension:171		return fileTypeMetadata172	default:173		return fileTypeOther174	}175}176177// fileRoleOf reads which one of its kind a file is. A file in no category has178// no role.179//180// A trickplay directory never reaches here: its category comes from the181// directory's name rather than from fileTypeOf, and the walk builds its182// row with the tiles role already set.183func fileRoleOf(category, name string, place filePlace) string {184	base := strings.ToLower(stripAnyExtension(name))185	switch category {186	case fileTypeVideo:187		return videoRole(base, place)188	case fileTypeAudio:189		return audioRole(base)190	case fileTypeSubtitle:191		return subtitleRole(base)192	case fileTypeImage:193		return imageRole(base, place)194	case fileTypeMetadata:195		return metadataRole(base, place)196	}197	return ""198}199200// The marks Jellyfin appends to an extra's file name. Each one is the last201// token of the base name, as in The Matrix (1999)-featurette.mkv, which is how202// an extra beside the feature says what it is.203var extraMarks = map[string]bool{204	"behindthescenes": true, "deleted": true, "deletedscene": true,205	"deletedscenes": true, "featurette": true, "featurettes": true,206	"interview": true, "scene": true, "short": true, "clip": true,207	"extra": true, "other": true,208}209210// videoRole reads which video a file is. A mark in the name wins over the211// folder, so a trailer under Extras still reads as a trailer, and a video in an212// extras folder with no mark takes the folder's own word.213//214// Every mark, sample among them, is read off the last token alone, so a215// title that opens with one of the words is not a sample.216func videoRole(base string, place filePlace) string {217	tokens := nameTokens(base)218	last := lastToken(tokens)219	switch {220	case last == fileRoleSample:221		return fileRoleSample222	case last == fileRoleTrailer:223		return fileRoleTrailer224	case last == fileRoleTheme:225		return fileRoleTheme226	case extraMarks[last]:227		return fileRoleExtra228	}229	if place.extras == extrasTrailers {230		return fileRoleTrailer231	}232	if place.extras != "" {233		return fileRoleExtra234	}235	return fileRolePrimary236}237238// audioRole tells a theme song from an ordinary track. Jellyfin writes a theme239// as theme.mp3, and Kodi as <title>-theme.mp3.240func audioRole(base string) string {241	if lastToken(nameTokens(base)) == fileRoleTheme {242		return fileRoleTheme243	}244	return fileRoleTrack245}246247// subtitleFlagWindow bounds how far back from the end of a name the scanner248// reads a subtitle flag, so a title word does not read as one.249const subtitleFlagWindow = 2250251// subtitleRole reads the flag the tools write after the language tag. A252// subtitle with no flag is the full track.253func subtitleRole(base string) string {254	tokens := nameTokens(base)255	for i := max(len(tokens)-subtitleFlagWindow, 0); i < len(tokens); i++ {256		switch tokens[i] {257		case fileRoleForced:258			return fileRoleForced259		case fileRoleSDH, "cc":260			return fileRoleSDH261		}262	}263	if hearingImpairedFlag(base) {264		return fileRoleSDH265	}266	return fileRoleFull267}268269// hearingImpairedTag is what the tools write for a hearing-impaired track,270// and it is also the language tag for Hindi. The two are told apart by what271// comes before: a language tag precedes the flag, so The Matrix.en.hi.srt is272// English for the hearing impaired, and The Matrix.hi.srt is Hindi.273const hearingImpairedTag = "hi"274275// hearingImpairedFlag reports whether a name carries hi as the flag rather276// than as the language. It reads the dotted tokens, which is where the tools277// write both, and it needs a token before the language tag as well, so the278// title itself is never read as one.279func hearingImpairedFlag(base string) bool {280	tokens := strings.Split(strings.ToLower(base), ".")281	for i := len(tokens) - 1; i >= 2; i-- {282		if tokens[i] == hearingImpairedTag {283			return isLanguageTag(tokens[i-1])284		}285	}286	return false287}288289// The words an image's name carries, and the art each one names. This is a290// slice and not a map, so the words are read in this order every time, and a291// compound word comes before the word inside it.292var imageMarks = []struct {293	mark string294	role string295}{296	{"clearlogo", fileRoleLogo},297	{"clearart", fileRoleClearart},298	{"discart", fileRoleDisc},299	{"cdart", fileRoleDisc},300	{"backdrop", fileRoleBackdrop},301	{"fanart", fileRoleBackdrop},302	{"banner", fileRoleBanner},303	{"landscape", fileRoleThumb},304	{"thumb", fileRoleThumb},305	{"poster", fileRolePoster},306	{"folder", fileRolePoster},307	{"cover", fileRolePoster},308	{"logo", fileRoleLogo},309	{"disc", fileRoleDisc},310	{"still", fileRoleStill},311}312313// imageRole reads which art an image is. An image in a season folder that314// carries none of the words is the still beside an episode, which is the one315// image a season folder holds under a video's own name.316//317// The word is read off the end of the name's last token, where the tools318// write it, the way videoRole reads its own marks. Trailing digits are319// stripped before the match, so extrafanart1.jpg is a backdrop. A title320// that holds one of the words anywhere else, the way Discovery holds321// disc, is not art.322func imageRole(base string, place filePlace) string {323	if role, _, _ := imageArt(base); role != "" {324		return role325	}326	if place.season {327		return fileRoleStill328	}329	return ""330}331332// imageArt reads which art an image is, from its name alone. bare says333// the name is the mark word itself rather than a word after a title, and334// rank is the mark's place in imageMarks, where the explicit name comes335// before the generic one; discoverArt picks the lower rank among the336// images of one role. A name that names no art reads as no role, at a337// rank past every mark.338func imageArt(base string) (role string, rank int, bare bool) {339	tokens := nameTokens(base)340	last := strings.TrimRight(lastToken(tokens), "0123456789")341	for index, entry := range imageMarks {342		if strings.HasSuffix(last, entry.mark) {343			return entry.role, index, len(tokens) == 1 && last == entry.mark344		}345	}346	return "", len(imageMarks), false347}348349// metadataRole reads which sidecar an .nfo is. The fixed names win, and a350// sidecar named after its own file takes the kind of the library that holds it.351func metadataRole(base string, place filePlace) string {352	switch {353	case base == fileRoleMovie:354		return fileRoleMovie355	case base == fileRoleTVShow:356		return fileRoleTVShow357	case base == fileRoleCollection:358		return fileRoleCollection359	case strings.HasPrefix(base, fileRoleSeason):360		return fileRoleSeason361	}362	if place.kind == libraryKindSeries {363		return fileRoleEpisode364	}365	return fileRoleMovie366}367368// The flags a subtitle name carries after its language tag. The language read369// steps over them, so en.forced reads as the language en.370var subtitleFlags = map[string]bool{371	fileRoleForced: true, fileRoleSDH: true, "cc": true,372	"default": true, fileRoleFull: true,373}374375// fileLanguage reads the language tag off a file name, in the form the tools376// write it: The Matrix (1999).en.srt, or The Matrix (1999).en.forced.srt. It is377// a two-letter or three-letter tag as the name gave it, with no translation378// between the two. A name with one dotted token carries no tag, so a film named379// Up keeps its title. It steps over the flags that follow the tag, hi among380// them where hi is the flag and not the Hindi language.381func fileLanguage(name string) string {382	base := stripAnyExtension(name)383	flagged := hearingImpairedFlag(base)384	tokens := strings.Split(base, ".")385	for i := len(tokens) - 1; i >= 1; i-- {386		token := strings.ToLower(strings.TrimSpace(tokens[i]))387		if subtitleFlags[token] {388			continue389		}390		if token == hearingImpairedTag && flagged {391			continue392		}393		if isLanguageTag(token) {394			return token395		}396		return ""397	}398	return ""399}400401// isLanguageTag reports whether a token reads as a two-letter or three-letter402// language tag.403func isLanguageTag(token string) bool {404	if len(token) != 2 && len(token) != 3 {405		return false406	}407	for _, r := range token {408		if r < 'a' || r > 'z' {409			return false410		}411	}412	return true413}414415// nameTokens splits a base name on the separators the tools write between a416// title and the marks that follow it.417func nameTokens(base string) []string {418	return strings.FieldsFunc(base, func(r rune) bool {419		return r == '.' || r == '-' || r == '_' || r == ' '420	})421}422423// lastToken reads the trailing token of a name, the place the tools write the424// mark that says what a file is.425func lastToken(tokens []string) string {426	if len(tokens) == 0 {427		return ""428	}429	return tokens[len(tokens)-1]430}431432// stripAnyExtension drops a name's final extension, whatever it is, so433// The Matrix (1999).en.forced.srt reads as The Matrix (1999).en.forced.434func stripAnyExtension(name string) string {435	return strings.TrimSuffix(name, filepath.Ext(name))436}437438// folderFiles is one directory the walk reads for the files a title carries.439// The walk reads a title folder, a season folder under a series, and an extras440// folder under a title folder, and it descends no further.441type folderFiles struct {442	root    string443	dir     string444	library string445	place   filePlace446	// item names the item a file in this directory links to. A season folder447	// answers with the episode whose own name the file starts with; every448	// other place answers with one id.449	// The answer is a list, because a file beside a double-episode video450	// belongs to both of its episodes.451	item func(name string) []string452	// held is the names the item walk already wrote a row for, so this pass453	// adds no second row for a video that carries its sidecar's attributes.454	held map[string]bool455}456457// read returns a row for every file this directory holds, and the names of the458// subdirectories under it, so the caller descends into the season and extras459// folders from the one read. A .trickplay directory is one row and its tiles460// are none, because a large library holds millions of tiles and the directory461// is the unit a player asks for.462func (f folderFiles) read() ([]fileRow, []string, error) {463	entries, err := os.ReadDir(f.dir)464	if err != nil {465		return nil, nil, err466	}467	var rows []fileRow468	var subdirectories []string469	for _, entry := range entries {470		name := entry.Name()471		if skipName(name) {472			continue473		}474		if entry.IsDir() {475			if strings.EqualFold(filepath.Ext(name), trickplayExtension) {476				row, err := f.row(name, fileClass{Type: fileTypeTrickplay, Role: fileRoleTiles})477				if err != nil {478					return rows, subdirectories, err479				}480				rows = append(rows, row)481				continue482			}483			subdirectories = append(subdirectories, name)484			continue485		}486		if f.held[name] {487			continue488		}489		row, err := f.row(name, classifyFile(name, f.place))490		if err != nil {491			return rows, subdirectories, err492		}493		rows = append(rows, row)494	}495	return rows, subdirectories, nil496}497498// row builds one classified file row. The size and the modification time come499// from one stat, and no image is opened to measure it.500func (f folderFiles) row(name string, class fileClass) (fileRow, error) {501	absolute := filepath.Join(f.dir, name)502	size, modified, err := statFile(absolute)503	if err != nil {504		return fileRow{}, err505	}506	row := fileRow{507		Path:      relativePath(f.root, absolute),508		Library:   f.library,509		Container: containerFromExtension(name),510		SizeBytes: size,511		Modified:  modified,512		Type:      class.Type,513		Role:      class.Role,514		Language:  class.Language,515		Present:   true,516	}517	if class.Type == fileTypeVideo {518		stream, err := streamBeside(f.dir, name)519		if err != nil {520			return fileRow{}, err521		}522		row.Container, row.VideoCodec, row.AudioCodec, row.Width, row.Height, row.DurationMs =523			fileAttributes(name, stream)524		row.Trickplay = trickplayFor(f.root, f.dir, name)525	}526	if items := f.item(name); len(items) > 0 {527		row.Items = items528	}529	return row, nil530}531532// The probe writes one video's stream details into the sidecar beside it,533// under the same name with the .nfo extension. The sidecar is read for its534// streamdetails alone, so a movie sidecar and an episode sidecar both answer535// here. A video with no sidecar is not an error and reads its name instead. A536// sidecar the scanner cannot read is an error, because a row from the name537// would replace the details the volume holds and the prune would act on it.538func streamBeside(dir, file string) (*streamInfo, error) {539	data, err := os.ReadFile(sidecarBeside(filepath.Join(dir, file)))540	if errors.Is(err, fs.ErrNotExist) {541		return nil, nil542	}543	if err != nil {544		return nil, err545	}546	stream, err := parseStreamNFO(data)547	if err != nil || !stream.present() {548		return nil, nil549	}550	return &stream, nil551}552553// constantItem answers with one item id for every file in a directory, the554// resolver a movie title folder and a series folder both use.555func constantItem(item string) func(string) []string {556	return func(string) []string { return []string{item} }557}558559// episodeItem answers with the episode whose own file name a file's name starts560// with, and with the series where it matches none, which is where a season561// poster lands. The longest match wins, so an episode does not take a file that562// belongs to another episode whose name it is a prefix of.563// A video of two episodes carries both ids, so its sidecar, its subtitle, and564// its still link to the same episodes the video does.565func episodeItem(episodes map[string][]string, series string) func(string) []string {566	return func(name string) []string {567		base := stripAnyExtension(name)568		longest, items := "", []string{series}569		for episodeBase, ids := range episodes {570			if len(episodeBase) > len(longest) && strings.HasPrefix(base, episodeBase) {571				longest, items = episodeBase, ids572			}573		}574		return items575	}576}577578// statFile reads a path's size in bytes and the time it was last written, in579// Unix seconds, from one stat. A stat that fails is an error the caller580// folds into the walk's incomplete mark, because a size and a time of581// zero would otherwise land in the catalog as facts.582func statFile(path string) (int64, int64, error) {583	info, err := os.Stat(path)584	if err != nil {585		return 0, 0, err586	}587	return info.Size(), info.ModTime().Unix(), nil588}
franchiseart.go 94.7%
1package main23// franchiseart.go downloads the art each franchise.yaml links to into the4// Library's art claim, under the same directory name the checkout uses. The5// repository holds links and no bytes, because a franchise is an opinion about6// a story and the art belongs to whoever published it. The files land under7// Kodi's names, the names plan 30's art facts write, so the rows read them8// through discoverArt the way a movie's are read. One ledger, .liken/art.yaml,9// records the link every file came from: the same link is never read twice,10// and a changed link is read again. A file with no entry in that ledger is the11// owner's, and the fetch never writes over it.1213import (14	"context"15	"errors"16	"fmt"17	"io"18	"io/fs"19	"net/http"20	"os"21	"path/filepath"22	"strings"23	"time"24)2526// franchiseArtFact names the ledger this fetch writes, .liken/art.yaml. It is27// one file for one writer, the rule every .liken file follows, and the28// franchise art fetch is that one writer.29const franchiseArtFact = "art"3031// The bounds on one download: the wait, and the bytes it may answer with. 2032// MiB is far above any poster and far below a file that would fill the claim.33// Both are variables so a test can drive a cap a small answer crosses.34var (35	franchiseArtTimeout       = 30 * time.Second36	franchiseArtSizeCap int64 = 20 << 2037)3839// franchiseArtExtensions are the two image types the fetch writes, and the40// extension each takes. The extension comes from the answer and never from the41// URL, because a link carries no promise about what it serves.42var franchiseArtExtensions = map[string]string{43	"image/jpeg": ".jpg",44	"image/png":  ".png",45}4647// franchiseArtUserAgent names this fetch to the host it reads from. Wikimedia48// refuses a request that carries a generic client name, and answers one49// that says who is asking and where to read about it.50const franchiseArtUserAgent = "liken-library-operator (+https://library.liken.sh/)"5152// franchiseArtSuffixes are the extensions the fetch reads back when it looks53// for a file it or the owner already wrote.54var franchiseArtSuffixes = []string{".jpg", ".jpeg", ".png"}5556// franchiseArt is the art of one franchise, read off the claim the way a57// movie's is. A directory the fetch has not written yet holds no art, and that58// is an answer and not a failure, so a first scan still prunes.59func franchiseArt(artRoot, name string) (string, []string, error) {60	dir := filepath.Join(artRoot, name)61	if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {62		return "", nil, nil63	}64	return discoverArt(artRoot, dir)65}6667// franchiseArtFetch is one scan Job's art fetch: the client it reads with, the68// write door it writes through, and the log it reports to. It holds no69// catalog, because the art is files on the claim and the rows are read from70// those files afterwards.71type franchiseArtFetch struct {72	client *http.Client73	writer *volumeWriter74	log    func(format string, args ...any)75}7677// fetchAll downloads the art of every franchise the checkout holds, and78// returns how many files it wrote. A fetch that fails is logged and skipped,79// and the next scan asks again, because a link that is down for an hour must80// not fail a walk.81func (f franchiseArtFetch) fetchAll(ctx context.Context, checkout, artRoot string) int {82	names, err := franchiseDirectories(checkout)83	if err != nil {84		f.log("could not read the checkout for its art: %v", err)85		return 086	}87	wrote := 088	for _, name := range names {89		file, err := readFranchiseFile(checkout, name)90		if err != nil || file == nil {91			continue92		}93		wrote += f.fetchDirectory(ctx, filepath.Join(artRoot, name), name, file)94	}95	return wrote96}9798// fetchDirectory downloads the art one franchise links to, in the order its99// keys are named. The ledger is read once for the directory, because each link100// writes a file of its own kind and no two of them meet.101func (f franchiseArtFetch) fetchDirectory(ctx context.Context, dir, name string,102	file *franchiseFile) int {103	links := file.artLinks()104	if len(links) == 0 {105		return 0106	}107	ledger, err := readLikenLedger(dir, franchiseArtFact)108	if err != nil {109		f.log("could not read the art ledger of %s: %v", name, err)110		return 0111	}112	wrote := 0113	for _, link := range links {114		if f.fetchOne(ctx, dir, name, link, ledger) {115			wrote++116		}117	}118	return wrote119}120121// fetchOne handles one link: the file it names, whether this fetch wrote it,122// and whether the link has changed since. A file the ledger does not name is123// the owner's, and it stays. A file this fetch wrote from the same link is124// left alone, so a scan on a schedule reads nothing from the network.125func (f franchiseArtFetch) fetchOne(ctx context.Context, dir, name string,126	link franchiseArtLink, ledger likenLedger) bool {127	held := heldFranchiseArt(dir, link.Base)128	item, marked := ledger.itemAt(link.Base)129	if held != "" && !marked {130		return false131	}132	if held != "" && item.Source == link.URL {133		return false134	}135136	data, kind, err := f.read(ctx, link.URL)137	if err != nil {138		f.log("could not read the %s of %s: %v", link.Base, name, err)139		return false140	}141	extension, known := franchiseArtExtensions[kind]142	if !known {143		f.log("the %s of %s answered %s, which is neither a jpeg nor a png",144			link.Base, name, kind)145		return false146	}147148	target := link.Base + extension149	if err := f.writer.writeInto(dir, target, data); err != nil {150		f.log("could not write the %s of %s: %v", link.Base, name, err)151		return false152	}153	if held != "" && held != target {154		f.log("the %s of %s is now %s, and %s stands beside it", link.Base, name, target, held)155	}156	f.note(dir, name, link, target)157	return true158}159160// note records which link this file came from, keyed by the kind of art. The161// mark and the file are two writes, so a mark that fails leaves a file the162// next scan reads as the owner's, and the log names it.163func (f franchiseArtFetch) note(dir, name string, link franchiseArtLink, target string) {164	err := f.writer.updateLikenLedger(dir, franchiseArtFact, func(ledger *likenLedger) {165		ledger.noteItem(likenItem{166			Path: link.Base, Source: link.URL, Written: time.Now().UTC(),167		})168	})169	if err != nil {170		f.log("wrote %s of %s and could not record the link it came from: %v", target, name, err)171	}172}173174// heldFranchiseArt is the file one kind of art holds in a directory, whoever175// wrote it. The name is the kind and any image extension, because the owner's176// own poster may be a png where this fetch would write a jpg.177func heldFranchiseArt(dir, base string) string {178	for _, suffix := range franchiseArtSuffixes {179		if held, err := fileExists(filepath.Join(dir, base+suffix)); err == nil && held {180			return base + suffix181		}182	}183	return ""184}185186// read is one download, bounded by the wait and by the size cap. The bytes are187// held in memory from the answer to the rename and no longer, which is what188// the cap bounds.189func (f franchiseArtFetch) read(ctx context.Context, url string) ([]byte, string, error) {190	bounded, cancel := context.WithTimeout(ctx, franchiseArtTimeout)191	defer cancel()192193	request, err := http.NewRequestWithContext(bounded, http.MethodGet, url, nil)194	if err != nil {195		return nil, "", err196	}197	request.Header.Set("User-Agent", franchiseArtUserAgent)198	response, err := f.client.Do(request)199	if err != nil {200		return nil, "", err201	}202	defer drain(response.Body)203	if response.StatusCode < 200 || response.StatusCode > 299 {204		return nil, "", fmt.Errorf("%s answered %s", url, response.Status)205	}206207	data, err := io.ReadAll(io.LimitReader(response.Body, franchiseArtSizeCap+1))208	if err != nil {209		return nil, "", err210	}211	if int64(len(data)) > franchiseArtSizeCap {212		return nil, "", fmt.Errorf("%s answered more than the cap of %d bytes",213			url, franchiseArtSizeCap)214	}215	kind, _, _ := strings.Cut(response.Header.Get("Content-Type"), ";")216	return data, strings.TrimSpace(kind), nil217}
franchisefile.go 100.0%
1package main23// franchisefile.go reads one franchise.yaml and enforces the schema the4// public repository publishes, the same file as5// docs/static/franchise.schema.json. The file is the whole contract6// between its author and the scanner, because no provider holds a story7// order. A file that breaks one rule is refused whole; the scanner reports8// it and reads the other files. The rules are written by hand rather than9// read from the schema, because the operator's image carries one static10// binary and no schema library.1112import (13	"bytes"14	"fmt"15	"path/filepath"16	"regexp"17	"sort"18	"strconv"19	"strings"2021	"gopkg.in/yaml.v3"22)2324// franchiseFile is one franchise.yaml, in the file's own names. name and25// order are required, and every other part is optional.26type franchiseFile struct {27	Name     string             `yaml:"name"`28	Sources  []string           `yaml:"sources"`29	Calendar *franchiseCalendar `yaml:"calendar"`30	Universe string             `yaml:"universe"`31	Eras     []franchiseEra     `yaml:"eras"`32	Order    []franchiseEntry   `yaml:"order"`33	// Art is the franchise's own art, as links and not as bytes. The keys are34	// Kodi's names, and every one of them is optional. The repository holds no35	// image, because a franchise is an opinion about a story and the art36	// belongs to whoever published it.37	Art map[string]string `yaml:"art"`38}3940// franchiseArtKinds are the art keys the file may name, in the order the fetch41// reads them. Each one names the art fact of plan 30 that writes the same kind42// of image, so the file the fetch writes carries the name that fact writes.43// fanart is Kodi's name for the backdrop, and landscape for the thumb.44var franchiseArtKinds = []struct {45	Name string46	Fact string47}{48	{"poster", factPoster},49	{"fanart", factBackdrop},50	{"landscape", factLandscape},51	{"logo", factLogo},52	{"banner", factBanner},53}5455// artLinks are the links the file names, in the key order above, each with the56// file name the fetch writes it under. A file that names no art block names no57// link.58func (f *franchiseFile) artLinks() []franchiseArtLink {59	links := []franchiseArtLink{}60	for _, kind := range franchiseArtKinds {61		if url := f.Art[kind.Name]; url != "" {62			links = append(links, franchiseArtLink{Base: franchiseArtBase(kind.Fact), URL: url})63		}64	}65	return links66}6768// franchiseArtLink is one link of the art block: the URL, and the name the69// file takes on the claim without its extension. The extension comes from what70// the link answers, so the base is what the ledger and the fetch key on.71type franchiseArtLink struct {72	Base string73	URL  string74}7576// franchiseArtBase is the file name one art fact writes, without its77// extension. The names are plan 30's own, so a franchise's poster sits beside78// a film's poster under the same name.79func franchiseArtBase(fact string) string {80	file := artTypes[fact].file81	return strings.TrimSuffix(file, filepath.Ext(file))82}8384// validateArt requires every art key to be one of the five and every value to85// be an https link. The links leave the cluster, so http is refused rather86// than followed. The keys are read in name order, so a file with two faults87// reports the same one every time.88func (f *franchiseFile) validateArt() error {89	known := map[string]bool{}90	for _, kind := range franchiseArtKinds {91		known[kind.Name] = true92	}93	names := make([]string, 0, len(f.Art))94	for name := range f.Art {95		names = append(names, name)96	}97	sort.Strings(names)98	for _, name := range names {99		if !known[name] {100			return fmt.Errorf("art names %q, which is not poster, fanart, landscape, logo, or banner", name)101		}102		if !strings.HasPrefix(f.Art[name], artLinkScheme) {103			return fmt.Errorf("the art %s %q does not start with %s", name, f.Art[name], artLinkScheme)104		}105	}106	return nil107}108109// artLinkScheme is the one scheme an art link may carry.110const artLinkScheme = "https://"111112// franchiseCalendar is the franchise's own clock, which every time in the113// file counts in. unit is required, and it is years or days. zero, before,114// and after name the event the times count from, and a calendar without115// them counts in plain years.116type franchiseCalendar struct {117	Unit   string `yaml:"unit" json:"unit"`118	Zero   string `yaml:"zero" json:"zero,omitempty"`119	Before string `yaml:"before" json:"before,omitempty"`120	After  string `yaml:"after" json:"after,omitempty"`121}122123// franchiseEra is one named stretch of the timeline, which the page draws124// as a bar on the rail beside the wall. The name and both ends are125// required, and spans may overlap, because a saga holds phases.126type franchiseEra struct {127	Name string   `yaml:"name" json:"name"`128	From *float64 `yaml:"from" json:"from"`129	To   *float64 `yaml:"to" json:"to"`130}131132// franchiseTime is the span of one entry in the calendar's unit. Both ends133// are required, and they are equal for a story that stays in one year.134type franchiseTime struct {135	From *float64 `yaml:"from"`136	To   *float64 `yaml:"to"`137}138139// franchiseEntry is one entry of the story order: one film or one series.140// An entry holds exactly one of movie and series, each a provider id.141// seasons cut a series into the runs the story plays. universes names142// every universe whose story the entry continues or joins, and an entry143// with none is in the franchise's own universe.144type franchiseEntry struct {145	Movie  string `yaml:"movie"`146	Series string `yaml:"series"`147	Title  string `yaml:"title"`148	// Released is the real-world date, as much of it as the author knows:149	// 1999, 1999-05, or 1999-05-19. On a film it is the first public150	// release, and on a series the day the first episode aired. It is151	// never the story's own calendar, which Time carries. It is optional,152	// and the scanner never reads a date out of the title.153	Released  string            `yaml:"released"`154	Time      *franchiseTime    `yaml:"time"`155	Universes []string          `yaml:"universes"`156	Note      string            `yaml:"note"`157	Seasons   []franchiseSeason `yaml:"seasons"`158}159160// franchiseSeason is one season of a series entry, or one run of one161// season. The season number is required, and specials are season 0.162// episodes holds codes and ranges in the order they play, and a season with163// none plays whole.164type franchiseSeason struct {165	Season   *int           `yaml:"season"`166	Episodes []string       `yaml:"episodes"`167	Time     *franchiseTime `yaml:"time"`168	Note     string         `yaml:"note"`169}170171// franchiseEpisode is one episode of one season, as the runs table holds172// it.173type franchiseEpisode struct {174	Season  int175	Episode int176}177178// episodeCode admits the two forms an episodes entry takes: one code, or a179// range of two. The schema names the same pattern, so a file that180// validates in an editor validates here.181var episodeCode = regexp.MustCompile(`^S([0-9]{2,})E([0-9]{2,})(?:-S([0-9]{2,})E([0-9]{2,}))?$`)182183// releasedDate admits the three precisions a released date takes, the184// same pattern the schema states: a year, a year and a month, or a whole185// day. validReleased checks the two-digit parts beside the pattern,186// because 1999-13-32 matches the shape and names no date.187var releasedDate = regexp.MustCompile(`^([0-9]{4})(?:-([0-9]{2})(?:-([0-9]{2}))?)?$`)188189// providerReference is the shape of a provider id, scheme:id, such as190// tmdb:1893.191var providerReference = regexp.MustCompile(`^[a-z0-9]+:[A-Za-z0-9_-]+$`)192193// parseFranchiseFile reads one franchise.yaml and returns the file or the194// rule it breaks. The decoder refuses a field the schema does not name,195// because the schema closes every object.196func parseFranchiseFile(data []byte) (*franchiseFile, error) {197	file := &franchiseFile{}198	decoder := yaml.NewDecoder(bytes.NewReader(data))199	decoder.KnownFields(true)200	if err := decoder.Decode(file); err != nil {201		return nil, err202	}203	if err := file.validate(); err != nil {204		return nil, err205	}206	return file, nil207}208209// validate applies the rules the schema states, in the order a reader210// meets them in the file.211func (f *franchiseFile) validate() error {212	if f.Name == "" {213		return fmt.Errorf("the file names no name")214	}215	if len(f.Order) == 0 {216		return fmt.Errorf("the file names no order")217	}218	if len(f.Eras) > 0 && f.Calendar == nil {219		return fmt.Errorf("the file names eras and no calendar")220	}221	if err := f.Calendar.validate(); err != nil {222		return err223	}224	if err := f.validateArt(); err != nil {225		return err226	}227	for _, era := range f.Eras {228		if err := era.validate(); err != nil {229			return err230		}231	}232	for position, entry := range f.Order {233		if err := entry.validate(); err != nil {234			return fmt.Errorf("entry %d: %w", position+1, err)235		}236	}237	return nil238}239240// validate requires a unit of years or days. A file with no calendar holds241// an order alone, and that is legal.242func (c *franchiseCalendar) validate() error {243	if c == nil {244		return nil245	}246	if c.Unit != "years" && c.Unit != "days" {247		return fmt.Errorf("the calendar unit %q is neither years nor days", c.Unit)248	}249	return nil250}251252func (e franchiseEra) validate() error {253	if e.Name == "" {254		return fmt.Errorf("an era names no name")255	}256	return spanOf(e.From, e.To, "the era "+e.Name)257}258259// spanOf requires both ends of a span, because a bar with one end draws260// nothing.261func spanOf(from, to *float64, what string) error {262	if from == nil {263		return fmt.Errorf("%s names no from", what)264	}265	if to == nil {266		return fmt.Errorf("%s names no to", what)267	}268	return nil269}270271// validate requires an entry to be one film or one series, never both and272// never neither. A movie carries no seasons, because seasons cut a series.273func (e franchiseEntry) validate() error {274	if e.Movie == "" && e.Series == "" {275		return fmt.Errorf("names neither a movie nor a series")276	}277	if e.Movie != "" && e.Series != "" {278		return fmt.Errorf("names both a movie and a series")279	}280	if e.Movie != "" && len(e.Seasons) > 0 {281		return fmt.Errorf("names a movie and seasons")282	}283	if !providerReference.MatchString(e.reference()) {284		return fmt.Errorf("the provider id %q is not scheme:id", e.reference())285	}286	for _, universe := range e.Universes {287		if universe == "" {288			return fmt.Errorf("universes names an empty universe")289		}290	}291	if err := validReleased(e.Released); err != nil {292		return err293	}294	if e.Time != nil {295		if err := spanOf(e.Time.From, e.Time.To, "the time"); err != nil {296			return err297		}298	}299	for _, season := range e.Seasons {300		if err := season.validate(); err != nil {301			return err302		}303	}304	return nil305}306307// validReleased accepts a year, a year and a month, or a whole day, and308// refuses a month outside 01 to 12 or a day outside 01 to 31, so a date309// that matches the shape and names no day never reaches a row. It leaves310// the calendar's own month lengths alone: 1999-02-31 is admitted, because311// nothing reads the day back as a date.312func validReleased(released string) error {313	if released == "" {314		return nil315	}316	parts := releasedDate.FindStringSubmatch(released)317	if parts == nil {318		return fmt.Errorf("the released date %q is not a year, a year and a month, or a whole day", released)319	}320	if parts[2] != "" && (number(parts[2]) < 1 || number(parts[2]) > 12) {321		return fmt.Errorf("the released date %q names no month", released)322	}323	if parts[3] != "" && (number(parts[3]) < 1 || number(parts[3]) > 31) {324		return fmt.Errorf("the released date %q names no day", released)325	}326	return nil327}328329// releaseYear is the year the wall labels a row with, the first four330// characters of the released date. The file validated before a row read331// it, so those four characters are digits. An entry with no date leaves332// the year at 0.333func (e franchiseEntry) releaseYear() int {334	if len(e.Released) < 4 {335		return 0336	}337	return number(e.Released[:4])338}339340// reference is the provider id the entry names, whichever kind it is.341func (e franchiseEntry) reference() string {342	if e.Movie != "" {343		return e.Movie344	}345	return e.Series346}347348// kind is the kind column of the member row this entry writes.349func (e franchiseEntry) kind() string {350	if e.Movie != "" {351		return scopeMovie352	}353	return scopeSeries354}355356// validate requires a season number, and requires every episode code to357// expand.358func (s franchiseSeason) validate() error {359	if s.Season == nil {360		return fmt.Errorf("a season names no season number")361	}362	if *s.Season < 0 {363		return fmt.Errorf("the season number %d is below zero", *s.Season)364	}365	if s.Time != nil {366		if err := spanOf(s.Time.From, s.Time.To, "the time"); err != nil {367			return err368		}369	}370	for _, code := range s.Episodes {371		if _, err := expandEpisodeCode(code); err != nil {372			return err373		}374	}375	return nil376}377378// expandEpisodeCode turns one code into one episode, and a range into379// every episode between its two codes. The file expands here and not380// against the catalog, so a held-episode count on the page is one join. A381// range stays inside one season and never runs backwards.382func expandEpisodeCode(code string) ([]franchiseEpisode, error) {383	parts := episodeCode.FindStringSubmatch(code)384	if parts == nil {385		return nil, fmt.Errorf("the episode code %q is not SnnEnn or SnnEnn-SnnEnn", code)386	}387	season, episode := number(parts[1]), number(parts[2])388	if parts[3] == "" {389		return []franchiseEpisode{{Season: season, Episode: episode}}, nil390	}391	lastSeason, lastEpisode := number(parts[3]), number(parts[4])392	if lastSeason != season {393		return nil, fmt.Errorf("the range %q crosses two seasons", code)394	}395	if lastEpisode < episode {396		return nil, fmt.Errorf("the range %q ends before it starts", code)397	}398	held := make([]franchiseEpisode, 0, lastEpisode-episode+1)399	for number := episode; number <= lastEpisode; number++ {400		held = append(held, franchiseEpisode{Season: season, Episode: number})401	}402	return held, nil403}404405// number reads the digits of one code part. The pattern admits digits406// alone, so the read cannot fail.407func number(digits string) int {408	value, _ := strconv.Atoi(strings.TrimLeft(digits, "0"))409	return value410}
franchiserows.go 100.0%
1package main23// franchiserows.go writes the three franchise tables and sweeps them, the4// way genres.go writes and sweeps the genres table. The franchises row is5// an item row, so it marks and sweeps under the item key space beside the6// movies and the series. The members and the runs key on the franchise and7// the position, so each carries a key space of its own.89import (10	"context"11	"strconv"12	"strings"13)1415// The two key spaces the members and the runs mark under. A member key is16// the franchise and the position joined, and a run key adds the season and17// the episode.18const (19	seenFranchiseMember = "franchise-member:"20	seenFranchiseRun    = "franchise-run:"21)2223// UpsertFranchises writes the franchises rows. A repeat write updates the24// row in place, so a re-walk of an unchanged checkout changes no row and25// broadcasts nothing. The conflict target is the whole primary key, and the26// update names no key column, because cr-sqlite reads a change to a key27// column as a delete and a create.28func (c *Catalog) UpsertFranchises(ctx context.Context, rows []franchiseRow) (int, error) {29	statements := make([]statement, len(rows))30	for i, row := range rows {31		params := itemParams(row.Library, row.Id, row.Kind, row.Path, row.Title, row.SortKey,32			row.Released, row.Added, row.Art, row.Duration, row.Body, row.Slug)33		statements[i] = statement{34			sql: `INSERT INTO franchises (library, id, kind, path, title, sort_key, released, added, art, duration, body, slug, arts) ` +35				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +36				`ON CONFLICT (library, id) DO UPDATE SET ` +37				`kind = excluded.kind, path = excluded.path, title = excluded.title, ` +38				`sort_key = excluded.sort_key, released = excluded.released, added = excluded.added, ` +39				`art = excluded.art, duration = excluded.duration, body = excluded.body, ` +40				`slug = excluded.slug, arts = excluded.arts`,41			params: append(params, artsParam(row.Arts)),42		}43	}44	return c.apply(ctx, statements)45}4647// UpsertFranchiseMembers writes one member row per entry, keyed by the48// franchise and the position.49func (c *Catalog) UpsertFranchiseMembers(ctx context.Context, rows []franchiseMemberRow) (int, error) {50	statements := make([]statement, len(rows))51	for i, row := range rows {52		statements[i] = statement{53			sql: `INSERT INTO franchise_members (library, franchise, position, kind, alias, title, released, release_year, timed, time_from, time_to, universes) ` +54				`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` +55				`ON CONFLICT (library, franchise, position) DO UPDATE SET ` +56				`kind = excluded.kind, alias = excluded.alias, title = excluded.title, ` +57				`released = excluded.released, release_year = excluded.release_year, ` +58				`timed = excluded.timed, time_from = excluded.time_from, time_to = excluded.time_to, ` +59				`universes = excluded.universes`,60			params: []any{row.Library, row.Franchise, row.Position, row.Kind, row.Alias, row.Title,61				row.Released, row.ReleaseYear, row.Timed, row.TimeFrom, row.TimeTo, row.Universes},62		}63	}64	return c.apply(ctx, statements)65}6667// UpsertFranchiseRuns writes the run rows. Every column of a run row is a68// key column, so the row carries nothing to update and a repeat write69// broadcasts nothing.70func (c *Catalog) UpsertFranchiseRuns(ctx context.Context, rows []franchiseRunRow) (int, error) {71	statements := make([]statement, len(rows))72	for i, row := range rows {73		statements[i] = statement{74			sql: `INSERT INTO franchise_runs (library, franchise, position, season, episode) ` +75				`VALUES (?, ?, ?, ?, ?) ` +76				`ON CONFLICT (library, franchise, position, season, episode) DO NOTHING`,77			params: []any{row.Library, row.Franchise, row.Position, row.Season, row.Episode},78		}79	}80	return c.apply(ctx, statements)81}8283// DeleteFranchises removes the franchise rows whose directory left the84// repository.85func (c *Catalog) DeleteFranchises(ctx context.Context, library string, ids []string) (int, error) {86	return c.apply(ctx, deleteByKey("franchises", "id", library, ids))87}8889// franchiseMemberKey is the two key columns after the library, as the90// sweep reads them back.91type franchiseMemberKey struct {92	Franchise string93	Position  int94}9596// franchiseRunKey is the four key columns after the library, as the sweep97// reads them back.98type franchiseRunKey struct {99	Franchise string100	Position  int101	Season    int102	Episode   int103}104105// DeleteFranchiseMembers names every key column, so a sweep takes exactly106// the rows it read. DeleteFranchiseRuns does the same for the runs.107func (c *Catalog) DeleteFranchiseMembers(ctx context.Context, library string, keys []franchiseMemberKey) (int, error) {108	statements := make([]statement, len(keys))109	for i, key := range keys {110		statements[i] = statement{111			sql:    `DELETE FROM franchise_members WHERE library = ? AND franchise = ? AND position = ?`,112			params: []any{library, key.Franchise, key.Position},113		}114	}115	return c.apply(ctx, statements)116}117118func (c *Catalog) DeleteFranchiseRuns(ctx context.Context, library string, keys []franchiseRunKey) (int, error) {119	statements := make([]statement, len(keys))120	for i, key := range keys {121		statements[i] = statement{122			sql: `DELETE FROM franchise_runs WHERE library = ? AND franchise = ? AND position = ?` +123				` AND season = ? AND episode = ?`,124			params: []any{library, key.Franchise, key.Position, key.Season, key.Episode},125		}126	}127	return c.apply(ctx, statements)128}129130// The key travels through the sweep as one string, joined by the separator131// no id holds, the way a genre key does. The two functions after these132// read the strings back.133func franchiseMemberSeenKey(row franchiseMemberRow) string {134	return row.Franchise + linkKeySeparator + strconv.Itoa(row.Position)135}136137func franchiseRunSeenKey(row franchiseRunRow) string {138	return row.Franchise + linkKeySeparator + strconv.Itoa(row.Position) +139		linkKeySeparator + strconv.Itoa(row.Season) + linkKeySeparator + strconv.Itoa(row.Episode)140}141142func franchiseMemberKeys(keys []string) []franchiseMemberKey {143	out := make([]franchiseMemberKey, len(keys))144	for i, key := range keys {145		parts := strings.Split(key, linkKeySeparator)146		out[i] = franchiseMemberKey{Franchise: parts[0], Position: keyNumber(parts, 1)}147	}148	return out149}150151func franchiseRunKeys(keys []string) []franchiseRunKey {152	out := make([]franchiseRunKey, len(keys))153	for i, key := range keys {154		parts := strings.Split(key, linkKeySeparator)155		out[i] = franchiseRunKey{156			Franchise: parts[0],157			Position:  keyNumber(parts, 1),158			Season:    keyNumber(parts, 2),159			Episode:   keyNumber(parts, 3),160		}161	}162	return out163}164165// keyNumber reads one number out of a joined key, and 0 where the key is166// short. The sweep reads back the keys the mark wrote, so a short key167// cannot happen, and the delete refuses to guess at one.168func keyNumber(parts []string, index int) int {169	if index >= len(parts) {170		return 0171	}172	number, _ := strconv.Atoi(parts[index])173	return number174}175176// franchiseMemberPruneSQL selects the members this library holds that the177// current epoch did not mark, one bounded batch, joined the way the mark178// joined them. franchiseRunPruneSQL does the same for the runs.179func franchiseMemberPruneSQL() string {180	return `SELECT franchise || char(31) || position FROM franchise_members` +181		` WHERE library = ?` +182		` AND '` + seenFranchiseMember + `' || franchise || char(31) || position` +183		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +184		` LIMIT ?`185}186187func franchiseRunPruneSQL() string {188	return `SELECT franchise || char(31) || position || char(31) || season || char(31) || episode` +189		` FROM franchise_runs WHERE library = ?` +190		` AND '` + seenFranchiseRun + `' || franchise || char(31) || position` +191		` || char(31) || season || char(31) || episode` +192		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +193		` LIMIT ?`194}195196// librarySweepFranchiseMemberSQL selects one bounded batch of one197// library's members for the whole-library sweep, and198// librarySweepFranchiseRunSQL the runs.199func librarySweepFranchiseMemberSQL() string {200	return `SELECT franchise || char(31) || position FROM franchise_members WHERE library = ? LIMIT ?`201}202203func librarySweepFranchiseRunSQL() string {204	return `SELECT franchise || char(31) || position || char(31) || season || char(31) || episode` +205		` FROM franchise_runs WHERE library = ? LIMIT ?`206}
franchises.go 100.0%
1package main23// franchises.go reads a checkout of a franchises repository into the three4// franchise tables, the way movies.go reads a title folder. The checkout5// holds one directory per franchise, each with a franchise.yaml and the6// franchise's art beside it under Kodi's names. The walk reads no volume7// and no other library, because a member is a provider alias the member's8// own library writes. A file the schema refuses is counted unidentified and9// skipped, and the files beside it still write their rows.1011import (12	"encoding/json"13	"errors"14	"fmt"15	"io/fs"16	"os"17	"path/filepath"18	"sort"19)2021// scopeFranchise is the word that leads every franchise id, beside the22// scopes in rows.go.23const scopeFranchise = "franchise"2425// franchiseFileName is the one file the scanner reads out of a franchise26// directory. The AGENTS.md beside it is for the next author, and the art27// is for the enrichers.28const franchiseFileName = "franchise.yaml"2930// franchiseBody is what the page draws around the wall, as the franchises31// row's body. The names are the file's own, so a reader of the row and a32// reader of the file read one vocabulary.33type franchiseBody struct {34	Universe string             `json:"universe,omitempty"`35	Calendar *franchiseCalendar `json:"calendar,omitempty"`36	Eras     []franchiseEra     `json:"eras,omitempty"`37	Sources  []string           `json:"sources,omitempty"`38}3940// franchiseRow is one row of the franchises item table: the item header41// every kind carries, with the franchise's clock in the body. Released is42// empty, because the scanner reads no member's date.43type franchiseRow struct {44	Id       string45	Library  string46	Kind     string47	Path     string48	Title    string49	SortKey  string50	Slug     string51	Released string52	Added    int6453	Art      string54	Arts     []string55	Duration int6456	Body     franchiseBody57}5859// franchiseMemberRow is one entry of one franchise's order, at its position60// in story order. Alias is the member as the provider alias its own library61// writes, and it is the whole join to the catalog. Universes is a JSON62// list, empty where the entry is in the home universe alone. ReleaseYear is63// the file's own, and 0 where the file gives none.64type franchiseMemberRow struct {65	Library   string66	Franchise string67	Position  int68	Kind      string69	Alias     string70	Title     string71	Released  string72	// ReleaseYear is the year of that date, its first four characters, so73	// the wall reads one integer for its label and never parses a string.74	ReleaseYear int75	Timed       int76	TimeFrom    float6477	TimeTo      float6478	Universes   string79}8081// franchiseRunRow is one season, or one episode, of one series run.82// Episode 0 is the whole season, and a run with no rows is the whole show.83type franchiseRunRow struct {84	Library   string85	Franchise string86	Position  int87	Season    int88	Episode   int89}9091// franchiseID derives the id of one franchise from the name of its92// directory. The file carries no provider id, so the directory name is the93// identity, and a renamed directory is a new row.94func franchiseID(directory string) string {95	return scopeFranchise + ":name:" + slug(directory, 0)96}9798// walkFranchises reads a whole checkout into one walkResult, one directory99// at a time. The directories are read in name order, so two walks of one100// checkout write the same rows in the same order. A directory with no101// franchise.yaml is not a franchise, which is how the repository's own102// .git directory is passed over. The checkout holds the franchise.yaml103// files, and the art claim holds the art the scan downloaded from the links104// each one carries; a franchise's directory carries the same name in both.105func walkFranchises(checkout, artRoot, library string) *walkResult {106	result := &walkResult{}107	names, err := franchiseDirectories(checkout)108	if err != nil {109		result.noteReadError(err)110		return result111	}112	for _, name := range names {113		scanFranchiseDirectory(checkout, artRoot, name, library, result)114	}115	return result116}117118// franchiseDirectories are the directories of one checkout, in name order, so119// two walks of one checkout read them the same way. A dot name is passed over,120// which is how the repository's own .git directory is left out.121func franchiseDirectories(checkout string) ([]string, error) {122	entries, err := os.ReadDir(checkout)123	if err != nil {124		return nil, err125	}126	names := []string{}127	for _, entry := range entries {128		if entry.IsDir() && !skipName(entry.Name()) {129			names = append(names, entry.Name())130		}131	}132	sort.Strings(names)133	return names, nil134}135136// readFranchiseFile reads and validates one directory's franchise.yaml. A137// directory that holds no franchise.yaml is not a franchise, and it returns no138// file and no error.139func readFranchiseFile(checkout, name string) (*franchiseFile, error) {140	data, err := os.ReadFile(filepath.Join(checkout, name, franchiseFileName))141	if errors.Is(err, fs.ErrNotExist) {142		return nil, nil143	}144	if err != nil {145		return nil, fmt.Errorf("%w: %v", errFranchiseUnreadable, err)146	}147	return parseFranchiseFile(data)148}149150// errFranchiseUnreadable tells a file the scanner could not read from a file151// the schema refuses. The first marks the pass incomplete, and the second is152// reported and skipped.153var errFranchiseUnreadable = errors.New("the scanner could not read the file")154155// scanFranchiseDirectory reads one franchise directory into its rows. A156// file the schema refuses leaves the directory counted unidentified and157// named, the same reporting path a folder no sidecar identifies takes. A158// file the scanner cannot read marks the pass incomplete, so a checkout it159// could not read never sweeps the rows the catalog holds.160func scanFranchiseDirectory(checkout, artRoot, name, library string, result *walkResult) {161	file, err := readFranchiseFile(checkout, name)162	if err != nil {163		// A file the scanner could read and the schema refuses is a fault164		// in the file; a file it could not read is a fault in the checkout.165		if errors.Is(err, errFranchiseUnreadable) {166			result.noteReadError(err)167			return168		}169		result.unidentified++170		result.unidentifiedNames = append(result.unidentifiedNames, name)171		return172	}173	if file == nil {174		return175	}176177	id := franchiseID(name)178	// The art is read off the claim, the way every other kind reads it, so the179	// columns hold paths under the library root and never links.180	primaryArt, allArt, err := franchiseArt(artRoot, name)181	result.noteReadError(err)182183	result.franchises = append(result.franchises, franchiseRow{184		Id:      id,185		Library: library,186		Kind:    libraryKindFranchises,187		Path:    name,188		Title:   file.Name,189		SortKey: sortKey(file.Name),190		Slug:    slug(file.Name, 0),191		Art:     primaryArt,192		Arts:    allArt,193		Body: franchiseBody{194			Universe: file.Universe,195			Calendar: file.Calendar,196			Eras:     file.Eras,197			Sources:  file.Sources,198		},199	})200	appendFranchiseOrder(library, id, file, result)201	result.titles++202}203204// appendFranchiseOrder walks the order and writes one member row per205// entry, from position 1. A series with seasons is one member row and one206// run row per season or episode, so The Clone Wars is one slot on the wall207// and not thirty.208func appendFranchiseOrder(library, franchise string, file *franchiseFile, result *walkResult) {209	for index, entry := range file.Order {210		position := index + 1211		result.franchiseMembers = append(result.franchiseMembers,212			franchiseMemberRow{213				Library:     library,214				Franchise:   franchise,215				Position:    position,216				Kind:        entry.kind(),217				Alias:       entry.kind() + ":" + entry.reference(),218				Title:       entry.Title,219				Released:    entry.Released,220				ReleaseYear: entry.releaseYear(),221				Timed:       timedMark(entry.Time),222				TimeFrom:    spanEnd(entry.Time, func(t franchiseTime) *float64 { return t.From }),223				TimeTo:      spanEnd(entry.Time, func(t franchiseTime) *float64 { return t.To }),224				Universes:   universesValue(entry.Universes),225			})226		for _, season := range entry.Seasons {227			result.franchiseRuns = append(result.franchiseRuns,228				franchiseRunRows(library, franchise, position, season)...)229		}230	}231}232233// franchiseRunRows are the run rows of one season: the whole season, or234// one row per episode the file names. A range expands here, because the235// file expands with no catalog, and a held-episode count is then one join.236func franchiseRunRows(library, franchise string, position int, season franchiseSeason) []franchiseRunRow {237	row := franchiseRunRow{Library: library, Franchise: franchise, Position: position, Season: *season.Season}238	if len(season.Episodes) == 0 {239		return []franchiseRunRow{row}240	}241	rows := []franchiseRunRow{}242	for _, code := range season.Episodes {243		// The file validated before the walk read it, so every code244		// expands and the error here cannot happen.245		episodes, _ := expandEpisodeCode(code)246		for _, episode := range episodes {247			rows = append(rows, franchiseRunRow{248				Library: library, Franchise: franchise, Position: position,249				Season: episode.Season, Episode: episode.Episode,250			})251		}252	}253	return rows254}255256// timedMark is 1 where the entry carries a time, and 0 where it carries257// none.258func timedMark(span *franchiseTime) int {259	if span == nil {260		return 0261	}262	return 1263}264265// spanEnd is one end of an entry's span, and 0 where the entry has no266// time. The file validated before the walk read it, so a span the walk267// reads carries both ends.268func spanEnd(span *franchiseTime, end func(franchiseTime) *float64) float64 {269	if span == nil {270		return 0271	}272	if value := end(*span); value != nil {273		return *value274	}275	return 0276}277278// universesValue is the entry's universes as the JSON list the column279// holds. An entry that names none is in the home universe alone, which is280// the empty list.281func universesValue(universes []string) string {282	if len(universes) == 0 {283		return "[]"284	}285	payload, _ := json.Marshal(universes)286	return string(payload)287}
franchisescan.go 95.2%
1package main23// franchisescan.go is the whole of a franchises scan: fetch the art, read,4// write, prune. The checkout is a mounted claim and the files are a few5// hundred kilobytes, so every scan walks it, the way every other kind's6// scan walks its volume. The art ledger is what keeps a scan from reading7// a link twice, and that is the one cost worth avoiding.89import (10	"context"11	"net/http"12	"time"13)1415// franchiseScan reads the checkout on the claim into the catalog. A16// checkout the walk could not read in full prunes nothing, which is the17// incomplete-walk guard every kind has.18func (s *scanner) franchiseScan(ctx context.Context) error {19	s.walkMutex.Lock()20	defer s.walkMutex.Unlock()2122	started := time.Now()2324	// The art the files link to is downloaded into the art claim before the25	// rows are read, so a row reads the file the fetch just wrote. A fetch26	// that failed last time is asked again here.27	s.fetchFranchiseArt(ctx)2829	if err := s.catalog.ensureSeen(ctx); err != nil {30		return s.walkFailed("ensure the seen table", err)31	}32	epoch := time.Now().UnixNano()33	before, err := s.catalog.countItems(ctx, s.library)34	if err != nil {35		return s.walkFailed("count the catalog before the walk", err)36	}3738	result := walkFranchises(s.root, s.art, s.library)39	for _, failure := range result.readFailures {40		s.logf("could not read %s: %v", failure.path, failure.err)41	}42	if err := flushWalk(ctx, s.catalog, result, epoch); err != nil {43		return s.walkFailed("write the franchises", err)44	}4546	// A checkout the walk could not read in full describes only part of47	// the repository, so it prunes nothing and keeps the rows the catalog48	// holds.49	if incompleteWalk(result.readError, len(result.franchises), before) {50		s.logIncompleteWalk(result.readError, len(result.franchises), before)51		return errIncompleteWalk52	}5354	s.settleWalk(ctx, epoch, before, result.titles, result.unidentified,55		result.unidentifiedNames, started)56	return nil57}5859// fetchFranchiseArt downloads the art every franchise.yaml in the checkout60// links to into the art claim, which is the one claim this scan mounts61// writable.62func (s *scanner) fetchFranchiseArt(ctx context.Context) {63	franchiseArtFetch{64		client: &http.Client{Timeout: franchiseArtTimeout},65		writer: newVolumeWriter(s.job),66		log:    s.logf,67	}.fetchAll(ctx, s.root, s.art)68}
genres.go 100.0%
1package main23// genres.go is the genres table: one row per title and genre, in the order4// the sidecar lists them. The body column holds the same genres as JSON, which5// no index reaches, so a read over one genre needs this table. The order is6// kept because the sidecar's first genre is the title's main genre. The rows7// are derived from the sidecar, so the walk writes them and the mark-and-sweep8// prune takes them with the title, the way it takes credits. This file holds9// the row, the writes and deletes, and the sweep reads.1011import (12	"context"13	"strconv"14	"strings"15)1617// One genre of one title. The rank is its position in the sidecar's list,18// from zero, and it is the key beside the item, the way a credit keys on its19// billing.20type genreRow struct {21	Library string22	Item    string23	Rank    int24	Genre   string25}2627// The rows of one title, in the sidecar's order.28func genreRows(library, item string, genres []string) []genreRow {29	rows := make([]genreRow, 0, len(genres))30	for rank, genre := range genres {31		rows = append(rows, genreRow{Library: library, Item: item, Rank: rank, Genre: genre})32	}33	return rows34}3536// A repeat write updates the genre in place, keyed by the title and the rank,37// so a sidecar that reorders its genres updates the rows.38func (c *Catalog) UpsertGenres(ctx context.Context, rows []genreRow) (int, error) {39	statements := make([]statement, len(rows))40	for i, row := range rows {41		statements[i] = statement{42			sql: `INSERT INTO genres (library, item, rank, genre) VALUES (?, ?, ?, ?) ` +43				`ON CONFLICT (library, item, rank) DO UPDATE SET genre = excluded.genre`,44			params: []any{row.Library, row.Item, row.Rank, row.Genre},45		}46	}47	return c.apply(ctx, statements)48}4950// The delete names every key column, so a sweep takes the rows it read and51// no other library's.52func (c *Catalog) DeleteGenres(ctx context.Context, library string, keys []genreKey) (int, error) {53	statements := make([]statement, len(keys))54	for i, key := range keys {55		statements[i] = statement{56			sql:    `DELETE FROM genres WHERE library = ? AND item = ? AND rank = ?`,57			params: []any{library, key.Item, key.Rank},58		}59	}60	return c.apply(ctx, statements)61}6263// The two key columns after the library, as the sweep reads them back.64type genreKey struct {65	Item string66	Rank int67}6869// The key travels through the sweep as one string, joined by the separator70// no id holds, the way a credit key does.71func genreSeenKey(row genreRow) string {72	return row.Item + linkKeySeparator + strconv.Itoa(row.Rank)73}7475func genreKeys(keys []string) []genreKey {76	out := make([]genreKey, len(keys))77	for i, key := range keys {78		item, rank, _ := strings.Cut(key, linkKeySeparator)79		number, _ := strconv.Atoi(rank)80		out[i] = genreKey{Item: item, Rank: number}81	}82	return out83}8485// The genres this library holds that the current epoch did not mark, one86// bounded batch, joined the way the mark joined them.87func genrePruneSQL() string {88	return `SELECT item || char(31) || rank FROM genres` +89		` WHERE library = ?` +90		` AND '` + seenGenre + `' || item || char(31) || rank` +91		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +92		` LIMIT ?`93}9495// A rescan reaches one folder's genres through the movie or series row the96// folder holds, so this sweep runs before the item sweeps take that row.97func scopedGenrePruneSQL() string {98	scope := func(table string) string {99		return `SELECT id FROM ` + table + ` WHERE library = ? AND ` + pathScopeClause("path")100	}101	return `SELECT item || char(31) || rank FROM genres` +102		` WHERE library = ?` +103		` AND '` + seenGenre + `' || item || char(31) || rank` +104		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +105		` AND item IN (` + scope("movies") + ` UNION ` + scope("series") + `)` +106		` LIMIT ?`107}108109func scopedGenrePruneParams(library, folder string, epoch int64) []any {110	params := []any{library, epoch}111	for range 2 {112		params = append(params, library)113		params = append(params, pathScopeParams(folder)...)114	}115	return append(params, pruneBatch)116}117118// One bounded batch of one library's genres, for the whole-library sweep.119func librarySweepGenreSQL() string {120	return `SELECT item || char(31) || rank FROM genres WHERE library = ? LIMIT ?`121}
identity.go 100.0%
1package main23// identity.go rolls every name a title carries onto its one canonical id, so a4// lookup by any of its provider ids or its folder key resolves the same work.5// aliasesFor in rows.go covers the providers in the canonical order; this file6// adds the rest, so a movie that also lists a tvdb id still resolves by it.78import (9	"iter"10	"sort"11)1213// aliasRowsForItem builds every alias an item carries. It starts with14// aliasesFor, which reads the providers in the canonical order and the folder15// key, then adds an alias for every other provider id the sidecar named. The16// extra providers are added in sorted order, so a re-walk of the same sidecar17// writes the same rows.18func aliasRowsForItem(library, kind string, providerIDs map[string]string, folderKey, canonicalID string) []aliasRow {19	rows := aliasesFor(library, kind, providerIDs, folderKey, canonicalID)20	seen := map[string]bool{}21	for _, row := range rows {22		seen[row.Alias] = true23	}24	extras := make([]string, 0, len(providerIDs))25	for provider, value := range providerIDs {26		if value == "" {27			continue28		}29		alias := kind + ":" + provider + ":" + value30		if !seen[alias] {31			extras = append(extras, alias)32		}33	}34	sort.Strings(extras)35	for _, alias := range extras {36		rows = append(rows, aliasRow{Alias: alias, Library: library, Item: canonicalID, Source: aliasSourceProvider})37	}38	return rows39}4041// walkResult is what one walk read off the volume: the item rows per kind,42// the file rows and their item links, the alias rows, the attempts the .liken43// files record, and the two counts the report carries.44type walkResult struct {45	movies []movieRow46	// No folder produces a set row. The full walk fills sets after its last47	// folder, from the fold over every movie it read.48	sets     []setRow49	series   []seriesRow50	episodes []episodeRow51	files    []fileRow52	aliases  []aliasRow53	// The volume holds the attempts and the catalog only derives them, so a54	// folder that left takes its attempts with it.55	attempts []attemptRow56	// The people, which no title folder holds. The credits of each title come off57	// its own credits ledger, and the people themselves come off the walk of58	// .contributors/ after the last title folder.59	credits            []creditRow60	contributors       []contributorRow61	contributorAliases []contributorAliasRow62	// The genres of each movie and series, in the sidecar's order, derived63	// from the sidecar the way the attempts are derived from the ledgers.64	genres []genreRow65	// The three tables one franchise directory writes. The members and66	// the runs key on the franchise and the position, so they travel with67	// the franchises row that names them.68	franchises       []franchiseRow69	franchiseMembers []franchiseMemberRow70	franchiseRuns    []franchiseRunRow71	titles           int72	unidentified     int73	// the paths of the folders this walk could not identify, so a74	// full walk names a sample of them in its log without holding every75	// one. It carries one path per unidentified folder.76	unidentifiedNames []string77	// A walk that could not read a directory, a sidecar, or a file read78	// only part of the volume, whatever the depth of the failure. The79	// prune-abort guard then skips the prune for this pass and keeps the80	// rows the walk did not reach.81	readError bool82	// the directories the walk left unread, with the error each read83	// returned. The mark above says only that some read failed, so the84	// collector logs these to name the paths a person has to fix.85	readFailures []walkReadFailure86}8788// walkReadFailure is one directory the walk could not read: the path it89// tried and the error the read returned.90type walkReadFailure struct {91	path string92	err  error93}9495// noteReadError folds one failed read into the walk's incomplete mark. A96// folder the scanner could not read in full must never sweep as departed,97// and the mark is what holds the prune back.98func (r *walkResult) noteReadError(err error) {99	if err != nil {100		r.readError = true101	}102}103104// appendFolder folds one folder's rows into a running buffer, so the streaming105// full walk gathers several folders before it writes them in one batch and never106// holds the whole library.107func appendFolder(buffer, folder *walkResult) {108	buffer.movies = append(buffer.movies, folder.movies...)109	buffer.series = append(buffer.series, folder.series...)110	buffer.episodes = append(buffer.episodes, folder.episodes...)111	buffer.files = append(buffer.files, folder.files...)112	buffer.aliases = append(buffer.aliases, folder.aliases...)113	buffer.attempts = append(buffer.attempts, folder.attempts...)114	buffer.credits = append(buffer.credits, folder.credits...)115	buffer.contributors = append(buffer.contributors, folder.contributors...)116	buffer.contributorAliases = append(buffer.contributorAliases, folder.contributorAliases...)117	buffer.genres = append(buffer.genres, folder.genres...)118	buffer.franchises = append(buffer.franchises, folder.franchises...)119	buffer.franchiseMembers = append(buffer.franchiseMembers, folder.franchiseMembers...)120	buffer.franchiseRuns = append(buffer.franchiseRuns, folder.franchiseRuns...)121}122123// collectFolders reads a whole folder stream into one walkResult, with the124// counts and the read-error signal. The tests and a small library read a root125// this way.126func collectFolders(folders iter.Seq[*walkResult]) *walkResult {127	result := &walkResult{}128	for folder := range folders {129		appendFolder(result, folder)130		result.titles += folder.titles131		result.unidentified += folder.unidentified132		if folder.readError {133			result.readError = true134		}135		result.readFailures = append(result.readFailures, folder.readFailures...)136	}137	return result138}
identityladder.go 99.2%
1package main23// identityladder.go is the identity ladder: the exact tests a title climbs4// before liken writes an id. No rung carries a score. A score is a number5// nobody can check, and a reason is a sentence a person reads in the ledger6// next to the id.78import (9	"context"10	"fmt"11	"slices"12	"strconv"13	"strings"14	"time"15)1617// How far a provider's runtime may sit from the file's and still count as the18// same work. The plan leaves the number for the drill to settle.19const runtimeMargin = 5 * time.Minute2021// The name of each test the ladder runs. The ledger records the tests an22// answer passed as its reason, so a person reads what the answer rested on.23const (24	testTitle    = "title"25	testYear     = "year"26	testNearYear = "a year on either side"27	testCountry  = "country"28	testRuntime  = "runtime"29)3031// The reason is built from the tests the answer passed, so a new rung adds32// one word and every reason still reads as a sentence.33func reasonFrom(tests ...string) string {34	if len(tests) < 3 {35		return strings.Join(tests, " and ")36	}37	return strings.Join(tests[:len(tests)-1], ", ") + ", and " + tests[len(tests)-1]38}3940// What the ladder is asked about: the kind, the clues the name gave, and the41// runtime the probe measured, which is zero where none was read.42type identitySearch struct {43	kind     string44	title    string45	year     int46	duration time.Duration47}4849// What the ladder answers: an id with the reason for it, or the candidates a50// person chooses from.51type identityAnswer struct {52	id         int53	reason     string54	candidates []likenCandidate55}5657// One result the title test kept, with the runtime where the ladder read it.58type identityMatch struct {59	result  tmdbResult60	runtime time.Duration61}6263// The ladder itself, rung by rung. A name with no year climbs on the title64// alone, because such a folder is exactly the sidecar-less case the ladder65// exists for. One survivor is written with its reason. Several survivors go66// to the runtime rung when the probe measured one, and anything else is a67// candidate list.68func climbIdentityLadder(ctx context.Context, client *tmdbClient, search identitySearch) (identityAnswer, error) {69	search, country := readQualifier(search)70	matched, err := searchOnYear(ctx, client, search, search.year)71	if err != nil {72		return identityAnswer{}, err73	}74	tests := []string{testTitle, testYear}75	if search.year == 0 {76		tests = []string{testTitle}77	}7879	if len(matched) == 0 && search.year > 0 {80		matched, err = searchNeighbouringYears(ctx, client, search)81		if err != nil {82			return identityAnswer{}, err83		}84		tests = []string{testTitle, testNearYear}85	}86	if kept := fromCountry(matched, country); len(kept) > 0 {87		matched, tests = kept, append(tests, testCountry)88	}89	if len(matched) == 1 {90		return identityAnswer{id: matched[0].result.ID, reason: reasonFrom(tests...)}, nil91	}92	if len(matched) > 1 && search.duration > 0 {93		matched, err = readRuntimes(ctx, client, search.kind, matched)94		if err != nil {95			return identityAnswer{}, err96		}97		if near := withinRuntime(matched, search.duration); len(near) == 1 {98			return identityAnswer{id: near[0].result.ID, reason: reasonFrom(append(tests, testRuntime)...)}, nil99		}100	}101	return identityAnswer{candidates: candidatesFrom(matched, search)}, nil102}103104// The qualifier a namer writes after a title to part it from another show of105// the same name: a country, as in Shameless (US), or a year, as in The Office106// (2011). The title reaches the provider without it, because the provider107// names the show Shameless. A country is a test only for a series, where TMDb108// states origin_country.109func readQualifier(search identitySearch) (identitySearch, string) {110	base, qualifier := partTitle(search.title)111	search.title = base112	if year := qualifiedYear(qualifier); year > 0 {113		if search.year == 0 {114			search.year = year115		}116		return search, ""117	}118	if search.kind != libraryKindSeries {119		return search, ""120	}121	return search, countryCode(qualifier)122}123124// partTitle cuts a trailing parenthesized qualifier off a title and answers125// with both halves. A title that is one parenthesized group keeps it, because126// the group is the whole name and not a qualifier.127func partTitle(title string) (string, string) {128	trimmed := strings.TrimSpace(title)129	if !strings.HasSuffix(trimmed, ")") {130		return trimmed, ""131	}132	open := strings.LastIndexByte(trimmed, '(')133	if open <= 0 {134		return trimmed, ""135	}136	return strings.TrimSpace(trimmed[:open]), strings.TrimSpace(trimmed[open+1 : len(trimmed)-1])137}138139// qualifiedYear reads a four-digit qualifier as a year, and anything else as140// no year.141func qualifiedYear(qualifier string) int {142	if len(qualifier) != 4 {143		return 0144	}145	return leadingYear(qualifier)146}147148// countryCode reads a two-letter qualifier as a country code, in the upper149// case TMDb states origin_country in, and anything else as no country.150func countryCode(qualifier string) string {151	if len(qualifier) != 2 {152		return ""153	}154	upper := strings.ToUpper(qualifier)155	for _, letter := range upper {156		if letter < 'A' || letter > 'Z' {157			return ""158		}159	}160	return upper161}162163// The country test keeps the results the provider states that origin for. It164// runs only where it keeps one, because a provider that states another origin165// is a fact for a person to read on the candidate, and not a reason to answer166// with nothing.167func fromCountry(matched []identityMatch, country string) []identityMatch {168	if country == "" {169		return nil170	}171	var kept []identityMatch172	for _, match := range matched {173		if slices.Contains(match.result.OriginCountry, country) {174			kept = append(kept, match)175		}176	}177	return kept178}179180// Rung one: the title as the provider spells it, or as it was first released,181// and the year the name carried.182func searchOnYear(ctx context.Context, client *tmdbClient, search identitySearch, year int) ([]identityMatch, error) {183	results, err := client.search(ctx, search.kind, search.title, year)184	if err != nil {185		return nil, err186	}187	wanted := normalizeTitle(search.title)188	var matched []identityMatch189	for _, result := range results {190		if normalizeTitle(result.name()) != wanted && normalizeTitle(result.originalName()) != wanted {191			continue192		}193		if year > 0 && result.year() != year {194			continue195		}196		matched = append(matched, identityMatch{result: result})197	}198	return matched, nil199}200201// The year on either side, which exists because TMDb states the first release202// anywhere. A December opening abroad carries a different year from the one203// the release name states.204func searchNeighbouringYears(ctx context.Context, client *tmdbClient, search identitySearch) ([]identityMatch, error) {205	held := map[int]bool{}206	var matched []identityMatch207	for _, year := range []int{search.year - 1, search.year + 1} {208		found, err := searchOnYear(ctx, client, search, year)209		if err != nil {210			return nil, err211		}212		for _, match := range found {213			if held[match.result.ID] {214				continue215			}216			held[match.result.ID] = true217			matched = append(matched, match)218		}219	}220	return matched, nil221}222223// The runtime rung costs one call per candidate, because a search result224// carries no runtime. So the rung runs only where the title and the year left225// several.226func readRuntimes(ctx context.Context, client *tmdbClient, kind string, matched []identityMatch) ([]identityMatch, error) {227	for at, match := range matched {228		runtime, err := client.runtime(ctx, kind, match.result.ID)229		if err != nil {230			return nil, err231		}232		matched[at].runtime = runtime233	}234	return matched, nil235}236237// A provider that states no runtime is never kept by this rung, because a238// missing number is not a match.239func withinRuntime(matched []identityMatch, duration time.Duration) []identityMatch {240	var near []identityMatch241	for _, match := range matched {242		if match.runtime > 0 && absDuration(match.runtime-duration) <= runtimeMargin {243			near = append(near, match)244		}245	}246	return near247}248249func absDuration(d time.Duration) time.Duration {250	if d < 0 {251		return -d252	}253	return d254}255256// The receipt on each candidate says what matched and what did not, so a257// person chooses without running the search again.258func candidatesFrom(matched []identityMatch, search identitySearch) []likenCandidate {259	candidates := make([]likenCandidate, 0, len(matched))260	for _, match := range matched {261		candidates = append(candidates, likenCandidate{262			ID:      providerIDs{"tmdb": strconv.Itoa(match.result.ID)},263			Title:   match.result.name(),264			Year:    match.result.year(),265			Receipt: receiptFor(match, search),266		})267	}268	if len(candidates) == 0 {269		return nil270	}271	return candidates272}273274func receiptFor(match identityMatch, search identitySearch) map[string]string {275	receipt := map[string]string{"title": "match", "year": yearReceipt(match, search)}276	if search.duration > 0 && match.runtime > 0 {277		receipt["runtime"] = runtimeReceipt(match.runtime - search.duration)278	}279	return receipt280}281282func yearReceipt(match identityMatch, search identitySearch) string {283	switch {284	case search.year == 0:285		return "the name carries no year"286	case match.result.year() == search.year:287		return "match"288	default:289		return "no match"290	}291}292293// The receipt states the distance in minutes and never a score, so a person294// can check it against the file.295func runtimeReceipt(off time.Duration) string {296	off = absDuration(off).Round(time.Minute)297	if off == 0 {298		return "match"299	}300	return fmt.Sprintf("%d minutes off", int(off.Minutes()))301}302303// The articles the normalization drops, because a provider and a release name304// disagree about them.305var titleArticles = map[string]bool{"the": true, "a": true, "an": true}306307// The roman numerals a sequel carries. The numeral one is not among them,308// because a title ending in I is a word more often than a number.309var romanNumerals = map[string]int{310	"ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7,311	"viii": 8, "ix": 9, "x": 10, "xi": 11, "xii": 12, "xiii": 13,312}313314// The one normalization both sides of a title test run through: case,315// accents, punctuation, the leading article, and the roman numerals.316func normalizeTitle(title string) string {317	base, _ := partTitle(title)318	var folded strings.Builder319	for _, r := range strings.ToLower(base) {320		switch {321		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':322			folded.WriteRune(r)323		default:324			if ascii, held := accentFold[r]; held {325				folded.WriteString(ascii)326				continue327			}328			folded.WriteByte(' ')329		}330	}331	words := strings.Fields(folded.String())332	if len(words) > 1 && titleArticles[words[0]] {333		words = words[1:]334	}335	for at, word := range words {336		if value, held := romanNumerals[word]; held {337			words[at] = strconv.Itoa(value)338		}339	}340	return strings.Join(words, " ")341}
identityrole.go 96.9%
1package main23// identityrole.go is the identity fact's container: what it reads out of4// the catalog for one gap, and what it writes when the ladder answers.56import (7	"context"8	"fmt"9	"os"10	"path/filepath"11	"strconv"12	"time"13)1415// A container with no key fails before it writes anything, so the Job says16// what the pod is missing.17func (e *enricher) identityFact(ctx context.Context) error {18	token := os.Getenv(tmdbTokenVariable)19	if token == "" {20		return fmt.Errorf("%s is empty, and the identity fact cannot ask a provider without it", tmdbTokenVariable)21	}22	return e.identityGap(ctx, newTMDbClient(tmdbAPIBase, token))23}2425// A catalog read that fails ends the container, because the gap list is the26// work. A provider that refuses one title records an error attempt, and the27// run carries on to the next.28func (e *enricher) identityGap(ctx context.Context, client *tmdbClient) error {29	ids, err := e.gaps(ctx, factIdentity, time.Now().UTC())30	if err != nil {31		return err32	}33	asked := 034	for _, id := range ids {35		if err := ctx.Err(); err != nil {36			return err37		}38		item, held, err := e.catalog.identityItem(ctx, e.library, id)39		if err != nil {40			return err41		}42		if !held || !e.inScope(item.path) {43			continue44		}45		e.identifyOne(ctx, client, item)46		asked++47	}48	e.logf("asked the provider about %d of the %d titles with no id", asked, len(ids))49	return nil50}5152// One title: climb the ladder, write the id into the sidecar where the ladder53// is sure, and record the answer in the ledger either way.54func (e *enricher) identifyOne(ctx context.Context, client *tmdbClient, item identityItem) {55	folder := filepath.Join(e.root, item.path)56	answer, err := climbIdentityLadder(ctx, client, identitySearch{57		kind:     e.kind,58		title:    item.title,59		year:     item.year,60		duration: e.runtimeOf(item, folder),61	})62	if err != nil {63		e.logf("could not identify %s: %v", item.path, err)64		e.recordIdentity(folder, nil, attemptError)65		return66	}67	switch {68	case answer.id > 0:69		e.writeIdentity(ctx, client, folder, item, answer)70	case len(answer.candidates) > 0:71		e.logf("%s waits for a person, with %d candidates", item.path, len(answer.candidates))72		e.recordIdentity(folder, &likenItem{Path: likenSelfPath, Candidates: answer.candidates}, attemptCandidates)73	default:74		e.logf("no provider named %s", item.path)75		e.recordIdentity(folder, nil, attemptNothing)76	}77}7879// The other databases' ids follow the provider's own: one call to TMDb's80// external ids gives them, each one goes into the sidecar as its own81// uniqueid, and the scanner lifts every one into aliases. That is what makes82// a provider that keys on an IMDb id or a TheTVDB id reachable with no83// account at that database.84func (e *enricher) writeIdentity(ctx context.Context, client *tmdbClient, folder string,85	item identityItem, answer identityAnswer) {86	id := strconv.Itoa(answer.id)87	sidecar, rootElement := identitySidecar(e.kind, folder)88	if err := e.writeUniqueID(sidecar, rootElement, item.title, "tmdb", id); err != nil {89		e.logf("could not write the id of %s: %v", item.path, err)90		e.recordIdentity(folder, nil, attemptError)91		return92	}93	ids := providerIDs{"tmdb": id}94	external := e.externalIDs(ctx, client, item, answer.id)95	for _, provider := range sortedKeys(external) {96		if err := e.writeUniqueID(sidecar, rootElement, item.title, provider, external[provider]); err != nil {97			e.logf("could not write the %s id of %s: %v", provider, item.path, err)98			continue99		}100		ids[provider] = external[provider]101	}102	e.logf("identified %s as tmdb %s, by %s", item.path, id, answer.reason)103	e.recordIdentity(folder, &likenItem{104		Path: likenSelfPath, ID: ids, Reason: answer.reason, Written: time.Now().UTC(),105	}, attemptFound)106}107108// An id the provider will not answer for leaves the title with the id it has,109// because the provider's own id is what the catalog keys on, and the next run110// asks again.111func (e *enricher) externalIDs(ctx context.Context, client *tmdbClient,112	item identityItem, id int) providerIDs {113	external, err := client.externalIDs(ctx, e.kind, id)114	if err != nil {115		e.logf("could not read the other ids of %s: %v", item.path, err)116		return nil117	}118	return external.providerIDs()119}120121// The default mark goes on the provider this operator keys its own ids on, so122// a reader takes that one first.123func (e *enricher) writeUniqueID(sidecar, rootElement, title, provider, id string) error {124	element := fmt.Appendf(nil, `<uniqueid type=%q>%s</uniqueid>`, provider, id)125	if provider == "tmdb" {126		element = fmt.Appendf(nil, `<uniqueid type="tmdb" default="true">%s</uniqueid>`, id)127	}128	return e.writer.editNFO(sidecar, rootElement, title,129		xmlElement{name: "uniqueid", attribute: "type", value: provider}, element)130}131132// The item entry and the attempt are one write of one file, so a reader never133// sees an answer without its attempt.134func (e *enricher) recordIdentity(folder string, entry *likenItem, result string) {135	err := e.writer.updateLikenLedger(folder, factIdentity, func(ledger *likenLedger) {136		if entry != nil {137			ledger.noteItem(*entry)138		}139		ledger.noteAttempt(likenAttempt{Path: likenSelfPath, At: time.Now().UTC(), Result: result})140	})141	if err != nil {142		e.logf("could not record the identity attempt at %s: %v", folder, err)143	}144	if result == attemptFound {145		e.rescanTitle(folder)146		return147	}148	e.writeRows(factIdentity, folder, false)149}150151// Which sidecar carries a title's id: tvshow.nfo for a series, movie.nfo for152// a movie.153func identitySidecar(kind, folder string) (string, string) {154	if kind == libraryKindSeries {155		return filepath.Join(folder, seriesSidecarName), nfoRootSeries156	}157	return filepath.Join(folder, movieSidecarName), nfoRootMovie158}159160// The runtime comes off the sidecar where the catalog has none, because the161// probe container wrote it in this same Job and no scan has read it yet.162func (e *enricher) runtimeOf(item identityItem, folder string) time.Duration {163	if item.duration > 0 {164		return time.Duration(item.duration) * time.Second165	}166	if e.kind != libraryKindMovies {167		return 0168	}169	data, err := os.ReadFile(filepath.Join(folder, movieSidecarName))170	if err != nil {171		return 0172	}173	meta, err := parseMovieNFO(data)174	if err != nil {175		return 0176	}177	return time.Duration(meta.Duration) * time.Second178}179180// What the identity fact reads for one gap: where the title sits, the181// clues its name gave, and the runtime the catalog holds.182type identityItem struct {183	id       string184	path     string185	title    string186	year     int187	duration int64188}189190// The item table follows the id's own scope, movie or series. A row that left191// between the gap read and this one is skipped and not an error, because a192// folder may move while a Job runs.193func (c *Catalog) identityItem(ctx context.Context, library, id string) (identityItem, bool, error) {194	table := "movies"195	if !isMovieID(id) {196		table = "series"197	}198	item := identityItem{id: id}199	held := false200	err := c.stream(ctx, `SELECT path, title, released, duration FROM `+table+` WHERE library = ? AND id = ?`,201		[]any{library, id}, func(cells []any) error {202			if held || len(cells) < 4 {203				return nil204			}205			held = true206			item.path, _ = cells[0].(string)207			item.title, _ = cells[1].(string)208			released, _ := cells[2].(string)209			item.year = leadingYear(released)210			item.duration = cellNumber(cells[3])211			return nil212		})213	return item, held, err214}215216func isMovieID(id string) bool {217	return len(id) > len(scopeMovie) && id[:len(scopeMovie)+1] == scopeMovie+":"218}
librarysweep.go 96.2%
1package main23// This file is the sweep the cleanup Job runs: every row one library4// holds, out of every replicated table, through the local agent.5//6// The sweep reads bounded batches of keys and deletes by key, the7// shape pruneLibrary already uses, and not one bare DELETE per8// table. The local harness settled that. One `DELETE FROM movies9// WHERE library = ?` over 47,500 rows took 8.5 seconds, reached one10// peer after 67 seconds, and never reached the other: that peer11// buffered all 47,500 changes as one version with a contiguous seq12// range and no recorded gap, never applied them, stayed divergent13// past ten minutes, and an agent restart did not clear it. The same14// delete in batches of 2,500 reached both peers in about half a15// second. So no transaction here carries more than pruneBatch rows.1617import "context"1819// librarySweepStep is one table of the sweep: the read that answers20// a batch of the library's keys, and the delete that takes those21// keys out.22type librarySweepStep struct {23	read   string24	delete func(context.Context, []string) (int, error)25}2627// SweepLibrary deletes every row one library holds and answers with28// how many rows went. Every key leads with the library, so each read29// and each delete reaches that library's rows and no other's.30//31// The sweep is safe to repeat: a second run reads no keys and32// deletes nothing, which is what lets the cleanup Job re-issue it on33// every tick for as long as the operator keeps the pod up.34func (c *Catalog) SweepLibrary(ctx context.Context, library string) (int, error) {35	removed := 036	for _, step := range c.librarySweepSteps(library) {37		swept, err := c.sweep(ctx, step.read, []any{library, pruneBatch}, step.delete)38		removed += swept39		if err != nil {40			return removed, err41		}42	}43	return removed, nil44}4546// librarySweepSteps is the sweep in the order it runs. The aliases, the47// credits, and the links that point at an item go before the item rows,48// the files and the attempts follow, and the people go last, the49// contributor aliases before the contributors they resolve to. No step50// leaves a row whose parent is gone.51func (c *Catalog) librarySweepSteps(library string) []librarySweepStep {52	return []librarySweepStep{53		{librarySweepSQL("aliases", "alias"), func(ctx context.Context, keys []string) (int, error) {54			return c.DeleteAliases(ctx, library, keys)55		}},56		{librarySweepCreditSQL(), func(ctx context.Context, keys []string) (int, error) {57			return c.DeleteCredits(ctx, library, creditKeys(keys))58		}},59		{librarySweepSQL("movies", "id"), func(ctx context.Context, keys []string) (int, error) {60			return c.DeleteMovies(ctx, library, keys)61		}},62		{librarySweepSQL("sets", "id"), func(ctx context.Context, keys []string) (int, error) {63			return c.DeleteSets(ctx, library, keys)64		}},65		{librarySweepSQL("series", "id"), func(ctx context.Context, keys []string) (int, error) {66			return c.DeleteSeries(ctx, library, keys)67		}},68		{librarySweepSQL("episodes", "id"), func(ctx context.Context, keys []string) (int, error) {69			return c.DeleteEpisodes(ctx, library, keys)70		}},71		{librarySweepLinkSQL(), func(ctx context.Context, keys []string) (int, error) {72			return c.DeleteFileItems(ctx, library, fileItemKeys(keys))73		}},74		{librarySweepSQL("files", "path"), func(ctx context.Context, keys []string) (int, error) {75			return c.DeleteFiles(ctx, library, keys)76		}},77		{librarySweepAttemptSQL(), func(ctx context.Context, keys []string) (int, error) {78			return c.DeleteAttempts(ctx, library, attemptKeys(keys))79		}},80		{librarySweepGenreSQL(), func(ctx context.Context, keys []string) (int, error) {81			return c.DeleteGenres(ctx, library, genreKeys(keys))82		}},83		{librarySweepContributorAliasSQL(), func(ctx context.Context, keys []string) (int, error) {84			return c.DeleteContributorAliases(ctx, library, contributorAliasKeys(keys))85		}},86		{librarySweepSQL("contributors", "path"), func(ctx context.Context, keys []string) (int, error) {87			return c.DeleteContributors(ctx, library, keys)88		}},89		{librarySweepFranchiseMemberSQL(), func(ctx context.Context, keys []string) (int, error) {90			return c.DeleteFranchiseMembers(ctx, library, franchiseMemberKeys(keys))91		}},92		{librarySweepFranchiseRunSQL(), func(ctx context.Context, keys []string) (int, error) {93			return c.DeleteFranchiseRuns(ctx, library, franchiseRunKeys(keys))94		}},95		{librarySweepSQL("franchises", "id"), func(ctx context.Context, keys []string) (int, error) {96			return c.DeleteFranchises(ctx, library, keys)97		}},98	}99}100101// librarySweepSQL reads one bounded batch of one library's keys.102// The table and column names are constants this package holds and103// never input, so naming them in the SQL text carries no injection.104func librarySweepSQL(table, column string) string {105	return `SELECT ` + column + ` FROM ` + table + ` WHERE library = ? LIMIT ?`106}107108// One bounded batch of one library's attempts, with the two key109// columns joined the way every sweep of a two-column key joins them.110func librarySweepAttemptSQL() string {111	return `SELECT item || char(31) || ` + attemptFactColumn + ` FROM attempts WHERE library = ? LIMIT ?`112}113114// librarySweepLinkSQL reads the link table's two key columns joined115// by the same separator the prune uses, so one string comes back per116// row and fileItemKeys splits it into the columns the delete names.117func librarySweepLinkSQL() string {118	return `SELECT path || char(31) || item FROM file_items WHERE library = ? LIMIT ?`119}
likenledger.go 95.7%
1package main23// likenledger.go is the .liken/ directory beside a title: what each fact4// writes there, and how an entry is keyed. One file per writer is what lets5// several containers write beside one title on a network mount with no locks.6// No two of them ever open the same file for write.78import (9	"errors"10	"fmt"11	"io/fs"12	"os"13	"path/filepath"14	"slices"15	"strings"16	"time"1718	"gopkg.in/yaml.v3"19)2021// The directory beside a title that holds liken's own files. It is a dot22// name, so the walk and every ecosystem player skip it.23const likenDirectory = ".liken"2425// The entry path that names the folder's own title, as against a file under a26// season folder.27const likenSelfPath = "."2829// The ids are a map of provider to id and never one column, because a title30// carries ids under several schemes and a later fact adds more.31type providerIDs map[string]string3233// A numeric id is written as a number, which is the shape the plan's example34// and every provider's own documentation use.35func (p providerIDs) MarshalYAML() (any, error) {36	node := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}37	for _, provider := range sortedKeys(p) {38		value := &yaml.Node{Kind: yaml.ScalarNode, Value: p[provider]}39		if allDigits(p[provider]) {40			value.Tag = "!!int"41		}42		node.Content = append(node.Content,43			&yaml.Node{Kind: yaml.ScalarNode, Value: provider}, value)44	}45	return node, nil46}4748// A number and a string both read back as one id, so a person who quotes an49// id by hand is read the same as the writer.50func (p *providerIDs) UnmarshalYAML(node *yaml.Node) error {51	var raw map[string]any52	if err := node.Decode(&raw); err != nil {53		return err54	}55	ids := providerIDs{}56	for provider, value := range raw {57		ids[provider] = fmt.Sprint(value)58	}59	*p = ids60	return nil61}6263func allDigits(value string) bool {64	return value != "" && strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) < 065}6667func sortedKeys(ids providerIDs) []string {68	keys := make([]string, 0, len(ids))69	for key := range ids {70		keys = append(keys, key)71	}72	slices.Sort(keys)73	return keys74}7576// One .liken/<fact>.yaml file: the ledger the identity fact keeps, and77// the attempts every fact appends to.78type likenLedger struct {79	Items []likenItem `yaml:"items,omitempty"`80	// The credits fact's own list, in the file that is its ledger: one entry per81	// credited person, with the directory in .contributors/ that holds that82	// person. Only the credits fact writes it, so the list, the answer, and the83	// attempts are one write of one file.84	Credits []creditEntry `yaml:"credits,omitempty"`85	// The arrival fact's own list, in the file that is its ledger: one entry86	// per video file with the time it arrived. Only the arrival fact writes it,87	// and the walk reads it for the added and arrived columns.88	Files    []arrivalEntry `yaml:"files,omitempty"`89	Attempts []likenAttempt `yaml:"attempts,omitempty"`90}9192// The provider blocks that answered one fact: one name for a single value,93// and a list for a set the fact took the union of. A person reads either form94// back the same way.95type providerNames []string9697func (p providerNames) MarshalYAML() (any, error) {98	if len(p) == 1 {99		return p[0], nil100	}101	return []string(p), nil102}103104func (p *providerNames) UnmarshalYAML(node *yaml.Node) error {105	if node.Kind == yaml.ScalarNode {106		var name string107		if err := node.Decode(&name); err != nil {108			return err109		}110		*p = providerNames{name}111		return nil112	}113	var names []string114	if err := node.Decode(&names); err != nil {115		return err116	}117	*p = names118	return nil119}120121// One item as a ledger records it. Provider is which provider answered, and122// Wrote is the hash of the element group the fact left in the .nfo. The next123// run compares the group on disk with that hash, so a group another writer124// changed is a fight and not an overwrite.125type likenItem struct {126	Path string `yaml:"path"`127	// Which provider answered for this item, so a person reads why the file128	// looks the way it does. An art fact writes existing here for a file another129	// tool had already written.130	Provider providerNames `yaml:"provider,omitempty"`131	ID       providerIDs   `yaml:"id,omitempty"`132	Reason   string        `yaml:"reason,omitempty"`133	// Source is where the bytes came from, for a fact that writes a file from134	// a link. The franchise art fetch keys on it: the same link is never read135	// again, and a changed link is.136	Source     string           `yaml:"source,omitempty"`137	Wrote      string           `yaml:"wrote,omitempty"`138	Written    time.Time        `yaml:"written,omitempty"`139	Candidates []likenCandidate `yaml:"candidates,omitempty"`140}141142// One name reads back as the one provider that answered, so a fact that takes143// a single value asks whether that block wrote the item.144func (p providerNames) is(name string) bool {145	return len(p) == 1 && p[0] == name146}147148// One candidate and its receipt, which says what matched and what did not, so149// a person chooses without repeating the search.150type likenCandidate struct {151	ID      providerIDs       `yaml:"id"`152	Title   string            `yaml:"title"`153	Year    int               `yaml:"year,omitempty"`154	Receipt map[string]string `yaml:"receipt,omitempty"`155}156157// One attempt as a ledger records it. Provider names the provider blocks the158// attempt asked, empty for a fact that asks none, and the scanner lifts it159// into the attempts table.160type likenAttempt struct {161	Path     string        `yaml:"path"`162	At       time.Time     `yaml:"at"`163	Result   string        `yaml:"result"`164	Provider providerNames `yaml:"provider,omitempty"`165}166167// A fact's file is named for the fact itself, so the one-file-per-168// writer rule is the file name.169func likenLedgerName(fact string) string {170	return fact + ".yaml"171}172173// One item's entry out of a fact's ledger, or false where the ledger holds174// none for that path.175func (l *likenLedger) itemAt(path string) (likenItem, bool) {176	for _, item := range l.Items {177		if item.Path == path {178			return item, true179		}180	}181	return likenItem{}, false182}183184// A folder with no .liken directory reads as an empty ledger and not as an185// error, because the first write to a folder starts from nothing.186func readLikenLedger(folder, fact string) (likenLedger, error) {187	data, err := os.ReadFile(filepath.Join(folder, likenDirectory, likenLedgerName(fact)))188	if errors.Is(err, fs.ErrNotExist) {189		return likenLedger{}, nil190	}191	if err != nil {192		return likenLedger{}, err193	}194	var ledger likenLedger195	if err := yaml.Unmarshal(data, &ledger); err != nil {196		return likenLedger{}, fmt.Errorf("reading %s: %w", likenLedgerName(fact), err)197	}198	return ledger, nil199}200201// The whole file is read, changed, and written again through the write door.202// That is safe because one writer owns one file, and the write is a temporary203// and a rename, so a reader never sees half of it.204func (w *volumeWriter) updateLikenLedger(folder, fact string, change func(*likenLedger)) error {205	ledger, err := readLikenLedger(folder, fact)206	if err != nil {207		return err208	}209	change(&ledger)210	data, err := yaml.Marshal(ledger)211	if err != nil {212		return err213	}214	return w.writeInto(filepath.Join(folder, likenDirectory), likenLedgerName(fact), data)215}216217// One path holds one attempt, the latest, so a file grows with the titles218// under a folder and never with the runs over them.219func (l *likenLedger) noteAttempt(attempt likenAttempt) {220	for at, held := range l.Attempts {221		if held.Path == attempt.Path {222			l.Attempts[at] = attempt223			return224		}225	}226	l.Attempts = append(l.Attempts, attempt)227}228229// One path holds one answer. A later answer replaces the one before it,230// because the ledger says what the fact last found and not how it got231// there.232func (l *likenLedger) noteItem(item likenItem) {233	for at, held := range l.Items {234		if held.Path == item.Path {235			l.Items[at] = item236			return237		}238	}239	l.Items = append(l.Items, item)240}
main.go 0.0%
1// The library operator is the media library layer of a liken cluster.2// It declares libraries as Kubernetes resources, keeps a catalog of3// what they hold, and draws that catalog on the screens the media4// operator plays to.5//6// One binary, with modes, the way the media operator's one image runs7// in several roles. With no argument it is the operator. The plans8// that add the scanners, the enricher, and the organizer add their9// roles as arguments here.10package main1112import (13	"fmt"14	"os"15)1617func main() {18	if len(os.Args) > 1 {19		switch os.Args[1] {20		// Each pod role is a case here. It runs its role and returns.21		case scanMode:22			runScan()23			return24		case cleanupMode:25			runCleanup()26			return27		case reportMode:28			runReport()29			return30		case factsMode:31			runFacts()32			return33		case enrichMode:34			runEnrich()35			return36		}37	}3839	// A failure ends the process on purpose. The kubelet restarts the40	// pod with backoff, and the failure shows in kubectl instead of41	// hiding in a retry loop.42	if err := operate(); err != nil {43		fmt.Fprintf(os.Stderr, "%v\n", err)44		os.Exit(1)45	}46}
metadataprovider.go 93.3%
1package main23// metadataprovider.go holds the MetadataProvider wire type, one account with4// one metadata provider that a Library's sources name, and the reads the5// operator makes for it: the provider collection, its status write, and the6// Secret that holds the key.78import (9	"context"10	"encoding/json"11	"net/http"12	"slices"13	"time"14)1516// A MetadataProvider shares the Library's group and version, because it is17// this operator's own resource.18const metadataProviderAPIVersion = libraryAPIVersion1920// A MetadataProvider is one account with one provider: the Secret that holds21// its key, and the facts it may serve.22type MetadataProvider struct {23	APIVersion string                 `json:"apiVersion,omitempty"`24	Kind       string                 `json:"kind,omitempty"`25	Metadata   ObjectMeta             `json:"metadata"`26	Spec       MetadataProviderSpec   `json:"spec"`27	Status     MetadataProviderStatus `json:"status"`28}2930// The collection ListMetadataProviders answers, read once per pass.31type MetadataProviderList struct {32	Metadata ListMeta           `json:"metadata"`33	Items    []MetadataProvider `json:"items"`34}3536// One block per provider, the way a Library carries one block per kind, and37// the facts this account serves. An absent list of facts is every fact the38// operator's table holds for the block, so a person who wants all of one39// provider names the block alone.40type MetadataProviderSpec struct {41	TMDb   *ProviderTMDb   `json:"tmdb,omitempty"`42	OMDb   *ProviderOMDb   `json:"omdb,omitempty"`43	Fanart *ProviderFanart `json:"fanart,omitempty"`44	TVmaze *ProviderTVmaze `json:"tvmaze,omitempty"`45	Facts  []string        `json:"facts,omitempty"`46}4748// The TMDb block names the Secret alone. The endpoint is TMDb's own, and the49// account is the key.50type ProviderTMDb struct {51	SecretRef SecretKeyRef `json:"secretRef"`52}5354// The OMDb block names the Secret that holds the key of an OMDb account.55type ProviderOMDb struct {56	SecretRef SecretKeyRef `json:"secretRef"`57}5859// The Fanart.tv block names the Secret that holds the project key.60type ProviderFanart struct {61	SecretRef SecretKeyRef `json:"secretRef"`62}6364// The TVmaze block is empty, because TVmaze serves its free tier with no65// account. The block alone says that the operator may ask it.66type ProviderTVmaze struct{}6768// One key in one Secret of the provider's own namespace.69type SecretKeyRef struct {70	Name string `json:"name"`71	Key  string `json:"key,omitempty"`72}7374// The key a provider reads when it names none of its own.75const defaultProviderSecretKey = "token"7677// The key the operator reads out of the Secret: the provider's own, or the78// default the CRD writes.79func (r SecretKeyRef) secretKey() string {80	if r.Key != "" {81		return r.Key82	}83	return defaultProviderSecretKey84}8586// What the operator reports on a provider: the block this account names,87// which the PROVIDER column shows because no printer column can read which88// block a spec holds; the Ready condition its one check per pass produced;89// the facts the provider serves right now; and when the provider last refused90// the key.91type MetadataProviderStatus struct {92	Conditions  []Condition `json:"conditions,omitempty"`93	Provider    string      `json:"provider,omitempty"`94	Facts       []string    `json:"facts,omitempty"`95	LastRefusal time.Time   `json:"lastRefusal,omitzero"`96}9798// The reasons the Ready condition takes, one per answer the check can get.99// Unreachable is the answer where the provider gave no HTTP answer at all.100const (101	reasonReachable   = "Reachable"102	reasonNoSecret    = "NoSecret"103	reasonRefused     = "Refused"104	reasonUnreachable = "Unreachable"105)106107// A provider serves a fact when the table's row for its block holds that fact108// and spec.facts does not narrow it away. Readiness is a separate question,109// so a Library can say which repair a source needs.110func (p *MetadataProvider) serves(fact string) bool {111	return slices.Contains(p.servedFacts(), fact)112}113114// A provider is ready when its last check reached it. A provider no check has115// reported on yet is not ready.116func (p *MetadataProvider) ready() bool {117	for _, condition := range p.Status.Conditions {118		if condition.Type == conditionReady {119			return condition.Status == ConditionTrue120		}121	}122	return false123}124125// The reason of the Ready condition, which the Library's Sources condition126// repeats, so a person reads one answer on the Library and not two objects. A127// provider no check has reported on yet has no reason.128func (p *MetadataProvider) readyReason() string {129	for _, condition := range p.Status.Conditions {130		if condition.Type == conditionReady {131			return condition.Reason132		}133	}134	return ""135}136137// A Secret as this operator reads it. The Data values arrive base64-encoded,138// and a []byte field decodes them on the way in.139type Secret struct {140	Metadata ObjectMeta        `json:"metadata"`141	Data     map[string][]byte `json:"data,omitempty"`142}143144// The providers of every namespace, read with one request, and the two paths145// one provider is written on and its Secret is read on.146const metadataProvidersPath = "/apis/" + metadataProviderAPIVersion + "/metadataproviders"147148func metadataProviderPath(namespace, name string) string {149	return libraryPrefix + namespace + "/metadataproviders/" + name150}151152func secretPath(namespace, name string) string {153	return corePrefix + namespace + "/secrets/" + name154}155156// A cluster that has not applied this CRD serves no such collection. The157// caller reports that and carries on.158func ListMetadataProviders(ctx context.Context, c *Client) (*MetadataProviderList, error) {159	list := &MetadataProviderList{}160	if err := c.RequestJSON(ctx, http.MethodGet, metadataProvidersPath, nil, list); err != nil {161		return nil, err162	}163	return list, nil164}165166// The status subresource is its own write path, so this request never touches167// the spec a person declared.168func PutMetadataProviderStatus(ctx context.Context, c *Client, provider *MetadataProvider) (*MetadataProvider, error) {169	body, err := json.Marshal(provider)170	if err != nil {171		return nil, err172	}173	written := &MetadataProvider{}174	path := metadataProviderPath(provider.Metadata.Namespace, provider.Metadata.Name) + "/status"175	if err := c.RequestJSON(ctx, http.MethodPut, path, body, written); err != nil {176		return nil, err177	}178	return written, nil179}180181// The operator reads the Secret for the reachability check alone. The key182// reaches a container through a secretKeyRef on the pod, so no worker holds183// an API credential.184func GetSecret(ctx context.Context, c *Client, namespace, name string) (*Secret, error) {185	secret := &Secret{}186	if err := c.RequestJSON(ctx, http.MethodGet, secretPath(namespace, name), nil, secret); err != nil {187		return nil, err188	}189	return secret, nil190}
movies.go 96.6%
1package main23// movies.go reads one folder per title into item, file, and alias rows. A4// movies volume holds title folders at its root or under grouping folders, as5// the lab's volume groups by genre, so the walk steps through a grouping6// folder until it reaches a title folder or the depth cap.7//8// It reads every file a title folder holds, and the extras folders beside the9// feature, and not the video files alone. files.go classifies each one.1011import (12	"context"13	"errors"14	"io/fs"15	"os"16	"path/filepath"17	"strconv"18)1920// walkMovies reads a whole movies root into one walkResult by collecting the21// folder stream. The tests and a small library use this whole-root read. It22// keeps no arrival ledger, so a walk of a checked-in tree writes nothing into23// it.24func walkMovies(root, library string, ignore ignoreSet) *walkResult {25	scan := folderScan{root: root, library: library, kind: libraryKindMovies, ignore: ignore}26	return collectFolders(walkTree(context.Background(), root, movieFolderRule(scan)))27}2829// movieGroupingDepth bounds how deep the walk descends through grouping30// folders. A volume that groups by genre and then by studio nests a title31// two levels down, so the cap leaves room for that and more, and it stops a32// deep or looping tree from running the walk away.33const movieGroupingDepth = 83435// movieFolderRule is what the pool in walk.go needs to walk a movies volume. A36// directory that holds a movie.nfo or a video file is a title folder. A37// directory with neither is a grouping folder to descend into, down to the38// depth cap.39func movieFolderRule(scan folderScan) folderRule {40	return folderRule{41		isTitle: isMovieTitleFolder,42		scan: func(dir string, result *walkResult) {43			scanMovieFolder(scan, dir, result)44		},45		ignore:   scan.ignore,46		maxDepth: movieGroupingDepth,47	}48}4950// isMovieTitleFolder reports whether a directory is a title folder: it holds a51// movie.nfo or a video file. A directory with neither is a grouping folder the52// walk steps through.53func isMovieTitleFolder(dir string) bool {54	if exists, err := fileExists(filepath.Join(dir, "movie.nfo")); err == nil && exists {55		return true56	}57	videos, err := listVideoFiles(dir)58	return err == nil && len(videos) > 059}6061// scanMovieFolder reads one title folder into the result: a movie row, a file62// row per video file, a genre row per genre, and the alias rows. The identity63// comes from movie.nfo where the folder holds one, and from the folder name64// where it does not. A folder that yields neither a sidecar nor a year is65// counted unidentified and cataloged by its folder name, so it is still66// browsable and the count is accurate. The added column is the arrival of the67// folder's first video, the one movie.nfo describes.68func scanMovieFolder(scan folderScan, dir string, result *walkResult) {69	root, library := scan.root, scan.library70	name := filepath.Base(dir)71	meta, identified, err := movieIdentity(dir, name)72	// A folder whose sidecar could not be read has no identity this73	// pass, so it writes no row. A row written from the name alone would74	// carry a different id from the one the catalog holds. The walk is75	// already marked incomplete, so the rows the catalog holds stand.76	if err != nil {77		result.noteReadError(err)78		return79	}8081	title := meta.Title82	if !identified {83		title = name84	}8586	key := folderKey(name)87	id := itemID(scopeMovie, meta.ProviderIDs, key)88	relativeDir := relativePath(root, dir)89	primaryArt, allArt, err := discoverArt(root, dir)90	result.noteReadError(err)9192	body := meta.Body93	body.ProviderIDs = meta.ProviderIDs9495	files, err := listVideoFiles(dir)96	result.noteReadError(err)97	arrivals, err := folderArrivals(dir, files)98	result.noteReadError(err)99	var added int64100	if len(files) > 0 {101		added = arrivals[files[0]].added102	}103104	result.movies = append(result.movies, movieRow{105		Id:       id,106		Library:  library,107		Kind:     libraryKindMovies,108		Path:     relativeDir,109		Title:    title,110		SortKey:  sortKey(title),111		Slug:     slug(title, meta.Year),112		Released: meta.Released,113		Added:    added,114		Art:      primaryArt,115		Arts:     allArt,116		Duration: meta.Duration,117		Body:     body,118		SetID:    meta.SetID,119		NFOFacts: meta.NFOFacts,120	})121	result.genres = append(result.genres, genreRows(library, id, body.Genres)...)122123	videos := map[string]bool{}124	for i, video := range files {125		videos[video] = true126		stream, err := movieFileStream(dir, video, i, meta.Stream)127		result.noteReadError(err)128		if err != nil {129			continue130		}131		row, err := movieFileRow(root, dir, video, library, id, stream, arrivals[video].arrived)132		result.noteReadError(err)133		if err != nil {134			continue135		}136		result.files = append(result.files, row)137	}138139	scanMovieFiles(root, dir, library, id, videos, result)140141	result.aliases = append(result.aliases, aliasRowsForItem(library, scopeMovie, meta.ProviderIDs, key, id)...)142	readLikenSidecar(likenSidecar{root: root, dir: dir, library: library, item: id}, result)143	result.titles++144	if !identified {145		result.unidentified++146		result.unidentifiedNames = append(result.unidentifiedNames, relativeDir)147	}148}149150// movieIdentity reads a folder's identity. A readable movie.nfo with a title151// is the identity, and the folder is identified. A folder with no usable152// sidecar falls back to the name parse, and it is identified only when the153// name yields a year or a provider id, the signal that the parse read a real154// release and not an arbitrary folder. A name's provider ids fill what the155// sidecar left out, so a person confirms a candidate by naming the folder in156// Jellyfin's form. A sidecar that is not there falls through to the name157// parse. A sidecar the scanner cannot read is an error, because falling158// through would mint a different id and sweep the title's own rows.159func movieIdentity(dir, name string) (movieMeta, bool, error) {160	data, err := os.ReadFile(filepath.Join(dir, "movie.nfo"))161	switch {162	case err == nil:163		if meta, err := parseMovieNFO(data); err == nil && meta.Title != "" {164			meta.ProviderIDs = mergeProviderIDs(meta.ProviderIDs, parseProviderIDs(name))165			return meta, true, nil166		}167	case !errors.Is(err, fs.ErrNotExist):168		return movieMeta{}, false, err169	}170	title, year := parseReleaseName(name)171	if title == "" {172		title = name173	}174	ids := parseProviderIDs(name)175	return movieMeta{176		Title: title, Year: year, Released: releasedFromYear(year), ProviderIDs: ids,177	}, year > 0 || len(ids) > 0, nil178}179180// releasedFromYear renders a year as the released column, or leaves it empty181// where there is no year.182func releasedFromYear(year int) string {183	if year <= 0 {184		return ""185	}186	return strconv.Itoa(year)187}188189// movie.nfo describes the first video of a title folder, and the sidecar190// beside a file describes every other video. That is the same division the191// probe writes them under.192func movieFileStream(dir, file string, index int, folder streamInfo) (*streamInfo, error) {193	if index == 0 {194		if folder.present() {195			return &folder, nil196		}197		return nil, nil198	}199	return streamBeside(dir, file)200}201202// movieFileRow reads one video file into a file row linked to its movie. The203// technical attributes come from the sidecar's streamdetails where one was204// present, and from the file name where none was.205func movieFileRow(root, dir, file, library, itemID string, stream *streamInfo, arrived int64) (fileRow, error) {206	container, videoCodec, audioCodec, width, height, durationMs := fileAttributes(file, stream)207	absolute := filepath.Join(dir, file)208	size, modified, err := statFile(absolute)209	if err != nil {210		return fileRow{}, err211	}212	class := classifyFile(file, filePlace{kind: libraryKindMovies})213	return fileRow{214		Path:       relativePath(root, absolute),215		Library:    library,216		Container:  container,217		VideoCodec: videoCodec,218		AudioCodec: audioCodec,219		Width:      width,220		Height:     height,221		SizeBytes:  size,222		DurationMs: durationMs,223		Trickplay:  trickplayFor(root, dir, file),224		Present:    true,225		Type:       class.Type,226		Role:       class.Role,227		Modified:   modified,228		Arrived:    arrived,229		Items:      []string{itemID},230	}, nil231}232233// scanMovieFiles reads the rest of a movie title folder: the sidecar, the art,234// the subtitles, the trickplay directory, and the extras folders beside the235// feature. Every one of them links to the movie.236func scanMovieFiles(root, dir, library, itemID string, videos map[string]bool, result *walkResult) {237	rows, subdirectories, err := folderFiles{238		root:    root,239		dir:     dir,240		library: library,241		place:   filePlace{kind: libraryKindMovies},242		item:    constantItem(itemID),243		held:    videos,244	}.read()245	result.noteReadError(err)246	result.files = append(result.files, rows...)247248	for _, name := range subdirectories {249		extras := extrasFolderName(name)250		if extras == "" {251			continue252		}253		rows, _, err := folderFiles{254			root:    root,255			dir:     filepath.Join(dir, name),256			library: library,257			place:   filePlace{kind: libraryKindMovies, extras: extras},258			item:    constantItem(itemID),259		}.read()260		result.noteReadError(err)261		result.files = append(result.files, rows...)262	}263}
mqtt.go 100.0%
1package main23// The MQTT 3.1.1 wire codec, written straight against the protocol4// the way apiclient.go writes the Kubernetes client. The broker is5// Mosquitto and the protocol is a published standard, so a client6// that speaks the few packets this operator needs costs less than a7// third-party library and its release cadence. This operator uses8// QoS 0 alone: a scanner republishes its whole report on every9// reconnect, so the delivery guarantees of QoS 1 and QoS 2 buy10// nothing here.11//12// The functions below build and read whole packets and hold no13// connection state. bus.go owns the socket and drives them.1415import (16	"bufio"17	"fmt"18	"io"19)2021// The MQTT control packet types, in the high nibble of a fixed22// header's first byte. The low nibble carries per-type flags, which23// matter here only for a PUBLISH, where bit 0 is the retain flag.24const (25	mqttConnect   = 0x1026	mqttConnack   = 0x2027	mqttPublish   = 0x3028	mqttSubscribe = 0x8029	mqttSuback    = 0x9030	mqttPingreq   = 0xC031	mqttPingresp  = 0xD032)3334// The MQTT 3.1.1 protocol name and level. The name is the literal35// string "MQTT" and the level is 4, which together tell the broker36// which version of the protocol this client speaks.37const (38	mqttProtocolName  = "MQTT"39	mqttProtocolLevel = 0x0440)4142// The CONNECT flag bits this client sets. Clean session starts every43// connection with no server-side state, which is correct because the44// client re-subscribes and re-publishes on each connect. The will45// bits arrive only when the caller states a will. Username and46// password stay unset, because the in-cluster network is the trust47// boundary and the broker accepts the cluster's own pods.48const (49	connectCleanSession = 0x0250	connectWillFlag     = 0x0451	connectWillRetain   = 0x2052)5354// encodeRemainingLength writes the packet length in the variable-byte55// form the protocol uses: seven bits of length per byte, and the high56// bit set on every byte but the last. One byte covers up to 127, and57// four bytes cover the 268435455 the protocol allows.58func encodeRemainingLength(length int) []byte {59	var encoded []byte60	for {61		digit := byte(length % 128)62		length /= 12863		if length > 0 {64			digit |= 0x8065		}66		encoded = append(encoded, digit)67		if length == 0 {68			return encoded69		}70	}71}7273// decodeRemainingLength reads the variable-byte length back. It reads74// at most four bytes, because a fifth would exceed the protocol's75// limit and marks a stream that has lost frame alignment.76func decodeRemainingLength(reader io.ByteReader) (int, error) {77	length := 078	multiplier := 179	for count := 0; count < 4; count++ {80		digit, err := reader.ReadByte()81		if err != nil {82			return 0, err83		}84		length += int(digit&0x7F) * multiplier85		if digit&0x80 == 0 {86			return length, nil87		}88		multiplier *= 12889	}90	return 0, fmt.Errorf("mqtt: remaining length runs past four bytes")91}9293// appendString writes one length-prefixed UTF-8 string, the shape the94// protocol uses for a topic, a filter, and the client identifier: two95// bytes of length, most significant first, then the bytes.96func appendString(buffer []byte, value string) []byte {97	buffer = append(buffer, byte(len(value)>>8), byte(len(value)))98	return append(buffer, value...)99}100101// appendBytes writes one length-prefixed byte string, the shape a will102// payload takes.103func appendBytes(buffer []byte, value []byte) []byte {104	buffer = append(buffer, byte(len(value)>>8), byte(len(value)))105	return append(buffer, value...)106}107108// packet frames one control packet: a fixed-header first byte, then109// the remaining length, then the body. Every encode function ends110// here, so the length is computed once from the finished body.111func packet(first byte, body []byte) []byte {112	frame := make([]byte, 0, 2+len(body))113	frame = append(frame, first)114	frame = append(frame, encodeRemainingLength(len(body))...)115	return append(frame, body...)116}117118// encodeConnect builds the first packet the client sends. The body is119// the protocol name and level, one flags byte, the keepalive in120// seconds, and the payload: the client identifier and, when the caller121// states one, the will topic and payload. The client authenticates122// with nothing more than its identifier, because the broker accepts123// the cluster's own pods.124func encodeConnect(clientID string, keepalive uint16, will *busWill) []byte {125	flags := byte(connectCleanSession)126	if will != nil {127		flags |= connectWillFlag128		if will.Retained {129			flags |= connectWillRetain130		}131	}132133	var body []byte134	body = appendString(body, mqttProtocolName)135	body = append(body, mqttProtocolLevel, flags)136	body = append(body, byte(keepalive>>8), byte(keepalive))137	body = appendString(body, clientID)138	if will != nil {139		body = appendString(body, will.Topic)140		body = appendBytes(body, will.Payload)141	}142	return packet(mqttConnect, body)143}144145// encodePublish builds a QoS 0 PUBLISH. The retain flag is bit 0 of146// the first byte, and a retained publish tells the broker to hold this147// payload as the topic's last value and deliver it to every later148// subscriber. QoS 0 carries no packet identifier, so the body is the149// length-prefixed topic and then the raw payload.150func encodePublish(topic string, payload []byte, retained bool) []byte {151	first := byte(mqttPublish)152	if retained {153		first |= 0x01154	}155	body := appendString(nil, topic)156	body = append(body, payload...)157	return packet(first, body)158}159160// encodeSubscribe builds a SUBSCRIBE for one topic filter at QoS 0.161// The fixed header is 0x82, because bit 1 is reserved and must be set162// on a SUBSCRIBE. The body is the packet identifier the broker echoes163// in its SUBACK, then the length-prefixed filter and one byte of164// requested QoS.165func encodeSubscribe(packetID uint16, filter string) []byte {166	body := []byte{byte(packetID >> 8), byte(packetID)}167	body = appendString(body, filter)168	body = append(body, 0x00)169	return packet(mqttSubscribe|0x02, body)170}171172// encodePingreq builds the keepalive packet. It carries no body, so it173// is the two bytes 0xC0 0x00, and the broker answers with a PINGRESP.174func encodePingreq() []byte {175	return []byte{mqttPingreq, 0x00}176}177178// readPacket reads one whole control packet: the fixed-header first179// byte, the remaining length, and that many bytes of body. It returns180// the first byte so the caller reads both the packet type in the high181// nibble and the flags in the low nibble.182func readPacket(reader *bufio.Reader) (byte, []byte, error) {183	first, err := reader.ReadByte()184	if err != nil {185		return 0, nil, err186	}187	length, err := decodeRemainingLength(reader)188	if err != nil {189		return 0, nil, err190	}191	body := make([]byte, length)192	if _, err := io.ReadFull(reader, body); err != nil {193		return 0, nil, err194	}195	return first, body, nil196}197198// parseConnack reads the broker's answer to a CONNECT. The body is one199// byte of acknowledge flags and one byte of return code, and a return200// code other than zero is the broker refusing the connection.201func parseConnack(body []byte) error {202	if len(body) < 2 {203		return fmt.Errorf("mqtt: a CONNACK carried %d bytes, want 2", len(body))204	}205	if body[1] != 0x00 {206		return fmt.Errorf("mqtt: the broker refused the connection with code %d", body[1])207	}208	return nil209}210211// parseSuback reads the broker's answer to a SUBSCRIBE. The body is the212// echoed packet identifier and one return code per filter, and a213// return code of 0x80 is the broker refusing that subscription.214func parseSuback(body []byte) error {215	if len(body) < 3 {216		return fmt.Errorf("mqtt: a SUBACK carried %d bytes, want at least 3", len(body))217	}218	for _, code := range body[2:] {219		if code == 0x80 {220			return fmt.Errorf("mqtt: the broker refused a subscription")221		}222	}223	return nil224}225226// parsePublish reads an inbound PUBLISH body into its topic and227// payload. The client subscribes at QoS 0 alone, so an inbound publish228// carries no packet identifier and the payload begins right after the229// length-prefixed topic.230func parsePublish(body []byte) (topic string, payload []byte, ok bool) {231	if len(body) < 2 {232		return "", nil, false233	}234	topicLength := int(body[0])<<8 | int(body[1])235	if len(body) < 2+topicLength {236		return "", nil, false237	}238	return string(body[2 : 2+topicLength]), body[2+topicLength:], true239}
names.go 99.5%
1package main23// names.go reads a title, a year, a season and episode, and a provider id off4// a folder or file name in the *arr and Jellyfin forms, for a folder or file5// with no sidecar. The token lists are fixed, so a re-walk of the same volume6// reads the same names every time. It also reads a file's technical7// attributes off its name where a sidecar carried none, and discovers the art8// and the trickplay directory a folder holds.910import (11	"crypto/sha256"12	"encoding/hex"13	"errors"14	"io/fs"15	"os"16	"path/filepath"17	"regexp"18	"strconv"19	"strings"20)2122// videoExtensions is the fixed set the scanner treats as a video file. The set23// is closed, so a re-walk counts the same files, and a subtitle or an image24// beside the video is never mistaken for one.25var videoExtensions = map[string]bool{26	".mkv": true, ".mp4": true, ".m4v": true, ".avi": true, ".mov": true,27	".webm": true, ".ts": true, ".m2ts": true, ".wmv": true, ".mpg": true,28	".mpeg": true, ".flv": true,29}3031// releaseTokens are the words a *arr release name cuts the title at: a source, a32// codec, or an audio format. The title is everything before the first of these,33// so the set is fixed and a new word here changes every re-walk the same way.34var releaseTokens = map[string]bool{35	"bluray": true, "brrip": true, "bdrip": true, "webrip": true, "web": true,36	"webdl": true, "hdtv": true, "dvdrip": true, "dvd": true, "remux": true,37	"hdrip": true, "cam": true, "hdcam": true, "bdremux": true, "uhd": true,38	"x264": true, "x265": true, "h264": true, "h265": true, "hevc": true,39	"avc": true, "xvid": true, "divx": true, "av1": true,40	"dts": true, "ac3": true, "aac": true, "ddp": true, "truehd": true,41	"atmos": true, "flac": true, "eac3": true,42	"hdr": true, "hdr10": true, "dv": true, "10bit": true, "8bit": true,43	"proper": true, "repack": true, "extended": true, "remastered": true,44	"imax": true, "unrated": true,45}4647// releaseCodecPrefixes are the codec and source words a group tag follows with a48// dash, so x264-GROUP reads as a release token and not as part of the title.49var releaseCodecPrefixes = map[string]bool{50	"x264": true, "x265": true, "h264": true, "h265": true, "hevc": true,51	"web": true, "webdl": true, "bluray": true, "bdrip": true, "hdtv": true,52}5354var (55	// yearDelimited reads the year off a Title (Year) or Title [Year]56	// folder. The year comes only from a parenthesized or bracketed57	// token, so a bare number in the name is never read as a year.58	yearDelimited = regexp.MustCompile(`\((\d{4})\)|\[(\d{4})\]`)59	// resolutionToken reads a 720p or 1080i token.60	resolutionToken = regexp.MustCompile(`^\d{3,4}[pi]$`)61	// seasonFolder reads the number off a Season 02 folder.62	seasonFolder = regexp.MustCompile(`(?i)^season\s*0*(\d+)$`)63	// A provider id in a name, in Jellyfin's form, as in [tmdbid-603] or64	// [imdbid-tt0133093]. It is how a person confirms a candidate without65	// opening the sidecar.66	providerIDToken = regexp.MustCompile(`(?i)\[(tmdb|imdb|tvdb)id-([^]\s]+)]`)67	// episodeMarker reads the season and episode off an s02e05 or 2x05 name,68	// wherever it sits.69	// The third group closes a range, so s04e10-e11, s04e10-11, and s04e10e1170	// all read as episodes 10 and 11. It is optional, so an ordinary single71	// marker reads as it always did.72	episodeMarker = regexp.MustCompile(`(?i)s(\d{1,3})[ ._-]?e(\d{1,3})(?:(?:[ ._-]?e|-)(\d{1,3}))?|(\d{1,2})x(\d{1,3})`)73)7475// parseReleaseName reads a title and a year off a folder or file name in the76// *arr form. A provider-id token is cut out before the parse. A parenthesized77// or bracketed year wins; with none, a dotted release name is cut at its78// first release token and reads a year off a token before the cut. The year79// is 0 where the name carries none, the signal the walk counts as80// unidentified.81func parseReleaseName(name string) (string, int) {82	name = strings.TrimSpace(providerIDToken.ReplaceAllString(stripExtension(name), " "))83	if match := yearDelimited.FindStringIndex(name); match != nil {84		year, _ := strconv.Atoi(name[match[0]+1 : match[1]-1])85		return cleanTitle(name[:match[0]]), year86	}87	tokens := splitTokens(name)88	cut := len(tokens)89	for i, token := range tokens {90		if isReleaseToken(token) {91			cut = i92			break93		}94	}95	// Read a year off a token after the first, so a folder named only by96	// a four-digit number keeps that number as its title, not its year.97	year, yearIndex := 0, -198	for i := 1; i < cut; i++ {99		if value, ok := releaseYear(tokens[i]); ok {100			year, yearIndex = value, i101		}102	}103	end := cut104	if yearIndex >= 0 {105		end = yearIndex106	}107	return cleanTitle(strings.Join(tokens[:end], " ")), year108}109110// splitTokens splits a name on the separators a release name uses, so a dotted,111// spaced, or underscored name reads the same. A dash stays inside a token,112// because a title like Wall-E keeps it.113func splitTokens(name string) []string {114	return strings.FieldsFunc(name, func(r rune) bool {115		return r == '.' || r == '_' || r == ' '116	})117}118119// isReleaseToken reports whether a token starts the release part of a name: a120// resolution, a fixed source or codec word, or a codec followed by a group tag.121func isReleaseToken(token string) bool {122	lower := strings.ToLower(token)123	if releaseTokens[lower] || resolutionToken.MatchString(lower) {124		return true125	}126	if prefix, _, found := strings.Cut(lower, "-"); found {127		return releaseCodecPrefixes[prefix]128	}129	return false130}131132// The years a release can carry. The scanner reads a year off a folder133// name and off a sidecar's date, and both read the same range, so one134// title cannot be identified by its sidecar and unidentified by its135// folder.136const (137	firstReleaseYear = 1900138	lastReleaseYear  = 2099139)140141// plausibleYear is the one test of a four-digit number, so a number in a142// title is not mistaken for a year.143func plausibleYear(year int) bool {144	return year >= firstReleaseYear && year <= lastReleaseYear145}146147// releaseYear reads a plausible release year off a token, so a four-digit148// part of a title is not mistaken for one.149func releaseYear(token string) (int, bool) {150	if len(token) != 4 {151		return 0, false152	}153	year, err := strconv.Atoi(token)154	if err != nil || !plausibleYear(year) {155		return 0, false156	}157	return year, true158}159160// cleanTitle collapses the runs of whitespace a split leaves and trims the ends,161// so a title reads as a person wrote it.162func cleanTitle(title string) string {163	return strings.Join(strings.Fields(title), " ")164}165166// stripExtension drops a known video extension, so a file name parses as its167// title. It leaves a folder name, which has no extension, alone.168func stripExtension(name string) string {169	ext := strings.ToLower(filepath.Ext(name))170	if videoExtensions[ext] {171		return name[:len(name)-len(ext)]172	}173	return name174}175176// parseProviderIDs reads every provider id a folder or file name carries in177// Jellyfin's form, keyed by the lowercased provider, so a name states an id178// the way a sidecar does.179func parseProviderIDs(name string) map[string]string {180	ids := map[string]string{}181	for _, match := range providerIDToken.FindAllStringSubmatch(name, -1) {182		provider := strings.ToLower(match[1])183		if _, held := ids[provider]; !held {184			ids[provider] = match[2]185		}186	}187	if len(ids) == 0 {188		return nil189	}190	return ids191}192193// A sidecar's ids win over a name's, because the sidecar is the fuller194// record. A name's ids still fill the providers the sidecar left out.195func mergeProviderIDs(held, more map[string]string) map[string]string {196	if len(more) == 0 {197		return held198	}199	merged := map[string]string{}200	for provider, value := range more {201		merged[provider] = value202	}203	for provider, value := range held {204		merged[provider] = value205	}206	return merged207}208209// parseSeasonFolder reads a season number off a folder name. Specials is season210// zero, the number Jellyfin and Kodi both give it.211func parseSeasonFolder(name string) (int, bool) {212	if strings.EqualFold(strings.TrimSpace(name), "specials") {213		return 0, true214	}215	if match := seasonFolder.FindStringSubmatch(name); match != nil {216		season, _ := strconv.Atoi(match[1])217		return season, true218	}219	return 0, false220}221222// parseEpisodeMarker reads a season and an episode off a name in the s02e05 or223// 2x05 form, wherever the marker sits.224// A range marker names every episode from its first number to its last, so a225// file of two episodes reports both.226func parseEpisodeMarker(name string) (season int, episodes []int, ok bool) {227	match := episodeMarker.FindStringSubmatchIndex(name)228	if match == nil {229		return 0, nil, false230	}231	if match[2] >= 0 {232		season, _ = strconv.Atoi(name[match[2]:match[3]])233		first, _ := strconv.Atoi(name[match[4]:match[5]])234		return season, episodeRange(name, first, match[6], match[7]), true235	}236	season, _ = strconv.Atoi(name[match[8]:match[9]])237	episode, _ := strconv.Atoi(name[match[10]:match[11]])238	return season, []int{episode}, true239}240241// maxRangeEpisodes is the most episodes one file is read as holding. A242// broadcast block that ships as one file is two parts, sometimes three. Past243// that, a number after a marker is far more likely to be a resolution or a244// season pack than a real range, and the cap keeps such a name from minting a245// run of items that no volume holds.246const maxRangeEpisodes = 4247248// episodeRange expands a range marker into every episode between its two249// numbers. A range holds only where it passes three tests: its closing number250// ends the marker, it ascends, and it counts maxRangeEpisodes or fewer. A name251// that fails any of them names its first episode alone, which is what the252// scanner did before ranges were read at all.253//254// The first test is what keeps s01e05-1080p from reading as episodes 5 through255// 108. The digits there are followed by another digit, so the range is refused.256// RE2 has no lookahead, so the test reads the byte after the match.257func episodeRange(name string, first, start, end int) []int {258	if start < 0 || (end < len(name) && isAlphanumeric(name[end])) {259		return []int{first}260	}261	last, _ := strconv.Atoi(name[start:end])262	if last <= first || last-first+1 > maxRangeEpisodes {263		return []int{first}264	}265	episodes := make([]int, 0, last-first+1)266	for episode := first; episode <= last; episode++ {267		episodes = append(episodes, episode)268	}269	return episodes270}271272// isAlphanumeric reports whether a byte is an ASCII letter or digit, which is273// how episodeRange tells the end of a marker from the start of another word.274func isAlphanumeric(c byte) bool {275	switch {276	case c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':277		return true278	}279	return false280}281282// resolutionFromName reads a file's resolution off a token in its name, the283// width and height of the standard 16:9 frame that token names. It is the284// resolution a sidecar's streamdetails would state, read from the name where the285// sidecar carried none.286func resolutionFromName(name string) (int, int) {287	lower := strings.ToLower(name)288	switch {289	case strings.Contains(lower, "2160p"), strings.Contains(lower, "4k"):290		return 3840, 2160291	case strings.Contains(lower, "1080p"), strings.Contains(lower, "1080i"):292		return 1920, 1080293	case strings.Contains(lower, "720p"):294		return 1280, 720295	case strings.Contains(lower, "480p"):296		return 854, 480297	}298	return 0, 0299}300301// containerFromExtension reads a file's container off its extension, so a .mkv302// reads as mkv.303func containerFromExtension(name string) string {304	return strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")305}306307// fileAttributes reads a file's technical attributes: the container off the308// extension, the resolution off the name, and the codecs, the resolution, and309// the duration off the sidecar's streamdetails where one was present. The310// sidecar wins over the name, because it read the file itself. There is no media311// probe: the scanner image carries none.312func fileAttributes(name string, stream *streamInfo) (container, videoCodec, audioCodec string, width, height int, durationMs int64) {313	container = containerFromExtension(name)314	width, height = resolutionFromName(name)315	if stream != nil {316		if stream.Width > 0 {317			width = stream.Width318		}319		if stream.Height > 0 {320			height = stream.Height321		}322		videoCodec = stream.VideoCodec323		audioCodec = stream.AudioCodec324		durationMs = stream.DurationMs325	}326	return container, videoCodec, audioCodec, width, height, durationMs327}328329// artRoles are the art an item row carries, in the order the body lists330// them, and the first of them is the poster the item's own art column holds.331var artRoles = []string{fileRolePoster, fileRoleBackdrop, fileRoleLogo}332333// discoverArt reads the art beside a title. The primary is the poster, the art334// the item row carries; the full list is the poster, the backdrop, and the logo,335// in that order, for the body. Every path is relative to the library root, so336// the display draws the art from the volume and the scanner copies nothing.337//338// The art comes from the same classification the file rows carry, so339// what the files table calls a poster is what the item row shows, and a340// name-prefixed poster counts.341//342// One directory read answers for every role, and it opens no file.343// Among the images of one role, a bare name wins over a name-prefixed344// one, because a folder that holds both wrote folder.jpg for the whole345// title. Among names of one shape, the explicit mark wins over the346// generic one, in the order imageMarks lists them: poster over folder347// over cover, and discart over cdart over disc, so a share that carries348// a 500x281 folder.jpg beside a 680x1000 poster.jpg draws the poster.349// Among equals the first in name order wins, which is the order350// os.ReadDir returns.351func discoverArt(root, dir string) (string, []string, error) {352	entries, err := os.ReadDir(dir)353	if err != nil {354		return "", nil, err355	}356	chosen := map[string]string{}357	held := map[string]artCandidateRank{}358	for _, entry := range entries {359		name := entry.Name()360		if entry.IsDir() || skipName(name) || fileTypeOf(name) != fileTypeImage {361			continue362		}363		role, rank, bare := imageArt(strings.ToLower(stripAnyExtension(name)))364		if role == "" {365			continue366		}367		candidate := artCandidateRank{bare: bare, mark: rank}368		if _, taken := chosen[role]; taken && !candidate.beats(held[role]) {369			continue370		}371		chosen[role] = relativePath(root, filepath.Join(dir, name))372		held[role] = candidate373	}374375	var primary string376	var all []string377	for i, role := range artRoles {378		path, held := chosen[role]379		if !held {380			continue381		}382		if i == 0 {383			primary = path384		}385		all = append(all, path)386	}387	return primary, all, nil388}389390// artCandidateRank is what parts two images of one role: whether the391// name is the bare mark, and where that mark sits in imageMarks.392type artCandidateRank struct {393	bare bool394	mark int395}396397// beats says whether this candidate takes the role from the other. A398// bare name beats a name-prefixed one, whatever the marks are. Two names399// of one shape are parted by the mark, and the explicit mark sits before400// the generic one in imageMarks. Two names of one shape and one mark are401// equal, so the one the walk read first stands.402func (c artCandidateRank) beats(other artCandidateRank) bool {403	if c.bare != other.bare {404		return c.bare405	}406	return c.mark < other.mark407}408409// listVideoFiles reads a directory's video files in name order, so a re-walk410// reads a folder's files the same way and the first file is a fixed choice.411func listVideoFiles(dir string) ([]string, error) {412	entries, err := os.ReadDir(dir)413	if err != nil {414		return nil, err415	}416	var names []string417	for _, entry := range entries {418		if entry.IsDir() || skipName(entry.Name()) {419			continue420		}421		if videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {422			names = append(names, entry.Name())423		}424	}425	return names, nil426}427428// trickplayFor reads the path of a file's .trickplay directory, the thumbnail429// tiles Jellyfin writes beside the file under the file's own base name. The path430// is relative to the library root, and it is empty where the directory does not431// exist.432func trickplayFor(root, dir, file string) string {433	base := strings.TrimSuffix(file, filepath.Ext(file))434	candidate := filepath.Join(dir, base+trickplayExtension)435	if dirExists(candidate) {436		return relativePath(root, candidate)437	}438	return ""439}440441// episodeThumb reads the path of an episode's thumbnail, which Jellyfin writes442// beside the file as a -thumb.jpg.443func episodeThumb(root, dir, file string) (string, error) {444	base := strings.TrimSuffix(file, filepath.Ext(file))445	for _, suffix := range []string{"-thumb.jpg", "-thumb.png", ".jpg", ".png"} {446		candidate := filepath.Join(dir, base+suffix)447		exists, err := fileExists(candidate)448		if err != nil {449			return "", err450		}451		if exists {452			return relativePath(root, candidate), nil453		}454	}455	return "", nil456}457458// folderKey is the slug of a folder's own name, the key a title with no provider459// id rests its id on. It is stable for a folder that does not move, which is the460// weak case the scanner accepts for a sidecar-less title.461//462// A slug with no letter says too little to key on: a non-Latin name463// folds away to its year, or to nothing at all, and two such titles of464// the same year would share one id. Such a slug carries the head of a465// hash of the raw name, which parts them, and an empty slug is the hash466// alone.467func folderKey(name string) string {468	key := slug(name, 0)469	if strings.ContainsFunc(key, func(r rune) bool { return r >= 'a' && r <= 'z' }) {470		return key471	}472	sum := sha256.Sum256([]byte(name))473	hash := hex.EncodeToString(sum[:])[:8]474	if key == "" {475		return hash476	}477	return key + "-" + hash478}479480// relativePath reports a path relative to the library root, the form every row481// stores so the catalog reads the same whatever mount the volume takes. A path482// that does not sit under the root passes through.483func relativePath(root, path string) string {484	if relative, err := filepath.Rel(root, path); err == nil {485		return relative486	}487	return path488}489490// fileExists reports whether a path is a file that exists. An absent491// path is an answer; a stat that fails any other way is an error, because492// the walk must not read an unreadable path as a file that is not there.493func fileExists(path string) (bool, error) {494	info, err := os.Stat(path)495	if errors.Is(err, fs.ErrNotExist) {496		return false, nil497	}498	if err != nil {499		return false, err500	}501	return !info.IsDir(), nil502}503504// directoryExists reports whether a path is a directory that exists. An505// absent path is an answer, and a stat that fails any other way is an506// error, the rule fileExists follows. A rescan reads it, because a folder507// it cannot stat is not a folder that left the volume, and reading one as508// the other sweeps a live title's rows out of the catalog.509func directoryExists(path string) (bool, error) {510	info, err := os.Stat(path)511	if errors.Is(err, fs.ErrNotExist) {512		return false, nil513	}514	if err != nil {515		return false, err516	}517	return info.IsDir(), nil518}519520// dirExists reports whether a path is a directory that exists.521func dirExists(path string) bool {522	info, err := os.Stat(path)523	return err == nil && info.IsDir()524}525526// pathExists reports whether a path exists at all, the check the webhook527// resolver makes as it maps a payload path onto the volume.528func pathExists(path string) bool {529	_, err := os.Stat(path)530	return err == nil531}
namespacecatalog.go 100.0%
1package main23// namespacecatalog.go holds the Catalog wire type, the namespaced resource4// that owns a namespace's shared catalog. A namespace has exactly one5// Catalog. It sizes the durable volume every catalog agent takes, and it6// owns the catalog Service and EndpointSlice. It is hand-written, like the7// Library type in api.go.89import (10	"fmt"11	"sort"12	"strings"13)1415// The Catalog shares the Library's group and version.16const catalogAPIVersion = libraryAPIVersion1718// A Catalog is the namespace's declaration of where the shared catalog is19// stored and how large each agent's copy is. The operator reads the spec20// and writes the status. The Go type is NamespaceCatalog because the21// Corrosion client in catalog.go already holds the Catalog name.22type NamespaceCatalog struct {23	APIVersion string        `json:"apiVersion,omitempty"`24	Kind       string        `json:"kind,omitempty"`25	Metadata   ObjectMeta    `json:"metadata"`26	Spec       CatalogSpec   `json:"spec"`27	Status     CatalogStatus `json:"status"`28}2930type CatalogList struct {31	Metadata ListMeta           `json:"metadata"`32	Items    []NamespaceCatalog `json:"items"`33}3435// CatalogSpec is the storage every catalog agent in the namespace uses,36// with room for the catalog-wide settings the design grows into.37type CatalogSpec struct {38	Storage CatalogStorage `json:"storage"`39	// The settings every screen pod in the namespace takes.40	Screens CatalogScreens `json:"screens,omitzero"`41}4243// The screens' half of the Catalog. StorageClassName classes each44// screen's catalog volume, and it is a field of its own because the45// namespace's one durable copy and a screen's replica may want different46// classes. An empty value makes the operator omit the class, so the cluster's47// default StorageClass binds the claim. The size is spec.storage's, because a48// screen holds the same rows the durable catalog holds.49type CatalogScreens struct {50	StorageClassName string `json:"storageClassName,omitempty"`51}5253// Size is the one namespace-wide catalog volume size, because each agent54// holds the whole namespace's catalog. StorageClassName is optional. An55// empty StorageClassName makes the operator omit the class, so the56// cluster's default StorageClass binds the claim.57type CatalogStorage struct {58	Size             string `json:"size,omitempty"`59	StorageClassName string `json:"storageClassName,omitempty"`60	// The claim the catalog pod mounts in place of one the61	// operator provisions, for a namespace whose catalog volume a person62	// makes themselves.63	ClaimName string `json:"claimName,omitempty"`64}6566// The cluster the Catalog stands, so a person reads one object to see67// the namespace's catalog: the member agent pods, the storage the agents were68// given, the screens and the claims they run on, and the conditions.69type CatalogStatus struct {70	Members     []string        `json:"members,omitempty"`71	StorageSize string          `json:"storageSize,omitempty"`72	Screens     []CatalogScreen `json:"screens,omitempty"`73	Conditions  []Condition     `json:"conditions,omitempty"`74}7576// One screen pod of the namespace: the Player it draws for, the claim77// its catalog agent runs on, the node it runs on, and its phase. A screen in78// a namespace this operator provisions no claim for names none.79type CatalogScreen struct {80	Player string `json:"player,omitempty"`81	Claim  string `json:"claim,omitempty"`82	Node   string `json:"node,omitempty"`83	Phase  string `json:"phase,omitempty"`84}8586// The default catalog volume size. A catalog of movies and series is87// megabytes, and a catalog of a photo library in the millions is low88// gigabytes, so the default is small.89const defaultCatalogSize = "1Gi"9091// catalogStorageSize resolves the size the agents take: the Catalog's own92// value, or the small default when it names none.93func catalogStorageSize(catalog *NamespaceCatalog) string {94	if catalog.Spec.Storage.Size != "" {95		return catalog.Spec.Storage.Size96	}97	return defaultCatalogSize98}99100// The condition this operator publishes on a Catalog, and the reasons101// it takes. Ready reports the cluster the Catalog stands.102const (103	catalogConditionReady = "Ready"104105	catalogReasonStanding     = "Standing"106	catalogReasonManyCatalogs = "ManyCatalogs"107	// The catalog pod has not started, and the catalog pod108	// failed.109	catalogReasonPodPending = "PodPending"110	catalogReasonPodFailed  = "PodFailed"111)112113// catalogChoice is the namespace's single Catalog, or the reason a Library114// cannot proceed without exactly one. It is a binding-like value: a nil115// catalog carries the reason and message a Library's Ready condition116// reports.117type catalogChoice struct {118	catalog *NamespaceCatalog119	// The pod that Catalog stands, as the pass read it, or nil120	// when it does not stand yet.121	pod     *Pod122	reason  string123	message string124}125126// singleCatalog reduces a namespace's Catalog objects to the one the127// operator uses. There are three answers: no Catalog, exactly one, and more128// than one. The operator uses the single one and refuses to stand two.129func singleCatalog(catalogs []*NamespaceCatalog) catalogChoice {130	switch len(catalogs) {131	case 0:132		return catalogChoice{reason: reasonNoCatalog, message: "the namespace has no Catalog"}133	case 1:134		return catalogChoice{catalog: catalogs[0]}135	default:136		return catalogChoice{reason: reasonManyCatalogs, message: manyCatalogsMessage(catalogs)}137	}138}139140// manyCatalogsMessage names the conflict a person reads to fix it: the141// count and the names of the Catalogs in the namespace.142func manyCatalogsMessage(catalogs []*NamespaceCatalog) string {143	names := make([]string, 0, len(catalogs))144	for _, catalog := range catalogs {145		names = append(names, catalog.Metadata.Name)146	}147	sort.Strings(names)148	return fmt.Sprintf("the namespace has %d Catalogs (%s); the operator stands none until one remains",149		len(catalogs), strings.Join(names, ", "))150}151152// catalogsByNamespace groups the cluster's Catalogs by namespace, each list153// sorted by name, so the choice and its messages read the same way every154// pass.155func catalogsByNamespace(catalogs []NamespaceCatalog) map[string][]*NamespaceCatalog {156	byNamespace := map[string][]*NamespaceCatalog{}157	for index := range catalogs {158		catalog := &catalogs[index]159		byNamespace[catalog.Metadata.Namespace] = append(byNamespace[catalog.Metadata.Namespace], catalog)160	}161	for namespace := range byNamespace {162		list := byNamespace[namespace]163		sort.Slice(list, func(one, other int) bool {164			return list[one].Metadata.Name < list[other].Metadata.Name165		})166	}167	return byNamespace168}
nfo.go 99.0%
1package main23// The episode sidecar's reader is in nfoepisode.go.45import (6	"bytes"7	"encoding/xml"8	"strconv"9	"strings"10)1112// nfoUniqueID is one uniqueid element, the provider and the id it assigns, as13// in a uniqueid of type tmdb with the value 603.14type nfoUniqueID struct {15	Type  string `xml:"type,attr"`16	Value string `xml:",chardata"`17}1819// nfoActor is one actor element, a name and the part played.20// The thumb is read because the credits fact rewrites the actor group, and a21// picture another writer put there stays only where this reader keeps it.22// The order is read because Jellyfin writes the actor elements in no23// particular order and puts the billing in each one, so document order alone24// puts a lead last.25type nfoActor struct {26	Name  string `xml:"name"`27	Role  string `xml:"role"`28	Thumb string `xml:"thumb"`29	Order *int   `xml:"order"`30}3132// nfoSet is a set element, either a plain name or a nested name element.33// tmdbcolid is the collection id Jellyfin writes on the element, and it34// scopes the set's id where the sidecar carries one.35type nfoSet struct {36	TMDBColID string `xml:"tmdbcolid,attr"`37	Name      string `xml:"name"`38	Value     string `xml:",chardata"`39}4041// nfoVideo is one video stream in streamdetails: the width, height, codec, and42// duration a media probe would otherwise read.43type nfoVideo struct {44	Width    int     `xml:"width"`45	Height   int     `xml:"height"`46	Codec    string  `xml:"codec"`47	Duration float64 `xml:"durationinseconds"`48}4950// nfoAudio is one audio stream: the codec.51type nfoAudio struct {52	Codec string `xml:"codec"`53}5455// nfoStreamDetails is the technical block a sidecar carries for a file.56type nfoStreamDetails struct {57	Video []nfoVideo `xml:"video"`58	Audio []nfoAudio `xml:"audio"`59}6061// nfoFileInfo wraps streamdetails, the shape Jellyfin and Kodi both use.62type nfoFileInfo struct {63	StreamDetails nfoStreamDetails `xml:"streamdetails"`64}6566// movieNFO mirrors movie.nfo. Every field the movie body carries has a source67// here, and the convenience id tags are read beside uniqueid because older68// sidecars wrote them.69type movieNFO struct {70	XMLName       xml.Name      `xml:"movie"`71	Title         string        `xml:"title"`72	Year          int           `xml:"year"`73	Premiered     string        `xml:"premiered"`74	Runtime       int           `xml:"runtime"`75	Plot          string        `xml:"plot"`76	Tagline       string        `xml:"tagline"`77	Genres        []string      `xml:"genre"`78	Studios       []string      `xml:"studio"`79	Directors     []string      `xml:"director"`80	Writers       []string      `xml:"writer"`81	Credits       []string      `xml:"credits"`82	Actors        []nfoActor    `xml:"actor"`83	Set           nfoSet        `xml:"set"`84	Country       string        `xml:"country"`85	MPAA          string        `xml:"mpaa"`86	Certification string        `xml:"certification"`87	Ratings       nfoRatings    `xml:"ratings"`88	UniqueIDs     []nfoUniqueID `xml:"uniqueid"`89	IMDBID        string        `xml:"imdbid"`90	TMDBID        string        `xml:"tmdbid"`91	TVDBID        string        `xml:"tvdbid"`92	ID            string        `xml:"id"`93	FileInfo      nfoFileInfo   `xml:"fileinfo"`94}9596// seriesNFO mirrors tvshow.nfo, the series-level fields.97type seriesNFO struct {98	XMLName       xml.Name      `xml:"tvshow"`99	Title         string        `xml:"title"`100	Year          int           `xml:"year"`101	Premiered     string        `xml:"premiered"`102	Plot          string        `xml:"plot"`103	Tagline       string        `xml:"tagline"`104	Genres        []string      `xml:"genre"`105	Studios       []string      `xml:"studio"`106	Creators      []string      `xml:"creator"`107	Actors        []nfoActor    `xml:"actor"`108	Country       string        `xml:"country"`109	MPAA          string        `xml:"mpaa"`110	Certification string        `xml:"certification"`111	Ratings       nfoRatings    `xml:"ratings"`112	UniqueIDs     []nfoUniqueID `xml:"uniqueid"`113	IMDBID        string        `xml:"imdbid"`114	TMDBID        string        `xml:"tmdbid"`115	TVDBID        string        `xml:"tvdbid"`116	ID            string        `xml:"id"`117}118119// streamDetailsNFO is the one block a per-file sidecar is read for, under120// whatever root the sidecar carries. It names no root element on purpose, so121// encoding/xml takes the document's own: movie in a movies library,122// episodedetails in a series library.123type streamDetailsNFO struct {124	FileInfo nfoFileInfo `xml:"fileinfo"`125}126127// parseStreamNFO reads the stream details out of a sidecar of either root. It128// reads that block alone, because a per-file sidecar has nothing else a file129// row takes, and a movie and an episode sidecar answer the same way.130func parseStreamNFO(data []byte) (streamInfo, error) {131	var raw streamDetailsNFO132	if err := lenientXML(data).Decode(&raw); err != nil {133		return streamInfo{}, err134	}135	return streamFrom(raw.FileInfo), nil136}137138// streamInfo is the technical attributes a file carries: the resolution, the139// codecs, and the duration in milliseconds.140type streamInfo struct {141	Width      int142	Height     int143	VideoCodec string144	AudioCodec string145	DurationMs int64146}147148// present reports whether the stream holds anything a file can take.149func (s streamInfo) present() bool {150	return s.Width > 0 || s.Height > 0 || s.VideoCodec != "" || s.AudioCodec != "" || s.DurationMs > 0151}152153// movieMeta is what parseMovieNFO reads: the identity, the release, the provider154// ids, the movie body, the item duration, the file stream, and the id of the155// set the sidecar names, empty where it names none.156type movieMeta struct {157	Title       string158	Year        int159	Released    string160	ProviderIDs map[string]string161	Body        movieBody162	Duration    int64163	Stream      streamInfo164	SetID       string165	// The nfo facts this sidecar already answers, in the form the nfo_facts166	// column holds.167	NFOFacts string168}169170// parseMovieNFO reads movie.nfo into a movieMeta. The art and the provider-id171// copy on the body are filled by the walk, because they come from the folder172// beside the sidecar.173func parseMovieNFO(data []byte) (movieMeta, error) {174	var raw movieNFO175	if err := lenientXML(data).Decode(&raw); err != nil {176		return movieMeta{}, err177	}178	providers := collectProviders(raw.UniqueIDs, raw.IMDBID, raw.TMDBID, raw.TVDBID, raw.ID)179	year, released := releaseFields(raw.Year, raw.Premiered)180	stream := streamFrom(raw.FileInfo)181	collection := collectionName(raw.Set)182	return movieMeta{183		Title:       strings.TrimSpace(raw.Title),184		Year:        year,185		Released:    released,186		ProviderIDs: providers,187		Body: movieBody{188			Plot:          strings.TrimSpace(raw.Plot),189			Tagline:       strings.TrimSpace(raw.Tagline),190			Cast:          castMembers(raw.Actors),191			Directors:     trimAll(raw.Directors),192			Writers:       mergeDedup(raw.Writers, raw.Credits),193			Studios:       trimAll(raw.Studios),194			Genres:        trimAll(raw.Genres),195			Collection:    collection,196			ProviderIDs:   providers,197			Country:       strings.TrimSpace(raw.Country),198			ContentRating: contentRating(raw.MPAA, raw.Certification),199			Ratings:       bodyRatings(raw.Ratings.Ratings),200		},201		Duration: itemDuration(stream, raw.Runtime),202		Stream:   stream,203		SetID:    setID(strings.TrimSpace(raw.Set.TMDBColID), collection),204		NFOFacts: nfoFactsAnswered(raw.Plot, contentRating(raw.MPAA, raw.Certification),205			raw.Ratings.Ratings),206	}, nil207}208209// seriesMeta is what parseSeriesNFO reads from tvshow.nfo.210type seriesMeta struct {211	Title       string212	Year        int213	Released    string214	ProviderIDs map[string]string215	Body        seriesBody216	// The nfo facts this sidecar already answers, in the form the nfo_facts217	// column holds; a series sidecar answers the same facts a movie one does.218	NFOFacts string219}220221// parseSeriesNFO reads tvshow.nfo into a seriesMeta.222func parseSeriesNFO(data []byte) (seriesMeta, error) {223	var raw seriesNFO224	if err := lenientXML(data).Decode(&raw); err != nil {225		return seriesMeta{}, err226	}227	providers := collectProviders(raw.UniqueIDs, raw.IMDBID, raw.TMDBID, raw.TVDBID, raw.ID)228	year, released := releaseFields(raw.Year, raw.Premiered)229	return seriesMeta{230		Title:       strings.TrimSpace(raw.Title),231		Year:        year,232		Released:    released,233		ProviderIDs: providers,234		Body: seriesBody{235			Plot:          strings.TrimSpace(raw.Plot),236			Tagline:       strings.TrimSpace(raw.Tagline),237			Cast:          castMembers(raw.Actors),238			Creators:      trimAll(raw.Creators),239			Studios:       trimAll(raw.Studios),240			Genres:        trimAll(raw.Genres),241			ProviderIDs:   providers,242			Country:       strings.TrimSpace(raw.Country),243			ContentRating: contentRating(raw.MPAA, raw.Certification),244			Ratings:       bodyRatings(raw.Ratings.Ratings),245		},246		NFOFacts: nfoFactsAnswered(raw.Plot, contentRating(raw.MPAA, raw.Certification),247			raw.Ratings.Ratings),248	}, nil249}250251// collectProviders reads every uniqueid and the convenience id tags into one252// map, keyed by the lowercased provider. A uniqueid wins over a convenience tag253// for the same provider, and a bare id element that reads like an IMDB id fills254// imdb.255func collectProviders(uids []nfoUniqueID, imdb, tmdb, tvdb, id string) map[string]string {256	providers := map[string]string{}257	for _, u := range uids {258		provider := strings.ToLower(strings.TrimSpace(u.Type))259		value := strings.TrimSpace(u.Value)260		if provider != "" && value != "" {261			providers[provider] = value262		}263	}264	add := func(provider, value string) {265		value = strings.TrimSpace(value)266		if value == "" {267			return268		}269		if _, held := providers[provider]; !held {270			providers[provider] = value271		}272	}273	add("imdb", imdb)274	add("tmdb", tmdb)275	add("tvdb", tvdb)276	if strings.HasPrefix(strings.TrimSpace(id), "tt") {277		add("imdb", id)278	}279	if len(providers) == 0 {280		return nil281	}282	return providers283}284285// castMembers keeps the credited people with a name, in the sidecar's own286// order, which is billing order.287func castMembers(actors []nfoActor) []castMember {288	var out []castMember289	for _, actor := range actors {290		name := strings.TrimSpace(actor.Name)291		if name == "" {292			continue293		}294		out = append(out, castMember{Name: name, Role: strings.TrimSpace(actor.Role)})295	}296	return out297}298299// trimAll trims every entry and drops the empty ones.300func trimAll(in []string) []string {301	var out []string302	for _, s := range in {303		if s = strings.TrimSpace(s); s != "" {304			out = append(out, s)305		}306	}307	return out308}309310// mergeDedup joins two lists, trims them, and keeps the first of each name, so a311// writer named in both writer and credits appears once.312func mergeDedup(first, second []string) []string {313	var out []string314	seen := map[string]bool{}315	for _, s := range append(append([]string{}, first...), second...) {316		if s = strings.TrimSpace(s); s != "" && !seen[s] {317			seen[s] = true318			out = append(out, s)319		}320	}321	return out322}323324// collectionName reads a set's name, whether it is a nested name element or the325// element's own text.326func collectionName(set nfoSet) string {327	if name := strings.TrimSpace(set.Name); name != "" {328		return name329	}330	return strings.TrimSpace(set.Value)331}332333// contentRating prefers mpaa and falls back to certification, the two tags a334// sidecar writes the rating in.335func contentRating(mpaa, certification string) string {336	if r := strings.TrimSpace(mpaa); r != "" {337		return r338	}339	return strings.TrimSpace(certification)340}341342// releaseFields resolves the year and the released column. premiered is an ISO343// date and the released column takes it as it stands; the year fills the slug344// and falls back to the leading digits of the date.345func releaseFields(year int, premiered string) (int, string) {346	released := strings.TrimSpace(premiered)347	if year == 0 {348		year = leadingYear(released)349	}350	if released == "" && year > 0 {351		released = strconv.Itoa(year)352	}353	return year, released354}355356// leadingYear reads a four-digit year off the front of a date, or 0. The357// range is the release range in names.go, the same one a folder name is358// read against.359func leadingYear(date string) int {360	if len(date) < 4 {361		return 0362	}363	year, err := strconv.Atoi(date[:4])364	if err != nil || !plausibleYear(year) {365		return 0366	}367	return year368}369370// streamFrom reads the first video and audio stream. A file has one of each in371// the ordinary case, and the first is the one the display plays.372func streamFrom(info nfoFileInfo) streamInfo {373	var stream streamInfo374	if len(info.StreamDetails.Video) > 0 {375		video := info.StreamDetails.Video[0]376		stream.Width = video.Width377		stream.Height = video.Height378		stream.VideoCodec = normalizeCodec(video.Codec)379		if video.Duration > 0 {380			stream.DurationMs = int64(video.Duration * 1000)381		}382	}383	if len(info.StreamDetails.Audio) > 0 {384		stream.AudioCodec = normalizeCodec(info.StreamDetails.Audio[0].Codec)385	}386	return stream387}388389// normalizeCodec lowercases a codec name, so h264 and H264 read the same in the390// catalog.391func normalizeCodec(codec string) string {392	return strings.ToLower(strings.TrimSpace(codec))393}394395// itemDuration is the item's runtime in seconds: the stream's own duration where396// the sidecar carried one, or runtime in minutes.397func itemDuration(stream streamInfo, runtimeMinutes int) int64 {398	if stream.DurationMs > 0 {399		return stream.DurationMs / 1000400	}401	if runtimeMinutes > 0 {402		return int64(runtimeMinutes) * 60403	}404	return 0405}406407// Every read of a sidecar is lenient. Jellyfin writes a bare ampersand in a408// URL, as in a thumb that names an image server's id, and the strict reader409// stops at the first one, which failed a rating on a third of the series.410// A lenient reader passes an unknown entity through as text, and every411// element the facts read or edit is found the same way.412func lenientXML(data []byte) *xml.Decoder {413	decoder := xml.NewDecoder(bytes.NewReader(data))414	decoder.Strict = false415	return decoder416}
nfoanswer.go 98.9%
1package main23// The seam between one nfo fact and the providers that can answer it. Two4// rules from the plan: a single value takes the first provider in the5// Library's sources that answers, and a set is the union of every provider6// that answers, in that same order.78import (9	"context"10	"errors"11	"slices"12	"strings"13)1415// What a fact knows about a title before it asks: the kind of library it sits16// in, and every id its sidecar carries. A provider keys on the id it knows,17// so the ids the identity fact wrote are what make the other providers18// reachable.19type titleRef struct {20	kind string21	ids  providerIDs22}2324// One person a title credits: the name, and the ids that name the person's25// directory in .contributors/. The crew are people of this shape, with no26// part and no billing order, because a director directs all of the title and27// holds no place in the billing.28type creditedPerson struct {29	Name string30	IDs  providerIDs31}3233// One credited person as a fact writes them: the name, the part, the billing34// order, and the provider's own picture of them, which the people wave reads.35type creditedActor struct {36	Name  string37	Role  string38	Order int39	Thumb string40	// The ids the provider gave for the person, which name the person's directory41	// in .contributors/ and tell two people of one name apart. They never reach42	// the .nfo, because no player reads an id there.43	IDs providerIDs44}4546// The person behind one actor's credit, which is what the .contributors/47// store holds.48func (a creditedActor) person() creditedPerson {49	return creditedPerson{Name: a.Name, IDs: a.IDs}50}5152// One site's score and the count of votes behind it. A count of zero means53// the provider stated none.54type titleRating struct {55	Value float6456	Votes int57}5859// One answer holds every value the nfo facts of this wave can write. A60// provider fills the fields of the fact it was asked for and leaves the rest61// empty.62type factAnswer struct {63	Plot           string64	Tagline        string65	Genres         []string66	Studios        []string67	Premiered      string68	RuntimeMinutes int69	Certification  string70	Rating         *titleRating71	Cast           []creditedActor72	Directors      []creditedPerson73	Writers        []creditedPerson74}7576// One provider block, asked for one fact of one title. It answers false where77// the provider holds nothing for that title, which is not an error. A source78// naming a block this image cannot ask is skipped and never fails a run.79type answerer interface {80	providerBlock() string81	serves(fact string) bool82	answer(ctx context.Context, fact string, title titleRef) (factAnswer, bool, error)83}8485// A provider that states its day's calls are spent. A container stops asking86// that provider for the rest of the run, leaves the remaining titles their87// gaps, and logs the count it left, so no container sleeps for hours inside a88// Job.89var errDailyLimit = errors.New("the provider has spent its calls for the day")9091// One provider's answer with the block that gave it, so the ledger records92// which provider answered.93type providerAnswer struct {94	block  string95	answer factAnswer96}9798// The merge: a single value takes the first answer that holds one, and a set99// takes every answer's values in order, with a repeat dropped. The names it100// returns are the providers whose values reached the merged answer.101func mergeAnswers(fact string, answers []providerAnswer) (factAnswer, providerNames) {102	merged := factAnswer{}103	var names providerNames104	note := func(block string) {105		if !slices.Contains(names, block) {106			names = append(names, block)107		}108	}109	_, rating := ratingSites[fact]110	for _, held := range answers {111		switch {112		case rating:113			if merged.Rating == nil && held.answer.Rating != nil {114				merged.Rating = held.answer.Rating115				note(held.block)116			}117		case fact == factOverview:118			mergeOverview(&merged, held, note)119		case fact == factCertification:120			if merged.Certification == "" && held.answer.Certification != "" {121				merged.Certification = held.answer.Certification122				note(held.block)123			}124		case fact == factCredits:125			mergeCredits(&merged, held, note)126		}127	}128	return merged, names129}130131// Which fields of the overview are single values and which are sets. They132// merge by the two rules together in one pass.133func mergeOverview(merged *factAnswer, held providerAnswer, note func(string)) {134	if merged.Plot == "" && held.answer.Plot != "" {135		merged.Plot = held.answer.Plot136		note(held.block)137	}138	if merged.Tagline == "" && held.answer.Tagline != "" {139		merged.Tagline = held.answer.Tagline140		note(held.block)141	}142	if merged.Premiered == "" && held.answer.Premiered != "" {143		merged.Premiered = held.answer.Premiered144		note(held.block)145	}146	if merged.RuntimeMinutes == 0 && held.answer.RuntimeMinutes > 0 {147		merged.RuntimeMinutes = held.answer.RuntimeMinutes148		note(held.block)149	}150	merged.Genres = unionOf(merged.Genres, held.answer.Genres, held.block, note)151	merged.Studios = unionOf(merged.Studios, held.answer.Studios, held.block, note)152}153154func unionOf(held, adding []string, block string, note func(string)) []string {155	for _, value := range adding {156		if value = strings.TrimSpace(value); value == "" || slices.Contains(held, value) {157			continue158		}159		held = append(held, value)160		note(block)161	}162	return held163}164165// The three lists of the credits fact are sets, and each merges by the union166// rule. The crew carry no billing order, so a director's place is the order167// the providers answered in.168func mergeCredits(merged *factAnswer, held providerAnswer, note func(string)) {169	mergeCast(merged, held, note)170	directors, addedDirector := unionPeople(merged.Directors, held.answer.Directors)171	writers, addedWriter := unionPeople(merged.Writers, held.answer.Writers)172	merged.Directors, merged.Writers = directors, writers173	if addedDirector || addedWriter {174		note(held.block)175	}176}177178// The cast is a set keyed by the person's name, and the billing order is the179// place in the union, so two providers make one list a player reads in order.180func mergeCast(merged *factAnswer, held providerAnswer, note func(string)) {181	for _, actor := range held.answer.Cast {182		if actor.Name = strings.TrimSpace(actor.Name); actor.Name == "" || namedInCast(merged.Cast, actor.Name) {183			continue184		}185		actor.Order = len(merged.Cast)186		merged.Cast = append(merged.Cast, actor)187		note(held.block)188	}189}190191func namedInCast(cast []creditedActor, name string) bool {192	for _, held := range cast {193		if strings.EqualFold(held.Name, name) {194			return true195		}196	}197	return false198}199200// The union of one crew list, keyed by the person's name, with the list it201// starts from first. The second answer says whether the union202// added a person the first list did not hold, which is what records the203// provider that added them.204// A name both lists hold keeps its place and gains the ids the second205// states for it.206func unionPeople(held, adding []creditedPerson) ([]creditedPerson, bool) {207	added := false208	for _, person := range adding {209		name := strings.TrimSpace(person.Name)210		if name == "" {211			continue212		}213		at := personIndex(held, name)214		if at < 0 {215			held = append(held, creditedPerson{Name: name, IDs: person.IDs})216			added = true217			continue218		}219		held[at] = filledPerson(held[at], person)220	}221	return held, added222}223224func personIndex(people []creditedPerson, name string) int {225	for at, held := range people {226		if strings.EqualFold(held.Name, name) {227			return at228		}229	}230	return -1231}232233// What a person one list holds takes from the same person in the other list:234// Every id the second carries that the first lacks.235func filledPerson(held, adding creditedPerson) creditedPerson {236	for scheme, id := range adding.IDs {237		if held.IDs == nil {238			held.IDs = providerIDs{}239		}240		if held.IDs[scheme] == "" {241			held.IDs[scheme] = id242		}243	}244	return held245}246247// Whether the merged cast is what the sidecar already holds, which is what248// says if the actor group is rewritten. The ids are out of the comparison,249// because they never reach the .nfo.250func sameCast(sidecar, merged []creditedActor) bool {251	if len(sidecar) != len(merged) {252		return false253	}254	for at, held := range sidecar {255		if held.Name != merged[at].Name || held.Role != merged[at].Role || held.Thumb != merged[at].Thumb {256			return false257		}258	}259	return true260}261262// Whether one crew list is what the sidecar already holds. The ids are out of263// the comparison, as they are for the cast, because they never reach the264// .nfo.265func samePeople(sidecar, merged []creditedPerson) bool {266	if len(sidecar) != len(merged) {267		return false268	}269	for at, held := range sidecar {270		if held.Name != merged[at].Name {271			return false272		}273	}274	return true275}276277// An answer with nothing in it for the fact asked is a miss with a date and278// not a write.279func answersFact(fact string, answer factAnswer) bool {280	if _, rating := ratingSites[fact]; rating {281		return answer.Rating != nil282	}283	switch fact {284	case factOverview:285		return answer.Plot != "" || answer.Tagline != "" || answer.Premiered != "" ||286			answer.RuntimeMinutes > 0 || len(answer.Genres) > 0 || len(answer.Studios) > 0287	case factCertification:288		return answer.Certification != ""289	case factCredits:290		// A provider that answered is the whole answer, cast or none. The fact291		// writes credits.yaml either way, and an empty one is the mark that says292		// this title's people are asked for and not there.293		return true294	}295	return false296}
nfoedit.go 99.0%
1package main23// The one-element edit of volumewrite.go, widened to the group of elements4// one fact owns. A group is the unit because a fact such as overview owns six5// elements and writes them together, and a rating sits under the ratings6// element beside the ratings other facts write. Every other byte of the7// document stays as it was.89import (10	"bytes"11	"crypto/sha256"12	"encoding/hex"13	"encoding/xml"14	"errors"15	"io"16)1718// The elements one fact owns, and the element they sit under where they are19// not the root's own children.20type elementGroup struct {21	parent string22	owned  []xmlElement23}2425// Where one owned element starts and ends in the document.26type elementSpan struct {27	start int28	end   int29}3031// What one pass over the document reads: every owned element, where the32// parent holds its children, and the two places an insert can land.33type groupPlaces struct {34	spans           []elementSpan35	parentEnd       int36	parentFirst     int37	parentIndentEnd int38	rootEnd         int39	firstChild      int40}4142// One pass over the document that reads all of those, the way elementSpans43// does for one element, so a group edit needs no parse of the whole tree into44// values. The owned elements sit at one depth: the root's children, or the45// children of the named parent.46func groupSpans(document []byte, group elementGroup) (groupPlaces, error) {47	places := groupPlaces{parentEnd: -1, parentFirst: -1, parentIndentEnd: -1, rootEnd: -1, firstChild: -1}48	decoder := lenientXML(document)49	depth, target := 0, 250	inParent, parentRead := false, false51	if group.parent != "" {52		target = 353	}54	for {55		before := int(decoder.InputOffset())56		token, err := decoder.Token()57		if errors.Is(err, io.EOF) {58			return places, nil59		}60		if err != nil {61			return places, err62		}63		switch typed := token.(type) {64		case xml.StartElement:65			depth++66			if depth == 2 {67				if places.firstChild < 0 {68					places.firstChild = before69				}70				if group.parent != "" && !parentRead && typed.Name.Local == group.parent {71					inParent, parentRead = true, true72					places.parentIndentEnd = before73				}74			}75			if depth == 3 && inParent && places.parentFirst < 0 {76				places.parentFirst = before77			}78			if depth != target || (group.parent != "" && !inParent) || !ownedElement(typed, group.owned) {79				continue80			}81			if err := decoder.Skip(); err != nil {82				return places, err83			}84			places.spans = append(places.spans, elementSpan{start: before, end: int(decoder.InputOffset())})85			depth--86		case xml.EndElement:87			depth--88			if depth == 1 && inParent {89				inParent = false90				places.parentEnd = before91			}92			if depth == 0 && places.rootEnd < 0 {93				places.rootEnd = before94			}95		}96	}97}9899func ownedElement(token xml.StartElement, owned []xmlElement) bool {100	for _, element := range owned {101		if elementMatches(token, element) {102			return true103		}104	}105	return false106}107108// The group edit writes the elements where the first owned element stood,109// takes the rest of them out, and leaves every other byte. Where the document110// holds none of them, the block goes in before the root's end tag, or under111// the parent element, which is created where the document has none.112func editElementGroup(document []byte, group elementGroup, elements [][]byte) ([]byte, error) {113	places, err := groupSpans(document, group)114	if err != nil {115		return nil, err116	}117	if len(places.spans) > 0 {118		return replaceGroup(document, places, elements), nil119	}120	if group.parent != "" && places.parentEnd >= 0 {121		return insertUnderParent(document, places, elements), nil122	}123	if places.rootEnd < 0 {124		return nil, errors.New("the document has no root element to insert into")125	}126	block := elementBlock(elements, childIndent(document, places.firstChild))127	if group.parent != "" {128		block = wrapInParent(group.parent, block, childIndent(document, places.firstChild))129	}130	spans := documentSpans{start: -1, end: -1, rootEnd: places.rootEnd, firstChild: places.firstChild}131	return splice(document, places.rootEnd, places.rootEnd, spans.insertion(document, block)), nil132}133134// The block lands where the first owned element stood, so the group keeps the135// place a person or another writer gave it. The other owned elements go out136// with the whitespace that led them, so the edit leaves no blank line.137func replaceGroup(document []byte, places groupPlaces, elements [][]byte) []byte {138	first := places.spans[0]139	indent := afterLastNewline(trailingWhitespace(document[:first.start]))140	out := document141	for at := len(places.spans) - 1; at > 0; at-- {142		span := places.spans[at]143		lead := len(trailingWhitespace(document[:span.start]))144		out = splice(out, span.start-lead, span.end, nil)145	}146	return splice(out, first.start, first.end, elementBlock(elements, indent))147}148149// A group whose parent exists but holds none of its elements goes in before150// the parent's end tag, at the indentation the parent's own children carry.151func insertUnderParent(document []byte, places groupPlaces, elements [][]byte) []byte {152	parentIndent := childIndent(document, places.parentIndentEnd)153	indent := append(append([]byte{}, parentIndent...), ' ', ' ')154	if places.parentFirst >= 0 {155		indent = childIndent(document, places.parentFirst)156	}157	lead := trailingWhitespace(document[:places.parentEnd])158	block := append(append([]byte{}, elementBlock(elements, indent)...), lead...)159	if len(lead) == 0 {160		block = append(append([]byte{'\n'}, indent...), elementBlock(elements, indent)...)161		block = append(append(block, '\n'), parentIndent...)162	}163	return splice(document, places.parentEnd, places.parentEnd, block)164}165166// The indentation of an element is the run after the last newline before it,167// which is what an inserted element takes.168func childIndent(document []byte, at int) []byte {169	if at < 0 {170		return []byte("  ")171	}172	return append([]byte{}, afterLastNewline(trailingWhitespace(document[:at]))...)173}174175// Every line of every element takes the group's own indentation, so an176// element with children reads as if the same hand wrote it.177func elementBlock(elements [][]byte, indent []byte) []byte {178	separator := append([]byte{'\n'}, indent...)179	lines := make([][]byte, len(elements))180	for at, element := range elements {181		lines[at] = bytes.ReplaceAll(element, []byte("\n"), separator)182	}183	return bytes.Join(lines, separator)184}185186// A group under a parent the document does not hold arrives with that parent,187// so the first rating a fact writes creates the ratings element.188func wrapInParent(parent string, block, indent []byte) []byte {189	inner := append(append([]byte{}, indent...), ' ', ' ')190	nested := bytes.ReplaceAll(block, []byte("\n"+string(indent)), append([]byte{'\n'}, inner...))191	out := append([]byte("<"+parent+">\n"), inner...)192	out = append(out, nested...)193	out = append(out, '\n')194	out = append(out, indent...)195	return append(out, []byte("</"+parent+">")...)196}197198// What the ledger records as wrote: a hash of the group's own bytes as the199// document holds them, so the next run tells the group it wrote from a group200// another writer changed. A document with none of the group's elements hashes201// to nothing.202func groupHash(document []byte, group elementGroup) (string, error) {203	places, err := groupSpans(document, group)204	if err != nil {205		return "", err206	}207	if len(places.spans) == 0 {208		return "", nil209	}210	held := make([][]byte, len(places.spans))211	for at, span := range places.spans {212		held[at] = document[span.start:span.end]213	}214	sum := sha256.Sum256(bytes.Join(held, []byte("\n")))215	return hex.EncodeToString(sum[:]), nil216}
nfoepisode.go 100.0%
1package main23// The episode sidecar, the one document that places a file under its series.4// It stands apart from nfo.go because an episode file may hold two episodes,5// so the reader streams the document and keeps every block it holds.67import (8	"encoding/xml"9	"errors"10	"io"11	"strings"12)1314// episodeNFO mirrors an episodedetails .nfo, the fields that place an episode15// under its series and describe it.16type episodeNFO struct {17	XMLName   xml.Name      `xml:"episodedetails"`18	Title     string        `xml:"title"`19	Season    int           `xml:"season"`20	Episode   int           `xml:"episode"`21	Aired     string        `xml:"aired"`22	Premiered string        `xml:"premiered"`23	Plot      string        `xml:"plot"`24	Runtime   int           `xml:"runtime"`25	Directors []string      `xml:"director"`26	Writers   []string      `xml:"writer"`27	Credits   []string      `xml:"credits"`28	Actors    []nfoActor    `xml:"actor"`29	UniqueIDs []nfoUniqueID `xml:"uniqueid"`30	FileInfo  nfoFileInfo   `xml:"fileinfo"`31}3233// episodeMeta is what parseEpisodeNFOs reads from an episode .nfo.34type episodeMeta struct {35	Title       string36	Season      int37	Episode     int38	Released    string39	ProviderIDs map[string]string40	Body        episodeBody41	Duration    int6442	Stream      streamInfo43}4445// parseEpisodeNFOs reads every episodedetails block an episode .nfo holds, in46// the order the sidecar wrote them. A file that holds two episodes carries one47// block for each, which is how Kodi and Jellyfin write it.48//49// It streams the decoder over the file rather than unmarshaling once, because50// those blocks are consecutive root elements and encoding/xml reads only the51// first of those. A block that fails after one has been read keeps what was52// read, so a truncated second block does not lose the first.53func parseEpisodeNFOs(data []byte) ([]episodeMeta, error) {54	decoder := lenientXML(data)55	var metas []episodeMeta56	for {57		var raw episodeNFO58		err := decoder.Decode(&raw)59		if errors.Is(err, io.EOF) {60			return metas, nil61		}62		if err != nil {63			if len(metas) > 0 {64				return metas, nil65			}66			return nil, err67		}68		metas = append(metas, episodeMetaFrom(raw))69	}70}7172// episodeMetaFrom turns one episodedetails block into an episodeMeta.73func episodeMetaFrom(raw episodeNFO) episodeMeta {74	providers := collectProviders(raw.UniqueIDs, "", "", "", "")75	aired := strings.TrimSpace(raw.Aired)76	if aired == "" {77		aired = strings.TrimSpace(raw.Premiered)78	}79	stream := streamFrom(raw.FileInfo)80	return episodeMeta{81		Title:       strings.TrimSpace(raw.Title),82		Season:      raw.Season,83		Episode:     raw.Episode,84		Released:    aired,85		ProviderIDs: providers,86		Body: episodeBody{87			Plot:        strings.TrimSpace(raw.Plot),88			Directors:   trimAll(raw.Directors),89			Writers:     mergeDedup(raw.Writers, raw.Credits),90			Cast:        castMembers(raw.Actors),91			ProviderIDs: providers,92		},93		Duration: itemDuration(stream, raw.Runtime),94		Stream:   stream,95	}96}
nfofacts.go 100.0%
1package main23// The nfo facts this wave fills, the list a sidecar answers, and the gap4// query each fact works from. The full fact vocabulary lives in factnames.go.5// These names are the ones the nfo container runs today.67import "strings"89// The nfo facts this image runs today, in the order the container names them10// in LIBRARY_FACTS. overview runs first because it writes the elements every11// other reader looks for.12var nfoFacts = []string{13	factOverview, factCertification,14	factRatingTMDb, factRatingIMDb, factRatingRottenTomatoes, factRatingMetacritic,15	factCredits,16}1718// The container that fills the .nfo body, which is the phase a person reads19// in kubectl get pod.20const nfoContainerName = "nfo"2122// The nfo facts the Library's own sources serve, in the order the group runs23// them. A Library whose sources hold one provider asks for what that provider24// serves and no more.25func servedNFOFacts(library *Library, providers providerSet) []string {26	var served []string27	for _, fact := range nfoFacts {28		if providers.serving(library.Metadata.Namespace, library.Spec.Sources, fact) != nil {29			served = append(served, fact)30		}31	}32	return served33}3435// How the list is written into the nfo_facts column: every name wrapped in36// commas, so instr() matches a whole name and never a prefix of one. An empty37// list is an empty string.38const nfoFactSeparator = ","3940func nfoFactList(facts []string) string {41	if len(facts) == 0 {42		return ""43	}44	return nfoFactSeparator + strings.Join(facts, nfoFactSeparator) + nfoFactSeparator45}4647// Which nfo facts a sidecar already answers, read from the elements the48// sidecar holds. The scanner writes the answer into the nfo_facts column. The49// lead element of each group is the test: a sidecar that holds the plot holds50// the group the overview fact wrote.51//52// The credits fact is not in this map. The people are the point of the fact,53// and a sidecar's actors say nothing about credits.yaml or the .contributors/54// entries, so the credits gap reads the credits table instead.55func nfoFactsAnswered(plot, certification string, ratings []nfoRating) string {56	held := map[string]bool{57		factOverview:      strings.TrimSpace(plot) != "",58		factCertification: strings.TrimSpace(certification) != "",59	}60	for fact, site := range ratingSites {61		held[fact] = ratingNamed(ratings, site.name) != nil62	}63	var answered []string64	for _, fact := range nfoFacts {65		if held[fact] {66			answered = append(answered, fact)67		}68	}69	return nfoFactList(answered)70}7172// The gap query of one nfo fact. A title with no provider id is not a gap,73// because a fact cannot ask about a title no provider has named, and the74// identity fact fills that gap first. The query reads nfo_facts with instr(),75// and it excludes a title with an attempt inside that attempt's own window.76func nfoGapQuery(fact string) string {77	return `SELECT id FROM (` +78		`SELECT library, id, nfo_facts FROM movies WHERE id NOT LIKE 'movie:path:%' ` +79		`UNION ALL SELECT library, id, nfo_facts FROM series WHERE id NOT LIKE 'series:path:%') AS items ` +80		`WHERE library = ?1 AND ` + gapClause(fact, "id",81		`instr(nfo_facts, '`+nfoFactSeparator+fact+nfoFactSeparator+`') = 0`)82}8384// The credits gap, which is the one nfo gap that does not read nfo_facts: a85// title with a provider id whose credits.yaml is not there yet, which the86// catalog holds as no row in the credits table, whatever actors the sidecar87// holds. The attempt window is every other query's own.88func creditsGapQuery() string {89	return `SELECT id FROM (` +90		`SELECT library, id FROM movies WHERE id NOT LIKE 'movie:path:%' ` +91		`UNION ALL SELECT library, id FROM series WHERE id NOT LIKE 'series:path:%') AS items ` +92		`WHERE library = ?1 AND ` + gapClause(factCredits, "id",93		`id NOT IN (SELECT item FROM credits WHERE credits.library = ?1)`)94}9596// The count of fights every fact of one library recorded. The reporter97// publishes it and the operator folds it into Library status.98const fightsQuery = `SELECT count(*) FROM attempts WHERE library = ? AND result = '` + attemptFight + `'`
nfofactsrole.go 94.0%
1package main23// The nfo container's run of one fact. One title's work, in order: read the4// sidecar, compare the fact's element group with the hash the ledger holds,5// ask the providers, write the group, and record the answer and the attempt.67import (8	"context"9	"errors"10	"fmt"11	"io/fs"12	"math"13	"os"14	"path/filepath"15	"sort"16	"strings"17	"time"18)1920// One fact's run, bound to its name, so every nfo fact runs the same loop21// over its own gap.22func nfoFactRun(fact string) factRun {23	return func(ctx context.Context, e *enricher) error { return e.nfoFact(ctx, fact) }24}2526// The answerers this container can ask, in order, and the blocks that have27// spent their day. A spent block is spent for every fact the container has28// left to run.29type answerLine struct {30	answerers []answerer31	spent     map[string]bool32}3334// The line is built in the order LIBRARY_SOURCES names the blocks, which is35// the Library's own spec.sources order, and the two rules for who answers36// read that order. A block this image has no answerer for yet, and a block37// whose key did not reach the container, are both skipped with no error.38// TVmaze joins the line with no key, because it takes no account.39func newAnswerLine(blocks []string, value func(string) string) *answerLine {40	line := &answerLine{spent: map[string]bool{}}41	for _, block := range blocks {42		token := value(providerTokenVariable(block))43		switch {44		case block == providerBlockTMDb && token != "":45			line.answerers = append(line.answerers,46				tmdbAnswerer{client: newTMDbClient(tmdbAPIBase, token)})47		case block == providerBlockOMDb && token != "":48			line.answerers = append(line.answerers,49				newOMDbAnswerer(newOMDbClient(omdbAPIBase, token)))50		case block == providerBlockTVmaze:51			line.answerers = append(line.answerers,52				newTVmazeAnswerer(newTVmazeClient(tvmazeAPIBase)))53		}54	}55	return line56}5758// A fact with no answerer left has nothing to ask, so the titles that remain59// keep their gaps for the next run.60func (l *answerLine) live(fact string) bool {61	for _, one := range l.answerers {62		if !l.spent[one.providerBlock()] && one.serves(fact) {63			return true64		}65	}66	return false67}6869// One title's ask: every live answerer that serves the fact, in order. A70// provider that states its day is spent leaves the line, and the ask says so.71func (l *answerLine) ask(ctx context.Context, fact string, title titleRef) ([]providerAnswer, bool, error) {72	var answers []providerAnswer73	spentNow := false74	for _, one := range l.answerers {75		block := one.providerBlock()76		if l.spent[block] || !one.serves(fact) {77			continue78		}79		answer, held, err := one.answer(ctx, fact, title)80		if errors.Is(err, errDailyLimit) {81			l.spent[block], spentNow = true, true82			continue83		}84		if err != nil {85			return answers, spentNow, err86		}87		if held {88			answers = append(answers, providerAnswer{block: block, answer: answer})89		}90	}91	return answers, spentNow, nil92}9394// The line is built once for the container, so a provider that spends its day95// in the first fact is not asked again in the next one. A container with no96// answerer at all is a manifest to repair, because the operator creates it97// only where a source serves one of its facts.98func (e *enricher) nfoFact(ctx context.Context, fact string) error {99	if e.providers == nil {100		e.providers = newAnswerLine(commaNames(os.Getenv(librarySourcesVariable)), os.Getenv)101	}102	if len(e.providers.answerers) == 0 {103		return fmt.Errorf("no provider key reached this container, and the %s fact cannot ask without one", fact)104	}105	return e.nfoGap(ctx, fact, e.providers)106}107108// A catalog read that fails ends the container, because the gap list is the109// work. One title that fails records an error attempt, and the run carries110// on.111func (e *enricher) nfoGap(ctx context.Context, fact string, line *answerLine) error {112	ids, err := e.gaps(ctx, fact, time.Now().UTC())113	if err != nil {114		return err115	}116	wrote, fights, left := 0, 0, 0117	for _, id := range ids {118		if err := ctx.Err(); err != nil {119			return err120		}121		item, held, err := e.catalog.identityItem(ctx, e.library, id)122		if err != nil {123			return err124		}125		if !held || !e.inScope(item.path) {126			continue127		}128		if !line.live(fact) {129			left++130			continue131		}132		switch e.fillNFOFact(ctx, fact, line, item) {133		case attemptFight:134			fights++135		case attemptFound:136			wrote++137		case "":138			left++139		}140	}141	e.logf("wrote the %s of %d of the %d titles that lacked it, with %d held by another writer",142		fact, wrote, len(ids), fights)143	if left > 0 {144		e.logf("left the %s of %d titles for the next run, because every provider has spent its day", fact, left)145	}146	return nil147}148149// One title's fill, in order: the sidecar is read, the fight check runs, the150// providers are asked, the group is written, and the answer is recorded. A151// group another writer changed stops this title and nothing else.152func (e *enricher) fillNFOFact(ctx context.Context, fact string, line *answerLine, item identityItem) string {153	folder := filepath.Join(e.root, item.path)154	sidecar, rootElement := identitySidecar(e.kind, folder)155	document, err := os.ReadFile(sidecar)156	if err != nil && !errors.Is(err, fs.ErrNotExist) {157		e.logf("could not read the sidecar of %s: %v", item.path, err)158		e.recordNFO(folder, fact, nil, attemptError, nil)159		return attemptError160	}161	if !hasRootElement(document) {162		document = minimalNFO(rootElement, item.title)163	}164165	group := nfoGroup(fact)166	if fought, err := e.groupHeldByAnother(folder, fact, group, document); err != nil {167		e.logf("could not read the %s of %s: %v", fact, item.path, err)168		e.recordNFO(folder, fact, nil, attemptError, nil)169		return attemptError170	} else if fought {171		e.logf("another writer holds the %s of %s, so this run left it", fact, item.path)172		e.recordNFO(folder, fact, nil, attemptFight, nil)173		return attemptFight174	}175176	answers, spent, err := line.ask(ctx, fact, titleRef{kind: e.kind, ids: sidecarIDs(document)})177	if err != nil {178		e.logf("could not ask for the %s of %s: %v", fact, item.path, err)179		e.recordNFO(folder, fact, nil, attemptError, nil)180		return attemptError181	}182	if len(answers) == 0 {183		if spent {184			return ""185		}186		e.logf("no provider holds the %s of %s", fact, item.path)187		e.recordNFO(folder, fact, nil, attemptNothing, nil)188		return attemptNothing189	}190191	merged, names := mergeAnswers(fact, answers)192	if fact == factCredits {193		merged = creditsOrSidecar(merged, document)194	}195	if !answersFact(fact, merged) {196		e.recordNFO(folder, fact, nil, attemptNothing, nil)197		return attemptNothing198	}199	return e.writeNFOFact(folder, sidecar, fact, item, group, document, merged, names)200}201202// A provider that named any person is the whole cast and crew, and the203// sidecar's people stand only where no provider named one.204// Jellyfin writes a producer as an actor element whose role is205// "Producer" and whose type element is absent, so a union keeps the206// producer in the cast and names a person who acts and produces twice.207// A word list on the role is wrong, because a library holds a character208// named "Director".209func creditsOrSidecar(merged factAnswer, document []byte) factAnswer {210	if len(merged.Cast) > 0 || len(merged.Directors) > 0 || len(merged.Writers) > 0 {211		return merged212	}213	merged.Cast = sidecarCast(document)214	merged.Directors, merged.Writers = sidecarCrew(document)215	return merged216}217218// The write is the group edit and the ledger entry together. The hash the219// ledger keeps is read back off the document the edit left, so the next run220// compares like with like.221func (e *enricher) writeNFOFact(folder, sidecar, fact string, item identityItem, group elementGroup,222	document []byte, merged factAnswer, names providerNames) string {223	edited := document224	if groupNeedsWrite(fact, document, merged) {225		written, err := editElementGroup(document, group, nfoElements(fact, merged))226		if err != nil {227			e.logf("could not write the %s of %s: %v", fact, item.path, err)228			e.recordNFO(folder, fact, nil, attemptError, names)229			return attemptError230		}231		if err := e.writer.write(sidecar, written); err != nil {232			e.logf("could not write the %s of %s: %v", fact, item.path, err)233			e.recordNFO(folder, fact, nil, attemptError, names)234			return attemptError235		}236		edited = written237	}238	hash, err := groupHash(edited, group)239	if err != nil {240		e.logf("could not read back the %s of %s: %v", fact, item.path, err)241		e.recordNFO(folder, fact, nil, attemptError, names)242		return attemptError243	}244	// The credits fact writes credits.yaml and the people it names after the245	// actor elements, so a person the store has no entry for gains one on the246	// same run the .nfo names them.247	if fact == factCredits {248		e.writeCredits(folder, merged)249	}250	e.logf("wrote the %s of %s from %s", fact, item.path, strings.Join(names, ", "))251	e.recordNFO(folder, fact, &likenItem{252		Path: likenSelfPath, Provider: names, Wrote: hash, Written: time.Now().UTC(),253	}, attemptFound, names)254	return attemptFound255}256257// Which facts write their group on every answer and which compare first. The258// credits fact leaves the actor, director, and writer elements where259// credits.yaml and the .contributors/ entries are written either way.260// The credits fact rewrites nothing where the people it holds are the261// people the sidecar holds.262func groupNeedsWrite(fact string, document []byte, merged factAnswer) bool {263	if fact != factCredits {264		return true265	}266	directors, writers := sidecarCrew(document)267	return !sameCast(sidecarCast(document), merged.Cast) ||268		!samePeople(directors, merged.Directors) ||269		!samePeople(writers, merged.Writers)270}271272// The fight check compares the group on disk with the hash the ledger holds.273// A fact with no entry in its ledger has written nothing yet, so whatever the274// sidecar holds is another writer's, and this fact takes the group over.275func (e *enricher) groupHeldByAnother(folder, fact string, group elementGroup, document []byte) (bool, error) {276	ledger, err := readLikenLedger(folder, fact)277	if err != nil {278		return false, err279	}280	held, wrote := ledger.itemAt(likenSelfPath)281	if !wrote || held.Wrote == "" {282		return false, nil283	}284	hash, err := groupHash(document, group)285	if err != nil {286		return false, err287	}288	return hash != held.Wrote, nil289}290291// The item entry and the attempt are one write of one file, as the identity292// fact writes them, so a reader never sees an answer without its attempt.293func (e *enricher) recordNFO(folder, fact string, entry *likenItem, result string, names providerNames) {294	err := e.writer.updateLikenLedger(folder, fact, func(ledger *likenLedger) {295		if entry != nil {296			ledger.noteItem(*entry)297		}298		ledger.noteAttempt(likenAttempt{299			Path: likenSelfPath, At: time.Now().UTC(), Result: result, Provider: names,300		})301	})302	if err != nil {303		e.logf("could not record the %s attempt at %s: %v", fact, folder, err)304	}305	e.writeRows(fact, folder, result == attemptFound)306}307308// The actors the sidecar holds, in billing order. An actor element with an309// order takes that place, and one without310// follows every actor that has one, in document order. A document this reader311// cannot parse holds no cast, and the fill has already recorded that as an312// error.313// They are the cast where no provider named one.314func sidecarCast(document []byte) []creditedActor {315	var read struct {316		Actors []nfoActor `xml:"actor"`317	}318	if err := lenientXML(document).Decode(&read); err != nil {319		return nil320	}321	sort.SliceStable(read.Actors, func(i, j int) bool {322		return billingOf(read.Actors[i]) < billingOf(read.Actors[j])323	})324	var cast []creditedActor325	for _, actor := range read.Actors {326		name := strings.TrimSpace(actor.Name)327		if name == "" {328			continue329		}330		cast = append(cast, creditedActor{331			Name:  name,332			Role:  strings.TrimSpace(actor.Role),333			Thumb: strings.TrimSpace(actor.Thumb),334			Order: len(cast),335		})336	}337	return cast338}339340// The billing an actor element states, and a place after every stated one341// for an element that states none.342func billingOf(actor nfoActor) int {343	if actor.Order == nil {344		return math.MaxInt345	}346	return *actor.Order347}348349// The crew the sidecar holds, in its own order. Kodi writes a writer into the350// credits element and Jellyfin into the351// writer element, so the two read as one list of writers, the way the scanner352// reads them.353// They are the crew where no provider named one.354func sidecarCrew(document []byte) (directors, writers []creditedPerson) {355	var read struct {356		Directors []string `xml:"director"`357		Writers   []string `xml:"writer"`358		Credits   []string `xml:"credits"`359	}360	if err := lenientXML(document).Decode(&read); err != nil {361		return nil, nil362	}363	return namedPeople(trimAll(read.Directors)), namedPeople(mergeDedup(read.Writers, read.Credits))364}365366// The people one list of crew elements names. No element of the .nfo carries367// an id, so these people carry none.368func namedPeople(names []string) []creditedPerson {369	var people []creditedPerson370	for _, name := range names {371		people = append(people, creditedPerson{Name: name})372	}373	return people374}375376// The ids a fact asks with come off the sidecar itself, which is where the377// identity fact wrote every one of them.378func sidecarIDs(document []byte) providerIDs {379	var read struct {380		UniqueIDs []nfoUniqueID `xml:"uniqueid"`381		IMDBID    string        `xml:"imdbid"`382		TMDBID    string        `xml:"tmdbid"`383		TVDBID    string        `xml:"tvdbid"`384		ID        string        `xml:"id"`385	}386	if err := lenientXML(document).Decode(&read); err != nil {387		return providerIDs{}388	}389	return providerIDs(collectProviders(read.UniqueIDs, read.IMDBID, read.TMDBID, read.TVDBID, read.ID))390}
nforatings.go 100.0%
1package main23// nforatings.go is the ratings block of a sidecar: the element as Kodi and4// Jellyfin write it, the names and scales of the four sites the rating facts5// serve, and the read that carries the scores into an item's body.67import (8	"strconv"9	"strings"10)1112// One rating element inside the ratings block, in Kodi's form: the site that13// scored the title, the top of that site's scale, whether a reader takes this14// one first, the score, and how many people voted. The score is read as text15// and parsed per rating, because a number field fails the decode of the whole16// sidecar on one bad score, and a title would lose its plot and its cast to17// one site's empty value.18type nfoRating struct {19	Name    string  `xml:"name,attr"`20	Max     float64 `xml:"max,attr"`21	Default bool    `xml:"default,attr"`22	Value   string  `xml:"value"`23	Votes   int     `xml:"votes"`24}2526// The score as a number, and false where the element holds none or holds27// text that is not one.28func (r nfoRating) score() (float64, bool) {29	value, err := strconv.ParseFloat(strings.TrimSpace(r.Value), 64)30	return value, err == nil31}3233// The ratings element holds one rating per site, which is why one site's34// score is a fact of its own.35type nfoRatings struct {36	Ratings []nfoRating `xml:"rating"`37}3839// themoviedb is the name Kodi and Jellyfin write on a TMDb rating, and 10 is40// the top of TMDb's own scale.41const (42	tmdbRatingName = "themoviedb"43	tmdbRatingMax  = 1044)4546// The name and the scale of the three other sites. Kodi's own NFO page lists47// imdb, metacritic, and the tomatometer names for the ratings block. IMDb48// scores out of 10; the tomatometer and the Metascore score out of 100.49// Jellyfin reads a name that holds "tomato", without "audience" and without50// "avg", as the critic rating, which is why the Rotten Tomatoes name is51// tomatometerallcritics. Sources read on 2026-09-03: the Kodi wiki page NFO52// files/Movies, and Jellyfin's53// MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs.54const (55	imdbRatingName = "imdb"56	imdbRatingMax  = 105758	rottenTomatoesRatingName = "tomatometerallcritics"59	rottenTomatoesRatingMax  = 1006061	metacriticRatingName = "metacritic"62	metacriticRatingMax  = 10063)6465// One site's rating in the block, or nil where the block holds none.66func ratingNamed(ratings []nfoRating, name string) *nfoRating {67	for at := range ratings {68		if strings.EqualFold(strings.TrimSpace(ratings[at].Name), name) {69			return &ratings[at]70		}71	}72	return nil73}7475// The block as the body holds it: one entry per site that scored,76// keyed by the sidecar's own rating name, valued on that site's own scale. A77// rating that states no score is left out, and a block with no score at all78// leaves the item with no ratings key.79func bodyRatings(ratings []nfoRating) map[string]float64 {80	held := map[string]float64{}81	for _, rating := range ratings {82		name := strings.ToLower(strings.TrimSpace(rating.Name))83		value, scored := rating.score()84		if name == "" || !scored || value <= 0 {85			continue86		}87		held[name] = value88	}89	if len(held) == 0 {90		return nil91	}92	return held93}
nfowrite.go 100.0%
1package main23// Which elements each nfo fact owns, and the bytes it writes for them. The4// forms are Kodi's and Jellyfin's own, because those two readers are what a5// person plays the library with. A fact writes no element outside its own6// group.78import (9	"bytes"10	"fmt"11	"strconv"12	"strings"13)1415// One rating fact per site, and what a reader of the file needs to tell the16// sites apart: the name the rating element carries, the top of that site's17// scale, and which one a reader takes first. Only the TMDb rating carries the18// default mark, because Kodi reads one default, and the other three sit19// beside it.20type ratingSite struct {21	name  string22	max   int23	first bool24}2526var ratingSites = map[string]ratingSite{27	factRatingTMDb:           {name: tmdbRatingName, max: tmdbRatingMax, first: true},28	factRatingIMDb:           {name: imdbRatingName, max: imdbRatingMax},29	factRatingRottenTomatoes: {name: rottenTomatoesRatingName, max: rottenTomatoesRatingMax},30	factRatingMetacritic:     {name: metacriticRatingName, max: metacriticRatingMax},31}3233// The group of each fact. The rating group names a parent because Kodi holds34// one rating per site inside the ratings element, so the rating of one site35// is the group and the ratings of the other sites stay.36func nfoGroup(fact string) elementGroup {37	if site, held := ratingSites[fact]; held {38		return elementGroup{parent: "ratings", owned: []xmlElement{39			{name: "rating", attribute: "name", value: site.name},40		}}41	}42	switch fact {43	case factOverview:44		return elementGroup{owned: []xmlElement{45			{name: "plot"}, {name: "tagline"}, {name: "genre"},46			{name: "studio"}, {name: "premiered"}, {name: "runtime"},47		}}48	case factCertification:49		return elementGroup{owned: []xmlElement{{name: "mpaa"}}}50	case factCredits:51		// The credits element is not owned. Kodi reads a writer from it, but Kodi52		// and Jellyfin both read the writer element, so the fact writes the writer53		// element and leaves a credits element another writer put there.54		return elementGroup{owned: []xmlElement{55			{name: "actor"}, {name: "director"}, {name: "writer"},56		}}57	}58	return elementGroup{}59}6061// The group one fact writes, in the order a reader of the file expects. An62// empty value writes no element at all.63func nfoElements(fact string, answer factAnswer) [][]byte {64	if site, held := ratingSites[fact]; held {65		return [][]byte{ratingElement(site, *answer.Rating)}66	}67	switch fact {68	case factOverview:69		return overviewElements(answer)70	case factCertification:71		return [][]byte{textElement("mpaa", answer.Certification)}72	case factCredits:73		return creditsElements(answer)74	}75	return nil76}7778// The elements of the credits group: the cast, then the directors, then the79// writers.80func creditsElements(answer factAnswer) [][]byte {81	elements := actorElements(answer.Cast)82	elements = append(elements, crewElements("director", answer.Directors)...)83	return append(elements, crewElements("writer", answer.Writers)...)84}8586// A director element and a writer element carry the name alone, which is all87// Kodi and Jellyfin read from them.88func crewElements(name string, people []creditedPerson) [][]byte {89	elements := make([][]byte, 0, len(people))90	for _, person := range people {91		elements = append(elements, textElement(name, person.Name))92	}93	return elements94}9596func overviewElements(answer factAnswer) [][]byte {97	var elements [][]byte98	if answer.Plot != "" {99		elements = append(elements, textElement("plot", answer.Plot))100	}101	if answer.Tagline != "" {102		elements = append(elements, textElement("tagline", answer.Tagline))103	}104	for _, genre := range answer.Genres {105		elements = append(elements, textElement("genre", genre))106	}107	for _, studio := range answer.Studios {108		elements = append(elements, textElement("studio", studio))109	}110	if answer.Premiered != "" {111		elements = append(elements, textElement("premiered", answer.Premiered))112	}113	if answer.RuntimeMinutes > 0 {114		elements = append(elements, textElement("runtime", strconv.Itoa(answer.RuntimeMinutes)))115	}116	return elements117}118119// The rating's form: the site's name, the top of its scale, the mark that120// says a reader takes this one first where the site carries it, the score,121// and the votes where the provider stated a count.122func ratingElement(site ratingSite, rating titleRating) []byte {123	mark := ""124	if site.first {125		mark = ` default="true"`126	}127	out := fmt.Appendf(nil, "<rating name=%q max=%q%s>\n  <value>%s</value>",128		site.name, strconv.Itoa(site.max), mark, strconv.FormatFloat(rating.Value, 'f', -1, 64))129	if rating.Votes > 0 {130		out = fmt.Appendf(out, "\n  <votes>%d</votes>", rating.Votes)131	}132	return append(out, []byte("\n</rating>")...)133}134135// An actor element carries the name, the part, the billing order, and the136// picture the provider holds. The person's own ids stay out of it, because137// neither Kodi nor Jellyfin reads an id there.138func actorElements(cast []creditedActor) [][]byte {139	elements := make([][]byte, 0, len(cast))140	for _, actor := range cast {141		out := append([]byte("<actor>\n  "), textElement("name", actor.Name)...)142		if actor.Role != "" {143			out = append(append(out, []byte("\n  ")...), textElement("role", actor.Role)...)144		}145		out = append(append(out, []byte("\n  ")...), textElement("order", strconv.Itoa(actor.Order))...)146		if actor.Thumb != "" {147			out = append(append(out, []byte("\n  ")...), textElement("thumb", actor.Thumb)...)148		}149		elements = append(elements, append(out, []byte("\n</actor>")...))150	}151	return elements152}153154// Every value a fact writes is escaped, so a plot with an ampersand in it155// leaves a document a reader can parse. The three characters are escaped by156// hand because encoding/xml writes a newline as a character reference, and a157// plot of several paragraphs must read as one a person wrote.158var xmlText = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")159160func textElement(name, value string) []byte {161	var text bytes.Buffer162	text.WriteString("<" + name + ">")163	text.WriteString(xmlText.Replace(value))164	text.WriteString("</" + name + ">")165	return text.Bytes()166}
objects.go 100.0%
1package main23// The Kubernetes objects a Library touches, in the same4// hand-written form as the Library API in api.go: the claim and the5// volume behind it, which the operator reads, and the pods, which it6// writes. Each type carries only the fields this operator reads or7// writes; the API server fills in the rest.89import (10	"encoding/json"11	"maps"12	"slices"13	"time"14)1516// A PersistentVolumeClaim is read for two answers: whether it is17// bound, and which volume it is bound to. The operator never writes18// one, so the type carries nothing else.19type PersistentVolumeClaim struct {20	APIVersion string                      `json:"apiVersion,omitempty"`21	Kind       string                      `json:"kind,omitempty"`22	Metadata   ObjectMeta                  `json:"metadata"`23	Spec       PersistentVolumeClaimSpec   `json:"spec"`24	Status     PersistentVolumeClaimStatus `json:"status"`25}2627// VolumeName is written by the binder, not by whoever created the28// claim, so it is empty until the claim binds.29//30// PersistentVolumeClaimSpec is the write half of the claim. The operator31// reads a media claim through VolumeName and Phase, and it writes a catalog32// claim through AccessModes, Resources, and StorageClassName. An empty33// StorageClassName is omitted, so the cluster's default StorageClass binds34// the claim.35type PersistentVolumeClaimSpec struct {36	AccessModes      []string                   `json:"accessModes,omitempty"`37	Resources        VolumeResourceRequirements `json:"resources,omitzero"`38	StorageClassName string                     `json:"storageClassName,omitempty"`39	VolumeName       string                     `json:"volumeName,omitempty"`40}4142// VolumeResourceRequirements is the size a claim asks for. Only storage is43// stated, as a quantity carried as written rather than parsed.44type VolumeResourceRequirements struct {45	Requests map[string]string `json:"requests,omitempty"`46}4748type PersistentVolumeClaimStatus struct {49	Phase string `json:"phase,omitempty"`50}5152// A claim is usable only in the Bound phase. Pending means no volume53// answered it yet, and Lost means the volume behind it is gone.54const claimBound = "Bound"5556// The core group a PersistentVolumeClaim belongs to, and the access57// mode the catalog claim takes: ReadWriteOnce, because one agent writes58// one SQLite database and Corrosion agents gossip rather than share a59// file.60const (61	claimAPIVersion         = "v1"62	accessModeReadWriteOnce = "ReadWriteOnce"63)6465// A PersistentVolume is read for one answer: what serves the storage.66// The operator never writes one.67type PersistentVolume struct {68	APIVersion string               `json:"apiVersion,omitempty"`69	Kind       string               `json:"kind,omitempty"`70	Metadata   ObjectMeta           `json:"metadata"`71	Spec       PersistentVolumeSpec `json:"spec"`72}7374// PersistentVolumeSpec is the half of a PersistentVolume that says75// where the storage is. Kubernetes gives each kind of storage its own76// key under the spec, and a volume carries exactly one of them, so77// this type reports the key's name instead of holding a field per78// driver. A cluster that serves its movies through a driver this79// operator carries no type for still reports what serves them.80type PersistentVolumeSpec struct {81	// Source is the name of the storage key, such as nfs or csi, and82	// it is the type the status reports.83	Source string8485	// NFS is the one source this operator reads in full, because a86	// media reference over NFS is built from the server and the export87	// path.88	NFS *NFSVolumeSource89}9091type NFSVolumeSource struct {92	Server string `json:"server,omitempty"`93	Path   string `json:"path,omitempty"`94}9596// The spec keys that describe a volume rather than serve it. Every97// other key is a storage source. Naming the few settings, rather than98// the many drivers, is what lets an unknown driver report its own99// name.100var persistentVolumeSettings = map[string]bool{101	"accessModes":                   true,102	"capacity":                      true,103	"claimRef":                      true,104	"mountOptions":                  true,105	"nodeAffinity":                  true,106	"persistentVolumeReclaimPolicy": true,107	"storageClassName":              true,108	"volumeAttributesClassName":     true,109	"volumeMode":                    true,110}111112// UnmarshalJSON reads the spec as its raw keys and names the first one113// that is not a setting. The keys are sorted first, so a spec that114// somehow carries two sources decodes the same way every time.115func (s *PersistentVolumeSpec) UnmarshalJSON(data []byte) error {116	var keys map[string]json.RawMessage117	if err := json.Unmarshal(data, &keys); err != nil {118		return err119	}120	for _, name := range slices.Sorted(maps.Keys(keys)) {121		if persistentVolumeSettings[name] {122			continue123		}124		s.Source = name125		if name != "nfs" {126			return nil127		}128		s.NFS = &NFSVolumeSource{}129		return json.Unmarshal(keys[name], s.NFS)130	}131	return nil132}133134// The marks the objects this operator writes carry. The name135// label is the standard Kubernetes one, and its value tells a Job of136// this operator's from a catalog pod and from a screen pod, so one137// cluster-wide list answers one kind. The library label names the138// Library an object belongs to, and the worker label names which worker139// a Job runs. The member label is on every pod that holds a catalog140// agent, whatever kind of pod it is, and it is what the catalog141// EndpointSlice is written over. The annotation carries the hash of the142// template an object was built from, which is how a pass tells a live143// object from the one it would build now.144const (145	scannerLabelKey        = "app.kubernetes.io/name"146	workerLabelValue       = "library-worker"147	catalogLabelValue      = "library-catalog"148	libraryLabelKey        = "library.liken.sh/library"149	workerLabelKey         = "library.liken.sh/worker"150	memberLabelKey         = "library.liken.sh/catalog"151	memberLabelValue       = "member"152	templateHashAnnotation = "library.liken.sh/template-hash"153)154155// The label pair that names one Library's objects, on the156// catalog claim the Library owns.157func libraryLabels(library string) map[string]string {158	return map[string]string{libraryLabelKey: library}159}160161// The labels one Job and its pods carry: the name label one list162// selects on, the Library the Job works for, and the worker it runs.163func workerLabels(library, worker string) map[string]string {164	return map[string]string{165		scannerLabelKey: workerLabelValue,166		libraryLabelKey: library,167		workerLabelKey:  worker,168	}169}170171// The member label on top of the labels a pod already carries,172// so that every pod holding a catalog agent reaches the namespace's173// EndpointSlice through one selector.174func withMemberLabel(labels map[string]string) map[string]string {175	marked := maps.Clone(labels)176	marked[memberLabelKey] = memberLabelValue177	return marked178}179180// A pod. The operator writes a spec once and reads a status181// every pass, because a Library is Ready only when its namespace's182// catalog pod runs.183type Pod struct {184	APIVersion string     `json:"apiVersion,omitempty"`185	Kind       string     `json:"kind,omitempty"`186	Metadata   ObjectMeta `json:"metadata"`187	Spec       PodSpec    `json:"spec"`188	Status     PodStatus  `json:"status"`189}190191// PodList is the collection ListCatalogMemberPods returns. Its192// resourceVersion is where the pod watch begins.193type PodList struct {194	Metadata ListMeta `json:"metadata"`195	Items    []Pod    `json:"items"`196}197198// The pod spec's few fields: a restartPolicy, Always on a199// standing pod and Never on a Job's pod, which runs to completion. The200// termination grace period is long enough for a busy catalog agent to201// finish its exit.202type PodSpec struct {203	RestartPolicy string `json:"restartPolicy,omitempty"`204	// NodeName is written by the scheduler, never by the builder. The205	// catalog EndpointSlice carries it, so a reader can tell which peers206	// are local to a node.207	NodeName                      string `json:"nodeName,omitempty"`208	TerminationGracePeriodSeconds *int64 `json:"terminationGracePeriodSeconds,omitempty"`209	// AutomountServiceAccountToken is a pointer because the field's210	// default is true, and only an explicit false keeps the namespace's211	// default ServiceAccount token out of the pod.212	AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"`213	// InitContainers holds the native sidecar. A container here214	// with restartPolicy Always starts before the containers below and215	// keeps running beside them, so the kubelet brings the catalog agent216	// up and passes its startupProbe before it starts the container it217	// serves.218	InitContainers []Container `json:"initContainers,omitempty"`219	Containers     []Container `json:"containers"`220	Volumes        []Volume    `json:"volumes,omitempty"`221	// The claims the pod holds, under the names its containers ask for222	// them by. A screen pod names the display claim media-operator stood for223	// its Player, and no other pod this operator builds holds one.224	ResourceClaims []PodResourceClaim `json:"resourceClaims,omitempty"`225}226227// One claim the pod holds. Name is the pod-local name a container's228// resources.claims entry refers to, and ResourceClaimName is the229// ResourceClaim in the pod's namespace it stands for.230type PodResourceClaim struct {231	Name              string `json:"name"`232	ResourceClaimName string `json:"resourceClaimName,omitempty"`233}234235// Command replaces the image's entrypoint, which is how one image runs236// the operator and the scanner.237type Container struct {238	Name            string               `json:"name"`239	Image           string               `json:"image"`240	Command         []string             `json:"command,omitempty"`241	Args            []string             `json:"args,omitempty"`242	Env             []EnvVar             `json:"env,omitempty"`243	Ports           []ContainerPort      `json:"ports,omitempty"`244	Resources       ResourceRequirements `json:"resources,omitzero"`245	VolumeMounts    []VolumeMount        `json:"volumeMounts,omitempty"`246	SecurityContext *SecurityContext     `json:"securityContext,omitempty"`247	// RestartPolicy is set to Always on an initContainer to make248	// it a native sidecar: the kubelet keeps it running for the life of249	// the pod rather than waiting for it to exit before the next250	// container starts.251	RestartPolicy string `json:"restartPolicy,omitempty"`252	// The two probes on the catalog agent. The startupProbe gates253	// the start of the container beside it, and the livenessProbe covers254	// the agent's running life. There is no readinessProbe: a pod's255	// readiness gates its place in the catalog gossip EndpointSlice, and a256	// momentary API hiccup must not drop the agent from the bootstrap257	// list.258	StartupProbe  *Probe `json:"startupProbe,omitempty"`259	LivenessProbe *Probe `json:"livenessProbe,omitempty"`260}261262// A ContainerPort is one port a container listens on. Declaring it changes263// nothing at run time. It is how a person who reads the pod finds the port264// without reading this operator's source.265type ContainerPort struct {266	Name          string `json:"name"`267	ContainerPort int32  `json:"containerPort"`268	Protocol      string `json:"protocol,omitempty"`269}270271// A Probe is the check the kubelet runs on a container. This272// operator probes the catalog agent by running a command inside the273// container, because the agent's API binds loopback alone, so nothing274// the kubelet dials over the pod network reaches it.275type Probe struct {276	Exec                *ExecAction `json:"exec,omitempty"`277	InitialDelaySeconds int         `json:"initialDelaySeconds,omitempty"`278	PeriodSeconds       int         `json:"periodSeconds,omitempty"`279	TimeoutSeconds      int         `json:"timeoutSeconds,omitempty"`280	FailureThreshold    int         `json:"failureThreshold,omitempty"`281}282283// An ExecAction runs a command inside the container. An exit of284// zero is the check passing.285type ExecAction struct {286	Command []string `json:"command,omitempty"`287}288289// ResourceRequirements is the room a container asks for and the290// ceiling the kubelet holds it to. Kubernetes measures both in291// quantities, which are strings with a suffix: 10m is a thousandth of292// a core and 64Mi is a mebibyte count. The values are carried as293// written rather than parsed, because this operator only states them.294type ResourceRequirements struct {295	Requests map[string]string `json:"requests,omitempty"`296	Limits   map[string]string `json:"limits,omitempty"`297	// The requests of the pod's claims this container takes. A claim298	// can hold several devices, and a container receives only the requests it299	// names here.300	Claims []ResourceClaim `json:"claims,omitempty"`301}302303// One request of one claim the container takes. Name is the pod-local304// claim name from the pod spec, and Request is the name of a request inside305// that claim.306type ResourceClaim struct {307	Name    string `json:"name"`308	Request string `json:"request,omitempty"`309}310311// Every container this operator builds reads a volume or writes312// to a socket on its own pod, so none needs a capability at all and313// none may gain one.314type SecurityContext struct {315	Capabilities             *Capabilities `json:"capabilities,omitempty"`316	AllowPrivilegeEscalation *bool         `json:"allowPrivilegeEscalation,omitempty"`317}318319type Capabilities struct {320	Drop []string `json:"drop,omitempty"`321}322323// An env var carries a literal value, or a reference to a field of the324// pod itself. The catalog agent needs the address it gossips on, and325// the kubelet assigns that address when the pod starts, so that one326// value comes through valueFrom.327type EnvVar struct {328	Name      string        `json:"name"`329	Value     string        `json:"value,omitempty"`330	ValueFrom *EnvVarSource `json:"valueFrom,omitempty"`331}332333// An EnvVarSource reads the value from somewhere other than the pod spec. A334// fieldRef is the downward API: the kubelet reads the field off the pod it is335// starting and sets the variable from it. A secretKeyRef is one key of one336// Secret in the pod's namespace, which is how a provider key reaches an337// enricher container without passing through the operator.338type EnvVarSource struct {339	FieldRef     *ObjectFieldSelector `json:"fieldRef,omitempty"`340	SecretKeyRef *SecretKeySelector   `json:"secretKeyRef,omitempty"`341}342343// One key of one Secret in the pod's own namespace.344type SecretKeySelector struct {345	Name string `json:"name"`346	Key  string `json:"key"`347}348349// The field to read, as a path into the pod, such as status.podIP.350// That one is the address the catalog agent gossips on, which nothing351// knows until the pod has an address.352type ObjectFieldSelector struct {353	FieldPath string `json:"fieldPath"`354}355356type VolumeMount struct {357	Name      string `json:"name"`358	MountPath string `json:"mountPath"`359	ReadOnly  bool   `json:"readOnly,omitempty"`360}361362// A scan pod carries two volumes: the library's claim, mounted363// read-only, and the catalog agent's durable claim. A cleanup pod364// carries the second alone, and the catalog pod carries the365// namespace's own.366//367// A screen pod carries one claim per Library of its namespace, all368// read-only, and its catalog agent's own claim beside them. That agent holds369// a copy of the namespace's catalog, and the claim is what makes a restart a370// delta sync. A screen in a namespace with no single Catalog carries an371// unbounded emptyDir there. Its poster cache uses a separate bounded emptyDir.372type Volume struct {373	Name                  string                             `json:"name"`374	PersistentVolumeClaim *PersistentVolumeClaimVolumeSource `json:"persistentVolumeClaim,omitempty"`375	EmptyDir              *EmptyDirVolumeSource              `json:"emptyDir,omitempty"`376}377378// An emptyDir the kubelet creates with the pod and removes with it. A379// `SizeLimit` bounds the volume where the pod states one. The node's ephemeral380// storage limits apply as well.381type EmptyDirVolumeSource struct {382	SizeLimit string `json:"sizeLimit,omitempty"`383}384385// ReadOnly here is the mount the kubelet makes, so a scanner cannot386// write to the media volume even if its own mount said otherwise.387type PersistentVolumeClaimVolumeSource struct {388	ClaimName string `json:"claimName"`389	ReadOnly  bool   `json:"readOnly,omitempty"`390}391392// The pod status this operator reads: the phase, the per393// container readiness, the words the kubelet gives for a failure, and394// the address the catalog agents gossip on.395type PodStatus struct {396	Phase   string `json:"phase,omitempty"`397	Reason  string `json:"reason,omitempty"`398	Message string `json:"message,omitempty"`399	// PodIP is the address the kubelet assigned. It is the address the400	// catalog agents gossip on, and a pod without one is not a peer yet.401	PodIP string `json:"podIP,omitempty"`402	// The catalog agent is a native sidecar, so the kubelet reports it403	// here and not beside the container it serves. A readiness gate that404	// read only the list below would never see the agent at all.405	InitContainerStatuses []ContainerStatus `json:"initContainerStatuses,omitempty"`406	ContainerStatuses     []ContainerStatus `json:"containerStatuses,omitempty"`407	Conditions            []PodCondition    `json:"conditions,omitempty"`408}409410// PodCondition is the fields this operator reads of a pod411// condition. Status is a string rather than a bool, because a condition412// has three states: True, False, and Unknown.413type PodCondition struct {414	Type    string `json:"type"`415	Status  string `json:"status"`416	Reason  string `json:"reason,omitempty"`417	Message string `json:"message,omitempty"`418	// When the API server last wrote this verdict. The419	// unschedulable grace is measured from it, so no pass keeps a timer.420	LastTransitionTime time.Time `json:"lastTransitionTime,omitzero"`421}422423// The one pod condition this operator reads, and the verdict that424const (425	podScheduled     = "PodScheduled"426	conditionIsFalse = "False"427)428429// Ready is the kubelet's own verdict on the container, which is what430// the Library's Ready condition folds. A Running pod whose catalog431// agent has not opened its API yet is not ready.432type ContainerStatus struct {433	Name  string `json:"name"`434	Ready bool   `json:"ready"`435}436437// The pod phases Kubernetes reports, named here so the438// derivation reads as the mapping it is. A standing pod restarts in439// place, so it reaches Failed only when the kubelet gives up on it; a440// Job's pod runs to completion, so it reaches Succeeded or Failed on441// every run.442const (443	podPending   = "Pending"444	podRunning   = "Running"445	podSucceeded = "Succeeded"446	podFailed    = "Failed"447)
omdb.go 100.0%
1package main23// What the nfo facts ask OMDb: one lookup by IMDb id that answers the plot,4// the US certification, and the ratings of three sites. The free tier holds a5// thousand calls a day. A call past the limit ends OMDb's work for the run,6// because no container sleeps for hours.78import (9	"context"10	"net/http"11	"net/url"12	"strings"13	"sync"14)1516// The provider's own address, which only a test replaces.17var omdbAPIBase = "https://www.omdbapi.com"1819// OMDb serves one path. The parameters say which title and how much of the20// plot. The full plot is the one the nfo writes.21const (22	omdbPath      = "/"23	omdbFullPlot  = "full"24	omdbCheckPath = "/?i=tt0068646"25)2627// The Source strings OMDb writes in its Ratings list. The docs page read on28// 2026-09-03 did not show them, so a lookup that finds no source is a miss29// and not an error.30const (31	omdbSourceIMDb           = "Internet Movie Database"32	omdbSourceRottenTomatoes = "Rotten Tomatoes"33	omdbSourceMetacritic     = "Metacritic"34)3536// What OMDb writes when it holds no such title, and the word a 401 carries37// when the day's calls are gone.38const (39	omdbFalse       = "False"40	omdbLimitPhrase = "limit"41)4243// One account with OMDb, and what its key has done so far: whether a call44// ever worked, and whether the day's limit ended its work.45type omdbClient struct {46	providerRequests47	key string4849	mutex   sync.Mutex50	worked  bool51	limited bool52}5354func newOMDbClient(base, key string) *omdbClient {55	client := &omdbClient{key: key}56	client.providerRequests = newProviderRequests(providerBlockOMDb, base,57		func(request *http.Request) { queryKey(omdbAPIKeyParameter, client.key)(request) })58	return client59}6061// The parameter OMDb reads the key from.62const omdbAPIKeyParameter = "apikey"6364// One title as OMDb answers it. Every value is a string, OMDb's own form, the65// numbers included, and a value it does not hold is N/A.66type omdbTitle struct {67	Title      string      `json:"Title"`68	Year       string      `json:"Year"`69	Rated      string      `json:"Rated"`70	Released   string      `json:"Released"`71	Runtime    string      `json:"Runtime"`72	Genre      string      `json:"Genre"`73	Director   string      `json:"Director"`74	Writer     string      `json:"Writer"`75	Actors     string      `json:"Actors"`76	Plot       string      `json:"Plot"`77	Poster     string      `json:"Poster"`78	Ratings    []omdbScore `json:"Ratings"`79	Metascore  string      `json:"Metascore"`80	IMDbRating string      `json:"imdbRating"`81	IMDbVotes  string      `json:"imdbVotes"`82	IMDbID     string      `json:"imdbID"`83	Type       string      `json:"Type"`84	Response   string      `json:"Response"`85	Error      string      `json:"Error"`86}8788// One site's rating, in the site's own scale: 8.5/10, 91%, or 76/100.89type omdbScore struct {90	Source string `json:"Source"`91	Value  string `json:"Value"`92}9394// Whether OMDb holds this title. It answers 200 with Response False for a95// title it does not hold, so the status alone is not a find.96func (t omdbTitle) found() bool {97	return t.Response != "" && !strings.EqualFold(t.Response, omdbFalse)98}99100// The value one site scored, or an empty string where OMDb names no such101// source.102func (t omdbTitle) score(source string) string {103	for _, rating := range t.Ratings {104		if rating.Source == source {105			return rating.Value106		}107	}108	return ""109}110111// The title of one IMDb id, with the full plot. This is the one call every112// nfo fact of this provider reads.113func (c *omdbClient) title(ctx context.Context, imdbID string) (*omdbTitle, error) {114	answer := &omdbTitle{}115	query := url.Values{"i": {imdbID}, "plot": {omdbFullPlot}}116	if err := c.call(ctx, query, answer); err != nil {117		return nil, err118	}119	return answer, nil120}121122// Every call goes through here, so the key's two 401 answers are told apart:123// a key OMDb refuses before it ever worked, and the day's limit on a key that124// did work. The docs read on 2026-09-03 did not show the body of a limit125// answer, so a 401 that names a limit and a 401 after a call that worked both126// end the run.127func (c *omdbClient) call(ctx context.Context, query url.Values, into any) error {128	err := c.get(ctx, omdbPath, query, into)129	c.mutex.Lock()130	defer c.mutex.Unlock()131	if err == nil {132		c.worked = true133		return nil134	}135	if !answeredWith(err, http.StatusUnauthorized) {136		return err137	}138	if c.worked || strings.Contains(strings.ToLower(err.Error()), omdbLimitPhrase) {139		c.limited = true140	}141	return err142}143144// Whether this key has no calls left today. The container that reads true145// leaves the rest of its titles to the next run.146func (c *omdbClient) dailyLimitReached() bool {147	c.mutex.Lock()148	defer c.mutex.Unlock()149	return c.limited150}
omdbfacts.go 98.5%
1package main23// What the nfo facts make of OMDb. OMDb answers one lookup per title, keyed4// on the IMDb id the identity fact wrote, and the one answer carries the5// plot, the US certification, and the scores of three sites. A title OMDb6// does not hold is a miss and not an error. A key with no calls left ends7// OMDb's work for the run.89import (10	"context"11	"slices"12	"strconv"13	"strings"14	"time"15)1617// The word OMDb writes in every field it holds no value for, which is no18// answer at all.19const omdbNotAvailable = "N/A"2021// The form OMDb states a release date in, against the ISO date the premiered22// element carries.23const omdbReleasedLayout = "2 Jan 2006"2425func omdbValue(value string) string {26	if value = strings.TrimSpace(value); strings.EqualFold(value, omdbNotAvailable) {27		return ""28	}29	return value30}3132// The date the premiered element takes, or nothing where OMDb states a date33// this layout does not read.34func omdbPremiered(released string) string {35	day, err := time.Parse(omdbReleasedLayout, omdbValue(released))36	if err != nil {37		return ""38	}39	return day.Format(time.DateOnly)40}4142// OMDb states the runtime as minutes with the word after it.43func omdbRuntimeMinutes(runtime string) int {44	minutes, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(omdbValue(runtime), "min")))45	if err != nil || minutes <= 0 {46		return 047	}48	return minutes49}5051// OMDb states the genres as one line, separated by commas.52func omdbGenres(genre string) []string {53	var genres []string54	for _, name := range strings.Split(omdbValue(genre), ",") {55		if name = strings.TrimSpace(name); name != "" {56			genres = append(genres, name)57		}58	}59	return genres60}6162// Each site states its score in its own form: 9.2/10, 97%, or 76/100. The63// number in front of the scale is the score, and the scale itself is the max64// the rating element carries.65func omdbScoreValue(value string) (float64, bool) {66	value = strings.TrimSuffix(omdbValue(value), "%")67	if before, _, held := strings.Cut(value, "/"); held {68		value = before69	}70	score, err := strconv.ParseFloat(strings.TrimSpace(value), 64)71	if err != nil || score <= 0 {72		return 0, false73	}74	return score, true75}7677// OMDb states one site's score in two places, so the first form that reads as78// a number is the answer, and a site it scored nowhere is no answer.79func omdbFirstScore(values ...string) *titleRating {80	for _, value := range values {81		if score, held := omdbScoreValue(value); held {82			return &titleRating{Value: score}83		}84	}85	return nil86}8788// OMDb states the count of votes with the thousands marked.89func omdbVotes(votes string) int {90	count, err := strconv.Atoi(strings.ReplaceAll(omdbValue(votes), ",", ""))91	if err != nil || count <= 0 {92		return 093	}94	return count95}9697// The OMDb answerer: one account answers one fact of one title. Every call98// keys on the IMDb id, and a title with no IMDb id is no answer, because the99// identity fact fills that id first. The answer of each title is held for the100// life of the container, so the facts of one title cost one call.101type omdbAnswerer struct {102	client *omdbClient103	titles map[string]*omdbTitle104}105106func newOMDbAnswerer(client *omdbClient) omdbAnswerer {107	return omdbAnswerer{client: client, titles: map[string]*omdbTitle{}}108}109110func (a omdbAnswerer) providerBlock() string { return providerBlockOMDb }111112func (a omdbAnswerer) serves(fact string) bool {113	return slices.Contains(providerFacts[providerBlockOMDb], fact)114}115116// The ask. The day's limit is read before the call and after it, so a key117// with no calls left leaves the line instead of failing the title, and the118// remaining titles keep their gaps for the next run.119func (a omdbAnswerer) answer(ctx context.Context, fact string, title titleRef) (factAnswer, bool, error) {120	imdb := strings.TrimSpace(title.ids["imdb"])121	if !a.serves(fact) || imdb == "" {122		return factAnswer{}, false, nil123	}124	held, err := a.title(ctx, imdb)125	if err != nil || held == nil {126		return factAnswer{}, false, err127	}128	answer := omdbAnswerOf(fact, *held)129	return answer, answersFact(fact, answer), nil130}131132// The one call a title costs. The answer the container holds is read again133// for every other fact of the same title. A title OMDb does not hold is held134// as no title at all, and a title already held spends no call, so the day's135// limit is read only where a call is made.136func (a omdbAnswerer) title(ctx context.Context, imdb string) (*omdbTitle, error) {137	if held, cached := a.titles[imdb]; cached {138		return held, nil139	}140	if a.client.dailyLimitReached() {141		return nil, errDailyLimit142	}143	held, err := a.client.title(ctx, imdb)144	if err != nil {145		if a.client.dailyLimitReached() {146			return nil, errDailyLimit147		}148		return nil, err149	}150	if !held.found() {151		held = nil152	}153	a.titles[imdb] = held154	return held, nil155}156157// Which fields of the one answer each fact reads. OMDb names no studio and no158// tagline, so the overview it answers holds neither. The certification is the159// Rated value as it stands, which is what Jellyfin writes into the mpaa160// element, per its MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs, read161// on 2026-09-03.162func omdbAnswerOf(fact string, title omdbTitle) factAnswer {163	switch fact {164	case factOverview:165		return factAnswer{166			Plot:           omdbValue(title.Plot),167			Genres:         omdbGenres(title.Genre),168			Premiered:      omdbPremiered(title.Released),169			RuntimeMinutes: omdbRuntimeMinutes(title.Runtime),170		}171	case factCertification:172		return factAnswer{Certification: omdbValue(title.Rated)}173	case factRatingIMDb:174		return factAnswer{Rating: omdbIMDbRating(title)}175	case factRatingRottenTomatoes:176		return factAnswer{Rating: omdbFirstScore(title.score(omdbSourceRottenTomatoes))}177	case factRatingMetacritic:178		return factAnswer{Rating: omdbFirstScore(title.Metascore, title.score(omdbSourceMetacritic))}179	}180	return factAnswer{}181}182183// IMDb is the one site OMDb states a count of votes for.184func omdbIMDbRating(title omdbTitle) *titleRating {185	rating := omdbFirstScore(title.IMDbRating, title.score(omdbSourceIMDb))186	if rating == nil {187		return nil188	}189	rating.Votes = omdbVotes(title.IMDbVotes)190	return rating191}
operate.go 91.5%
1package main23// The operator's loop has the shape liken's own operators use:4// level-triggered, woken by a watch, with a ticker as the backstop,5// and a reconcile before the first event ever arrives.6//7// A pass reads the whole collection instead of acting on the object an8// event carried. The event is only a wake. Every pass derives every9// status from what the API server and the report desk hold right now,10// so a lost event costs at most one backstop tick, a burst of events11// collapses into one pass, and a restarted operator starts correct12// with no replay.1314import (15	"context"16	"encoding/json"17	"fmt"18	"io"19	"net/http"20	"os"21	"os/signal"22	"strings"23	"syscall"24	"time"25)2627// The three image overrides. A variable that is set wins over the28// image operatorimages.go derives from the operator's own pod, and29// one that is unset derives.30const (31	scannerImageVariable   = "SCANNER_IMAGE"32	corrosionImageVariable = "CORROSION_IMAGE"33	browserImageVariable   = "BROWSER_IMAGE"34)3536// The name this operator answers to as an idle controller. A Player37// whose status.idle.controller reads this gets a screen pod, and no other38// Player does. media-operator writes the name; this operator only compares it.39const screenController = "library.liken.sh/media-browser"4041// BackstopInterval is how often the loop reconciles with nothing to42// prompt it. The tick recovers a lost watch event, and it is what43// notices a pod that changed phase while the watch was down.44const backstopInterval = 10 * time.Second4546// PassTimeout bounds every request one pass makes. The pass owns the47// context rather than taking the stop signal, so a shutdown lets the pass48// in flight finish its writes, and the loop returns on the next turn. A49// pass whose API server stops answering ends here, and the next pass50// starts clean. It is a variable so a test drives a short timeout.51var passTimeout = 30 * time.Second5253// Operator holds what every pass needs: the client it reads and writes54// through, the settings it stamps into each pod and Job it55// creates, the bus, and the desks the bus folds each message onto. They56// are fields rather than globals so a test builds an operator around a57// desk and a cluster it controls.58type operator struct {59	client         *Client60	scannerImage   string61	corrosionImage string62	browserImage   string63	// The household wall-clock zone the pass read last, which every screen64	// pod it stands carries as TZ. Empty where the cluster states none.65	timeZone   string66	busAddress string67	topicBase  string68	bus        *Bus69	reports    *reports7071	// The provider endpoint every reachability check calls, one per provider72	// block, and the client it calls through, as fields so a test points them73	// at a server of its own and no test reaches the internet.74	providerBases  map[string]string75	providerClient *http.Client7677	// The namespace this operator runs in, which is what the78	// webhook address it reports on every Library names, and the address79	// its own webhook server listens on.80	namespace      string81	webhookAddress string8283	// Whether each namespace's reporter is on the bus, which is84	// what "online" means for every Library of that namespace.85	reporters *reporters8687	// The webhook paths the server holds for the next pass, one88	// set per Library. A path becomes a scan Job on the pass that finds89	// no full walk running.90	paths *heldPaths9192	// The play requests the bus handler holds for the next pass. A93	// screen's choice reaches the API server only here, because the94	// screen pod holds no credential of its own.95	plays *playRequests9697	// Wake is the loop's own wake channel, and one channel serves the98	// two watches and the bus handler, because a wake says nothing99	// beyond "read the collection again".100	wake chan struct{}101102	// The recreate backoff of each departing Library's cleanup Job,103	// keyed the way the report desk keys a Library, and dropped when104	// the Library goes.105	cleanupStands map[string]cleanupStand106}107108// NewOperator builds the operator and the two things it listens109// through: the desk that holds each Library's newest report, and the110// bus subscriptions that fill it. The subscriptions are remembered111// here and sent on every connection, so they outlive a broker112// restart.113func newOperator(client *Client, scannerImage, corrosionImage, browserImage, busAddress, topicBase, namespace, webhookAddress string) *operator {114	wake := make(chan struct{}, 1)115	library := &operator{116		client:         client,117		scannerImage:   scannerImage,118		corrosionImage: corrosionImage,119		browserImage:   browserImage,120		busAddress:     busAddress,121		topicBase:      topicBase,122		namespace:      namespace,123		webhookAddress: webhookAddress,124		reports:        newReports(wake),125		reporters:      newReporters(wake),126		paths:          newHeldPaths(wake),127		plays:          newPlayRequests(wake),128		wake:           wake,129		cleanupStands:  map[string]cleanupStand{},130		providerBases:  defaultProviderBases(),131		providerClient: &http.Client{Timeout: providerCheckTimeout},132	}133	// The operator names no will. Its one publish is the empty134	// retained payload that drops a departed library's topics, and a135	// will replaces nothing about that: there is no message of the136	// operator's own that a broker should stand in for when the137	// connection breaks.138	library.bus = newBus(busAddress, "library-operator", nil, nil, library.handleBusMessage)139	library.bus.Subscribe(libraryStatusFilter(topicBase))140	library.bus.Subscribe(catalogAvailabilityFilter(topicBase))141	library.bus.Subscribe(playRequestFilter(topicBase))142	return library143}144145// Operate reads the operator's environment and returns its failure146// instead of exiting, so main is the only place that ends the process147// and a test drives the whole setup. A missing setting fails here,148// before the first pass, because a pod that cannot name the images it149// creates has nothing to reconcile with.150func operate() error {151	busAddress := os.Getenv(busAddressVariable)152	if busAddress == "" {153		return fmt.Errorf("%s is unset; the Deployment must name the broker", busAddressVariable)154	}155	// The namespace the operator's own Service is in, which is156	// what the address it reports on every Library names. The Deployment157	// reads it off the pod with the downward API.158	namespace := os.Getenv(operatorNamespaceVariable)159	if namespace == "" {160		return fmt.Errorf("%s is unset; the Deployment must name the operator's namespace", operatorNamespaceVariable)161	}162	// The topic base has a default, because a cluster that runs one163	// bus needs no policy for it.164	topicBase := os.Getenv(topicBaseVariable)165	if topicBase == "" {166		topicBase = defaultTopicBase167	}168	// The port the webhook endpoint answers on, with a default,169	// because a cluster that takes the manifest as it ships needs no170	// policy for it.171	port := os.Getenv(webhookPortVariable)172	if port == "" {173		port = defaultWebhookPort174	}175176	client, err := InClusterClient()177	if err != nil {178		return fmt.Errorf("in-cluster config: %w", err)179	}180181	// The kubelet stops the pod with SIGTERM, and a person who runs182	// the binary by hand stops it with SIGINT. Both end the context,183	// and the process exits with a zero status.184	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)185	defer stop()186187	// The stop signal is registered before the pod read, so a SIGTERM188	// during start-up reaches the handler, and the read has the same189	// timeout as a pass.190	naming, endNaming := context.WithTimeout(context.Background(), passTimeout)191	defer endNaming()192	stamped, err := operatorImages(naming, client, namespace)193	if err != nil {194		return err195	}196197	return newOperator(client, stamped.scanner, stamped.corrosion, stamped.browser,198		busAddress, topicBase, namespace, ":"+port).run(stopped, os.Stdout)199}200201// Run is the operator without the process around it, so a test drives202// the whole loop against an API server it controls. It returns when203// the context ends, which is the stop signal.204func (o *operator) run(stopped context.Context, report io.Writer) error {205	go o.bus.Run(stopped)206207	// The first lists do two jobs: they prove the operator can read208	// the collections it reconciles, and their resourceVersions are209	// where the watches start.210	startup, endStartup := context.WithTimeout(context.Background(), passTimeout)211	defer endStartup()212213	libraries, err := ListLibraries(startup, o.client)214	if err != nil {215		return fmt.Errorf("listing libraries: %w", err)216	}217	catalogs, err := ListCatalogs(startup, o.client)218	if err != nil {219		return fmt.Errorf("listing catalogs: %w", err)220	}221	pods, err := ListCatalogMemberPods(startup, o.client)222	if err != nil {223		return fmt.Errorf("listing catalog member pods: %w", err)224	}225	// A Player belongs to media-operator, and a cluster that runs none226	// serves no such collection. That failure is reported and the operator227	// carries on with the libraries, so a cluster with no screens still scans.228	// The watch then resumes from an empty version, which the API server reads229	// as the state it holds now.230	players, err := ListPlayers(startup, o.client)231	if err != nil {232		fmt.Fprintf(os.Stderr, "listing players: %v\n", err)233		players = &PlayerList{}234	}235	// The household defaults are read on the same terms as the Players,236	// because the same operator owns both.237	preferences, err := ListMediaPreferences(startup, o.client)238	if err != nil {239		fmt.Fprintf(os.Stderr, "listing media preferences: %v\n", err)240		preferences = &MediaPreferencesList{}241	}242	// The providers are read on the same terms as the Players: a cluster that243	// has not applied the CRD serves no such collection, and its libraries are244	// still scanned and still reported.245	providers, err := ListMetadataProviders(startup, o.client)246	if err != nil {247		fmt.Fprintf(os.Stderr, "listing metadata providers: %v\n", err)248		providers = &MetadataProviderList{}249	}250	fmt.Fprintf(report, "library.liken.sh: operating %d libraries over %s\n",251		len(libraries.Items), o.busAddress)252253	go watchLibraries(o.client, libraries.Metadata.ResourceVersion, o.wake)254	go watchCatalogs(o.client, catalogs.Metadata.ResourceVersion, o.wake)255	go watchPods(o.client, pods.Metadata.ResourceVersion, o.wake)256	go watchPlayers(o.client, players.Metadata.ResourceVersion, o.wake)257	go watchMediaPreferences(o.client, preferences.Metadata.ResourceVersion, o.wake)258	go watchMetadataProviders(o.client, providers.Metadata.ResourceVersion, o.wake)259260	// The webhook endpoint runs for the life of the operator. A261	// failure to listen ends the loop, because an operator that reports262	// an address nothing answers is worse than one that stops.263	serving := make(chan error, 1)264	go func() { serving <- o.serveWebhooks(stopped, o.webhookAddress) }()265266	ticker := time.NewTicker(backstopInterval)267	defer ticker.Stop()268	for {269		o.pass()270		select {271		case <-stopped.Done():272			return nil273		case err := <-serving:274			if err != nil {275				return fmt.Errorf("serving webhooks on %s: %w", o.webhookAddress, err)276			}277			return nil278		case <-o.wake:279		case <-ticker.C:280		}281	}282}283284// Pass reconciles every Library in the cluster against its namespace's285// Catalog, then stands the catalog cluster of each namespace that holds286// a Catalog. That is the namespace's work and not one Library's. A287// failure on one object is reported and the pass continues, because one288// library's broken claim must not freeze every other library's status.289//290// it stands the screen of every delegated Player as well, after the291// libraries and before the catalog cluster, so the catalog step reads the292// screen pods this pass created. A pod with no address yet reaches the293// EndpointSlice on the pass after the kubelet gives it one.294func (o *operator) pass() {295	ctx, done := context.WithTimeout(context.Background(), passTimeout)296	defer done()297298	libraries, err := ListLibraries(ctx, o.client)299	if err != nil {300		fmt.Fprintf(os.Stderr, "listing libraries: %v\n", err)301		return302	}303	// The Catalog decides whether a Library proceeds, so the pass reads304	// the collection before it reconciles a Library, not after.305	catalogs, err := ListCatalogs(ctx, o.client)306	if err != nil {307		fmt.Fprintf(os.Stderr, "listing catalogs: %v\n", err)308		return309	}310	// The Players are read after the Catalogs and reported the same311	// way, except that a failure here is not the end of the pass: a cluster312	// with no media-operator serves no Players, and its libraries are still313	// scanned and still reported.314	players, err := ListPlayers(ctx, o.client)315	if err != nil {316		fmt.Fprintf(os.Stderr, "listing players: %v\n", err)317		players = &PlayerList{}318	}319	// The household zone is one setting per cluster, read once for the320	// pass and stamped on every screen pod it stands. A list that fails321	// reads as no zone, and the screens stay on UTC until the next pass.322	preferences, err := ListMediaPreferences(ctx, o.client)323	if err != nil {324		fmt.Fprintf(os.Stderr, "listing media preferences: %v\n", err)325		preferences = &MediaPreferencesList{}326	}327	o.timeZone = householdZone(preferences)328	// The Jobs and the member pods are read once for the whole329	// pass, because a Library's status reads both and the catalog step330	// reads the pods again. A list that fails ends the pass: without the331	// Jobs the pass cannot tell what is running, and without the pods it332	// cannot tell whether a namespace's catalog stands.333	jobs, err := ListWorkerJobs(ctx, o.client)334	if err != nil {335		fmt.Fprintf(os.Stderr, "listing worker jobs: %v\n", err)336		return337	}338	members, err := ListCatalogMemberPods(ctx, o.client)339	if err != nil {340		fmt.Fprintf(os.Stderr, "listing catalog member pods: %v\n", err)341		return342	}343	// The providers of every namespace, read and checked once per pass. A344	// cluster that has not applied the CRD serves no such collection, and its345	// libraries are still scanned and still reported.346	providers, err := ListMetadataProviders(ctx, o.client)347	if err != nil {348		fmt.Fprintf(os.Stderr, "listing metadata providers: %v\n", err)349		providers = &MetadataProviderList{}350	}351	byNamespace := catalogsByNamespace(catalogs.Items)352	now := time.Now().UTC()353	// The succeeded Jobs of every namespace go first, so what a person sees in354	// kubectl get pods is what runs now and what failed.355	o.retireSucceededJobs(ctx, jobs.Items, now)356	checked := o.checkProviders(ctx, providers.Items, now)357358	live := make(map[string]bool, len(libraries.Items))359	for index := range libraries.Items {360		library := &libraries.Items[index]361		namespace, name := library.Metadata.Namespace, library.Metadata.Name362		live[libraryKey(namespace, name)] = true363364		choice := singleCatalog(byNamespace[namespace])365		choice.pod = catalogPodOf(choice.catalog, members.Items)366367		// A deleting Library takes the departure and never the368		// reconcile, because the reconcile would stand the schedule369		// back up to rewrite the rows the sweep is deleting.370		if library.Metadata.deleting() {371			if err := o.depart(ctx, library, choice, jobs.Items); err != nil {372				fmt.Fprintf(os.Stderr, "departing library %s/%s: %v\n", namespace, name, err)373			}374			continue375		}376377		if err := o.reconcile(ctx, library, choice, jobs.Items, checked, now); err != nil {378			fmt.Fprintf(os.Stderr, "reconciling library %s/%s: %v\n", namespace, name, err)379		}380	}381	// The collection this pass read is the whole set of Libraries, so382	// anything else the desk holds belongs to a Library that is gone.383	// The pass clears the topics of every key it drops, because the384	// desk holds a key only while a retained message stands on the385	// bus: a report from before this operator cleared topics is386	// standing there still. Only a pass may clear one, because387	// the bus handler holds no Library list; the subscription388	// delivers the litter, the desk holds it, and the next pass389	// drops it.390	for _, key := range o.reports.retain(live) {391		namespace, name, _ := strings.Cut(key, "/")392		o.clearLibraryTopics(namespace, name)393	}394	o.paths.retain(live)395	for key := range o.cleanupStands {396		if !live[key] {397			delete(o.cleanupStands, key)398		}399	}400401	// The screen pods are read once for every namespace, so the pass402	// deletes only a pod that stands. A list that fails costs the pass403	// its deletes and nothing else: a delegated Player is still stood,404	// and the pod of one that switched away goes on the next pass.405	screens, err := ListScreenPods(ctx, o.client)406	if err != nil {407		fmt.Fprintf(os.Stderr, "listing screen pods: %v\n", err)408		screens = &PodList{}409	}410	for _, namespace := range screenNamespaces(players.Items) {411		// A screen's catalog claim is sized and classed by the412		// namespace's one Catalog, and a namespace with none, or with more413		// than one, stands its screens on an emptyDir.414		o.reconcileScreens(ctx, namespace, singleCatalog(byNamespace[namespace]).catalog,415			players.Items, libraries.Items, screens.Items, now)416	}417	// The play requests are served last, on the collections this pass418	// already read. A request is one moment: the pass creates its Play419	// now or drops it, and the person presses again.420	o.createPlays(ctx, players.Items, libraries.Items)421422	o.reconcileCatalogs(ctx, byNamespace, members.Items, now)423}424425// HandleBusMessage folds one message from the broker onto the place426// that holds it: a library report onto the desk, a play request onto427// its queue. It runs on the bus reader's goroutine, so it does nothing428// beyond the fold, and the wake each fold raises is what carries the429// message into the next pass.430func (o *operator) handleBusMessage(topic string, payload []byte) {431	// A play request is the one message that is not a report. It is432	// held for the next pass, because creating a Play is a write and433	// the bus reader's goroutine makes none.434	if namespace, player, ok := parsePlayRequestTopic(o.topicBase, topic); ok {435		o.readPlayRequest(namespace, player, topic, payload)436		return437	}438	// A namespace's reporter says online or offline on a topic of439	// its own, and that one signal stands for every Library of the440	// namespace.441	if namespace, ok := parseCatalogAvailabilityTopic(o.topicBase, topic); ok {442		if len(payload) != 0 {443			o.reporters.mark(namespace, string(payload) == availabilityOnline)444		}445		return446	}447	namespace, name, kind, ok := parseLibraryTopic(o.topicBase, topic)448	if !ok {449		return450	}451	// An empty payload is how a retained topic is cleared, so it452	// carries nothing to fold. The operator subscribes to the topics it453	// clears, so its own clears come back to it, and folding one would454	// put back the desk state the pass just dropped.455	if len(payload) == 0 || kind != libraryStatusKind {456		return457	}458	var report libraryReport459	if err := json.Unmarshal(payload, &report); err != nil {460		fmt.Fprintf(os.Stderr, "reading the report on %s: %v\n", topic, err)461		return462	}463	o.reports.fold(namespace, name, report)464}
operatorimages.go 94.7%
1package main23// The images the operator stamps into the pods and Jobs it creates4// come from its own pod. The Deployment names the operator image5// once, with a tag, and every companion image is that repository at6// the same tag: library-operator itself for the scanner, and7// library-operator-corrosion and library-operator-media-browser8// beside it. So one pin in a kustomization moves every image9// together, and no manifest names a version twice. SCANNER_IMAGE,10// CORROSION_IMAGE, and BROWSER_IMAGE still win when set, for a test11// or for a cluster whose pod names its image by digest, which has no12// tag to share.1314import (15	"context"16	"fmt"17	"os"18	"strings"19)2021const (22	// The downward API sets this, so the operator can read the pod it23	// runs in. Nothing else tells a container which pod it is.24	podNameVariable = "POD_NAME"25	// The container this binary runs in. Its image is the reference26	// every companion image derives from.27	operatorContainer = "operator"28)2930// The three images the operator stamps into the pods and Jobs it31// creates.32type images struct {33	scanner   string34	corrosion string35	browser   string36}3738// operatorImages settles each companion image. A variable that is39// set wins. When every variable is set, the operator reads no pod, so40// a cluster with no downward API can still run it. Otherwise it reads41// its own pod and derives the rest from the operator container's42// image.43func operatorImages(ctx context.Context, client *Client, namespace string) (images, error) {44	named := images{45		scanner:   os.Getenv(scannerImageVariable),46		corrosion: os.Getenv(corrosionImageVariable),47		browser:   os.Getenv(browserImageVariable),48	}49	if named.scanner != "" && named.corrosion != "" && named.browser != "" {50		return named, nil51	}52	name := os.Getenv(podNameVariable)53	if name == "" {54		return images{}, fmt.Errorf("%s is unset; the Deployment must name the operator's pod", podNameVariable)55	}56	pod, err := GetPod(ctx, client, namespace, name)57	if err != nil {58		return images{}, fmt.Errorf("reading pod %s/%s: %w", namespace, name, err)59	}60	reference := containerImage(pod, operatorContainer)61	if reference == "" {62		return images{}, fmt.Errorf("pod %s/%s has no container named %s", namespace, name, operatorContainer)63	}64	derived, err := deriveImages(reference)65	if err != nil {66		return images{}, err67	}68	if named.scanner != "" {69		derived.scanner = named.scanner70	}71	if named.corrosion != "" {72		derived.corrosion = named.corrosion73	}74	if named.browser != "" {75		derived.browser = named.browser76	}77	return derived, nil78}7980// containerImage returns the image one container of the pod's spec81// names. The spec holds what the manifest stated; the status holds82// what the kubelet resolved, which can differ.83func containerImage(pod *Pod, name string) string {84	for _, container := range pod.Spec.Containers {85		if container.Name == name {86			return container.Image87		}88	}89	return ""90}9192// deriveImages names each companion at the operator's repository and93// tag: the scanner is the operator's own image, and the others take a94// suffix. An image with no tag has no version to share, and the error95// says what to set.96func deriveImages(reference string) (images, error) {97	repository, tag, tagged := splitReference(reference)98	if !tagged {99		return images{}, fmt.Errorf("the operator's image %q has no tag; every companion image takes the tag of this one", reference)100	}101	return images{102		scanner:   reference,103		corrosion: repository + "-corrosion:" + tag,104		browser:   repository + "-media-browser:" + tag,105	}, nil106}107108// splitReference takes the repository and the tag apart. Only the109// part after the last "/" can hold a tag: a "@" there is a digest,110// which names no tag, and the last ":" there splits the tag off. A111// ":" before that "/" is a registry port, so it never splits.112func splitReference(reference string) (repository, tag string, tagged bool) {113	name := reference[strings.LastIndex(reference, "/")+1:]114	if strings.Contains(name, "@") {115		return "", "", false116	}117	colon := strings.LastIndex(name, ":")118	if colon <= 0 || colon == len(name)-1 {119		return "", "", false120	}121	cut := len(reference) - len(name) + colon122	return reference[:cut], reference[cut+1:], true123}
playrequest.go 100.0%
1package main23// The playback half of the operator. A screen pod holds no API4// credential, so a person's choice on the wall reaches the control5// plane over the bus: the browser resolves the list from the catalog6// beside it and publishes the paths, and this file joins each path to7// the Library's claim and creates the Play. The browser resolves and8// the operator does not, because Corrosion's API binds to loopback in9// every pod, so the operator can read no namespace's catalog.1011import (12	"context"13	"encoding/json"14	"fmt"15	"os"16	"path"17	"strings"18	"sync"19)2021// playRequest is one request as the browser publishes it. The22// namespace and the Player come from the topic and never from the23// payload, so a request cannot name a Player other than the one whose24// topic carried it.25type playRequest struct {26	Namespace string `json:"-"`27	Player    string `json:"-"`28	Library   string `json:"library"`29	// The catalog's slug for the item the person chose, the movie's or30	// the chosen episode's. It names the Play and nothing else, so a31	// request that carries none still plays.32	Slug  string            `json:"slug"`33	Items []playRequestItem `json:"items"`34}3536// playRequestItem is one item of the list. Every path is relative to37// the library root, exactly as the catalog stores it, and the operator38// joins the claim and the root onto it.39type playRequestItem struct {40	Path         string            `json:"path"`41	Presentation *PlayPresentation `json:"presentation,omitempty"`42}4344// playRequests is the queue the bus handler fills and the pass drains.45// The handler runs on the bus reader's goroutine and the pass on the46// loop's, so one mutex covers the slice.47type playRequests struct {48	mutex   sync.Mutex49	pending []playRequest50	wake    chan<- struct{}51}5253func newPlayRequests(wake chan<- struct{}) *playRequests {54	return &playRequests{wake: wake}55}5657// hold keeps one request for the next pass and wakes the loop. A58// person waits at the screen for the film to start, so a request never59// waits for the backstop tick.60func (p *playRequests) hold(request playRequest) {61	p.mutex.Lock()62	p.pending = append(p.pending, request)63	p.mutex.Unlock()64	select {65	case p.wake <- struct{}{}:66	default:67	}68}6970// take returns everything held, in the order it arrived, and empties71// the queue. A request is one moment: a pass that could not serve it72// must not serve it again on the next tick.73func (p *playRequests) take() []playRequest {74	p.mutex.Lock()75	defer p.mutex.Unlock()76	taken := p.pending77	p.pending = nil78	return taken79}8081// readPlayRequest decodes one message off a play topic. An empty82// payload and one that does not decode are both dropped. Only the83// second is reported, because nothing retained stands on a play topic84// for a clear to remove.85func (o *operator) readPlayRequest(namespace, player, topic string, payload []byte) {86	if len(payload) == 0 {87		return88	}89	var request playRequest90	if err := json.Unmarshal(payload, &request); err != nil {91		fmt.Fprintf(os.Stderr, "reading the play request on %s: %v\n", topic, err)92		return93	}94	request.Namespace, request.Player = namespace, player95	o.plays.hold(request)96}9798// createPlays turns every held request into a Play. The pass holds99// the Players and the Libraries already, so every check is a read of100// what it has: the Player must be one this operator serves, and the101// Library must be one the Player's namespace holds. A request that102// fails a check is reported and dropped, because the screen has no way103// to answer and the pod log is where a person looks.104func (o *operator) createPlays(ctx context.Context, players []Player, libraries []Library) {105	for _, request := range o.plays.take() {106		play, err := request.play(players, libraries)107		if err != nil {108			fmt.Fprintf(os.Stderr, "playing on %s/%s: %v\n",109				request.Namespace, request.Player, err)110			continue111		}112		if _, err := CreatePlay(ctx, o.client, play); err != nil {113			fmt.Fprintf(os.Stderr, "playing on %s/%s: %v\n",114				request.Namespace, request.Player, err)115		}116	}117}118119// play is the Play one request becomes, or the reason it becomes none.120// Every refusal here is a request that named something the screen may121// not reach.122func (r playRequest) play(players []Player, libraries []Library) (*Play, error) {123	player := r.player(players)124	if player == nil {125		return nil, fmt.Errorf("no player of this operator's answers to that name")126	}127	library := r.library(libraries)128	if library == nil {129		return nil, fmt.Errorf("namespace %s holds no library %s", r.Namespace, r.Library)130	}131	if library.Spec.screenClaim() == "" {132		return nil, fmt.Errorf("library %s names no claim", r.Library)133	}134135	items := make([]PlayItem, 0, len(r.Items))136	for _, item := range r.Items {137		stamped, err := item.stamped(library)138		if err != nil {139			return nil, err140		}141		items = append(items, stamped)142	}143	if len(items) == 0 {144		return nil, fmt.Errorf("the request named nothing to play")145	}146147	return &Play{148		APIVersion: playerAPIVersion,149		Kind:       "Play",150		Metadata: ObjectMeta{151			GenerateName: playGenerateName(r.Player, r.Slug),152			Namespace:    r.Namespace,153		},154		Spec: PlaySpec{Players: []string{r.Player}, Items: items},155	}, nil156}157158// The longest prefix the operator asks the API server to mint a name159// from, counting the Player, the slug, and the two hyphens that join160// them. The API server appends its own suffix, so this budget leaves161// room for it inside a DNS-1123 label.162const playNameBudget = 50163164// playGenerateName is the prefix the API server mints a Play name from.165// The slug is in it so that kubectl get plays reads as titles instead166// of one line per unit. A request that carries no slug, or one whose167// slug folds to nothing, falls back to the Player alone, because a Play168// that starts matters more than its name.169func playGenerateName(player, slug string) string {170	fragment := capped(labelFragment(slug), playNameBudget-len(player)-2)171	if fragment == "" {172		return player + "-"173	}174	return player + "-" + fragment + "-"175}176177// labelFragment folds a catalog slug to a DNS-1123 label fragment.178// Lowercase letters and digits pass through, a run of anything else179// becomes one hyphen, and the fragment carries no leading or trailing180// hyphen. The operator folds a slug the catalog already built because181// the request comes over the bus from a pod, so nothing but this182// function guarantees the shape a name needs.183func labelFragment(text string) string {184	var folded strings.Builder185	pendingHyphen := false186	for _, letter := range strings.ToLower(text) {187		switch {188		case letter >= 'a' && letter <= 'z', letter >= '0' && letter <= '9':189			if pendingHyphen && folded.Len() > 0 {190				folded.WriteByte('-')191			}192			pendingHyphen = false193			folded.WriteRune(letter)194		default:195			pendingHyphen = true196		}197	}198	return folded.String()199}200201// capped is the cap on the fragment, in bytes. A fragment longer than202// the budget is cut back to the last hyphen inside it, so a name ends203// on a whole word where one is in reach and on the hard cut where none204// is. A budget of nothing leaves no fragment.205func capped(fragment string, budget int) string {206	if budget <= 0 {207		return ""208	}209	if len(fragment) <= budget {210		return fragment211	}212	cut := fragment[:budget]213	if at := strings.LastIndexByte(cut, '-'); at >= 0 {214		cut = cut[:at]215	}216	return cut217}218219// player is the Player this request names, and only when this220// operator stands its idle screen. A request for any other Player came221// from a screen this operator does not draw.222func (r playRequest) player(players []Player) *Player {223	for index := range players {224		player := &players[index]225		if player.Metadata.Namespace != r.Namespace || player.Metadata.Name != r.Player {226			continue227		}228		if !player.delegated() {229			return nil230		}231		return player232	}233	return nil234}235236// library is the Library this request names, and only in the Player's237// own namespace. The namespace is the boundary: a screen plays the238// libraries beside it and no others.239func (r playRequest) library(libraries []Library) *Library {240	namespace, name, found := strings.Cut(r.Library, "/")241	if !found || namespace != r.Namespace {242		return nil243	}244	for index := range libraries {245		library := &libraries[index]246		if library.Metadata.Namespace == namespace && library.Metadata.Name == name {247			return library248		}249	}250	return nil251}252253// stamped is one item with its paths joined to the library's claim.254// The main file must be there. The art and the trickplay are joined255// only where the catalog holds them, and an item with neither carries256// neither.257func (i playRequestItem) stamped(library *Library) (PlayItem, error) {258	uri, err := reference(library, i.Path)259	if err != nil {260		return PlayItem{}, err261	}262	item := PlayItem{URI: uri}263	if i.Presentation == nil {264		return item, nil265	}266267	presentation := *i.Presentation268	for _, beside := range []*string{&presentation.Art, &presentation.Trickplay} {269		if *beside == "" {270			continue271		}272		if *beside, err = reference(library, *beside); err != nil {273			return PlayItem{}, err274		}275	}276	item.Presentation = &presentation277	return item, nil278}279280// reference is the media reference one relative path becomes. The claim281// scheme mounts the claim a screen reads read-only on the playback pod, so282// a file plays from the volume the screen showed and no second claim is283// created.284func reference(library *Library, relative string) (string, error) {285	if !inside(relative) {286		return "", fmt.Errorf("the path %q is not inside the library", relative)287	}288	return "claim://" + library.Spec.screenClaim() + "/" +289		path.Join(library.Spec.screenRoot(), relative), nil290}291292// inside reports whether a path names a file under the library root.293// An empty path names nothing, an absolute path leaves the mount, and294// a path that climbs above the root reaches another library's files295// or the rest of the volume.296func inside(relative string) bool {297	if relative == "" || path.IsAbs(relative) {298		return false299	}300	cleaned := path.Clean(relative)301	return cleaned != ".." && !strings.HasPrefix(cleaned, "../")302}
pod.go 100.0%
1package main23// The containers every pod this operator builds is made of, and4// the pod template a scan Job runs. A worker pod holds two containers:5// the worker itself, and a Corrosion agent of its own as a native6// sidecar. They share the pod because they share a loopback address and7// a lifetime: no agent answers on the network, so a worker that writes8// the catalog carries the agent that holds it.910// The containers, and the pod-local names of the two volumes they11// mount. The container names reach a person through kubectl logs, so12// they say what the container does rather than what it runs.1314import "encoding/json"1516const (17	scannerContainer  = "scanner"18	catalogContainer  = "catalog"19	cleanupContainer  = "cleanup"20	reporterContainer = "reporter"2122	libraryVolumeName = "library"23	catalogVolumeName = "catalog"24	// artVolumeName is the art claim a franchises scan mounts beside its25	// storage claim. The storage holds the checkout and is read-only, so26	// the art the scan downloads lands on a claim of its own.27	artVolumeName = "art"28)2930// CatalogStatePath is where the catalog agent writes its database, its31// write-ahead log, and its admin socket. The image's own configuration32// names this one directory, so the durable catalog claim mounted here is33// every writable path the agent needs.34const catalogStatePath = "/var/lib/corrosion"3536// The database file the agent writes under that directory, from37// corrosion/config.toml. The media browser reads the catalog straight from38// this file, so the name is stated here and in the image's configuration and39// nowhere else.40const catalogStateFile = "state.db"4142// The two variables the catalog agent reads. Corrosion takes an43// environment variable over the matching setting in its configuration44// file, with two underscores between the table and the key, so45// GOSSIP__ADDR is the gossip table's bind address.46//47// The image is built long before any pod exists, so its configuration48// cannot name the pod's address. The kubelet assigns that address when49// it starts the pod, the downward API reads it into POD_IP, and the50// kubelet expands $(POD_IP) in the value beside it. So the agent binds51// the gossip port on the pod's own address, and it announces the52// address it bound.53//54// The agent binds the pod's address rather than every address on55// purpose. Corrosion drops its own address from the bootstrap list by56// comparison with the address it bound. An agent bound on 0.0.0.057// finds its own pod in the list, announces to itself on every retry,58// and logs an error each time.59const (60	podIPVariable         = "POD_IP"61	podIPFieldPath        = "status.podIP"62	gossipAddressVariable = "GOSSIP__ADDR"63	gossipAddress         = "$(" + podIPVariable + "):8787"64)6566// CatalogBinary is the Corrosion binary the image's entrypoint67// runs, from corrosion/Dockerfile. The kubelet's probes run it with the68// query subcommand, which reaches the agent's loopback API from inside69// the container.70const catalogBinary = "/corrosion"7172// ScannerGracePeriod is how long the kubelet waits between the SIGTERM73// and the kill. A busy catalog agent flushes its database on the way74// out, so a pod asks for a minute rather than the default 30 seconds.75const scannerGracePeriod = 607677// The room each container asks for. The requests are what the78// scheduler places the pod by, and they are small because both79// containers idle between walks. Only memory is capped: a container80// over its memory limit is killed, which is the failure worth having,81// where a CPU limit only throttles a walk that is already bounded by82// the volume it reads.83//84// The catalog agent's ceiling is the wide one. Its first sync holds85// the whole catalog in memory as it applies it, which measured up to86// 380 MB, and it settles far below that once the sync completes.87const (88	scannerCPURequest    = "10m"89	scannerMemoryRequest = "32Mi"90	scannerMemoryLimit   = "64Mi"9192	catalogCPURequest    = "10m"93	catalogMemoryRequest = "64Mi"94	catalogMemoryLimit   = "512Mi"95)9697// The pod a scan Job runs: the scanner beside a Corrosion agent98// on the Library's own catalog claim, with the library volume mounted99// read-only. It is a function of the Library, the scan path, and the100// operator's own settings alone, so two passes build the same template,101// which is what makes the template hash mean anything.102func scanPodTemplate(library *Library, scanPath, scannerImage, corrosionImage, busAddress, topicBase string) PodTemplateSpec {103	template := workerPodTemplate(library, workerScan,104		scannerSidecar(library, scanPath, scannerImage, busAddress, topicBase), corrosionImage)105	// The library volume is the scanner's alone; the cleanup worker106	// reads no media, so it mounts none.107	//108	// Every kind mounts its storage claim read-only, because every scanner109	// reads the storage and writes nothing to it.110	template.Spec.Volumes = append(template.Spec.Volumes, Volume{111		Name: libraryVolumeName,112		PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{113			ClaimName: library.Spec.Storage.Claim,114			ReadOnly:  true,115		},116	})117	// A franchises library mounts its art claim writable beside the118	// read-only storage, because the scan downloads the art that each119	// franchise.yaml links to.120	if claim := library.Spec.artClaim(); claim != "" {121		template.Spec.Volumes = append(template.Spec.Volumes, Volume{122			Name: artVolumeName,123			PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{124				ClaimName: claim,125			},126		})127	}128	return template129}130131// The pod shape both workers share: one worker container, the132// catalog agent beside it on the Library's catalog claim, and no133// Kubernetes credential.134func workerPodTemplate(library *Library, worker string, container Container, corrosionImage string) PodTemplateSpec {135	grace := int64(scannerGracePeriod)136	// A worker holds no Kubernetes credential: it writes the catalog137	// through the agent beside it, and the operator alone writes the138	// status. Without this the kubelet would mount the namespace's139	// default ServiceAccount token into both containers.140	noToken := false141	return PodTemplateSpec{142		Metadata: ObjectMeta{143			Labels: withMemberLabel(workerLabels(library.Metadata.Name, worker)),144		},145		Spec: PodSpec{146			// Never, because a Job's pod runs to completion, and a147			// restart in place would hide the failure the Job reports.148			RestartPolicy:                 "Never",149			TerminationGracePeriodSeconds: &grace,150			AutomountServiceAccountToken:  &noToken,151			InitContainers: []Container{152				catalogSidecar(corrosionImage),153			},154			Containers: []Container{container},155			Volumes: []Volume{156				// The agent's state is the Library's own durable claim.157				// It keeps the agent's actor id and its rows between158				// runs, so a run syncs a delta rather than the whole159				// namespace, and its ReadWriteOnce is what serializes160				// one library's workers.161				{Name: catalogVolumeName, PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{162					ClaimName: scannerCatalogClaimName(library.Metadata.Name),163				}},164			},165		},166	}167}168169// LibraryOwner ties the pod's life to the Library's. Controller is170// true because exactly one thing manages this pod, and the UID is what171// the garbage collector matches: a Library deleted and recreated under172// the same name is a different owner, and the old pod goes.173func libraryOwner(library *Library) OwnerReference {174	return OwnerReference{175		APIVersion: libraryAPIVersion,176		Kind:       "Library",177		Name:       library.Metadata.Name,178		UID:        library.Metadata.UID,179		Controller: true,180	}181}182183// ScannerSidecar builds the container that walks the volume. It runs184// this operator's own image in its scan role, unless the kind's185// settings block names an image of its own, which is how a person186// supplies a scanner the project does not ship.187//188// The container learns which Library it serves from its environment189// alone, because it holds no API credential to look one up with. The190// claim is mounted read-only, so a scanner cannot write to the media191// volume whatever it does.192//193// An empty scan path is a full walk, and a path names the one194// folder to rescan; the Job's own name arrives through the downward195// API, because the scanner writes it into the runs row the reporter196// echoes back.197func scannerSidecar(library *Library, scanPath, image, busAddress, topicBase string) Container {198	if settings := library.Spec.settings(); settings != nil && settings.Image != "" {199		image = settings.Image200	}201	return Container{202		Name:    scannerContainer,203		Image:   image,204		Command: []string{"/library-operator", scanMode},205		Env: []EnvVar{206			{Name: libraryNamespaceVariable, Value: library.Metadata.Namespace},207			{Name: libraryNameVariable, Value: library.Metadata.Name},208			{Name: libraryKindVariable, Value: library.Spec.Kind},209			{Name: libraryRootVariable, Value: library.Spec.Storage.Root},210			{Name: busAddressVariable, Value: busAddress},211			{Name: topicBaseVariable, Value: topicBase},212			{Name: catalogAPIVariable, Value: defaultCatalogAPI},213			{Name: libraryIgnoreVariable, Value: ignoreValue(library)},214			{Name: libraryArtVariable, Value: artPathOf(library)},215			{Name: scanPathVariable, Value: scanPath},216			{Name: jobNameVariable, ValueFrom: &EnvVarSource{217				FieldRef: &ObjectFieldSelector{FieldPath: jobNameFieldPath},218			}},219		},220		VolumeMounts: scannerMounts(library),221		Resources: ResourceRequirements{222			Requests: map[string]string{"cpu": scannerCPURequest, "memory": scannerMemoryRequest},223			Limits:   map[string]string{"memory": scannerMemoryLimit},224		},225		SecurityContext: unprivileged(),226	}227}228229// CatalogSidecar builds the Corrosion agent. The image carries the230// agent's configuration and runs it as its default command, so the pod231// states only what the image cannot know: the address the agent232// announces, and the directory it writes.233//234// The agent is a native sidecar: an initContainer with235// restartPolicy Always. The kubelet starts it and waits for its236// startupProbe before it starts the scanner, so the scanner's first walk237// never races a catalog API that is not listening.238//239// The probes run a query inside the container, not an httpGet or240// a TCP dial from the kubelet. The agent's API binds loopback alone (see241// corrosion/config.toml), so nothing the kubelet reaches over the pod242// network can dial it. `corrosion query "SELECT 1"` connects to that243// loopback API from inside the container and exits zero only when the244// API answers, which is more than a bound port: it is the API and the245// database behind it both up.246func catalogSidecar(image string) Container {247	always := "Always"248	return Container{249		Name:  catalogContainer,250		Image: image,251		Env: []EnvVar{252			{Name: podIPVariable, ValueFrom: &EnvVarSource{253				FieldRef: &ObjectFieldSelector{FieldPath: podIPFieldPath},254			}},255			{Name: gossipAddressVariable, Value: gossipAddress},256		},257		VolumeMounts: []VolumeMount{258			{Name: catalogVolumeName, MountPath: catalogStatePath},259		},260		Resources: ResourceRequirements{261			Requests: map[string]string{"cpu": catalogCPURequest, "memory": catalogMemoryRequest},262			Limits:   map[string]string{"memory": catalogMemoryLimit},263		},264		SecurityContext: unprivileged(),265		RestartPolicy:   always,266		// The startupProbe gives a cold agent up to 90 seconds to267		// open its API, because an agent that replays its database on268		// start takes a while, and it gates the scanner's start.269		StartupProbe: catalogProbe(3, 30),270		// The livenessProbe runs every 30 seconds and restarts a271		// wedged agent after three failures, at near-zero cost.272		LivenessProbe: catalogProbe(30, 3),273	}274}275276// CatalogProbe builds a probe that runs the catalog agent's query277// command inside the container on the given schedule. The query reaches278// the agent's loopback API and exits zero only when it answers.279func catalogProbe(period, failureThreshold int) *Probe {280	return &Probe{281		Exec:             &ExecAction{Command: []string{catalogBinary, "query", "SELECT 1"}},282		PeriodSeconds:    period,283		FailureThreshold: failureThreshold,284	}285}286287// Unprivileged is the security context both containers carry. One288// reads a mounted volume and the other writes a database on a289// loopback socket, so neither needs a capability, and neither may290// gain one.291func unprivileged() *SecurityContext {292	escalation := false293	return &SecurityContext{294		Capabilities:             &Capabilities{Drop: []string{"ALL"}},295		AllowPrivilegeEscalation: &escalation,296	}297}298299// scannerMounts are the storage claim every scanner mounts read-only, and300// the art claim a franchises scanner mounts writable beside it.301func scannerMounts(library *Library) []VolumeMount {302	mounts := []VolumeMount{303		{Name: libraryVolumeName, MountPath: libraryMountPath, ReadOnly: true},304	}305	if library.Spec.artClaim() != "" {306		mounts = append(mounts, VolumeMount{Name: artVolumeName, MountPath: artMountPath})307	}308	return mounts309}310311// artPathOf is where the art claim is mounted. The scanner learns it from312// its environment alone, because the pod carries no credential to read313// the Library with. It is empty for a library that names no art claim, and314// that scanner downloads nothing.315func artPathOf(library *Library) string {316	if library.Spec.artClaim() == "" {317		return ""318	}319	return artMountPath320}321322// The ignore list travels as one JSON value, so a folder name of any323// character reaches the scanner whole.324func ignoreValue(library *Library) string {325	ignore, _ := json.Marshal(library.Spec.Ignore)326	return string(ignore)327}
probe.go 98.7%
1package main23// probe.go is the probe fact: the one container that opens a video file.4// The answer goes into the .nfo and not into the catalog alone, because the5// volume holds the truth. A rebuilt catalog reads the sidecar and probes6// nothing.78import (9	"context"10	"encoding/json"11	"encoding/xml"12	"errors"13	"fmt"14	"io/fs"15	"os"16	"os/exec"17	"path/filepath"18	"strconv"19	"strings"20	"time"21)2223// One file's bound, so a file the kernel will not answer for cannot hold the24// container open.25var ffprobeTimeout = time.Minute2627// One read of one file's container, which a test replaces with an answer of28// its own.29type mediaProbe func(ctx context.Context, path string) ([]byte, error)3031// The read the probe fact makes of every file in its gap. It is a variable so32// a test answers in ffprobe's place.33var probeFile mediaProbe = ffprobeFile3435// The probe fact's whole run: the gap of files with no duration, read with36// ffprobe.37func (e *enricher) probeFact(ctx context.Context) error {38	return e.probeGap(ctx, probeFile)39}4041// A catalog read that fails ends the container, because the gap list is the42// work and there is nothing to do without it. A file that will not open43// records an error attempt, and the run carries on to the next file.44func (e *enricher) probeGap(ctx context.Context, probe mediaProbe) error {45	if err := e.markRunStarted(ctx); err != nil {46		return err47	}48	paths, err := e.gaps(ctx, factProbe, time.Now().UTC())49	if err != nil {50		return err51	}52	probed := 053	for _, path := range paths {54		if err := ctx.Err(); err != nil {55			return err56		}57		if !e.inScope(path) {58			continue59		}60		e.probeOne(ctx, probe, path)61		probed++62	}63	e.logf("probed %d of the %d files with no stream details", probed, len(paths))64	return nil65}6667// One file: read it, write its stream details into the sidecar the scanner68// reads them from, and record what happened either way.69func (e *enricher) probeOne(ctx context.Context, probe mediaProbe, path string) {70	absolute := filepath.Join(e.root, path)71	result := attemptFound72	if err := e.writeStreamDetails(ctx, probe, absolute); err != nil {73		e.logf("could not probe %s: %v", path, err)74		result = attemptError75	}76	folder, entry := likenFolderFor(e.kind, absolute)77	e.recordAttempt(folder, factProbe, entry, result, time.Now().UTC())78}7980// The answer is one surgical edit of the sidecar, so every other element the81// sidecar holds stays as it was. A file with no sidecar gets a minimal one,82// and the later facts edit that same file.83func (e *enricher) writeStreamDetails(ctx context.Context, probe mediaProbe, absolute string) error {84	output, err := probe(ctx, absolute)85	if err != nil {86		return err87	}88	var read ffprobeAnswer89	if err := json.Unmarshal(output, &read); err != nil {90		return fmt.Errorf("reading the probe of %s: %w", absolute, err)91	}92	element, err := xml.MarshalIndent(read.fileInfo(), "  ", "  ")93	if err != nil {94		return err95	}96	sidecar, rootElement, title := probeSidecar(e.kind, absolute)97	return e.writer.editNFO(sidecar, rootElement, title, xmlElement{name: "fileinfo"}, element)98}99100// The sidecar names and the root elements the scanner reads a title's, a101// series', and an episode's facts from.102const (103	movieSidecarName  = "movie.nfo"104	seriesSidecarName = "tvshow.nfo"105	nfoRootMovie      = "movie"106	nfoRootSeries     = "tvshow"107	nfoRootEpisode    = "episodedetails"108)109110// Which sidecar carries a file's stream details: the title's own for the111// first video of a movie folder, and the file's own for every other video.112// That is where the scanner reads each of them from, so a trailer's details113// never land in movie.nfo.114func probeSidecar(kind, absolute string) (string, string, string) {115	dir, name := filepath.Dir(absolute), filepath.Base(absolute)116	rootElement := nfoRootEpisode117	if kind == libraryKindMovies {118		rootElement = nfoRootMovie119		if videos, err := listVideoFiles(dir); err == nil && len(videos) > 0 && videos[0] == name &&120			extrasFolderName(filepath.Base(dir)) == "" {121			title, _ := parseReleaseName(filepath.Base(dir))122			return filepath.Join(dir, movieSidecarName), nfoRootMovie, title123		}124	}125	title, _ := parseReleaseName(name)126	return sidecarBeside(absolute), rootElement, title127}128129func sidecarBeside(absolute string) string {130	return strings.TrimSuffix(absolute, filepath.Ext(absolute)) + metadataExtension131}132133// The smallest document a reader accepts: the root element and a title. Every134// later fact edits this same file.135func minimalNFO(rootElement, title string) []byte {136	var escaped strings.Builder137	_ = xml.EscapeText(&escaped, []byte(title))138	return fmt.Appendf(nil, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<%s>\n  <title>%s</title>\n</%s>\n",139		rootElement, escaped.String(), rootElement)140}141142// The ffprobe answer this container reads: the container's own facts and one143// entry per stream.144type ffprobeAnswer struct {145	Streams []ffprobeStream `json:"streams"`146	Format  ffprobeFormat   `json:"format"`147}148149type ffprobeFormat struct {150	Duration string `json:"duration"`151}152153type ffprobeStream struct {154	CodecType string      `json:"codec_type"`155	CodecName string      `json:"codec_name"`156	Width     int         `json:"width"`157	Height    int         `json:"height"`158	Channels  int         `json:"channels"`159	Duration  string      `json:"duration"`160	Tags      ffprobeTags `json:"tags"`161}162163type ffprobeTags struct {164	Language string `json:"language"`165}166167// The streamdetails block, in the shape nfo.go reads and Kodi and Jellyfin168// both write.169type nfoFileInfoElement struct {170	XMLName       xml.Name             `xml:"fileinfo"`171	StreamDetails nfoStreamDetailsBody `xml:"streamdetails"`172}173174type nfoStreamDetailsBody struct {175	Video    []nfoVideoElement    `xml:"video"`176	Audio    []nfoAudioElement    `xml:"audio"`177	Subtitle []nfoSubtitleElement `xml:"subtitle"`178}179180type nfoVideoElement struct {181	Codec    string `xml:"codec"`182	Width    int    `xml:"width"`183	Height   int    `xml:"height"`184	Duration int    `xml:"durationinseconds"`185}186187type nfoAudioElement struct {188	Codec    string `xml:"codec"`189	Channels int    `xml:"channels,omitempty"`190	Language string `xml:"language,omitempty"`191}192193type nfoSubtitleElement struct {194	Language string `xml:"language,omitempty"`195}196197// The container's duration wins over a stream's, because it is the length of198// the file as a player sees it. Every video, audio, and subtitle stream is199// written, not the first of each, because a second audio track is a fact a200// person looks for.201func (a ffprobeAnswer) fileInfo() nfoFileInfoElement {202	var details nfoStreamDetailsBody203	seconds := probeSeconds(a.Format.Duration)204	for _, stream := range a.Streams {205		switch stream.CodecType {206		case fileTypeVideo:207			duration := seconds208			if duration == 0 {209				duration = probeSeconds(stream.Duration)210			}211			details.Video = append(details.Video, nfoVideoElement{212				Codec: stream.CodecName, Width: stream.Width, Height: stream.Height, Duration: duration,213			})214		case fileTypeAudio:215			details.Audio = append(details.Audio, nfoAudioElement{216				Codec: stream.CodecName, Channels: stream.Channels, Language: stream.Tags.Language,217			})218		case fileTypeSubtitle:219			details.Subtitle = append(details.Subtitle, nfoSubtitleElement{Language: stream.Tags.Language})220		}221	}222	return nfoFileInfoElement{StreamDetails: details}223}224225// A duration ffprobe states as a decimal string reads as whole seconds, which226// is what the sidecar carries.227func probeSeconds(value string) int {228	seconds, err := strconv.ParseFloat(strings.TrimSpace(value), 64)229	if err != nil || seconds <= 0 {230		return 0231	}232	return int(seconds + 0.5)233}234235// The one call that opens a file. The timeout is per file, so one file that236// hangs costs its own minute and no more.237func ffprobeFile(ctx context.Context, path string) ([]byte, error) {238	timed, cancel := context.WithTimeout(ctx, ffprobeTimeout)239	defer cancel()240241	command := exec.CommandContext(timed, "ffprobe",242		"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)243	output, err := command.Output()244	if err != nil {245		return nil, fmt.Errorf("ffprobe %s: %w", filepath.Base(path), err)246	}247	return output, nil248}249250// An absent sidecar becomes a minimal one and not an error, because the251// sidecar-less title is the case this fact exists for. A sidecar with no252// root element, an empty file or a declaration alone, is treated the same253// way, because there is nothing in it to keep. Otherwise the edit never254// rewrites the document it read. It replaces one element and keeps every255// other byte.256func (w *volumeWriter) editNFO(path, rootElement, title string, element xmlElement, replacement []byte) error {257	document, err := os.ReadFile(path)258	if err != nil && !errors.Is(err, fs.ErrNotExist) {259		return err260	}261	if !hasRootElement(document) {262		document = minimalNFO(rootElement, title)263	}264	edited, err := editElement(document, element, replacement)265	if err != nil {266		return fmt.Errorf("editing %s: %w", filepath.Base(path), err)267	}268	return w.write(path, edited)269}
providercheck.go 96.8%
1package main23// providercheck.go holds the one call the operator makes against each4// MetadataProvider per pass, the Ready condition it writes from the answer,5// and the resolution of a Library's ordered sources to the provider that6// serves a fact.78import (9	"context"10	"errors"11	"fmt"12	"net/http"13	"os"14	"slices"15	"time"16)1718// The one call each provider answers for the check: the path, and how the key19// travels on it. A provider that takes no key authorizes nothing. The call is20// the cheapest read each provider serves, so a pass costs one request per21// account.22type providerReach struct {23	path      string24	authorize func(*http.Request, string)25}2627var providerReaches = map[string]providerReach{28	providerBlockTMDb:   {path: tmdbConfigurationPath, authorize: authorizeTMDb},29	providerBlockOMDb:   {path: omdbCheckPath, authorize: authorizeParameter(omdbAPIKeyParameter)},30	providerBlockFanart: {path: fanartCheckPath, authorize: authorizeParameter(fanartAPIKeyParam)},31	providerBlockTVmaze: {path: tvmazeCheckPath},32}3334// The address of each provider the check calls, which a test replaces with a35// server of its own.36func defaultProviderBases() map[string]string {37	return map[string]string{38		providerBlockTMDb:   tmdbAPIBase,39		providerBlockOMDb:   omdbAPIBase,40		providerBlockFanart: fanartAPIBase,41		providerBlockTVmaze: tvmazeAPIBase,42	}43}4445// A key that travels as a query parameter, in the shape the check calls an46// authorization in.47func authorizeParameter(name string) func(*http.Request, string) {48	return func(request *http.Request, key string) {49		queryKey(name, key)(request)50	}51}5253// The check must not hold a pass open. It is a variable so a test drives a54// short one.55var providerCheckTimeout = 10 * time.Second5657// Every provider the pass read, keyed the way the report desk keys a Library,58// with the verdict this pass wrote on each.59type providerSet map[string]*MetadataProvider6061// The provider a Library's sources resolve to for one fact: the first62// named provider that exists, is Ready, and lists the fact.63func (s providerSet) serving(namespace string, sources []string, fact string) *MetadataProvider {64	for _, name := range sources {65		provider, held := s[libraryKey(namespace, name)]66		if held && provider.ready() && provider.serves(fact) {67			return provider68		}69	}70	return nil71}7273// A group of facts resolves to the first provider that serves any one of74// them, which is what stands the container that runs the group.75func (s providerSet) servingAny(namespace string, sources, facts []string) *MetadataProvider {76	for _, fact := range facts {77		if provider := s.serving(namespace, sources, fact); provider != nil {78			return provider79		}80	}81	return nil82}8384// What one check learned, or an empty reason for an answer that says nothing85// about the account.86type providerVerdict struct {87	reason  string88	message string89}9091// The pass checks every provider once and answers with the set the Libraries92// are reconciled against. A check that fails is reported, and the provider93// keeps the verdict it carried.94func (o *operator) checkProviders(ctx context.Context, providers []MetadataProvider, now time.Time) providerSet {95	set := providerSet{}96	for index := range providers {97		provider := &providers[index]98		set[libraryKey(provider.Metadata.Namespace, provider.Metadata.Name)] = provider99		if err := o.checkProvider(ctx, provider, now); err != nil {100			fmt.Fprintf(os.Stderr, "checking the metadata provider %s/%s: %v\n",101				provider.Metadata.Namespace, provider.Metadata.Name, err)102		}103	}104	return set105}106107// An empty verdict leaves the last condition standing. Two answers still108// produce one: a status that is neither 200 nor 401, and a Secret the API109// server would not serve.110func (o *operator) checkProvider(ctx context.Context, provider *MetadataProvider, now time.Time) error {111	verdict, err := o.reachProvider(ctx, provider)112	if verdict.reason == "" {113		return err114	}115	desired := deriveProviderStatus(provider, verdict, now)116	same, err := sameStatus(provider.Status, desired)117	if err != nil || same {118		return err119	}120	provider.Status = desired121	_, err = PutMetadataProviderStatus(ctx, o.client, provider)122	if errors.Is(err, ErrConflict) {123		// Something wrote the provider between the list and this write. The next124		// pass reads it again.125		return nil126	}127	return err128}129130// The whole status of one provider from its verdict alone. The refusal time131// stands until another refusal replaces it, so a person reads when the key132// last failed even after it works again. The facts are what the provider133// serves right now, so a provider that is not Ready reports none, and the134// list reads as what this provider can be asked for today.135func deriveProviderStatus(provider *MetadataProvider, verdict providerVerdict, now time.Time) MetadataProviderStatus {136	status := MetadataProviderStatus{137		LastRefusal: provider.Status.LastRefusal,138		Provider:    provider.block(),139	}140	if verdict.reason == reasonRefused {141		status.LastRefusal = now142	}143	condition := Condition{144		Type:               conditionReady,145		Status:             ConditionFalse,146		ObservedGeneration: provider.Metadata.Generation,147		Reason:             verdict.reason,148		Message:            verdict.message,149	}150	if verdict.reason == reasonReachable {151		condition.Status = ConditionTrue152		status.Facts = provider.servedFacts()153	}154	status.Conditions = SetCondition(slices.Clone(provider.Status.Conditions), condition, now)155	return status156}157158// What each answer means: 200 is the account working, 401 is the provider159// refusing the key, no answer at all is Unreachable, and every other status160// leaves the last verdict.161func (o *operator) reachProvider(ctx context.Context, provider *MetadataProvider) (providerVerdict, error) {162	block := provider.block()163	if block == "" {164		return providerVerdict{reason: reasonNoSecret,165			message: "the provider names no block"}, nil166	}167	// A provider that takes no key skips the Secret, because TVmaze serves its168	// free tier to anyone.169	key, verdict, err := o.providerKey(ctx, provider)170	if verdict.reason != "" || err != nil {171		return verdict, err172	}173174	status, err := o.askProvider(ctx, block, key)175	if err != nil {176		return providerVerdict{reason: reasonUnreachable, message: err.Error()}, nil177	}178	switch status {179	case http.StatusOK:180		return providerVerdict{reason: reasonReachable,181			message: "the provider answered the check call"}, nil182	case http.StatusUnauthorized:183		return providerVerdict{reason: reasonRefused,184			message: "the provider refused the key of " + block}, nil185	}186	return providerVerdict{}, fmt.Errorf("the provider answered %d", status)187}188189// The key of one provider, out of the Secret its block names. An empty key190// and an empty verdict together are a provider that needs none.191func (o *operator) providerKey(ctx context.Context, provider *MetadataProvider) (string, providerVerdict, error) {192	reference := provider.secretRef()193	if reference == nil {194		return "", providerVerdict{}, nil195	}196	secret, err := GetSecret(ctx, o.client, provider.Metadata.Namespace, reference.Name)197	if errors.Is(err, ErrNotFound) {198		return "", providerVerdict{reason: reasonNoSecret,199			message: fmt.Sprintf("the Secret %s does not exist in namespace %s",200				reference.Name, provider.Metadata.Namespace)}, nil201	}202	if err != nil {203		return "", providerVerdict{}, err204	}205	key := string(secret.Data[reference.secretKey()])206	if key == "" {207		return "", providerVerdict{reason: reasonNoSecret,208			message: fmt.Sprintf("the Secret %s holds no %s", reference.Name, reference.secretKey())}, nil209	}210	return key, providerVerdict{}, nil211}212213// The request carries a timeout of its own, so a provider that stops214// answering costs the pass its check and no more. The key travels in the form215// its shape names.216func (o *operator) askProvider(ctx context.Context, block, key string) (int, error) {217	asking, done := context.WithTimeout(ctx, providerCheckTimeout)218	defer done()219220	reach := providerReaches[block]221	request, err := http.NewRequestWithContext(asking, http.MethodGet,222		o.providerBases[block]+reach.path, nil)223	if err != nil {224		return 0, err225	}226	request.Header.Set("Accept", jsonContentType)227	if reach.authorize != nil {228		reach.authorize(request, key)229	}230231	response, err := o.providerClient.Do(request)232	if err != nil {233		return 0, err234	}235	drain(response.Body)236	return response.StatusCode, nil237}238239// The Sources condition's reasons: every named provider resolves, one does240// not exist, the provider that serves a fact is not Ready, or no named241// provider serves a fact this library needs.242const (243	conditionSources = "Sources"244245	reasonSourcesReady     = "SourcesReady"246	reasonProviderNotFound = "ProviderNotFound"247	reasonProviderNotReady = "ProviderNotReady"248	reasonFactNotServed    = "FactNotServed"249)250251// What the Library's sources resolved to, in the shape a binding takes. An252// empty reason is a Library that names no source, and that Library carries no253// Sources condition at all.254type sourcesVerdict struct {255	reason  string256	message string257}258259// The verdict on one Library's ordered sources. The facts a Library needs260// from a provider are identity alone in this plan, so a list where none261// serves identity is a list that fills no gap.262func checkSources(library *Library, providers providerSet) sourcesVerdict {263	namespace := library.Metadata.Namespace264	if len(library.Spec.Sources) == 0 {265		return sourcesVerdict{}266	}267	for _, name := range library.Spec.Sources {268		if _, held := providers[libraryKey(namespace, name)]; !held {269			return sourcesVerdict{270				reason: reasonProviderNotFound,271				message: fmt.Sprintf("the MetadataProvider %s does not exist in namespace %s",272					name, namespace),273			}274		}275	}276	if providers.serving(namespace, library.Spec.Sources, factIdentity) == nil {277		return unservedVerdict(namespace, library.Spec.Sources, providers, factIdentity)278	}279	return sourcesVerdict{280		reason:  reasonSourcesReady,281		message: "the sources serve the facts this library needs",282	}283}284285// A list that fills no gap has two reasons, because they call for two286// repairs. A provider that lists the fact and failed its check is a key or287// a Secret to repair, and a list where no provider lists the fact at all288// is a source to add. The message names the provider and the reason its own289// check wrote.290func unservedVerdict(namespace string, sources []string, providers providerSet, fact string) sourcesVerdict {291	for _, name := range sources {292		provider, held := providers[libraryKey(namespace, name)]293		if !held || !provider.serves(fact) {294			continue295		}296		return sourcesVerdict{297			reason: reasonProviderNotReady,298			message: fmt.Sprintf("the MetadataProvider %s is not Ready, with the reason %s",299				name, provider.readyReason()),300		}301	}302	return sourcesVerdict{303		reason:  reasonFactNotServed,304		message: "no source serves the " + fact + " fact",305	}306}
providerenv.go 100.0%
1package main23// The provider keys the enricher's containers read. Every container that asks4// a provider reads the same set, so the Job builder calls one function and a5// new container gains the keys with it.67import "strings"89// The variable that carries the source order into every facts container: the10// block of each Ready source the Library names, in spec.sources order,11// separated by commas. A container needs it because the two rules for who12// answers read the Library's own order, and a container holds no API13// credential to read the Library itself.14const librarySourcesVariable = "LIBRARY_SOURCES"1516// The variable one provider block's key travels in: the block name in17// capitals, then _TOKEN. TMDB_TOKEN, the one the identity fact reads, is that18// rule for tmdb.19func providerTokenVariable(block string) string {20	return strings.ToUpper(block) + "_TOKEN"21}2223// Every key the Library's sources reach, in the order spec.sources names24// them. A provider that is not Ready contributes none, because a secretKeyRef25// to a Secret that does not exist holds the pod out of Running. A provider26// that takes no key contributes none. The first account of a block wins,27// because two accounts cannot share one variable name.28func providerKeyEnv(library *Library, providers providerSet) []EnvVar {29	keys := []EnvVar{}30	held := map[string]bool{}31	for _, name := range library.Spec.Sources {32		provider, exists := providers[libraryKey(library.Metadata.Namespace, name)]33		if !exists || !provider.ready() {34			continue35		}36		block := provider.block()37		reference := provider.secretRef()38		if reference == nil || held[block] {39			continue40		}41		held[block] = true42		keys = append(keys, EnvVar{43			Name: providerTokenVariable(block),44			ValueFrom: &EnvVarSource{SecretKeyRef: &SecretKeySelector{45				Name: reference.Name,46				Key:  reference.secretKey(),47			}},48		})49	}50	return keys51}5253// The whole provider environment of a facts container: the keys, and the54// order the blocks are asked in. Both come from one walk of spec.sources, so55// the container asks in the order a person wrote.56func providerEnv(library *Library, providers providerSet) []EnvVar {57	return append(providerKeyEnv(library, providers),58		EnvVar{Name: librarySourcesVariable, Value: strings.Join(sourceBlocks(library, providers), ",")})59}6061// Which blocks reach the container: the block of every Ready source the62// Library names, in order, with the first account of a block winning, as the63// keys do. A block that takes no account is named here too, because a64// provider with no key still answers.65func sourceBlocks(library *Library, providers providerSet) []string {66	blocks := []string{}67	held := map[string]bool{}68	for _, name := range library.Spec.Sources {69		provider, exists := providers[libraryKey(library.Metadata.Namespace, name)]70		if !exists || !provider.ready() {71			continue72		}73		block := provider.block()74		if block == "" || held[block] {75			continue76		}77		held[block] = true78		blocks = append(blocks, block)79	}80	return blocks81}8283// A comma-separated list with every empty name dropped, so a trailing comma84// or a space around a name names nothing.85func commaNames(list string) []string {86	var names []string87	for _, name := range strings.Split(list, ",") {88		if name = strings.TrimSpace(name); name != "" {89			names = append(names, name)90		}91	}92	return names93}
providerhttp.go 94.2%
1package main23// What every provider client shares: one request form, the 429 cooldown rule,4// and the error an answer outside 2xx becomes. Each provider file holds its5// own address, its own auth form, and the calls it makes.67import (8	"context"9	"encoding/json"10	"errors"11	"fmt"12	"io"13	"net/http"14	"net/url"15	"strconv"16	"strings"17	"time"18)1920// The cooldown a 429 with no Retry-After header takes.21const providerCooldown = 10 * time.Second2223// How many times one request goes out, so a provider that answers 429 without24// end fails the attempt instead of holding the container.25const providerAttempts = 32627// One request's bound, so a provider that stops answering cannot hold the28// container open.29var providerRequestTimeout = 30 * time.Second3031// One answer's bound, so a provider that streams without end cannot grow the32// container.33const providerAnswerLimit = 1 << 203435// What every client is made of: the block name, which names the provider in36// an error; the address, which only a test replaces; the wait a cooldown37// takes, which a test replaces so no test sleeps; and the form the key38// travels in.39type providerRequests struct {40	provider  string41	base      string42	http      *http.Client43	wait      func(context.Context, time.Duration) error44	authorize func(*http.Request)45}4647// The requests one account makes. A provider that needs no key authorizes48// nothing.49func newProviderRequests(provider, base string, authorize func(*http.Request)) providerRequests {50	return providerRequests{51		provider:  provider,52		base:      base,53		http:      &http.Client{Timeout: providerRequestTimeout},54		wait:      waitFor,55		authorize: authorize,56	}57}5859// The wait ends on the context as well as on the clock, so a container that60// is told to stop does not sleep out its cooldown first.61func waitFor(ctx context.Context, cooldown time.Duration) error {62	timer := time.NewTimer(cooldown)63	defer timer.Stop()64	select {65	case <-ctx.Done():66		return ctx.Err()67	case <-timer.C:68		return nil69	}70}7172// The answer a provider gave outside 2xx. It is a type and not a sentence73// alone, so a caller reads the status back with errors.As and tells a title74// the provider does not hold from a key it refused.75type providerStatusError struct {76	provider string77	path     string78	status   int79	body     string80}8182func (e providerStatusError) Error() string {83	return fmt.Sprintf("%s %s: %d: %s", e.provider, e.path, e.status, e.body)84}8586// Whether this error is the provider's answer with that status.87func answeredWith(err error, status int) bool {88	answer := providerStatusError{}89	return errors.As(err, &answer) && answer.status == status90}9192// The whole retry rule: a 429 waits the header's own cooldown, or ten seconds93// where it names none, and the request goes out again.94func (r *providerRequests) get(ctx context.Context, path string, query url.Values, into any) error {95	for attempt := 1; ; attempt++ {96		status, cooldown, body, err := r.send(ctx, path, query)97		if err != nil {98			return err99		}100		if status == http.StatusTooManyRequests && attempt < providerAttempts {101			if err := r.wait(ctx, cooldown); err != nil {102				return err103			}104			continue105		}106		if status < 200 || status > 299 {107			return providerStatusError{provider: r.provider, path: path,108				status: status, body: strings.TrimSpace(string(body))}109		}110		return json.Unmarshal(body, into)111	}112}113114// The send builds the request and lets the key's own shape decide the form it115// travels in.116func (r *providerRequests) send(ctx context.Context, path string, query url.Values) (int, time.Duration, []byte, error) {117	address := r.base + path118	if len(query) > 0 {119		address += "?" + query.Encode()120	}121	request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)122	if err != nil {123		return 0, 0, nil, err124	}125	request.Header.Set("Accept", jsonContentType)126	if r.authorize != nil {127		r.authorize(request)128	}129130	response, err := r.http.Do(request)131	if err != nil {132		return 0, 0, nil, err133	}134	defer drain(response.Body)135136	body, err := io.ReadAll(io.LimitReader(response.Body, providerAnswerLimit))137	if err != nil {138		return 0, 0, nil, err139	}140	return response.StatusCode, retryAfter(response.Header.Get("Retry-After")), body, nil141}142143// One file, by the retry rule the JSON calls follow. It carries no credential144// and asks for no JSON, because the host that serves a provider's images and145// headshots is a plain file host. Its bound is above the answer's, because a146// file is larger than an answer, and the caller holds the bytes only until147// the write door has them.148const providerFileLimit = 16 << 20149150func (r *providerRequests) fetchFile(ctx context.Context, address string) ([]byte, error) {151	for attempt := 1; ; attempt++ {152		status, cooldown, body, err := r.sendFile(ctx, address)153		if err != nil {154			return nil, err155		}156		if status == http.StatusTooManyRequests && attempt < providerAttempts {157			if err := r.wait(ctx, cooldown); err != nil {158				return nil, err159			}160			continue161		}162		if status < 200 || status > 299 {163			return nil, providerStatusError{provider: r.provider, path: address, status: status}164		}165		if len(body) == 0 {166			return nil, providerStatusError{provider: r.provider, path: address,167				status: status, body: "the answer was empty"}168		}169		return body, nil170	}171}172173func (r *providerRequests) sendFile(ctx context.Context, address string) (int, time.Duration, []byte, error) {174	request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)175	if err != nil {176		return 0, 0, nil, err177	}178	response, err := r.http.Do(request)179	if err != nil {180		return 0, 0, nil, err181	}182	defer drain(response.Body)183184	body, err := io.ReadAll(io.LimitReader(response.Body, providerFileLimit))185	if err != nil {186		return 0, 0, nil, err187	}188	return response.StatusCode, retryAfter(response.Header.Get("Retry-After")), body, nil189}190191// An unreadable or absent header takes the fixed cooldown.192func retryAfter(header string) time.Duration {193	seconds, err := strconv.Atoi(strings.TrimSpace(header))194	if err != nil || seconds <= 0 {195		return providerCooldown196	}197	return time.Duration(seconds) * time.Second198}199200// A key that travels as a query parameter, which is the form OMDb and201// Fanart.tv take. The name is the parameter the provider reads it from.202func queryKey(name, key string) func(*http.Request) {203	return func(request *http.Request) {204		query := request.URL.Query()205		query.Set(name, key)206		request.URL.RawQuery = query.Encode()207	}208}
providertable.go 100.0%
1package main23// Which provider can serve which fact. A MetadataProvider that names no facts4// of its own serves everything its row here holds, so a person who wants all5// of one provider writes the block and nothing else, and a person who wants6// less narrows it with spec.facts.78import "slices"910// The table, one row per provider block, each row in the order the facts run.11// A row grows as this operator learns to ask its provider for more. A12// provider block with no row here serves nothing.13var providerFacts = map[string][]string{14	providerBlockTMDb: {15		factIdentity,16		factOverview,17		factCertification,18		factRatingTMDb,19		factCredits,20		factPoster,21		factBackdrop,22		factLogo,23		factSeasonPoster,24		factEpisodeThumb,25		factContributorIDs,26		factContributorBiography,27		factContributorHeadshot,28	},29	// OMDb answers on an IMDb id and holds the ratings of three sites, the US30	// certification, and the plot. Its credits are names with no ids, so the31	// credits fact does not read them.32	providerBlockOMDb: {33		factOverview,34		factCertification,35		factRatingIMDb,36		factRatingRottenTomatoes,37		factRatingMetacritic,38	},39	// Fanart.tv holds art alone, and it is the only provider of the clearart,40	// the banner, the landscape, the discart, and the season banner.41	providerBlockFanart: {42		factPoster,43		factBackdrop,44		factLogo,45		factClearart,46		factBanner,47		factLandscape,48		factDiscart,49		factSeasonPoster,50		factSeasonBanner,51	},52	// TVmaze holds series alone and needs no account. Its show call carries the53	// external ids, the summary and the genres, the cast, and the poster, the54	// background, and the banner.55	providerBlockTVmaze: {56		factIdentity,57		factOverview,58		factCredits,59		factPoster,60		factBackdrop,61		factBanner,62	},63}6465// The block names of the table's rows, which are the field names of66// MetadataProviderSpec.67const (68	providerBlockTMDb   = "tmdb"69	providerBlockOMDb   = "omdb"70	providerBlockFanart = "fanart"71	providerBlockTVmaze = "tvmaze"72)7374// The block this provider names. A provider that names none has no row in the75// table and serves no fact.76func (p *MetadataProvider) block() string {77	switch {78	case p.Spec.TMDb != nil:79		return providerBlockTMDb80	case p.Spec.OMDb != nil:81		return providerBlockOMDb82	case p.Spec.Fanart != nil:83		return providerBlockFanart84	case p.Spec.TVmaze != nil:85		return providerBlockTVmaze86	}87	return ""88}8990// The Secret this provider's block names, or none for a provider that takes91// no key. The operator reads it for the check, and the enricher's containers92// read it through a secretKeyRef.93func (p *MetadataProvider) secretRef() *SecretKeyRef {94	switch p.block() {95	case providerBlockTMDb:96		return &p.Spec.TMDb.SecretRef97	case providerBlockOMDb:98		return &p.Spec.OMDb.SecretRef99	case providerBlockFanart:100		return &p.Spec.Fanart.SecretRef101	}102	return nil103}104105// Every fact this provider serves: its row in the table, narrowed to the106// facts spec.facts names where it names any. The order is the table's, so two107// providers of one block report their facts in one order.108func (p *MetadataProvider) servedFacts() []string {109	table := providerFacts[p.block()]110	if len(p.Spec.Facts) == 0 {111		return slices.Clone(table)112	}113	served := []string{}114	for _, fact := range table {115		if slices.Contains(p.Spec.Facts, fact) {116			served = append(served, fact)117		}118	}119	return served120}
prune.go 98.0%
1package main23// prune.go reconciles the catalog against the volume by marking and4// sweeping, in place of the in-memory record of the last walk it replaced.5// A full walk marks every id it reads with the walk's epoch in the seen6// table, and the prune deletes the catalog rows the walk did not mark this7// epoch. So a removal survives a restart, and the scanner never holds the8// whole key set in memory.9//10// The seen table is local to the agent and never gossips. A mark on a11// replicated row would gossip to every reader on every walk, and a new12// column on a populated cr-sqlite table backfills a clock row for every13// existing row. The scanner creates seen at runtime, not in the schema14// file, because cr-sqlite makes every table a schema file names a15// replicated table. A table created through the write API stays local.1617import (18	"context"19	"fmt"20	"strings"21	"time"22)2324// pruneBatch bounds how many unmarked ids the prune reads and deletes at25// once, so the prune holds one batch and never the whole set. It is a var26// so a test drives several batches over a small set.27var pruneBatch = 5002829// pruneMinFraction is the share of the catalog's items a walk must find30// before the prune runs. A walk that finds a far smaller share than the31// catalog holds read only part of the volume, so its prune is skipped and32// the rows stand for the next clean walk. It is a var so a test drives the33// threshold.34var pruneMinFraction = 0.53536// pruneRatioFloor is the item count below which the fraction guard does37// not apply, so a small catalog is not held hostage to a noisy ratio. The38// read-error guard still applies at any size.39var pruneRatioFloor = 84041// ensureSeen creates the local seen table if it does not exist. The table42// is not in the schema file, because every table the schema file names43// becomes a replicated table that gossips. This one is created through the44// write API instead, so it stays a plain local table the agent never45// replicates.46//47// The index on epoch is what every prune query reads, because each one48// asks for the ids this epoch marked.49func (c *Catalog) ensureSeen(ctx context.Context) error {50	_, err := c.apply(ctx, []statement{51		{sql: `CREATE TABLE IF NOT EXISTS seen (id TEXT NOT NULL PRIMARY KEY, epoch INTEGER NOT NULL DEFAULT 0)`},52		{sql: `CREATE INDEX IF NOT EXISTS seen_epoch ON seen (epoch)`},53	})54	return err55}5657// The key spaces of the seen table. Four kinds of key are marked, and an58// alias can be the same string as an item's id: a title that gains a59// provider id keeps its old path-derived id as an alias of the new one. With60// one key space, that alias marks the stale item row every walk, and the61// prune never removes it, so the catalog holds the title twice. Each kind of62// key carries its own prefix, so an alias marks only aliases.63const (64	seenItem  = "item:"65	seenFile  = "file:"66	seenAlias = "alias:"67	seenLink  = "link:"68	// An attempt has a key space of its own, and its key is the item and the69	// fact joined, so a mark on an attempt never touches an item.70	seenAttempt = "attempt:"71	// The three key spaces of the people. A person keys on the directory that72	// holds them, an id on the scheme and the id joined, and a credit on the73	// title and the billing order joined.74	seenContributor      = "contributor:"75	seenContributorAlias = "contributor-alias:"76	seenCredit           = "credit:"77	// A genre keys on the title and the rank joined, in a key space of its own,78	// the way a credit does.79	seenGenre = "genre:"80)8182// The separator between a link key's two halves. A path and an item id can83// both hold most characters, so the separator is one neither ever holds, and84// no two different pairs render the same key. SQL rebuilds the identical85// string with char(31).86const linkKeySeparator = "\x1f"8788// markSeen marks every id with the current epoch. A re-mark of an id89// already present updates its epoch in place.90func (c *Catalog) markSeen(ctx context.Context, ids []string, epoch int64) (int, error) {91	statements := make([]statement, len(ids))92	for i, id := range ids {93		statements[i] = statement{94			sql:    `INSERT INTO seen (id, epoch) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET epoch = excluded.epoch`,95			params: []any{id, epoch},96		}97	}98	return c.apply(ctx, statements)99}100101// cleanSeen drops the marks behind the current epoch, so the seen table102// tracks the live catalog and not every id the scanner ever saw. Every row103// the walk kept was marked with the current epoch, so a mark behind it104// belongs to a row the prune removed.105func (c *Catalog) cleanSeen(ctx context.Context, epoch int64) (int, error) {106	return c.apply(ctx, []statement{{107		sql:    `DELETE FROM seen WHERE epoch < ?`,108		params: []any{epoch},109	}})110}111112// countItems reads how many item rows the catalog holds for this library,113// across the four item tables. The prune-abort guard reads it to tell a114// complete walk from a walk that returned far fewer rows than the catalog115// holds.116func (c *Catalog) countItems(ctx context.Context, library string) (int, error) {117	return c.queryInt(ctx, `SELECT `+118		`(SELECT count(*) FROM movies WHERE library = ?) + `+119		`(SELECT count(*) FROM series WHERE library = ?) + `+120		`(SELECT count(*) FROM episodes WHERE library = ?) + `+121		`(SELECT count(*) FROM franchises WHERE library = ?)`,122		[]any{library, library, library, library})123}124125// countSeen reads how many ids this epoch marked. The prune guard reads126// it, because an epoch that marked nothing would sweep every row the127// library holds.128func (c *Catalog) countSeen(ctx context.Context, epoch int64) (int, error) {129	return c.queryInt(ctx, `SELECT count(*) FROM seen WHERE epoch = ?`, []any{epoch})130}131132// countFiles reads how many file rows the catalog holds for this133// library. The report carries it beside the item count, so a Library's134// status shows both.135func (c *Catalog) countFiles(ctx context.Context, library string) (int, error) {136	return c.queryInt(ctx, `SELECT count(*) FROM files WHERE library = ?`, []any{library})137}138139// The two counts of one library, the pair a Job's echo compares140// against the report the standing pod publishes.141type libraryCounts struct {142	items int143	files int144}145146// Reads both counts, and fails on the first read that fails,147// because a Job that half read them has nothing to compare.148func (c *Catalog) countsOf(ctx context.Context, library string) (libraryCounts, error) {149	items, err := c.countItems(ctx, library)150	if err != nil {151		return libraryCounts{}, err152	}153	files, err := c.countFiles(ctx, library)154	if err != nil {155		return libraryCounts{}, err156	}157	return libraryCounts{items: items, files: files}, nil158}159160// markKeys reads every id, file path, link, and alias a walk produced into one161// deduplicated list, the set the walk marks with its epoch. Each key carries162// the prefix of its own key space, so an alias that reads the same as an163// item's id marks the alias and not the item.164func markKeys(result *walkResult) []string {165	seen := map[string]bool{}166	var keys []string167	add := func(space, key string) {168		if key == "" || seen[space+key] {169			return170		}171		seen[space+key] = true172		keys = append(keys, space+key)173	}174	for _, row := range result.movies {175		add(seenItem, row.Id)176	}177	for _, row := range result.sets {178		add(seenItem, row.Id)179	}180	for _, row := range result.series {181		add(seenItem, row.Id)182	}183	for _, row := range result.episodes {184		add(seenItem, row.Id)185	}186	for _, row := range result.files {187		add(seenFile, row.Path)188		for _, item := range row.Items {189			add(seenLink, row.Path+linkKeySeparator+item)190		}191	}192	for _, row := range result.aliases {193		add(seenAlias, row.Alias)194	}195	for _, row := range result.attempts {196		add(seenAttempt, attemptSeenKey(row))197	}198	for _, row := range result.contributors {199		add(seenContributor, row.Path)200	}201	for _, row := range result.contributorAliases {202		add(seenContributorAlias, contributorAliasSeenKey(row))203	}204	for _, row := range result.credits {205		add(seenCredit, creditSeenKey(row))206	}207	for _, row := range result.genres {208		add(seenGenre, genreSeenKey(row))209	}210	for _, row := range result.franchises {211		add(seenItem, row.Id)212	}213	for _, row := range result.franchiseMembers {214		add(seenFranchiseMember, franchiseMemberSeenKey(row))215	}216	for _, row := range result.franchiseRuns {217		add(seenFranchiseRun, franchiseRunSeenKey(row))218	}219	return keys220}221222// incompleteWalk reports whether a walk read only part of the volume, so223// the caller skips the prune and keeps the rows. A read error anywhere224// in the walk, at any depth, in a directory, a sidecar, or a file, is one225// signal. A walk that found far fewer items than the catalog holds is the226// other, once the catalog holds more than the ratio floor.227func incompleteWalk(readError bool, items, catalogItems int) bool {228	if readError {229		return true230	}231	if catalogItems > pruneRatioFloor && float64(items) < pruneMinFraction*float64(catalogItems) {232		return true233	}234	return false235}236237// sweep reads the unmarked ids one bounded batch at a time and deletes238// each batch, until a query returns fewer than a full batch. It holds one239// batch and never the whole set. It returns the count of rows deleted.240//241// A batch that deletes nothing while the query still answers with keys is242// a sweep that cannot end, so it stops with an error rather than spinning243// under the walk lock.244func (c *Catalog) sweep(ctx context.Context, sql string, params []any, del func(ctx context.Context, keys []string) (int, error)) (int, error) {245	removed := 0246	for {247		keys, err := c.queryStrings(ctx, sql, params)248		if err != nil {249			return removed, err250		}251		if len(keys) == 0 {252			return removed, nil253		}254		deleted, err := del(ctx, keys)255		if err != nil {256			return removed, err257		}258		if deleted == 0 {259			return removed, fmt.Errorf("the sweep deleted none of the %d keys it read", len(keys))260		}261		removed += len(keys)262		if len(keys) < pruneBatch {263			return removed, nil264		}265	}266}267268// pruneLibrary deletes every catalog row this library holds that the269// current epoch did not mark. It reads the unmarked ids through the query270// API and deletes them by key, the form that needs no delete-time join271// against the local seen table. It returns the count of rows removed.272//273// Every table carries the library, so each sweep scopes itself, and the274// order below is free. It runs the aliases, then the items, then the275// links, then the files.276func pruneLibrary(ctx context.Context, catalog *Catalog, library string, epoch int64) (int, error) {277	removed := 0278279	// Every sweep below deletes what this epoch did not mark, so an280	// epoch with no marks at all would delete the whole library. The walk281	// wrote its marks before this prune; an epoch with none is a mark282	// write that did not land, and the rows stand for the next walk.283	marks, err := catalog.countSeen(ctx, epoch)284	if err != nil {285		return removed, err286	}287	if marks == 0 {288		return removed, fmt.Errorf("the walk marked no keys with epoch %d", epoch)289	}290291	n, err := catalog.sweep(ctx, itemPruneSQL("aliases", "alias", seenAlias), []any{library, epoch, pruneBatch},292		func(ctx context.Context, keys []string) (int, error) {293			return catalog.DeleteAliases(ctx, library, keys)294		})295	if err != nil {296		return removed, err297	}298	removed += n299300	for _, table := range []struct {301		name   string302		delete func(context.Context, string, []string) (int, error)303	}{304		{"movies", catalog.DeleteMovies},305		{"sets", catalog.DeleteSets},306		{"series", catalog.DeleteSeries},307		{"episodes", catalog.DeleteEpisodes},308		{"franchises", catalog.DeleteFranchises},309	} {310		n, err := catalog.sweep(ctx, itemPruneSQL(table.name, "id", seenItem), []any{library, epoch, pruneBatch},311			func(ctx context.Context, keys []string) (int, error) {312				return table.delete(ctx, library, keys)313			})314		if err != nil {315			return removed, err316		}317		removed += n318	}319320	n, err = catalog.sweep(ctx, linkPruneSQL(), []any{library, epoch, pruneBatch},321		func(ctx context.Context, keys []string) (int, error) {322			return catalog.DeleteFileItems(ctx, library, fileItemKeys(keys))323		})324	if err != nil {325		return removed, err326	}327	removed += n328329	n, err = catalog.sweep(ctx, filePruneSQL(), []any{library, epoch, walkStart(epoch), pruneBatch},330		func(ctx context.Context, keys []string) (int, error) {331			return catalog.DeleteFiles(ctx, library, keys)332		})333	if err != nil {334		return removed, err335	}336	removed += n337338	n, err = catalog.sweep(ctx, attemptPruneSQL(), []any{library, epoch, walkStart(epoch), pruneBatch},339		func(ctx context.Context, keys []string) (int, error) {340			return catalog.DeleteAttempts(ctx, library, attemptKeys(keys))341		})342	if err != nil {343		return removed, err344	}345	removed += n346347	// The people, swept the way every other table is. The credits of a title that348	// left the volume are unmarked with it, and a person whose directory left the349	// store leaves with their ids.350	n, err = catalog.sweep(ctx, creditPruneSQL(), []any{library, epoch, pruneBatch},351		func(ctx context.Context, keys []string) (int, error) {352			return catalog.DeleteCredits(ctx, library, creditKeys(keys))353		})354	if err != nil {355		return removed, err356	}357	removed += n358359	n, err = catalog.sweep(ctx, contributorAliasPruneSQL(), []any{library, epoch, pruneBatch},360		func(ctx context.Context, keys []string) (int, error) {361			return catalog.DeleteContributorAliases(ctx, library, contributorAliasKeys(keys))362		})363	if err != nil {364		return removed, err365	}366	removed += n367368	// The genres of a title that left the volume are unmarked with it, and a369	// sidecar that lists fewer genres than before leaves its higher ranks370	// unmarked.371	n, err = catalog.sweep(ctx, genrePruneSQL(), []any{library, epoch, pruneBatch},372		func(ctx context.Context, keys []string) (int, error) {373			return catalog.DeleteGenres(ctx, library, genreKeys(keys))374		})375	if err != nil {376		return removed, err377	}378	removed += n379380	n, err = catalog.sweep(ctx, itemPruneSQL("contributors", "path", seenContributor),381		[]any{library, epoch, pruneBatch},382		func(ctx context.Context, keys []string) (int, error) {383			return catalog.DeleteContributors(ctx, library, keys)384		})385	if err != nil {386		return removed, err387	}388	removed += n389390	// The members and the runs of a franchise that left the repository.391	// They sweep after the franchises row, because each keys on the392	// franchise and the position and never on the row the sweep above393	// took.394	n, err = catalog.sweep(ctx, franchiseMemberPruneSQL(), []any{library, epoch, pruneBatch},395		func(ctx context.Context, keys []string) (int, error) {396			return catalog.DeleteFranchiseMembers(ctx, library, franchiseMemberKeys(keys))397		})398	if err != nil {399		return removed, err400	}401	removed += n402403	n, err = catalog.sweep(ctx, franchiseRunPruneSQL(), []any{library, epoch, pruneBatch},404		func(ctx context.Context, keys []string) (int, error) {405			return catalog.DeleteFranchiseRuns(ctx, library, franchiseRunKeys(keys))406		})407	if err != nil {408		return removed, err409	}410	removed += n411412	if _, err := catalog.cleanSeen(ctx, epoch); err != nil {413		return removed, err414	}415	return removed, nil416}417418// fileItemKeys splits each composite key the link sweep read back into the419// file path and the item id, so the delete names both columns of the row.420func fileItemKeys(keys []string) []fileItemKey {421	links := make([]fileItemKey, len(keys))422	for i, key := range keys {423		path, item, _ := strings.Cut(key, linkKeySeparator)424		links[i] = fileItemKey{Path: path, Item: item}425	}426	return links427}428429// linkPruneSQL reads the links this library holds that the current430// epoch did not mark, one bounded batch. A link row carries its own431// library, so the read needs no join to files. It reads the two columns432// joined by the same separator the mark used, so the comparison is one433// string against one string.434func linkPruneSQL() string {435	return `SELECT path || char(31) || item FROM file_items` +436		` WHERE library = ?` +437		` AND '` + seenLink + `' || path || char(31) || item` +438		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +439		` LIMIT ?`440}441442// itemPruneSQL reads the keys of a table this library holds that the443// current epoch did not mark, one bounded batch. table, key, and space are444// constants this package names and never input, so naming them in the SQL445// text carries no injection.446func itemPruneSQL(table, key, space string) string {447	return `SELECT ` + key + ` FROM ` + table +448		` WHERE library = ? AND '` + space + `' || ` + key +449		` NOT IN (SELECT id FROM seen WHERE epoch = ?) LIMIT ?`450}451452// pathScopeClause matches the rows of one title folder: the item at the453// folder's own path, and every file and episode under it. It uses a range454// over the path rather than a LIKE, so a folder name that holds a LIKE455// metacharacter still scopes correctly and needs no escape.456func pathScopeClause(column string) string {457	return `(` + column + ` = ? OR (` + column + ` >= ? AND ` + column + ` < ?))`458}459460// pathScopeParams renders the three bounds pathScopeClause reads: the461// folder's own path, and the half-open range that holds every path under462// it. The upper bound is the folder path with the byte after the463// separator, so it stops at the end of the folder's children.464func pathScopeParams(folder string) []any {465	return []any{folder, folder + "/", folder + "0"}466}467468// pruneScope deletes the rows of one title folder that the current epoch469// did not mark, the reconciliation a webhook rescan drives. A folder still470// on the volume keeps the rows the rescan re-read; a folder that left the471// volume marks nothing, so every one of its rows is unmarked and leaves.472// It reads no seen marks behind the epoch, because a full walk owns that473// cleanup. It returns the count of rows removed.474func pruneScope(ctx context.Context, catalog *Catalog, library, folder string, epoch int64) (int, error) {475	removed := 0476477	// The alias and attempt sweeps run before the item sweeps. Each of them478	// scopes itself through the item it names, so those item rows must still479	// stand when these sweeps read them.480	n, err := catalog.sweep(ctx, scopedAliasPruneSQL(), scopedAliasPruneParams(library, folder, epoch),481		func(ctx context.Context, keys []string) (int, error) {482			return catalog.DeleteAliases(ctx, library, keys)483		})484	if err != nil {485		return removed, err486	}487	removed += n488489	n, err = catalog.sweep(ctx, scopedAttemptPruneSQL(), scopedAttemptPruneParams(library, folder, epoch),490		func(ctx context.Context, keys []string) (int, error) {491			return catalog.DeleteAttempts(ctx, library, attemptKeys(keys))492		})493	if err != nil {494		return removed, err495	}496	removed += n497498	// The genre sweep scopes itself through the folder's title row, so it runs499	// here, before the item sweeps, like the two above it.500	n, err = catalog.sweep(ctx, scopedGenrePruneSQL(), scopedGenrePruneParams(library, folder, epoch),501		func(ctx context.Context, keys []string) (int, error) {502			return catalog.DeleteGenres(ctx, library, genreKeys(keys))503		})504	if err != nil {505		return removed, err506	}507	removed += n508509	// The credits of the folder's title, swept the way its genres are. A510	// sidecar that lists fewer people than before leaves its higher511	// billings unmarked, and a title that left the volume leaves every512	// credit it held.513	n, err = catalog.sweep(ctx, scopedCreditPruneSQL(), scopedCreditPruneParams(library, folder, epoch),514		func(ctx context.Context, keys []string) (int, error) {515			return catalog.DeleteCredits(ctx, library, creditKeys(keys))516		})517	if err != nil {518		return removed, err519	}520	removed += n521522	for _, table := range []struct {523		name   string524		delete func(context.Context, string, []string) (int, error)525	}{526		{"movies", catalog.DeleteMovies},527		{"series", catalog.DeleteSeries},528		{"episodes", catalog.DeleteEpisodes},529	} {530		n, err := catalog.sweep(ctx, scopedItemPruneSQL(table.name, "id", seenItem), scopedItemPruneParams(library, folder, epoch),531			func(ctx context.Context, keys []string) (int, error) {532				return table.delete(ctx, library, keys)533			})534		if err != nil {535			return removed, err536		}537		removed += n538	}539540	n, err = catalog.sweep(ctx, scopedLinkPruneSQL(), scopedItemPruneParams(library, folder, epoch),541		func(ctx context.Context, keys []string) (int, error) {542			return catalog.DeleteFileItems(ctx, library, fileItemKeys(keys))543		})544	if err != nil {545		return removed, err546	}547	removed += n548549	n, err = catalog.sweep(ctx, scopedFilePruneSQL(), append(scopedItemPruneParams(library, folder, epoch)[:5], walkStart(epoch), pruneBatch),550		func(ctx context.Context, keys []string) (int, error) {551			return catalog.DeleteFiles(ctx, library, keys)552		})553	if err != nil {554		return removed, err555	}556	removed += n557	return removed, nil558}559560// scopedLinkPruneSQL reads the links under one folder that the current561// epoch did not mark, one bounded batch. A link row carries both the562// library and the file path, so the scope reads the link table alone,563// with the same parameters the scoped item sweeps take.564func scopedLinkPruneSQL() string {565	return `SELECT path || char(31) || item FROM file_items` +566		` WHERE library = ? AND ` + pathScopeClause("path") +567		` AND '` + seenLink + `' || path || char(31) || item` +568		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +569		` LIMIT ?`570}571572// A walk's epoch is its start in nanoseconds, and a row a fact writes after573// that start carries no mark from the walk. The sweep spares a file whose574// modified time, and an attempt whose time, is past the start, so a poster575// written while the walk ran survives to the next walk, which marks it.576func walkStart(epoch int64) int64 {577	return epoch / int64(time.Second)578}579580func filePruneSQL() string {581	return `SELECT path FROM files` +582		` WHERE library = ? AND '` + seenFile + `' || path` +583		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +584		` AND modified < ? LIMIT ?`585}586587func scopedFilePruneSQL() string {588	return `SELECT path FROM files` +589		` WHERE library = ? AND ` + pathScopeClause("path") + ` AND '` + seenFile + `' || path` +590		` NOT IN (SELECT id FROM seen WHERE epoch = ?)` +591		` AND modified < ? LIMIT ?`592}593594func scopedItemPruneSQL(table, key, space string) string {595	return `SELECT ` + key + ` FROM ` + table +596		` WHERE library = ? AND ` + pathScopeClause("path") + ` AND '` + space + `' || ` + key +597		` NOT IN (SELECT id FROM seen WHERE epoch = ?) LIMIT ?`598}599600func scopedItemPruneParams(library, folder string, epoch int64) []any {601	params := []any{library}602	params = append(params, pathScopeParams(folder)...)603	return append(params, epoch, pruneBatch)604}605606// scopedAliasPruneSQL reads the aliases of one folder's items that the607// current epoch did not mark. The alias row carries the library, so the608// library scopes it directly, and the item tables only narrow it to the609// folder. Each item subquery matches the library as well as the id,610// because an id names one row only inside its own library.611func scopedAliasPruneSQL() string {612	scope := func(table string) string {613		return `SELECT id FROM ` + table + ` WHERE library = ? AND ` + pathScopeClause("path")614	}615	return `SELECT alias FROM aliases` +616		` WHERE library = ?` +617		` AND '` + seenAlias + `' || alias NOT IN (SELECT id FROM seen WHERE epoch = ?)` +618		` AND item IN (` +619		scope("movies") + ` UNION ` + scope("series") + ` UNION ` + scope("episodes") +620		`) LIMIT ?`621}622623func scopedAliasPruneParams(library, folder string, epoch int64) []any {624	params := []any{library, epoch}625	for range 3 {626		params = append(params, library)627		params = append(params, pathScopeParams(folder)...)628	}629	return append(params, pruneBatch)630}
reconcile.go 88.9%
1package main23// One pass over one Library: resolve the storage it names, stand4// the schedule its full walk runs on, create a Job for every webhook5// path it holds, and write what all of it says into its status.6//7// The pass also creates the Job of the first full walk, for a Library8// that no scan has run against yet, so a new Library has rows before9// its CronJob's first turn.10//11// The order is the order of the conditions. A Library that names a12// claim nothing has bound has no volume to mount, so it gets no13// schedule, and the Bound condition alone says why. Only a bound14// Library reaches the CronJob.1516import (17	"context"18	"encoding/json"19	"errors"20	"fmt"21	"hash/fnv"22	"slices"23	"strconv"24	"time"25)2627// Binding is what a Library's storage resolved to: the volume behind28// the claim, and the reason and message the Bound condition carries. A29// binding with no volume is a Library that cannot be scanned, and the30// reason names which of the three ways it failed.31type binding struct {32	volume  *LibraryVolume33	reason  string34	message string35}3637// Reconcile brings one Library into line and reports on it. It reads38// the whole state every pass rather than acting on what an event39// carried, so the same facts reach the same status whatever order the40// events arrived in.41//42// The catalog is a precondition beside the storage. A Library stands a43// schedule only when its storage is bound and its namespace holds44// exactly one Catalog, because every scan Job's catalog agent joins the45// cluster the Catalog stands and takes a volume the Catalog sizes.46func (o *operator) reconcile(ctx context.Context, library *Library, choice catalogChoice,47	jobs []Job, providers providerSet, now time.Time) error {48	if err := o.holdLibrary(ctx, library); err != nil {49		return err50	}5152	bound, err := resolveStorage(ctx, o.client, library)53	if err != nil {54		return err55	}5657	namespace, name := library.Metadata.Namespace, library.Metadata.Name58	report := o.reports.latestFor(namespace, name)5960	// A Library with no volume, or in a namespace with no single61	// Catalog, gets no schedule. There would be nothing to mount, or no62	// cluster to join, and the Ready condition says which.63	var cronJob *CronJob64	if libraryStands(bound, choice) {65		if err := o.standCatalogClaim(ctx, library, choice.catalog); err != nil {66			return err67		}68		cronJob, err = o.standScanCronJob(ctx, library)69		if err != nil {70			return err71		}72		o.holdFirstWalk(library, report, jobs)73		if err := o.serveHeldPaths(ctx, library, jobs, now); err != nil {74			return err75		}76		if err := o.enrich(ctx, library, choice.catalog, report, jobs, providers); err != nil {77			return err78		}79	} else if err := o.stopScanCronJob(ctx, library); err != nil {80		return err81	}8283	return writeLibraryStatus(ctx, o.client, library, deriveLibraryStatus(library, libraryObservation{84		bound:             bound,85		choice:            choice,86		cronJob:           cronJob,87		report:            report,88		sources:           checkSources(library, providers),89		online:            o.reporters.onlineFor(namespace),90		operatorNamespace: o.namespace,91	}, now))92}9394// holdFirstWalk gives a new Library its first full walk. A Library the95// reporter carries no scan run for, and that has no unfinished scan Job,96// holds the empty path, which is the full walk, so serveHeldPaths97// creates the Job on this same pass. Without this the library would98// hold no rows until the CronJob's first turn, up to an hour later.99//100// The rule fires once. The Job the pass created is unfinished until101// the controller marks it Complete or Failed, which covers the pod it102// has not created yet and the backoff between its pods. By then the103// walk's started runs row has reached the report, and the report104// carries a scan run for the rest of the Library's life.105func (o *operator) holdFirstWalk(library *Library, report *libraryReport, jobs []Job) {106	namespace, name := library.Metadata.Namespace, library.Metadata.Name107	if report != nil {108		if _, ran := runOf(report.Runs, workerScan); ran {109			return110		}111	}112	if scanUnfinished(jobs, namespace, name) {113		return114	}115	o.paths.hold(namespace, name, "")116}117118// HoldLibrary puts the finalizer on a Library that does not carry119// it, so a later delete waits for the departure in depart.go instead120// of taking the rows' only sweeper with the object. A Library from121// before this operator held finalizers adopts one here on its next122// pass. The patch produces a new resourceVersion, and the copy123// carries it forward so the status write later in this pass states124// the version the server now holds.125func (o *operator) holdLibrary(ctx context.Context, library *Library) error {126	if library.Metadata.holds(libraryFinalizer) && !library.Metadata.holds(formerLibraryFinalizer) {127		return nil128	}129	// The former name goes in the same patch that puts the current130	// one on, so a Library from the release that named it swaps in131	// one write.132	finalizers := library.Metadata.without(formerLibraryFinalizer)133	if !slices.Contains(finalizers, libraryFinalizer) {134		finalizers = append(finalizers, libraryFinalizer)135	}136	version, err := PatchLibraryFinalizers(ctx, o.client, library.Metadata.Namespace,137		library.Metadata.Name, library.Metadata.ResourceVersion, finalizers)138	if errors.Is(err, ErrConflict) {139		// A write between the list and this patch wakes the140		// libraries watch, and the next pass patches again.141		return nil142	}143	if err != nil {144		return err145	}146	library.Metadata.Finalizers = finalizers147	library.Metadata.ResourceVersion = version148	return nil149}150151// LibraryStands reports the one condition a Library's scans need: its152// storage is bound, and its namespace holds exactly one Catalog. The pass153// reads it to create the objects, and the status derivation reads it to154// report the webhook address, so the two cannot answer differently.155func libraryStands(bound binding, choice catalogChoice) bool {156	return bound.volume != nil && choice.catalog != nil157}158159// ResolveStorage reads the claim a Library names and the volume behind160// it. Every answer that is the cluster's own state is a binding rather161// than a failure: a claim a person has not created, a claim still162// waiting on a volume, and a volume that has gone are all states to163// report. Only a request that fails is an error, because then the pass164// does not know what the storage is.165func resolveStorage(ctx context.Context, c *Client, library *Library) (binding, error) {166	namespace, name := library.Metadata.Namespace, library.Spec.Storage.Claim167168	claim, err := GetPersistentVolumeClaim(ctx, c, namespace, name)169	if errors.Is(err, ErrNotFound) {170		return binding{171			reason: reasonClaimNotFound,172			message: fmt.Sprintf("the PersistentVolumeClaim %s does not exist in namespace %s",173				name, namespace),174		}, nil175	}176	if err != nil {177		return binding{}, fmt.Errorf("reading the claim %s: %w", name, err)178	}179180	// The binder writes volumeName, so a claim is usable only once it181	// carries both the Bound phase and a volume to read.182	if claim.Status.Phase != claimBound || claim.Spec.VolumeName == "" {183		return binding{184			reason:  reasonClaimUnbound,185			message: fmt.Sprintf("the PersistentVolumeClaim %s is %s", name, claimState(claim)),186		}, nil187	}188189	volume, err := GetPersistentVolume(ctx, c, claim.Spec.VolumeName)190	if errors.Is(err, ErrNotFound) {191		return binding{192			reason: reasonVolumeNotFound,193			message: fmt.Sprintf("the PersistentVolume %s the claim %s names does not exist",194				claim.Spec.VolumeName, name),195		}, nil196	}197	if err != nil {198		return binding{}, fmt.Errorf("reading the volume %s: %w", claim.Spec.VolumeName, err)199	}200201	return binding{202		volume: libraryVolume(volume),203		reason: reasonBound,204		message: fmt.Sprintf("the claim %s is bound to the PersistentVolume %s",205			name, volume.Metadata.Name),206	}, nil207}208209// ClaimState names what a claim is doing, for the message the Bound210// condition carries. Pending is a claim no volume has answered, and211// Lost is a claim whose volume has gone.212func claimState(claim *PersistentVolumeClaim) string {213	if claim.Status.Phase == "" {214		return "not bound to a volume"215	}216	return claim.Status.Phase217}218219// LibraryVolume reports what serves the storage. The type is the name220// of the volume's own source key, so a cluster that serves its movies221// through a driver this operator knows nothing about still reports222// which one. The NFS pair is filled for an NFS volume alone, because223// a media reference over NFS is built from the server and the export.224func libraryVolume(volume *PersistentVolume) *LibraryVolume {225	reported := &LibraryVolume{Name: volume.Metadata.Name, Type: volume.Spec.Source}226	if volume.Spec.NFS != nil {227		reported.Server = volume.Spec.NFS.Server228		reported.Path = volume.Spec.NFS.Path229	}230	return reported231}232233// StandPod brings the cluster into line with the pod a pass built, and234// returns the pod that stands after it: the live pod when it matches the235// template, the created pod when there was none, and nil when this pass236// deleted a stale one or another writer created it first. Every pod this237// operator stands and rebuilds goes through here: a Catalog's catalog pod238// and a Player's screen pod both.239//240// A Deployment finds a stale pod by stamping a hash of the241// template it built and comparing that hash, never by comparing live242// specs, because the API server defaults fields the builder never set and a243// live comparison would either roll on every pass or grow a244// field-by-field allowlist. This operator does the same with one245// annotation on the pod it creates.246func (o *operator) standPod(ctx context.Context, desired *Pod) (*Pod, error) {247	if err := stampTemplateHash(&desired.Metadata, desired.Spec); err != nil {248		return nil, err249	}250	namespace, name := desired.Metadata.Namespace, desired.Metadata.Name251252	live, err := GetPod(ctx, o.client, namespace, name)253	if errors.Is(err, ErrNotFound) {254		created, err := CreatePod(ctx, o.client, desired)255		if errors.Is(err, ErrConflict) {256			// Another pass, or another copy of this operator, created257			// the pod first, which is success. The next pass reads it.258			return nil, nil259		}260		if err != nil {261			return nil, err262		}263		return created, nil264	}265	if err != nil {266		return nil, err267	}268269	// A pod on its way out counts as still present. The delete this270	// operator sent is in progress, and the pass leaves it alone until271	// it completes, so one divergence causes one delete and not one272	// delete per pass.273	//274	// this wait is also the ReadWriteOnce handoff for the catalog pod,275	// whose claim admits one pod at a time: the pass creates the276	// replacement only after the old pod releases the claim, which is the277	// create on the not-found branch above.278	//279	// A screen pod's catalog claim is ReadWriteOnce as well, so the280	// same wait is its handoff: the replacement mounts the claim only after281	// the pod before it has released it.282	if live.Metadata.DeletionTimestamp != "" {283		return live, nil284	}285286	// A live pod stamped with a different hash is stale, and so is one287	// carrying no stamp at all. The delete is the whole replacement:288	// the next pass finds no pod and creates the one it built.289	if !sameTemplate(&live.Metadata, &desired.Metadata) {290		if err := DeletePod(ctx, o.client, namespace, name); err != nil {291			return nil, err292		}293		return nil, nil294	}295	return live, nil296}297298// TemplateHash reduces one built spec to the string the annotation299// carries. fnv-1a is enough, because the whole job is to tell one300// pass's output from another's. Nothing signs the value and nothing301// outside this operator reads it, so the hash needs no collision302// resistance against an attacker.303//304// The input is the spec alone and never the metadata, so the305// annotation is not part of what it hashes and a stamped pod hashes to306// the same value as the pod before the stamp.307func templateHash(spec any) (string, error) {308	body, err := json.Marshal(spec)309	if err != nil {310		return "", err311	}312	sum := fnv.New64a()313	// A hash never fails a write, so the error is the interface's and314	// not a state this code can reach.315	_, _ = sum.Write(body)316	return strconv.FormatUint(sum.Sum64(), 16), nil317}318319// StampTemplateHash writes the hash of one built spec onto the object320// that carries it. The caller hands in the metadata and the spec of321// the same object, and the stamp is what a later pass compares322// against.323func stampTemplateHash(metadata *ObjectMeta, spec any) error {324	hash, err := templateHash(spec)325	if err != nil {326		return err327	}328	if metadata.Annotations == nil {329		metadata.Annotations = map[string]string{}330	}331	metadata.Annotations[templateHashAnnotation] = hash332	return nil333}334335// SameTemplate reports whether a live pod carries the hash the pass336// just stamped on the pod it built. An absent annotation reads as an337// empty string, which never equals a hash, so a pod created by338// anything but this operator counts as diverged.339func sameTemplate(live, desired *ObjectMeta) bool {340	return live.Annotations[templateHashAnnotation] == desired.Annotations[templateHashAnnotation]341}
report.go 100.0%
1package main23// The report desk is the boundary between the bus and the4// reconcile loop; the operator subscribes to every Library's status5// topic, the bus handler folds each message in here, and the reconcile6// pass reads the newest report per Library and writes it into that7// Library's status. The catalog pod holds no API credentials, so this8// desk is the only path a report takes to the control plane.910import (11	"slices"12	"sync"13	"time"14)1516// What the namespace's reporter says about one library: how many17// titles the catalog holds, how many folders no sidecar identified, when18// the last walk ended and the last change landed, and the run of every19// worker. The reporter publishes it retained, so the broker holds the20// current counts for a subscriber that arrives later.21type libraryReport struct {22	Titles       int       `json:"titles"`23	Unidentified int       `json:"unidentified"`24	LastWalk     time.Time `json:"lastWalk"`25	LastChange   time.Time `json:"lastChange"`26	// Items and Files are the catalog's own counts after the last walk27	// pruned: the item rows and the file rows it holds for this library.28	// The operator folds them into Library status.29	Items int `json:"items"`30	Files int `json:"files"`31	// True while a scan Job runs, which the reporter reads off the32	// scan run whose start is later than its finish, so the operator's33	// phase follows the walk.34	Walking bool `json:"walking"`35	// The count of rows the last full sweep removed, so a mass delete that a36	// partial walk caused is visible on the bus without a shell. The operator37	// folds it into Library status.38	RemovedLastSweep int `json:"removedLastSweep"`39	// One entry per worker that has run against this library, sorted40	// by worker, each naming the Job that ran and what it left. A Job waits41	// for its own entry here before it exits.42	Runs []libraryRun `json:"runs,omitempty"`43	// One count per fact of the rows that fact has left to fill,44	// from gapQueries. The operator creates the enricher Job when any45	// count is above zero, so a fact with no key here never runs.46	Gaps map[string]int `json:"gaps,omitempty"`47	// The oldest attempt this library holds for each fact. The operator48	// reads it against the Library's spec.refresh: a refresh later than49	// the oldest attempt is a fact with work left, whatever the gap50	// count says, because the reporter counts with no refresh.51	OldestAttempts map[string]time.Time `json:"oldestAttempts,omitempty"`52	// Waiting is the titles whose identity ended in candidates for a53	// person to choose from, and Unresolved the titles no provider could54	// name. Both are folded into Library status.55	Waiting    int `json:"waiting"`56	Unresolved int `json:"unresolved"`57	// The count of titles a fact left because another writer holds the element58	// group it writes. The operator folds it into Library status.59	Fights int `json:"fights"`60}6162// reports holds the newest report per Library and the wake the loop63// reads. One mutex covers the map, because the bus handler runs on64// the bus reader's goroutine and the loop runs on its own.65type reports struct {66	mutex  sync.Mutex67	latest map[string]libraryReport68	wake   chan<- struct{}69}7071func newReports(wake chan<- struct{}) *reports {72	return &reports{73		latest: map[string]libraryReport{},74		wake:   wake,75	}76}7778// libraryKey is the one key shape for a Library. Namespace and name79// identify a Library everywhere in this operator, and one shape keeps80// the desk and the reconcile pass in step.81func libraryKey(namespace, name string) string {82	return namespace + "/" + name83}8485// fold records the newest report for a Library and wakes the loop. A86// report is a whole observation, so the newest one says everything an87// older one did.88//89// Every fold wakes the loop. A scanner publishes when it finishes a90// walk and when it applies a change, and neither happens often, so91// there is nothing here to throttle: a report that arrives is a report92// worth writing into the resource at once.93func (r *reports) fold(namespace, name string, report libraryReport) {94	key := libraryKey(namespace, name)95	r.mutex.Lock()96	r.latest[key] = report97	r.mutex.Unlock()98	r.poke()99}100101// latestFor returns the newest report, the only one kept, or nil when102// the desk holds none. A Library with no report is one whose scanner103// has not finished a walk yet, and the operator says so in the104// Library's conditions.105func (r *reports) latestFor(namespace, name string) *libraryReport {106	r.mutex.Lock()107	defer r.mutex.Unlock()108	report, held := r.latest[libraryKey(namespace, name)]109	if !held {110		return nil111	}112	return &report113}114115// retain drops everything the desk holds for a deleted Library. The116// pass hands over the set of Libraries that still exist, and the map117// shrinks to match, so the desk never serves a report for a Library the118// collection no longer holds and a Library created later under the same119// name starts with none.120//121// retain answers with the keys it dropped, because desk state for a122// Library the collection does not hold is a retained message still123// standing on the bus, and the pass is the only reader that holds124// the whole Library list, so it is the one that clears those topics.125// The keys come back sorted, so a pass clears them in one order and126// a broker log reads the same way every time.127func (r *reports) retain(live map[string]bool) []string {128	r.mutex.Lock()129	defer r.mutex.Unlock()130	keys := []string{}131	for key := range r.latest {132		if !live[key] {133			delete(r.latest, key)134			keys = append(keys, key)135		}136	}137	slices.Sort(keys)138	return keys139}140141// poke never blocks, and the wake channel buffers exactly one. A wake142// already queued says everything a second one would say, because the143// pass that answers it reads the whole collection.144func (r *reports) poke() {145	select {146	case r.wake <- struct{}{}:147	default:148	}149}
reporter.go 95.1%
1package main23// The reporter is the container beside the standing catalog agent. It4// is the one process in the namespace that reads the catalog and5// publishes what it holds: one retained report per library, rebuilt6// whenever the runs table changes and while any replicated table7// keeps changing. It holds no Kubernetes credentials,8// it answers on no port, and it never exits on its own, because every9// Job in the namespace waits on this process to echo its run.1011import (12	"context"13	"encoding/json"14	"fmt"15	"io"16	"net/http"17	"os"18	"os/signal"19	"slices"20	"strings"21	"sync"22	"syscall"23	"time"24)2526// The argument that selects this role, the way scanMode selects the27// scanner. The operator writes it over the image's entrypoint.28const reportMode = "report"2930// reportReadTimeout bounds one read of the catalog, so a stuck agent31// cannot hold the reporter in a query forever.32var reportReadTimeout = 30 * time.Second3334// The wait before the reporter opens the run stream again after it35// ends. It doubles up to the ceiling, so an agent that is down does not36// become a tight loop. Variables, so a test drives a reconnect in37// milliseconds.38var (39	reportMinBackoff = time.Second40	reportMaxBackoff = 30 * time.Second41)4243// How long a run of changes waits before the reporter44// republishes again, so a walk that writes thousands of rows costs one45// report a second and not one report a row. A variable, so a test46// drives a republish in milliseconds.47var reportDebounce = time.Second4849// How long the reporter holds the bus open after it publishes the50// closing offline, so the writer goroutine sends it before the process51// exits. A variable, so a test drives a shutdown in milliseconds.52var reportFlushGrace = 500 * time.Millisecond5354// One reporter: the namespace it serves, the catalog it reads, the bus55// it publishes on, and the report it last published per library.56type reporter struct {57	namespace         string58	topicBase         string59	availabilityTopic string60	catalog           *Catalog61	bus               *Bus62	log               io.Writer6364	mutex     sync.Mutex65	published map[string]libraryReport66}6768// runReport is the report role's whole program: read the environment,69// publish until the kubelet stops the container, and mark the reporter70// offline on the way out.71func runReport() {72	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)73	defer stop()7475	newReporter(os.Stdout).serve(stopped)76}7778// newReporter reads the namespace, the bus, and the agent's address79// out of the container's environment, the only place a pod with no API80// credential learns them.81func newReporter(log io.Writer) *reporter {82	namespace := os.Getenv(libraryNamespaceVariable)83	base := os.Getenv(topicBaseVariable)84	if base == "" {85		base = defaultTopicBase86	}87	api := os.Getenv(catalogAPIVariable)88	if api == "" {89		api = defaultCatalogAPI90	}9192	fmt.Fprintf(log, "library.liken.sh: reporting the catalog of %s from %s\n", namespace, api)9394	report := &reporter{95		namespace:         namespace,96		topicBase:         base,97		availabilityTopic: catalogAvailabilityTopic(base, namespace),98		// No client timeout, because the run stream stays open for the99		// life of the pod. Every read bounds itself with a context instead.100		catalog:   NewCatalog(api, &http.Client{}),101		log:       log,102		published: map[string]libraryReport{},103	}104	report.bus = newBus(os.Getenv(busAddressVariable), "catalog-"+namespace,105		&busWill{Topic: report.availabilityTopic, Payload: []byte(availabilityOffline), Retained: true},106		report.onConnect, nil)107	return report108}109110// Serve holds the bus, the run stream, and one update stream111// per replicated table open until the context ends, then marks this112// reporter offline and returns. The bus runs on a context of its own,113// so the closing publish has a live connection to go out on.114func (r *reporter) serve(stopped context.Context) {115	running, stopBus := context.WithCancel(context.Background())116	done := make(chan struct{})117	go func() {118		defer close(done)119		r.bus.Run(running)120	}()121122	// The runs stream carries the values a report needs. The update123	// streams carry only the fact that a row moved, so they mark a124	// change, and the republish reads the catalog.125	changed := make(chan struct{}, 1)126	var streams sync.WaitGroup127	for _, table := range catalogTables {128		streams.Add(1)129		go func() {130			defer streams.Done()131			r.followTable(stopped, table, changed)132		}()133	}134	streams.Add(1)135	go func() {136		defer streams.Done()137		r.republishWhileChanging(stopped, changed)138	}()139140	r.follow(stopped)141	streams.Wait()142143	r.bus.Publish(r.availabilityTopic, []byte(availabilityOffline), true)144	time.Sleep(reportFlushGrace)145	stopBus()146	<-done147}148149// follow publishes every library the catalog holds, then follows the150// runs table until the stream ends, and starts over after a backoff.151// Nothing the catalog answers ends this loop. Only the context does.152func (r *reporter) follow(ctx context.Context) {153	backoff := reportMinBackoff154	for ctx.Err() == nil {155		r.publishEveryLibrary(ctx)156157		reached := false158		err := r.catalog.subscribeRuns(ctx, func() { reached = true }, func(library string) {159			r.publishLibrary(ctx, library)160		})161		if err != nil && ctx.Err() == nil {162			r.logf("the run stream ended: %v", err)163		}164		if reached {165			backoff = reportMinBackoff166		}167168		select {169		case <-ctx.Done():170			return171		case <-time.After(backoff):172		}173		if !reached {174			backoff = min(backoff*2, reportMaxBackoff)175		}176	}177}178179// Follows one table's update stream and marks a change on every180// event, opening the stream again after a backoff for as long as the181// context runs. The stream's events name no library, so the mark says182// only that something moved.183func (r *reporter) followTable(ctx context.Context, table string, changed chan<- struct{}) {184	backoff := reportMinBackoff185	for ctx.Err() == nil {186		opened := false187		err := r.catalog.followUpdates(ctx, table,188			func() { opened = true },189			func() { markChanged(changed) })190		if err != nil && ctx.Err() == nil {191			r.logf("the update stream of %s ended: %v", table, err)192		}193		// The events between one stream and the next are gone, so194		// a stream that ended marks a change and the republish reads what195		// the catalog holds now.196		markChanged(changed)197		if opened {198			backoff = reportMinBackoff199		}200201		select {202		case <-ctx.Done():203			return204		case <-time.After(backoff):205		}206		if !opened {207			backoff = min(backoff*2, reportMaxBackoff)208		}209	}210}211212// Republishes every library the reporter knows on each marked213// change, then holds off for the debounce, so a run of changes costs214// one report per library per interval and one more after they stop.215func (r *reporter) republishWhileChanging(ctx context.Context, changed <-chan struct{}) {216	for {217		select {218		case <-ctx.Done():219			return220		case <-changed:221		}222223		r.publishKnownLibraries(ctx)224225		select {226		case <-ctx.Done():227			return228		case <-time.After(reportDebounce):229		}230	}231}232233// The mark never blocks and never queues more than one, because234// the republish that answers it reads every library whole.235func markChanged(changed chan<- struct{}) {236	select {237	case changed <- struct{}{}:238	default:239	}240}241242// Publishes a report for every library the reporter has already243// published one for, which is every library the catalog held when the244// reporter started and every library a run has named since.245func (r *reporter) publishKnownLibraries(ctx context.Context) {246	r.mutex.Lock()247	libraries := make([]string, 0, len(r.published))248	for library := range r.published {249		libraries = append(libraries, library)250	}251	r.mutex.Unlock()252253	slices.Sort(libraries)254	for _, library := range libraries {255		r.publishLibrary(ctx, library)256	}257}258259// publishEveryLibrary publishes one report for every library the260// catalog holds rows for. This is how a reporter that has just started261// fills the broker before the first run lands.262func (r *reporter) publishEveryLibrary(ctx context.Context) {263	read, cancel := context.WithTimeout(ctx, reportReadTimeout)264	defer cancel()265266	libraries, err := r.catalog.LibraryKeys(read)267	if err != nil {268		r.logf("could not read the libraries the catalog holds: %v", err)269		return270	}271	for _, library := range libraries {272		r.publishLibrary(ctx, library)273	}274}275276// publishLibrary builds one library's report from the catalog and277// publishes it retained. A read that fails leaves the retained report278// where it is, so an agent that stops answering never reads as an279// empty library.280func (r *reporter) publishLibrary(ctx context.Context, library string) {281	namespace, name, ok := splitLibraryKey(library)282	if !ok {283		return284	}285	read, cancel := context.WithTimeout(ctx, reportReadTimeout)286	defer cancel()287288	report, err := r.buildReport(read, library)289	if err != nil {290		r.logf("could not build the report of %s: %v", library, err)291		return292	}293	r.mutex.Lock()294	r.published[library] = report295	r.mutex.Unlock()296297	payload, _ := json.Marshal(report)298	r.bus.Publish(libraryStatusTopic(r.topicBase, namespace, name), payload, true)299}300301// buildReport reads one library's counts, runs, and gaps. The scan run is302// what says when the volume was last walked, how many folders the walk303// could not identify, how many rows its prune took, and whether a walk304// runs now, which is a scan run that started after it last finished. The305// gaps come from the same queries the enricher containers work from, so306// the count the operator schedules on is the count of rows a container307// finds.308func (r *reporter) buildReport(ctx context.Context, library string) (libraryReport, error) {309	runs, err := r.catalog.Runs(ctx)310	if err != nil {311		return libraryReport{}, err312	}313	titles, err := r.catalog.countTitles(ctx, library)314	if err != nil {315		return libraryReport{}, err316	}317	items, err := r.catalog.countItems(ctx, library)318	if err != nil {319		return libraryReport{}, err320	}321	files, err := r.catalog.countFiles(ctx, library)322	if err != nil {323		return libraryReport{}, err324	}325326	report := libraryReport{Titles: titles, Items: items, Files: files, Runs: runs[library]}327	if walk, held := runOf(report.Runs, workerScan); held {328		report.LastWalk = walk.Finished329		report.Unidentified = walk.Unidentified330		report.RemovedLastSweep = walk.Removed331		report.Walking = walk.Started.After(walk.Finished)332	}333	report.LastChange = r.lastChange(library, report)334335	gaps, err := r.catalog.gapCounts(ctx, library, time.Now().UTC())336	if err != nil {337		return libraryReport{}, err338	}339	report.Gaps = gaps340	report.OldestAttempts, err = r.catalog.oldestAttempts(ctx, library)341	if err != nil {342		return libraryReport{}, err343	}344	report.Waiting, report.Unresolved, err = r.catalog.identityCounts(ctx, library)345	if err != nil {346		return libraryReport{}, err347	}348	report.Fights, err = r.catalog.fightCount(ctx, library)349	if err != nil {350		return libraryReport{}, err351	}352	return report, nil353}354355// lastChange is the time this library's counts last moved. The356// reporter compares each report with the one it published before, so a357// report that counts the same rows carries the same time. A reporter358// that has just started has no earlier report, and takes the last walk.359func (r *reporter) lastChange(library string, report libraryReport) time.Time {360	r.mutex.Lock()361	defer r.mutex.Unlock()362	previous, held := r.published[library]363	if !held {364		return report.LastWalk365	}366	if previous.Titles == report.Titles && previous.Items == report.Items && previous.Files == report.Files {367		return previous.LastChange368	}369	return time.Now().UTC()370}371372// onConnect refills the broker the moment a session connects, because373// a broker that restarts drops its retained messages.374func (r *reporter) onConnect(bus *Bus) {375	bus.Publish(r.availabilityTopic, []byte(availabilityOnline), true)376	r.mutex.Lock()377	held := make(map[string]libraryReport, len(r.published))378	for library, report := range r.published {379		held[library] = report380	}381	r.mutex.Unlock()382	for library, report := range held {383		namespace, name, ok := splitLibraryKey(library)384		if !ok {385			continue386		}387		payload, _ := json.Marshal(report)388		bus.Publish(libraryStatusTopic(r.topicBase, namespace, name), payload, true)389	}390}391392// splitLibraryKey reads a library key back into the namespace and the393// name that libraryKey joined.394func splitLibraryKey(library string) (namespace, name string, ok bool) {395	namespace, name, found := strings.Cut(library, "/")396	if !found || namespace == "" || name == "" {397		return "", "", false398	}399	return namespace, name, true400}401402// logf writes one line under the shared prefix, or nothing when the403// reporter was built without a log.404func (r *reporter) logf(format string, args ...any) {405	if r.log == nil {406		return407	}408	fmt.Fprintf(r.log, "library.liken.sh: "+format+"\n", args...)409}
rows.go 98.0%
1package main23// rows.go holds the catalog's three row kinds as Go values, and the pure4// functions that derive an item's identity. The scanner fills a movieRow,5// a seriesRow, or an episodeRow, a fileRow for each physical file, and the6// aliasRow values that roll an item's several names onto one id, then hands7// them to the catalog write client. Nothing here reaches the network. An8// item's id is a function of what the volume already holds, so a re-walk9// derives the same id and the scanner mints none.1011import (12	"fmt"13	"strconv"14	"strings"15)1617// The id scopes, the word that leads every canonical id. The scope is the18// singular of the kind, so a movies library holds movie:... items. The19// scope namespaces the id, so a movie and a series that carry the same20// provider's numeric id do not collide.21const (22	scopeMovie   = "movie"23	scopeSeries  = "series"24	scopeEpisode = "episode"25)2627// The provider preference per scope. itemID takes the first provider present28// in this order, so one title resolves to one canonical id whichever databases29// its sidecar names. The order leads with the database the project trusts most.30var providerOrder = map[string][]string{31	scopeMovie:  {"tmdb", "imdb"},32	scopeSeries: {"tvdb", "tmdb", "imdb"},33}3435// The sources an alias row records: a provider id read from the sidecar, or36// the folder name the scanner fell back to. The source says how the name was37// learned, so a later pass tells a durable provider id from a guessed folder.38const (39	aliasSourceProvider = "provider"40	aliasSourceFolder   = "folder"41)4243// castMember is one credited person and the part they played.44type castMember struct {45	Name string `json:"name,omitempty"`46	Role string `json:"role,omitempty"`47}4849// movieBody is what movie.nfo holds beyond the shared header, stored in the50// item's body column as JSON.51type movieBody struct {52	Plot          string            `json:"plot,omitempty"`53	Tagline       string            `json:"tagline,omitempty"`54	Cast          []castMember      `json:"cast,omitempty"`55	Directors     []string          `json:"directors,omitempty"`56	Writers       []string          `json:"writers,omitempty"`57	Studios       []string          `json:"studios,omitempty"`58	Genres        []string          `json:"genres,omitempty"`59	Collection    string            `json:"collection,omitempty"`60	ProviderIDs   map[string]string `json:"providerIds,omitempty"`61	Country       string            `json:"country,omitempty"`62	ContentRating string            `json:"contentRating,omitempty"`63	// The sidecar's ratings block, each site's own name against its64	// score on that site's scale.65	Ratings map[string]float64 `json:"ratings,omitempty"`66}6768// seriesBody is what tvshow.nfo holds beyond the shared header. A series69// credits its creators where a movie credits its directors and writers.70type seriesBody struct {71	Plot          string            `json:"plot,omitempty"`72	Tagline       string            `json:"tagline,omitempty"`73	Cast          []castMember      `json:"cast,omitempty"`74	Creators      []string          `json:"creators,omitempty"`75	Studios       []string          `json:"studios,omitempty"`76	Genres        []string          `json:"genres,omitempty"`77	ProviderIDs   map[string]string `json:"providerIds,omitempty"`78	Country       string            `json:"country,omitempty"`79	ContentRating string            `json:"contentRating,omitempty"`80	// The sidecar's ratings block, the same shape a movie carries.81	Ratings map[string]float64 `json:"ratings,omitempty"`82}8384// episodeBody is what an episode .nfo holds beyond the shared header.85type episodeBody struct {86	Plot        string            `json:"plot,omitempty"`87	Directors   []string          `json:"directors,omitempty"`88	Writers     []string          `json:"writers,omitempty"`89	Cast        []castMember      `json:"cast,omitempty"`90	ProviderIDs map[string]string `json:"providerIds,omitempty"`91}9293// movieRow is one row of the movies item table: the header columns every94// kind sorts on, the movie body, and the id of the set the movie belongs95// to, empty where the sidecar names no set.96type movieRow struct {97	Id       string98	Library  string99	Kind     string100	Path     string101	Title    string102	SortKey  string103	Slug     string104	Released string105	Added    int64106	Art      string107	Arts     []string108	Duration int64109	Body     movieBody110	SetID    string111	// The nfo_facts column: the nfo facts the title's sidecar already answers,112	// each name wrapped in commas. The gap query of each nfo fact reads it.113	NFOFacts string114}115116// seriesRow is one row of the series item table, the same header as a117// movieRow with the series body.118type seriesRow struct {119	Id       string120	Library  string121	Kind     string122	Path     string123	Title    string124	SortKey  string125	Slug     string126	Released string127	Added    int64128	Art      string129	Arts     []string130	Duration int64131	Body     seriesBody132	// The nfo_facts column, the same list a movie carries.133	NFOFacts string134}135136// episodeRow is one row of the episodes item table: the shared header, the137// episode body, and the three columns that place the episode under its series.138type episodeRow struct {139	Id       string140	Library  string141	Kind     string142	Path     string143	Title    string144	SortKey  string145	Slug     string146	Released string147	Added    int64148	Art      string149	Arts     []string150	Duration int64151	Body     episodeBody152	Series   string153	Season   int154	Episode  int155}156157// fileRow is one physical file and the item ids it belongs to. Items drives158// the many-to-many file_items link: one file names more than one item where159// a file holds two episodes, and one item holds more than one file where a160// title has a second encoding.161//162// Type, Role, and Language are the classification in files.go, read off the163// file's name and the directory that holds it. Modified is the time the one164// stat that read the size also read.165type fileRow struct {166	Path       string167	Library    string168	Container  string169	VideoCodec string170	AudioCodec string171	Width      int172	Height     int173	SizeBytes  int64174	DurationMs int64175	Trickplay  string176	Present    bool177	Type       string178	Role       string179	Language   string180	Modified   int64181	// The time the arrival ledger holds for the file, and zero where the ledger182	// holds no entry, which is the arrival fact's gap.183	Arrived int64184	Items   []string185}186187// fileItemKey names one row of the link table: the file's path, and the id of188// the item it belongs to.189type fileItemKey struct {190	Path string191	Item string192}193194// aliasRow maps one of an item's names to the item, with the source of the195// name.196//197// The alias table keys on the library and the alias together, so two198// libraries that read the same name each keep their own row, and neither199// overwrites the other.200type aliasRow struct {201	Alias   string202	Library string203	Item    string204	Source  string205}206207// itemID derives the provider-scoped canonical id. It takes the first provider208// present in the scope's fixed order and mints none, so a re-walk of an209// unchanged sidecar derives the same id, movie:tmdb:603. A folder with no210// provider id falls back to movie:path:<key>, which a move of that folder211// breaks. That is the weak case the design accepts, in place of a minted id212// the derived catalog has nowhere to keep.213func itemID(kind string, providerIDs map[string]string, folderKey string) string {214	for _, provider := range providerOrder[kind] {215		if value := providerIDs[provider]; value != "" {216			return kind + ":" + provider + ":" + value217		}218	}219	return kind + ":path:" + folderKey220}221222// episodeID reuses the series' provider tail under the episode scope, with a223// zero-padded season and episode, so series:tvdb:81189 yields224// episode:tvdb:81189:s02e05. A path-fallback series id carries its tail225// through unchanged, so a sidecar-less series still gives its episodes a stable226// id for as long as its folder holds.227func episodeID(seriesID string, season, episode int) string {228	_, tail, _ := strings.Cut(seriesID, ":")229	return fmt.Sprintf("%s:%s:s%02de%02d", scopeEpisode, tail, season, episode)230}231232// aliasesFor rolls every name an item carries onto its canonical id: one row233// per provider id, one for the folder key, and the canonical id itself. The234// canonical is its own alias, so alias resolution is one lookup and needs no235// special case for the id a caller already holds.236func aliasesFor(library, kind string, providerIDs map[string]string, folderKey, canonicalID string) []aliasRow {237	var rows []aliasRow238	seen := map[string]bool{}239	add := func(alias, source string) {240		if alias == "" || seen[alias] {241			return242		}243		seen[alias] = true244		rows = append(rows, aliasRow{Alias: alias, Library: library, Item: canonicalID, Source: source})245	}246	for _, provider := range providerOrder[kind] {247		if value := providerIDs[provider]; value != "" {248			add(kind+":"+provider+":"+value, aliasSourceProvider)249		}250	}251	if folderKey != "" {252		add(kind+":path:"+folderKey, aliasSourceFolder)253	}254	if _, held := seen[canonicalID]; !held {255		add(canonicalID, aliasSourceProvider)256	}257	return rows258}259260// sortKey strips a leading article so a list sorts on the first word that261// carries meaning, and "The Matrix" files under M. This is the opposite of the262// slug, which keeps the article.263func sortKey(title string) string {264	for _, article := range []string{"The ", "A ", "An "} {265		if len(title) >= len(article) && strings.EqualFold(title[:len(article)], article) {266			return title[len(article):]267		}268	}269	return title270}271272// slug is the legible display name for a URL and a screen, such as273// the-matrix-1999. It lowercases the title, folds accents to ASCII, keeps274// the article, hyphenates the rest, and appends the year where there is one.275// The slug is a display name and not the item's id, so a corrected title276// changes it freely. What does key on a name is folderKey, the slug of a277// folder's own name, which a title with no provider id rests its id on.278func slug(title string, year int) string {279	var b strings.Builder280	// A run of separators becomes at most one hyphen, and only between two kept281	// tokens, so the slug carries no leading or trailing hyphen.282	pendingHyphen := false283	write := func(s string) {284		if pendingHyphen && b.Len() > 0 {285			b.WriteByte('-')286		}287		pendingHyphen = false288		b.WriteString(s)289	}290	for _, r := range strings.ToLower(title) {291		switch {292		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':293			write(string(r))294		default:295			if folded, ok := accentFold[r]; ok {296				write(folded)297				continue298			}299			pendingHyphen = true300		}301	}302	out := b.String()303	if year > 0 {304		if out == "" {305			return strconv.Itoa(year)306		}307		return out + "-" + strconv.Itoa(year)308	}309	return out310}311312// accentFold maps the common accented Latin letters to their ASCII base, so313// slug folds an accent with no Unicode-table dependency in the image.314var accentFold = func() map[rune]string {315	folds := map[string]string{316		"a": "àáâãäåāăą",317		"c": "çćĉċč",318		"d": "ďđ",319		"e": "èéêëēĕėęě",320		"g": "ĝğġģ",321		"i": "ìíîïĩīĭįı",322		"l": "ĺļľŀł",323		"n": "ñńņňʼn",324		"o": "òóôõöøōŏő",325		"r": "ŕŗř",326		"s": "śŝşš",327		"t": "ţťŧ",328		"u": "ùúûüũūŭůűų",329		"y": "ýÿŷ",330		"z": "źżž",331	}332	m := map[rune]string{'ß': "ss", 'æ': "ae", 'œ': "oe"}333	for base, accented := range folds {334		for _, r := range accented {335			m[r] = base336		}337	}338	return m339}()
runs.go 100.0%
1package main23// The runs table is how a Job proves its rows reached the standing4// catalog. A Corrosion agent drops the broadcasts it has not sent when5// it receives SIGTERM, and its peers fill a gap only by pulling from6// the agent that holds the rows. So a Job's agent must not exit until7// the standing pod holds what the Job wrote. Every worker writes one8// runs row per library as its last catalog write, the standing pod's9// reporter publishes that row back in the library's report, and the10// Job exits only once it reads its own name there.11//12// The last write is not the last row to arrive, because the13// agent applies a version as it arrives and fills the gaps behind it14// by pulling from the source, so the report has to carry the Job's own15// counts as well as its run.1617import (18	"context"19	"encoding/json"20	"fmt"21	"io"22	"os"23	"slices"24	"strings"25	"sync"26	"time"27)2829// The workers that write a runs row today. The word is the row's30// second key column, so one library holds one row per worker.31//32// A folder scan is its own worker, because it reads one folder33// and its counts do not describe the whole volume, so its row must34// never overwrite the full walk's row.35const (36	workerScan    = "scan"37	workerRescan  = "rescan"38	workerCleanup = "cleanup"39)4041// The environment every Job container carries: the Job's own name,42// which its runs row carries so the echo can name it, and how long it43// waits for the echo before it fails.44const (45	jobNameVariable     = "JOB_NAME"46	echoTimeoutVariable = "ECHO_TIMEOUT"47)4849// The wait a Job gives the echo when the environment names none. The50// echo arrives within the update stream's latency in the normal case,51// and two minutes covers a catalog pod that is restarting.52const defaultEchoTimeout = 2 * time.Minute5354// One worker's last run of one library, as the runs table holds it55// and as the reporter publishes it. Finished is zero while the run is56// in progress. Unidentified and Removed are the scan worker's counts,57// and zero for every other worker.58type libraryRun struct {59	Worker       string    `json:"worker"`60	Job          string    `json:"job"`61	Started      time.Time `json:"started"`62	Finished     time.Time `json:"finished,omitempty"`63	Unidentified int       `json:"unidentified,omitempty"`64	Removed      int       `json:"removed,omitempty"`65	// Failure is why that run failed, and empty for a run that finished66	// its work. status.phase reads Failed while the scan run carries one.67	Failure string `json:"failure,omitempty"`68}6970// echoTimeout reads the echo wait out of the environment. An empty,71// unreadable, or negative value takes the default rather than failing72// the Job, because the wait is a bound and not a fact about the volume.73func echoTimeout(raw string) time.Duration {74	if raw == "" {75		return defaultEchoTimeout76	}77	timeout, err := time.ParseDuration(raw)78	if err != nil || timeout <= 0 {79		return defaultEchoTimeout80	}81	return timeout82}8384// echoBusAddress reads the broker address every Job role needs. A Job85// with no broker can never hear the echo that ends its wait, so a role86// refuses to start on an empty address, rather than write the catalog87// and hold the claim for the whole timeout. The one log line names the88// variable the pod is missing.89func echoBusAddress(log io.Writer) (string, error) {90	address := os.Getenv(busAddressVariable)91	if address == "" {92		fmt.Fprintf(log, "library.liken.sh: %s is empty, and a job with no broker cannot hear its echo\n",93			busAddressVariable)94		return "", fmt.Errorf("%s names no broker", busAddressVariable)95	}96	return address, nil97}9899// The read of the whole runs table. It needs no LIMIT, because the100// table holds one row per library and worker.101const runsQuery = `SELECT library, worker, job, started, finished, unidentified, removed, failure FROM runs`102103// The column the reporter reads out of a runs change to learn which104// library's report to publish again.105const runsLibraryColumn = "library"106107// UpsertRun writes one worker's run of one library in place. The conflict108// target is the whole primary key, and the update names no key column,109// because cr-sqlite reads a change to a key column as a delete and a110// create.111func (c *Catalog) UpsertRun(ctx context.Context, library string, run libraryRun) error {112	_, err := c.apply(ctx, []statement{{113		sql: `INSERT INTO runs (library, worker, job, started, finished, unidentified, removed, failure) ` +114			`VALUES (?, ?, ?, ?, ?, ?, ?, ?) ` +115			`ON CONFLICT (library, worker) DO UPDATE SET ` +116			`job = excluded.job, started = excluded.started, finished = excluded.finished, ` +117			`unidentified = excluded.unidentified, removed = excluded.removed, ` +118			`failure = excluded.failure`,119		params: []any{library, run.Worker, run.Job, runSeconds(run.Started), runSeconds(run.Finished),120			run.Unidentified, run.Removed, run.Failure},121	}})122	return err123}124125// DeleteRuns takes every worker's row for one library. The runs table126// holds one row per worker, so this is a handful of rows and never the127// batch a table of items needs.128func (c *Catalog) DeleteRuns(ctx context.Context, library string) (int, error) {129	return c.apply(ctx, []statement{{130		sql:    `DELETE FROM runs WHERE library = ?`,131		params: []any{library},132	}})133}134135// Runs reads every run the catalog holds, keyed by library, each136// library's runs sorted by worker.137func (c *Catalog) Runs(ctx context.Context) (map[string][]libraryRun, error) {138	held := map[string][]libraryRun{}139	err := c.stream(ctx, runsQuery, nil, func(cells []any) error {140		library, run, ok := decodeRun(cells)141		if !ok {142			return nil143		}144		held[library] = append(held[library], run)145		return nil146	})147	for _, runs := range held {148		slices.SortFunc(runs, func(a, b libraryRun) int {149			return strings.Compare(a.Worker, b.Worker)150		})151	}152	return held, err153}154155// subscribeRuns follows the runs table for the life of the context.156// It names the library of every row the opening snapshot holds and of157// every change after it, and the reporter publishes that library's158// report again on each one.159func (c *Catalog) subscribeRuns(ctx context.Context, onReady func(), onLibrary func(library string)) error {160	return c.subscribe(ctx, runsQuery, onReady, func(columns []string, cells []any) {161		cell, held := cellNamed(columns, cells, runsLibraryColumn)162		if !held {163			return164		}165		if library, ok := cell.(string); ok && library != "" {166			onLibrary(library)167		}168	})169}170171// decodeRun reads one runs row out of the cells the query streams, in172// the column order runsQuery names.173func decodeRun(cells []any) (string, libraryRun, bool) {174	if len(cells) < 8 {175		return "", libraryRun{}, false176	}177	library, ok := cells[0].(string)178	if !ok {179		return "", libraryRun{}, false180	}181	worker, _ := cells[1].(string)182	job, _ := cells[2].(string)183	failure, _ := cells[7].(string)184	return library, libraryRun{185		Worker:       worker,186		Job:          job,187		Started:      runTime(cellNumber(cells[3])),188		Finished:     runTime(cellNumber(cells[4])),189		Unidentified: int(cellNumber(cells[5])),190		Removed:      int(cellNumber(cells[6])),191		Failure:      failure,192	}, true193}194195// A SQLite integer arrives from the API as a JSON number, so every196// count and every time reads back through float64.197func cellNumber(cell any) int64 {198	number, _ := cell.(float64)199	return int64(number)200}201202// The runs table holds Unix seconds. A run that has not finished holds203// zero rather than a time, and runTime reads zero back as the zero time.204func runSeconds(at time.Time) int64 {205	if at.IsZero() {206		return 0207	}208	return at.Unix()209}210211func runTime(seconds int64) time.Time {212	if seconds == 0 {213		return time.Time{}214	}215	return time.Unix(seconds, 0).UTC()216}217218// runOf reads one worker's run out of a library's runs.219func runOf(runs []libraryRun, worker string) (libraryRun, bool) {220	for _, run := range runs {221		if run.Worker == worker {222			return run, true223		}224	}225	return libraryRun{}, false226}227228// echoWaiter is one Job's wait for the report that names its own run.229// The report comes from the standing catalog pod, so a report that230// names this Job proves that pod holds every row the Job wrote.231type echoWaiter struct {232	topic  string233	worker string234	job    string235	// The counts the report has to carry beside the run, or nil236	// where the Job could not read them and waits on the run alone.237	counts *echoCounts238239	once   sync.Once240	echoed chan struct{}241}242243// What the Job's own agent held for this library after the Job's244// last write, which the standing pod's report has to match.245type echoCounts struct {246	items int247	files int248}249250func newEchoWaiter(topic, worker, job string) *echoWaiter {251	return &echoWaiter{topic: topic, worker: worker, job: job, echoed: make(chan struct{})}252}253254// Sets the counts the echo has to carry. It is called before the255// wait starts, which is before the bus handler can read them.256func (w *echoWaiter) expect(items, files int) {257	w.counts = &echoCounts{items: items, files: files}258}259260// note is the bus handler. A report on this library's topic whose run261// for this worker names this Job, with a finish time on it and the262// counts the Job expects, ends the wait. Every other message is263// ignored.264//265// The run row can reach the standing pod before the rows the Job266// wrote before it, because the agent applies a version when it arrives267// and fills the gaps behind it by pulling from the source. The counts268// are what say the gaps are filled.269func (w *echoWaiter) note(topic string, payload []byte) {270	if topic != w.topic {271		return272	}273	var report libraryReport274	if json.Unmarshal(payload, &report) != nil {275		return276	}277	run, held := runOf(report.Runs, w.worker)278	if !held || run.Job != w.job || run.Finished.IsZero() {279		return280	}281	if w.counts != nil && (report.Items != w.counts.items || report.Files != w.counts.files) {282		return283	}284	w.once.Do(func() { close(w.echoed) })285}286287// wait runs the bus, subscribes, and waits for the echo. The wait is288// bounded by the timeout and by the context, so a Job that never hears289// an echo fails and Kubernetes retries it, rather than holding the290// claim forever.291func (w *echoWaiter) wait(ctx context.Context, bus *Bus, timeout time.Duration) error {292	running, stop := context.WithCancel(ctx)293	done := make(chan struct{})294	go func() {295		defer close(done)296		bus.Run(running)297	}()298	defer func() {299		stop()300		<-done301	}()302303	bus.Subscribe(w.topic)304305	timer := time.NewTimer(timeout)306	defer timer.Stop()307	select {308	case <-w.echoed:309		return nil310	case <-timer.C:311		return fmt.Errorf("the catalog did not report the %s run of %s within %s", w.worker, w.job, timeout)312	case <-ctx.Done():313		return ctx.Err()314	}315}
scan.go 91.0%
1package main23// The scanner is the container of one scan Job. It walks one4// library's volume once, beside a Corrosion agent of its own on the5// Library's claim, and it holds no Kubernetes credentials. It writes the6// catalog only through that agent's transaction API, on the pod's7// loopback. It publishes no report: it writes a runs row first and last,8// and waits for the namespace's reporter to publish that row back before9// it exits, because an agent drops unsent broadcasts on SIGTERM.10//11// A Job walks the whole root, or the one folder SCAN_PATH names,12// which is the path a webhook reported. It does not use inotify, which13// fires only for writes made through the same kernel and never for14// another client's writes to a network volume.1516import (17	"context"18	"encoding/json"19	"errors"20	"fmt"21	"io"22	"iter"23	"net/http"24	"os"25	"os/signal"26	"path"27	"path/filepath"28	"strings"29	"sync"30	"syscall"31	"time"32)3334// scanMode is the argument that selects this role. The operator writes35// it into the scanner container's command, over the image's36// entrypoint, so one image serves the operator and every scanner.37const scanMode = "scan"3839// The environment the operator writes into the scanner container. The40// scanner learns which Library it serves from these alone, because the41// pod carries no API credential to look one up with.42const (43	libraryNamespaceVariable = "LIBRARY_NAMESPACE"44	libraryNameVariable      = "LIBRARY_NAME"45	libraryKindVariable      = "LIBRARY_KIND"46	libraryRootVariable      = "LIBRARY_ROOT"47	busAddressVariable       = "LIBRARY_BUS_ADDRESS"48	topicBaseVariable        = "LIBRARY_TOPIC_BASE"49	catalogAPIVariable       = "LIBRARY_CATALOG_API"50	libraryIgnoreVariable    = "LIBRARY_IGNORE"51	// LIBRARY_ART is where the art claim is mounted, for a franchises52	// scanner alone. Every other kind reads it empty and downloads nothing.53	libraryArtVariable = "LIBRARY_ART"54)5556// The one folder a scan Job rescans, in the form the webhook57// handler maps onto the volume. An empty value is a full walk.58const scanPathVariable = "SCAN_PATH"5960// ignoreSet is the folder names the walk skips, and the test for one. A61// folder whose name is in the set, and everything under it, is left out62// of the walk. A nil set skips nothing.63type ignoreSet map[string]bool6465func (s ignoreSet) skips(name string) bool {66	return s[name]67}6869// parseIgnore reads the ignore list the operator JSON-encodes into the70// environment. A single JSON value carries a folder name of any71// character, and an empty or unreadable value is an empty set.72func parseIgnore(raw string) ignoreSet {73	set := ignoreSet{}74	if raw == "" {75		return set76	}77	var names []string78	if err := json.Unmarshal([]byte(raw), &names); err != nil {79		return set80	}81	for _, name := range names {82		set[name] = true83	}84	return set85}8687// The catalog API address the scanner posts to when the environment88// names none. The agent binds it on the pod's loopback, so the scanner89// and its agent share one address.90const defaultCatalogAPI = "http://127.0.0.1:8080"9192// libraryMountPath is where the operator mounts the Library's claim in93// the scanner container. The root from the Library's spec is a path94// inside that mount, so the volume's own layout is what the spec names95// and the mount point is this operator's choice.96const libraryMountPath = "/library"9798// artMountPath is where the operator mounts a franchises Job's art claim,99// beside the read-only storage claim. A franchise's directory in the100// checkout and its art directory on the art claim carry the same name.101const artMountPath = "/art"102103// catalogWriteTimeout bounds a walk's writes to the catalog agent, so a104// stuck agent cannot hold a walk open forever.105var catalogWriteTimeout = 2 * time.Minute106107// One scan Job's scanner: the root it walks, the catalog it108// writes, the run it records, and the echo it waits for. The walk's own109// counts are held under a mutex, because the walk and the run row that110// reads them are written in different steps.111type scanner struct {112	statusTopic string113	root        string114	library     string115	kind        string116	ignore      ignoreSet117	// art is the mount a franchises scan writes its art into, and empty118	// for every other kind.119	art     string120	catalog *Catalog121	bus     *Bus122	echo    *echoWaiter123	// The Job this container runs, the folder it rescans, and how124	// long it waits for the reporter to publish its run back.125	job         string126	scanPath    string127	echoTimeout time.Duration128	// log is where the scanner writes a walk that could not finish a catalog129	// step, so a swallowed error shows in the pod log instead of a gap in130	// the report. A scanner built without one writes nowhere.131	log io.Writer132133	// What the walk read, which the run row carries and nothing134	// publishes.135	mutex  sync.Mutex136	report libraryReport137	// What this Job's own agent held for the library when the138	// walk ended, and whether the walk could read it.139	counts     libraryCounts140	countsRead bool141142	// One walk runs at a time, so the reconciliation reads a143	// settled catalog whichever caller drives the walk.144	walkMutex sync.Mutex145}146147// The scan role's whole program: read the environment, run the148// one walk, and end the process with what the Job left. A failure is a149// non-zero exit, so the Job fails and Kubernetes retries it.150func runScan() {151	// The kernel runs no default action for a signal sent to PID 1, and152	// the scanner is its container's PID 1. The signal context is what153	// ends the wait below, on the kubelet's SIGTERM or on the interrupt154	// a person who runs the binary by hand sends.155	stopped, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)156	defer stop()157158	scan, err := newScanner(time.Now().UTC(), os.Stdout)159	if err != nil {160		stop()161		os.Exit(1)162	}163	if err := scan.runJob(stopped); err != nil {164		scan.logf("the scan job failed: %v", err)165		stop()166		os.Exit(1)167	}168}169170// NewScanner reads the container's environment and builds the171// client that speaks for this Library. started is the time the walk's own172// record carries before it reads anything.173//174// It refuses to build a scanner when the environment names no broker,175// before anything is written.176func newScanner(started time.Time, log io.Writer) (*scanner, error) {177	address, err := echoBusAddress(log)178	if err != nil {179		return nil, err180	}181	namespace := os.Getenv(libraryNamespaceVariable)182	name := os.Getenv(libraryNameVariable)183	kind := os.Getenv(libraryKindVariable)184	base := os.Getenv(topicBaseVariable)185	if base == "" {186		base = defaultTopicBase187	}188	root := os.Getenv(libraryRootVariable)189	if root == "" {190		root = "/"191	}192	api := os.Getenv(catalogAPIVariable)193	if api == "" {194		api = defaultCatalogAPI195	}196	ignore := parseIgnore(os.Getenv(libraryIgnoreVariable))197	mountRoot := path.Join(libraryMountPath, root)198199	// One line in the pod's log says what this container was given, so200	// a person who reads the pod sees the same wiring the Library201	// declares.202	fmt.Fprintf(log, "library.liken.sh: %s/%s is a %s library at %s\n",203		namespace, name, kind, mountRoot)204205	scan := &scanner{206		statusTopic: libraryStatusTopic(base, namespace, name),207		root:        mountRoot,208		library:     libraryKey(namespace, name),209		kind:        kind,210		ignore:      ignore,211		art:         os.Getenv(libraryArtVariable),212		catalog:     NewCatalog(api, &http.Client{Timeout: catalogWriteTimeout}),213		log:         log,214		report:      libraryReport{LastWalk: started, LastChange: started},215		job:         os.Getenv(jobNameVariable),216		scanPath:    os.Getenv(scanPathVariable),217		echoTimeout: echoTimeout(os.Getenv(echoTimeoutVariable)),218	}219	// The Job holds no will and publishes nothing. Its one use of220	// the bus is the subscription that carries the reporter's echo back.221	scan.echo = newEchoWaiter(scan.statusTopic, scan.worker(), scan.job)222	scan.bus = newBus(address, "scan-"+namespace+"-"+name, nil, nil, scan.echo.note)223	return scan, nil224}225226// The whole of a scan Job: write the run with no finish, walk,227// write the run again with what the walk left, and wait for the228// namespace's reporter to publish that run back with the counts this229// Job's own agent holds.230//231// The first write says a walk is running. The echo of the last232// write, carrying the counts, is what proves the standing pod holds233// every row this Job wrote.234func (s *scanner) runJob(ctx context.Context) error {235	run := libraryRun{Worker: s.worker(), Job: s.job, Started: time.Now().UTC()}236	if err := s.catalog.UpsertRun(ctx, s.library, run); err != nil {237		return fmt.Errorf("writing the run of %s: %w", s.library, err)238	}239240	walked := s.walkOnce(ctx)241242	run.Finished = time.Now().UTC()243	if walked != nil {244		run.Failure = walked.Error()245	}246	s.mutex.Lock()247	run.Unidentified = s.report.Unidentified248	run.Removed = s.report.RemovedLastSweep249	counts, read := s.counts, s.countsRead250	s.mutex.Unlock()251	if err := s.catalog.UpsertRun(ctx, s.library, run); err != nil {252		return fmt.Errorf("writing the finished run of %s: %w", s.library, err)253	}254255	if read {256		s.echo.expect(counts.items, counts.files)257	}258	if err := s.echo.wait(ctx, s.bus, s.echoTimeout); err != nil {259		return err260	}261	return walked262}263264// The worker whose runs row this Job writes and whose echo it265// waits for. A Job that names a folder is the rescan worker, so its266// row stands beside the full walk's row and never over it, and the267// reporter reads the walk's own numbers off the scan row alone.268//269// A folder scan that falls back to the whole root keeps the270// rescan worker, because the Job it runs is the one the webhook asked271// for.272func (s *scanner) worker() string {273	if s.scanPath == "" {274		return workerScan275	}276	return workerRescan277}278279// The one walk this Job runs: the whole root, or the single folder280// SCAN_PATH names, which falls back to the whole root when the path names281// no folder on the volume.282func (s *scanner) walkOnce(ctx context.Context) error {283	if s.kind == libraryKindFranchises {284		return s.franchiseScan(ctx)285	}286	if s.scanPath == "" {287		return s.fullWalk(ctx)288	}289	absolute := s.resolveWebhookPath(s.scanPath)290	if absolute == "" {291		s.logf("could not map %s onto the volume, walking the whole root", s.scanPath)292		return s.fullWalk(ctx)293	}294	return s.rescan(ctx, absolute)295}296297// walkFolders streams this library's title folders, read by the pool in298// walk.go and handed one at a time to the caller, which is the walk's one299// collector. An unknown kind streams nothing and reports zero titles rather300// than failing. A cancelled context stops the stream between folders, so a301// walk of a large volume does not run on past a shutdown.302func (s *scanner) walkFolders(ctx context.Context) iter.Seq[*walkResult] {303	switch s.kind {304	case libraryKindMovies:305		return walkTree(ctx, s.root, movieFolderRule(s.folderScan()))306	case libraryKindSeries:307		return walkTree(ctx, s.root, seriesFolderRule(s.folderScan()))308	}309	return func(yield func(*walkResult) bool) {}310}311312// The reader's view of this scanner's library, which the full walk and a313// rescan share. Nothing here writes the volume.314func (s *scanner) folderScan() folderScan {315	return folderScan{root: s.root, library: s.library, kind: s.kind, ignore: s.ignore}316}317318// FullWalk is the walk's one collector. A pool of workers reads the319// root, and this goroutine takes their folders one at a time. It buffers the320// rows until the item rows reach scanFlushBatch; the file, link, and alias321// rows travel with their items and stay uncounted. The people of the store322// follow the titles through the same buffer, one person counted as one item.323// It flushes each buffer:324// it upserts the rows and marks them with the walk's epoch. It then prunes325// the rows the walk did not mark, and records what it read. A write that326// fails leaves the catalog as it was and fails the Job, so the next run327// retries. An incomplete walk keeps its prune for the next clean walk, so a328// partial read never mass-deletes.329func (s *scanner) fullWalk(ctx context.Context) error {330	s.walkMutex.Lock()331	defer s.walkMutex.Unlock()332333	started := time.Now()334	s.logf("walking %s", s.root)335336	if err := s.catalog.ensureSeen(ctx); err != nil {337		return s.walkFailed("ensure the seen table", err)338	}339	epoch := time.Now().UnixNano()340341	before, err := s.catalog.countItems(ctx, s.library)342	if err != nil {343		return s.walkFailed("count the catalog before the walk", err)344	}345346	buffer := &walkResult{}347	// The fold lives for the whole walk, so a set derives from every member348	// the walk reads and not from the batch its members landed in.349	sets := setFold{}350	buffered, items, titles, unidentified := 0, 0, 0, 0351	readError := false352	var unidentifiedNames []string353	flush := func() error {354		if err := ctx.Err(); err != nil {355			return s.walkFailed("write a walk batch", err)356		}357		if buffered == 0 {358			return nil359		}360		if err := flushWalk(ctx, s.catalog, buffer, epoch); err != nil {361			return s.walkFailed("write a walk batch", err)362		}363		buffer = &walkResult{}364		buffered = 0365		return nil366	}367368	for folder := range s.walkFolders(ctx) {369		if folder.readError {370			readError = true371		}372		// The collector takes each folder as the worker hands it373		// over, so a failed read reaches the log at the moment it happens374		// and names the path. The summary line below reports only that the375		// pass was incomplete.376		for _, failure := range folder.readFailures {377			s.logf("could not read %s: %v", failure.path, failure.err)378		}379		appendFolder(buffer, folder)380		sets.add(folder.movies)381		found := len(folder.movies) + len(folder.series) + len(folder.episodes)382		items += found383		buffered += found384		titles += folder.titles385		unidentified += folder.unidentified386		unidentifiedNames = appendSample(unidentifiedNames, folder.unidentifiedNames, unidentifiedSample)387		if buffered >= scanFlushBatch {388			if err := flush(); err != nil {389				return err390			}391		}392	}393	if err := flush(); err != nil {394		return err395	}396397	// The people are read after the last title folder, because the walk of the398	// titles skips every dot directory and this store is the one exception. They399	// stream through the same buffer as the titles, so the scanner holds one400	// batch of people and never the whole store. A store the scanner cannot read401	// marks the pass incomplete, as a title folder does, so a store that would402	// not open never sweeps its people.403	for person := range walkContributors(s.root, s.library) {404		if person.readError {405			readError = true406		}407		appendFolder(buffer, person)408		buffered += len(person.contributors)409		if buffered >= scanFlushBatch {410			if err := flush(); err != nil {411				return err412			}413		}414	}415	if err := flush(); err != nil {416		return err417	}418419	// A cancelled walk read only part of the volume and wrote only part420	// of its rows, so it prunes nothing, writes no counts, and leaves the421	// last-walk time where it was.422	if err := ctx.Err(); err != nil {423		return s.walkFailed("finish the walk", err)424	}425426	// An incomplete walk read only part of the volume, so its counts427	// do not describe what the volume holds. It fails the Job here without428	// pruning, so a partial read never mass-deletes.429	if incompleteWalk(readError, items, before) {430		s.logIncompleteWalk(readError, items, before)431		return errIncompleteWalk432	}433434	// The sets are written after the last folder, because a set is derived435	// from all of its members. They carry the walk's own epoch, so a set436	// whose last member left the volume is unmarked and the prune below takes437	// it. A write that fails returns before the prune, because a prune with438	// no set marked would sweep every set the catalog holds.439	if err := flushWalk(ctx, s.catalog, &walkResult{sets: sets.rows()}, epoch); err != nil {440		return s.walkFailed("write the sets", err)441	}442443	s.settleWalk(ctx, epoch, before, titles, unidentified, unidentifiedNames, started)444	return nil445}446447// settleWalk is the tail every walk of this library shares: the prune,448// the counts, the report, and the one summary line. The walk read its449// source and wrote what it holds, so the count is settled before this450// runs. The prune and the second count read the catalog back through the451// query API, which can miss the walk's own writes for a window after a452// fresh agent starts, so a failure in either is logged and left for the453// next walk. `folders` is what the walk read, and the title count the454// log carries is what the catalog holds after the prune. The two differ455// where two folders name one provider id, because the id is the item's456// key and the second folder writes over the first. The log names both,457// so the number the Library's status shows is in the line beside the458// number the walk read.459func (s *scanner) settleWalk(ctx context.Context, epoch int64, before, folders, unidentified int,460	unidentifiedNames []string, started time.Time) {461	removed := -1462	if count, err := pruneLibrary(ctx, s.catalog, s.library, epoch); err != nil {463		s.logWalk("prune the catalog", err)464	} else {465		removed = count466	}467468	after, err := s.catalog.countItems(ctx, s.library)469	countedItems := err == nil470	if err != nil {471		s.logWalk("count the catalog after the walk", err)472		after = before473	}474475	// The catalog's own file count, read here beside the item count.476	// A read that fails leaves the walk's own file count at zero, the way477	// the item count holds on a failed read.478	files, err := s.catalog.countFiles(ctx, s.library)479	countedFiles := err == nil480	if err != nil {481		s.logWalk("count the catalog's files", err)482	}483484	// The titles the catalog holds, which is what the reporter publishes485	// and the Library's status carries. It is read for the log alone, so486	// a read that lags behind the walk's own writes costs one line and487	// never a count the operator acts on. A read that fails leaves the488	// folder count in its place, so the walk still reports a number.489	titles, err := s.catalog.countTitles(ctx, s.library)490	if err != nil {491		s.logWalk("count the catalog's titles", err)492		titles = folders493	}494495	now := time.Now().UTC()496	s.mutex.Lock()497	s.report.Titles = folders498	s.report.Unidentified = unidentified499	s.report.LastWalk = now500	if removed > 0 || after != before {501		s.report.LastChange = now502	}503	if removed >= 0 {504		s.report.RemovedLastSweep = removed505	}506	if countedItems {507		s.report.Items = after508	}509	if countedFiles {510		s.report.Files = files511	}512	if countedItems && countedFiles {513		s.counts = libraryCounts{items: after, files: files}514		s.countsRead = true515	}516	s.mutex.Unlock()517518	s.logWalkComplete(titles, folders, unidentified, removed, unidentifiedNames, time.Since(started))519}520521// The walk read only part of the volume, so it pruned nothing and522// kept the counts it had. The Job fails on it, because a run that read523// half a library is not a run the catalog can be reconciled against.524var errIncompleteWalk = errors.New("the walk did not read the whole volume")525526// unidentifiedSample bounds how many unidentified folder names a walk527// names in its log, so a library of millions logs a sample and never one528// line per folder. The count is always reported; the names past this are a529// tally.530const unidentifiedSample = 10531532// appendSample folds a folder's unidentified names into a running533// sample, stopping at the limit, so the walk holds a sample and never every534// name.535func appendSample(have, more []string, limit int) []string {536	for _, name := range more {537		if len(have) >= limit {538			return have539		}540		have = append(have, name)541	}542	return have543}544545// logWalk writes one line about a walk that could not finish a step, naming546// the step and the error. It turns a swallowed catalog error into a visible547// line in the pod log, in place of a count held at its last value until the548// next walk. A scanner built without a log writes nowhere.549func (s *scanner) logWalk(step string, err error) {550	s.logf("full walk could not %s: %v", step, err)551}552553// Names the step that stopped a walk in the pod log and in the554// error the Job fails with, so the failure reads the same in both places.555func (s *scanner) walkFailed(step string, err error) error {556	s.logWalk(step, err)557	return fmt.Errorf("could not %s: %w", step, err)558}559560// LogIncompleteWalk names why a walk pruned nothing and kept the561// counts it had: a root it could not read, or a count far below the562// catalog's.563func (s *scanner) logIncompleteWalk(readError bool, items, before int) {564	if readError {565		s.logf("incomplete walk: could not read the whole volume, keeping the last counts")566		return567	}568	s.logf("incomplete walk: read %d of %d cataloged items, keeping the last counts", items, before)569}570571// logWalkComplete writes the one summary line a finished walk leaves:572// the counts, the sweep, and how long the walk took, then a capped sample of573// the folders it could not identify.574func (s *scanner) logWalkComplete(titles, folders, unidentified, removed int, names []string, took time.Duration) {575	if removed >= 0 {576		s.logf("walk complete: %d titles from %d folders, %d unidentified, %d removed, in %s", titles, folders, unidentified, removed, took.Round(time.Millisecond))577	} else {578		s.logf("walk complete: %d titles from %d folders, %d unidentified, prune deferred, in %s", titles, folders, unidentified, took.Round(time.Millisecond))579	}580	if unidentified == 0 {581		return582	}583	if more := unidentified - len(names); more > 0 {584		s.logf("unidentified folders: %s, and %d more", strings.Join(names, ", "), more)585		return586	}587	s.logf("unidentified folders: %s", strings.Join(names, ", "))588}589590// logf writes one scanner log line under the shared prefix, or nothing591// when the scanner was built without a log.592func (s *scanner) logf(format string, args ...any) {593	if s.log == nil {594		return595	}596	fmt.Fprintf(s.log, "library.liken.sh: "+format+"\n", args...)597}598599// Rescan reads one title or series folder and reconciles the600// catalog to it, the answer to the folder a scan Job is given. It upserts what601// the folder holds and prunes only that folder's rows the re-read did not602// produce, such as a file an upgrade replaced. A folder that left the603// volume marks nothing, so all of its rows leave. It moves the last-change604// time when it wrote or removed a row, and leaves the counts and the605// last-walk time to the next full walk. A path that resolves to no folder606// falls back to a full walk.607func (s *scanner) rescan(ctx context.Context, absolute string) error {608	folder := s.titleFolderOf(absolute)609	if folder == "" {610		return s.fullWalk(ctx)611	}612613	s.walkMutex.Lock()614	defer s.walkMutex.Unlock()615616	relative := relativePath(s.root, folder)617	written, removed, err := rescanFolder(ctx, s.catalog, s.folderScan(), folder)618	if err != nil {619		return s.walkFailed("rescan "+relative, err)620	}621622	// A rescan moves the counts, so the Job's echo compares623	// against what the agent holds after it and never against a full624	// walk's counts.625	counts, err := s.catalog.countsOf(ctx, s.library)626	if err != nil {627		return s.walkFailed("count the catalog after a rescan", err)628	}629	s.mutex.Lock()630	s.counts = counts631	s.countsRead = true632	s.mutex.Unlock()633634	if written == 0 && removed == 0 {635		s.logf("rescanned %s: no change", relative)636		return nil637	}638	s.logf("rescanned %s: wrote %d, removed %d", relative, written, removed)639	s.mutex.Lock()640	s.report.LastChange = time.Now().UTC()641	s.report.RemovedLastSweep = removed642	s.mutex.Unlock()643	return nil644}645646// What a reader of one folder needs beside the folder: the root, the647// library and kind the rows belong to, and the folder names the walk skips.648// There is no writer, because the walk and a fact's re-read both only649// read.650type folderScan struct {651	root    string652	library string653	kind    string654	ignore  ignoreSet655}656657// Reads one title or series folder into rows, upserts them, and prunes the658// folder's rows the read did not produce. It is the body of a webhook rescan,659// and the identity fact calls it after it writes a title's ids, because the660// id keys every other row of the title.661func rescanFolder(ctx context.Context, catalog *Catalog, scan folderScan, folder string) (int, int, error) {662	relative := relativePath(scan.root, folder)663	if err := catalog.ensureSeen(ctx); err != nil {664		return 0, 0, fmt.Errorf("ensure the seen table: %w", err)665	}666	epoch := time.Now().UnixNano()667	result := readFolder(scan, folder)668	if result == nil {669		return 0, 0, nil670	}671	// A read that failed describes only part of the folder, so the rescan672	// writes nothing and prunes nothing. It is the rule the full walk673	// follows, and it is what keeps a share that refuses one directory674	// from emptying a title's rows.675	if result.readError {676		return 0, 0, fmt.Errorf("could not read %s in full", relative)677	}678679	// The sets this folder's movies named are read before the upsert, so a680	// movie that left its set still names the set that has to be derived681	// again. The set the folder names now is affected as well.682	var affected []string683	if scan.kind == libraryKindMovies {684		held, err := catalog.setIDsUnder(ctx, scan.library, relative)685		if err != nil {686			return 0, 0, fmt.Errorf("read the sets of a rescan: %w", err)687		}688		affected = append(held, setIDsOf(result.movies)...)689	}690691	if err := flushWalk(ctx, catalog, result, epoch); err != nil {692		return 0, 0, fmt.Errorf("write a rescan: %w", err)693	}694695	removed, err := pruneScope(ctx, catalog, scan.library, relative, epoch)696	if err != nil {697		return 0, removed, fmt.Errorf("prune a rescan: %w", err)698	}699700	// A rescan reads one folder and not a set's other members, so each701	// affected set derives again from the movie rows the catalog holds, after702	// the prune has taken the rows this folder lost.703	if err := reconcileSets(ctx, catalog, scan.library, affected); err != nil {704		return 0, removed, fmt.Errorf("write the sets of a rescan: %w", err)705	}706	written := len(result.movies) + len(result.series) + len(result.episodes) + len(result.files)707	return written, removed, nil708}709710// One title or series folder as rows, through the reader the walk uses for711// the kind. A folder that left the volume reads as no rows, so the prune that712// follows takes every row it held. A folder the scanner could not stat713// is not a folder that left the volume, so it marks the read incomplete714// and the caller sweeps nothing. A kind with no reader reads as nil.715func readFolder(scan folderScan, folder string) *walkResult {716	result := &walkResult{}717	held, err := directoryExists(folder)718	result.noteReadError(err)719	if err != nil || !held {720		return result721	}722	switch scan.kind {723	case libraryKindMovies:724		scanMovieFolder(scan, folder, result)725	case libraryKindSeries:726		scanSeriesFolder(scan, folder, result)727	default:728		return nil729	}730	return result731}732733// titleFolderOf maps a path on the volume to the title or series folder734// that holds it. A path outside the root maps to nothing, and so does a path735// that names no title folder, and the caller then walks the whole root.736func (s *scanner) titleFolderOf(absolute string) string {737	folder, held := titleFolderOf(s.root, s.kind, absolute)738	if !held {739		return ""740	}741	return folder742}743744// splitPath splits a relative path into its elements, dropping the745// empty ones a leading or doubled separator leaves.746func splitPath(relative string) []string {747	var parts []string748	for _, part := range strings.Split(relative, string(filepath.Separator)) {749		if part != "" && part != "." {750			parts = append(parts, part)751		}752	}753	return parts754}
scanjob.go 96.7%
1package main23// A scan is a Job. The full walk runs from a CronJob on the4// Library's schedule, and a folder scan runs from a Job the webhook5// creates for one path. Both run the same pod on the Library's own6// catalog claim, whose ReadWriteOnce admits one of them at a time.78import (9	"context"10	"errors"11	"fmt"12	"time"13)1415// How many pods Kubernetes replaces before the Job itself fails, and how long16// a finished Job stays for a person to read its logs. The TTL is the hour a17// failed Job keeps. A succeeded Job goes sooner, because the operator deletes18// it after succeededJobGrace, and the TTL is the backstop when the operator19// is down.20const (21	scanBackoffLimit = 222	scanJobTTL       = 360023)2425// A walk that runs past its next turn skips that turn, because26// the claim admits one writer and a second walk would only wait on it.27const forbidConcurrency = "Forbid"2829// How many finished Jobs of each outcome the CronJob keeps. One30// success is the last good run, and three failures are enough to read a31// pattern out of.32const (33	successfulJobsKept = 134	failedJobsKept     = 335)3637// The schedule one Library's full walk runs on, named from the38// Library, so every pass names the same CronJob.39func scanCronJobName(library string) string {40	return library + "-scan"41}4243// The schedule the Library's full walk runs on, built from the44// Library and the operator's own settings alone, so two passes over an45// unchanged Library build the same object.46func buildScanCronJob(library *Library, scannerImage, corrosionImage, busAddress, topicBase string) *CronJob {47	successes, failures := int32(successfulJobsKept), int32(failedJobsKept)48	return &CronJob{49		APIVersion: batchAPIVersion,50		Kind:       "CronJob",51		Metadata: ObjectMeta{52			Name:            scanCronJobName(library.Metadata.Name),53			Namespace:       library.Metadata.Namespace,54			Labels:          workerLabels(library.Metadata.Name, workerScan),55			OwnerReferences: []OwnerReference{libraryOwner(library)},56		},57		Spec: CronJobSpec{58			Schedule:                   library.Spec.scanSchedule(),59			ConcurrencyPolicy:          forbidConcurrency,60			SuccessfulJobsHistoryLimit: &successes,61			FailedJobsHistoryLimit:     &failures,62			JobTemplate: JobTemplateSpec{63				Metadata: ObjectMeta{Labels: workerLabels(library.Metadata.Name, workerScan)},64				Spec:     scanJobSpec(library, "", scannerImage, corrosionImage, busAddress, topicBase),65			},66		},67	}68}6970// The Job one held webhook path becomes, owned by the Library so the garbage71// collector takes it with the Library. It opens a chain: it carries the chain72// marks, and the enricher and the rescan of the same folder follow it under73// the same chain.74func buildFolderScanJob(library *Library, path string, now time.Time, scannerImage, corrosionImage, busAddress, topicBase string) *Job {75	chain := newChain(path, now)76	return &Job{77		APIVersion: batchAPIVersion,78		Kind:       "Job",79		Metadata: ObjectMeta{80			Name:            chainJobName(library.Metadata.Name, chainStageScan, chain),81			Namespace:       library.Metadata.Namespace,82			Labels:          workerLabels(library.Metadata.Name, workerScan),83			Annotations:     chainMarks(chain, path, chainStageScan),84			OwnerReferences: []OwnerReference{libraryOwner(library)},85		},86		Spec: scanJobSpec(library, path, scannerImage, corrosionImage, busAddress, topicBase),87	}88}8990// The spec both scan Jobs share, which differ in the scan path91// alone: empty is the full walk, and a path is the one folder to92// rescan.93func scanJobSpec(library *Library, path, scannerImage, corrosionImage, busAddress, topicBase string) JobSpec {94	backoff, ttl := int32(scanBackoffLimit), int32(scanJobTTL)95	return JobSpec{96		BackoffLimit:            &backoff,97		TTLSecondsAfterFinished: &ttl,98		Template:                scanPodTemplate(library, path, scannerImage, corrosionImage, busAddress, topicBase),99	}100}101102// The CronJob is created when there is none and rewritten when103// the pass builds a different one, which is how a changed schedule or a104// changed image reaches the cluster. The stamped hash is what tells the105// two apart, the rule standPod follows for a pod.106func (o *operator) standScanCronJob(ctx context.Context, library *Library) (*CronJob, error) {107	desired := buildScanCronJob(library, o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase)108	if err := stampTemplateHash(&desired.Metadata, desired.Spec); err != nil {109		return nil, err110	}111	namespace, name := desired.Metadata.Namespace, desired.Metadata.Name112113	live, err := GetCronJob(ctx, o.client, namespace, name)114	if errors.Is(err, ErrNotFound) {115		created, err := CreateCronJob(ctx, o.client, desired)116		if errors.Is(err, ErrConflict) {117			// Another pass, or another copy of this operator, created118			// it first, which is success. The next pass reads it.119			return nil, nil120		}121		if err != nil {122			return nil, err123		}124		return created, nil125	}126	if err != nil {127		return nil, err128	}129	if sameTemplate(&live.Metadata, &desired.Metadata) {130		return live, nil131	}132	desired.Metadata.ResourceVersion = live.Metadata.ResourceVersion133	written, err := UpdateCronJob(ctx, o.client, desired)134	if errors.Is(err, ErrConflict) {135		return live, nil136	}137	return written, err138}139140// The schedule of a Library that no longer stands one goes, so a141// Library whose claim or Catalog went away stops walking a volume it no142// longer reports. An absent CronJob is success.143func (o *operator) stopScanCronJob(ctx context.Context, library *Library) error {144	return DeleteCronJob(ctx, o.client, library.Metadata.Namespace,145		scanCronJobName(library.Metadata.Name))146}147148// The Jobs of one Library and one worker, out of the whole149// cluster's Jobs the pass listed.150func jobsOf(jobs []Job, namespace, library, worker string) []Job {151	held := []Job{}152	for index := range jobs {153		job := &jobs[index]154		if job.Metadata.Namespace != namespace {155			continue156		}157		if job.Metadata.Labels[libraryLabelKey] != library {158			continue159		}160		if job.Metadata.Labels[workerLabelKey] != worker {161			continue162		}163		held = append(held, *job)164	}165	return held166}167168// Whether a full walk of this Library has a pod running. A full169// walk is a Job the CronJob created, which is the ownerReference the170// CronJob controller writes, so a folder scan is never mistaken for171// one.172func fullWalkRunning(jobs []Job, namespace, library string) bool {173	for _, job := range jobsOf(jobs, namespace, library, workerScan) {174		if job.active() && ownedByCronJob(&job) {175			return true176		}177	}178	return false179}180181// scanUnfinished is whether any scan Job of this Library is still182// open: one the controller has marked neither Complete nor Failed. A183// Job between the pods of its backoff counts.184func scanUnfinished(jobs []Job, namespace, library string) bool {185	for _, job := range jobsOf(jobs, namespace, library, workerScan) {186		if !job.finished() {187			return true188		}189	}190	return false191}192193func ownedByCronJob(job *Job) bool {194	for _, owner := range job.Metadata.OwnerReferences {195		if owner.Kind == "CronJob" {196			return true197		}198	}199	return false200}201202// The held webhook paths of one Library become Jobs here, one203// Job per path, and each path is dropped once its Job exists. A path204// held while a full walk runs stays held: the walk covers it, and the205// claim would admit no second writer anyway.206func (o *operator) serveHeldPaths(ctx context.Context, library *Library, jobs []Job, now time.Time) error {207	namespace, name := library.Metadata.Namespace, library.Metadata.Name208	if fullWalkRunning(jobs, namespace, name) {209		return nil210	}211	for _, path := range o.paths.held(namespace, name) {212		job := buildFolderScanJob(library, path, now,213			o.scannerImage, o.corrosionImage, o.busAddress, o.topicBase)214		if _, err := CreateJob(ctx, o.client, job); err != nil && !errors.Is(err, ErrConflict) {215			return fmt.Errorf("creating the scan job for %s: %w", path, err)216		}217		o.paths.release(namespace, name, path)218	}219	return nil220}
scanwrite.go 100.0%
1package main23// scanwrite.go writes a walk's rows to the catalog. The scanner is the one4// writer of its agent's catalog. It upserts every row a walk read, and a5// separate mark-and-sweep pass in prune.go removes the rows a walk did not6// reach.78import "context"910// scanFlushBatch bounds how many items the streaming full walk buffers before it11// writes them, so the walk holds one batch and never the whole library. It is a12// var so a test drives several flushes over a small set, the way pruneBatch13// bounds the prune.14var scanFlushBatch = 5121516// flushWalk writes a buffer of walked rows to the catalog and marks their keys17// with the walk's epoch. Both the streaming full walk and a webhook rescan write18// a folder through it.19func flushWalk(ctx context.Context, catalog *Catalog, result *walkResult, epoch int64) error {20	if err := upsertWalk(ctx, catalog, result); err != nil {21		return err22	}23	_, err := catalog.markSeen(ctx, markKeys(result), epoch)24	return err25}2627// upsertWalk writes every row a walk produced: the items, the files and their28// item links, the aliases, and the attempts read out of the .liken files.29func upsertWalk(ctx context.Context, catalog *Catalog, result *walkResult) error {30	steps := []func() (int, error){31		func() (int, error) { return catalog.UpsertMovies(ctx, result.movies) },32		func() (int, error) { return catalog.UpsertSets(ctx, result.sets) },33		func() (int, error) { return catalog.UpsertSeries(ctx, result.series) },34		func() (int, error) { return catalog.UpsertEpisodes(ctx, result.episodes) },35		func() (int, error) { return catalog.UpsertFiles(ctx, result.files) },36		func() (int, error) { return catalog.UpsertFileItems(ctx, result.files) },37		func() (int, error) { return catalog.UpsertAliases(ctx, result.aliases) },38		func() (int, error) { return catalog.UpsertAttempts(ctx, result.attempts) },39		func() (int, error) { return catalog.UpsertContributors(ctx, result.contributors) },40		func() (int, error) {41			return catalog.UpsertContributorAliases(ctx, result.contributorAliases)42		},43		func() (int, error) { return catalog.UpsertCredits(ctx, result.credits) },44		func() (int, error) { return catalog.UpsertGenres(ctx, result.genres) },45		func() (int, error) { return catalog.UpsertFranchises(ctx, result.franchises) },46		func() (int, error) {47			return catalog.UpsertFranchiseMembers(ctx, result.franchiseMembers)48		},49		func() (int, error) { return catalog.UpsertFranchiseRuns(ctx, result.franchiseRuns) },50	}51	for _, step := range steps {52		if _, err := step(); err != nil {53			return err54		}55	}56	return nil57}
screenclaim.go 100.0%
1package main23// The volume a screen's catalog agent runs on. Every screen in a4// namespace with one Catalog holds a claim of its own, sized from that5// Catalog and classed by its screens block, so a screen that restarts syncs6// a delta. A screen the scheduler cannot place where its volume is loses7// both the pod and the claim, and the next pass creates them again.89import (10	"context"11	"errors"12	"slices"13	"time"14)1516// The claim one screen mounts, derived from the screen pod's name, so17// every pass names the same claim and the operator keeps no record of it.18func screenClaimName(player string) string {19	return screenPodName(player) + "-catalog"20}2122// The two marks a screen's claim carries, which are two of the three23// guards on the one delete the operator sends for a claim.24func screenClaimLabels(player string) map[string]string {25	return map[string]string{26		scannerLabelKey: screenLabelValue,27		playerLabelKey:  player,28	}29}3031// The claim a screen's agent runs on. It is ReadWriteOnce, because one32// agent writes one SQLite database, sized from the namespace Catalog, classed33// by spec.screens.storageClassName, and owned by the Player as the pod is. An34// empty StorageClassName is omitted, so the cluster's default binds it.35func buildScreenClaim(player *Player, catalog *NamespaceCatalog) *PersistentVolumeClaim {36	return &PersistentVolumeClaim{37		APIVersion: claimAPIVersion,38		Kind:       "PersistentVolumeClaim",39		Metadata: ObjectMeta{40			Name:            screenClaimName(player.Metadata.Name),41			Namespace:       player.Metadata.Namespace,42			Labels:          screenClaimLabels(player.Metadata.Name),43			OwnerReferences: []OwnerReference{playerOwner(player)},44		},45		Spec: PersistentVolumeClaimSpec{46			AccessModes: []string{accessModeReadWriteOnce},47			Resources: VolumeResourceRequirements{48				Requests: map[string]string{"storage": catalogStorageSize(catalog)},49			},50			StorageClassName: catalog.Spec.Screens.StorageClassName,51		},52	}53}5455// The claim is created when there is none and left alone when it56// stands, the rule standCatalogClaim follows, because a claim's spec is57// immutable once it binds. A conflict on the create means another writer got58// there first, which is success.59func (o *operator) standScreenClaim(ctx context.Context, player *Player, catalog *NamespaceCatalog) error {60	namespace, name := player.Metadata.Namespace, screenClaimName(player.Metadata.Name)6162	_, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)63	if err == nil {64		return nil65	}66	if !errors.Is(err, ErrNotFound) {67		return err68	}69	_, err = CreatePersistentVolumeClaim(ctx, o.client, buildScreenClaim(player, catalog))70	if errors.Is(err, ErrConflict) {71		return nil72	}73	return err74}7576// How long a screen pod may carry PodScheduled False before the77// operator takes its claim away. It is a variable so a test drives it in78// milliseconds.79var unschedulableGrace = 5 * time.Minute8081// Whether the scheduler has refused this pod for longer than the82// grace. The verdict is the API server's own lastTransitionTime, so a83// restarted operator holds the verdict the one before it held, and no pass84// keeps a timer. A condition with no time is no verdict yet.85func unschedulablePastGrace(pod *Pod, now time.Time) bool {86	for _, condition := range pod.Status.Conditions {87		if condition.Type != podScheduled || condition.Status != conditionIsFalse {88			continue89		}90		return !condition.LastTransitionTime.IsZero() &&91			now.Sub(condition.LastTransitionTime) > unschedulableGrace92	}93	return false94}9596// The three guards on the delete: the claim's name is the derived97// screen claim name, it carries the screen name label and the player label,98// and its controller ownerReference names the Player with the UID this pass99// read. A Library's media claim matches none of the three.100func claimBelongsToScreen(claim *PersistentVolumeClaim, player *Player) bool {101	labels := claim.Metadata.Labels102	return claim.Metadata.Name == screenClaimName(player.Metadata.Name) &&103		labels[scannerLabelKey] == screenLabelValue &&104		labels[playerLabelKey] == player.Metadata.Name &&105		slices.Contains(claim.Metadata.OwnerReferences, playerOwner(player))106}107108// A node-local volume binds to the node the pod first landed on, so a109// screen whose display moved is unschedulable for good. Past the grace, on a110// Bound claim of this Player's own, the operator deletes the pod and the111// claim, and the next pass creates both on the new node. Only a Bound claim112// is deleted, so a claim that never binds leaves the recovery quiet.113func (o *operator) recoverUnschedulableScreen(ctx context.Context, player *Player, pod *Pod, now time.Time) (bool, error) {114	if pod == nil || !unschedulablePastGrace(pod, now) {115		return false, nil116	}117	namespace, name := player.Metadata.Namespace, screenClaimName(player.Metadata.Name)118119	claim, err := GetPersistentVolumeClaim(ctx, o.client, namespace, name)120	if errors.Is(err, ErrNotFound) {121		return false, nil122	}123	if err != nil {124		return false, err125	}126	if claim.Status.Phase != claimBound || !claimBelongsToScreen(claim, player) {127		return false, nil128	}129	if err := DeletePod(ctx, o.client, namespace, pod.Metadata.Name); err != nil {130		return false, err131	}132	return true, DeletePersistentVolumeClaim(ctx, o.client, namespace, name)133}
screenpod.go 98.8%
1package main23// The screen pod is what a delegated Player becomes: one pod per4// Player, in the Player's namespace and owned by it, so deleting the Player5// tears it down. It holds the media browser and a Corrosion agent of its own.6//7// The agent's state is a claim of the screen's own, sized by the8// namespace Catalog, so a screen that restarts syncs a delta rather than9// pulling the whole catalog. A screen in a namespace with no single Catalog10// has no size to read, so its agent keeps an emptyDir and rebuilds from its11// peers on every start.12//13// The browser reads the catalog from the agent's file and the update14// stream from its loopback API, and it draws poster art from every Library's15// storage claim in the namespace, each mounted read-only. It draws on the16// screen media-operator claimed for the Player: the pod holds that claim, and17// the browser container takes the requests media-operator named in it.1819import (20	"context"21	"fmt"22	"maps"23	"os"24	"path"25	"slices"26	"strconv"27	"strings"28	"time"29)3031// The container's name reaches a person through kubectl logs, so it32const browserContainer = "browser"3334// The name label value a screen pod carries. It is neither a35// worker Job's value nor the catalog pod's, so one list answers one36// kind of pod.37const screenLabelValue = "library-media-browser"3839// The label that names the Player a screen pod draws for, beside the40// name label above. A person lists one Player's pod by this pair.41const playerLabelKey = "library.liken.sh/player"4243// Where the operator mounts the Libraries of the namespace, one44// directory per Library under this root. The browser reads a title's poster45// from the mount its library root names.46const librariesMountPath = "/libraries"4748// The browser keeps scaled posters on the node's local disk. The extra49// 128 MiB above the disk cache's 512 MiB cap gives atomic writes room for50// temporary files before their rename.51const (52	posterCacheVolumeName = "poster-cache"53	posterCacheMountPath  = "/var/cache/media-browser"54	posterCacheSizeLimit  = "640Mi"55)5657// The pod-local name of the display claim. The pod holds58// media-operator's ResourceClaim under this name, and the browser container's59// resource claims refer to the same name.60const displayClaimName = "devices"6162// The seconds the browser waits for a window before it exits 7 and the63// kubelet restarts it. The container reads the variable; a run outside a pod64// sets none and waits forever.65const (66	windowGraceVariable = "WINDOW_GRACE_SECONDS"67	// The zone the browser's clock and its day's draw read, the standard68	// name and not a LIBRARY_ one, because glibc reads it.69	timeZoneVariable   = "TZ"70	windowGraceSeconds = "15"71)7273// The variables that carry status.idle into the browser. They are the74// names media-operator's own client reads, and the media-screen crate75// both clients link reads them in its wiring.rs, so the two clients of76// one contract are wired the same way. They name the broker, the77// Player's own object name that every focus mark holds, the retained78// status, the level, the commands topic that carries the re-present,79// the panel topic the client states the panel desire on, and the two80// newline-joined lists of the unit's controllers. The level variable is81// absent for a unit with no sinks.82const (83	mediaBusAddressVariable         = "MEDIA_BUS_ADDRESS"84	mediaPlayerNameVariable         = "MEDIA_PLAYER_NAME"85	mediaStatusTopicVariable        = "MEDIA_PLAYER_STATUS_TOPIC"86	mediaVolumeTopicVariable        = "MEDIA_PLAYER_VOLUME_TOPIC"87	mediaCommandsTopicVariable      = "MEDIA_PLAYER_COMMANDS_TOPIC"88	mediaPanelTopicVariable         = "MEDIA_PLAYER_PANEL_TOPIC"89	mediaRemoteEventsTopicsVariable = "MEDIA_REMOTE_EVENTS_TOPICS"90	mediaRemoteFocusTopicsVariable  = "MEDIA_REMOTE_FOCUS_TOPICS"91)9293// The two windows the browser runs itself, in seconds. They are always94// set beside the bus block, because the crate holds the timers and an95// absent variable is not a policy a client can read. Zero on the fade96// means the screen never fades on its own, and zero on the off window97// leaves the panel lit.98const (99	idleFadeAfterSecondsVariable = "IDLE_FADE_AFTER_SECONDS"100	idleOffAfterSecondsVariable  = "IDLE_OFF_AFTER_SECONDS"101)102103// The topic the browser publishes a play request on. It is this104// operator's own variable and not media-operator's, because this105// operator names the topic and reads it.106const libraryPlayTopicVariable = "LIBRARY_PLAY_TOPIC"107108// ScreenPodName is the pod one Player becomes. The name is derived109// rather than generated, so every pass names the same pod and the operator110// needs no record of what it created.111func screenPodName(player string) string {112	return player + "-media-browser"113}114115// ScreenLabels is what one Player's screen pod carries: the name116// label a list of this operator's screens selects on, the Player it117// draws for, and the member label that makes its agent a peer of the118// namespace's catalog cluster.119func screenLabels(player string) map[string]string {120	return withMemberLabel(map[string]string{121		scannerLabelKey: screenLabelValue,122		playerLabelKey:  player,123	})124}125126// PlayerOwner ties the pod's life to the Player's. Controller is true127// because exactly one thing manages this pod, and the UID is what the garbage128// collector matches: a Player deleted and recreated under the same name is a129// different owner, and the old pod goes.130func playerOwner(player *Player) OwnerReference {131	return OwnerReference{132		APIVersion: playerAPIVersion,133		Kind:       "Player",134		Name:       player.Metadata.Name,135		UID:        player.Metadata.UID,136		Controller: true,137	}138}139140// BuildScreenPod writes the pod one delegated Player becomes. It is a141// function of the Player, the namespace's Libraries, and the operator's own142// settings alone, so two passes over an unchanged namespace build the same143// pod, which is what makes the template hash mean anything. The Libraries are144// read in name order for the same reason.145func buildScreenPod(player *Player, libraries []Library, catalog *NamespaceCatalog, browserImage, corrosionImage, topicBase, timeZone string) *Pod {146	grace := int64(scannerGracePeriod)147	// The browser holds no Kubernetes credential. It reads the catalog148	// from the agent beside it, and nothing in the pod speaks to the API149	// server.150	noToken := false151	shown := slices.Clone(libraries)152	slices.SortFunc(shown, func(one, other Library) int {153		return strings.Compare(one.Metadata.Name, other.Metadata.Name)154	})155156	volumes := []Volume{}157	// Each Library brings the claim that holds the files this screen158	// reads: the art claim of a franchises library, and the storage claim159	// of every other kind.160	for index := range shown {161		library := &shown[index]162		volumes = append(volumes, Volume{163			Name: libraryVolumeName + "-" + library.Metadata.Name,164			PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{165				ClaimName: library.Spec.screenClaim(),166				ReadOnly:  true,167			},168		})169	}170	// The agent's state is the screen's own claim, which the pass171	// creates before this pod. A namespace with no single Catalog states no172	// size, so the agent takes an emptyDir and pays a full sync per start.173	volumes = append(volumes,174		screenCatalogVolume(player, catalog),175		Volume{176			Name:     posterCacheVolumeName,177			EmptyDir: &EmptyDirVolumeSource{SizeLimit: posterCacheSizeLimit},178		},179	)180181	return &Pod{182		APIVersion: podAPIVersion,183		Kind:       "Pod",184		Metadata: ObjectMeta{185			Name:            screenPodName(player.Metadata.Name),186			Namespace:       player.Metadata.Namespace,187			Labels:          screenLabels(player.Metadata.Name),188			OwnerReferences: []OwnerReference{playerOwner(player)},189		},190		Spec: PodSpec{191			// A screen is a standing service, so the kubelet restarts a192			// container that exits rather than letting the pod end. That is193			// also what puts the browser back after the window watchdog exits194			// it.195			RestartPolicy:                 "Always",196			TerminationGracePeriodSeconds: &grace,197			AutomountServiceAccountToken:  &noToken,198			// The catalog agent is the same native sidecar every other199			// pod runs, so the kubelet passes its startupProbe before it200			// starts the browser, and the browser's first read never races an201			// API that is not listening.202			InitContainers: []Container{203				catalogSidecar(corrosionImage),204			},205			Containers: []Container{206				browserSidecar(player, shown, browserImage, topicBase, timeZone),207			},208			Volumes: volumes,209			// The display claim media-operator stood for this Player.210			// The pod holds it, and the browser container takes the requests211			// inside it.212			ResourceClaims: []PodResourceClaim{213				{Name: displayClaimName, ResourceClaimName: player.idle().Claim},214			},215		},216	}217}218219// The volume the catalog agent's state is on: the screen's own claim220// where the namespace holds one Catalog, and an emptyDir where it does not.221func screenCatalogVolume(player *Player, catalog *NamespaceCatalog) Volume {222	if catalog == nil {223		return Volume{Name: catalogVolumeName, EmptyDir: &EmptyDirVolumeSource{}}224	}225	return Volume{Name: catalogVolumeName, PersistentVolumeClaim: &PersistentVolumeClaimVolumeSource{226		ClaimName: screenClaimName(player.Metadata.Name),227	}}228}229230// BrowserSidecar builds the container that draws the wall. It learns231// the catalog, the update stream, and every library root from its arguments232// alone, because it holds no API credential to look one up with. Each library233// claim is mounted read-only, so the browser cannot write to a media volume234// whatever it does.235func browserSidecar(player *Player, libraries []Library, image, topicBase, timeZone string) Container {236	args := []string{237		"--catalog", path.Join(catalogStatePath, catalogStateFile),238		"--updates", defaultCatalogAPI,239		"--cache-dir", posterCacheMountPath,240	}241	// The browser reads the agent's database file straight off the242	// shared volume, so the catalog volume is mounted here as well as in243	// the agent. Without this mount the path --catalog names does not244	// exist in the browser's filesystem, the open fails, and the wall245	// draws an empty library list. The mount is not read-only, because246	// SQLite in WAL mode opens a read-only connection through the -shm247	// file beside the database, and that file must be writable.248	mounts := []VolumeMount{249		{Name: catalogVolumeName, MountPath: catalogStatePath},250		{Name: posterCacheVolumeName, MountPath: posterCacheMountPath},251	}252	for index := range libraries {253		library := &libraries[index]254		mountPath := path.Join(librariesMountPath, library.Metadata.Name)255		mounts = append(mounts, VolumeMount{256			Name:      libraryVolumeName + "-" + library.Metadata.Name,257			MountPath: mountPath,258			ReadOnly:  true,259		})260		// The browser keys a title's library by namespace and name, the261		// same key the catalog rows carry, and reads that library's files262		// under the library's root inside the claim it mounts.263		args = append(args, "--library-root", fmt.Sprintf("%s/%s=%s",264			library.Metadata.Namespace, library.Metadata.Name,265			path.Join(mountPath, library.Spec.screenRoot())))266	}267268	claims := []ResourceClaim{}269	for _, request := range player.idle().Requests {270		claims = append(claims, ResourceClaim{Name: displayClaimName, Request: request})271	}272273	idle := player.idle()274	environment := []EnvVar{275		{Name: windowGraceVariable, Value: windowGraceSeconds},276	}277	// The clock reads TZ against the image's tz database. Set it only278	// when the household stated a zone, so an unset zone leaves the pod279	// on UTC, the way media-operator's own pods do. The template hash280	// rolls the pod when the zone changes.281	if timeZone != "" {282		environment = append(environment, EnvVar{Name: timeZoneVariable, Value: timeZone})283	}284	// A Player whose status names a bus gets the wiring, and its browser285	// takes the room's remotes. A Player under an older media-operator286	// gets none of it, and its browser opens no connection and takes the287	// keyboard alone. The template hash rolls the pod when the block288	// appears.289	if bus := idle.Bus; bus != nil {290		environment = append(environment,291			EnvVar{Name: mediaBusAddressVariable, Value: bus.Address},292			// The Player's own object name, which is what every focus293			// mark holds. A client that reads no name matches no mark294			// and answers no press.295			EnvVar{Name: mediaPlayerNameVariable, Value: player.Metadata.Name},296			EnvVar{Name: mediaStatusTopicVariable, Value: bus.StatusTopic},297		)298		// The level topic is the speaker gate as well as the address,299		// so a unit with no sinks carries no variable rather than an300		// empty one.301		if bus.VolumeTopic != "" {302			environment = append(environment,303				EnvVar{Name: mediaVolumeTopicVariable, Value: bus.VolumeTopic})304		}305		environment = append(environment,306			EnvVar{Name: mediaCommandsTopicVariable, Value: bus.CommandsTopic},307			EnvVar{Name: mediaPanelTopicVariable, Value: bus.PanelTopic},308		)309		environment = append(environment, remoteTopics(bus.Remotes)...)310		environment = append(environment,311			EnvVar{Name: idleFadeAfterSecondsVariable,312				Value: strconv.FormatInt(idle.FadeAfterSeconds, 10)},313			EnvVar{Name: idleOffAfterSecondsVariable,314				Value: strconv.FormatInt(idle.OffAfterSeconds, 10)},315			// The play topic travels with the rest, because it is the316			// same connection. A browser with no broker publishes no317			// request, so the topic alone would name nothing.318			EnvVar{Name: libraryPlayTopicVariable, Value: playRequestTopic(319				topicBase, player.Metadata.Namespace, player.Metadata.Name)},320		)321	}322323	return Container{324		Name:            browserContainer,325		Image:           image,326		Args:            args,327		Env:             environment,328		VolumeMounts:    mounts,329		Resources:       ResourceRequirements{Claims: claims},330		SecurityContext: unprivileged(),331	}332}333334// remoteTopics is the unit's controllers as the two newline-joined335// lists the crate reads. They pair by position: a line's number is the336// controller's place in spec.remotes, which is the index a focus moment337// carries, so a controller with no focus topic contributes an empty338// line rather than shifting the pairing. A unit with no controllers339// carries neither variable.340func remoteTopics(remotes []PlayerIdleRemote) []EnvVar {341	if len(remotes) == 0 {342		return nil343	}344	events := make([]string, 0, len(remotes))345	focuses := make([]string, 0, len(remotes))346	for _, remote := range remotes {347		events = append(events, remote.Events)348		focuses = append(focuses, remote.Focus)349	}350	return []EnvVar{351		{Name: mediaRemoteEventsTopicsVariable, Value: strings.Join(events, "\n")},352		{Name: mediaRemoteFocusTopicsVariable, Value: strings.Join(focuses, "\n")},353	}354}355356// ReconcileScreens brings one namespace's screen pods into line. A357// Player that names this operator as its idle controller gets a claim and a358// pod, and a Player that names another, or none, loses the pod that stands359// for it.360//361// The operator sends two other deletes here. media-operator deletes362// the pod itself when the claim under it must be replaced, and the next pass363// creates it again. A screen the scheduler has refused for longer than the364// grace loses its pod and its catalog claim, which is the recovery in365// screenclaim.go.366//367// A failure on one Player is reported and the pass carries on, because368// one broken screen must not hold up another room's.369func (o *operator) reconcileScreens(ctx context.Context, namespace string, catalog *NamespaceCatalog, players []Player, libraries []Library, screens []Pod, now time.Time) {370	inNamespace := []Library{}371	for index := range libraries {372		if libraries[index].Metadata.Namespace == namespace {373			inNamespace = append(inNamespace, libraries[index])374		}375	}376	// The screen pods that stand in this namespace now, by name. A377	// Player this operator does not serve costs no request unless one378	// of them is its pod, so a cluster full of undelegated units sends379	// no delete on every pass.380	standing := map[string]*Pod{}381	for index := range screens {382		if screens[index].Metadata.Namespace == namespace {383			standing[screens[index].Metadata.Name] = &screens[index]384		}385	}386387	for index := range players {388		player := &players[index]389		if player.Metadata.Namespace != namespace {390			continue391		}392		name := player.Metadata.Name393		if !player.delegated() {394			if standing[screenPodName(name)] == nil {395				continue396			}397			if err := DeletePod(ctx, o.client, namespace, screenPodName(name)); err != nil {398				fmt.Fprintf(os.Stderr, "stopping the screen of %s/%s: %v\n", namespace, name, err)399			}400			continue401		}402		// The recovery runs before the claim and the pod, because a403		// pass that has just deleted both creates them on the next one.404		recovered, err := o.recoverUnschedulableScreen(ctx, player, standing[screenPodName(name)], now)405		if err != nil {406			fmt.Fprintf(os.Stderr, "recovering the screen of %s/%s: %v\n", namespace, name, err)407			continue408		}409		if recovered {410			continue411		}412		// The claim stands before the pod, because a pod that named a413		// claim nothing had created would sit Pending until the next pass.414		if catalog != nil {415			if err := o.standScreenClaim(ctx, player, catalog); err != nil {416				fmt.Fprintf(os.Stderr, "standing the catalog claim of the screen of %s/%s: %v\n",417					namespace, name, err)418				continue419			}420		}421		desired := buildScreenPod(player, inNamespace, catalog, o.browserImage, o.corrosionImage, o.topicBase, o.timeZone)422		if _, err := o.standPod(ctx, desired); err != nil {423			fmt.Fprintf(os.Stderr, "standing the screen of %s/%s: %v\n", namespace, name, err)424		}425	}426}427428// ScreenNamespaces is every namespace that holds a Player, in name429// order. The pass reconciles the screens one namespace at a time, because a430// screen pod mounts the Libraries of its own namespace and no other.431func screenNamespaces(players []Player) []string {432	namespaces := map[string]bool{}433	for index := range players {434		namespaces[players[index].Metadata.Namespace] = true435	}436	return slices.Sorted(maps.Keys(namespaces))437}
series.go 95.9%
1package main23// series.go reads one folder per series into a series item, an episode item4// per episode, and one file row per video file. A file named for two episodes5// is one file that both episode items link to.6//7// A season is a grouping the media browser8// draws from the episodes' season numbers, so the walk records a season on each9// episode and mints no season item.10//11// It reads every file the series folder, its season folders, and its extras12// folders hold. Each one links to the episode whose own name it starts with,13// and to the series where it matches no episode.1415import (16	"context"17	"errors"18	"fmt"19	"io/fs"20	"os"21	"path/filepath"22)2324// walkSeries reads a whole series root into one walkResult by collecting the25// folder stream. The tests and a small library use this whole-root read. It26// keeps no arrival ledger, so a walk of a checked-in tree writes nothing into27// it.28func walkSeries(root, library string, ignore ignoreSet) *walkResult {29	scan := folderScan{root: root, library: library, kind: libraryKindSeries, ignore: ignore}30	return collectFolders(walkTree(context.Background(), root, seriesFolderRule(scan)))31}3233// seriesFolderRule is what the pool in walk.go needs to walk a series volume.34// Every directory under the root is one series, so the rule answers yes to all35// of them and the walk descends no further. A series folder's own season36// folders are read by the folder scan, not by the pool.37func seriesFolderRule(scan folderScan) folderRule {38	return folderRule{39		isTitle: func(string) bool { return true },40		scan: func(dir string, result *walkResult) {41			scanSeriesFolder(scan, dir, result)42		},43		ignore: scan.ignore,44	}45}4647// scanSeriesFolder reads one series folder into the result: the series item48// with its genre rows, and an episode item and a file for every episode under49// it. The identity comes from tvshow.nfo where the folder holds one, and from50// the folder name where it does not. The series' added is the earliest arrival51// among its episodes, and zero where it has none.52func scanSeriesFolder(scan folderScan, dir string, result *walkResult) {53	root, library, ignore := scan.root, scan.library, scan.ignore54	name := filepath.Base(dir)55	meta, identified, err := seriesIdentity(dir, name)56	// The same rule the movies walk follows: a folder whose sidecar could57	// not be read has no identity this pass, so it writes no row.58	if err != nil {59		result.noteReadError(err)60		return61	}6263	title := meta.Title64	if !identified {65		title = name66	}6768	key := folderKey(name)69	seriesID := itemID(scopeSeries, meta.ProviderIDs, key)70	primaryArt, allArt, err := discoverArt(root, dir)71	result.noteReadError(err)7273	body := meta.Body74	body.ProviderIDs = meta.ProviderIDs7576	series := len(result.series)77	result.series = append(result.series, seriesRow{78		Id:       seriesID,79		Library:  library,80		Kind:     libraryKindSeries,81		Path:     relativePath(root, dir),82		Title:    title,83		SortKey:  sortKey(title),84		Slug:     slug(title, meta.Year),85		Released: meta.Released,86		Art:      primaryArt,87		Arts:     allArt,88		Body:     body,89		NFOFacts: meta.NFOFacts,90	})91	result.genres = append(result.genres, genreRows(library, seriesID, body.Genres)...)92	result.aliases = append(result.aliases, aliasRowsForItem(library, scopeSeries, meta.ProviderIDs, key, seriesID)...)93	readLikenSidecar(likenSidecar{root: root, dir: dir, library: library, item: seriesID}, result)94	result.titles++95	if !identified {96		result.unidentified++97		result.unidentifiedNames = append(result.unidentifiedNames, relativePath(root, dir))98	}99100	// The episodes are read first, so the file pass has the two things it101	// needs from them: the videos that already have a row, and the episode102	// each of a season folder's files belongs to.103	folders := newSeriesFolders()104	episodeFiles, err := collectEpisodeFiles(dir, ignore)105	result.noteReadError(err)106	arrivals := episodeArrivals(episodeFiles, result)107	for _, episode := range episodeFiles {108		before := len(result.episodes)109		folders.note(episode, scanEpisode(root, library, seriesID, episode, arrivals[episode], result))110		for _, row := range result.episodes[before:] {111			result.series[series].Added = earliestArrival(result.series[series].Added, row.Added)112		}113	}114115	scanSeriesFiles(root, dir, library, seriesID, ignore, folders, result)116}117118// The arrival of every episode file, read from one ledger per folder that119// holds episodes: a season folder, or the series folder for a file kept120// there.121func episodeArrivals(episodes []episodeFile, result *walkResult) map[episodeFile]fileArrival {122	byDir := map[string][]string{}123	var dirs []string124	for _, episode := range episodes {125		if _, held := byDir[episode.dir]; !held {126			dirs = append(dirs, episode.dir)127		}128		byDir[episode.dir] = append(byDir[episode.dir], episode.file)129	}130	arrivals := map[episodeFile]fileArrival{}131	for _, dir := range dirs {132		held, err := folderArrivals(dir, byDir[dir])133		result.noteReadError(err)134		for file, at := range held {135			arrivals[episodeFile{dir: dir, file: file}] = at136		}137	}138	return arrivals139}140141// seriesFolders is what one series' episode pass leaves for its file pass,142// one entry per directory that held an episode. The three maps travel143// together because the file pass reads all three for every directory.144type seriesFolders struct {145	// episodes answers the episode a file in the directory belongs to, keyed by146	// the episode file's name without its extension.147	episodes map[string]map[string][]string148	// videos is the episode files that already have a row from the episode149	// pass, so the file pass writes no second one.150	videos map[string]map[string]bool151	// items keys on the episode file's own name, which is the item's path152	// relative to the folder, the way a .liken entry names it.153	items map[string]map[string]string154}155156func newSeriesFolders() seriesFolders {157	return seriesFolders{158		episodes: map[string]map[string][]string{},159		videos:   map[string]map[string]bool{},160		items:    map[string]map[string]string{},161	}162}163164// note records one episode file under the directory that holds it. A file the165// scanner could not number reports no id and is left for the file pass.166func (f seriesFolders) note(episode episodeFile, episodeItemIDs []string) {167	if len(episodeItemIDs) == 0 {168		return169	}170	if f.videos[episode.dir] == nil {171		f.videos[episode.dir] = map[string]bool{}172		f.episodes[episode.dir] = map[string][]string{}173		f.items[episode.dir] = map[string]string{}174	}175	f.videos[episode.dir][episode.file] = true176	f.episodes[episode.dir][stripAnyExtension(episode.file)] = episodeItemIDs177	f.items[episode.dir][episode.file] = episodeItemIDs[0]178}179180// Every folder this pass reads files from has its .liken lifted here, because181// the probe records an attempt beside every file it opens, an extras folder's182// among them.183func scanSeriesFiles(root, dir, library, seriesID string, ignore ignoreSet, folders seriesFolders, result *walkResult) {184	rows, subdirectories, err := folderFiles{185		root:    root,186		dir:     dir,187		library: library,188		place:   filePlace{kind: libraryKindSeries},189		item:    constantItem(seriesID),190		held:    folders.videos[dir],191	}.read()192	result.noteReadError(err)193	result.files = append(result.files, rows...)194195	for _, name := range subdirectories {196		if ignore.skips(name) {197			continue198		}199		child := filepath.Join(dir, name)200		place := filePlace{kind: libraryKindSeries}201		if extras := extrasFolderName(name); extras != "" {202			place.extras = extras203		} else {204			place.season = true205		}206		rows, _, err := folderFiles{207			root:    root,208			dir:     child,209			library: library,210			place:   place,211			item:    episodeItem(folders.episodes[child], seriesID),212			held:    folders.videos[child],213		}.read()214		result.noteReadError(err)215		result.files = append(result.files, rows...)216		readLikenSidecar(likenSidecar{217			root: root, dir: child, library: library,218			item: seriesID, items: folders.items[child],219		}, result)220	}221}222223// seriesIdentity reads a series folder's identity, the same ladder the movies224// walk uses: a readable tvshow.nfo with a title, or the folder name,225// identified when the name yields a year or a provider id. The sidecar read226// answers the way movieIdentity's does: an absent sidecar falls through to227// the name, and a sidecar the scanner cannot read is an error.228func seriesIdentity(dir, name string) (seriesMeta, bool, error) {229	data, err := os.ReadFile(filepath.Join(dir, "tvshow.nfo"))230	switch {231	case err == nil:232		if meta, err := parseSeriesNFO(data); err == nil && meta.Title != "" {233			meta.ProviderIDs = mergeProviderIDs(meta.ProviderIDs, parseProviderIDs(name))234			return meta, true, nil235		}236	case !errors.Is(err, fs.ErrNotExist):237		return seriesMeta{}, false, err238	}239	title, year := parseReleaseName(name)240	if title == "" {241		title = name242	}243	ids := parseProviderIDs(name)244	return seriesMeta{245		Title: title, Year: year, Released: releasedFromYear(year), ProviderIDs: ids,246	}, year > 0 || len(ids) > 0, nil247}248249// episodeFile is one episode file and the directory that holds it, so the250// season folder's name is at hand where the sidecar carried no season number.251type episodeFile struct {252	dir  string253	file string254}255256// collectEpisodeFiles reads a series folder's episode files: the files in a257// season folder one level down, and any file directly in the series folder. The258// walk goes one level deep, because a season folder is the only nesting a series259// volume uses.260func collectEpisodeFiles(seriesDir string, ignore ignoreSet) ([]episodeFile, error) {261	var files []episodeFile262	videos, err := listVideoFiles(seriesDir)263	if err != nil {264		return files, err265	}266	for _, file := range videos {267		files = append(files, episodeFile{dir: seriesDir, file: file})268	}269	entries, err := os.ReadDir(seriesDir)270	if err != nil {271		return files, err272	}273	for _, entry := range entries {274		// The season descent skips the same two lists the pool's275		// descent skips: skipName, the closed list of dot-names and service276		// directories that are no season anywhere, and the ignore set this277		// Library declares. Neither one is read, so neither one marks the278		// pass incomplete.279		if !entry.IsDir() || skipName(entry.Name()) || ignore.skips(entry.Name()) {280			continue281		}282		seasonDir := filepath.Join(seriesDir, entry.Name())283		videos, err := listVideoFiles(seasonDir)284		if err != nil {285			return files, err286		}287		for _, file := range videos {288			files = append(files, episodeFile{dir: seasonDir, file: file})289		}290	}291	return files, nil292}293294// scanEpisode reads one episode file into an item per episode it holds and one295// file row for the file itself, and reports the item ids. The file is one file,296// and its path is the primary key of the files table, so a double episode is297// two items and one row. Both items play the file from the start, because298// nothing on the volume says where the second episode begins.299//300// The season and episode numbers come from the301// episode .nfo beside the file where there is one, and from the season folder and302// the file name where none does. An episode the scanner cannot number is left303// out and reports no id, because it has no place under the series.304func scanEpisode(root, library, seriesID string, episode episodeFile, arrival fileArrival, result *walkResult) []string {305	metas, err := episodeIdentity(episode)306	// An episode whose sidecar could not be read has no numbers this307	// pass, and a row read from the file name alone could carry another308	// episode's id.309	if err != nil {310		result.noteReadError(err)311		return nil312	}313	absolute := filepath.Join(episode.dir, episode.file)314	thumb, err := episodeThumb(root, episode.dir, episode.file)315	result.noteReadError(err)316317	var episodeItemIDs []string318	for _, meta := range metas {319		if meta.Episode <= 0 {320			continue321		}322		episodeItemID := episodeID(seriesID, meta.Season, meta.Episode)323		title := meta.Title324		if title == "" {325			title = fmt.Sprintf("S%02dE%02d", meta.Season, meta.Episode)326		}327328		body := meta.Body329		var arts []string330		if thumb != "" {331			arts = []string{thumb}332		}333334		result.episodes = append(result.episodes, episodeRow{335			Id:       episodeItemID,336			Library:  library,337			Kind:     libraryKindSeries,338			Path:     relativePath(root, absolute),339			Title:    title,340			SortKey:  sortKey(title),341			Slug:     slug(title, 0),342			Released: meta.Released,343			Added:    arrival.added,344			Art:      thumb,345			Arts:     arts,346			Duration: meta.Duration,347			Body:     body,348			Series:   seriesID,349			Season:   meta.Season,350			Episode:  meta.Episode,351		})352		result.aliases = append(result.aliases, aliasRowsForItem(library, scopeEpisode, meta.ProviderIDs, "", episodeItemID)...)353		episodeItemIDs = append(episodeItemIDs, episodeItemID)354	}355	if len(episodeItemIDs) == 0 {356		return nil357	}358359	var stream *streamInfo360	if metas[0].Stream.present() {361		stream = &metas[0].Stream362	}363	container, videoCodec, audioCodec, width, height, durationMs := fileAttributes(episode.file, stream)364	size, modified, err := statFile(absolute)365	if err != nil {366		result.noteReadError(err)367		return episodeItemIDs368	}369	class := classifyFile(episode.file, filePlace{kind: libraryKindSeries, season: true})370	result.files = append(result.files, fileRow{371		Path:       relativePath(root, absolute),372		Library:    library,373		Container:  container,374		VideoCodec: videoCodec,375		AudioCodec: audioCodec,376		Width:      width,377		Height:     height,378		SizeBytes:  size,379		DurationMs: durationMs,380		Trickplay:  trickplayFor(root, episode.dir, episode.file),381		Present:    true,382		Type:       class.Type,383		Role:       class.Role,384		Modified:   modified,385		Arrived:    arrival.arrived,386		Items:      episodeItemIDs,387	})388	return episodeItemIDs389}390391// episodeIdentity reads the numbers and the body of every episode one file392// holds. A range marker in the name numbers each of them, within one season.393//394// A sidecar of several episodedetails blocks gives each episode its own title395// and body, in the sidecar's own order. An episode past the last block takes396// the file's technical attributes and no title of its own, so scanEpisode397// names it S04E11 and no episode ever carries another episode's title.398//399// The .nfo beside the file400// is the source where one exists; the season folder and the file name fill a401// number the sidecar left at zero, so a sidecar that names only the episode402// still takes its season from the folder.403func episodeIdentity(episode episodeFile) ([]episodeMeta, error) {404	var blocks []episodeMeta405	nfoPath := filepath.Join(episode.dir, stripExtension(episode.file)+".nfo")406	data, err := os.ReadFile(nfoPath)407	switch {408	case err == nil:409		if parsed, err := parseEpisodeNFOs(data); err == nil {410			blocks = parsed411		}412	case !errors.Is(err, fs.ErrNotExist):413		return nil, err414	}415416	var first episodeMeta417	if len(blocks) > 0 {418		first = blocks[0]419	}420	season, episodeNumbers, marked := parseEpisodeMarker(episode.file)421	if first.Season == 0 {422		if folderSeason, ok := parseSeasonFolder(filepath.Base(episode.dir)); ok {423			first.Season = folderSeason424		} else if marked {425			first.Season = season426		}427	}428	if first.Episode == 0 && marked {429		first.Episode = episodeNumbers[0]430	}431432	metas := []episodeMeta{first}433	for index := 1; index < len(episodeNumbers); index++ {434		next := episodeMeta{Stream: first.Stream, Duration: itemDuration(first.Stream, 0)}435		if index < len(blocks) {436			next = blocks[index]437		}438		next.Season = first.Season439		next.Episode = episodeNumbers[index]440		metas = append(metas, next)441	}442	return metas, nil443}
service.go 100.0%
1package main23// A namespace is a boundary, and the agents of one namespace form one4// Corrosion cluster. They find each other through a headless Service5// named catalog in that namespace. The sidecar image bootstraps to the6// short name catalog, and the pod's own search path resolves it to7// the Service in the pod's namespace. The Service names no selector,8// because this operator writes the EndpointSlice behind it, in9// endpoints.go. The operator creates the Service rather than a10// manifest, because no manifest can know which namespaces hold a11// Library.1213import (14	"context"15	"errors"16	"maps"17	"slices"18)1920// The API group the Service belongs to, and the clusterIP value that21// makes a Service headless. None is the word Kubernetes uses for22// "assign no address": the name resolves to the endpoints themselves.23const (24	serviceAPIVersion = "v1"25	headlessClusterIP = "None"26)2728// The fields the operator reads and writes on the Service, and29// nothing else. ClusterIP is None, which makes the Service headless:30// the name resolves to every peer's own address, and no proxy is in31// the gossip path. ClusterIPs is read and written back untouched,32// because the API server fills it and both fields are immutable.33// PublishNotReadyAddresses is set because an agent that is starting is34// still a peer to gossip with, and a peer list that drops every35// not-ready agent is empty at the moment a cluster forms.36type Service struct {37	APIVersion string      `json:"apiVersion,omitempty"`38	Kind       string      `json:"kind,omitempty"`39	Metadata   ObjectMeta  `json:"metadata"`40	Spec       ServiceSpec `json:"spec"`41}4243// Selector is omitted where it is empty. The catalog Service must state no44// selector, because a Service that states one takes its endpoints from the45// API server in place of the slice this operator writes.46type ServiceSpec struct {47	ClusterIP                string            `json:"clusterIP,omitempty"`48	ClusterIPs               []string          `json:"clusterIPs,omitempty"`49	PublishNotReadyAddresses bool              `json:"publishNotReadyAddresses"`50	Selector                 map[string]string `json:"selector,omitempty"`51	Ports                    []ServicePort     `json:"ports"`52}5354// One port of the Service. The name ties this port to the port of the55// same name on the slice the operator writes. The protocol is UDP56// because Corrosion gossips over QUIC.57type ServicePort struct {58	Name     string `json:"name"`59	Protocol string `json:"protocol"`60	Port     int32  `json:"port"`61}6263// buildCatalogService builds the Service for one namespace. It is a64// function of the namespace and the owners alone, so two passes build65// the same object. The namespace's one Catalog owns the Service, so the66// garbage collector removes it with that Catalog, and the operator needs67// no delete verb.68func buildCatalogService(namespace string, owners []OwnerReference) *Service {69	return &Service{70		APIVersion: serviceAPIVersion,71		Kind:       "Service",72		Metadata: ObjectMeta{73			Name:            catalogServiceName,74			Namespace:       namespace,75			OwnerReferences: owners,76		},77		Spec: ServiceSpec{78			ClusterIP:                headlessClusterIP,79			PublishNotReadyAddresses: true,80			Ports: []ServicePort{81				{Name: catalogPortName, Protocol: catalogPortProtocol, Port: catalogPort},82			},83		},84	}85}8687// standCatalogService brings the live Service into line with the one88// this pass built. It writes on divergence only, the same rule89// standCatalogEndpoints follows. A conflict on the create means90// another writer got there first, which is success.91//92// The update writes the live object with the operator's own fields93// set on it, never the object the pass built. clusterIP and clusterIPs94// are immutable and the API server assigned them, so the write carries95// back what the read answered.96func (o *operator) standCatalogService(ctx context.Context, namespace string, owners []OwnerReference) error {97	desired := buildCatalogService(namespace, owners)9899	live, err := GetService(ctx, o.client, namespace, catalogServiceName)100	if errors.Is(err, ErrNotFound) {101		_, err := CreateService(ctx, o.client, desired)102		if errors.Is(err, ErrConflict) {103			return nil104		}105		return err106	}107	if err != nil {108		return err109	}110111	if sameService(live, desired) {112		return nil113	}114	live.Metadata.OwnerReferences = desired.Metadata.OwnerReferences115	live.Spec.PublishNotReadyAddresses = desired.Spec.PublishNotReadyAddresses116	live.Spec.Selector = desired.Spec.Selector117	live.Spec.Ports = desired.Spec.Ports118	_, err = UpdateService(ctx, o.client, live)119	return err120}121122// sameService compares only what the operator states: the owners, that the123// Service is headless, that it publishes not-ready addresses, that it states124// no selector, and the ports. Everything else on a live Service belongs to125// the API server, and a comparison of it would rewrite the object every pass.126//127// The selector is compared because a selector added by hand would make the128// API server write the endpoints, in place of the EndpointSlice this operator129// writes, and the agents would lose their peer list. The update above clears130// it again.131func sameService(live, desired *Service) bool {132	if !slices.Equal(live.Metadata.OwnerReferences, desired.Metadata.OwnerReferences) {133		return false134	}135	if live.Spec.ClusterIP != desired.Spec.ClusterIP {136		return false137	}138	if live.Spec.PublishNotReadyAddresses != desired.Spec.PublishNotReadyAddresses {139		return false140	}141	if !maps.Equal(live.Spec.Selector, desired.Spec.Selector) {142		return false143	}144	return slices.Equal(live.Spec.Ports, desired.Spec.Ports)145}
sets.go 94.9%
1package main23// sets.go derives the sets item table. A set is the collection a movie4// sidecar names, a film and its sequels, and nothing on the volume holds it,5// so every set row is derived from the movies that name it. A full walk6// derives each set from all of its members as they arrive. A rescan reads7// one folder and cannot see a set's other members, so it derives the set8// again from the movie rows the catalog holds.910import (11	"context"12	"encoding/json"13	"sort"14)1516// scopeSet is the word that leads every set id, beside the scopes in17// rows.go.18const scopeSet = "set"1920// setBody is empty, because a set holds nothing beyond the item header. The21// empty struct marshals to the {} the body column holds by default.22type setBody struct{}2324// setRow is one row of the sets item table: the item header every kind25// carries, with the empty body.26type setRow struct {27	Id       string28	Library  string29	Kind     string30	Path     string31	Title    string32	SortKey  string33	Slug     string34	Released string35	Added    int6436	Art      string37	Duration int6438	Body     setBody39}4041// setID derives a set's id the way itemID derives a movie's: from the42// tmdbcolid the set element carries, or from the slug of the set's name where43// the element carries no id. A set with neither has no id, and the movie that44// names it belongs to no set.45func setID(collectionID, name string) string {46	if collectionID != "" {47		return scopeSet + ":tmdb:" + collectionID48	}49	key := slug(name, 0)50	if key == "" {51		return ""52	}53	return scopeSet + ":name:" + key54}5556// setMember is the one movie a set derives its row from: the earliest57// released of its members. The movie's own id breaks a tie on the date, so58// the fold and the catalog read below pick the same member whatever order the59// walk's workers read the folders in. The added field is the earliest arrival60// among every member, which is not always the earliest-released one's.61type setMember struct {62	set      string63	movie    string64	library  string65	name     string66	released string67	art      string68	added    int6469}7071// earlier reports whether this member replaces the one the fold holds.72func (m setMember) earlier(than setMember) bool {73	if m.released != than.released {74		return m.released < than.released75	}76	return m.movie < than.movie77}7879// row is the set row this member derives. The set has no path, because80// nothing on the volume holds it, and its slug carries no year, because a81// set gains members over time and a year would move.82func (m setMember) row() setRow {83	return setRow{84		Id:       m.set,85		Library:  m.library,86		Kind:     libraryKindMovies,87		Title:    m.name,88		SortKey:  sortKey(m.name),89		Slug:     slug(m.name, 0),90		Released: m.released,91		Added:    m.added,92		Art:      m.art,93	}94}9596// setFold holds the earliest member of every set the walk has read so far.97// It lives for the whole walk, because two members of one set can land in98// different write batches, and a set derived per batch would be overwritten99// by the batch that held only its later member. It holds one entry per set,100// never the whole library.101type setFold map[string]setMember102103// add folds one folder's movie rows in.104func (f setFold) add(movies []movieRow) {105	for _, movie := range movies {106		if movie.SetID == "" {107			continue108		}109		candidate := setMember{110			set:      movie.SetID,111			movie:    movie.Id,112			library:  movie.Library,113			name:     movie.Body.Collection,114			released: movie.Released,115			art:      movie.Art,116			added:    movie.Added,117		}118		held, exists := f[movie.SetID]119		if exists {120			candidate.added = earliestArrival(held.added, candidate.added)121			if !candidate.earlier(held) {122				held.added = candidate.added123				f[movie.SetID] = held124				continue125			}126		}127		f[movie.SetID] = candidate128	}129}130131// rows is the fold as set rows, in id order, so a walk writes the same rows132// in the same order every time.133func (f setFold) rows() []setRow {134	rows := make([]setRow, 0, len(f))135	for _, id := range sortedSetIDs(f) {136		rows = append(rows, f[id].row())137	}138	return rows139}140141// sortedSetIDs is the fold's keys in order.142func sortedSetIDs(f setFold) []string {143	ids := make([]string, 0, len(f))144	for id := range f {145		ids = append(ids, id)146	}147	sort.Strings(ids)148	return ids149}150151// uniqueSetIDs is the list with the empty id and every repeat removed, in152// order, so a reconciliation reads and writes each set one time.153func uniqueSetIDs(ids []string) []string {154	held := map[string]bool{}155	unique := make([]string, 0, len(ids))156	for _, id := range ids {157		if id == "" || held[id] {158			continue159		}160		held[id] = true161		unique = append(unique, id)162	}163	sort.Strings(unique)164	return unique165}166167// setIDsOf is the set ids a folder's movie rows name.168func setIDsOf(movies []movieRow) []string {169	var ids []string170	for _, movie := range movies {171		if movie.SetID != "" {172			ids = append(ids, movie.SetID)173		}174	}175	return ids176}177178// reconcileSets derives each named set again from the movie rows the179// catalog holds for it, and deletes a set with no member left. A rescan180// takes this step because it reads one folder and not the set's other181// members, and because the scoped prune cannot reach a set row: a set has an182// empty path, which is outside every folder's range.183func reconcileSets(ctx context.Context, catalog *Catalog, library string, ids []string) error {184	for _, id := range uniqueSetIDs(ids) {185		member, found, err := catalog.earliestSetMember(ctx, library, id)186		if err != nil {187			return err188		}189		if !found {190			if _, err := catalog.DeleteSets(ctx, library, []string{id}); err != nil {191				return err192			}193			continue194		}195		if _, err := catalog.UpsertSets(ctx, []setRow{member.row()}); err != nil {196			return err197		}198	}199	return nil200}201202// earliestSetMember reads the member a set derives its row from, in the203// order the fold uses. The set's name comes off that movie's body, where the204// sidecar's set name lands.205func (c *Catalog) earliestSetMember(ctx context.Context, library, set string) (setMember, bool, error) {206	member := setMember{set: set, library: library}207	found := false208	err := c.stream(ctx, earliestSetMemberSQL(), []any{library, set}, func(cells []any) error {209		if found || len(cells) < 4 {210			return nil211		}212		member.released, _ = cells[0].(string)213		member.art, _ = cells[1].(string)214		// The query API answers JSON, so an integer column arrives as a215		// float64.216		if added, ok := cells[2].(float64); ok {217			member.added = int64(added)218		}219		body, _ := cells[3].(string)220		var decoded movieBody221		if err := json.Unmarshal([]byte(body), &decoded); err == nil {222			member.name = decoded.Collection223		}224		found = true225		return nil226	})227	return member, found, err228}229230// earliestSetMemberSQL reads one set's earliest member through the231// movies_library_set_id index, and beside it the earliest arrival over every232// member, where an arrival of zero means none is known.233func earliestSetMemberSQL() string {234	return `SELECT released, art,` +235		` (SELECT min(CASE WHEN added > 0 THEN added END) FROM movies WHERE library = ?1 AND set_id = ?2),` +236		` body FROM movies` +237		` WHERE library = ?1 AND set_id = ?2 ORDER BY released, id LIMIT 1`238}239240// setIDsUnder reads the sets the movies of one title folder name, before a241// rescan of that folder writes it again. A movie the rescan moves out of its242// set, or removes, can leave that set without a member.243func (c *Catalog) setIDsUnder(ctx context.Context, library, folder string) ([]string, error) {244	params := []any{library}245	params = append(params, pathScopeParams(folder)...)246	params = append(params, pruneBatch)247	return c.queryStrings(ctx, setIDsUnderSQL(), params)248}249250// setIDsUnderSQL scopes the read to one title folder, the same range over251// the path the scoped prune reads.252func setIDsUnderSQL() string {253	return `SELECT set_id FROM movies WHERE library = ? AND ` + pathScopeClause("path") +254		` AND set_id <> '' LIMIT ?`255}
status.go 97.0%
1package main23// Every status this operator writes comes from the one derivation in4// this file, and the derivation reads nothing but its arguments. The5// shape matters because the loop is level-triggered: a pass must reach6// the same status from the same facts, whatever order the events7// arrived in, and a function of its arguments cannot do otherwise.89import (10	"context"11	"encoding/json"12	"errors"13	"fmt"14	"slices"15	"time"16)1718// Everything one pass observed about one Library, gathered so19// the derivation stays one function of its arguments: what the API20// server says about the storage, which Catalog the namespace resolved21// to and how its pod is doing, whether the schedule stands, what the22// namespace's reporter last said about this library, whether that23// reporter is on the bus, and the namespace the operator's own Service24// is in.25type libraryObservation struct {26	bound             binding27	choice            catalogChoice28	cronJob           *CronJob29	report            *libraryReport30	online            bool31	operatorNamespace string32	// What the Library's ordered sources resolved to against the33	// MetadataProviders this pass checked. An empty reason is a Library that34	// names no source, and that Library carries no Sources condition.35	sources sourcesVerdict36}3738// deriveLibraryStatus builds the whole status of one Library from one39// pass's observation and nothing else. A nil cronJob is a library whose40// schedule does not stand, and a nil report is one the reporter has41// said nothing about yet.42func deriveLibraryStatus(library *Library, seen libraryObservation, now time.Time) LibraryStatus {43	status := LibraryStatus{Volume: seen.bound.volume}4445	// The webhook address names the operator's own Service and this46	// Library, so it holds for the whole life of the Library. It is47	// reported on the same condition the schedule is written on, so a48	// Library that is not being scanned reports no address to send an49	// import to.50	if libraryStands(seen.bound, seen.choice) {51		status.Webhook = webhookURL(seen.operatorNamespace,52			library.Metadata.Namespace, library.Metadata.Name)53	}5455	// The counts, the times, and the runs are the reporter's, carried56	// through as it published them. A Library with no report keeps57	// zeroes, and the Ready condition says why.58	if latest := seen.report; latest != nil {59		status.Titles = latest.Titles60		status.Unidentified = latest.Unidentified61		status.Items = latest.Items62		status.Files = latest.Files63		status.RemovedLastSweep = latest.RemovedLastSweep64		status.LastWalk = latest.LastWalk65		status.LastChange = latest.LastChange66		status.Runs = latest.Runs67		// The gap counts and the two identity counts are the reporter's own,68		// carried through as it published them, so the number the operator69		// schedules on is the number a person reads.70		status.Gaps = latest.Gaps71		status.Waiting = latest.Waiting72		status.Unresolved = latest.Unresolved73		status.Fights = latest.Fights74	}7576	// The conditions are built on a copy of the ones the Library77	// carries, because SetCondition writes in place and the caller78	// compares this status against the Library's own to decide whether79	// to write at all. Writing through the Library's slice would make80	// every status look unchanged.81	conditions := slices.Clone(library.Status.Conditions)82	generation := library.Metadata.Generation83	ready := readyCondition(seen, generation)84	conditions = SetCondition(conditions, boundCondition(seen.bound, generation), now)85	conditions = SetCondition(conditions, ready, now)86	// A Library that names no source carries no Sources condition at all,87	// because there is nothing to report about a list it does not have.88	if seen.sources.reason != "" {89		conditions = SetCondition(conditions, sourcesCondition(seen.sources, generation), now)90	}91	status.Conditions = conditions92	status.Phase = libraryPhase(ready, seen.report)93	return status94}9596// LibraryPhase says what the library is doing, in the word a person reads in97// the status column. It reads the Ready condition this same derivation built,98// so the column and the condition never disagree. The phase is Offline when99// the reporter has left the bus, Pending while any other step of the path is100// missing, Scanning while the report says a walk runs, Enriching while the101// report carries an enrich run that has started and not finished, and Idle102// otherwise.103func libraryPhase(ready Condition, latest *libraryReport) string {104	switch {105	case ready.Reason == reasonOffline:106		return phaseOffline107	case ready.Status != ConditionTrue:108		return phasePending109	case latest != nil && scanRunOf(latest).Failure != "":110		return phaseFailed111	case latest != nil && latest.Walking:112		return phaseScanning113	case latest != nil && enrichInFlight(latest.Runs):114		return phaseEnriching115	default:116		return phaseIdle117	}118}119120// scanRunOf is the full walk's own run out of a report, and an empty run121// where the report carries none. The commit and the failure of a library122// are the scan worker's alone, because a folder rescan reads one folder123// and never the repository.124func scanRunOf(latest *libraryReport) libraryRun {125	run, _ := runOf(latest.Runs, workerScan)126	return run127}128129// Whether an enricher of this library is in flight, which the runs say: a row130// for the enrich worker with a start and no finish. The reporter derives131// Walking the same way from the scan row.132func enrichInFlight(runs []libraryRun) bool {133	run, held := runOf(runs, workerEnrich)134	return held && !run.Started.IsZero() && run.Finished.IsZero()135}136137// The Sources condition reports the providers a Library names: True when138// every one exists and one of them serves the facts this library needs,139// and False with the reason that names what is wrong.140func sourcesCondition(verdict sourcesVerdict, generation int64) Condition {141	status := ConditionFalse142	if verdict.reason == reasonSourcesReady {143		status = ConditionTrue144	}145	return Condition{146		Type:               conditionSources,147		Status:             status,148		ObservedGeneration: generation,149		Reason:             verdict.reason,150		Message:            verdict.message,151	}152}153154// boundCondition reports the storage. The volume is the whole verdict:155// the binding carries one only when the claim exists, is bound, and156// its PersistentVolume was read.157func boundCondition(bound binding, generation int64) Condition {158	status := ConditionFalse159	if bound.volume != nil {160		status = ConditionTrue161	}162	return Condition{163		Type:               conditionBound,164		Status:             status,165		ObservedGeneration: generation,166		Reason:             bound.reason,167		Message:            bound.message,168	}169}170171// ReadyCondition reports whether this library is being scanned.172// Ready is the whole path working: the storage is bound, the namespace173// holds one Catalog whose pod runs with every container ready, the174// schedule stands, the reporter is on the bus, and it has reported this175// library. Each reason names the step that has not happened, so the176// condition says where to look.177func readyCondition(seen libraryObservation, generation int64) Condition {178	condition := Condition{179		Type:               conditionReady,180		Status:             ConditionFalse,181		ObservedGeneration: generation,182	}183	_, blocker := catalogPodBlocker(seen.choice.pod)184	switch {185	case seen.bound.volume == nil:186		condition.Reason = reasonNotBound187		condition.Message = "the library's storage is not bound"188	case seen.choice.catalog == nil:189		// The namespace has no single Catalog, so the Library waits. The190		// reason and message are the catalog choice's own, which name whether191		// the namespace has none or several.192		condition.Reason = seen.choice.reason193		condition.Message = seen.choice.message194	case blocker != "":195		condition.Reason = reasonCatalogPending196		condition.Message = blocker197	case seen.cronJob == nil:198		condition.Reason = reasonScanPending199		condition.Message = "the scan schedule does not stand yet"200	case !seen.online:201		condition.Reason = reasonOffline202		condition.Message = "the namespace's reporter is not on the bus"203	case seen.report == nil:204		condition.Reason = reasonNoReport205		condition.Message = "the reporter has not reported this library yet"206	default:207		condition.Status = ConditionTrue208		condition.Reason = reasonReady209		condition.Message = fmt.Sprintf("the reporter reports %d titles", seen.report.Titles)210	}211	return condition212}213214// everyContainerReady reports whether the kubelet marks every215// container in the pod ready. A pod the kubelet has said nothing about216// is not ready: an empty list is a pod that is still starting, not a217// pod whose containers all passed.218//219// The catalog agent counts here as much as the container beside it, and220// the kubelet reports it under initContainerStatuses because it is a221// native sidecar. A pod whose agent has not opened its API is not up.222func everyContainerReady(pod *Pod) bool {223	if !containerReady(pod.Status.InitContainerStatuses, catalogContainer) {224		return false225	}226	if len(pod.Status.ContainerStatuses) == 0 {227		return false228	}229	for _, container := range pod.Status.ContainerStatuses {230		if !container.Ready {231			return false232		}233	}234	return true235}236237// containerReady reads one named container's readiness out of a status238// list. A container the kubelet has not reported is not ready.239func containerReady(statuses []ContainerStatus, name string) bool {240	for _, status := range statuses {241		if status.Name == name {242			return status.Ready243		}244	}245	return false246}247248// podPendingMessage prefers the kubelet's own words, because the249// ordinary hold is the volume: a pod whose claim no node can mount250// says so, and that sentence is the part a person acts on.251func podPendingMessage(pod *Pod) string {252	if pod.Status.Message != "" {253		return pod.Status.Message254	}255	if pod.Status.Phase == podRunning {256		return "the catalog pod runs and not every container is ready"257	}258	return "the catalog pod has not started"259}260261func podFailureMessage(pod *Pod) string {262	if pod.Status.Message != "" {263		return "the catalog pod failed: " + pod.Status.Message264	}265	if pod.Status.Reason != "" {266		return "the catalog pod failed: " + pod.Status.Reason267	}268	return "the catalog pod failed"269}270271// writeLibraryStatus writes only a status that differs from the one272// the Library carries. Every write bumps the resourceVersion, and this273// operator watches its own collection, so a write on every pass would274// wake the watch that wakes the pass, and the backstop tick would275// become one write per library every ten seconds.276func writeLibraryStatus(ctx context.Context, c *Client, library *Library, desired LibraryStatus) error {277	same, err := sameStatus(library.Status, desired)278	if err != nil || same {279		return err280	}281282	library.Status = desired283	_, err = PutLibraryStatus(ctx, c, library)284	if errors.Is(err, ErrConflict) {285		// Something wrote this Library between the list and this286		// write. That write bumped the resourceVersion, which wakes287		// this operator's own libraries watch, and the pass it wakes288		// reads the fresh copy and derives the status again.289		return nil290	}291	return err292}293294// sameStatus compares the marshaled form, because that is what the API295// server stores and what each field's omitempty decides: two statuses296// that marshal alike write alike.297func sameStatus[T any](current, desired T) (bool, error) {298	was, err := json.Marshal(current)299	if err != nil {300		return false, err301	}302	wants, err := json.Marshal(desired)303	if err != nil {304		return false, err305	}306	return string(was) == string(wants), nil307}
tmdb.go 100.0%
1package main23// tmdb.go is the whole of what the identity fact asks TMDb: a search by4// title and year, and the runtime of one result. A 429 is a cooldown inside5// the container and nothing more. TMDb's limit is about 40 requests a second,6// and one library's gaps never come near it, so no limiter lives anywhere7// else.89import (10	"context"11	"encoding/hex"12	"net/http"13	"net/url"14	"strconv"15	"time"16)1718// The provider's own address, which only a test replaces.19var tmdbAPIBase = "https://api.themoviedb.org"2021// One account with TMDb.22type tmdbClient struct {23	providerRequests24	key string25}2627func newTMDbClient(base, key string) *tmdbClient {28	client := &tmdbClient{key: key}29	client.providerRequests = newProviderRequests(providerBlockTMDb, base,30		func(request *http.Request) { authorizeTMDb(request, client.key) })31	return client32}3334// One search result, with the movie fields and the series fields together,35// because the two searches answer the same shape under two names.36type tmdbResult struct {37	ID            int    `json:"id"`38	Title         string `json:"title"`39	OriginalTitle string `json:"original_title"`40	ReleaseDate   string `json:"release_date"`41	Name          string `json:"name"`42	OriginalName  string `json:"original_name"`43	FirstAirDate  string `json:"first_air_date"`44	// The ladder reads it because two shows of one name are told apart by the45	// country a namer writes after the title.46	OriginCountry []string `json:"origin_country"`47}4849// One accessor answers for both kinds: a movie states title, and a series50// states name.51func (r tmdbResult) name() string {52	if r.Title != "" {53		return r.Title54	}55	return r.Name56}5758func (r tmdbResult) originalName() string {59	if r.OriginalTitle != "" {60		return r.OriginalTitle61	}62	return r.OriginalName63}6465// The year comes off release_date for a movie and first_air_date for a66// series. TMDb states the first release anywhere, which is why the ladder67// also tries the years on either side.68func (r tmdbResult) year() int {69	if r.ReleaseDate != "" {70		return leadingYear(r.ReleaseDate)71	}72	return leadingYear(r.FirstAirDate)73}7475type tmdbSearchAnswer struct {76	Results []tmdbResult `json:"results"`77}7879// A movie states one runtime, and a series states a list of episode runtimes.80// The ladder reads the first of the list, which is the usual length of an81// episode.82type tmdbDetailAnswer struct {83	Runtime        int   `json:"runtime"`84	EpisodeRuntime []int `json:"episode_run_time"`85}8687func (a tmdbDetailAnswer) runtime() time.Duration {88	minutes := a.Runtime89	if minutes == 0 && len(a.EpisodeRuntime) > 0 {90		minutes = a.EpisodeRuntime[0]91	}92	return time.Duration(minutes) * time.Minute93}9495// The search is narrowed by the year the name carried, because a title alone96// matches every remake. A series takes its own year parameter,97// first_air_date_year, where a movie takes year.98func (c *tmdbClient) search(ctx context.Context, kind, title string, year int) ([]tmdbResult, error) {99	query := url.Values{"query": {title}}100	path := "/3/search/movie"101	yearField := "year"102	if kind == libraryKindSeries {103		path = "/3/search/tv"104		yearField = "first_air_date_year"105	}106	if year > 0 {107		query.Set(yearField, strconv.Itoa(year))108	}109	var answer tmdbSearchAnswer110	if err := c.get(ctx, path, query, &answer); err != nil {111		return nil, err112	}113	return answer.Results, nil114}115116// The runtime is a second call, because a search result carries none. The117// ladder makes it only on the rung that needs it.118func (c *tmdbClient) runtime(ctx context.Context, kind string, id int) (time.Duration, error) {119	path := "/3/movie/" + strconv.Itoa(id)120	if kind == libraryKindSeries {121		path = "/3/tv/" + strconv.Itoa(id)122	}123	var answer tmdbDetailAnswer124	if err := c.get(ctx, path, nil, &answer); err != nil {125		return 0, err126	}127	return answer.runtime(), nil128}129130// TMDb takes two credential kinds. A v3 API key is 32 hex characters and131// travels as the api_key query parameter. Anything else is a v4 read access132// token and travels as a bearer token. TMDb refuses a v3 key sent as a bearer133// token, checked against the real API on 2026-09-03. This is the one place134// either caller decides.135const tmdbV3KeyLength = 32136137func authorizeTMDb(request *http.Request, key string) {138	if _, err := hex.DecodeString(key); err == nil && len(key) == tmdbV3KeyLength {139		queryKey("api_key", key)(request)140		return141	}142	request.Header.Set("Authorization", "Bearer "+key)143}144145// The cheapest authenticated read TMDb serves, so the operator's check costs146// one request and names no title.147const tmdbConfigurationPath = "/3/configuration"
tmdbart.go 100.0%
1package main23// The art answerer TMDb serves through. It holds what the other two do not:4// the provider's own settings, which name the image host and the sizes, read5// once for the container and read at all only where a title has an image to6// fetch.78import (9	"context"10	"slices"11)1213// TMDb's art answerer: one account and the settings it read. The settings are14// held here because every address hangs off the image host they name.15type tmdbArtAnswerer struct {16	client        *tmdbClient17	configuration tmdbConfiguration18	read          bool19}2021func newTMDbArtAnswerer(client *tmdbClient) *tmdbArtAnswerer {22	return &tmdbArtAnswerer{client: client}23}2425func (a *tmdbArtAnswerer) providerBlock() string { return providerBlockTMDb }2627func (a *tmdbArtAnswerer) serves(fact string) bool {28	return slices.Contains(providerFacts[providerBlockTMDb], fact)29}3031func (a *tmdbArtAnswerer) fetchFile(ctx context.Context, address string) ([]byte, error) {32	return a.client.fetchFile(ctx, address)33}3435// The one read of the provider's own settings.36func (a *tmdbArtAnswerer) settings(ctx context.Context) (tmdbConfiguration, error) {37	if a.read {38		return a.configuration, nil39	}40	configuration, err := a.client.configuration(ctx)41	if err != nil {42		return tmdbConfiguration{}, err43	}44	a.configuration, a.read = configuration, true45	return configuration, nil46}4748// The images of one gap, from the endpoint its fact reads, with the address49// of each one built from the size that fact asks for. A fact TMDb keeps no50// list for answers nothing. The settings are read after the images, so a51// title the provider has no image for costs one call.52func (a *tmdbArtAnswerer) candidates(ctx context.Context, fact string, gap artGap,53	title titleRef) ([]artCandidate, error) {54	art := artTypes[fact]55	if art.list == "" {56		return nil, nil57	}58	answer, err := a.client.images(ctx, title.kind, fact, gap)59	if err != nil {60		return nil, err61	}62	images := answer.list(art.list)63	if len(images) == 0 {64		return nil, nil65	}66	configuration, err := a.settings(ctx)67	if err != nil {68		return nil, err69	}70	size := configuration.sizeFor(art)71	candidates := []artCandidate{}72	for _, image := range images {73		if image.FilePath == "" {74			continue75		}76		candidates = append(candidates, artCandidate{77			URL:      configuration.imageURL(size, image.FilePath),78			Language: image.Language,79			Votes:    image.VoteAverage,80		})81	}82	return candidates, nil83}
tmdbfacts.go 98.4%
1package main23// The calls the nfo facts make against TMDb, and the answerer that turns them4// into answers. Each fact makes its own call, so a fact that fails leaves the5// others their answers. A title with no TMDb id is no answer and not an6// error.78import (9	"context"10	"slices"11	"strconv"12	"strings"13)1415// The country whose certification the mpaa element carries. TMDb states one16// certification per country.17const tmdbCertificationCountry = "US"1819// Where a TMDb image lives, and the size the profile picture of a credited20// person takes.21const tmdbProfileSize = "original"2223// The host every image path hangs off, which only a test replaces.24var tmdbImageBase = "https://image.tmdb.org/t/p/"2526// Why the cast is cut: a title carries a hundred credited people at TMDb, and27// the sidecar is read on every walk, so the fact writes the billed cast and28// no further.29const tmdbCastLimit = 253031// What the external ids call answers: the ids of the same title in the other32// databases, which is what makes a provider that keys on an IMDb id or a33// TheTVDB id reachable.34type tmdbExternalIDs struct {35	IMDbID string `json:"imdb_id"`36	TVDbID int    `json:"tvdb_id"`37}3839// The ids as a map of the same shape the sidecar and the ledger carry, with40// an id the provider left empty dropped.41func (ids tmdbExternalIDs) providerIDs() providerIDs {42	held := providerIDs{}43	if imdb := strings.TrimSpace(ids.IMDbID); imdb != "" {44		held["imdb"] = imdb45	}46	if ids.TVDbID > 0 {47		held["tvdb"] = strconv.Itoa(ids.TVDbID)48	}49	return held50}5152func (c *tmdbClient) externalIDs(ctx context.Context, kind string, id int) (tmdbExternalIDs, error) {53	var answer tmdbExternalIDs54	err := c.get(ctx, tmdbTitlePath(kind, id)+"/external_ids", nil, &answer)55	return answer, err56}5758// One call answers the whole overview and the score, because TMDb states them59// together on the title itself.60type tmdbDetails struct {61	Overview     string     `json:"overview"`62	Tagline      string     `json:"tagline"`63	Genres       []tmdbName `json:"genres"`64	Companies    []tmdbName `json:"production_companies"`65	Networks     []tmdbName `json:"networks"`66	ReleaseDate  string     `json:"release_date"`67	FirstAirDate string     `json:"first_air_date"`68	Runtime      int        `json:"runtime"`69	EpisodeRun   []int      `json:"episode_run_time"`70	VoteAverage  float64    `json:"vote_average"`71	VoteCount    int        `json:"vote_count"`72}7374type tmdbName struct {75	Name string `json:"name"`76}7778func namesOf(held []tmdbName) []string {79	var names []string80	for _, one := range held {81		if name := strings.TrimSpace(one.Name); name != "" {82			names = append(names, name)83		}84	}85	return names86}8788// Which field each kind states the same value in: a movie states a release89// date and one runtime, and a series states a first air date and the runtime90// of an episode.91func (d tmdbDetails) premiered() string {92	if d.ReleaseDate != "" {93		return d.ReleaseDate94	}95	return d.FirstAirDate96}9798func (d tmdbDetails) runtimeMinutes() int {99	if d.Runtime > 0 {100		return d.Runtime101	}102	if len(d.EpisodeRun) > 0 {103		return d.EpisodeRun[0]104	}105	return 0106}107108func (d tmdbDetails) studios() []string {109	if len(d.Companies) > 0 {110		return namesOf(d.Companies)111	}112	return namesOf(d.Networks)113}114115func (c *tmdbClient) details(ctx context.Context, kind string, id int) (tmdbDetails, error) {116	var answer tmdbDetails117	err := c.get(ctx, tmdbTitlePath(kind, id), nil, &answer)118	return answer, err119}120121// The two kinds answer the certification under two names: a movie states it122// beside each release date, and a series states it as the rating of a123// country.124type tmdbCertifications struct {125	Results []struct {126		Country      string `json:"iso_3166_1"`127		Rating       string `json:"rating"`128		ReleaseDates []struct {129			Certification string `json:"certification"`130		} `json:"release_dates"`131	} `json:"results"`132}133134func (a tmdbCertifications) certificationOf(country string) string {135	for _, result := range a.Results {136		if result.Country != country {137			continue138		}139		if rating := strings.TrimSpace(result.Rating); rating != "" {140			return rating141		}142		for _, release := range result.ReleaseDates {143			if certification := strings.TrimSpace(release.Certification); certification != "" {144				return certification145			}146		}147	}148	return ""149}150151func (c *tmdbClient) certification(ctx context.Context, kind string, id int) (string, error) {152	path := tmdbTitlePath(kind, id) + "/release_dates"153	if kind == libraryKindSeries {154		path = tmdbTitlePath(kind, id) + "/content_ratings"155	}156	var answer tmdbCertifications157	if err := c.get(ctx, path, nil, &answer); err != nil {158		return "", err159	}160	return answer.certificationOf(tmdbCertificationCountry), nil161}162163// A movie states one character per credited person, and a series states the164// characters of every season together, which is why the series answer carries165// a list of roles.166type tmdbCredits struct {167	Cast []struct {168		ID          int    `json:"id"`169		Name        string `json:"name"`170		Character   string `json:"character"`171		Order       int    `json:"order"`172		ProfilePath string `json:"profile_path"`173		Roles       []struct {174			Character string `json:"character"`175		} `json:"roles"`176	} `json:"cast"`177	Crew []tmdbCrewMember `json:"crew"`178}179180// One crew credit. A movie states one job per credit, and a series states181// every job the person held over its seasons, which is why the jobs of a182// credit are a list.183type tmdbCrewMember struct {184	ID         int    `json:"id"`185	Name       string `json:"name"`186	Job        string `json:"job"`187	Department string `json:"department"`188	Jobs       []struct {189		Job string `json:"job"`190	} `json:"jobs"`191}192193func (m tmdbCrewMember) jobs() []string {194	if len(m.Jobs) == 0 {195		return []string{m.Job}196	}197	held := make([]string, 0, len(m.Jobs))198	for _, one := range m.Jobs {199		held = append(held, one.Job)200	}201	return held202}203204// Which crew credits the two parts take. A director is the one job of that205// name, because the directing department also holds the assistants a player206// does not name. A writer is anyone in the writing department, because a207// person credited with the screenplay, the story, or the novel wrote the208// title.209const (210	tmdbDirectorJob       = "Director"211	tmdbWritingDepartment = "Writing"212)213214func (m tmdbCrewMember) directs() bool { return slices.Contains(m.jobs(), tmdbDirectorJob) }215216func (m tmdbCrewMember) writes() bool { return m.Department == tmdbWritingDepartment }217218// The crew of one title that one part takes, in the order TMDb states them,219// with one entry per person, because a person credited twice is one name a220// player reads once. The id tells two people of one name apart, and a person221// TMDb states no id for is told apart by the name alone.222func tmdbCrew(members []tmdbCrewMember, takes func(tmdbCrewMember) bool) []creditedPerson {223	var people []creditedPerson224	held := map[int]bool{}225	for _, member := range members {226		name := strings.TrimSpace(member.Name)227		if name == "" || !takes(member) {228			continue229		}230		if member.ID > 0 && held[member.ID] {231			continue232		}233		if member.ID <= 0 && personIndex(people, name) >= 0 {234			continue235		}236		held[member.ID] = true237		people = append(people, creditedPerson{Name: name, IDs: creditedIDs(member.ID)})238	}239	return people240}241242// The people one title's credits name: the billed cast, cut to the limit, and243// the crew, which has no cut because a title names few of them.244type titleCredits struct {245	Cast      []creditedActor246	Directors []creditedPerson247	Writers   []creditedPerson248}249250func (c *tmdbClient) credits(ctx context.Context, kind string, id int) (titleCredits, error) {251	path := tmdbTitlePath(kind, id) + "/credits"252	if kind == libraryKindSeries {253		path = tmdbTitlePath(kind, id) + "/aggregate_credits"254	}255	var answer tmdbCredits256	if err := c.get(ctx, path, nil, &answer); err != nil {257		return titleCredits{}, err258	}259	cast := make([]creditedActor, 0, len(answer.Cast))260	for _, member := range answer.Cast {261		role := member.Character262		if role == "" && len(member.Roles) > 0 {263			role = member.Roles[0].Character264		}265		cast = append(cast, creditedActor{266			Name:  strings.TrimSpace(member.Name),267			Role:  strings.TrimSpace(role),268			Order: member.Order,269			Thumb: tmdbImageURL(tmdbProfileSize, member.ProfilePath),270			IDs:   creditedIDs(member.ID),271		})272	}273	slices.SortStableFunc(cast, func(one, two creditedActor) int { return one.Order - two.Order })274	if len(cast) > tmdbCastLimit {275		cast = cast[:tmdbCastLimit]276	}277	return titleCredits{278		Cast:      cast,279		Directors: tmdbCrew(answer.Crew, tmdbCrewMember.directs),280		Writers:   tmdbCrew(answer.Crew, tmdbCrewMember.writes),281	}, nil282}283284// The ids one credit carries. A person TMDb states no id for carries none, and285// the credits fact then keys that person on the name alone.286func creditedIDs(id int) providerIDs {287	if id <= 0 {288		return nil289	}290	return providerIDs{contributorTMDbScheme: strconv.Itoa(id)}291}292293// A path the provider left empty is no picture at all.294func tmdbImageURL(size, path string) string {295	if strings.TrimSpace(path) == "" {296		return ""297	}298	return tmdbImageBase + size + path299}300301func tmdbTitlePath(kind string, id int) string {302	if kind == libraryKindSeries {303		return "/3/tv/" + strconv.Itoa(id)304	}305	return "/3/movie/" + strconv.Itoa(id)306}307308// The TMDb answerer: one account, asked for one fact of one title. It answers309// nothing for a title with no TMDb id, because every call it makes keys on310// that id.311type tmdbAnswerer struct {312	client *tmdbClient313}314315func (a tmdbAnswerer) providerBlock() string { return providerBlockTMDb }316317func (a tmdbAnswerer) serves(fact string) bool {318	return slices.Contains(providerFacts[providerBlockTMDb], fact)319}320321func (a tmdbAnswerer) answer(ctx context.Context, fact string, title titleRef) (factAnswer, bool, error) {322	id, err := strconv.Atoi(title.ids["tmdb"])323	if err != nil || id <= 0 {324		return factAnswer{}, false, nil325	}326	switch fact {327	case factOverview:328		return a.overview(ctx, title.kind, id)329	case factCertification:330		return a.certification(ctx, title.kind, id)331	case factRatingTMDb:332		return a.rating(ctx, title.kind, id)333	case factCredits:334		return a.credits(ctx, title.kind, id)335	}336	return factAnswer{}, false, nil337}338339func (a tmdbAnswerer) overview(ctx context.Context, kind string, id int) (factAnswer, bool, error) {340	details, err := a.client.details(ctx, kind, id)341	if err != nil {342		return factAnswer{}, false, err343	}344	answer := factAnswer{345		Plot:           strings.TrimSpace(details.Overview),346		Tagline:        strings.TrimSpace(details.Tagline),347		Genres:         namesOf(details.Genres),348		Studios:        details.studios(),349		Premiered:      strings.TrimSpace(details.premiered()),350		RuntimeMinutes: details.runtimeMinutes(),351	}352	return answer, answersFact(factOverview, answer), nil353}354355func (a tmdbAnswerer) certification(ctx context.Context, kind string, id int) (factAnswer, bool, error) {356	certification, err := a.client.certification(ctx, kind, id)357	if err != nil {358		return factAnswer{}, false, err359	}360	return factAnswer{Certification: certification}, certification != "", nil361}362363// A title nobody has voted on has no rating to write, so a score of zero is364// no answer.365func (a tmdbAnswerer) rating(ctx context.Context, kind string, id int) (factAnswer, bool, error) {366	details, err := a.client.details(ctx, kind, id)367	if err != nil {368		return factAnswer{}, false, err369	}370	if details.VoteAverage <= 0 {371		return factAnswer{}, false, nil372	}373	return factAnswer{Rating: &titleRating{Value: details.VoteAverage, Votes: details.VoteCount}}, true, nil374}375376func (a tmdbAnswerer) credits(ctx context.Context, kind string, id int) (factAnswer, bool, error) {377	credits, err := a.client.credits(ctx, kind, id)378	if err != nil {379		return factAnswer{}, false, err380	}381	answer := factAnswer{Cast: credits.Cast, Directors: credits.Directors, Writers: credits.Writers}382	held := len(credits.Cast) > 0 || len(credits.Directors) > 0 || len(credits.Writers) > 0383	return answer, held, nil384}
tmdbperson.go 100.0%
1package main23// What the three contributor facts ask TMDb: the person, the person's ids in4// the other databases, and the headshot. Each fact makes its own call, so a5// fact that fails leaves the others their answers.67import (8	"context"9	"strconv"10	"strings"11)1213// The size the headshot fact fetches. TMDb serves a profile in w45, w185,14// h632, and the original. h632 is the one the browser can draw a person at15// full height from, and it is far under the 2 MiB a decode draws in bands at.16// w185 is a thumbnail the browser would have to enlarge.17const tmdbHeadshotSize = "h632"1819// The person call, which answers the birth date, the death date, the20// biography, and the path of the headshot. Three facts read it, each for its21// own fields.22type tmdbPerson struct {23	Name        string `json:"name"`24	Biography   string `json:"biography"`25	Birthday    string `json:"birthday"`26	Deathday    string `json:"deathday"`27	ProfilePath string `json:"profile_path"`28}2930func (c *tmdbClient) person(ctx context.Context, id string) (tmdbPerson, error) {31	var answer tmdbPerson32	err := c.get(ctx, tmdbPersonPath(id), nil, &answer)33	return answer, err34}3536func tmdbPersonPath(id string) string {37	return "/3/person/" + id38}3940// The ids of the same person in the other databases. The three below are the41// schemes that name a person; the social handles the same answer carries name42// an account and never a person, so the ids fact leaves them.43type tmdbPersonIDs struct {44	IMDbID     string `json:"imdb_id"`45	WikidataID string `json:"wikidata_id"`46	TVRageID   int    `json:"tvrage_id"`47}4849func (ids tmdbPersonIDs) providerIDs() providerIDs {50	held := providerIDs{}51	if imdb := strings.TrimSpace(ids.IMDbID); imdb != "" {52		held["imdb"] = imdb53	}54	if wikidata := strings.TrimSpace(ids.WikidataID); wikidata != "" {55		held["wikidata"] = wikidata56	}57	if ids.TVRageID > 0 {58		held["tvrage"] = strconv.Itoa(ids.TVRageID)59	}60	return held61}6263func (c *tmdbClient) personIDs(ctx context.Context, id string) (providerIDs, error) {64	var answer tmdbPersonIDs65	if err := c.get(ctx, tmdbPersonPath(id)+"/external_ids", nil, &answer); err != nil {66		return nil, err67	}68	return answer.providerIDs(), nil69}
topics.go 100.0%
1package main23// The bus topic layout. No pod this operator stands holds an API4// credential, so every fact a scanner learns about a library, and5// every choice a person makes on a screen, reaches the control plane6// over the bus. This file builds the topics the pods publish and the7// filters the operator subscribes to, so it is the public contract of8// the bus and the one place another program reads to follow a library9// or to ask for a play.10//11// The broker is the one the media operator runs. Each topic extends a12// base the operator holds as one string, liken/library by default, so13// this operator's tree and the media operator's liken/media tree stay14// disjoint on the same broker. A base that carries a cluster's name so15// several clusters share one broker is a later refinement the string16// already allows.1718import "strings"1920// defaultTopicBase is the base every topic extends when the operator21// sets none.22const defaultTopicBase = "liken/library"2324// The two words an availability topic carries. The reporter names25// its topic as the MQTT Last Will with offline as the payload, and26// publishes online once it connects, so a retained report a killed pod27// left behind does not read as a running reporter.28const (29	availabilityOnline  = "online"30	availabilityOffline = "offline"31)3233// The kind at the end of a libraries topic. parseLibraryTopic returns34// one of these so the operator folds a report and an availability35// signal through separate paths.36const (37	libraryStatusKind       = "status"38	libraryAvailabilityKind = "availability"39)4041// Carries one Library's report: its counts, the folders no42// sidecar identified, and the runs of every worker. The namespace's43// reporter publishes it retained, so an operator that restarts reads the44// current counts back from the broker without waiting for a walk.45func libraryStatusTopic(base, namespace, name string) string {46	return base + "/libraries/" + namespace + "/" + name + "/" + libraryStatusKind47}4849// The per-Library availability topic. No pod publishes it any more,50// because the reporter publishes one availability per namespace. The51// departure still clears it, so a retained message an older scanner52// pod left behind goes with the Library.53func libraryAvailabilityTopic(base, namespace, name string) string {54	return base + "/libraries/" + namespace + "/" + name + "/" + libraryAvailabilityKind55}5657// libraryStatusFilter is the subscription that reaches every Library's58// report, whatever namespace and name it carries. The two plus signs59// are the MQTT single-level wildcards for the namespace and the name.60func libraryStatusFilter(base string) string {61	return base + "/libraries/+/+/" + libraryStatusKind62}6364// libraryAvailabilityFilter is the subscription that reaches every65// scanner's availability signal.66func libraryAvailabilityFilter(base string) string {67	return base + "/libraries/+/+/" + libraryAvailabilityKind68}6970// Carries online or offline for the namespace's one reporter, the71// container beside the standing catalog agent that publishes every72// library's report.73func catalogAvailabilityTopic(base, namespace string) string {74	return base + "/catalogs/" + namespace + "/" + libraryAvailabilityKind75}7677// The subscription that reaches every namespace's reporter.78func catalogAvailabilityFilter(base string) string {79	return base + "/catalogs/+/" + libraryAvailabilityKind80}8182// Maps an inbound catalogs topic back to the namespace it names,83// so the operator folds one reporter's availability.84func parseCatalogAvailabilityTopic(base, topic string) (namespace string, ok bool) {85	prefix := base + "/catalogs/"86	if !strings.HasPrefix(topic, prefix) {87		return "", false88	}89	parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")90	if len(parts) != 2 || parts[0] == "" || parts[1] != libraryAvailabilityKind {91		return "", false92	}93	return parts[0], true94}9596// playRequestKind is the last level of a play request topic. A screen97// pod publishes what a person chose here, because the browser holds98// the catalog and the operator holds the credential that creates a99// Play.100const playRequestKind = "play"101102// playRequestTopic carries one Player's play requests. The operator103// sets it on the browser container, because the browser knows neither104// this operator's topic base nor the Player's name.105func playRequestTopic(base, namespace, player string) string {106	return base + "/players/" + namespace + "/" + player + "/" + playRequestKind107}108109// playRequestFilter is the subscription that reaches every Player's110// play requests. A media browser of another make that publishes here111// gets the same service.112func playRequestFilter(base string) string {113	return base + "/players/+/+/" + playRequestKind114}115116// parsePlayRequestTopic maps an inbound play topic back to the Player117// it names. The operator creates a Play only for a Player it serves,118// so the topic is what says which Player asked.119func parsePlayRequestTopic(base, topic string) (namespace, player string, ok bool) {120	prefix := base + "/players/"121	if !strings.HasPrefix(topic, prefix) {122		return "", "", false123	}124	parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")125	if len(parts) != 3 {126		return "", "", false127	}128	namespace, player = parts[0], parts[1]129	if namespace == "" || player == "" || parts[2] != playRequestKind {130		return "", "", false131	}132	return namespace, player, true133}134135// parseLibraryTopic maps an inbound libraries topic back to the136// Library it names and the kind of message it carries. The operator137// subscribes to the two filters above, and each wildcard subscription138// carries messages for every Library on one stream, so the topic is139// what says which Library a message belongs to.140func parseLibraryTopic(base, topic string) (namespace, name, kind string, ok bool) {141	prefix := base + "/libraries/"142	if !strings.HasPrefix(topic, prefix) {143		return "", "", "", false144	}145	parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")146	if len(parts) != 3 {147		return "", "", "", false148	}149	namespace, name, kind = parts[0], parts[1], parts[2]150	if namespace == "" || name == "" {151		return "", "", "", false152	}153	if kind != libraryStatusKind && kind != libraryAvailabilityKind {154		return "", "", "", false155	}156	return namespace, name, kind, true157}
trickplay.go 96.2%
1package main23// The trickplay fact's whole run: the gap of videos with a length and no tiles4// beside them, the ffmpeg pass over one of them, and the sheets and the map5// created where none exist. The fact asks no provider, because the file alone6// answers it, and it writes nothing into the .nfo.78import (9	"context"10	"fmt"11	"os"12	"path/filepath"13	"time"14)1516// The name of the container that runs this fact.17const trickplayContainerName = "trickplay"1819// How much memory this container may take, and the share of a core it asks20// for. Both are above the scanner's, because ffmpeg decodes a video where21// every other container reads rows and files.22const (23	trickplayMemoryLimit = "512Mi"24	trickplayCPURequest  = "500m"25)2627// The gap. A video the probe gave a length to, with no trickplay directory28// beside it in the catalog, outside the retry window. The scanner writes the29// column from the directory it finds, so the tiles this fact writes close the30// gap on the next walk.31func trickplayGapSQL() string {32	return `SELECT path, duration_ms FROM files ` +33		`WHERE library = ?1 AND type = '` + fileTypeVideo + `' AND present = 1 ` +34		`AND duration_ms > 0 AND video_codec != '' ` +35		`AND ` + gapClause(factTrickplay, "path", `trickplay = ''`)36}3738// One gap: the file to open, and the length the probe wrote, which is what39// says how many thumbnails cover it.40type trickplayGap struct {41	path     string42	duration time.Duration43}4445// The work list, out of the local copy of the catalog, with the same query the46// reporter counts the gap with.47func (c *Catalog) trickplayGaps(ctx context.Context, library string,48	now, refresh time.Time) ([]trickplayGap, error) {49	var gaps []trickplayGap50	err := c.stream(ctx, gapQueries[factTrickplay], gapParams(factTrickplay, library, now, refresh),51		func(cells []any) error {52			if len(cells) < 2 {53				return nil54			}55			path, _ := cells[0].(string)56			if path == "" {57				return nil58			}59			gaps = append(gaps, trickplayGap{60				path:     path,61				duration: time.Duration(cellNumber(cells[1])) * time.Millisecond,62			})63			return nil64		})65	if err != nil {66		return nil, fmt.Errorf("reading the %s gap of %s: %w", factTrickplay, library, err)67	}68	return gaps, nil69}7071// The whole run. A catalog read that fails ends the container, because the gap72// list is the work. A file ffmpeg cannot read records an error attempt, and73// the run carries on to the next file. The files run one at a time, so one74// ffmpeg holds the container's memory line.75func (e *enricher) trickplayFact(ctx context.Context) error {76	gaps, err := e.catalog.trickplayGaps(ctx, e.library, time.Now().UTC(), e.refresh[factTrickplay])77	if err != nil {78		return err79	}80	written := 081	for _, gap := range gaps {82		if err := ctx.Err(); err != nil {83			return err84		}85		if !e.inScope(gap.path) {86			continue87		}88		if e.trickplayOne(ctx, gap) {89			written++90		}91	}92	e.logf("wrote the trickplay of %d of the %d files that had none", written, len(gaps))93	return nil94}9596// One file. The volume is read before ffmpeg runs, because a directory that97// landed since the last walk is the answer already and costs no decode, and98// the ledger records that the tiles were already there.99func (e *enricher) trickplayOne(ctx context.Context, gap trickplayGap) bool {100	absolute := filepath.Join(e.root, gap.path)101	folder, entry := likenFolderFor(e.kind, absolute)102	target := trickplayDirectory(absolute)103	if dirExists(target) {104		e.recordArt(folder, factTrickplay, entry, artProviderExisting, attemptFound)105		return false106	}107	// The line goes out before the decode, because a decode of a feature108	// runs for minutes with nothing else to say.109	e.logf("tiling %s, %s long", filepath.Base(absolute), gap.duration.Round(time.Second))110	result := e.buildTrickplay(ctx, absolute, target, gap.duration)111	e.recordArt(folder, factTrickplay, entry, "", result)112	return result == attemptFound113}114115// The decode and the write. ffmpeg writes its sheets under a staging name that116// carries the temporary mark, and one rename lands the whole tree, so the117// directory a player reads holds every sheet of the title or does not exist. A118// run that ends before the rename leaves the staging alone on the volume, and119// the run that follows it clears that staging first.120func (e *enricher) buildTrickplay(ctx context.Context, input, target string, duration time.Duration) string {121	staging, err := e.writer.stageTree(target)122	if err != nil {123		e.logf("could not stage the trickplay of %s: %v", filepath.Base(input), err)124		return attemptError125	}126	defer func() {127		if err := e.writer.removeTemporaryTree(staging); err != nil {128			e.logf("could not clear %s: %v", staging, err)129		}130	}()131132	sheets, result := e.stageTrickplay(ctx, input, staging, duration)133	if result != attemptFound {134		return result135	}136	landed, err := e.writer.createTree(target)137	if err != nil {138		e.logf("could not write %s: %v", target, err)139		return attemptError140	}141	if landed {142		e.logf("wrote %d trickplay sheets under %s", sheets, target)143	}144	return attemptFound145}146147// The staged tree, which is the whole directory a player reads. ffmpeg tiles148// its sheets straight into the folder that states the width and the grid, so149// no sheet is ever read back to be written again, and the map goes beside150// them. The tile size comes off the first sheet's own header, so the map151// states the region ffmpeg actually wrote.152func (e *enricher) stageTrickplay(ctx context.Context, input, staging string,153	duration time.Duration) (int, string) {154	tiles := filepath.Join(staging, trickplayTilesFolder())155	if err := os.MkdirAll(tiles, volumeDirectoryPerm); err != nil {156		e.logf("could not stage the trickplay of %s: %v", filepath.Base(input), err)157		return 0, attemptError158	}159	// A decode ffmpeg refuses is the file's own state, and not a fault of160	// the run, so it is a miss with a date and the long window applies: a file161	// that will not decode today will not decode tomorrow. A run a signal162	// ended is an error, and the error window applies.163	if err := ffmpegSheets(ctx, input, tiles); err != nil {164		e.logf("could not tile %s: %v", filepath.Base(input), err)165		if ffmpegRefused(err) {166			return 0, attemptNothing167		}168		return 0, attemptError169	}170	sheets, err := sheetsIn(tiles)171	if err != nil {172		e.logf("could not read the sheets of %s: %v", filepath.Base(input), err)173		return 0, attemptError174	}175	if len(sheets) == 0 {176		e.logf("ffmpeg read no frame of %s", filepath.Base(input))177		return 0, attemptNothing178	}179	tileWidth, tileHeight, err := tileSize(filepath.Join(tiles, sheets[0]))180	if err != nil {181		e.logf("could not measure %s: %v", sheets[0], err)182		return 0, attemptError183	}184	index := trickplayVTT(trickplayTiles(duration, len(sheets)), tileWidth, tileHeight, duration)185	if err := e.writer.writeInto(tiles, trickplayIndexName, index); err != nil {186		e.logf("could not write %s: %v", filepath.Join(tiles, trickplayIndexName), err)187		return 0, attemptError188	}189	return len(sheets), attemptFound190}
trickplaysheets.go 100.0%
1package main23// The trickplay layout: the folder name Jellyfin writes beside a video, the4// one ffmpeg call that tiles the thumbnails into sheets, and the WebVTT that5// maps a time range onto a region of one sheet. The layout is Jellyfin's, read6// off the lab's own volume on 2026-09-03: <video base>.trickplay/<width> -7// <columns>x<rows>/<index>.jpg, with the grid in the folder name and the8// sheets numbered from zero. Jellyfin serves the map from its API and writes9// none, so the WebVTT beside the sheets is this project's own. See10// https://forum.jellyfin.org/t-trickplay-location and11// https://jellyfin.org/docs/general/server/media/trickplay-images/.1213import (14	"context"15	"errors"16	"fmt"17	"image"1819	// Image.DecodeConfig reads a sheet's header only through the format that20	// registers itself here.21	_ "image/jpeg"22	"os"23	"os/exec"24	"path/filepath"25	"slices"26	"strconv"27	"strings"28	"time"29)3031// The four numbers of the layout. Ten seconds is Jellyfin's own interval, 32032// px is its thumbnail width, and ten by ten is its grid, so a player that33// reads one library reads both.34const (35	trickplayInterval = 10 * time.Second36	trickplayWidth    = 32037	trickplayColumns  = 1038	trickplayRows     = 1039)4041// One sheet holds the whole grid, padded where the video ends inside it.42const trickplayTilesPerSheet = trickplayColumns * trickplayRows4344// The extension every sheet carries, and the name of the map beside them.45const (46	sheetExtension     = ".jpg"47	trickplayIndexName = "tiles.vtt"48)4950// One file's bound, so a video the decoder will not finish cannot hold the51// container open. An hour is above the longest title the lab holds.52var ffmpegTimeout = time.Hour5354// The directory the tiles of one video go beside it under, which is the file's55// own name with the extension replaced. names.go reads the same name back for56// the catalog's column.57func trickplayDirectory(absolute string) string {58	return strings.TrimSuffix(absolute, filepath.Ext(absolute)) + trickplayExtension59}6061// The folder inside it, which states the width and the grid, so a second width62// is a second folder and neither reads the other's sheets.63func trickplayTilesFolder() string {64	return fmt.Sprintf("%d - %dx%d", trickplayWidth, trickplayColumns, trickplayRows)65}6667func sheetName(index int) string {68	return strconv.Itoa(index) + sheetExtension69}7071// The one call that opens a video. One decode pass writes every sheet to its72// own file, so the frames of a whole title are never held in memory, and the73// container runs one of these at a time.74func ffmpegSheets(ctx context.Context, input, directory string) error {75	timed, cancel := context.WithTimeout(ctx, ffmpegTimeout)76	defer cancel()7778	// The height follows the source's own aspect, rounded to an even number,79	// which is what the JPEG encoder takes.80	filter := fmt.Sprintf("fps=1/%d,scale=%d:-2,tile=%dx%d",81		int(trickplayInterval.Seconds()), trickplayWidth, trickplayColumns, trickplayRows)82	command := exec.CommandContext(timed, "ffmpeg",83		"-nostdin", "-loglevel", "error", "-i", input,84		"-an", "-sn", "-dn", "-vf", filter, "-qscale:v", "4",85		"-start_number", "0", "-f", "image2", filepath.Join(directory, "%d"+sheetExtension))86	output, err := command.CombinedOutput()87	if err != nil {88		return fmt.Errorf("ffmpeg %s: %w: %s", filepath.Base(input), err, strings.TrimSpace(string(output)))89	}90	return nil91}9293// Whether ffmpeg ended the run itself, with an exit code, which is what it94// does for a file it cannot read. A run a signal ended, such as a kill for95// memory, has no exit code, and it says nothing about the file.96func ffmpegRefused(err error) bool {97	var exit *exec.ExitError98	return errors.As(err, &exit) && exit.ExitCode() >= 099}100101// The sheets one run left, in the order ffmpeg numbered them. A name that is102// not a number is not a sheet, so a stray file in the staging directory never103// becomes a tile.104func sheetsIn(directory string) ([]string, error) {105	entries, err := os.ReadDir(directory)106	if err != nil {107		return nil, err108	}109	var names []string110	for _, entry := range entries {111		name := entry.Name()112		if entry.IsDir() || filepath.Ext(name) != sheetExtension {113			continue114		}115		if _, err := strconv.Atoi(strings.TrimSuffix(name, sheetExtension)); err != nil {116			continue117		}118		names = append(names, name)119	}120	slices.SortFunc(names, func(a, b string) int { return sheetIndex(a) - sheetIndex(b) })121	return names, nil122}123124// A name that reached the list above parses, so a failure here is impossible125// and reads as the first sheet.126func sheetIndex(name string) int {127	index, _ := strconv.Atoi(strings.TrimSuffix(name, sheetExtension))128	return index129}130131// The size of one thumbnail, out of the first sheet's own header. The grid is132// fixed, so the sheet's width and height divided by it are the tile, and no133// frame is decoded to learn them.134func tileSize(sheet string) (int, int, error) {135	file, err := os.Open(sheet)136	if err != nil {137		return 0, 0, err138	}139	defer file.Close()140	config, _, err := image.DecodeConfig(file)141	if err != nil {142		return 0, 0, fmt.Errorf("reading %s: %w", filepath.Base(sheet), err)143	}144	return config.Width / trickplayColumns, config.Height / trickplayRows, nil145}146147// How many thumbnails cover a title of this length, bounded by what ffmpeg148// actually wrote, so the map never names a tile the last padded sheet holds no149// frame for.150func trickplayTiles(duration time.Duration, sheets int) int {151	tiles := int(duration / trickplayInterval)152	if duration%trickplayInterval > 0 {153		tiles++154	}155	return min(tiles, sheets*trickplayTilesPerSheet)156}157158// The map itself. One cue per thumbnail, naming the sheet and the region of it159// the thumbnail sits in, and the last cue ends at the title's own end and not160// at the end of its ten seconds.161func trickplayVTT(tiles, tileWidth, tileHeight int, duration time.Duration) []byte {162	var out strings.Builder163	out.WriteString("WEBVTT\n")164	for tile := range tiles {165		start := time.Duration(tile) * trickplayInterval166		end := min(start+trickplayInterval, duration)167		column := tile % trickplayColumns168		row := tile / trickplayColumns % trickplayRows169		fmt.Fprintf(&out, "\n%s --> %s\n%s#xywh=%d,%d,%d,%d\n",170			vttTimestamp(start), vttTimestamp(end), sheetName(tile/trickplayTilesPerSheet),171			column*tileWidth, row*tileHeight, tileWidth, tileHeight)172	}173	return []byte(out.String())174}175176// The timestamp WebVTT states, hours to milliseconds, with every field padded,177// because a reader takes no short form.178func vttTimestamp(at time.Duration) string {179	milliseconds := at.Milliseconds()180	return fmt.Sprintf("%02d:%02d:%02d.%03d", milliseconds/3_600_000,181		milliseconds/60_000%60, milliseconds/1_000%60, milliseconds%1_000)182}
tvmaze.go 100.0%
1package main23// What the series facts ask TVmaze: a lookup by an id another provider gave,4// the show itself, its cast, and its images. TVmaze serves series alone and5// takes no account, so a MetadataProvider of this block names no Secret. Its6// limit is 20 calls every 10 seconds per address, and a 429 takes the7// cooldown every provider takes.89import (10	"context"11	"net/http"12	"net/url"13	"strconv"14)1516// The provider's own address, which only a test replaces.17var tvmazeAPIBase = "https://api.tvmaze.com"1819// The paths TVmaze answers on, and the show the check reads, which is the20// first show TVmaze holds.21const (22	tvmazeLookupPath = "/lookup/shows"23	tvmazeShowsPath  = "/shows/"24	tvmazeCheckPath  = tvmazeShowsPath + "1"25)2627// The schemes a lookup takes, which are the ids the identity fact writes into28// the .nfo.29const (30	tvmazeSchemeIMDb    = "imdb"31	tvmazeSchemeTheTVDB = "thetvdb"32)3334// TVmaze needs no account, so this client holds the address and nothing else.35type tvmazeClient struct {36	providerRequests37}3839func newTVmazeClient(base string) *tvmazeClient {40	return &tvmazeClient{newProviderRequests(providerBlockTVmaze, base, nil)}41}4243// One show, with what the overview fact reads beside the ids the identity44// fact reads. The summary is HTML, which the nfo strips.45type tvmazeShow struct {46	ID             int             `json:"id"`47	Name           string          `json:"name"`48	Premiered      string          `json:"premiered"`49	Genres         []string        `json:"genres"`50	Runtime        int             `json:"runtime"`51	AverageRuntime int             `json:"averageRuntime"`52	Summary        string          `json:"summary"`53	Rating         tvmazeRating    `json:"rating"`54	Network        *tvmazeNetwork  `json:"network"`55	WebChannel     *tvmazeNetwork  `json:"webChannel"`56	Image          tvmazeImage     `json:"image"`57	Externals      tvmazeExternals `json:"externals"`58}5960type tvmazeRating struct {61	Average float64 `json:"average"`62}6364// Who broadcast the show. A show on a streaming service names a webChannel65// and no network.66type tvmazeNetwork struct {67	ID   int    `json:"id"`68	Name string `json:"name"`69}7071type tvmazeImage struct {72	Medium   string `json:"medium"`73	Original string `json:"original"`74}7576// The ids of the other databases, which is what makes a TVmaze answer an77// identity.78type tvmazeExternals struct {79	IMDb    string `json:"imdb"`80	TheTVDB int    `json:"thetvdb"`81	TVRage  int    `json:"tvrage"`82}8384// One person in the cast, and the character they play.85type tvmazeCastMember struct {86	Person    tvmazePerson    `json:"person"`87	Character tvmazeCharacter `json:"character"`88}8990type tvmazePerson struct {91	ID    int         `json:"id"`92	Name  string      `json:"name"`93	Image tvmazeImage `json:"image"`94}9596type tvmazeCharacter struct {97	ID   int    `json:"id"`98	Name string `json:"name"`99}100101// One image of a show. The type is poster, banner, background, or typography,102// and every image carries its original resolution.103type tvmazeArtwork struct {104	ID          int               `json:"id"`105	Type        string            `json:"type"`106	Main        bool              `json:"main"`107	Resolutions tvmazeResolutions `json:"resolutions"`108}109110type tvmazeResolutions struct {111	Original tvmazeResolution `json:"original"`112	Medium   tvmazeResolution `json:"medium"`113}114115type tvmazeResolution struct {116	URL    string `json:"url"`117	Width  int    `json:"width"`118	Height int    `json:"height"`119}120121// The art types TVmaze names, which the art facts read.122const (123	tvmazeArtworkPoster     = "poster"124	tvmazeArtworkBanner     = "banner"125	tvmazeArtworkBackground = "background"126)127128// The show one id names, under the scheme that id belongs to. TVmaze answers129// 404 for an id it does not hold, which is a miss and not an error.130func (c *tvmazeClient) lookup(ctx context.Context, scheme, id string) (*tvmazeShow, error) {131	answer := &tvmazeShow{}132	if err := c.get(ctx, tvmazeLookupPath, url.Values{scheme: {id}}, answer); err != nil {133		if answeredWith(err, http.StatusNotFound) {134			return nil, nil135		}136		return nil, err137	}138	return answer, nil139}140141// One show by TVmaze's own id, which is what the lookup answered.142func (c *tvmazeClient) show(ctx context.Context, id int) (*tvmazeShow, error) {143	answer := &tvmazeShow{}144	if err := c.get(ctx, tvmazeShowsPath+strconv.Itoa(id), nil, answer); err != nil {145		return nil, err146	}147	return answer, nil148}149150// The cast of one show, in the order TVmaze holds it, which the credits fact151// writes as the actors.152func (c *tvmazeClient) cast(ctx context.Context, id int) ([]tvmazeCastMember, error) {153	answer := []tvmazeCastMember{}154	if err := c.get(ctx, tvmazeShowsPath+strconv.Itoa(id)+"/cast", nil, &answer); err != nil {155		return nil, err156	}157	return answer, nil158}159160// Every image of one show, of every type. Each art fact narrows the list to161// the type it writes.162func (c *tvmazeClient) images(ctx context.Context, id int) ([]tvmazeArtwork, error) {163	answer := []tvmazeArtwork{}164	if err := c.get(ctx, tvmazeShowsPath+strconv.Itoa(id)+"/images", nil, &answer); err != nil {165		return nil, err166	}167	return answer, nil168}169170// The images of one type, in the order TVmaze holds them.171func artworkOfType(images []tvmazeArtwork, kind string) []tvmazeArtwork {172	held := []tvmazeArtwork{}173	for _, image := range images {174		if image.Type == kind {175			held = append(held, image)176		}177	}178	return held179}
tvmazeart.go 100.0%
1package main23// The art answerer TVmaze serves through. TVmaze keys on its own show id, so4// a title reaches its images through one lookup on an id another provider5// gave, and that lookup is held for the rest of the container.67import (8	"context"9	"slices"10)1112// TVmaze's art answerer, which needs no account. It holds the nfo answerer's13// lookup, so the art facts and the nfo facts read one rule for which id a14// show is found by, and one held answer per id.15type tvmazeArtAnswerer struct {16	shows tvmazeAnswerer17}1819func newTVmazeArtAnswerer(client *tvmazeClient) *tvmazeArtAnswerer {20	return &tvmazeArtAnswerer{shows: newTVmazeAnswerer(client)}21}2223func (a *tvmazeArtAnswerer) providerBlock() string { return providerBlockTVmaze }2425func (a *tvmazeArtAnswerer) serves(fact string) bool {26	return slices.Contains(providerFacts[providerBlockTVmaze], fact)27}2829func (a *tvmazeArtAnswerer) fetchFile(ctx context.Context, address string) ([]byte, error) {30	return a.shows.client.fetchFile(ctx, address)31}3233// Which TVmaze type each art fact reads. TVmaze names three types this34// project writes, and a fact outside the three is no answer.35var tvmazeArtworkTypes = map[string]string{36	factPoster:   tvmazeArtworkPoster,37	factBackdrop: tvmazeArtworkBackground,38	factBanner:   tvmazeArtworkBanner,39}4041// The images of one series, of the type the fact writes. TVmaze holds series42// alone, so a movie is no answer, and a show it answers nothing for is a miss43// and not an error.44func (a *tvmazeArtAnswerer) candidates(ctx context.Context, fact string, gap artGap,45	title titleRef) ([]artCandidate, error) {46	kind, held := tvmazeArtworkTypes[fact]47	if !held || title.kind != libraryKindSeries {48		return nil, nil49	}50	show, err := a.shows.show(ctx, title.ids)51	if err != nil || show == nil || show.ID == 0 {52		return nil, err53	}54	images, err := a.shows.client.images(ctx, show.ID)55	if err != nil {56		return nil, err57	}58	return tvmazeCandidates(artworkOfType(images, kind)), nil59}6061// One list as the choice reads it. TVmaze gives two things less than the62// others: an image carries no language and no vote, so the order is the whole63// choice, and the image TVmaze marks as the main one leads the list.64func tvmazeCandidates(images []tvmazeArtwork) []artCandidate {65	candidates := []artCandidate{}66	for _, main := range []bool{true, false} {67		for _, image := range images {68			if image.Main != main || image.Resolutions.Original.URL == "" {69				continue70			}71			candidates = append(candidates, artCandidate{URL: image.Resolutions.Original.URL})72		}73	}74	return candidates75}
tvmazefacts.go 100.0%
1package main23// What the nfo facts make of TVmaze. TVmaze holds series alone and takes no4// account. The lookup keys on the IMDb id or the TheTVDB id the identity fact5// wrote, the show it answers carries the overview, and one more call carries6// the cast. A movie library's title is no answer.78import (9	"context"10	"html"11	"regexp"12	"slices"13	"strings"14)1516// TVmaze states the summary as HTML. A paragraph and a line break end a line,17// every other tag goes, and the entities read as the characters a person18// wrote.19var (20	tvmazeLineBreaks = strings.NewReplacer(21		"</p>", "\n", "<br>", "\n", "<br/>", "\n", "<br />", "\n")22	tvmazeTag = regexp.MustCompile(`<[^>]*>`)23)2425func tvmazePlot(summary string) string {26	text := html.UnescapeString(tvmazeTag.ReplaceAllString(tvmazeLineBreaks.Replace(summary), ""))27	var paragraphs []string28	for _, paragraph := range strings.Split(text, "\n") {29		if paragraph = strings.TrimSpace(paragraph); paragraph != "" {30			paragraphs = append(paragraphs, paragraph)31		}32	}33	return strings.Join(paragraphs, "\n\n")34}3536// Who broadcast the show is the studio the sidecar carries. A show on a37// streaming service names a web channel where a broadcaster would be.38func tvmazeStudios(show tvmazeShow) []string {39	for _, source := range []*tvmazeNetwork{show.Network, show.WebChannel} {40		if source == nil {41			continue42		}43		if name := strings.TrimSpace(source.Name); name != "" {44			return []string{name}45		}46	}47	return nil48}4950// TVmaze states the runtime of an episode twice: the length of a slot and the51// length the episodes average.52func tvmazeRuntimeMinutes(show tvmazeShow) int {53	if show.Runtime > 0 {54		return show.Runtime55	}56	return show.AverageRuntime57}5859// The cast in the order TVmaze holds it, which is the billing order the60// sidecar carries, with the character each person plays and the picture61// TVmaze holds of them.62func tvmazeCast(members []tvmazeCastMember) []creditedActor {63	cast := make([]creditedActor, 0, len(members))64	for at, member := range members {65		cast = append(cast, creditedActor{66			Name:  strings.TrimSpace(member.Person.Name),67			Role:  strings.TrimSpace(member.Character.Name),68			Order: at,69			Thumb: strings.TrimSpace(member.Person.Image.Original),70		})71	}72	return cast73}7475// The TVmaze answerer. It needs no key, and it answers nothing for a movie76// library, because TVmaze holds series alone. The show each id answered is77// held for the life of the container, so the facts of one title cost one78// lookup, and an id TVmaze does not hold is held as no show at all.79type tvmazeAnswerer struct {80	client *tvmazeClient81	shows  map[string]*tvmazeShow82}8384func newTVmazeAnswerer(client *tvmazeClient) tvmazeAnswerer {85	return tvmazeAnswerer{client: client, shows: map[string]*tvmazeShow{}}86}8788func (a tvmazeAnswerer) providerBlock() string { return providerBlockTVmaze }8990// The facts this answerer asks TVmaze for. The table's row also names the91// identity and the art the show carries; the identity ladder does not ask92// TVmaze yet, and the art facts ask it through their own answerer.93var tvmazeAnsweredFacts = []string{factOverview, factCredits}9495func (a tvmazeAnswerer) serves(fact string) bool {96	return slices.Contains(tvmazeAnsweredFacts, fact)97}9899func (a tvmazeAnswerer) answer(ctx context.Context, fact string, title titleRef) (factAnswer, bool, error) {100	if !a.serves(fact) || title.kind != libraryKindSeries {101		return factAnswer{}, false, nil102	}103	show, err := a.show(ctx, title.ids)104	if err != nil || show == nil {105		return factAnswer{}, false, err106	}107	if fact == factCredits {108		return a.credits(ctx, show.ID)109	}110	answer := factAnswer{111		Plot:           tvmazePlot(show.Summary),112		Genres:         show.Genres,113		Studios:        tvmazeStudios(*show),114		Premiered:      strings.TrimSpace(show.Premiered),115		RuntimeMinutes: tvmazeRuntimeMinutes(*show),116	}117	return answer, answersFact(factOverview, answer), nil118}119120// Which id the lookup keys on. TVmaze answers on an IMDb id or a TheTVDB id.121// The IMDb id is asked first because every provider states one. A title with122// neither id, and an id TVmaze does not hold, are both no answer.123func (a tvmazeAnswerer) show(ctx context.Context, ids providerIDs) (*tvmazeShow, error) {124	for _, key := range []struct{ sidecar, scheme string }{125		{sidecar: "imdb", scheme: tvmazeSchemeIMDb},126		{sidecar: "tvdb", scheme: tvmazeSchemeTheTVDB},127	} {128		id := strings.TrimSpace(ids[key.sidecar])129		if id == "" {130			continue131		}132		show, err := a.lookup(ctx, key.scheme, id)133		if err != nil || show != nil {134			return show, err135		}136	}137	return nil, nil138}139140// The one lookup an id costs. The show the container holds is read again for141// every other fact of the same title, and an id already looked up makes no142// request at all.143func (a tvmazeAnswerer) lookup(ctx context.Context, scheme, id string) (*tvmazeShow, error) {144	if held, cached := a.shows[scheme+"="+id]; cached {145		return held, nil146	}147	show, err := a.client.lookup(ctx, scheme, id)148	if err != nil {149		return nil, err150	}151	a.shows[scheme+"="+id] = show152	return show, nil153}154155func (a tvmazeAnswerer) credits(ctx context.Context, id int) (factAnswer, bool, error) {156	members, err := a.client.cast(ctx, id)157	if err != nil {158		return factAnswer{}, false, err159	}160	cast := tvmazeCast(members)161	return factAnswer{Cast: cast}, len(cast) > 0, nil162}
volumewrite.go 95.3%
1package main23// volumewrite.go is the one door every enricher write to a library volume4// goes through. On the lab that volume is the production copy, so the rules5// here are what keep a bad write from losing a file a person cares about: a6// temporary and a rename, an edit of one element, and a remove that refuses7// every name but a temporary's.89import (10	"bytes"11	"encoding/xml"12	"errors"13	"fmt"14	"io"15	"io/fs"16	"os"17	"path/filepath"18	"strings"19)2021// The mark every temporary carries. The remove below checks for it, and the22// scanner reads it as a junk name, so a stray temporary never becomes a row.23const likenTempMark = ".liken-tmp-"2425// The modes a new file and a new directory take on the volume.26const (27	volumeFilePerm      fs.FileMode = 0o64428	volumeDirectoryPerm fs.FileMode = 0o75529)3031// One Job's writes to the volume, named by the Job so two Jobs never share a32// temporary.33type volumeWriter struct {34	job string35}3637// A test or a local run may hold no Job name. The temporary still needs a38// suffix after the mark, so an unnamed Job writes as a Job called job.39func newVolumeWriter(job string) *volumeWriter {40	if job == "" {41		job = "job"42	}43	return &volumeWriter{job: job}44}4546// The temporary sits in the target's own directory and carries the target's47// name, so the rename is one directory entry and a person who finds a stray48// knows which file it was for.49func (w *volumeWriter) temporary(target string) string {50	dir, base := filepath.Split(target)51	return filepath.Join(dir, base+likenTempMark+w.job)52}5354// The whole write rule: a temporary in the same directory, flushed, then55// renamed onto the target. A crash leaves a stray temporary and never a56// half-written file. The rename lands on a target that may exist, which is57// how an edited .nfo replaces the one before it.58func (w *volumeWriter) write(target string, data []byte) error {59	temporary := w.temporary(target)60	if err := w.stage(temporary, data); err != nil {61		return err62	}63	if err := os.Rename(temporary, target); err != nil {64		_ = w.removeTemporary(temporary)65		return err66	}67	return nil68}6970// The write that never lands on a file that exists. The link fails where the71// target is there, and the filesystem itself decides, so two writers never72// lose one of the two files. The answer says whether this call wrote the73// file. Art takes this door and not write, because a poster another tool74// wrote is a file a person kept, and plan 30 leaves it.75func (w *volumeWriter) createOnce(target string, data []byte) (bool, error) {76	temporary := w.temporary(target)77	if err := w.stage(temporary, data); err != nil {78		return false, err79	}80	linked := os.Link(temporary, target)81	_ = w.removeTemporary(temporary)82	if errors.Is(linked, fs.ErrExist) {83		return false, nil84	}85	if linked != nil {86		return false, linked87	}88	return true, nil89}9091// The temporary both writes start from: opened, written, flushed, and closed,92// so the bytes are on the disk before any name points at them. A failure93// takes the temporary with it.94func (w *volumeWriter) stage(temporary string, data []byte) error {95	file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, volumeFilePerm)96	if err != nil {97		return err98	}99	if err := writeAndSync(file, data); err != nil {100		file.Close()101		_ = w.removeTemporary(temporary)102		return err103	}104	if err := file.Close(); err != nil {105		_ = w.removeTemporary(temporary)106		return err107	}108	return nil109}110111// The bytes reach the disk before the rename names them, so a power loss112// after the rename never leaves an empty target on the volume.113func writeAndSync(file *os.File, data []byte) error {114	if _, err := file.Write(data); err != nil {115		return err116	}117	return file.Sync()118}119120// The one remove in the enrichers. It refuses every name that does not carry121// the temporary mark, so no code path in this binary can delete a file a122// person or another tool wrote. A test reads every other file for a remove123// and fails the build on one.124func (w *volumeWriter) removeTemporary(path string) error {125	if !strings.Contains(filepath.Base(path), likenTempMark) {126		return fmt.Errorf("refusing to remove %s: it carries no %s mark", path, likenTempMark)127	}128	return os.Remove(path)129}130131// The staging door for a tool that writes its own files. The directory carries132// the temporary mark, so the remove below takes it and the scanner reads133// nothing under it as a title's file. A staging a crashed run left behind goes134// first, so the tool never reads that run's output as its own.135func (w *volumeWriter) stageTree(target string) (string, error) {136	staging := w.temporary(target)137	if err := w.removeTemporaryTree(staging); err != nil {138		return "", err139	}140	if err := os.MkdirAll(staging, volumeDirectoryPerm); err != nil {141		return "", err142	}143	return staging, nil144}145146// The create door for a whole directory. Every file in the staged tree and147// every directory of it reaches the disk, then one rename lands the tree under148// its real name, so a reader sees the whole directory or none of it. The Lstat149// is what refuses a target that is there: it decides, because a rename onto an150// existing empty directory would succeed and take it. The rename's own failure151// on a directory that holds files is the backstop for the window between the152// two, where another writer created the target. A failure takes the staged153// tree with it, and the answer says whether this call landed it.154func (w *volumeWriter) createTree(target string) (bool, error) {155	staging := w.temporary(target)156	if _, err := os.Lstat(target); err == nil {157		return false, w.removeTemporaryTree(staging)158	} else if !errors.Is(err, fs.ErrNotExist) {159		return false, err160	}161	if err := syncTree(staging); err != nil {162		_ = w.removeTemporaryTree(staging)163		return false, err164	}165	if err := os.Rename(staging, target); err != nil {166		_ = w.removeTemporaryTree(staging)167		return false, err168	}169	return true, nil170}171172// Every file first, then the directory that names it, so the rename above173// lands a tree whose bytes and whose entries are both on the disk.174func syncTree(directory string) error {175	entries, err := os.ReadDir(directory)176	if err != nil {177		return err178	}179	for _, entry := range entries {180		under := filepath.Join(directory, entry.Name())181		if entry.IsDir() {182			err = syncTree(under)183		} else {184			err = syncPath(under)185		}186		if err != nil {187			return err188		}189	}190	return syncPath(directory)191}192193// One open and one sync. A directory answers this call the way a file does,194// which is how the entries under it reach the disk.195func syncPath(path string) error {196	file, err := os.Open(path)197	if err != nil {198		return err199	}200	defer file.Close()201	return file.Sync()202}203204// The remove that takes a staging directory and everything under it. It205// refuses every name that does not carry the temporary mark, the rule206// removeTemporary holds, so the files under a name a person wrote are out of207// reach of this binary.208func (w *volumeWriter) removeTemporaryTree(path string) error {209	if !strings.Contains(filepath.Base(path), likenTempMark) {210		return fmt.Errorf("refusing to remove %s: it carries no %s mark", path, likenTempMark)211	}212	return os.RemoveAll(path)213}214215// A .liken directory does not exist until the first fact writes into it,216// so the directory is created before the file lands in it.217func (w *volumeWriter) writeInto(directory, name string, data []byte) error {218	if err := os.MkdirAll(directory, volumeDirectoryPerm); err != nil {219		return err220	}221	return w.write(filepath.Join(directory, name), data)222}223224// The create door for a directory that may not exist yet, which is what a225// person's own directory under .contributors/ is on its first write. It is226// createOnce and never write, so a file another writer put there is kept.227func (w *volumeWriter) createInto(directory, name string, data []byte) (bool, error) {228	if err := os.MkdirAll(directory, volumeDirectoryPerm); err != nil {229		return false, err230	}231	return w.createOnce(filepath.Join(directory, name), data)232}233234// The element an edit inserts or replaces, and the attribute that tells one235// uniqueid from another where a document holds several.236type xmlElement struct {237	name      string238	attribute string239	value     string240}241242// The surgical edit: one element in, every other byte as it was, so nothing243// another tool wrote is lost. The whole document is never parsed into values244// and written back, because a round trip drops every element the parser does245// not model.246func editElement(document []byte, element xmlElement, replacement []byte) ([]byte, error) {247	spans, err := elementSpans(document, element)248	if err != nil {249		return nil, err250	}251	if spans.start >= 0 {252		return splice(document, spans.start, spans.end, replacement), nil253	}254	if spans.rootEnd < 0 {255		return nil, errors.New("the document has no root element to insert into")256	}257	return splice(document, spans.rootEnd, spans.rootEnd, spans.insertion(document, replacement)), nil258}259260// hasRootElement reports whether a document holds an element to edit. An261// empty file, or an XML declaration with nothing under it, holds none. A262// document the parser stops on counts as holding one here, so the edit itself263// names the error and the bytes stay as they were.264func hasRootElement(document []byte) bool {265	decoder := lenientXML(document)266	for {267		token, err := decoder.Token()268		if errors.Is(err, io.EOF) {269			return false270		}271		if err != nil {272			return true273		}274		if _, isStart := token.(xml.StartElement); isStart {275			return true276		}277	}278}279280// An inserted element takes the indentation the document's own children281// carry, so the edit reads as the same hand wrote it. The indentation the282// replacement already carries is dropped first, so the block is indented once283// and not twice.284func (s documentSpans) insertion(document, replacement []byte) []byte {285	lead := trailingWhitespace(document[:s.rootEnd])286	block := append([]byte{}, replacement...)287	if len(afterLastNewline(lead)) == 0 && s.firstChild >= 0 {288		indent := afterLastNewline(trailingWhitespace(document[:s.firstChild]))289		block = append(append([]byte{}, indent...), bytes.TrimLeft(replacement, " \t")...)290	}291	return append(block, lead...)292}293294// Only the run after the last newline counts as indentation. Blank lines295// above it belong to the document's spacing, not to the child's margin.296func afterLastNewline(space []byte) []byte {297	if at := bytes.LastIndexByte(space, '\n'); at >= 0 {298		return space[at+1:]299	}300	return space301}302303// The result is a new slice, so the caller's document is never written over.304func splice(document []byte, start, end int, replacement []byte) []byte {305	out := make([]byte, 0, len(document)-(end-start)+len(replacement))306	out = append(out, document[:start]...)307	out = append(out, replacement...)308	return append(out, document[end:]...)309}310311// The run of whitespace before the root's end tag is repeated after an312// inserted element, so the indentation the document already had holds.313func trailingWhitespace(document []byte) []byte {314	at := len(document)315	for at > 0 && isXMLSpace(document[at-1]) {316		at--317	}318	return document[at:]319}320321func isXMLSpace(c byte) bool {322	return c == ' ' || c == '\t' || c == '\n' || c == '\r'323}324325// The three places in a document an edit reads: the element it may replace,326// where the root's end tag begins, and where the first child begins.327type documentSpans struct {328	start      int329	end        int330	rootEnd    int331	firstChild int332}333334// Reads those three places in one pass over the document, so the edit needs335// no parse of the whole tree into values. Only the root's direct children are336// candidates, because every element the facts edit sits there.337func elementSpans(document []byte, element xmlElement) (documentSpans, error) {338	spans := documentSpans{start: -1, end: -1, rootEnd: -1, firstChild: -1}339	decoder := lenientXML(document)340	depth := 0341	for {342		before := int(decoder.InputOffset())343		token, err := decoder.Token()344		if errors.Is(err, io.EOF) {345			return spans, nil346		}347		if err != nil {348			return spans, err349		}350		switch typed := token.(type) {351		case xml.StartElement:352			depth++353			if depth != 2 {354				continue355			}356			if spans.firstChild < 0 {357				spans.firstChild = before358			}359			if spans.start >= 0 || !elementMatches(typed, element) {360				continue361			}362			if err := decoder.Skip(); err != nil {363				return spans, err364			}365			spans.start, spans.end, depth = before, int(decoder.InputOffset()), depth-1366		case xml.EndElement:367			depth--368			if depth == 0 && spans.rootEnd < 0 {369				spans.rootEnd = before370			}371		}372	}373}374375// An element with no attribute named matches by its name alone, which is the376// ordinary case.377func elementMatches(token xml.StartElement, element xmlElement) bool {378	if token.Name.Local != element.name {379		return false380	}381	if element.attribute == "" {382		return true383	}384	for _, attribute := range token.Attr {385		if attribute.Name.Local == element.attribute && attribute.Value == element.value {386			return true387		}388	}389	return false390}
walk.go 100.0%
1package main23// walk.go reads a library's title folders with a fixed pool of workers. Almost4// all of a walk is waiting: every folder costs a directory read, a sidecar5// read, and a stat of each file, and each of those is a round trip to a network6// volume. Folders share no state, and their rows are written by key, so the7// order they are read in changes nothing, and eight folders read at once wait8// eight times less.9//10// A worker takes one directory and classifies it. A title folder is scanned11// into its rows. A grouping folder is read, and its child directories go back12// to the pool. So the descent through a movies volume's grouping folders is13// parallel with the scanning, and no one worker classifies folders for the14// rest.1516import (17	"context"18	"errors"19	"fmt"20	"io/fs"21	"iter"22	"os"23	"path/filepath"24	"sync"25)2627// How many folders the walk reads at once. Eight requests in flight keep a28// network volume busy, without a burst large enough to slow the players that29// read the same server, and the scanner then holds at most eight folders and30// one flush buffer. It is a var so a test drives one worker and eight over the31// same tree.32var walkWorkers = 83334// walkDirectory is one directory waiting for a worker. The depth travels with35// it, because the descent runs in the workers and no one goroutine counts the36// levels for the rest.37type walkDirectory struct {38	path  string39	depth int40}4142// folderRule is what one kind needs to walk a directory: a test for a title43// folder, how to scan one into its rows, which names to skip, and how deep to44// descend. The movies rule reads a folder's contents to tell a title folder45// from a grouping folder. Every directory under a series root is a series, so46// the series rule answers yes to all of them and descends no further. The pool47// below is the same code for both kinds.48type folderRule struct {49	isTitle  func(dir string) bool50	scan     func(dir string, result *walkResult)51	ignore   ignoreSet52	maxDepth int53}5455// read classifies one directory. A title folder is scanned into its rows. A56// grouping folder hands its child directories back to the pool.57//58// A directory the walk cannot read marks the pass incomplete, wherever it is in59// the tree. The walk would otherwise miss every title under it, and the prune60// would then delete their rows as departed. The one exception is a directory61// below the root that no longer exists, which is a title deleted while the walk62// ran. That is an ordinary event on a live volume, and the next walk reports63// the deletion.64//65// A directory past the depth cap is unread in the same way, so it marks66// the pass incomplete rather than returning silently.67func (r folderRule) read(dir walkDirectory) (*walkResult, []walkDirectory) {68	if dir.depth > 0 && r.isTitle(dir.path) {69		folder := &walkResult{}70		r.scan(dir.path, folder)71		return folder, nil72	}73	if dir.depth > r.maxDepth {74		return unreadDirectory(dir.path, fmt.Errorf("deeper than the cap of %d grouping folders", r.maxDepth)), nil75	}76	entries, err := os.ReadDir(dir.path)77	if err != nil {78		if dir.depth > 0 && errors.Is(err, fs.ErrNotExist) {79			return nil, nil80		}81		return unreadDirectory(dir.path, err), nil82	}83	var children []walkDirectory84	for _, entry := range entries {85		// Two lists keep a directory out of the walk: skipName, the86		// closed list of dot-names and service directories that hold no87		// media anywhere, and the ignore set, the folders this Library88		// names. Neither one is read, so neither one marks the pass89		// incomplete.90		if !entry.IsDir() || skipName(entry.Name()) || r.ignore.skips(entry.Name()) {91			continue92		}93		children = append(children, walkDirectory{94			path:  filepath.Join(dir.path, entry.Name()),95			depth: dir.depth + 1,96		})97	}98	return nil, children99}100101// unreadDirectory is what one directory the walk could not read leaves102// behind: the incomplete mark that holds the prune back, and the path and the103// error the collector logs. A summary that says only that some read failed104// leaves a person with nothing to fix.105func unreadDirectory(path string, err error) *walkResult {106	return &walkResult{readError: true, readFailures: []walkReadFailure{{path: path, err: err}}}107}108109// walkTree streams the title folders under a root to the caller's loop, which110// is the walk's one collector: it appends the rows, sums the counts, and111// flushes a full buffer. The channel is unbuffered, so a worker waits for the112// collector to take its folder, and the rows in flight are the eight the113// workers hold and no more.114func walkTree(ctx context.Context, root string, rule folderRule) iter.Seq[*walkResult] {115	return func(yield func(*walkResult) bool) {116		pool := newWalkPool(walkDirectory{path: root})117		folders := make(chan *walkResult)118		var workers sync.WaitGroup119		for range walkWorkers {120			workers.Add(1)121			go func() {122				defer workers.Done()123				pool.work(ctx, rule, folders)124			}()125		}126		go func() {127			workers.Wait()128			close(folders)129		}()130		// The pool stops and the folders drain, whether the collector read131		// the whole tree or stopped early. The drain releases a worker132		// waiting to hand over a folder, and the channel closes only after133		// every worker has returned, so no worker outlives the walk.134		defer func() {135			pool.stop()136			for range folders {137			}138		}()139140		for folder := range folders {141			if !yield(folder) {142				return143			}144		}145	}146}147148// walkPool holds the directories waiting for a worker, and the count of the149// directories the walk has not finished.150//151// The count is what ends the walk. A closed channel cannot end it, because a152// worker makes more work: it reads a grouping folder and hands back its153// children. A worker adds those children before it marks its own directory154// finished, so the count reaches zero only when the whole tree is read.155type walkPool struct {156	mutex       sync.Mutex157	ready       *sync.Cond158	waiting     []walkDirectory159	outstanding int160	stopped     bool161}162163func newWalkPool(start walkDirectory) *walkPool {164	pool := &walkPool{waiting: []walkDirectory{start}, outstanding: 1}165	pool.ready = sync.NewCond(&pool.mutex)166	return pool167}168169// work is one worker. It takes a directory, reads it, hands the rows to the170// collector, and queues whatever children the directory held, until the pool171// empties or the context ends. The context is read between folders, so a172// shutdown stops the walk within one folder and never inside one.173func (p *walkPool) work(ctx context.Context, rule folderRule, folders chan<- *walkResult) {174	for {175		if ctx.Err() != nil {176			p.stop()177			return178		}179		dir, taken := p.take()180		if !taken {181			return182		}183		folder, children := rule.read(dir)184		if folder != nil {185			folders <- folder186		}187		p.add(children)188		p.finish()189	}190}191192// take waits for a directory and reports false once the walk has stopped. A193// worker waits only while another worker is still reading, because a wait194// means the queue is empty and the count is not yet zero.195func (p *walkPool) take() (walkDirectory, bool) {196	p.mutex.Lock()197	defer p.mutex.Unlock()198	for {199		if p.stopped {200			return walkDirectory{}, false201		}202		if len(p.waiting) > 0 {203			dir := p.waiting[len(p.waiting)-1]204			p.waiting = p.waiting[:len(p.waiting)-1]205			return dir, true206		}207		p.ready.Wait()208	}209}210211// add queues the child directories one grouping folder held, and counts them212// as outstanding before the parent reports itself finished.213func (p *walkPool) add(children []walkDirectory) {214	if len(children) == 0 {215		return216	}217	p.mutex.Lock()218	defer p.mutex.Unlock()219	p.waiting = append(p.waiting, children...)220	p.outstanding += len(children)221	p.ready.Broadcast()222}223224// finish marks one directory read. The directory that brings the count to zero225// is the last in the tree, so the pool stops there and every worker returns.226func (p *walkPool) finish() {227	p.mutex.Lock()228	defer p.mutex.Unlock()229	p.outstanding--230	if p.outstanding == 0 {231		p.stopped = true232		p.ready.Broadcast()233	}234}235236// stop ends every worker, whether the tree was read or not. A cancelled237// context and a collector that stopped reading both end the walk this way.238func (p *walkPool) stop() {239	p.mutex.Lock()240	defer p.mutex.Unlock()241	p.stopped = true242	p.ready.Broadcast()243}
watch.go 94.2%
1package main23// A watch is an ordinary GET with watch=true whose response never4// ends: the API server holds the connection open and writes one JSON5// event per change, the same protocol liken's own operators speak.6//7// A watch carries no object to the loop. Every pass re-lists, so a8// change here is only a wake, and the loop decides what to read.910import (11	"context"12	"encoding/json"13	"fmt"14	"net/http"15	"os"16	"sync/atomic"17	"time"18)1920// A watch is a request whose response never ends, so it carries a context21// with no deadline and no cancel; the bounded contexts belong to the22// passes in operate.go. A watcher runs for the life of the process, and23// the process ending is what ends it.24func watchContext() context.Context {25	return context.Background()26}2728// WatchRetryPause is how long a watcher waits before it re-lists after29// a dropped stream, and a variable so a test drives a reconnect in30// milliseconds. It is an atomic because a watcher has no stop: the31// watchers one test's operator started outlive that test and read the32// pause while a later test writes it.33var watchRetryPause = newPause(2 * time.Second)3435// A duration that goroutines read while a test writes it.36type pause struct {37	nanos atomic.Int6438}3940func newPause(d time.Duration) *pause {41	p := &pause{}42	p.set(d)43	return p44}4546func (p *pause) get() time.Duration {47	return time.Duration(p.nanos.Load())48}4950func (p *pause) set(d time.Duration) {51	p.nanos.Store(int64(d))52}5354// WatchLibraries resumes each stream from a resourceVersion, so no55// change is missed between reconnects. A 410 Gone and a routine stream56// end recover the same way: list the collection, wake the loop, and57// watch again from the list's own version.58//59// The list after every ended stream is what keeps the resume point60// current, so a bookmark's version matters only when that list itself61// fails. The watcher asks for bookmarks anyway because they cost one62// line each and make that failure window resumable, where a relist63// costs one full read of the collection and the pass the wake64// triggers.65func watchLibraries(c *Client, resourceVersion string, wake chan<- struct{}) {66	for {67		path := librariesPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion68		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)69		if err == nil && resp.StatusCode == http.StatusOK {70			resourceVersion = readWatchStream(resp, resourceVersion, wake)71		}72		if resp != nil {73			drain(resp.Body)74		}7576		// A failed watch is never fatal. The ticker keeps the passes77		// running while this loop is down, and a relist is the whole78		// recovery.79		time.Sleep(watchRetryPause.get())80		list, err := ListLibraries(watchContext(), c)81		if err != nil {82			fmt.Fprintf(os.Stderr, "listing libraries to resume the watch: %v\n", err)83			continue84		}85		resourceVersion = list.Metadata.ResourceVersion86		poke(wake)87	}88}8990// WatchCatalogs wakes the loop on every Catalog change, so a Library91// waiting on its namespace's Catalog proceeds on the next pass, and a92// second Catalog is marked Blocked without a backstop tick's delay. The93// recovery is watchLibraries's: a dropped stream or a 410 Gone lists the94// collection, wakes the loop, and resumes from the list's version.95func watchCatalogs(c *Client, resourceVersion string, wake chan<- struct{}) {96	for {97		path := catalogsPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion98		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)99		if err == nil && resp.StatusCode == http.StatusOK {100			resourceVersion = readWatchStream(resp, resourceVersion, wake)101		}102		if resp != nil {103			drain(resp.Body)104		}105106		time.Sleep(watchRetryPause.get())107		list, err := ListCatalogs(watchContext(), c)108		if err != nil {109			fmt.Fprintf(os.Stderr, "listing catalogs to resume the watch: %v\n", err)110			continue111		}112		resourceVersion = list.Metadata.ResourceVersion113		poke(wake)114	}115}116117// WatchPlayers wakes the loop on every Player change, so a Player that118// names this operator as its idle controller gets a screen pod without a119// backstop tick's delay, and one that names another controller loses its120// screen pod as fast. The recovery is watchLibraries's: a dropped stream or a121// 410 Gone lists the collection, wakes the loop, and resumes from the list's122// version. A list that fails leaves the resume point where it was, which is123// what a cluster with no media-operator answers on every turn.124func watchPlayers(c *Client, resourceVersion string, wake chan<- struct{}) {125	for {126		path := playersPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion127		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)128		if err == nil && resp.StatusCode == http.StatusOK {129			resourceVersion = readWatchStream(resp, resourceVersion, wake)130		}131		if resp != nil {132			drain(resp.Body)133		}134135		time.Sleep(watchRetryPause.get())136		list, err := ListPlayers(watchContext(), c)137		if err != nil {138			fmt.Fprintf(os.Stderr, "listing players to resume the watch: %v\n", err)139			continue140		}141		resourceVersion = list.Metadata.ResourceVersion142		poke(wake)143	}144}145146// This watcher wakes the loop on every MediaPreferences change, so a zone147// the household just set rolls the screen pods without a backstop tick's148// delay. The recovery is watchPlayers's, and so is the list that fails on a149// cluster with no media-operator.150func watchMediaPreferences(c *Client, resourceVersion string, wake chan<- struct{}) {151	for {152		path := mediaPreferencesPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion153		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)154		if err == nil && resp.StatusCode == http.StatusOK {155			resourceVersion = readWatchStream(resp, resourceVersion, wake)156		}157		if resp != nil {158			drain(resp.Body)159		}160161		time.Sleep(watchRetryPause.get())162		list, err := ListMediaPreferences(watchContext(), c)163		if err != nil {164			fmt.Fprintf(os.Stderr, "listing media preferences to resume the watch: %v\n", err)165			continue166		}167		resourceVersion = list.Metadata.ResourceVersion168		poke(wake)169	}170}171172// This watcher wakes the loop on every MetadataProvider change, so a key a173// person has just declared is checked without a backstop tick's delay. The174// recovery is watchLibraries's. A list that fails leaves the resume point175// where it was, which is what a cluster that has not applied the CRD answers176// on every turn.177func watchMetadataProviders(c *Client, resourceVersion string, wake chan<- struct{}) {178	for {179		path := metadataProvidersPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion180		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)181		if err == nil && resp.StatusCode == http.StatusOK {182			resourceVersion = readWatchStream(resp, resourceVersion, wake)183		}184		if resp != nil {185			drain(resp.Body)186		}187188		time.Sleep(watchRetryPause.get())189		list, err := ListMetadataProviders(watchContext(), c)190		if err != nil {191			fmt.Fprintf(os.Stderr, "listing metadata providers to resume the watch: %v\n", err)192			continue193		}194		resourceVersion = list.Metadata.ResourceVersion195		poke(wake)196	}197}198199// WatchPods wakes the loop on every change to a pod that holds a200// catalog agent, and the label selector keeps the stream to those pods.201// Every event earns a wake here, because a Library is Ready only while202// its namespace's catalog pod runs with every container ready: the203// update that turns a container ready is as much a change to report as204// a delete.205//206// The recovery is the same as watchLibraries: a dropped stream or a207// 410 Gone lists the collection, wakes the loop, and resumes the watch208// from the list's version.209func watchPods(c *Client, resourceVersion string, wake chan<- struct{}) {210	for {211		path := podsAllPath + "?watch=true&allowWatchBookmarks=true&" + catalogMemberQuery +212			"&resourceVersion=" + resourceVersion213		resp, err := c.Do(watchContext(), http.MethodGet, path, nil)214		if err == nil && resp.StatusCode == http.StatusOK {215			resourceVersion = readWatchStream(resp, resourceVersion, wake)216		}217		if resp != nil {218			drain(resp.Body)219		}220221		time.Sleep(watchRetryPause.get())222		list, err := ListCatalogMemberPods(watchContext(), c)223		if err != nil {224			fmt.Fprintf(os.Stderr, "listing catalog member pods to resume the watch: %v\n", err)225			continue226		}227		resourceVersion = list.Metadata.ResourceVersion228		poke(wake)229	}230}231232// ReadWatchStream reads one connection's worth of events. The returned233// version is where the next watch resumes.234func readWatchStream(resp *http.Response, resourceVersion string, wake chan<- struct{}) string {235	decoder := json.NewDecoder(resp.Body)236	for {237		var event struct {238			Type   string `json:"type"`239			Object struct {240				Metadata ObjectMeta `json:"metadata"`241			} `json:"object"`242		}243		if err := decoder.Decode(&event); err != nil {244			return resourceVersion245		}246		if event.Type == "ERROR" {247			// Usually a 410 Gone wrapped in an event: the server no248			// longer holds this resourceVersion. The relist in the249			// caller is the answer.250			return resourceVersion251		}252		if event.Object.Metadata.ResourceVersion != "" {253			resourceVersion = event.Object.Metadata.ResourceVersion254		}255		if event.Type == "BOOKMARK" {256			// A bookmark moves the resume point and reconciles257			// nothing, so it earns no wake.258			continue259		}260		poke(wake)261	}262}263264// Poke never blocks, and the wake channel buffers exactly one. A wake265// already queued says everything a second one would say, because the266// pass that answers it reads the whole collection.267func poke(wake chan<- struct{}) {268	select {269	case wake <- struct{}{}:270	default:271	}272}
webhook.go 100.0%
1package main23// webhook.go is the fast way the scanner detects a change: a small HTTP4// endpoint that accepts the webhook Radarr, Sonarr, and Jellyfin send on5// import. It reads the changed path out of the common payload shapes, maps it6// onto the volume, and rescans that one path. The slow walk is the other way,7// and it is what finds a file that arrived with no webhook.89import (10	"context"11	"encoding/json"12	"io"13	"net/http"14	"path/filepath"15	"strings"16	"time"17)1819// webhookBodyLimit bounds the payload the endpoint reads, so a webhook cannot20// hold the scanner open with an endless body.21const webhookBodyLimit = 1 << 202223// webhookRescanTimeout bounds a rescan a webhook drives, so a slow volume24// cannot hold an HTTP request open without end.25var webhookRescanTimeout = 30 * time.Second2627// webhookHandler is the endpoint the *arr tools and Jellyfin post to. It reads28// the changed path, rescans it, and answers no-content. A path it cannot map to29// the volume drives a full walk, so a webhook is never worse than the slow30// timer.31func (s *scanner) webhookHandler() http.Handler {32	return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {33		if request.Method != http.MethodPost {34			w.WriteHeader(http.StatusMethodNotAllowed)35			return36		}37		body, _ := io.ReadAll(io.LimitReader(request.Body, webhookBodyLimit))38		ctx, cancel := context.WithTimeout(request.Context(), webhookRescanTimeout)39		defer cancel()4041		if absolute := s.resolveWebhookPath(extractWebhookPath(body)); absolute != "" {42			s.rescan(ctx, absolute)43		} else {44			s.fullWalk(ctx)45		}46		w.WriteHeader(http.StatusNoContent)47	})48}4950// extractWebhookPath reads the changed path out of a webhook payload. It51// prefers a file path, then a folder path, across the shapes Radarr, Sonarr,52// and Jellyfin send, and returns the first it finds.53func extractWebhookPath(body []byte) string {54	var top map[string]json.RawMessage55	if json.Unmarshal(body, &top) != nil {56		return ""57	}58	for _, field := range []struct{ object, key string }{59		{"movieFile", "path"},60		{"episodeFile", "path"},61		{"movie", "folderPath"},62		{"movie", "path"},63		{"series", "path"},64	} {65		if value := nestedString(top, field.object, field.key); value != "" {66			return value67		}68	}69	for _, key := range []string{"Path", "path"} {70		if value := topString(top, key); value != "" {71			return value72		}73	}74	return ""75}7677// nestedString reads a string field from a nested object in a payload, the78// shape the *arr tools use for a file or a folder path.79func nestedString(top map[string]json.RawMessage, object, key string) string {80	raw, held := top[object]81	if !held {82		return ""83	}84	var inner map[string]json.RawMessage85	if json.Unmarshal(raw, &inner) != nil {86		return ""87	}88	return topString(inner, key)89}9091// topString reads a string field from an object, and reads nothing from a92// field that is not a string.93func topString(top map[string]json.RawMessage, key string) string {94	raw, held := top[key]95	if !held {96		return ""97	}98	var value string99	if json.Unmarshal(raw, &value) != nil {100		return ""101	}102	return strings.TrimSpace(value)103}104105// resolveWebhookPath maps a payload path onto the library root. The106// scanner and the enrichers resolve a SCAN_PATH through the one function107// below, so a folder the webhook named reads the same in every Job of108// its chain.109func (s *scanner) resolveWebhookPath(payloadPath string) string {110	return resolveVolumePath(s.root, payloadPath)111}112113// resolveVolumePath maps one path onto the root. A relative path joins the114// root. An absolute path is the media server's own, whose prefix no115// container can know, so the resolver takes the longest suffix of it that116// exists under the root. A path that maps to nothing returns empty, and the117// caller covers the whole library.118func resolveVolumePath(root, payloadPath string) string {119	payloadPath = strings.TrimSpace(payloadPath)120	if payloadPath == "" {121		return ""122	}123	cleaned := filepath.Clean(payloadPath)124	if !filepath.IsAbs(cleaned) {125		// A relative path that climbs above the root maps to nothing. The126		// path arrives from a media server over HTTP and from a Job's own127		// annotation, so no caller may read it as a path under the mount.128		if !inside(cleaned) {129			return ""130		}131		candidate := filepath.Join(root, cleaned)132		if pathExists(candidate) {133			return candidate134		}135		return ""136	}137	parts := strings.Split(strings.TrimPrefix(cleaned, "/"), "/")138	for i := range parts {139		candidate := filepath.Join(append([]string{root}, parts[i:]...)...)140		if pathExists(candidate) {141			return candidate142		}143	}144	return ""145}
webhookserver.go 98.2%
1package main23// The webhook is on the operator. Radarr, Sonarr, and Jellyfin4// post to one address with a path per Library, and the operator reads5// the changed path out of the payload and creates a scan Job for that6// one folder. The scanner answers no webhook of its own any more,7// because a scan is a Job that runs and exits, and an address must8// stand between runs.910import (11	"context"12	"errors"13	"fmt"14	"io"15	"maps"16	"net/http"17	"os"18	"slices"19	"strings"20	"sync"21	"time"22)2324// The variables the Deployment states: the namespace the operator's own25// Service is in, which is what the reported address names, and the port26// the operator listens on.27const (28	operatorNamespaceVariable = "OPERATOR_NAMESPACE"29	webhookPortVariable       = "WEBHOOK_PORT"30	defaultWebhookPort        = "8080"31)3233// The Service over the operator, the path each Library answers on, and34// the scheme and DNS suffix the reported address is built from. A name35// of this form resolves from any pod in the cluster, which is where the36// media servers that send these webhooks run.37const (38	operatorServiceName = "library-operator"39	webhookPathPrefix   = "/webhook/"40	webhookScheme       = "http://"41	clusterDNSSuffix    = ".svc"42)4344// How long the server waits for a sender's headers, so a45// connection that opens and says nothing cannot hold a slot.46const webhookHeaderTimeout = 10 * time.Second4748// How many paths one Library holds before the operator stops49// keeping them apart. Past the limit one full walk covers every path at50// once, and a sender that floods the endpoint costs one walk rather51// than unbounded memory.52const heldPathLimit = 645354// WebhookURL is the address a person gives to Radarr, Sonarr, or55// Jellyfin. It names the operator's own Service and the Library, and56// never a pod, so it is the same address for the whole life of the57// Library.58func webhookURL(operatorNamespace, namespace, name string) string {59	return webhookScheme + operatorServiceName + "." + operatorNamespace + clusterDNSSuffix +60		webhookPathPrefix + namespace + "/" + name61}6263// The paths the operator holds for each Library until it can64// create their Jobs. A restart loses them, which the next full walk65// covers.66type heldPaths struct {67	mutex sync.Mutex68	paths map[string]map[string]bool69	wake  chan<- struct{}70}7172func newHeldPaths(wake chan<- struct{}) *heldPaths {73	return &heldPaths{paths: map[string]map[string]bool{}, wake: wake}74}7576// One path is held for one Library and the loop is woken, so the77// Job is created on the next pass rather than on the next tick.78func (h *heldPaths) hold(namespace, name, path string) {79	key := libraryKey(namespace, name)80	h.mutex.Lock()81	held, standing := h.paths[key]82	if !standing {83		held = map[string]bool{}84		h.paths[key] = held85	}86	// A full walk already covers every path, so nothing is held beside87	// one. Past the limit the whole set collapses to that walk, and a88	// sender that floods the endpoint costs one walk.89	if held[""] {90		h.mutex.Unlock()91		return92	}93	// A full walk held after a folder path replaces the set, so a Library94	// never stands two scan Jobs for one claim that admits one writer.95	if path == "" || len(held) >= heldPathLimit {96		h.paths[key] = map[string]bool{"": true}97	} else {98		held[path] = true99	}100	h.mutex.Unlock()101	poke(h.wake)102}103104// The paths one Library holds now, sorted, so a pass creates105// their Jobs in one order.106func (h *heldPaths) held(namespace, name string) []string {107	h.mutex.Lock()108	defer h.mutex.Unlock()109	return slices.Sorted(maps.Keys(h.paths[libraryKey(namespace, name)]))110}111112// A path is released once its Job exists.113func (h *heldPaths) release(namespace, name, path string) {114	key := libraryKey(namespace, name)115	h.mutex.Lock()116	defer h.mutex.Unlock()117	delete(h.paths[key], path)118	if len(h.paths[key]) == 0 {119		delete(h.paths, key)120	}121}122123// Paths held for a Library the collection no longer holds are124// dropped, the rule the report desk follows, so a webhook for a deleted125// Library never creates a Job.126func (h *heldPaths) retain(live map[string]bool) {127	h.mutex.Lock()128	defer h.mutex.Unlock()129	for key := range h.paths {130		if !live[key] {131			delete(h.paths, key)132		}133	}134}135136// The endpoint the media servers post to. It reads the changed137// path out of the payload, holds it for the Library the URL names, and138// answers no-content. A payload it cannot read a path from holds the139// empty path, which is a full walk, so a webhook is never worse than140// the schedule.141func (o *operator) webhookHandler() http.Handler {142	return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {143		if request.Method != http.MethodPost {144			w.WriteHeader(http.StatusMethodNotAllowed)145			return146		}147		namespace, name, ok := parseWebhookPath(request.URL.Path)148		if !ok {149			w.WriteHeader(http.StatusNotFound)150			return151		}152		body, _ := io.ReadAll(io.LimitReader(request.Body, webhookBodyLimit))153		o.paths.hold(namespace, name, extractWebhookPath(body))154		w.WriteHeader(http.StatusNoContent)155	})156}157158// The Library one request's path names. A path with anything but159// a namespace and a name under the prefix names no Library.160func parseWebhookPath(urlPath string) (namespace, name string, ok bool) {161	if !strings.HasPrefix(urlPath, webhookPathPrefix) {162		return "", "", false163	}164	parts := strings.Split(strings.TrimPrefix(urlPath, webhookPathPrefix), "/")165	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {166		return "", "", false167	}168	return parts[0], parts[1], true169}170171// The server runs for the life of the operator and stops with172// it. A failure to listen ends the process, because an operator that173// reports a webhook address nothing answers is worse than one that174// refuses to start.175func (o *operator) serveWebhooks(stopped context.Context, address string) error {176	server := &http.Server{177		Addr:              address,178		Handler:           o.webhookHandler(),179		ReadHeaderTimeout: webhookHeaderTimeout,180	}181	go func() {182		<-stopped.Done()183		// The shutdown takes a context of its own, because the one that184		// ended is the signal to shut down.185		ending, done := context.WithTimeout(context.Background(), passTimeout)186		defer done()187		if err := server.Shutdown(ending); err != nil {188			fmt.Fprintf(os.Stderr, "shutting the webhook server down: %v\n", err)189		}190	}()191	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {192		return err193	}194	return nil195}

Rust (media-browser)

15789 of 16089 lines, 98.1%.

FileCovered linesTotal linesPercent
src/browser.rs260260100.0%
src/browser/keys.rs454697.8%
src/browser/reader.rs6969100.0%
src/browser/stack.rs6060100.0%
src/bus/play.rs122122100.0%
src/catalog.rs9810197.0%
src/catalog/art.rs6060100.0%
src/catalog/draw.rs216216100.0%
src/catalog/franchise.rs29129299.7%
src/catalog/pool.rs3636100.0%
src/catalog/query.rs116116100.0%
src/catalog/recency.rs132132100.0%
src/catalog/search.rs142142100.0%
src/catalog/search/build.rs140140100.0%
src/catalog/search/fold.rs3838100.0%
src/catalog/search/rank.rs9595100.0%
src/catalog/sidecar.rs28829199.0%
src/catalog/sidecar/details.rs555698.2%
src/catalog/sidecar/files.rs2525100.0%
src/catalog/sidecar/franchises.rs199199100.0%
src/catalog/sidecar/genres.rs11611898.3%
src/catalog/sidecar/item.rs9999100.0%
src/catalog/sidecar/people.rs19419798.5%
src/catalog/sidecar/play.rs989999.0%
src/catalog/sidecar/pool.rs5454100.0%
src/catalog/sidecar/recent.rs4343100.0%
src/catalog/sidecar/search.rs105105100.0%
src/catalog/sidecar/search/read.rs136136100.0%
src/catalog/sidecar/series.rs7272100.0%
src/catalog/sidecar/updates.rs677095.7%
src/clock.rs5252100.0%
src/focus.rs166166100.0%
src/harness.rs11812792.9%
src/harness/app.rs12215180.8%
src/harness/capture.rs8989100.0%
src/harness/frame.rs14918082.8%
src/harness/graphics.rs11612394.3%
src/harness/options.rs11511699.1%
src/harness/stats.rs17417599.4%
src/harness/timeline.rs9393100.0%
src/harness/watchdog.rs798395.2%
src/look.rs5252100.0%
src/main.rs434987.8%
src/posters.rs1313100.0%
src/posters/art.rs11211498.2%
src/posters/cache.rs11211399.1%
src/posters/decode.rs1212100.0%
src/posters/disk.rs17419688.8%
src/posters/disk/format.rs17218593.0%
src/posters/disk/trim.rs798197.5%
src/posters/key.rs6565100.0%
src/posters/queue.rs9797100.0%
src/posters/store.rs158158100.0%
src/posters/volumes.rs22322499.6%
src/sample.rs34834999.7%
src/sample/draw.rs252252100.0%
src/sample/orders.rs288288100.0%
src/sample/people.rs858698.8%
src/sample/search.rs107107100.0%
src/screens.rs52052399.4%
src/screens/credits.rs112112100.0%
src/screens/facts.rs22422599.6%
src/screens/foot.rs172172100.0%
src/screens/franchise.rs9810197.0%
src/screens/franchise/card.rs2727100.0%
src/screens/franchise/metro.rs16216498.8%
src/screens/franchise/page.rs33235194.6%
src/screens/franchise/strips.rs20620899.0%
src/screens/franchise/wall.rs294294100.0%
src/screens/home.rs26426998.1%
src/screens/home/banner.rs13113398.5%
src/screens/home/layout.rs13613799.3%
src/screens/home/page.rs4848100.0%
src/screens/home/recent.rs123123100.0%
src/screens/home/rows.rs63763899.8%
src/screens/loading.rs9393100.0%
src/screens/movie.rs24324898.0%
src/screens/movie/page.rs24725198.4%
src/screens/person.rs798098.8%
src/screens/person/page.rs879195.6%
src/screens/series.rs21422993.4%
src/screens/series/layout.rs14214399.3%
src/screens/series/page.rs16819586.2%
src/screens/series/seasons.rs150150100.0%
src/screens/slots.rs21422097.3%
src/screens/stripes.rs155155100.0%
src/screens/volume.rs118118100.0%
src/screens/wall.rs19920298.5%
src/screens/wall/rail.rs25025398.8%
src/screens/wall/search.rs899197.8%
src/views.rs24525297.2%
src/views/band.rs3131100.0%
src/views/banner.rs128128100.0%
src/views/buttons.rs4343100.0%
src/views/card.rs6161100.0%
src/views/clock.rs5757100.0%
src/views/clock/strip.rs6767100.0%
src/views/curtain.rs16316897.0%
src/views/divider.rs1515100.0%
src/views/field.rs101101100.0%
src/views/header.rs3939100.0%
src/views/keyboard.rs11111299.1%
src/views/layers.rs12212399.2%
src/views/people.rs114114100.0%
src/views/rail.rs145145100.0%
src/views/ratings.rs13413599.3%
src/views/scroll.rs6060100.0%
src/views/stack.rs5959100.0%
src/views/strip.rs34435297.7%
src/views/text.rs30430599.7%
src/views/volume.rs13013199.2%
src/views/wall.rs24626293.9%
src/browser.rs 100.0%
1// The media browser: a stack of screens over a catalog source and a2// poster store. The keyboard and the bus fold through one key handler.3// The home page is always the bottom of the stack, so the screen is never4// empty, and back from the home page asks for the shade. The5// `media-screen` crate holds the shade, the focus gate, and the two6// windows, so a press that reaches this file is one to act on.78use std::cell::RefCell;9use std::convert::Infallible;1011use iced_wgpu::Renderer;12use iced_widget::{Space, Stack, canvas};13use iced_winit::core::{Color, Element, Length, Theme};1415use media_screen::{Bus, Moment};1617use crate::bus::play;18use crate::catalog::draw::Date;19use crate::catalog::search::Size;20use crate::catalog::{Selection, Source};21use crate::clock;22use crate::harness::{Screen, Waker};23use crate::look;24use crate::posters::{PosterCounts, Posters};25use crate::screens::{self, Step, home, loading, volume};26use crate::views;2728mod keys;29mod reader;30// The stack module: the screens a person descended through, and every31// move across them.32mod stack;3334use keys::key_of;3536/// The browsing screen, generic over where its rows and its posters37/// come from, so one browser draws the sidecar's file, a test fixture, and38/// the sample the same way.39pub struct Browser<S: Source, P: Posters> {40    source: S,41    // The store is in a RefCell because a canvas program draws through42    // a shared reference while the store mutates its cache.43    posters: RefCell<P>,44    // The home page is a field of its own, so the type guarantees a45    // screen to draw; the stack holds only descents.46    home: screens::Screen,47    stack: Vec<screens::Screen>,48    // Where the home page's reads run. Every read after the first goes49    // through here, so the frame thread draws while the read runs.50    reader: reader::Reader,51    // Whether the home page is behind the catalog. A change the source52    // reports marks it, whether or not a page covers the home page,53    // because back pops to the home page with no read of its own.54    home_stale: bool,55    // The date the home page was read on. The day's draw is seeded by56    // the date, so a page read yesterday is behind today.57    home_date: Date,58    // Where the date comes from. It is a field so a test moves the day59    // without the wall clock.60    today: fn() -> Date,61    // The connection to the room's remotes, or nothing on a run that takes62    // the keyboard alone.63    bus: Option<Box<dyn Bus>>,64    // The topic this operator reads play requests on. The browser65    // publishes on the connection the crate already holds, so the topic66    // is held here and not in the crate, which reads none of it.67    play_topic: String,68    // Whether the shade is down. The browser never decides it: it asks for69    // the shade, the crate decides, and the moment comes back here.70    asleep: bool,71    // Whether a present asked for a fresh Wayland surface.72    surface_due: bool,73    // The size a page's backdrop is decoded at, which is the size of the74    // window.75    page: (u32, u32),76    // The second of the last frame. The rest is measured from it.77    clock: f64,78    // The second at which the focused item's backdrop is asked for, or79    // nothing while focus moves or after the ask is spent.80    rest: Option<f64>,81    // The loading state the page under a chosen title is in, or nothing82    // while no title has been chosen.83    loading: Option<loading::Loading>,84    // The volume row's state, which the level moments the bus delivers fold85    // into.86    level: volume::Level,87    // The reading the strip draws, read at every tick.88    time: clock::Time,89    // Whether the strip over every screen holds focus. It is the90    // browser's field and not a screen's, because the strip is the91    // browser's layer, and it clears whenever the stack changes.92    on_strip: bool,93    // The second on the loop's own clock at which the minute turns, or94    // nothing before the first tick. The clock draws a reading to the95    // minute, so this is the one frame it asks for.96    minute: Option<f64>,97}9899// How long focus stands still before the browser asks the store for the100// backdrop of the page under it. A press inside this window replaces the101// ask, so a walk across a wall decodes the backdrop of the item a person102// stopped on and no other.103const REST: f64 = 0.3;104105// The size a run that asked for no window size decodes a backdrop at.106const PAGE: (u32, u32) = (1920, 1080);107108impl<S: Source, P: Posters> Browser<S, P> {109    /// Open the browser on its first screen, the home page.110    pub fn new(mut source: S, posters: P) -> Self {111        let home_date = Date::today();112        // The first read is the one a person waits for, so the run says113        // how long it took, the way the reader thread says it of a re-read.114        let started = std::time::Instant::now();115        let home = screens::Screen::Home(home::Home::open(&mut source));116        let ms = started.elapsed().as_secs_f64() * 1_000.0;117        eprintln!("media-browser: the home page opened in {ms:.1} ms");118        let reader = reader::Reader::new(source.reader());119        Self {120            source,121            posters: RefCell::new(posters),122            home,123            stack: Vec::new(),124            reader,125            home_stale: false,126            home_date,127            today: Date::today,128            bus: None,129            play_topic: String::new(),130            asleep: false,131            surface_due: false,132            page: PAGE,133            clock: 0.0,134            rest: None,135            loading: None,136            level: volume::Level::default(),137            time: clock::now(),138            on_strip: false,139            minute: None,140        }141    }142143    /// The browser on a window of this size. A page's backdrop is decoded144    /// at this size, so the decode the wall asked for is the one the page145    /// draws.146    pub fn with_page(mut self, page: (u32, u32)) -> Self {147        self.page = page;148        self149    }150151    /// The browser that prints the milliseconds each home read takes,152    /// which the measured run asks for.153    pub fn with_timing(self, timed: bool) -> Self {154        self.reader.timed(timed);155        self156    }157158    /// The browser on a bus, and the topic it publishes a play request159    /// on. A `Player` whose status names no bus wires none, and the160    /// browser then takes the keyboard alone.161    pub fn with_bus(mut self, bus: Option<Box<dyn Bus>>, play_topic: String) -> Self {162        self.bus = bus;163        self.play_topic = play_topic;164        self165    }166167    /// Whether the shade is down. The frame is black while it is.168    pub fn asleep(&self) -> bool {169        self.asleep170    }171172    // Fold one moment in. A press is a key by another route, and the173    // crate already applied the focus gate and the play gate, so a174    // press that arrives here is one to act on. A press the browser175    // binds no key for changes nothing.176    fn receive(&mut self, moment: Moment) {177        match moment {178            Moment::Press(name) => {179                if let Some(key) = key_of(name) {180                    self.key(key);181                }182            }183            Moment::Sleep => self.asleep = true,184            Moment::Wake => {185                self.asleep = false;186                self.presented();187                self.lifted();188            }189            Moment::Present => {190                self.surface_due = true;191                self.presented();192                self.lifted();193            }194            // A level brings up the volume row, which draws over every195            // screen.196            Moment::Level { volume, pressed } => self.level.fold(volume, pressed, self.clock),197            // The browser draws no identity block and no unit status, so198            // these two change nothing here.199            Moment::Focus { .. } | Moment::Status(_) => {}200        }201    }202203    // Fold everything the bus delivered since the last wake. The answer is204    // whether anything folded.205    fn drain_bus(&mut self) -> bool {206        let moments = match &self.bus {207            Some(bus) => bus.drain(),208            None => return false,209        };210        let folded = !moments.is_empty();211        for moment in moments {212            self.receive(moment);213        }214        folded215    }216217    // Ask the reader for the home page, but only where the page is218    // behind: a change the source reported, or a day other than the one219    // it was read on. A read in place lands on this call, and a read on220    // the thread lands on a later pass.221    fn refresh_home(&mut self) {222        let today = (self.today)();223        if !self.home_stale && self.home_date == today {224            return;225        }226        self.reader.ask(&mut self.source, today);227        self.landed_home();228    }229230    // Take the page the reader answered, where one landed. The answer is231    // whether the home page changed, which is a frame to draw.232    fn landed_home(&mut self) -> bool {233        let Some(page) = self.reader.take() else {234            return false;235        };236        self.home_date = page.date;237        self.home_stale = false;238        if let screens::Screen::Home(home) = &mut self.home {239            home.apply(page);240        }241        true242    }243244    // The browser is on the screen again, whether the film played245    // through or the `Play` never started, so the page comes back.246    fn presented(&mut self) {247        if let Some(state) = &mut self.loading {248            state.leave(self.clock);249        }250    }251252    // The shade lifted, so the home page is read again where it is253    // behind: a change the source reported while the shade was down, or a254    // shade that lifts on a new day, whose draw is another day's. The home255    // page is read whether or not a screen covers it, because back pops to256    // it with no draw of its own.257    fn lifted(&mut self) {258        self.refresh_home();259    }260261    // Resolve the choice through the catalog and publish it. The browser262    // resolves the list because it holds the catalog, and the operator263    // creates the `Play` because it holds the credential. A choice with264    // no main file starts nothing, and the line in the pod log is the265    // only sign of the gap.266    //267    // The answer is whether the catalog resolved a film, and not whether268    // the request went out. A run with no bus browses the same way, and269    // the page it draws while it waits is the same page.270    fn request_play(&mut self, library: &str, selection: &Selection) -> bool {271        let items = self.source.play(library, selection);272        if items.is_empty() {273            eprintln!(274                "media-browser: no file to play for {} in {library}",275                selection.named()276            );277            return false;278        }279        let Some(bus) = &self.bus else {280            return true;281        };282        // An older library operator names no topic, and the browser283        // then browses and starts nothing. The line in the pod log is284        // the only sign of the gap.285        if self.play_topic.is_empty() {286            eprintln!("media-browser: no play topic, so this browser starts nothing");287            return true;288        }289        // A request is an event, so it is not retained: a broker that290        // held the last one would replay it to the operator on every291        // reconnect.292        bus.publish(&self.play_topic, play::payload(library, &items), false);293        true294    }295296    // The strip the browser draws over whatever screen is on the stack,297    // or nothing while the shade is down. No screen draws it, so every298    // screen carries it in the same place. The field is the search299    // wall's own, read off the top screen.300    fn strip(&self) -> Option<views::clock::strip::Strip<'_>> {301        (!self.asleep).then(|| views::clock::strip::Strip {302            time: self.time,303            field: self.top().field(),304            focused: self.on_strip,305        })306    }307308    // One press while the strip holds focus. Select opens the search309    // wall with the grid, or shows the grid on a search wall. Down gives310    // focus back to the screen. A word that edits the field types into311    // it on a search wall and gives focus back with it. Every other312    // word, the arrows included, moves nothing.313    fn on_strip(&mut self, name: &str) -> bool {314        match name {315            "enter" => {316                match self.top().searching() {317                    true => {318                        self.on_strip = false;319                        let top = self.stack.last_mut().unwrap_or(&mut self.home);320                        top.show_grid();321                    }322                    false => self.search("", true),323                }324                true325            }326            "down" => {327                self.on_strip = false;328                true329            }330            _ if views::field::edits(name) && self.top().searching() => {331                self.on_strip = false;332                self.on_screen(name)333            }334            _ => false,335        }336    }337338    // One press the screen on top takes. An up the screen answers339    // `Still` to moved nothing there, so it puts focus on the strip.340    fn on_screen(&mut self, name: &str) -> bool {341        let top = self.stack.last_mut().unwrap_or(&mut self.home);342        let step = top.key(name, &mut self.source);343        let still = matches!(step, Step::Still);344        self.take(step);345        if still && name == "up" {346            self.on_strip = true;347            return true;348        }349        !still350    }351}352353impl<S: Source, P: Posters> Screen for Browser<S, P> {354    // Nothing on the screen emits a message; a remote's presses355    // arrive as keys, and the type says so.356    type Message = Infallible;357358    // The shade means dark, so a sleeping browser clears to black and359    // not to the theme ground.360    fn background(&self) -> Color {361        if self.asleep {362            return Color::BLACK;363        }364        look::BACKGROUND365    }366367    fn key(&mut self, name: &str) -> bool {368        // A press during the loading state reaches no screen under it.369        // Back exits the state here and now, and cancels nothing: the370        // `Play` this browser asked for is the operator's to run.371        if self.loading.is_some() {372            if name == "escape" || name == "backspace" {373                self.presented();374            }375            return true;376        }377        let mut changed = true;378        match name {379            // Escape on the strip gives focus back to the screen and pops380            // nothing, because the strip is over the stack and not on it.381            "escape" if self.on_strip => self.on_strip = false,382            // The screen on top is asked first, because a search wall383            // reads backspace as a deleted character and escape as the384            // text cleared. Every other screen takes neither, and both385            // words are then back.386            "escape" | "backspace" => {387                let top = self.stack.last_mut().unwrap_or(&mut self.home);388                match top.escape(name, &mut self.source) {389                    Some(step) => self.take(step),390                    None => self.back(),391                }392            }393            "home" => self.home(),394            // The search key opens the empty wall with the grid shown, and395            // does nothing on a search wall.396            "search" => match self.top().searching() {397                true => changed = false,398                false => self.search("", true),399            },400            // A letter or a digit opens the search wall seeded with the401            // character and the grid hidden, because a person who typed a402            // letter has a keyboard. It happens here and not in a screen,403            // so every screen reaches search the same way. The wall is404            // pushed, so back returns to the screen the person left. On405            // the search wall the letter types.406            _ if views::field::typed(name) && !self.top().searching() => {407                self.search(name, false);408            }409            _ if self.on_strip => changed = self.on_strip(name),410            _ => changed = self.on_screen(name),411        }412        // Every press starts the rest again, so the store decodes the413        // backdrop of the item a person stopped on and not of every item414        // focus passed over. A strip that holds focus asks for nothing,415        // because no press there opens a page over art.416        self.rest = (!self.on_strip && self.top().prefetches()).then_some(self.clock + REST);417        changed418    }419420    // A poster that landed changes the frame and not the rows, so a421    // delivery redraws what is already read and only a changed source422    // re-reads the screen. A home page the reader answered lands here too,423    // and it is a frame to draw.424    fn pump(&mut self, at: f64) -> bool {425        // The clock moves here as well as on a frame, because a covered426        // browser draws none: a wake that arrived under a film would427        // otherwise start the exit at the second of the last frame before428        // the film, which is already spent.429        self.clock = at;430        let folded = self.drain_bus();431        let delivered = self.posters.get_mut().delivered();432        let landed = self.landed_home();433        if !self.source.changed() {434            return folded || delivered || landed;435        }436        // A change marks the home page behind whether or not a page covers437        // it, because back pops to the home page with no read of its own.438        self.home_stale = true;439        self.reread_top();440        true441    }442443    // The source, the poster store, the home page's reader, and the bus444    // deliver on threads of their own, so all four take the handle that445    // wakes the loop.446    fn wake_by(&mut self, wake: Waker) {447        self.source.wake_by(wake.clone());448        if let Some(bus) = &self.bus {449            bus.wake_on_delivery(wake.clone());450        }451        self.reader.wake_by(wake.clone());452        self.posters.get_mut().wake_by(wake);453    }454455    fn surface_due(&mut self) -> bool {456        std::mem::take(&mut self.surface_due)457    }458459    fn poster_counts(&self) -> PosterCounts {460        self.posters.borrow().counts()461    }462463    // The size of the source's search index, for the stats line. A464    // source with no index answers nothing, and the line leaves the465    // numbers out.466    fn index_size(&mut self) -> Option<Size> {467        self.source.index_size()468    }469470    // The clock is read here alone, so the rest is measured on the same471    // clock the harness drives every frame with.472    fn tick(&mut self, at: f64) {473        self.clock = at;474        self.time = clock::now();475        self.minute = Some(at + clock::seconds_to_next_minute());476        if self.loading.is_some_and(|state| state.done(at)) {477            self.loading = None;478        }479        if self.rest.is_some_and(|due| at >= due) {480            self.rest = None;481            self.prefetch();482        }483    }484485    fn view(&self) -> Element<'_, Self::Message, Theme, Renderer> {486        // The shade is down, so the frame is the clear color and nothing over487        // it. The screen and its focus are held for the wake.488        let Some(strip) = self.strip() else {489            return Space::new().width(Length::Fill).height(Length::Fill).into();490        };491492        let screen = self.top().view(493            &self.posters,494            self.loading.map(|state| state.curtain(self.clock)),495            !self.on_strip,496        );497498        // The strip and the row are the browser's own layers over499        // whatever screen is on the stack, so a page change under them500        // neither resets them nor covers them.501        let mut layers = vec![502            screen,503            canvas(strip)504                .width(Length::Fill)505                .height(Length::Fill)506                .into(),507        ];508        if let Some(row) = self.level.row(self.clock) {509            layers.push(canvas(row).width(Length::Fill).height(Length::Fill).into());510        }511        Stack::with_children(layers)512            .width(Length::Fill)513            .height(Length::Fill)514            .into()515    }516517    // Every view here is still until something changes it, and the518    // source wakes the loop itself, so an idle browser schedules nothing519    // and the loop waits on events.520    //521    // Four things schedule a frame: the end of a rest; every frame of the522    // loading state, which answers now on every ask so the mark pulses at523    // the loop's own floor rate; the volume row, which asks for a frame524    // through each of its fades and names the second it starts to leave525    // through the hold between them; and the clock, which asks for the526    // second the minute turns. Nothing under a film schedules a frame,527    // because those frames would draw a black shade nobody sees.528    fn next_frame(&self, at: f64) -> Option<f64> {529        let drawing = !self.asleep;530        let loading = (drawing && self.loading.is_some()).then_some(at);531        let level = drawing.then(|| self.level.next_frame(at)).flatten();532        let minute = drawing.then_some(self.minute).flatten();533        [loading, level, minute, self.rest]534            .into_iter()535            .flatten()536            .min_by(f64::total_cmp)537    }538}539540#[cfg(test)]541mod tests;
src/browser/keys.rs 97.8%
1// The table that turns a kernel key name into the word the screens2// take. Letters and digits come through it too: a remote with a3// keyboard, like the Fire TV X6, sends them as ordinary evdev keys, and4// the search wall types them.56/// One kernel key name as the browser key it is. Several names reach one7/// key, because remotes differ in the name they send for OK and for back.8/// Select is enter and back is escape, so a press from a remote takes the9/// path the keyboard and the script take. Home pops to the home page,10/// search opens the search wall, and a letter or a digit is the11/// character itself, which is the word a typed key gives on a local run.12pub fn key_of(name: &str) -> Option<&'static str> {13    match name {14        "KEY_UP" => Some("up"),15        "KEY_DOWN" => Some("down"),16        "KEY_LEFT" => Some("left"),17        "KEY_RIGHT" => Some("right"),18        "KEY_ENTER" | "KEY_OK" | "KEY_SELECT" | "KEY_KPENTER" => Some("enter"),19        "KEY_BACK" | "KEY_ESC" | "KEY_EXIT" => Some("escape"),20        "KEY_HOMEPAGE" => Some("home"),21        "KEY_SEARCH" => Some("search"),22        "KEY_BACKSPACE" => Some("backspace"),23        "KEY_SPACE" => Some(" "),24        _ => typed(name),25    }26}2728// The words are static strings because a browser word outlives the key29// name it was read from, and a slice of that name would not. Two tables30// give every letter and digit a static word without an arm each.31const LETTERS: [&str; 26] = [32    "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",33    "t", "u", "v", "w", "x", "y", "z",34];3536const DIGITS: [&str; 10] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];3738// One letter or digit key as the character it carries. The kernel names39// these keys after the character, KEY_A and KEY_7, so the one character40// after the prefix is the word. A longer tail, like KEY_KP0, is not one.41fn typed(name: &str) -> Option<&'static str> {42    let mut tail = name.strip_prefix("KEY_")?.chars();43    let letter = tail.next()?;44    if tail.next().is_some() {45        return None;46    }47    match letter {48        'A'..='Z' => Some(LETTERS[letter as usize - 'A' as usize]),49        '0'..='9' => Some(DIGITS[letter as usize - '0' as usize]),50        _ => None,51    }52}5354#[cfg(test)]55mod tests {56    use super::*;5758    // Every name that carries a word of its own, so a binding that59    // changes shows up in one place.60    const BOUND: [(&str, &str); 15] = [61        ("KEY_UP", "up"),62        ("KEY_DOWN", "down"),63        ("KEY_LEFT", "left"),64        ("KEY_RIGHT", "right"),65        ("KEY_ENTER", "enter"),66        ("KEY_OK", "enter"),67        ("KEY_SELECT", "enter"),68        ("KEY_KPENTER", "enter"),69        ("KEY_BACK", "escape"),70        ("KEY_ESC", "escape"),71        ("KEY_EXIT", "escape"),72        ("KEY_HOMEPAGE", "home"),73        ("KEY_SEARCH", "search"),74        ("KEY_BACKSPACE", "backspace"),75        ("KEY_SPACE", " "),76    ];7778    #[test]79    fn every_bound_name_carries_its_word() {80        for (name, word) in BOUND {81            assert_eq!(key_of(name), Some(word), "{name}");82        }83    }8485    #[test]86    fn every_letter_and_every_digit_carry_the_character_itself() {87        assert_eq!(LETTERS.len(), 26);88        assert_eq!(DIGITS.len(), 10);89        for (index, word) in LETTERS.iter().enumerate() {90            let letter = (b'a' + index as u8) as char;91            assert_eq!(*word, letter.to_string(), "{letter}");92            let name = format!("KEY_{}", letter.to_ascii_uppercase());93            assert_eq!(key_of(&name), Some(*word), "{name}");94        }95        for (index, word) in DIGITS.iter().enumerate() {96            let digit = (b'0' + index as u8) as char;97            assert_eq!(*word, digit.to_string(), "{digit}");98            assert_eq!(key_of(&format!("KEY_{digit}")), Some(*word), "{digit}");99        }100    }101102    // Names the browser binds nothing for, including ones that look like103    // a letter key and are not.104    const UNBOUND: [&str; 6] = ["KEY_F1", "KEY_KP0", "KEY_", "KEY_UNKNOWN", "A", "up"];105106    #[test]107    fn a_name_the_browser_binds_nothing_for_carries_no_word() {108        for name in UNBOUND {109            assert_eq!(key_of(name), None, "{name}");110        }111    }112}
src/browser/reader.rs 100.0%
1// The home page's reader. The browser asks it for a page and takes the2// page that landed. A source that gives a second read of its own reads3// on a thread and wakes the loop when the page is in hand, and a source4// that gives none reads in place on the ask, so a test reads the same5// way with no thread.67use std::sync::atomic::{AtomicBool, Ordering};8use std::sync::mpsc::{self, Receiver, Sender};9use std::sync::{Arc, Mutex};10use std::thread;11use std::time::Instant;1213use crate::catalog::Source;14use crate::catalog::draw::Date;15use crate::harness::Waker;16use crate::screens::home::{self, Page};1718/// What the browser asks for a home page. One read runs at a time, and19/// an ask made while one runs is served by one read after it, so a burst20/// of asks costs two reads and not one each.21pub struct Reader {22    // The thread over the second source, or nothing where the source23    // gave none and every read runs in place.24    thread: Option<Thread>,25    // The page that landed and no screen has taken yet.26    landed: Option<Page>,27    // Whether a read is in flight on the thread.28    reading: bool,29    // The date of the read that is due after the one in flight.30    due: Option<Date>,31    // Whether each read prints the milliseconds it took.32    timed: Arc<AtomicBool>,33}3435impl Reader {36    /// The reader over this second source, or over none.37    pub fn new(source: Option<Box<dyn Source + Send>>) -> Self {38        let timed = Arc::new(AtomicBool::new(false));39        Self {40            thread: source.map(|source| Thread::spawn(source, timed.clone())),41            landed: None,42            reading: false,43            due: None,44            timed,45        }46    }4748    /// Print the milliseconds each read takes, which the measured run49    /// asks for.50    pub fn timed(&self, timed: bool) {51        self.timed.store(timed, Ordering::Relaxed);52    }5354    /// Ask for one page of this date. A read already in flight is left55    /// to land, and one read follows it.56    pub fn ask(&mut self, source: &mut dyn Source, today: Date) {57        let Some(thread) = &self.thread else {58            self.landed = Some(read(source, today, &self.timed));59            return;60        };61        if self.reading {62            self.due = Some(today);63            return;64        }65        self.reading = true;66        let _ = thread.asks.send(today);67    }6869    /// The page that landed, or nothing while none has. A read that was70    /// due starts here, once the one before it landed.71    pub fn take(&mut self) -> Option<Page> {72        if let Some(thread) = &self.thread {73            while let Ok(page) = thread.pages.try_recv() {74                self.landed = Some(page);75                self.reading = false;76            }77            if !self.reading78                && let Some(date) = self.due.take()79            {80                self.reading = true;81                let _ = thread.asks.send(date);82            }83        }84        self.landed.take()85    }8687    /// Take the handle that wakes the loop, so a page that lands reaches88    /// the browser on the next pass and not on the next frame.89    pub fn wake_by(&mut self, wake: Waker) {90        if let Some(thread) = &self.thread {91            *thread.wake.lock().expect("no thread panics with the lock") = Some(wake);92        }93    }94}9596// The thread over the second source: the dates it reads on, the pages97// it answers, and the handle it wakes the loop with.98struct Thread {99    asks: Sender<Date>,100    pages: Receiver<Page>,101    wake: Arc<Mutex<Option<Waker>>>,102}103104impl Thread {105    // The thread runs until the browser drops, because the ask channel106    // closes with it and the read loop ends there. A read in flight at107    // that moment finishes and is dropped with the channel.108    fn spawn(mut source: Box<dyn Source + Send>, timed: Arc<AtomicBool>) -> Self {109        let (asks, dates) = mpsc::channel::<Date>();110        let (answers, pages) = mpsc::channel::<Page>();111        let wake = Arc::new(Mutex::new(None));112        let woken: Arc<Mutex<Option<Waker>>> = wake.clone();113        thread::spawn(move || {114            while let Ok(date) = dates.recv() {115                let page = read(&mut *source, date, &timed);116                let _ = answers.send(page);117                // The page is sent before the wake fires, so the pass the118                // wake starts takes a page that is already in the channel.119                let wake = woken120                    .lock()121                    .expect("no thread panics with the lock")122                    .clone();123                if let Some(wake) = wake {124                    wake();125                }126            }127        });128        Self { asks, pages, wake }129    }130}131132// One read, and the milliseconds it took where the run measures them.133fn read(source: &mut dyn Source, today: Date, timed: &AtomicBool) -> Page {134    let started = Instant::now();135    let page = home::read(source, today);136    if timed.load(Ordering::Relaxed) {137        let ms = started.elapsed().as_secs_f64() * 1_000.0;138        eprintln!("media-browser: the home page read in {ms:.1} ms");139    }140    page141}
src/browser/stack.rs 100.0%
1// The navigation stack and the moves across it. The home page is the2// bottom and never leaves, so the screen is never empty. Open, back,3// home, and search push and pop the screens over it, and each of them4// takes focus off the strip, because the screen under it changed. Only5// the browser holds the stack, so a screen names the screen it opens6// and never pushes one itself.78use super::Browser;9use crate::catalog::Source;10use crate::posters::Posters;11use crate::screens::{self, Step, loading};1213impl<S: Source, P: Posters> Browser<S, P> {14    pub(super) fn top(&self) -> &screens::Screen {15        self.stack.last().unwrap_or(&self.home)16    }1718    // Read the screen on top again. The home page goes through the19    // reader, so the read that uncovers it never holds the frame thread.20    pub(super) fn reread_top(&mut self) {21        let Some(top) = self.stack.last_mut() else {22            self.refresh_home();23            return;24        };25        top.reread(&mut self.source);26        top.volume(&*self.posters.borrow());27    }2829    // Do what the screen that took the press asked for. Only the browser30    // holds the stack, so a screen names the screen it opens and never31    // pushes one itself.32    pub(super) fn take(&mut self, step: Step) {33        match step {34            Step::Stay | Step::Still => {}35            Step::Open(screen) => self.opened(screen),36            Step::Replace(screen) => {37                self.stack.pop();38                self.opened(screen);39            }40            Step::Play { library, selection } => {41                // The press enters the state in the frame it lands in.42                // Nothing downstream is awaited: the request crosses the43                // bus, the operator creates the `Play`, and the pod44                // starts, and none of the three reaches this browser. A45                // choice with no film behind it enters nothing, because46                // no film will ever cover the page.47                if self.request_play(&library, &selection) {48                    self.loading = Some(loading::Loading::entered(self.clock));49                }50            }51        }52    }5354    // Push a screen and read the files it draws off the volume,55    // which the screen itself cannot reach: only the browser holds the56    // store that resolves a library's root.57    pub(super) fn opened(&mut self, mut screen: screens::Screen) {58        screen.volume(&*self.posters.borrow());59        self.stack.push(screen);60        self.on_strip = false;61    }6263    // Ask the store for the backdrop of the page under the focused item,64    // at the size the page draws it. The answer is dropped. The ask is65    // the point: the decode lands in the cache before the page opens.66    pub(super) fn prefetch(&mut self) {67        let top = self.stack.last().unwrap_or(&self.home);68        let Some((library, art)) = top.resting(&mut self.source) else {69            return;70        };71        let (width, height) = self.page;72        let _ = self.posters.get_mut().poster(&library, &art, width, height);73    }7475    // Back pops one descent and re-reads the screen it uncovers,76    // because a change that landed while that screen was covered was77    // folded into the screen that was shown at the time and not into78    // this one. The home page is the one screen that is read only where79    // it is behind, so back to it draws the page a person left at once.80    //81    // At the home page there is nowhere to climb, so a browser on a bus82    // asks for the shade. Only the browser knows whether back has83    // anywhere to go, which is why the crate never sleeps on back84    // itself.85    pub(super) fn back(&mut self) {86        self.on_strip = false;87        if self.stack.pop().is_some() {88            self.reread_top();89            return;90        }91        if let Some(bus) = &self.bus {92            bus.sleep();93        }94    }9596    // Push the search wall over the screen the person is on, so back97    // returns to it. The text seeds the field. The grid shows when the98    // way in was a button and not a keyboard.99    pub(super) fn search(&mut self, text: &str, grid: bool) {100        let screen = screens::wall::searched(text, grid, &mut self.source);101        self.opened(screen);102    }103104    // Home drops every screen over the home page in one press and then105    // reads the home page the way back landing on it does: only where106    // the page is behind. On the home page there is nothing to drop, and107    // the press puts focus back on the first row.108    pub(super) fn home(&mut self) {109        self.on_strip = false;110        if self.stack.is_empty() {111            if let screens::Screen::Home(home) = &mut self.home {112                home.top();113            }114            return;115        }116        self.stack.clear();117        self.refresh_home();118    }119}
src/bus/play.rs 100.0%
1// The one request this browser publishes with a body: the play list it2// resolved, on the topic the library operator named. The operator joins3// each path to a claim reference and creates the `Play`, because the4// screen pod holds no API credential.56use serde_json::{Map, Value};78use crate::catalog::{PlayItem, Presentation};910/// The request as bytes. `library` is the catalog's library column,11/// `namespace/name`, and every path is relative to that library's root.12///13/// The slug is the chosen item's, which is the first of the list: the14/// movie, or the episode a person picked, with the rest of its season15/// after it. The operator folds it into the `Play`'s name. A list that16/// resolved nothing carries an empty slug, and the operator then names17/// the `Play` after the unit alone.18pub fn payload(library: &str, items: &[PlayItem]) -> Vec<u8> {19    let mut request = Map::new();20    request.insert("library".into(), Value::from(library));21    request.insert(22        "slug".into(),23        Value::from(items.first().map(|item| item.slug.as_str()).unwrap_or("")),24    );25    request.insert(26        "items".into(),27        Value::Array(items.iter().map(one).collect()),28    );29    Value::Object(request).to_string().into_bytes()30}3132// One item: the path of its main file, and the presentation beside it.33fn one(item: &PlayItem) -> Value {34    let mut object = Map::new();35    object.insert("path".into(), Value::from(item.path.as_str()));36    object.insert("presentation".into(), presentation(&item.presentation));37    Value::Object(object)38}3940// The presentation, in media-operator's own field names. An empty field41// is left out rather than sent empty, so the object carries what the42// catalog holds and nothing more.43fn presentation(presentation: &Presentation) -> Value {44    let mut object = Map::new();45    for (name, text) in [46        ("type", &presentation.kind),47        ("hint", &presentation.hint),48        ("title", &presentation.title),49        ("series", &presentation.series),50        ("episodeTitle", &presentation.episode_title),51        ("date", &presentation.date),52        ("art", &presentation.art),53        ("trickplay", &presentation.trickplay),54    ] {55        if !text.is_empty() {56            object.insert(name.into(), Value::from(text.as_str()));57        }58    }59    for (name, number) in [60        ("season", presentation.season),61        ("episode", presentation.episode),62        ("year", presentation.year),63    ] {64        if number != 0 {65            object.insert(name.into(), Value::from(number));66        }67    }68    Value::Object(object)69}7071#[cfg(test)]72mod tests {73    use super::*;7475    fn movie() -> PlayItem {76        PlayItem {77            path: "Some Film (1999)/Some Film (1999).mkv".into(),78            slug: "some-film-1999".into(),79            presentation: Presentation {80                kind: "video".into(),81                hint: "movie".into(),82                title: "Some Film".into(),83                year: 1999,84                art: "Some Film (1999)/poster.jpg".into(),85                trickplay: "Some Film (1999)/Some Film (1999).trickplay".into(),86                ..Presentation::default()87            },88        }89    }9091    fn decoded(library: &str, items: &[PlayItem]) -> Value {92        serde_json::from_slice(&payload(library, items)).expect("the request is JSON")93    }9495    #[test]96    fn a_movie_request_carries_the_library_the_path_and_the_presentation() {97        assert_eq!(98            decoded("default/films", &[movie()]),99            serde_json::json!({100                "library": "default/films",101                "slug": "some-film-1999",102                "items": [{103                    "path": "Some Film (1999)/Some Film (1999).mkv",104                    "presentation": {105                        "type": "video",106                        "hint": "movie",107                        "title": "Some Film",108                        "year": 1999,109                        "art": "Some Film (1999)/poster.jpg",110                        "trickplay": "Some Film (1999)/Some Film (1999).trickplay",111                    },112                }],113            })114        );115    }116117    #[test]118    fn an_episode_request_carries_the_series_the_numbers_and_the_date() {119        let item = PlayItem {120            path: "Show/S01/Show S01E02.mkv".into(),121            slug: "show-s01e02".into(),122            presentation: Presentation {123                kind: "video".into(),124                hint: "series".into(),125                series: "Show".into(),126                season: 1,127                episode: 2,128                episode_title: "The Second".into(),129                date: "2004-09-22".into(),130                ..Presentation::default()131            },132        };133134        assert_eq!(135            decoded("default/shows", &[item])["items"][0]["presentation"],136            serde_json::json!({137                "type": "video",138                "hint": "series",139                "series": "Show",140                "season": 1,141                "episode": 2,142                "episodeTitle": "The Second",143                "date": "2004-09-22",144            })145        );146    }147148    #[test]149    fn an_empty_field_is_left_out_of_the_request() {150        assert_eq!(151            decoded(152                "default/films",153                &[PlayItem {154                    path: "film.mkv".into(),155                    slug: String::new(),156                    presentation: Presentation::default(),157                }]158            )["items"][0]["presentation"],159            serde_json::json!({})160        );161    }162163    #[test]164    fn a_request_keeps_the_order_the_catalog_answered() {165        let mut second = movie();166        second.path = "Later.mkv".into();167        let request = decoded("default/films", &[movie(), second]);168169        assert_eq!(170            request["items"][0]["path"],171            "Some Film (1999)/Some Film (1999).mkv"172        );173        assert_eq!(request["items"][1]["path"], "Later.mkv");174    }175}
src/catalog.rs 97.0%
1// The seam between the catalog and the views. The views draw rows, and a2// `Source` yields them, so one set of views draws the sidecar's file, a3// test fixture, and the sample data the same way.45use crate::harness::Waker;67// The sidecar module implements this seam over plan 06's delivery: a8// read-only open of the sidecar's file, and its update stream.9pub mod sidecar;1011// The query module: the closed set of queries a wall is fed by, and the12// slots a source answers one with.13pub mod query;1415// The franchise module: what the two franchise reads answer with.16pub mod franchise;1718// The recency module: the fold the Released and Added queries share,19// and the constants that bound them.20pub mod recency;2122// The pool module: the candidate strips the home page draws from, each23// with its weight.24pub mod pool;2526// The draw module: the date seed and the weighted draw of the day's27// strips from the pool.28pub mod draw;2930// The art module: which file an item's art is, out of the list of every31// art file beside it.32pub mod art;3334// The search module: the in-memory index the `Search` query is answered35// from, with its fold and its ranking.36pub mod search;3738pub use franchise::{Calendar, Entry, Era, Franchise, Held, Membership};39pub use query::{Answer, Counts, Fold, GenreSort, InSeries, Order, Query, Slot, Sort};4041/// How many posters the tile of a library or a genre draws, as a 2x2.42pub const TILES: usize = 4;4344/// How many posters a genre offers [`unrepeated`], so a genre whose45/// newest posters an earlier genre took has more to fall back on.46pub const TILE_CANDIDATES: usize = 3 * TILES;4748/// One library as the home page's libraries strip draws it: the name,49/// the kind, the count of items it holds, and the art of its newest-added50/// titles.51#[derive(Debug, Clone, PartialEq, Eq)]52pub struct LibraryEntry {53    /// The catalog's `library` column: the `Library`'s namespace and name,54    /// joined as `namespace/name`.55    pub library: String,56    /// The library's kind, `movies` or `series`. The libraries strip draws57    /// it under the name. The wall it opens reads by the library alone, and58    /// every slot names its own kind.59    pub kind: String,60    /// How many items the library holds.61    pub items: u64,62    /// The posters of the library's newest-added titles that have one, up63    /// to [`TILES`] of them, which the libraries strip draws as a mosaic.64    /// Every path resolves against the library itself.65    pub art: Vec<String>,66}6768/// One genre as the home page's genres strip draws it: the name, how69/// many titles carry it, and the art it draws as, with the library that70/// art resolves against.71#[derive(Debug, Clone, Default, PartialEq, Eq)]72pub struct GenreEntry {73    /// The genre, as the catalog's genres table spells it.74    pub name: String,75    /// How many movies and series carry the genre at any rank, across76    /// every library.77    pub titles: u64,78    /// The posters the genre's tile draws, each with the library it79    /// resolves against, because a genre spans libraries. A read answers up80    /// to [`TILE_CANDIDATES`] of them, the titles that lead with the genre81    /// first and the newest release next, and [`unrepeated`] cuts each82    /// entry to the [`TILES`] no earlier genre took.83    pub art: Vec<(String, String)>,84}8586/// The posters each genre's tile draws, in the strip's order: the first87/// [`TILES`] candidates of the entry that no earlier entry took, so one88/// poster never stands on two tiles of the row. An entry whose candidates89/// run out draws fewer.90pub fn unrepeated(entries: &mut [GenreEntry]) {91    let mut taken: Vec<(String, String)> = Vec::new();92    for entry in entries {93        let mut drawn: Vec<(String, String)> = Vec::with_capacity(TILES);94        for poster in std::mem::take(&mut entry.art) {95            if drawn.len() == TILES {96                break;97            }98            if taken.contains(&poster) {99                continue;100            }101            taken.push(poster.clone());102            drawn.push(poster);103        }104        entry.art = drawn;105    }106}107108/// One franchise as the home page's franchises strip draws it. `library` and109/// `id` name the `Library` of kind franchises and the row in it, which is what110/// a press opens. `title` is the name a person reads, and the strip draws it111/// on the slot where the row carries no art. `art` is the file beside the112/// franchise.yaml, or where the directory holds none, the poster of the first113/// held member in story order. `art_library` is the library that art114/// resolves against, which is the member's own library in the second case.115/// `slug` is the catalog's own name for the row.116/// `movies` and `series` count every entry of the order by kind, held or117/// not: the scope the tile draws under the title, and the count a strip118/// heading carries.119#[derive(Debug, Clone, Default, PartialEq, Eq)]120pub struct FranchiseEntry {121    pub library: String,122    pub id: String,123    pub title: String,124    pub art: String,125    pub art_library: String,126    pub slug: String,127    pub movies: i64,128    pub series: i64,129}130131/// One title in a kind's top list: a movie, or a series.132#[derive(Debug, Clone, Default, PartialEq, Eq)]133pub struct Title {134    /// The item's provider-scoped id, unique inside its library.135    pub id: String,136    /// The name a person reads.137    pub title: String,138    /// The year or the date of release, as the catalog stores it.139    pub released: String,140    /// The path of the primary art, relative to the library root, or empty141    /// where the item has none.142    pub art: String,143    /// The item's running time in seconds, zero where the catalog holds144    /// none.145    pub duration: i64,146    /// The content rating from the body, empty where the sidecar named147    /// none.148    pub rating: String,149    /// The tagline from the body, empty where the sidecar wrote none. A150    /// film's card leads with it.151    pub tagline: String,152}153154/// One credited person and the part they played, from the body's cast.155#[derive(Debug, Clone, Default, PartialEq, Eq)]156pub struct Credit {157    /// The person's name.158    pub name: String,159    /// The part they played, empty where the sidecar named none.160    pub role: String,161}162163/// What a movie's page draws: the item's own columns, the fields of its164/// body, and the three files the page reads by role.165#[derive(Debug, Clone, Default, PartialEq)]166pub struct MovieDetails {167    /// The name a person reads.168    pub title: String,169    /// The year or the date of release, as the catalog stores it.170    pub released: String,171    /// The running time in seconds, zero where the catalog holds none.172    pub duration: i64,173    /// The content rating, empty where the sidecar named none.174    pub rating: String,175    /// The genres, in the order the sidecar named them.176    pub genres: Vec<String>,177    /// The one-line tagline, empty where the sidecar named none.178    pub tagline: String,179    /// The plot. The page cuts it to four lines.180    pub plot: String,181    /// The directors, in the order the sidecar named them.182    pub directors: Vec<String>,183    /// The writers, in the order the sidecar named them.184    pub writers: Vec<String>,185    /// The cast, in the order the sidecar named them.186    pub cast: Vec<Credit>,187    /// The studios, in the order the sidecar names them.188    pub studios: Vec<String>,189    /// Each site's score of the movie, keyed by the sidecar's own name for190    /// the site, on that site's own scale.191    pub ratings: Vec<(String, f64)>,192    /// The id of the set the movie belongs to, empty where it belongs to193    /// none.194    pub set_id: String,195    /// The path of the backdrop file, relative to the library root, or196    /// empty where the item has none.197    pub backdrop: String,198    /// The path of the logo file, relative to the library root, or empty199    /// where the item has none.200    pub logo: String,201    /// The path of the trailer file, relative to the library root, or202    /// empty where the item has none.203    pub trailer: String,204}205206/// One set and every movie in it, in release order, as the strip on a207/// movie's page draws them.208#[derive(Debug, Clone, Default, PartialEq, Eq)]209pub struct MovieSet {210    /// The set's own title. The strip draws it as its heading.211    pub title: String,212    /// The movies in the set, in release order.213    pub members: Vec<Title>,214}215216/// The name half of a `library` column, `namespace/name`, which is the217/// half a screen draws.218pub fn library_name(library: &str) -> &str {219    library.split_once('/').map_or(library, |(_, name)| name)220}221222/// What a series' page draws: the item's own columns, the fields of its223/// body, the two files it reads by role, and how many seasons its224/// episodes fall into.225#[derive(Debug, Clone, Default, PartialEq)]226pub struct SeriesDetails {227    /// The name a person reads.228    pub title: String,229    /// The year or the date of release, as the catalog stores it.230    pub released: String,231    /// The running time in seconds, zero where the catalog holds none.232    pub duration: i64,233    /// The content rating, empty where the sidecar named none.234    pub rating: String,235    /// The genres, in the order the sidecar named them.236    pub genres: Vec<String>,237    /// The one-line tagline, empty where the sidecar named none.238    pub tagline: String,239    /// The plot. The page cuts it to two lines.240    pub plot: String,241    /// The creators, in the order the sidecar named them.242    pub creators: Vec<String>,243    /// The cast, in the order the sidecar named them.244    pub cast: Vec<Credit>,245    /// The studios, in the order the sidecar names them.246    pub studios: Vec<String>,247    /// Each site's score of the series, keyed by the sidecar's own name for248    /// the site, on that site's own scale.249    pub ratings: Vec<(String, f64)>,250    /// The path of the backdrop file, relative to the library root, or251    /// empty where the item has none.252    pub backdrop: String,253    /// The path of the logo file, relative to the library root, or empty254    /// where the item has none.255    pub logo: String,256    /// How many seasons the series' episodes fall into.257    pub seasons: i64,258}259260/// One episode of a series, as one still of the series page's wall.261#[derive(Debug, Clone, Default, PartialEq, Eq)]262pub struct Episode {263    /// The episode's id inside its library, which its files are read by.264    pub id: String,265    /// The aired season number that places the episode.266    pub season: i64,267    /// The aired episode number inside the season.268    pub episode: i64,269    /// The name a person reads.270    pub title: String,271    /// The year or the date the episode aired, as the catalog stores it.272    pub released: String,273    /// The running time in seconds, zero where the catalog holds none.274    pub duration: i64,275    /// The plot, empty where the sidecar named none.276    pub plot: String,277    /// The path the still draws, relative to the library root: the278    /// episode's own still, and the art of its series where the catalog279    /// holds no still for the episode. Empty where the series holds no280    /// art either. See [`art::still`].281    pub art: String,282}283284/// One slot of a title's stripe: the person, what they did on this285/// title, and where their entry lives.286#[derive(Debug, Clone, Default, PartialEq, Eq)]287pub struct CreditSlot {288    /// The name a person reads, as the title's own credits name it.289    pub name: String,290    /// The character an actor played, empty for the crew and for an291    /// actor the credits gave no role.292    pub role: String,293    /// The person's directory relative to the library volume, empty294    /// where the library's store holds no entry for them.295    pub contributor: String,296    /// Whether `headshot.jpg` is beside that entry.297    pub headshot: bool,298}299300/// One file of a title, as the foot of a page reads it: what the file is,301/// how it is encoded, and how large it is.302#[derive(Debug, Clone, Default, PartialEq, Eq)]303pub struct FileFacts {304    /// Which one of its kind the file is, such as `primary`.305    pub role: String,306    /// The file's category, such as `video` or `subtitle`.307    pub kind: String,308    /// The container the file is written in.309    pub container: String,310    /// The video codec, empty where the scanner read none.311    pub video_codec: String,312    /// The audio codec, empty where the scanner read none.313    pub audio_codec: String,314    /// The width in pixels, zero where the scanner read none.315    pub width: i64,316    /// The height in pixels, zero where the scanner read none.317    pub height: i64,318    /// The size in bytes, zero where the scanner read none.319    pub size_bytes: i64,320    /// The language tag the file name carries, empty where it carries none.321    pub language: String,322}323324/// One title's credited people, split into the three stripes a page325/// draws, each in billing order.326#[derive(Debug, Clone, Default, PartialEq, Eq)]327pub struct Credits {328    /// The directors, in billing order.329    pub directors: Vec<CreditSlot>,330    /// The writers, in billing order.331    pub writers: Vec<CreditSlot>,332    /// The cast, in billing order.333    pub cast: Vec<CreditSlot>,334}335336/// One person, as their own page draws them. `library` and `path` name337/// the entry the page opened from. The headshot and the biography can338/// each come from another library's entry for the same person, so the339/// four fields after the flags say which library and which directory340/// hold each file.341#[derive(Debug, Clone, Default, PartialEq, Eq)]342pub struct Person {343    /// The library the page opened from, as `namespace/name`.344    pub library: String,345    /// The person's directory in that library, relative to its346    /// volume.347    pub path: String,348    /// The name a person reads.349    pub name: String,350    /// The date of birth the entry holds, empty where it holds351    /// none.352    pub born: String,353    /// The date of death the entry holds, empty where it holds354    /// none.355    pub died: String,356    /// Whether any library holding this person has `biography.txt`357    /// beside the entry.358    pub biography: bool,359    /// Whether any library holding this person has `headshot.jpg`360    /// beside the entry.361    pub headshot: bool,362    /// The library whose entry holds the biography, empty where no363    /// library holds one.364    pub biography_library: String,365    /// The person's directory in that library.366    pub biography_path: String,367    /// The library whose entry holds the headshot, empty where no368    /// library holds one.369    pub headshot_library: String,370    /// The person's directory in that library.371    pub headshot_path: String,372}373374/// What the views read. Every list comes back in the order the views draw375/// it, so the views sort nothing: titles by the scanner's sort key, and376/// episodes by their aired numbers.377///378/// Every method reads local state and returns at once; no call waits on a379/// network. [`Source::changed`] carries the freshness contract from plan380/// 06: a source with an update stream folds events in behind these calls,381/// wakes the loop through the handle from [`Source::wake_by`], and answers382/// true once, and the views then re-read what they show.383pub trait Source {384    /// Start one home page read. A source can retain repeated answers until385    /// [`Source::end_page_read`].386    fn begin_page_read(&mut self) {}387388    /// End one home page read and release any answers retained for it.389    fn end_page_read(&mut self) {}390391    /// Every library in the catalog, ordered by name. Which libraries a392    /// screen shows is an open problem, so until that resource exists the393    /// home page's libraries strip shows them all.394    fn libraries(&mut self) -> Vec<LibraryEntry>;395396    /// Every genre the catalog holds, in name order, each with its count397    /// of titles and the art the strip draws it as. One read, because the398    /// genres strip is a row of every home page.399    fn genres(&mut self) -> Vec<GenreEntry>;400401    /// Every franchise the catalog holds, across every library of the402    /// namespace, in the sort order a wall of them draws. The franchises strip403    /// of the home page reads it, and draws every row and not a sample of404    /// them, the way the genres strip does.405    fn franchises(&mut self) -> Vec<FranchiseEntry>;406407    /// The one read behind every wall. Every slot carries its library and408    /// its kind, so one wall draws a library, a person's works, a set, and409    /// a franchise's held members from the same answer. The answer names410    /// what the query is about and holds its slots in the query's order.411    /// It is empty where the query names nothing the catalog holds.412    fn wall(&mut self, query: &Query) -> Answer;413414    /// Every candidate strip the day may draw, with its weight: every415    /// genre, every person with more than `WORKS_FLOOR` works, and every set416    /// with at least two members. The pool is one read because the draw is a417    /// pure function of the date and the pool, so the draw needs nothing418    /// else.419    fn pool(&mut self) -> Vec<pool::Candidate>;420421    /// One movie's details, or nothing where the library holds no movie422    /// under that id.423    fn movie(&mut self, library: &str, id: &str) -> Option<MovieDetails>;424425    /// One series' details, or nothing where the library holds no series426    /// under that id.427    fn series(&mut self, library: &str, id: &str) -> Option<SeriesDetails>;428429    /// Every episode of one series, in aired order: by season, and by430    /// episode inside a season.431    fn episodes(&mut self, library: &str, series: &str) -> Vec<Episode>;432433    /// One set and its members in release order, or nothing where the434    /// library holds no set under that id.435    fn set(&mut self, library: &str, id: &str) -> Option<MovieSet>;436437    /// Every franchise one title belongs to, with the members some library438    /// of the namespace holds, in story order. The strip on the title's439    /// page draws them, so it draws what a person can play. The title's440    /// own aliases find the franchises, so a member resolves by string441    /// match and no library reads another's volume.442    fn franchises_of(&mut self, library: &str, id: &str) -> Vec<Membership>;443444    /// One franchise as its own page draws it, or nothing where that445    /// `Library` holds no franchise under that id. Every entry is in story446    /// order, held or not, so a gap draws with the file's own title.447    fn franchise(&mut self, library: &str, id: &str) -> Option<Franchise>;448449    /// The play list one choice resolves to. A movie is one item or450    /// none. An episode is itself and every later episode of its season,451    /// in episode order. A choice whose own main file is missing452    /// resolves to nothing, because a play that skipped what the person453    /// chose is worse than no play at all.454    fn play(&mut self, library: &str, selection: &Selection) -> Vec<PlayItem>;455456    /// One title's credited people, split by part and in billing457    /// order within a part.458    fn credits(&mut self, library: &str, id: &str) -> Credits;459460    /// Every file of one item, in path order, as the foot of a page reads461    /// them.462    fn files(&mut self, library: &str, item: &str) -> Vec<FileFacts>;463464    /// One person by the library and the directory that name them,465    /// or nothing where that library holds no such entry.466    fn person(&mut self, library: &str, path: &str) -> Option<Person>;467468    /// How large this source's search index is, or nothing where the469    /// source holds none. The stats line reports the numbers, so a run470    /// says what the index cost on the machine it ran on. A source that471    /// answers `Search` from no index of its own answers nothing.472    fn index_size(&mut self) -> Option<search::Size> {473        None474    }475476    /// Whether anything changed since the last call.477    fn changed(&mut self) -> bool;478479    /// A second source over the same catalog, for a reader thread of its480    /// own, so a read runs off the frame thread. A source that has no481    /// second read to give answers nothing, and the caller then reads in482    /// place. The second source reports no changes and wakes no loop: the483    /// first one carries the stream.484    fn reader(&mut self) -> Option<Box<dyn Source + Send>> {485        None486    }487488    /// Take the handle that wakes the loop, for a source with a stream of489    /// its own. A source with no stream takes it and does nothing.490    fn wake_by(&mut self, wake: Waker);491}492493/// What a person chose, as the three things that resolve to a play494/// list: a movie by its id, a movie's trailer by the movie's id, and an495/// episode by its series, season, and aired number. The library is not in here because every read496/// takes it beside the choice.497#[derive(Debug, Clone, PartialEq, Eq)]498pub enum Selection {499    /// One movie, named by its provider-scoped id.500    Movie {501        /// The movie's id inside its library.502        id: String,503    },504    /// One movie's trailer, named by the movie's own id.505    Trailer {506        /// The movie's id inside its library.507        id: String,508    },509    /// One episode, and with it the rest of its season.510    Episode {511        /// The parent series' id inside the library.512        series: String,513        /// The aired season number.514        season: i64,515        /// The aired episode number the person chose.516        episode: i64,517    },518}519520impl Selection {521    /// The choice as one line, for the log line a resolve that found522    /// nothing writes.523    pub fn named(&self) -> String {524        match self {525            Self::Movie { id } => id.clone(),526            Self::Trailer { id } => format!("{id} trailer"),527            Self::Episode {528                series,529                season,530                episode,531            } => format!("{series} S{season}E{episode}"),532        }533    }534}535536/// One item of a play list: the main file's path relative to the537/// library root, and the words the film's own display shows.538#[derive(Debug, Clone, PartialEq, Eq)]539pub struct PlayItem {540    /// The main file's path, relative to the library root.541    pub path: String,542    /// The catalog's slug for this item, such as `some-film-1999`. The543    /// operator folds the chosen item's slug into the `Play`'s name, so544    /// `kubectl get plays` reads as titles.545    pub slug: String,546    /// The presentation the operator passes through to the `Play`.547    pub presentation: Presentation,548}549550/// media-operator's own presentation block, as the catalog answers it.551/// Every empty field is left out of the request, so this type carries552/// the same absences the JSON does.553#[derive(Debug, Clone, Default, PartialEq, Eq)]554pub struct Presentation {555    /// The medium, `video` for everything this plan resolves. It is556    /// `type` in the JSON, which Rust reserves.557    pub kind: String,558    /// What the item is, `movie` or `series`.559    pub hint: String,560    /// The movie's title. An episode carries none.561    pub title: String,562    /// The series' title, from the series row.563    pub series: String,564    /// The aired season number.565    pub season: i64,566    /// The aired episode number.567    pub episode: i64,568    /// The episode's own title.569    pub episode_title: String,570    /// The year of release, the first four digits of the catalog's571    /// released column.572    pub year: i64,573    /// The full ISO date of release, where the catalog holds one.574    pub date: String,575    /// The art path, relative to the library root.576    pub art: String,577    /// The trickplay path, relative to the library root.578    pub trickplay: String,579}580581#[cfg(test)]582mod tests {583    use super::*;584585    fn entry(name: &str, art: &[&str]) -> GenreEntry {586        GenreEntry {587            name: name.into(),588            titles: art.len() as u64,589            art: art590                .iter()591                .map(|path| ("screening/films".to_string(), (*path).to_string()))592                .collect(),593        }594    }595596    fn posters(entry: &GenreEntry) -> Vec<&str> {597        entry.art.iter().map(|(_, path)| path.as_str()).collect()598    }599600    #[test]601    fn a_genre_draws_the_first_four_posters_no_earlier_genre_took() {602        let mut entries = [603            entry("Crime", &["a", "b", "c", "d", "e", "f"]),604            entry("Drama", &["a", "b", "e", "g", "h", "i"]),605        ];606        unrepeated(&mut entries);607        assert_eq!(posters(&entries[0]), ["a", "b", "c", "d"]);608        assert_eq!(posters(&entries[1]), ["e", "g", "h", "i"]);609    }610611    #[test]612    fn a_genre_whose_candidates_run_out_draws_fewer() {613        let mut entries = [614            entry("Crime", &["a", "b"]),615            entry("Drama", &["a", "b", "c"]),616            entry("Silent", &[]),617        ];618        unrepeated(&mut entries);619        assert_eq!(posters(&entries[0]), ["a", "b"]);620        assert_eq!(posters(&entries[1]), ["c"]);621        assert!(entries[2].art.is_empty());622    }623624    #[test]625    fn one_path_in_two_libraries_is_two_posters() {626        let mut entries = [627            entry("Crime", &["a"]),628            GenreEntry {629                art: vec![("screening/serials".into(), "a".into())],630                ..entry("Drama", &[])631            },632        ];633        unrepeated(&mut entries);634        assert_eq!(entries[0].art, [("screening/films".into(), "a".into())]);635        assert_eq!(entries[1].art, [("screening/serials".into(), "a".into())]);636    }637638    #[test]639    fn a_library_names_itself_after_its_namespace() {640        assert_eq!(library_name("screening/features"), "features");641        assert_eq!(library_name("features"), "features");642    }643644    #[test]645    fn every_choice_names_itself_for_the_log() {646        assert_eq!(647            Selection::Movie {648                id: "movie:tmdb:603".into()649            }650            .named(),651            "movie:tmdb:603"652        );653        assert_eq!(654            Selection::Trailer {655                id: "movie:tmdb:603".into()656            }657            .named(),658            "movie:tmdb:603 trailer"659        );660        assert_eq!(661            Selection::Episode {662                series: "series:tvdb:1".into(),663                season: 2,664                episode: 4,665            }666            .named(),667            "series:tvdb:1 S2E4"668        );669    }670}
src/catalog/art.rs 100.0%
1// Which file an item's art is, out of the list of every art file beside2// it. The catalog holds the primary art in one column and every art file3// in another, under the names the scanners write, so the choice of file4// is a rule over those names and not a column of its own.56/// The 16:9 art of one item, out of the list of every art file beside it:7/// the landscape file first, then the fanart, then the backdrop, which is8/// the other name the scanners of this catalog write the same 16:9 art9/// under. Nothing where the item holds none of the three.10pub fn landscape(arts: &[String]) -> Option<&str> {11    LANDSCAPE12        .iter()13        .find_map(|name| arts.iter().find(|art| named(art, name)))14        .map(String::as_str)15}1617/// The art an episode's card draws in its 16:9 slot: the episode's own18/// still, and where the catalog holds none for it, the series' own art,19/// its 16:9 art first and its poster last. The fallback is the drawing20/// alone. The episode's still column stays empty, so the enricher's gap21/// stays open and the still itself takes the slot once the enricher22/// writes one. Empty where the series holds no art either, and the card23/// then draws its title on an empty slot.24pub fn still(episode: &str, series_art: &str, series_arts: &[String]) -> String {25    if !episode.is_empty() {26        return episode.to_string();27    }28    landscape(series_arts).unwrap_or(series_art).to_string()29}3031// The names an item's 16:9 art is written under, in the order the ladder32// takes them.33const LANDSCAPE: [&str; 3] = ["landscape.jpg", "fanart.jpg", "backdrop.jpg"];3435// Whether one art path is the file of this name. The paths are relative36// to the library's volume, so the name is the last part of the path.37fn named(art: &str, name: &str) -> bool {38    art.rsplit('/').next().is_some_and(|file| file == name)39}4041#[cfg(test)]42mod tests {43    use super::*;4445    fn arts(paths: &[&str]) -> Vec<String> {46        paths.iter().map(|path| (*path).to_string()).collect()47    }4849    #[test]50    fn an_item_draws_its_landscape_then_its_fanart_then_its_backdrop() {51        for (files, drawn) in [52            (53                &["Show/poster.jpg", "Show/fanart.jpg", "Show/landscape.jpg"][..],54                Some("Show/landscape.jpg"),55            ),56            (57                &["Show/poster.jpg", "Show/backdrop.jpg", "Show/fanart.jpg"][..],58                Some("Show/fanart.jpg"),59            ),60            (61                &["Show/poster.jpg", "Show/backdrop.jpg"][..],62                Some("Show/backdrop.jpg"),63            ),64            (&["Show/poster.jpg", "Show/clearlogo.png"][..], None),65            (&[][..], None),66        ] {67            assert_eq!(landscape(&arts(files)), drawn);68        }69    }7071    #[test]72    fn a_name_a_title_carries_is_not_the_art_of_that_name() {73        assert_eq!(landscape(&arts(&["Landscape (1999)/poster.jpg"])), None);74    }7576    #[test]77    fn an_episode_with_a_still_of_its_own_draws_it() {78        let files = arts(&["Show/landscape.jpg"]);79        assert_eq!(80            still("Show/Season 11/S11E01-thumb.jpg", "Show/poster.jpg", &files),81            "Show/Season 11/S11E01-thumb.jpg"82        );83    }8485    #[test]86    fn an_episode_with_no_still_draws_the_art_of_its_series() {87        for (files, drawn) in [88            (89                &["Show/landscape.jpg", "Show/fanart.jpg"][..],90                "Show/landscape.jpg",91            ),92            (&["Show/fanart.jpg"][..], "Show/fanart.jpg"),93            (&["Show/clearlogo.png"][..], "Show/poster.jpg"),94            (&[][..], "Show/poster.jpg"),95        ] {96            assert_eq!(still("", "Show/poster.jpg", &arts(files)), drawn);97        }98    }99100    #[test]101    fn an_episode_of_a_series_with_no_art_draws_nothing() {102        assert_eq!(still("", "", &[]), "");103    }104}
src/catalog/draw.rs 100.0%
1// The day's draw. The local civil date is the seed, so the page is the2// same all day and different tomorrow, and the draw is a pure function3// of the date and the pool, so a wrong page reproduces from those two4// alone. There is no random-number crate: splitmix64 is a few lines, and5// the seed is the date.67use std::collections::HashSet;89use super::pool::{Candidate, Kind};10use super::recency::{self, DRAWN};1112/// A civil date in the process's own zone: the year, the month from one,13/// and the day of the month.14#[derive(Debug, Clone, Copy, PartialEq, Eq)]15pub struct Date {16    pub year: i64,17    pub month: u8,18    pub day: u8,19}2021impl Date {22    /// Today's date in the process's zone, through the crate's one read of23    /// the local time.24    pub fn today() -> Self {25        let local = crate::clock::local();26        Self {27            year: i64::from(local.tm_year) + 1900,28            month: (local.tm_mon + 1) as u8,29            day: local.tm_mday as u8,30        }31    }3233    /// The date as the catalog writes one, yyyy-mm-dd.34    pub fn iso(self) -> String {35        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)36    }3738    /// The date as Unix seconds at midnight UTC, through the same reader the39    /// catalog's dates go through, so there is one arithmetic and one40    /// midnight.41    pub fn seconds(self) -> i64 {42        recency::date_seconds(&self.iso()).unwrap_or(0)43    }4445    /// The civil date of Unix seconds, Howard Hinnant's civil-from-days. A46    /// test names a date by its distance from today with it, so no test47    /// depends on the wall clock.48    pub fn from_seconds(seconds: i64) -> Self {49        let days = seconds.div_euclid(86_400) + 719_468;50        let era = days.div_euclid(146_097);51        let day_of_era = days - era * 146_097;52        let year_of_era =53            (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;54        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);55        let month_index = (5 * day_of_year + 2) / 153;56        let day = day_of_year - (153 * month_index + 2) / 5 + 1;57        let month = if month_index < 10 {58            month_index + 359        } else {60            month_index - 961        };62        let year = year_of_era + era * 400 + i64::from(month <= 2);63        Self {64            year,65            month: month as u8,66            day: day as u8,67        }68    }6970    // The seed: the date as one number, yyyymmdd, so two dates never share71    // one.72    fn seed(self) -> u64 {73        (self.year.unsigned_abs() * 10_000) + u64::from(self.month) * 100 + u64::from(self.day)74    }75}7677// splitmix64, seeded once from the date. The same date walks the same78// sequence.79struct Generator(u64);8081impl Generator {82    fn next(&mut self) -> u64 {83        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);84        let mut z = self.0;85        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);86        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);87        z ^ (z >> 31)88    }89}9091/// The draw: `DRAWN` candidates, weighted, without replacement, and no92/// two of one kind until every kind that has candidates has been drawn93/// once, so a page shows a mix and not four genres. A candidate with no94/// weight is never drawn.95pub fn draw(date: Date, pool: &[Candidate]) -> Vec<Candidate> {96    let mut generator = Generator(date.seed());97    let mut remaining: Vec<&Candidate> = pool.iter().filter(|c| c.weight > 0).collect();98    let mut kinds: HashSet<Kind> = HashSet::new();99    let mut drawn = Vec::new();100    while drawn.len() < DRAWN && !remaining.is_empty() {101        let fresh: Vec<usize> = (0..remaining.len())102            .filter(|index| !kinds.contains(&remaining[*index].kind()))103            .collect();104        let eligible: Vec<usize> = match fresh.is_empty() {105            true => (0..remaining.len()).collect(),106            false => fresh,107        };108        let total: u64 = eligible.iter().map(|index| remaining[*index].weight).sum();109        let mut point = generator.next() % total;110        let mut chosen = eligible[0];111        for index in eligible {112            let weight = remaining[index].weight;113            if point < weight {114                chosen = index;115                break;116            }117            point -= weight;118        }119        let candidate = remaining.remove(chosen);120        kinds.insert(candidate.kind());121        drawn.push(candidate.clone());122    }123    drawn124}125126#[cfg(test)]127mod tests {128    use super::*;129    use crate::catalog::{GenreSort, Order, Query};130131    fn genre(name: &str, weight: u64) -> Candidate {132        Candidate {133            query: Query::Genre {134                name: name.into(),135                order: Order::Released,136                sort: GenreSort::default(),137            },138            name: name.into(),139            weight,140        }141    }142143    fn person(name: &str, weight: u64) -> Candidate {144        Candidate {145            query: Query::Person {146                library: "screening/features".into(),147                path: format!(".contributors/{name}"),148            },149            name: name.into(),150            weight,151        }152    }153154    fn set(name: &str, weight: u64) -> Candidate {155        Candidate {156            query: Query::Set {157                library: "screening/features".into(),158                id: format!("set:{name}"),159            },160            name: name.into(),161            weight,162        }163    }164165    fn date(year: i64, month: u8, day: u8) -> Date {166        Date { year, month, day }167    }168169    fn names(drawn: &[Candidate]) -> Vec<&str> {170        drawn171            .iter()172            .map(|candidate| candidate.name.as_str())173            .collect()174    }175176    fn kinds(drawn: &[Candidate]) -> Vec<Kind> {177        drawn.iter().map(Candidate::kind).collect()178    }179180    // A pool with two of every kind, so every draw shows one of each and181    // then a repeat.182    fn mixed() -> Vec<Candidate> {183        vec![184            genre("Western", 200),185            genre("Drama", 40),186            person("A Player", 15),187            person("A Director", 4),188            set("The Cycle", 3),189            set("The Sequels", 2),190        ]191    }192193    #[test]194    fn the_first_three_draws_are_one_of_each_kind_and_the_fourth_repeats_one() {195        for day in 1..=28 {196            let drawn = draw(date(2026, 9, day), &mixed());197            assert_eq!(drawn.len(), DRAWN);198            let first: HashSet<Kind> = kinds(&drawn[..3]).into_iter().collect();199            assert_eq!(first.len(), 3, "day {day}: {:?}", names(&drawn));200        }201    }202203    #[test]204    fn the_draw_is_fixed_by_the_date_and_the_pool() {205        let cases = [206            (207                date(2026, 9, 3),208                mixed(),209                vec!["Western", "A Player", "The Cycle", "Drama"],210            ),211            (212                date(2026, 9, 4),213                mixed(),214                vec!["Western", "A Player", "The Sequels", "Drama"],215            ),216            (217                date(2026, 9, 5),218                mixed(),219                vec!["Western", "The Cycle", "A Player", "Drama"],220            ),221            (222                date(2026, 9, 3),223                vec![genre("Western", 1), genre("Drama", 1)],224                vec!["Western", "Drama"],225            ),226        ];227        for (date, pool, expected) in cases {228            assert_eq!(names(&draw(date, &pool)), expected, "{date:?}");229        }230    }231232    #[test]233    fn two_dates_draw_differently_where_the_pool_allows_it() {234        let pool: Vec<Candidate> = (1..=12)235            .map(|number| genre(&format!("Genre {number}"), 10))236            .collect();237        assert_ne!(238            names(&draw(date(2026, 9, 3), &pool)),239            names(&draw(date(2026, 9, 4), &pool))240        );241        assert_ne!(242            names(&draw(date(2026, 9, 3), &pool)),243            names(&draw(date(2025, 9, 3), &pool))244        );245    }246247    #[test]248    fn a_draw_never_repeats_a_candidate_and_never_takes_one_with_no_weight() {249        let pool = vec![250            genre("Western", 5),251            genre("Silent", 0),252            person("A Player", 5),253        ];254        for day in 1..=28 {255            let drawn = draw(date(2026, 9, day), &pool);256            assert_eq!(drawn.len(), 2);257            assert!(!names(&drawn).contains(&"Silent"));258            let distinct: HashSet<&str> = names(&drawn).into_iter().collect();259            assert_eq!(distinct.len(), 2);260        }261    }262263    #[test]264    fn a_pool_of_one_kind_draws_up_to_the_count() {265        let pool: Vec<Candidate> = (1..=6)266            .map(|number| genre(&format!("Genre {number}"), number))267            .collect();268        assert_eq!(draw(date(2026, 9, 3), &pool).len(), DRAWN);269        assert!(draw(date(2026, 9, 3), &[]).is_empty());270    }271272    #[test]273    fn a_heavier_candidate_draws_more_often() {274        let pool = vec![genre("Western", 90), genre("Drama", 10)];275        let westerns = (1..=30)276            .filter(|day| draw(date(2026, 9, *day), &pool)[0].name == "Western")277            .count();278        assert!(westerns > 20, "{westerns} of 30");279    }280281    #[test]282    fn two_dates_never_share_a_seed() {283        assert_ne!(date(2026, 9, 3).seed(), date(2026, 9, 4).seed());284        assert_ne!(date(2026, 9, 3).seed(), date(2026, 10, 3).seed());285        assert_eq!(date(2026, 9, 3).seed(), 20_260_903);286    }287288    #[test]289    fn a_date_reads_as_seconds_and_back() {290        for (date, seconds) in [291            (date(1970, 1, 1), 0),292            (date(2000, 3, 1), 951_868_800),293            (date(2024, 2, 29), 1_709_164_800),294            (date(2026, 9, 3), 1_788_393_600),295            (date(2026, 12, 31), 1_798_675_200),296        ] {297            assert_eq!(date.seconds(), seconds);298            assert_eq!(Date::from_seconds(seconds), date);299            assert_eq!(Date::from_seconds(seconds + 3_600), date);300        }301        assert_eq!(date(2026, 9, 3).iso(), "2026-09-03");302    }303304    #[test]305    fn today_is_a_civil_date() {306        let today = Date::today();307        assert!(today.year >= 2026);308        assert!((1..=12).contains(&today.month));309        assert!((1..=31).contains(&today.day));310    }311}
src/catalog/franchise.rs 99.7%
1// A franchise is one story across films and series in story order, read from a2// franchise.yaml in a git repository that a `Library` of kind franchises3// names. This module holds what the two reads of it answer with: the franchise4// a page draws, and the membership a strip draws. The words the page draws5// around these rows are the screen's own.67use super::{Answer, Slot, Title};89/// The franchise's own clock, from the file's calendar block. `unit` is years10/// or days. `zero` names the event the times count from, and the caption over11/// the page's time column reads it. `before` and `after` are the marks a12/// negative and a positive time take, BBY and ABY for Star Wars, and both are13/// empty for a calendar that counts in plain years or in days.14#[derive(Debug, Clone, Default, PartialEq, Eq)]15pub struct Calendar {16    pub unit: String,17    pub zero: String,18    pub before: String,19    pub after: String,20}2122impl Calendar {23    /// The time label of one span, in the file's own marks. One time reads as24    /// "32 BBY", and a span as "22 to 20 BBY". A span that crosses zero25    /// carries a mark at each end, "5 BBY to 5 ABY". A calendar of years with26    /// no marks reads as plain years, "2024" and "-58", and one of days27    /// counts days from its zero, "Day 1141".28    pub fn label(&self, from: f64, to: f64) -> String {29        let (first, last) = (self.mark(from), self.mark(to));30        let span = match (from == to, first == last) {31            (true, _) => marked(&self.number(from), first),32            (false, true) => marked(33                &format!("{}{SPAN}{}", self.number(from), self.number(to)),34                last,35            ),36            (false, false) => format!(37                "{}{SPAN}{}",38                marked(&self.number(from), first),39                marked(&self.number(to), last)40            ),41        };42        self.counted(&span)43    }4445    /// The caption over the times, "Years from the Battle of Yavin", and46    /// nothing where the file names no zero. The times on the page count from47    /// one event, and the caption is where the page says which event and in48    /// what unit.49    pub fn caption(&self) -> String {50        match self.zero.is_empty() {51            true => String::new(),52            false => format!("{} from {}", capitalized(&self.unit), lowered(&self.zero)),53        }54    }5556    // The mark one time takes: the one for before zero on a negative time,57    // and the one for after zero on zero and every time past it.58    fn mark(&self, value: f64) -> &str {59        match value < 0.0 {60            true => &self.before,61            false => &self.after,62        }63    }6465    // One time as the label writes it: the magnitude where a mark follows it,66    // because the mark says which side of zero the time is on and a minus67    // sign in front of BBY says it a second time. A time with no mark keeps68    // its sign, which is then all the reader has.69    fn number(&self, value: f64) -> String {70        match self.mark(value).is_empty() {71            true => number(value),72            false => number(value.abs()),73        }74    }7576    // One label with the word a count of days leads with. A calendar of days77    // with no marks numbers its rows from zero, and 1141 alone reads as a78    // year. A calendar that names marks says what its numbers are already.79    fn counted(&self, label: &str) -> String {80        let bare = self.before.is_empty() && self.after.is_empty();81        match bare && self.unit == DAYS {82            true => format!("{DAY} {label}"),83            false => label.to_string(),84        }85    }86}8788/// The word between the two times of a span. The page's time column splits a89/// label on it to stack the span on two lines, so the label and the column90/// spell the word once.91pub const SPAN: &str = " to ";9293// The unit a calendar of days names, and the word its labels lead with.94const DAYS: &str = "days";95const DAY: &str = "Day";9697// One time and the mark after it, and the time alone on a calendar that names98// no mark for that side of zero.99fn marked(value: &str, mark: &str) -> String {100    match mark.is_empty() {101        true => value.to_string(),102        false => format!("{value} {mark}"),103    }104}105106// One time as the label writes it: the digits alone where the file gave a107// whole number, because a year is a whole number and a trailing zero after108// the point reads as a measurement.109fn number(value: f64) -> String {110    match value.fract() == 0.0 {111        true => format!("{value:.0}"),112        false => format!("{value}"),113    }114}115116// The unit at the head of the caption, with its first letter in the upper117// case, because the caption starts with it.118fn capitalized(unit: &str) -> String {119    let mut letters = unit.chars();120    match letters.next() {121        Some(first) => first.to_uppercase().chain(letters).collect(),122        None => String::new(),123    }124}125126// The zero as the caption reads it: a leading "The" in the lower case,127// because the file writes the zero as a title and the caption reads it inside128// a sentence.129fn lowered(zero: &str) -> String {130    match zero.strip_prefix("The ") {131        Some(rest) => format!("the {rest}"),132        None => zero.to_string(),133    }134}135136/// One named stretch of the franchise's timeline. The file writes it, and the137/// page derives the rows it covers from each row's own span. Spans overlap on138/// purpose: a saga holds phases.139#[derive(Debug, Clone, Default, PartialEq)]140pub struct Era {141    pub name: String,142    pub from: f64,143    pub to: f64,144}145146impl Era {147    /// How much of the timeline the era covers. The rail sorts on it, widest148    /// first, so a phase nests inside its saga.149    pub fn width(&self) -> f64 {150        self.to - self.from151    }152153    /// Whether this era covers another one whole.154    pub fn holds(&self, inner: &Self) -> bool {155        self.from <= inner.from && inner.to <= self.to156    }157158    /// Whether one span falls inside the era's own.159    pub fn meets(&self, from: f64, to: f64) -> bool {160        self.from <= to && from <= self.to161    }162}163164/// The item some library of the namespace holds for one entry. The read keeps165/// the first library by name where two hold the member. `kind` is the item166/// table the row came from, movies or series. `arts` is every art file167/// beside the item, as the catalog's own list; a wall of landscape cells168/// picks its art out of that list, and a strip of posters draws `art` and169/// `tagline` and `plot` come out of the item's body, the way the movie170/// page reads them. A card of the wall draws the tagline, or the first171/// lines of the plot where there is none; a card of the strip leads with172/// the tagline and reads no plot.173#[derive(Debug, Clone, Default, PartialEq, Eq)]174pub struct Held {175    pub library: String,176    pub id: String,177    pub kind: String,178    pub title: String,179    pub art: String,180    pub arts: Vec<String>,181    pub released: String,182    pub slug: String,183    pub tagline: String,184    pub plot: String,185    /// The item's running time in seconds, and 0 where the catalog holds186    /// none. A film's card words it the way the film's own page does.187    pub duration: i64,188}189190/// One entry of one franchise's order: a film, or one run of a series.191/// `position` is its place in story order, first to last. `kind` is movie or192/// series, as the file wrote it. `alias` is the provider alias the member's193/// own library writes. `title` is the file's own name for the member, drawn194/// only where no library holds it. `released` is the file's release date195/// at year, month, or day precision, empty where it gives none; the196/// standing of a gap reads it. `release_year` is derived from it, for197/// the year beside a title.198#[derive(Debug, Clone, Default, PartialEq)]199pub struct Entry {200    pub position: i64,201    pub kind: String,202    pub alias: String,203    pub title: String,204    pub released: String,205    pub release_year: i64,206    pub timed: bool,207    pub from: f64,208    pub to: f64,209    pub universes: Vec<String>,210    pub held: Option<Held>,211    pub episodes: i64,212}213214// The two kinds a franchise entry names.215pub const MOVIE: &str = "movie";216pub const SERIES: &str = "series";217218impl Entry {219    /// The name the page draws: the held item's own, and the file's title for220    /// a gap.221    pub fn name(&self) -> &str {222        match &self.held {223            Some(held) => &held.title,224            None => &self.title,225        }226    }227228    /// Whether some library holds the entry, the title is still to come, or229    /// nobody has it yet. A gap is coming where `today`, an ISO date, cut230    /// to the precision of the file's release date, sorts before that231    /// date: `2027` is coming through 2026, `2026-10` through September232    /// 2026, and `2026-09-20` through the 19th. ISO dates at one precision233    /// sort as text, so the comparison is one string compare after the234    /// cut. A gap with no date is missing.235    pub fn standing(&self, today: &str) -> Standing {236        if self.held.is_some() {237            return Standing::Held;238        }239        let known = self.released.len().min(today.len());240        match !self.released.is_empty() && today[..known] < self.released[..] {241            true => Standing::Coming,242            false => Standing::Missing,243        }244    }245}246247/// What the page says about an entry: the catalog holds it, the title is still248/// to come, or nobody has it yet. Held is the default, because it is the249/// ordinary case.250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]251pub enum Standing {252    #[default]253    Held,254    Coming,255    Missing,256}257258/// One franchise as its own page draws it: the header from the franchises row,259/// and every entry in story order, held or not. `universe` is the franchise's260/// own, the first column of the page. `calendar` is nothing where the file261/// names none, and the page then draws no time and no rail.262#[derive(Debug, Clone, Default, PartialEq)]263pub struct Franchise {264    pub library: String,265    pub id: String,266    pub title: String,267    pub art: String,268    pub universe: String,269    pub calendar: Option<Calendar>,270    pub eras: Vec<Era>,271    pub entries: Vec<Entry>,272}273274/// The held members of one franchise as the slots of a strip, in story order.275/// Each slot carries the library and the kind of the item that holds it,276/// because a franchise spans libraries and both kinds. The answer names the277/// franchise, which is the strip's heading.278pub fn answer(franchise: Option<Franchise>) -> Answer {279    let Some(franchise) = franchise else {280        return Answer::default();281    };282    Answer {283        name: franchise.title,284        slots: slots(&franchise.entries),285    }286}287288/// The held members of one order as the slots of a strip, in the order289/// they were given. A gap yields no slot, because a strip draws what a290/// person can play. The content rating is in neither read, so a slot291/// carries none.292pub fn slots(entries: &[Entry]) -> Vec<Slot> {293    entries294        .iter()295        .filter_map(|entry| entry.held.clone().map(slot))296        .collect()297}298299/// One held member as a slot: what a strip draws, and what a select on it300/// opens.301pub fn slot(held: Held) -> Slot {302    Slot::of(303        &held.library,304        &held.kind,305        Title {306            id: held.id,307            title: held.title,308            released: held.released,309            art: held.art,310            duration: held.duration,311            rating: String::new(),312            tagline: held.tagline,313        },314    )315}316317/// One franchise a title belongs to, with the members some library holds, in318/// story order. The strip on the title's page draws it, so a person plays what319/// the namespace has and the page behind the heading holds the rest. `movies`320/// and `series` count every entry of the order, held or not, and the strip's321/// heading carries that count, because the scope of the order is what tells322/// a franchise from a set.323#[derive(Debug, Clone, Default, PartialEq)]324pub struct Membership {325    pub library: String,326    pub id: String,327    pub title: String,328    pub movies: i64,329    pub series: i64,330    pub members: Vec<Entry>,331}332333#[cfg(test)]334mod tests {335    use super::*;336337    fn yavin() -> Calendar {338        Calendar {339            unit: "years".into(),340            zero: "the Battle of Yavin".into(),341            before: "BBY".into(),342            after: "ABY".into(),343        }344    }345346    fn plain() -> Calendar {347        Calendar {348            unit: "years".into(),349            ..Calendar::default()350        }351    }352353    fn outbreak() -> Calendar {354        Calendar {355            unit: "days".into(),356            zero: "The outbreak".into(),357            ..Calendar::default()358        }359    }360361    #[test]362    fn one_time_reads_as_the_number_and_its_mark() {363        assert_eq!(yavin().label(-32.0, -32.0), "32 BBY");364        assert_eq!(yavin().label(5.0, 5.0), "5 ABY");365        assert_eq!(yavin().label(0.0, 0.0), "0 ABY");366        assert_eq!(plain().label(2024.0, 2024.0), "2024");367    }368369    #[test]370    fn a_time_before_zero_drops_its_sign_where_a_mark_says_the_side() {371        assert_eq!(yavin().label(-32.0, -32.0), "32 BBY");372        assert_eq!(373            Calendar {374                after: String::new(),375                ..yavin()376            }377            .label(5.0, 5.0),378            "5"379        );380        assert_eq!(plain().label(-58.0, -58.0), "-58");381    }382383    #[test]384    fn a_span_reads_as_two_numbers_and_one_mark() {385        assert_eq!(yavin().label(-22.0, -20.0), "22 to 20 BBY");386        assert_eq!(plain().label(2002.0, 2005.0), "2002 to 2005");387    }388389    #[test]390    fn a_span_that_crosses_zero_carries_a_mark_at_each_end() {391        assert_eq!(yavin().label(-5.0, 5.0), "5 BBY to 5 ABY");392    }393394    #[test]395    fn a_calendar_of_days_with_no_marks_counts_days() {396        let cases = [397            ((1141.0, 1141.0), "Day 1141"),398            ((1141.0, 1142.0), "Day 1141 to 1142"),399            ((-3.0, -3.0), "Day -3"),400            ((0.0, 0.0), "Day 0"),401        ];402        for ((from, to), label) in cases {403            assert_eq!(outbreak().label(from, to), label, "{from} to {to}");404        }405    }406407    #[test]408    fn a_calendar_of_days_that_names_marks_leads_with_the_mark_and_not_the_day() {409        let dated = Calendar {410            unit: "days".into(),411            before: "BO".into(),412            after: "AO".into(),413            ..outbreak()414        };415        assert_eq!(dated.label(-3.0, -3.0), "3 BO");416        assert_eq!(dated.label(1141.0, 1141.0), "1141 AO");417    }418419    #[test]420    fn a_time_between_two_years_keeps_its_point() {421        assert_eq!(plain().label(2024.5, 2024.5), "2024.5");422        assert_eq!(yavin().label(-32.5, -32.5), "32.5 BBY");423    }424425    #[test]426    fn the_caption_names_the_unit_and_the_event_the_times_count_from() {427        let cases = [428            (yavin(), "Years from the Battle of Yavin"),429            (outbreak(), "Days from the outbreak"),430            (431                Calendar {432                    unit: "years".into(),433                    zero: "The Fall of the Twelve Colonies".into(),434                    ..Calendar::default()435                },436                "Years from the Fall of the Twelve Colonies",437            ),438            (439                Calendar {440                    zero: "Aegon's Conquest".into(),441                    ..plain()442                },443                "Years from Aegon's Conquest",444            ),445        ];446        for (calendar, caption) in cases {447            assert_eq!(calendar.caption(), caption, "{calendar:?}");448        }449    }450451    #[test]452    fn a_calendar_with_no_zero_carries_no_caption() {453        assert_eq!(plain().caption(), "");454        assert_eq!(Calendar::default().caption(), "");455    }456457    #[test]458    fn an_era_measures_and_nests_by_its_span() {459        let saga = Era {460            name: "The Saga".into(),461            from: -40.0,462            to: 40.0,463        };464        let phase = Era {465            name: "One Phase".into(),466            from: -5.0,467            to: 5.0,468        };469        assert_eq!(saga.width(), 80.0);470        assert!(saga.holds(&phase));471        assert!(!phase.holds(&saga));472    }473474    #[test]475    fn an_era_meets_the_spans_that_touch_it() {476        let era = Era {477            name: "An Era".into(),478            from: -5.0,479            to: 5.0,480        };481        assert!(era.meets(0.0, 0.0));482        assert!(era.meets(-40.0, -5.0));483        assert!(era.meets(5.0, 40.0));484        assert!(!era.meets(6.0, 40.0));485        assert!(!era.meets(-40.0, -6.0));486    }487488    fn film(title: &str) -> Entry {489        Entry {490            position: 1,491            kind: MOVIE.into(),492            alias: "movie:tmdb:1893".into(),493            title: title.into(),494            ..Entry::default()495        }496    }497498    #[test]499    fn a_held_entry_draws_the_librarys_own_title_and_a_gap_the_files() {500        let entry = Entry {501            held: Some(Held {502                title: "The Held Title".into(),503                ..Held::default()504            }),505            ..film("The File's Title")506        };507        assert_eq!(entry.name(), "The Held Title");508        assert_eq!(entry.standing("2026-09-04"), Standing::Held);509        assert_eq!(film("A Film").name(), "A Film");510    }511512    #[test]513    fn a_gap_is_coming_while_today_is_before_its_release_at_the_precision_given() {514        let released = |date: &str| Entry {515            released: date.into(),516            ..film("A Film")517        };518        let cases = [519            ("2031", "2026-09-04", Standing::Coming),520            ("2027", "2026-12-31", Standing::Coming),521            ("2026", "2026-09-04", Standing::Missing),522            ("1979", "2026-09-04", Standing::Missing),523            ("2026-10", "2026-09-04", Standing::Coming),524            ("2026-09", "2026-09-04", Standing::Missing),525            ("2026-09-20", "2026-09-04", Standing::Coming),526            ("2026-09-20", "2026-09-19", Standing::Coming),527            ("2026-09-20", "2026-09-20", Standing::Missing),528            ("2026-09-04", "2026-09-05", Standing::Missing),529        ];530        for (date, today, standing) in cases {531            assert_eq!(532                released(date).standing(today),533                standing,534                "{date} on {today}"535            );536        }537    }538539    #[test]540    fn a_release_date_of_any_bytes_never_splits_the_day_it_is_held_against() {541        let cases = ["2027", "27", "2027-03", "soon™", "é"];542        for date in cases {543            let entry = Entry {544                released: date.into(),545                ..film("A Film")546            };547            let _ = entry.standing("2026-09-04");548        }549    }550551    #[test]552    fn a_held_entry_stands_held_whatever_its_release() {553        let entry = Entry {554            released: "2099-01-01".into(),555            held: Some(Held::default()),556            ..film("A Film")557        };558        assert_eq!(entry.standing("2026-09-04"), Standing::Held);559    }560561    #[test]562    fn a_gap_the_file_gives_no_release_date_is_missing() {563        assert_eq!(film("A Film").release_year, 0);564        assert_eq!(film("A Film").released, "");565        assert_eq!(film("A Film").standing("2026-09-04"), Standing::Missing);566    }567}
src/catalog/pool.rs 100.0%
1// The pool: every candidate strip the day may draw, as the query that2// reads it, the name a heading draws, and the weight the draw favors it3// by. A genre weighs the titles that carry it, with the ones that lead4// with it counted twice. A person weighs their works. A set weighs its5// members.67use super::Query;89/// The three kinds a candidate can be, so the draw takes no two of one10/// kind until every kind has been drawn once.11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]12pub enum Kind {13    Genre,14    Person,15    Set,16}1718/// One candidate of the pool: the query, the name its strip is headed19/// by, and its weight.20#[derive(Debug, Clone, PartialEq, Eq)]21pub struct Candidate {22    pub query: Query,23    pub name: String,24    pub weight: u64,25}2627impl Candidate {28    /// The candidate's kind. It comes off the query because only three29    /// query shapes enter the pool, and the shape says which.30    pub fn kind(&self) -> Kind {31        match self.query {32            Query::Person { .. } => Kind::Person,33            Query::Set { .. } => Kind::Set,34            _ => Kind::Genre,35        }36    }37}3839#[cfg(test)]40mod tests {41    use super::*;42    use crate::catalog::{GenreSort, Order};4344    #[test]45    fn a_candidates_kind_is_its_querys_shape() {46        let genre = Candidate {47            query: Query::Genre {48                name: "Western".into(),49                order: Order::Released,50                sort: GenreSort::default(),51            },52            name: "Western".into(),53            weight: 5,54        };55        assert_eq!(genre.kind(), Kind::Genre);56        let person = Candidate {57            query: Query::Person {58                library: "screening/features".into(),59                path: ".contributors/A Player".into(),60            },61            name: "A Player".into(),62            weight: 4,63        };64        assert_eq!(person.kind(), Kind::Person);65        let set = Candidate {66            query: Query::Set {67                library: "screening/features".into(),68                id: "set:1".into(),69            },70            name: "The Cycle".into(),71            weight: 3,72        };73        assert_eq!(set.kind(), Kind::Set);74    }75}
src/catalog/query.rs 100.0%
1// Every screen of titles is one Query and one wall. A Query is a value2// of a closed set of shapes. Each shape has a heading a person reads, an3// order, and a read the source answers fast: from an index in SQL, or4// for `Search`, from the in-memory index. None is a string of SQL: a5// string cannot be named in a heading and cannot promise a fast read.6// This module holds the Query, the Slot a read answers with, and the7// Answer that names what the query is about.89use super::{Title, library_name};1011/// How a recency query treats episodes. An episode is the only new12/// thing about a series, and without a fold a season drop is ten slots13/// that hide everything else. `Titles` folds every episode to its series.14/// `Episodes` folds none. `Airing` keeps an episode released within the15/// window of its arrival and folds the rest.16#[derive(Debug, Clone, Copy, PartialEq, Eq)]17pub enum Fold {18    Titles,19    Episodes,20    Airing,21    /// Every episode of a series folds to one slot, drawn as the still of22    /// the newest of them, which counts how many of them are current on23    /// `today`, in seconds.24    Shows {25        today: i64,26    },27}2829/// The column a read orders by: the release date or the arrival. It is30/// a closed pair and not a column name, because the sidecar formats it31/// into SQL, and the genre read and the recency read share it.32#[derive(Debug, Clone, Copy, PartialEq, Eq)]33pub enum Order {34    Released,35    Added,36}3738/// The three orders a library wall can be read in, which the rail's39/// button cycles. `Newest` and `Oldest` order by the release date and40/// `Title` by the sort key, "The Matrix" under M. Title is a library41/// wall's default, because a whole library is what a person walks by42/// name.43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]44pub enum Sort {45    Newest,46    Oldest,47    #[default]48    Title,49}5051impl Sort {52    /// The word the rail's sort button shows.53    pub fn word(&self) -> &'static str {54        match self {55            Self::Newest => "Newest",56            Self::Oldest => "Oldest",57            Self::Title => "Title",58        }59    }6061    /// The next order in the cycle. The three orders are one ring, so a62    /// fourth press is back at the first.63    pub fn next(&self) -> Self {64        match self {65            Self::Title => Self::Newest,66            Self::Newest => Self::Oldest,67            Self::Oldest => Self::Title,68        }69    }70}7172/// The four orders a genre wall can be read in. `Leads` is the titles73/// that lead with the genre first, then the rest, each run newest74/// first; it is a genre wall's default and its own type, so a library75/// wall cannot be asked for it. The other three are `Sort`.76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]77pub enum GenreSort {78    #[default]79    Leads,80    By(Sort),81}8283impl GenreSort {84    /// The word the rail's sort button shows.85    pub fn word(&self) -> &'static str {86        match self {87            Self::Leads => "Genre",88            Self::By(sort) => sort.word(),89        }90    }9192    /// The next order in the cycle. The four orders are one ring back to93    /// `Leads`.94    pub fn next(&self) -> Self {95        match self {96            Self::Leads => Self::By(Sort::Newest),97            Self::By(Sort::Newest) => Self::By(Sort::Oldest),98            Self::By(Sort::Oldest) => Self::By(Sort::Title),99            Self::By(Sort::Title) => Self::Leads,100        }101    }102}103104/// The queries a wall can be fed. The set is closed and grows by one105/// variant per plan. `Library` is one library in sort order. `Person` is106/// every work of one person across the libraries. `Set` is the members of107/// one set in release order. `Released` is movies and episodes newest108/// release first, and `Added` is the same newest arrival first, both109/// across every library and both folded by their `Fold`. `Genre` is every110/// movie and series across every library that carries the genre, in its111/// `GenreSort`; in `Leads`, the titles that lead with it come first and112/// the rest follow, each run newest by the order's column.113/// `Franchise` is the members of one franchise that some library holds,114/// in story order; it names the `Library` of kind franchises that holds115/// the order, and never a member's own library.116/// `Search` is the text a person typed, answered from the in-memory117/// search index and never from SQL, best hit first.118/// `Library` and `Genre` carry the order their rail's button cycles.119/// The recency queries are newest first by definition and carry none.120#[derive(Debug, Clone, PartialEq, Eq)]121pub enum Query {122    Library {123        library: String,124        sort: Sort,125    },126    Person {127        library: String,128        path: String,129    },130    Set {131        library: String,132        id: String,133    },134    Released {135        fold: Fold,136    },137    Added {138        fold: Fold,139    },140    Genre {141        name: String,142        order: Order,143        sort: GenreSort,144    },145    Franchise {146        library: String,147        id: String,148    },149    Search {150        text: String,151    },152}153154impl Query {155    /// The heading without the count, which is what a strip draws over its156    /// slots. A person's, a set's, and a franchise's name comes with the157    /// answer, because only the catalog holds it.158    /// A search is named by the text a person typed, which the query159    /// holds, so no answer carries a name for it.160    pub fn name(&self, name: &str) -> String {161        match self {162            Self::Library { library, .. } => library_name(library).to_string(),163            Self::Person { .. } | Self::Set { .. } | Self::Franchise { .. } => name.to_string(),164            Self::Released { .. } => "Recently released".to_string(),165            Self::Added { .. } => "Recently added".to_string(),166            Self::Genre { name, .. } => name.clone(),167            Self::Search { text } => text.clone(),168        }169    }170171    /// The heading the band draws over this query's slots. A library's172    /// heading and a recency query's carry the count. A person's, a set's,173    /// and a franchise's carry the name alone.174    /// A genre's heading carries the counts by kind, "Science Fiction ·175    /// 429 movies, 70 series", which is what the head over the wall176    /// carried before the band took it.177    pub fn heading(&self, name: &str, counts: Counts) -> String {178        match self {179            Self::Person { .. } | Self::Set { .. } | Self::Franchise { .. } => name.to_string(),180            Self::Genre { .. } => format!("{} · {}", self.name(name), counts.words()),181            _ => format!("{} · {}", self.name(name), counts.items),182        }183    }184185    /// The same query in the next order its wall cycles to. A query with186    /// no button is unchanged.187    pub fn resorted(&self) -> Self {188        match self.clone() {189            Self::Library { library, sort } => Self::Library {190                library,191                sort: sort.next(),192            },193            Self::Genre { name, order, sort } => Self::Genre {194                name,195                order,196                sort: sort.next(),197            },198            query => query,199        }200    }201202    /// The word the sort button shows, or nothing on a wall that draws no203    /// button: the recency walls, a person, a set, a franchise, and a204    /// search.205    pub fn sort_word(&self) -> Option<&'static str> {206        match self {207            Self::Library { sort, .. } => Some(sort.word()),208            Self::Genre { sort, .. } => Some(sort.word()),209            _ => None,210        }211    }212213    /// The query a "see all" slot opens. A recency query opens itself with214    /// every episode folded to its series, so the wall stays all posters at215    /// one ratio and no wall ever holds a still. Every other query opens216    /// itself.217    pub fn all_titles(&self) -> Self {218        match self.clone() {219            Self::Released { .. } => Self::Released { fold: Fold::Titles },220            Self::Added { .. } => Self::Added { fold: Fold::Titles },221            query => query,222        }223    }224}225226/// What a heading counts: every item, and the split by kind a genre's227/// heading reads.228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]229pub struct Counts {230    pub items: usize,231    pub movies: usize,232    pub series: usize,233}234235impl Counts {236    /// The counts over the kind word of every item a wall holds.237    pub fn of<'a>(kinds: impl IntoIterator<Item = &'a str>) -> Self {238        let mut counts = Self::default();239        for kind in kinds {240            counts.items += 1;241            match kind {242                "movies" => counts.movies += 1,243                "series" => counts.series += 1,244                _ => {}245            }246        }247        counts248    }249250    // The counts by kind as one phrase. A kind with no items is left251    // out, and a wall of neither kind reads as its item count.252    fn words(&self) -> String {253        let mut words = Vec::new();254        if self.movies > 0 {255            words.push(format!("{} {}", self.movies, noun(self.movies, "movie")));256        }257        if self.series > 0 {258            words.push(format!("{} series", self.series));259        }260        match words.is_empty() {261            true => self.items.to_string(),262            false => words.join(", "),263        }264    }265}266267// The noun a count takes, singular at one.268fn noun(count: usize, singular: &str) -> String {269    match count {270        1 => singular.to_string(),271        _ => format!("{singular}s"),272    }273}274275/// What an episode slot carries beyond a title: the series it is in,276/// that series' name for the caption, and the aired numbers a select277/// opens the series page on. A slot that holds one draws as a still at278/// 16:9, and every other slot draws as a poster.279#[derive(Debug, Clone, Default, PartialEq, Eq)]280pub struct InSeries {281    pub series: String,282    pub name: String,283    pub season: i64,284    pub episode: i64,285}286287/// One title as a read answers it. Every slot carries its own library288/// and kind, because no wall fixes up front what a select opens, and a289/// person's works span libraries. `parts` is empty on every read but a290/// person's.291/// queries answer with under the `Episodes` and `Airing` folds.292#[derive(Debug, Clone, Default, PartialEq, Eq)]293pub struct Slot {294    pub library: String,295    pub kind: String,296    pub id: String,297    pub title: String,298    pub released: String,299    pub art: String,300    pub duration: i64,301    pub rating: String,302    /// The tagline the sidecar wrote, empty where the read carried none.303    /// A film's card leads with it.304    pub tagline: String,305    pub parts: String,306    pub episode: Option<InSeries>,307    /// How many of the episodes a folded show holds are current, and308    /// zero on every other slot.309    pub new: usize,310    /// How many seasons a series slot's episodes fall into. Zero on every311    /// other kind, and where the read carried none.312    pub seasons: i64,313}314315impl Slot {316    /// One title row as a slot of the library and kind that hold it, with317    /// no parts.318    pub fn of(library: &str, kind: &str, title: Title) -> Self {319        Self {320            library: library.to_string(),321            kind: kind.to_string(),322            id: title.id,323            title: title.title,324            released: title.released,325            art: title.art,326            duration: title.duration,327            rating: title.rating,328            tagline: title.tagline,329            parts: String::new(),330            episode: None,331            new: 0,332            seasons: 0,333        }334    }335336    /// Whether the slot's art is a still at 16:9 and not a poster at 2:3.337    /// Only an episode's is.338    pub fn still(&self) -> bool {339        self.episode.is_some()340    }341342    /// Whether the slot is a whole show folded into one still, and not343    /// one episode of it: its id is then its series' id.344    pub fn folded(&self) -> bool {345        self.episode346            .as_ref()347            .is_some_and(|place| place.series == self.id)348    }349}350351/// What a source answers a query with. The name is what the query is352/// about, and a person's or a set's heading is that name. An empty name353/// means the query named nothing the catalog holds.354#[derive(Debug, Clone, Default, PartialEq, Eq)]355pub struct Answer {356    pub name: String,357    pub slots: Vec<Slot>,358}359360#[cfg(test)]361mod tests;
src/catalog/recency.rs 100.0%
1// The fold behind the two recency queries. The sidecar and the sample2// answer the same query, so one rule decides what a strip shows. This3// module holds the constants the recency queries are bounded by, the4// candidate a read answers with before the fold, and the fold that turns5// candidates into slots.67use super::{InSeries, Slot, Title};8use crate::catalog::Fold;910/// The window, in days, inside which an episode's release and its11/// arrival count as airing. The number is a guess to live with, beside12/// the other numbers here.13pub const WINDOW_DAYS: i64 = 14;1415/// The window, in days, inside which a release date counts as current.16/// The released strip shows what is new in the world and nothing older17/// than this, and the wall behind it shows everything.18pub const CURRENT_DAYS: i64 = 30;1920/// How many candidate rows a read takes, newest first, before the fold.21/// The bound keeps the read small on a catalog of thousands.22pub const CANDIDATES: usize = 120;2324/// How many slots a strip shows of what the fold answered. The wall25/// behind "see all" shows the rest.26pub const SHOWN: usize = 24;2728/// How many folded slots a recency read collects before it stops29/// paging: twice what a strip shows, so the added strip still fills30/// after it drops what the released strip shows. A season drop of a31/// hundred episodes is one slot, so a read that counted rows would32/// stop far short of a strip.33pub const FILL: usize = SHOWN * 2;3435/// The most pages of `CANDIDATES` rows a recency read walks before it36/// stops, whatever the fold made of them. It bounds the read on a37/// catalog whose newest arrivals are all one series.38pub const PAGES: usize = 8;3940/// A person enters the pool with more works than this. A person41/// credited in one or two titles makes a strip of one or two slots.42pub const WORKS_FLOOR: u64 = 3;4344/// How many candidates the day draws from the pool. Four is enough for45/// one of each of the three kinds and one more, and a guess to live with46/// beside the other numbers here.47pub const DRAWN: usize = 4;4849const DAY: i64 = 86_400;5051/// One row a recency read answers with before the fold: a movie as its52/// slot, or an episode with its series row read beside it, because a53/// folded episode becomes a slot for the series with the series'54/// poster. The read resolves the episode's own art before the fold, so55/// an episode the catalog holds no still for already carries the art of56/// its series.57#[derive(Debug, Clone, PartialEq, Eq)]58pub enum Candidate {59    Movie {60        slot: Slot,61    },62    Episode {63        library: String,64        episode: Title,65        added: i64,66        season: i64,67        number: i64,68        series: Title,69    },70}7172/// The fold behind a recency strip. The read answers at most `PAGES`73/// pages in one vector, and the fold consumes the same `CANDIDATES`-row74/// prefixes as separate page reads. It stops when a page is short, the75/// prefix makes `FILL` slots, or it consumes `PAGES` pages.76pub fn filled(fold: Fold, candidates: Vec<Candidate>) -> Vec<Slot> {77    let mut read = Vec::new();78    for page in candidates.chunks(CANDIDATES).take(PAGES) {79        let short = page.len() < CANDIDATES;80        read.extend_from_slice(page);81        if short || self::fold(read.clone(), fold).len() >= FILL {82            break;83        }84    }85    self::fold(read, fold)86}8788/// The fold, over candidates in the query's order, newest first. A89/// movie is a slot. An episode that stands alone is a slot with its90/// still. Every other episode folds to its series, and a series appears91/// once, at the newest date among its folded episodes.92pub fn fold(candidates: Vec<Candidate>, fold: Fold) -> Vec<Slot> {93    // The Shows fold has a pass of its own, because it answers one slot94    // per series and not one per candidate that stands alone.95    if let Fold::Shows { today } = fold {96        return shows(candidates, today);97    }98    let mut slots: Vec<Slot> = Vec::new();99    for candidate in candidates {100        match candidate {101            Candidate::Movie { slot } => slots.push(slot),102            Candidate::Episode {103                library,104                episode,105                added,106                season,107                number,108                series,109            } => {110                if stands_alone(fold, &episode.released, added) {111                    slots.push(episode_slot(&library, episode, season, number, &series));112                    continue;113                }114                let folded = slots.iter().any(|slot| {115                    slot.kind == "series" && slot.library == library && slot.id == series.id116                });117                if !folded {118                    let mut slot = Slot::of(&library, "series", series);119                    slot.released = episode.released;120                    slots.push(slot);121                }122            }123        }124    }125    slots126}127128// The Shows fold: a movie is its own slot, and every episode of a series129// folds to one slot for the series, at the place the series first came130// in.131fn shows(candidates: Vec<Candidate>, today: i64) -> Vec<Slot> {132    let mut slots: Vec<Slot> = Vec::new();133    let mut shows: Vec<(usize, Show)> = Vec::new();134    for candidate in candidates {135        match candidate {136            Candidate::Movie { slot } => slots.push(slot),137            Candidate::Episode {138                library,139                episode,140                season,141                number,142                series,143                ..144            } => {145                let new = usize::from(current(&episode.released, today));146                match shows147                    .iter()148                    .position(|(_, show)| show.holds(&library, &series.id))149                {150                    Some(at) => shows[at].1.add(episode, season, number, new),151                    None => {152                        shows.push((153                            slots.len(),154                            Show {155                                library,156                                series,157                                episode,158                                season,159                                number,160                                new,161                            },162                        ));163                        slots.push(Slot::default());164                    }165                }166            }167        }168    }169    // Each show takes the place its first episode held, so the slots keep170    // the order the candidates came in.171    for (at, show) in shows {172        slots[at] = show.slot();173    }174    slots175}176177// The episodes of one series folded together: the series, the newest178// episode among them, and how many of them are current.179struct Show {180    library: String,181    series: Title,182    episode: Title,183    season: i64,184    number: i64,185    new: usize,186}187188impl Show {189    // Whether this is the show of that library and series.190    fn holds(&self, library: &str, series: &str) -> bool {191        self.library == library && self.series.id == series192    }193194    // One more episode folded in. It counts toward `new` where it is195    // current, and it takes the still where it is the newest.196    fn add(&mut self, episode: Title, season: i64, number: i64, new: usize) {197        self.new += new;198        if (&episode.released, season, number) <= (&self.episode.released, self.season, self.number)199        {200            return;201        }202        self.episode = episode;203        self.season = season;204        self.number = number;205    }206207    // The slot: the newest episode's still under the series' id, so a208    // select opens the series on that episode, as an episode slot does.209    fn slot(self) -> Slot {210        let mut slot = episode_slot(211            &self.library,212            self.episode,213            self.season,214            self.number,215            &self.series,216        );217        slot.id = self.series.id;218        slot.new = self.new;219        slot220    }221}222223// Whether an episode stands alone under this fold: never under224// `Titles`, always under `Episodes`, and under `Airing` when its release225// date and its arrival fall inside the window. An episode with no full226// date folds, because the gap cannot be measured.227fn stands_alone(fold: Fold, released: &str, added: i64) -> bool {228    match fold {229        Fold::Titles | Fold::Shows { .. } => false,230        Fold::Episodes => true,231        Fold::Airing => match date_seconds(released) {232            Some(aired) => (added - aired).abs() <= WINDOW_DAYS * DAY,233            None => false,234        },235    }236}237238fn episode_slot(library: &str, episode: Title, season: i64, number: i64, series: &Title) -> Slot {239    let mut slot = Slot::of(library, "episodes", episode);240    slot.episode = Some(InSeries {241        series: series.id.clone(),242        name: series.title.clone(),243        season,244        episode: number,245    });246    slot247}248249/// Whether a release date falls inside the window of today, in seconds.250/// The window is measured both ways, because a date a day ahead by zone251/// is still current. A title with no full date is never current.252pub fn current(released: &str, today: i64) -> bool {253    date_seconds(released).is_some_and(|aired| (today - aired).abs() <= CURRENT_DAYS * DAY)254}255256/// A `released` column as Unix seconds at midnight UTC, or nothing257/// where it holds less than a full date. The civil-to-days arithmetic is258/// Howard Hinnant's, so no date crate is pulled in for one259/// subtraction.260pub fn date_seconds(released: &str) -> Option<i64> {261    let mut parts = released.splitn(3, '-').map(|part| part.parse::<i64>().ok());262    let (year, month, day) = (parts.next()??, parts.next()??, parts.next()??);263    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {264        return None;265    }266    let year = if month <= 2 { year - 1 } else { year };267    let era = year.div_euclid(400);268    let year_of_era = year - era * 400;269    let month_index = (month + 9) % 12;270    let day_of_year = (153 * month_index + 2) / 5 + day - 1;271    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;272    let days = era * 146_097 + day_of_era - 719_468;273    Some(days * DAY)274}275276#[cfg(test)]277mod tests;
src/catalog/search.rs 100.0%
1// The in-memory index that answers the `Search` query. Corrosion's2// schema allows only tables and indexes, so no FTS5 table replicates,3// and a second database file next to the replica would be one more4// thing to be stale or torn on a one-gigabyte machine. The catalog is5// thousands of titles, so a folded copy of every searchable string fits6// in a few megabytes and a read over all of it takes milliseconds.7//8// The index is built whole from the replica and replaced whole. A9// partial update would be a second copy of the catalog's state with its10// own bugs, and a whole build is cheap enough to run after every quiet11// period on the updates feed.1213use std::mem::size_of;1415use crate::catalog::Slot;1617// The build: items and people go in one at a time, and `finish` lays18// them out as the flat arrays a read walks.19mod build;2021// The fold: what every string and every query text is reduced to22// before they are compared.23mod fold;2425// The rank: where a match landed and how it matched, as the byte a read26// orders hits by.27mod rank;2829pub use build::{Builder, Item, Person, Place};30pub use rank::{Kind, Where};3132/// The kind word a person's slot carries. The wall reads it to open the33/// person's page instead of a title's.34pub const PEOPLE: &str = "people";3536/// The file name of a person's headshot under their path. The index37/// holds a flag and derives the art path at the read, because holding38/// the path for every person cost about 5 MB at the scale test's size.39pub const HEADSHOT: &str = "headshot.jpg";4041/// One string in the text arena: its byte offset and its length. Every42/// string the index holds is a span, so a title costs eight bytes plus43/// its text and no allocation of its own.44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]45struct Span {46    at: u32,47    len: u32,48}4950// One title the index answers with: enough of a `Slot` to draw its51// card. The library and the kind word repeat across thousands of rows,52// so each is an index into a short list and costs two bytes.53#[derive(Debug, Clone, Copy)]54struct Held {55    id: Span,56    title: Span,57    released: Span,58    art: Span,59    rating: Span,60    tagline: Span,61    sort_key: Span,62    duration: i32,63    seasons: i32,64    library: u16,65    word: u16,66    kind: Kind,67}6869// One person the index answers with. A person has no release, runtime,70// or rating, so a `Held` for each of 33,000 contributors would waste71// most of its bytes. People are a second list after the titles, and one72// numbering runs over both.73#[derive(Debug, Clone, Copy)]74struct Someone {75    path: Span,76    name: Span,77    library: u16,78    headshot: bool,79}8081/// How large the index is. `entries` counts the strings folded in,82/// `items` the titles and people, `words` the vocabulary, and `bytes`83/// the memory the arrays hold. The stats line reports it.84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]85pub struct Size {86    pub entries: usize,87    pub items: usize,88    pub words: usize,89    pub bytes: usize,90}9192/// The index. Every string is a span into one text arena, and every93/// link from a word to an item is a `u32` place and a `u8` rank in two94/// parallel arrays. Flat arrays keep the index at a few bytes per link95/// and let a read walk them with no pointer chasing.96#[derive(Debug, Default)]97pub struct Index {98    text: String,99    words: Vec<Span>,100    // The offset where each word's links start in `places` and `ranks`.101    // It has one entry more than `words`, so the links of word `n` are102    // always `firsts[n]..firsts[n + 1]`.103    firsts: Vec<u32>,104    places: Vec<u32>,105    ranks: Vec<u8>,106    items: Vec<Held>,107    people: Vec<Someone>,108    libraries: Vec<String>,109    kinds: Vec<String>,110    folded: usize,111}112113impl Index {114    /// The ranked hits for this text. Every word of the text must match115    /// the same item, so "batman 1989" narrows instead of widening. Text116    /// that folds to no words answers nothing.117    pub fn find(&self, text: &str) -> Vec<Slot> {118        let asked = fold::words(text);119        if asked.is_empty() {120            return Vec::new();121        }122        let mut found = Found::new(self.count());123        for (turn, word) in asked.iter().enumerate() {124            self.walk(word, turn as u32, &mut found);125        }126        let mut hits: Vec<usize> = (0..self.count())127            .filter(|item| found.hits[*item] as usize == asked.len())128            .collect();129        for item in &hits {130            found.close(*item);131        }132        hits.sort_by(|one, other| self.order(&found, *one, *other));133        hits.into_iter().map(|item| self.slot(item)).collect()134    }135136    /// The size of the index, counted from the arrays' capacities.137    pub fn size(&self) -> Size {138        Size {139            entries: self.folded,140            items: self.count(),141            words: self.words.len(),142            bytes: self.text.capacity()143                + self.words.capacity() * size_of::<Span>()144                + self.firsts.capacity() * size_of::<u32>()145                + self.places.capacity() * size_of::<u32>()146                + self.ranks.capacity()147                + self.items.capacity() * size_of::<Held>()148                + self.people.capacity() * size_of::<Someone>()149                + self150                    .libraries151                    .iter()152                    .chain(&self.kinds)153                    .map(|name| name.capacity() + size_of::<String>())154                    .sum::<usize>(),155        }156    }157158    // Fold every match of one query word into the tally. The scan is159    // over the whole vocabulary because the inside-a-word rung has no160    // order that groups its matches. At 80,000 words the scan takes161    // about 10 ms in a debug build.162    fn walk(&self, asked: &str, turn: u32, found: &mut Found) {163        for (word, span) in self.words.iter().enumerate() {164            let Some(relation) = rank::relate(self.slice(*span), asked) else {165                continue;166            };167            let links = self.firsts[word] as usize..self.firsts[word + 1] as usize;168            for link in links {169                let item = self.places[link] as usize;170                let stored = self.ranks[link];171                found.mark(item, turn, rank::found(stored, relation), stored);172            }173        }174    }175176    // The order two hits stand in: the rank, then the kind, then the177    // newest release, then the sort key.178    fn order(&self, found: &Found, one: usize, other: usize) -> std::cmp::Ordering {179        found.best[one]180            .cmp(&found.best[other])181            .then_with(|| self.ties(found, one).cmp(&self.ties(found, other)))182            .then_with(|| self.released(other).cmp(self.released(one)))183            .then_with(|| self.sort_key(one).cmp(self.sort_key(other)))184    }185186    // The kind a hit ties by. An episode's strings are folded onto its187    // series, so the hit answers as the series. When the deciding match188    // came off an episode's string, the hit ties as an episode, which189    // keeps the plan's order of series before episodes.190    fn ties(&self, found: &Found, item: usize) -> Kind {191        if found.episode[item] {192            return Kind::Episode;193        }194        match self.items.get(item) {195            Some(held) => held.kind,196            None => Kind::Person,197        }198    }199200    // Titles and people share one numbering: a person's number is their201    // position in `people` plus the count of titles.202    fn count(&self) -> usize {203        self.items.len() + self.people.len()204    }205206    fn released(&self, item: usize) -> &str {207        match self.items.get(item) {208            Some(held) => self.slice(held.released),209            None => "",210        }211    }212213    fn sort_key(&self, item: usize) -> &str {214        match self.items.get(item) {215            Some(held) => self.slice(held.sort_key),216            None => self.slice(self.people[item - self.items.len()].name),217        }218    }219220    fn slice(&self, span: Span) -> &str {221        &self.text[span.at as usize..(span.at + span.len) as usize]222    }223224    // One title as the slot a wall draws.225    fn slot(&self, item: usize) -> Slot {226        let Some(held) = self.items.get(item) else {227            return self.someone(item - self.items.len());228        };229        Slot {230            library: self.libraries[held.library as usize].clone(),231            kind: self.kinds[held.word as usize].clone(),232            id: self.slice(held.id).to_string(),233            title: self.slice(held.title).to_string(),234            released: self.slice(held.released).to_string(),235            art: self.slice(held.art).to_string(),236            duration: held.duration as i64,237            rating: self.slice(held.rating).to_string(),238            tagline: self.slice(held.tagline).to_string(),239            parts: String::new(),240            episode: None,241            new: 0,242            seasons: held.seasons as i64,243        }244    }245246    // One person as the slot their page opens from. The id is the path247    // the person's page reads by.248    fn someone(&self, at: usize) -> Slot {249        let someone = self.people[at];250        let path = self.slice(someone.path);251        Slot {252            library: self.libraries[someone.library as usize].clone(),253            kind: PEOPLE.to_string(),254            art: match someone.headshot {255                true => format!("{path}/{HEADSHOT}"),256                false => String::new(),257            },258            id: path.to_string(),259            title: self.slice(someone.name).to_string(),260            ..Slot::default()261        }262    }263}264265// The tally one read builds over every item. An item's rank is the266// weakest of the ranks its query words reached. With the best instead,267// "serial 03" would rank "Serial 12" (whose episode is titled "Segment268// 03") level with "Serial 03", because both match "serial" on the title.269struct Found {270    best: Vec<u8>,271    episode: Vec<bool>,272    word: Vec<u8>,273    from: Vec<bool>,274    turn: Vec<u32>,275    hits: Vec<u32>,276}277278impl Found {279    fn new(items: usize) -> Self {280        Self {281            best: vec![0; items],282            episode: vec![false; items],283            word: vec![0; items],284            from: vec![false; items],285            turn: vec![u32::MAX; items],286            hits: vec![0; items],287        }288    }289290    // One match of one query word on one item. A word counts once per291    // item however many strings it lands in, so an item with a long292    // plot does not out-hit an item whose title is the word.293    fn mark(&mut self, item: usize, turn: u32, rank: u8, stored: u8) {294        let episode = rank::from_episode(stored);295        if self.turn[item] != turn {296            self.close(item);297            self.turn[item] = turn;298            self.hits[item] += 1;299            self.word[item] = rank;300            self.from[item] = episode;301            return;302        }303        if rank < self.word[item] {304            self.word[item] = rank;305            self.from[item] = episode;306        } else if rank == self.word[item] && !episode {307            self.from[item] = false;308        }309    }310311    // Fold the word just finished into the item's rank. The word with the312    // weakest rank decides, and whether that word landed on an episode's313    // string decides the kind the tie breaks by.314    fn close(&mut self, item: usize) {315        if self.word[item] > self.best[item] {316            self.best[item] = self.word[item];317            self.episode[item] = self.from[item];318        }319    }320}321322#[cfg(test)]323mod tests;
src/catalog/search/build.rs 100.0%
1// The build of an index. Items and people go in one at a time, each2// string folded into links from its words to the item. `finish` sorts3// the links and lays them out as the flat arrays a read walks. A build4// is always whole: the arrays are packed for reading, and inserting5// into them would cost more than building them again.67use std::collections::HashMap;89use super::fold;10use super::rank::{self, Kind, Position, Where};11use super::{Held, Index, Someone, Span};12use crate::catalog::Slot;1314// One link while the build runs is a `u64`: the word in the high bits,15// the item's place under it, and the stored rank in the low byte. A16// plain sort of these numbers groups links by word, then by item, with17// the best rank of each pair first, which is the order `finish` needs.18const ITEM_SHIFT: u32 = 8;19const WORD_SHIFT: u32 = 40;20const BELOW_WORD: u64 = (1 << WORD_SHIFT) - 1;2122// The high bit marks a person's place while the build runs, because23// people are numbered after the titles and the count of titles is not24// known until the last item is added. `sorted` takes the mark off.25const SOMEONE: u32 = 1 << 31;2627/// The place of one item in the index, as `add` answers it, so a caller28/// can fold more strings onto that item later.29#[derive(Debug, Clone, Copy, PartialEq, Eq)]30pub struct Place(pub(super) u32);3132/// One person the index can answer with. A person is found by name33/// alone, so nothing is folded onto them later and `person` answers no34/// place.35#[derive(Debug, Clone, Default)]36pub struct Person {37    pub library: String,38    pub path: String,39    pub name: String,40    pub headshot: bool,41}4243/// One item the index can answer with: the slot a wall draws, the sort44/// key ties break by, the kind ties order by, and every string a search45/// can land on with the rung each one is.46#[derive(Debug, Clone, Default)]47pub struct Item {48    pub slot: Slot,49    pub sort_key: String,50    pub kind: Kind,51    pub strings: Vec<(Where, String)>,52}5354/// The build of one index. An alias or an episode's string arrives after55/// its item, so `add` answers a `Place` and `fold` takes one.56#[derive(Debug, Default)]57pub struct Builder {58    text: String,59    held: HashMap<Box<str>, Span>,60    vocabulary: HashMap<Box<str>, u32>,61    links: Vec<u64>,62    items: Vec<Held>,63    people: Vec<Someone>,64    libraries: Vec<String>,65    kinds: Vec<String>,66    folded: usize,67    buffer: Vec<String>,68}6970impl Builder {71    /// A build with nothing in it.72    pub fn new() -> Self {73        Self::default()74    }7576    /// Add one item and answer its place.77    pub fn add(&mut self, item: Item) -> Place {78        let place = Place(self.items.len() as u32);79        for (rung, string) in &item.strings {80            self.fold(place, *rung, string);81        }82        let held = Held {83            library: named(&mut self.libraries, &item.slot.library),84            word: named(&mut self.kinds, &item.slot.kind),85            kind: item.kind,86            duration: item.slot.duration as i32,87            seasons: item.slot.seasons as i32,88            id: self.hold(&item.slot.id),89            title: self.hold(&item.slot.title),90            released: self.hold(&item.slot.released),91            art: self.hold(&item.slot.art),92            rating: self.hold(&item.slot.rating),93            tagline: self.hold(&item.slot.tagline),94            sort_key: self.hold(&item.sort_key),95        };96        self.items.push(held);97        place98    }99100    /// Add one person, found by their name alone.101    pub fn person(&mut self, person: Person) {102        let place = Place(SOMEONE | self.people.len() as u32);103        self.fold(place, Where::Person, &person.name);104        let someone = Someone {105            library: named(&mut self.libraries, &person.library),106            path: self.hold(&person.path),107            name: self.hold(&person.name),108            headshot: person.headshot,109        };110        self.people.push(someone);111    }112113    /// Fold one more string onto an item already added. The sidecar read114    /// streams aliases and episodes in their own passes after the titles,115    /// and both reach their item through the place `add` answered.116    pub fn fold(&mut self, place: Place, rung: Where, text: &str) {117        self.folded += 1;118        let mut words = std::mem::take(&mut self.buffer);119        words.clear();120        fold::fold(text, &mut words);121        let alone = words.len() == 1;122        for (at, word) in words.iter().enumerate() {123            let position = match (alone, at) {124                (true, _) => Position::Alone,125                (_, 0) => Position::First,126                _ => Position::Later,127            };128            let id = self.word(word);129            let rank = rank::stored(rung, position);130            self.links131                .push((id as u64) << WORD_SHIFT | (place.0 as u64) << ITEM_SHIFT | rank as u64);132        }133        self.buffer = words;134    }135136    /// The index these items make.137    pub fn finish(mut self) -> Index {138        let words = self.sorted();139        self.links.sort_unstable();140        // One link per word and item, at the best rank. The rank is the141        // low byte, so the sort put the best link of each pair first and142        // dedup keeps that one.143        self.links.dedup_by_key(|link| *link >> ITEM_SHIFT);144145        let mut firsts = vec![0u32; words.len() + 1];146        let mut places = Vec::with_capacity(self.links.len());147        let mut ranks = Vec::with_capacity(self.links.len());148        for link in &self.links {149            firsts[(link >> WORD_SHIFT) as usize + 1] += 1;150            places.push(((link & BELOW_WORD) >> ITEM_SHIFT) as u32);151            ranks.push((link & 0xFF) as u8);152        }153        for at in 1..firsts.len() {154            firsts[at] += firsts[at - 1];155        }156157        let mut text = self.text;158        text.shrink_to_fit();159        let mut items = self.items;160        items.shrink_to_fit();161        let mut people = self.people;162        people.shrink_to_fit();163        Index {164            text,165            words,166            firsts,167            places,168            ranks,169            items,170            people,171            libraries: self.libraries,172            kinds: self.kinds,173            folded: self.folded,174        }175    }176177    // The vocabulary in word order, appended to the arena. Words were178    // numbered in arrival order during the build, so every link is179    // remapped to the word's sorted number here.180    fn sorted(&mut self) -> Vec<Span> {181        let mut order: Vec<(Box<str>, u32)> =182            std::mem::take(&mut self.vocabulary).into_iter().collect();183        order.sort_unstable_by(|one, other| one.0.cmp(&other.0));184        let mut moved = vec![0u32; order.len()];185        let mut words = Vec::with_capacity(order.len());186        for (fresh, (word, was)) in order.iter().enumerate() {187            moved[*was as usize] = fresh as u32;188            words.push(self.append(word));189        }190        // The count of titles is final now, so a marked place becomes191        // its number after the titles.192        let titles = self.items.len() as u32;193        for link in &mut self.links {194            let word = moved[(*link >> WORD_SHIFT) as usize] as u64;195            let mut place = ((*link & BELOW_WORD) >> ITEM_SHIFT) as u32;196            if place & SOMEONE == SOMEONE {197                place = titles + (place & !SOMEONE);198            }199            *link = word << WORD_SHIFT | (place as u64) << ITEM_SHIFT | (*link & 0xFF);200        }201        words202    }203204    // One string held once in the arena. Release dates, ratings, and205    // art paths repeat across many rows, so the second and later copies206    // cost only a span.207    fn hold(&mut self, text: &str) -> Span {208        if text.is_empty() {209            return Span::default();210        }211        if let Some(span) = self.held.get(text) {212            return *span;213        }214        let span = self.append(text);215        self.held.insert(text.into(), span);216        span217    }218219    // One string appended to the arena.220    fn append(&mut self, text: &str) -> Span {221        let span = Span {222            at: self.text.len() as u32,223            len: text.len() as u32,224        };225        self.text.push_str(text);226        span227    }228229    // The number of one folded word while the build runs, in arrival230    // order.231    fn word(&mut self, word: &str) -> u32 {232        if let Some(id) = self.vocabulary.get(word) {233            return *id;234        }235        let id = self.vocabulary.len() as u32;236        self.vocabulary.insert(word.into(), id);237        id238    }239}240241// The index of one name in a short list, added if absent. A library name242// or a kind word repeats on every item, so an item carries two bytes and243// the list holds the string once.244fn named(list: &mut Vec<String>, name: &str) -> u16 {245    match list.iter().position(|held| held == name) {246        Some(at) => at as u16,247        None => {248            list.push(name.to_string());249            (list.len() - 1) as u16250        }251    }252}
src/catalog/search/fold.rs 100.0%
1// The fold every searchable string and every query text goes through:2// lowercase, diacritics removed, split on every character that is not a3// letter or a digit. Both sides go through this one function, because4// a query folded one way against strings folded another way misses5// exactly the matches a person expects.67use unicode_normalization::UnicodeNormalization;8use unicode_normalization::char::is_combining_mark;910/// The words a string folds to. Text with no letters or digits folds to11/// no words.12pub fn words(text: &str) -> Vec<String> {13    let mut found = Vec::new();14    fold(text, &mut found);15    found16}1718/// The same fold, appended to a buffer the caller owns. A build folds19/// hundreds of thousands of strings, and reusing one buffer saves an20/// allocation per string.21pub fn fold(text: &str, found: &mut Vec<String>) {22    let mut word = String::new();23    // NFD splits "é" into "e" plus a combining accent, so dropping the24    // combining marks leaves the base letter. "ß" has no decomposition25    // and stays as it is.26    for letter in text.nfd() {27        if is_combining_mark(letter) {28            continue;29        }30        if letter.is_alphanumeric() {31            word.extend(letter.to_lowercase());32        } else if !word.is_empty() {33            found.push(std::mem::take(&mut word));34        }35    }36    if !word.is_empty() {37        found.push(word);38    }39}4041#[cfg(test)]42mod tests {43    use super::*;4445    #[test]46    fn a_string_folds_to_lowercase_words_split_on_everything_else() {47        assert_eq!(words("The Matrix"), ["the", "matrix"]);48        assert_eq!(49            words("Spider-Man: No Way Home"),50            ["spider", "man", "no", "way", "home"]51        );52        assert_eq!(words("2001"), ["2001"]);53        assert_eq!(words("  "), Vec::<String>::new());54        assert_eq!(words(""), Vec::<String>::new());55    }5657    #[test]58    fn a_diacritic_folds_away() {59        assert_eq!(words("Amélie"), ["amelie"]);60        assert_eq!(words("Ñuñez"), ["nunez"]);61        assert_eq!(words("Straße"), ["straße"]);62    }6364    #[test]65    fn a_fold_appends_to_the_buffer_it_is_given() {66        let mut found = vec!["kept".to_string()];67        fold("A Second", &mut found);68        assert_eq!(found, ["kept", "a", "second"]);69    }70}
src/catalog/search/rank.rs 100.0%
1// The two halves a hit is ranked by: where the match landed (a title,2// an alias, a person's name, an episode's title, a plot) and how it3// matched (the whole string, a prefix of the first word, a prefix of a4// later word, a substring inside a word). Where orders first, so the5// worst title match beats the best plot match.6//7// Two bytes carry this. The stored byte, one per link, says where the8// string came from and where the word stood in it, and is known at the9// build. The compared byte is computed at the read, once the query word10// and the indexed word are compared, so the index never stores a rank11// per possible query.1213/// Where a string came from, best first. `Plot` and `EpisodePlot` share14/// one rung: a plot is a plot for the rank, and the split only marks15/// whether the string was an episode's, which decides the tie kind.16#[derive(Debug, Clone, Copy, PartialEq, Eq)]17pub enum Where {18    Title,19    Alias,20    Person,21    EpisodeTitle,22    Plot,23    EpisodePlot,24}2526impl Where {27    // The rung as a number, best first.28    fn rung(self) -> u8 {29        match self {30            Self::Title => 0,31            Self::Alias => 1,32            Self::Person => 2,33            Self::EpisodeTitle => 3,34            Self::Plot | Self::EpisodePlot => 4,35        }36    }3738    // Whether the string was an episode's. A hit whose deciding match39    // came off an episode ties as an episode, after series and movies.40    fn episode(self) -> bool {41        matches!(self, Self::EpisodeTitle | Self::EpisodePlot)42    }43}4445/// Where one word stands in the string it came from: the whole string,46/// the first word of several, or a later word.47#[derive(Debug, Clone, Copy, PartialEq, Eq)]48pub enum Position {49    Alone,50    First,51    Later,52}5354/// How a query word met an indexed word: equal, a prefix of it, or a55/// substring inside it.56#[derive(Debug, Clone, Copy, PartialEq, Eq)]57pub enum Relation {58    Equal,59    Prefix,60    Inner,61}6263/// The kind order ties break by, best first: movies and series, then64/// sets and franchises, then episodes, then people.65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]66pub enum Kind {67    #[default]68    Title,69    Collection,70    Episode,71    Person,72}7374/// The byte the index stores for one word of one string: the rung in75/// the high bits, the position under it, and the episode mark in the76/// lowest bit. A smaller byte is a better match, so the build's plain77/// sort of links puts the best match of a word on an item first.78pub fn stored(rung: Where, position: Position) -> u8 {79    rung.rung() << 3 | (position as u8) << 1 | u8::from(rung.episode())80}8182/// Whether the stored match came off an episode's string.83pub fn from_episode(stored: u8) -> bool {84    stored & 1 == 185}8687/// The rank a read compares hits by: the rung, then how the query word88/// met the indexed word. A prefix of a one-word string ranks as a89/// first-word match, because "bat" against "Batman" is the same kind90/// of hit as "bat" against "Batman Begins". The how depends on the91/// query, so it is computed here and never stored.92pub fn found(stored: u8, relation: Relation) -> u8 {93    let position = (stored >> 1) & 3;94    let how = match relation {95        Relation::Inner => 3,96        Relation::Equal if position == Position::Alone as u8 => 0,97        _ => position.max(Position::First as u8),98    };99    (stored >> 3) << 2 | how100}101102/// How a query word meets an indexed word, or nothing when it does not.103pub fn relate(word: &str, asked: &str) -> Option<Relation> {104    if word == asked {105        return Some(Relation::Equal);106    }107    if word.starts_with(asked) {108        return Some(Relation::Prefix);109    }110    if word.contains(asked) {111        return Some(Relation::Inner);112    }113    None114}115116#[cfg(test)]117mod tests {118    use super::*;119120    #[test]121    fn a_query_word_meets_an_indexed_word_four_ways() {122        assert_eq!(relate("batman", "batman"), Some(Relation::Equal));123        assert_eq!(relate("batman", "bat"), Some(Relation::Prefix));124        assert_eq!(relate("batman", "atma"), Some(Relation::Inner));125        assert_eq!(relate("batman", "robin"), None);126    }127128    // The rank of one match, as a read compares it.129    fn rank(rung: Where, position: Position, relation: Relation) -> u8 {130        found(stored(rung, position), relation)131    }132133    #[test]134    fn the_whole_string_beats_a_first_word_beats_a_later_word_beats_an_inside() {135        let whole = rank(Where::Title, Position::Alone, Relation::Equal);136        let first = rank(Where::Title, Position::First, Relation::Equal);137        let later = rank(Where::Title, Position::Later, Relation::Equal);138        let inside = rank(Where::Title, Position::First, Relation::Inner);139        assert!(whole < first);140        assert!(first < later);141        assert!(later < inside);142    }143144    #[test]145    fn a_prefix_of_a_string_of_one_word_is_a_first_word_match() {146        assert_eq!(147            rank(Where::Title, Position::Alone, Relation::Prefix),148            rank(Where::Title, Position::First, Relation::Equal)149        );150    }151152    #[test]153    fn where_beats_how() {154        let worst_title = rank(Where::Title, Position::Later, Relation::Inner);155        let best_alias = rank(Where::Alias, Position::Alone, Relation::Equal);156        assert!(worst_title < best_alias);157    }158159    #[test]160    fn the_five_rungs_rank_in_the_order_the_plan_names() {161        let rungs = [162            Where::Title,163            Where::Alias,164            Where::Person,165            Where::EpisodeTitle,166            Where::Plot,167        ];168        let ranks: Vec<u8> = rungs169            .iter()170            .map(|rung| rank(*rung, Position::Alone, Relation::Equal))171            .collect();172        assert!(ranks.windows(2).all(|pair| pair[0] < pair[1]));173        assert_eq!(174            rank(Where::EpisodePlot, Position::Alone, Relation::Equal),175            rank(Where::Plot, Position::Alone, Relation::Equal)176        );177    }178179    #[test]180    fn only_an_episodes_strings_are_marked_as_one() {181        assert!(from_episode(stored(Where::EpisodeTitle, Position::Alone)));182        assert!(from_episode(stored(Where::EpisodePlot, Position::First)));183        assert!(!from_episode(stored(Where::Plot, Position::First)));184        assert!(!from_episode(stored(Where::Title, Position::Alone)));185    }186187    #[test]188    fn the_better_of_two_matches_of_one_word_is_the_smaller_byte() {189        let title = stored(Where::Title, Position::Later);190        let plot = stored(Where::Plot, Position::Alone);191        assert!(title < plot);192        let alone = stored(Where::Title, Position::Alone);193        assert!(alone < title);194    }195}
src/catalog/sidecar.rs 99.0%
1// The Source over plan 06's delivery. Every read is a SQLite read of2// the sidecar's local file, and a background stream marks changes, so3// the views re-read the file and never ask a service.45use std::collections::HashMap;6use std::path::PathBuf;7use std::sync::Arc;8use std::sync::atomic::Ordering;9use std::time::Duration;1011use rusqlite::{Connection, OpenFlags, Row};1213use crate::catalog::franchise;14use crate::catalog::pool::Candidate;15use crate::catalog::recency::DRAWN;16use crate::catalog::{17    Answer, Credits, Episode, FileFacts, Franchise, FranchiseEntry, GenreEntry, LibraryEntry,18    Membership, MovieDetails, MovieSet, Order, Person, PlayItem, Query, Selection, SeriesDetails,19    Slot, Sort, Source, TILES, library_name, recency,20};21use crate::harness::Waker;2223mod details;24mod files;25mod franchises;26mod genres;27mod item;28mod people;29mod play;30mod pool;31mod recent;32mod search;33mod series;34mod updates;3536// The file opens read-only because only scanners write, through their37// agents. A write from here would bypass the agent's CRDT bookkeeping,38// and the row would never reach a peer.39pub struct SidecarSource {40    database: PathBuf,41    connection: Option<Connection>,42    shared: Arc<updates::Shared>,43    shelf: Arc<search::Shelf>,44    page_reads: Option<PageReads>,45    // Whether this source owns the update streams. The source the browser46    // holds owns them and stops them when it drops. The second source over47    // the same file reads alone and stops nothing.48    streams: bool,49}5051type PersonEntries = Arc<Vec<(String, String)>>;5253#[derive(Default)]54struct PageReads {55    people: HashMap<(String, String), PersonEntries>,56    kinds: Option<Arc<HashMap<String, String>>>,57}5859impl SidecarSource {60    // `database` is the sidecar's SQLite file, and `api` is the agent's61    // loopback HTTP base. One stream per item table follows updates62    // from construction on, so an event before the first read still63    // marks a re-read and nothing lands unseen.64    pub fn new(database: impl Into<PathBuf>, api: &str) -> Self {65        Self::quieting(database, api, search::QUIET)66    }6768    // The same source with the search index's quiet period given, so a69    // test can wait milliseconds for a build instead of two seconds.70    fn quieting(database: impl Into<PathBuf>, api: &str, quiet: Duration) -> Self {71        let shared = Arc::new(updates::Shared::default());72        for table in ["movies", "series", "episodes"] {73            updates::follow(shared.clone(), api.to_string(), table);74        }75        let database = database.into();76        let shelf = Arc::new(search::Shelf::default());77        search::follow(shelf.clone(), shared.clone(), database.clone(), quiet);78        Self {79            database,80            connection: None,81            shared,82            shelf,83            page_reads: None,84            streams: true,85        }86    }8788    // The connection opens on demand and drops on any failure, because89    // plan 06 lets the sidecar be absent for a moment. A missing or90    // half-born file reads as empty, and the next call retries. The91    // failure is logged, because a wall that draws nothing looks the92    // same whether the catalog is empty or the file is unreachable, and93    // the log is where the difference shows.94    fn read_result<T>(95        &mut self,96        run: impl FnOnce(&Connection) -> rusqlite::Result<T>,97    ) -> rusqlite::Result<T> {98        if self.connection.is_none() {99            let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;100            match Connection::open_with_flags(&self.database, flags) {101                Ok(connection) => self.connection = Some(connection),102                Err(error) => {103                    eprintln!(104                        "media-browser: cannot open the catalog {}: {error}",105                        self.database.display()106                    );107                    return Err(error);108                }109            }110        }111        let result = run(self.connection.as_ref().expect("the connection opened"));112        if result.is_err() {113            self.connection = None;114        }115        result116    }117118    fn read<T>(&mut self, run: impl FnOnce(&Connection) -> rusqlite::Result<Vec<T>>) -> Vec<T> {119        self.read_result(run).unwrap_or_default()120    }121122    fn person_entries(&mut self, library: &str, path: &str) -> Option<PersonEntries> {123        let key = (library.to_string(), path.to_string());124        if let Some(entries) = self125            .page_reads126            .as_ref()127            .and_then(|reads| reads.people.get(&key))128        {129            return Some(entries.clone());130        }131132        let entries = Arc::new(133            self.read_result(|connection| people::entries(connection, library, path))134                .ok()?,135        );136        if let Some(reads) = self.page_reads.as_mut()137            && reads.people.len() < DRAWN138        {139            reads.people.insert(key, entries.clone());140        }141        Some(entries)142    }143144    fn library_kinds(&mut self) -> Option<Arc<HashMap<String, String>>> {145        if let Some(kinds) = self146            .page_reads147            .as_ref()148            .and_then(|reads| reads.kinds.as_ref())149        {150            return Some(kinds.clone());151        }152153        let kinds = Arc::new(self.read_result(people::kinds).ok()?);154        if let Some(reads) = self.page_reads.as_mut() {155            reads.kinds = Some(kinds.clone());156        }157        Some(kinds)158    }159}160161impl Drop for SidecarSource {162    fn drop(&mut self) {163        if self.streams {164            self.shared.halt();165        }166    }167}168169// The kind names the item table. This closed match is the whole of170// what may reach the SQL text, so no caller-supplied string is ever171// formatted into a query.172fn item_table(kind: &str) -> Option<&'static str> {173    match kind {174        "movies" => Some("movies"),175        "series" => Some("series"),176        _ => None,177    }178}179180// One library's slots come off both item tables in one read. A library181// has one kind, so one half of the union is empty and the whole is that182// kind's rows, each stamped with its kind. The library binds once as183// `?1` for both halves, and the sort key is selected only to order the184// union.185// The sort formats into the outer ORDER BY. Title reads off the186// (library, sort_key) index and the two release orders off (library,187// released), so no order scans.188fn library_slots(189    connection: &Connection,190    library: &str,191    sort: Sort,192) -> rusqlite::Result<Vec<Slot>> {193    let sql = format!(194        "SELECT * FROM (\195           SELECT {columns}, {movies} AS seasons, 'movies' AS kind, sort_key \196           FROM movies WHERE library = ?1 \197           UNION ALL \198           SELECT {columns}, {series} AS seasons, 'series' AS kind, sort_key \199           FROM series WHERE library = ?1\200         ) ORDER BY {ordering}",201        columns = item::COLUMNS,202        movies = item::seasons("movies"),203        series = item::seasons("series"),204        ordering = ordering(sort),205    );206    collect(connection, &sql, &[&library], |row| {207        let kind: String = row.get(item::WIDTH + 1)?;208        Ok(Slot {209            seasons: row.get(item::WIDTH)?,210            ..Slot::of(library, &kind, item::title(row)?)211        })212    })213}214215// The ORDER BY one sort names. The closed match is all that can reach216// the SQL text, and the sort key breaks every tie so a read answers the217// same order every time.218fn ordering(sort: Sort) -> &'static str {219    match sort {220        Sort::Title => "sort_key",221        Sort::Newest => "released DESC, sort_key",222        Sort::Oldest => "released ASC, sort_key",223    }224}225226// The posters of one library's newest-added titles that have one, in the227// order the Added query reads them, and no more than a tile holds.228fn newest_art(connection: &Connection, library: &str, kind: &str) -> rusqlite::Result<Vec<String>> {229    let Some(table) = item_table(kind) else {230        return Ok(Vec::new());231    };232    let sql = format!(233        "SELECT art FROM {table} WHERE library = ?1 AND art != '' \234         ORDER BY added DESC, id LIMIT {TILES}"235    );236    collect(connection, &sql, &[&library], |row| row.get(0))237}238239fn collect<T>(240    connection: &Connection,241    sql: &str,242    params: &[&dyn rusqlite::ToSql],243    map: impl Fn(&Row<'_>) -> rusqlite::Result<T>,244) -> rusqlite::Result<Vec<T>> {245    let mut statement = connection.prepare(sql)?;246    let rows = statement.query_map(params, |row| map(row))?;247    rows.collect()248}249250impl Source for SidecarSource {251    fn begin_page_read(&mut self) {252        self.page_reads = Some(PageReads::default());253    }254255    fn end_page_read(&mut self) {256        self.page_reads = None;257    }258259    fn libraries(&mut self) -> Vec<LibraryEntry> {260        // A library has one kind, so the union yields one row per library, and261        // the outer ORDER BY gives the libraries strip its order.262        let sql = "SELECT library, kind, items FROM (\263                     SELECT library, 'movies' AS kind, COUNT(*) AS items \264                     FROM movies GROUP BY library \265                     UNION ALL \266                     SELECT library, 'series' AS kind, COUNT(*) AS items \267                     FROM series GROUP BY library\268                   ) ORDER BY library";269        let mut entries: Vec<LibraryEntry> = self.read(|connection| {270            collect(connection, sql, &[], |row| {271                Ok(LibraryEntry {272                    library: row.get(0)?,273                    kind: row.get(1)?,274                    items: row.get::<_, i64>(2)?.max(0) as u64,275                    art: Vec::new(),276                })277            })278        });279        // The art is one read per library, because the count is a grouped280        // read of the whole table and the art is the head of that library's281        // own (library, added) index.282        for entry in &mut entries {283            let library = entry.library.clone();284            let kind = entry.kind.clone();285            entry.art = self.read(|connection| newest_art(connection, &library, &kind));286        }287        entries288    }289290    fn genres(&mut self) -> Vec<GenreEntry> {291        let Some(kinds) = self.library_kinds() else {292            return Vec::new();293        };294        self.read(|connection| genres::entries(connection, &kinds))295    }296297    fn franchises(&mut self) -> Vec<FranchiseEntry> {298        self.read(franchises::all)299    }300301    fn wall(&mut self, query: &Query) -> Answer {302        match query {303            Query::Library { library, sort } => Answer {304                name: library_name(library).to_string(),305                slots: self.read(|connection| library_slots(connection, library, *sort)),306            },307            Query::Person { library, path } => {308                let name = self309                    .read(|connection| people::name(connection, library, path))310                    .into_iter()311                    .next()312                    .unwrap_or_default();313                let slots = if let Some(entries) = self.person_entries(library, path) {314                    let kinds = self.library_kinds();315                    kinds.map_or_else(Vec::new, |kinds| {316                        self.read(|connection| people::works(connection, &entries, &kinds))317                    })318                } else {319                    Vec::new()320                };321                Answer { name, slots }322            }323            Query::Set { library, id } => {324                let Some(set) = self.set(library, id) else {325                    return Answer::default();326                };327                Answer {328                    name: set.title,329                    slots: set330                        .members331                        .into_iter()332                        .map(|member| Slot::of(library, "movies", member))333                        .collect(),334                }335            }336            Query::Released { fold } => Answer {337                name: String::new(),338                slots: recency::filled(339                    *fold,340                    self.read(|connection| recent::candidates(connection, Order::Released)),341                ),342            },343            Query::Added { fold } => Answer {344                name: String::new(),345                slots: recency::filled(346                    *fold,347                    self.read(|connection| recent::candidates(connection, Order::Added)),348                ),349            },350            Query::Genre { name, order, sort } => {351                let kinds = self.library_kinds();352                Answer {353                    name: name.clone(),354                    slots: kinds.map_or_else(Vec::new, |kinds| {355                        self.read(|connection| {356                            genres::titles(connection, name, *order, *sort, &kinds)357                        })358                    }),359                }360            }361            Query::Franchise { library, id } => franchise::answer(self.franchise(library, id)),362            Query::Search { text } => Answer {363                name: String::new(),364                slots: self.shelf.find(text),365            },366        }367    }368369    fn index_size(&mut self) -> Option<crate::catalog::search::Size> {370        Some(self.shelf.held()?.size())371    }372373    fn pool(&mut self) -> Vec<Candidate> {374        self.read(pool::candidates)375    }376377    fn movie(&mut self, library: &str, id: &str) -> Option<MovieDetails> {378        self.read(|connection| details::movie(connection, library, id))379            .into_iter()380            .next()381    }382383    fn set(&mut self, library: &str, id: &str) -> Option<MovieSet> {384        self.read(|connection| details::set(connection, library, id))385            .into_iter()386            .next()387    }388389    fn series(&mut self, library: &str, id: &str) -> Option<SeriesDetails> {390        self.read(|connection| series::series(connection, library, id))391            .into_iter()392            .next()393    }394395    fn episodes(&mut self, library: &str, id: &str) -> Vec<Episode> {396        self.read(|connection| series::episodes(connection, library, id))397    }398399    fn play(&mut self, library: &str, selection: &Selection) -> Vec<PlayItem> {400        match selection {401            Selection::Movie { id } => self.read(|connection| play::movie(connection, library, id)),402            Selection::Trailer { id } => {403                self.read(|connection| play::trailer(connection, library, id))404            }405            Selection::Episode {406                series,407                season,408                episode,409            } => self410                .read(|connection| play::episodes(connection, library, series, *season, *episode)),411        }412    }413414    fn franchises_of(&mut self, library: &str, id: &str) -> Vec<Membership> {415        self.read(|connection| franchises::strips(connection, library, id))416    }417418    fn franchise(&mut self, library: &str, id: &str) -> Option<Franchise> {419        self.read(|connection| franchises::franchise(connection, library, id))420            .into_iter()421            .next()422    }423424    fn credits(&mut self, library: &str, id: &str) -> Credits {425        self.read(|connection| people::credits(connection, library, id))426            .into_iter()427            .next()428            .unwrap_or_default()429    }430431    fn files(&mut self, library: &str, item: &str) -> Vec<FileFacts> {432        self.read(|connection| files::files(connection, library, item))433    }434435    fn person(&mut self, library: &str, path: &str) -> Option<Person> {436        let person = self437            .read(|connection| people::local_person(connection, library, path))438            .into_iter()439            .next()?;440        if person.biography && person.headshot {441            return Some(person);442        }443        let entries = self.person_entries(library, path)?;444        self.read(|connection| people::person(connection, person, &entries))445            .into_iter()446            .next()447    }448449    fn changed(&mut self) -> bool {450        self.streams && self.shared.changed.swap(false, Ordering::AcqRel)451    }452453    fn wake_by(&mut self, wake: Waker) {454        if self.streams {455            *self.shared.wake.lock().unwrap() = Some(wake);456        }457    }458459    // The second source over the same file: its own read-only connection,460    // opened on its first read, and no claim on the streams.461    fn reader(&mut self) -> Option<Box<dyn Source + Send>> {462        Some(Box::new(Self {463            database: self.database.clone(),464            connection: None,465            shared: self.shared.clone(),466            shelf: self.shelf.clone(),467            page_reads: None,468            streams: false,469        }))470    }471}472473#[cfg(test)]474mod tests;
src/catalog/sidecar/details.rs 98.2%
1// The reads behind a movie's page. The item's own columns come off the2// movies row. The fields the sidecar wrote come out of the body column3// with SQLite's json_extract. The backdrop, the logo, and the trailer4// come off the files table through file_items, by role.56use rusqlite::Connection;78use super::collect;9use super::item::{self, COLUMNS};10use crate::catalog::{MovieDetails, MovieSet};1112/// One movie's details, as a list of one, or an empty list where the13/// library holds no movie under that id.14pub fn movie(15    connection: &Connection,16    library: &str,17    id: &str,18) -> rusqlite::Result<Vec<MovieDetails>> {19    let sql = "SELECT title, released, duration, set_id, \20                      json_extract(body, '$.contentRating'), \21                      json_extract(body, '$.tagline'), \22                      json_extract(body, '$.plot'), \23                      json_extract(body, '$.genres'), \24                      json_extract(body, '$.directors'), \25                      json_extract(body, '$.writers'), \26                      json_extract(body, '$.cast'), \27                      json_extract(body, '$.studios'), \28                      json_extract(body, '$.ratings') \29               FROM movies WHERE library = ? AND id = ?";30    let mut found = collect(connection, sql, &[&library, &id], |row| {31        Ok(MovieDetails {32            title: row.get(0)?,33            released: row.get(1)?,34            duration: row.get(2)?,35            set_id: row.get(3)?,36            rating: item::text(row, 4)?,37            tagline: item::text(row, 5)?,38            plot: item::text(row, 6)?,39            genres: item::strings(&item::text(row, 7)?),40            directors: item::strings(&item::text(row, 8)?),41            writers: item::strings(&item::text(row, 9)?),42            cast: item::credits(&item::text(row, 10)?),43            studios: item::strings(&item::text(row, 11)?),44            ratings: item::ratings(&item::text(row, 12)?),45            backdrop: String::new(),46            logo: String::new(),47            trailer: String::new(),48        })49    })?;5051    if let Some(details) = found.first_mut() {52        for (role, path) in item::art(connection, library, id)? {53            match role.as_str() {54                "backdrop" => details.backdrop = path,55                "logo" => details.logo = path,56                _ => details.trailer = path,57            }58        }59    }60    Ok(found)61}6263/// One set and its members in release order, as a list of one, or an64/// empty list where the library holds no set under that id.65pub fn set(connection: &Connection, library: &str, id: &str) -> rusqlite::Result<Vec<MovieSet>> {66    let named = collect(67        connection,68        "SELECT title FROM sets WHERE library = ? AND id = ?",69        &[&library, &id],70        |row| row.get::<_, String>(0),71    )?;72    let Some(title) = named.into_iter().next() else {73        return Ok(Vec::new());74    };7576    // The index selects only this set's members. SQLite sorts those rows77    // by release, without reading the rest of the library.78    let sql = format!(79        "SELECT {COLUMNS} FROM movies INDEXED BY movies_library_set_id \80         WHERE library = ? AND set_id = ? ORDER BY released, sort_key"81    );82    let members = collect(connection, &sql, &[&library, &id], item::title)?;83    Ok(vec![MovieSet { title, members }])84}
src/catalog/sidecar/files.rs 100.0%
1// The read behind the foot of a page: every file of one item, joined2// through the file items table, with the columns the foot draws.34use rusqlite::Connection;56use super::collect;7use crate::catalog::FileFacts;89/// Every file of one item, in path order, so a title with two encodings10/// draws its lines in the same order on every read.11pub fn files(12    connection: &Connection,13    library: &str,14    item: &str,15) -> rusqlite::Result<Vec<FileFacts>> {16    let sql = "SELECT files.role, files.type, files.container, files.video_codec, \17                      files.audio_codec, files.width, files.height, files.size_bytes, \18                      files.language \19               FROM files \20               JOIN file_items ON file_items.library = files.library \21               AND file_items.path = files.path \22               WHERE file_items.library = ? AND file_items.item = ? \23               ORDER BY files.path";24    collect(connection, sql, &[&library, &item], |row| {25        Ok(FileFacts {26            role: row.get(0)?,27            kind: row.get(1)?,28            container: row.get(2)?,29            video_codec: row.get(3)?,30            audio_codec: row.get(4)?,31            width: row.get(5)?,32            height: row.get(6)?,33            size_bytes: row.get(7)?,34            language: row.get(8)?,35        })36    })37}
src/catalog/sidecar/franchises.rs 100.0%
1// The two reads a media browser makes of a franchise, as catalog.sql writes2// them beside the tables. The strip read is the INNER JOIN: the whole order3// with only the members some library holds. The page read is the LEFT JOIN:4// every entry, held or not, with the episodes the catalog holds for a series5// run. Both resolve a member through the aliases table across every library of6// the namespace, and the first library by name wins where two hold one member.78use rusqlite::{Connection, Row};9use serde_json::Value;1011use super::collect;12use super::item;13use crate::catalog::FranchiseEntry;14use crate::catalog::franchise::{Calendar, Entry, Era, Franchise, Held, MOVIE, Membership, SERIES};1516// The two item tables as one list of members, which both reads join17// through. The kind column names the table a row came from, so a press18// on a slot opens the page of its own kind.19const ITEMS: &str = "SELECT library, id, title, art, arts, released, slug, body, duration, \20                            'movies' AS kind \21                     FROM movies \22                     UNION ALL \23                     SELECT library, id, title, art, arts, released, slug, body, duration, \24                            'series' AS kind \25                     FROM series";2627// The member columns both reads answer with, in the order [`entry`]28// takes them. The last of them is the held item, which is NULL for29// every gap of the page read.30const COLUMNS: &str = "m.position, m.kind, m.alias, m.title, m.release_year, m.timed, \31                       m.time_from, m.time_to, m.universes, MIN(i.library), i.id, i.title, \32                       i.art, i.arts, i.released, i.slug, i.kind, m.released, i.duration";3334/// Every franchise row of every library, in sort order. The read is one scan35/// of the franchises table, which holds one row per franchise of the namespace36/// and is the smallest table of the catalog, so it needs no index of its own.37pub fn all(connection: &Connection) -> rusqlite::Result<Vec<FranchiseEntry>> {38    // Where the franchises row carries no art of its own, the page falls39    // back to the poster of the first member some library holds. The40    // member comes off the same alias join the other two reads make, in41    // story order, and the first one with a poster wins. The poster42    // resolves against that member's own library, which is the second43    // subquery.44    let member = |column: &str| {45        format!(46            "(SELECT {column} FROM franchise_members m \47              JOIN aliases a ON a.alias = m.alias \48              JOIN items i ON i.library = a.library AND i.id = a.item \49              WHERE m.library = f.library AND m.franchise = f.id AND i.art != '' \50              ORDER BY m.position, i.library LIMIT 1)"51        )52    };53    // The two counts are the correlated counts the strip read makes, on54    // the primary key of franchise_members, so the tile and the strip55    // heading say one scope.56    let kind = |kind: &str| {57        format!(58            "(SELECT count(*) FROM franchise_members every \59              WHERE every.library = f.library AND every.franchise = f.id \60                AND every.kind = '{kind}')"61        )62    };63    let sql = format!(64        "WITH items AS ({ITEMS}) \65         SELECT f.library, f.id, f.title, f.art, f.slug, {art}, {holder}, {movies}, {series} \66         FROM franchises f ORDER BY f.sort_key, f.library, f.id",67        art = member("i.art"),68        holder = member("i.library"),69        movies = kind(MOVIE),70        series = kind(SERIES),71    );72    collect(connection, &sql, &[], |row| {73        let own: String = item::text(row, 3)?;74        let (art, art_library) = match own.is_empty() {75            true => (item::text(row, 5)?, item::text(row, 6)?),76            false => (own, row.get(0)?),77        };78        Ok(FranchiseEntry {79            library: row.get(0)?,80            id: row.get(1)?,81            title: row.get(2)?,82            art,83            art_library,84            slug: item::text(row, 4)?,85            movies: row.get(7)?,86            series: row.get(8)?,87        })88    })89}9091/// Every franchise this item belongs to, with its held members in position92/// order. The franchises come from the item's own aliases, and the join to the93/// item tables keeps the members some library holds. The franchise's title94/// comes off the franchises row, because the strip's heading is that title.95/// The read also counts the entries of the order by kind, held or not, on96/// the primary key of `franchise_members`, so the heading says the scope of97/// the order and not only the members the namespace holds.98pub fn strips(99    connection: &Connection,100    library: &str,101    id: &str,102) -> rusqlite::Result<Vec<Membership>> {103    let sql = format!(104        "WITH mine AS (\105           SELECT alias FROM aliases WHERE library = ?1 AND item = ?2\106         ), found AS (\107           SELECT DISTINCT library, franchise FROM franchise_members \108           WHERE alias IN (SELECT alias FROM mine)\109         ), items AS ({ITEMS}) \110         SELECT m.library, m.franchise, \111           (SELECT title FROM franchises named \112            WHERE named.library = m.library AND named.id = m.franchise), \113           (SELECT count(*) FROM franchise_members every \114            WHERE every.library = m.library AND every.franchise = m.franchise \115              AND every.kind = 'movie'), \116           (SELECT count(*) FROM franchise_members every \117            WHERE every.library = m.library AND every.franchise = m.franchise \118              AND every.kind = 'series'), \119           {COLUMNS}, {HELD} \120         FROM franchise_members m \121         JOIN found f ON f.library = m.library AND f.franchise = m.franchise \122         JOIN aliases a ON a.alias = m.alias \123         JOIN items i ON i.library = a.library AND i.id = a.item \124         GROUP BY m.library, m.franchise, m.position \125         ORDER BY m.library, m.franchise, m.position"126    );127    let members = collect(connection, &sql, &[&library, &id], |row| {128        // The membership's own five columns stand before the member's.129        let mut member = entry(row, 5)?;130        held(row, 5 + MEMBER, &mut member)?;131        Ok((132            Membership {133                library: row.get(0)?,134                id: row.get(1)?,135                title: item::text(row, 2)?,136                movies: row.get(3)?,137                series: row.get(4)?,138                members: Vec::new(),139            },140            member,141        ))142    })?;143144    // The read answers one row per member, ordered by the franchise, so145    // the members fold into the franchise the row before them opened.146    let mut strips: Vec<Membership> = Vec::new();147    for (franchise, member) in members {148        match strips.last_mut() {149            Some(last) if last.library == franchise.library && last.id == franchise.id => {}150            _ => strips.push(franchise),151        }152        if let Some(last) = strips.last_mut() {153            last.members.push(member);154        }155    }156    Ok(strips)157}158159/// One franchise as a list of one, and an empty list where that `Library`160/// holds no franchise under that id. The header comes off the franchises row,161/// and the body carries the universe, the calendar, and the eras as the file162/// wrote them.163pub fn franchise(164    connection: &Connection,165    library: &str,166    id: &str,167) -> rusqlite::Result<Vec<Franchise>> {168    let sql = "SELECT title, art, json_extract(body, '$.universe'), \169                      json_extract(body, '$.calendar'), json_extract(body, '$.eras') \170               FROM franchises WHERE library = ? AND id = ?";171    let mut found = collect(connection, sql, &[&library, &id], |row| {172        Ok(Franchise {173            library: library.to_string(),174            id: id.to_string(),175            title: row.get(0)?,176            art: item::text(row, 1)?,177            universe: item::text(row, 2)?,178            calendar: calendar(&item::text(row, 3)?),179            eras: eras(&item::text(row, 4)?),180            entries: Vec::new(),181        })182    })?;183184    if let Some(page) = found.first_mut() {185        page.entries = entries(connection, library, id)?;186    }187    Ok(found)188}189190// The two columns both member reads select after [`COLUMNS`]: how many191// episodes of a series run the catalog holds, and the tagline of the held192// item. A card of the wall and a card of the strip both draw them.193const HELD: &str = "(SELECT count(*) FROM episodes e \194                     JOIN aliases sa ON sa.library = e.library AND sa.item = e.series \195                     WHERE sa.alias = m.alias \196                       AND (NOT EXISTS (SELECT 1 FROM franchise_runs r \197                                        WHERE r.library = m.library \198                                          AND r.franchise = m.franchise \199                                          AND r.position = m.position) \200                            OR EXISTS (SELECT 1 FROM franchise_runs r \201                                       WHERE r.library = m.library \202                                         AND r.franchise = m.franchise \203                                         AND r.position = m.position AND r.season = e.season \204                                         AND r.episode IN (0, e.episode)))) AS held_episodes, \205                    json_extract(i.body, '$.tagline')";206207// How many columns [`COLUMNS`] names, so a read counts the ones it208// selects after them from where the member's own end.209const MEMBER: usize = 19;210211// The two columns [`HELD`] adds, folded into the entry the row opened.212fn held(row: &Row<'_>, at: usize, member: &mut Entry) -> rusqlite::Result<()> {213    member.episodes = row.get(at)?;214    if let Some(held) = member.held.as_mut() {215        held.tagline = item::text(row, at + 1)?;216    }217    Ok(())218}219220// Every entry of one franchise in story order, held or not. The221// episodes column counts what the catalog holds for a series run: every222// episode of the show where the run names no season, and the episodes223// the runs name where it does.224// The page's read also takes the plot out of the held item's body,225// because the wall's card draws it beside the art and the strip's card226// does not.227fn entries(connection: &Connection, library: &str, id: &str) -> rusqlite::Result<Vec<Entry>> {228    let sql = format!(229        "WITH items AS ({ITEMS}) \230         SELECT {COLUMNS}, {HELD}, json_extract(i.body, '$.plot') \231         FROM franchise_members m \232         LEFT JOIN aliases a ON a.alias = m.alias \233         LEFT JOIN items i ON i.library = a.library AND i.id = a.item \234         WHERE m.library = ?1 AND m.franchise = ?2 \235         GROUP BY m.position \236         ORDER BY m.position"237    );238    collect(connection, &sql, &[&library, &id], |row| {239        let mut member = entry(row, 0)?;240        held(row, MEMBER, &mut member)?;241        if let Some(held) = member.held.as_mut() {242            held.plot = item::text(row, MEMBER + 2)?;243        }244        Ok(member)245    })246}247248// One member row as an entry, from the columns [`COLUMNS`] names,249// starting at `at`. A held item's own library is the MIN over the250// libraries that hold it, and a NULL there is a gap.251fn entry(row: &Row<'_>, at: usize) -> rusqlite::Result<Entry> {252    let library: Option<String> = row.get(at + 9)?;253    let held = match library {254        Some(library) => Some(Held {255            library,256            id: item::text(row, at + 10)?,257            kind: item::text(row, at + 16)?,258            title: item::text(row, at + 11)?,259            art: item::text(row, at + 12)?,260            arts: item::strings(&item::text(row, at + 13)?),261            released: item::text(row, at + 14)?,262            slug: item::text(row, at + 15)?,263            tagline: String::new(),264            plot: String::new(),265            duration: row.get::<_, Option<i64>>(at + 18)?.unwrap_or_default(),266        }),267        None => None,268    };269    Ok(Entry {270        position: row.get(at)?,271        kind: row.get(at + 1)?,272        alias: row.get(at + 2)?,273        title: row.get(at + 3)?,274        released: item::text(row, at + 17)?,275        release_year: row.get(at + 4)?,276        timed: row.get::<_, i64>(at + 5)? != 0,277        from: row.get(at + 6)?,278        to: row.get(at + 7)?,279        universes: item::strings(&item::text(row, at + 8)?),280        held,281        episodes: 0,282    })283}284285// The body's calendar, and nothing where the file names none. A286// calendar needs a unit, so a block without one is no calendar.287fn calendar(json: &str) -> Option<Calendar> {288    let Ok(Value::Object(block)) = serde_json::from_str::<Value>(json) else {289        return None;290    };291    let unit = word(&block, "unit");292    match unit.is_empty() {293        true => None,294        false => Some(Calendar {295            unit,296            zero: word(&block, "zero"),297            before: word(&block, "before"),298            after: word(&block, "after"),299        }),300    }301}302303// The body's eras, in the order the file names them. An era with no304// name is left out, because a bar with no words on it says nothing.305fn eras(json: &str) -> Vec<Era> {306    let Ok(Value::Array(named)) = serde_json::from_str::<Value>(json) else {307        return Vec::new();308    };309    named310        .iter()311        .filter_map(|era| {312            let block = era.as_object()?;313            let name = word(block, "name");314            match name.is_empty() {315                true => None,316                false => Some(Era {317                    name,318                    from: era.get("from").and_then(Value::as_f64).unwrap_or_default(),319                    to: era.get("to").and_then(Value::as_f64).unwrap_or_default(),320                }),321            }322        })323        .collect()324}325326fn word(block: &serde_json::Map<String, Value>, name: &str) -> String {327    block328        .get(name)329        .and_then(Value::as_str)330        .unwrap_or_default()331        .to_string()332}333334#[cfg(test)]335mod tests {336    use super::*;337338    #[test]339    fn a_body_with_no_calendar_answers_none() {340        assert_eq!(calendar(""), None);341        assert_eq!(calendar("null"), None);342        assert_eq!(calendar("{}"), None);343        assert_eq!(calendar(r#"{"zero":"Yavin"}"#), None);344    }345346    #[test]347    fn a_calendar_carries_its_unit_and_its_two_words() {348        assert_eq!(349            calendar(r#"{"unit":"years","zero":"Yavin","before":"BBY","after":"ABY"}"#),350            Some(Calendar {351                unit: "years".into(),352                zero: "Yavin".into(),353                before: "BBY".into(),354                after: "ABY".into(),355            })356        );357    }358359    #[test]360    fn the_eras_come_in_the_order_the_file_names_them() {361        let eras = eras(r#"[{"name":"Late","from":-5,"to":5},{"name":"Early","from":-500}]"#);362        assert_eq!(363            eras,364            [365                Era {366                    name: "Late".into(),367                    from: -5.0,368                    to: 5.0369                },370                Era {371                    name: "Early".into(),372                    from: -500.0,373                    to: 0.0374                },375            ]376        );377    }378379    #[test]380    fn an_era_with_no_name_and_a_body_with_no_eras_leave_nothing_behind() {381        assert!(eras("").is_empty());382        assert!(eras("null").is_empty());383        assert!(eras(r#"{"name":"Late"}"#).is_empty());384        assert!(eras(r#"[{"from":-5,"to":5},"Late"]"#).is_empty());385    }386}
src/catalog/sidecar/genres.rs 98.3%
1// The two reads over the genres table. The `Genre` query's titles are2// one indexed range through `genres (library, genre)` per library,3// joined to that library's item table by `(library, item)`, then one4// sort in this process by rank first and the order's column newest5// first, because the ranges come off separate libraries.6// The genres strip's entries are two reads per library through the same7// index, the counts and the candidate posters, folded across libraries8// here for the same reason.910use std::collections::{BTreeMap, HashMap};1112use rusqlite::Connection;1314use super::{collect, item, item_table};15use crate::catalog::{GenreEntry, GenreSort, Order, Slot, Sort, TILE_CANDIDATES, unrepeated};1617/// Every title across every library that carries the genre, in the18/// order the sort names, then by library and id so the order is the19/// same on every read.20// `Leads` is the order the genre wall opens in: the titles that lead21// with the genre first. The three plain sorts drop the rank and order by22// the release or the sort key alone.23pub fn titles(24    connection: &Connection,25    name: &str,26    order: Order,27    sort: GenreSort,28    kinds: &HashMap<String, String>,29) -> rusqlite::Result<Vec<Slot>> {30    let mut kinds: Vec<(&String, &String)> = kinds.iter().collect();31    kinds.sort();32    let mut found: Vec<(Keys, Slot)> = Vec::new();33    for (library, kind) in kinds {34        let Some(table) = item_table(kind) else {35            continue;36        };37        let sql = format!(38            "SELECT {columns}, {seasons} AS seasons, genres.rank, {table}.added, \39                    {table}.sort_key \40             FROM genres JOIN {table} ON {table}.library = genres.library \41             AND {table}.id = genres.item \42             WHERE genres.library = ?1 AND genres.genre = ?2",43            columns = item::COLUMNS,44            seasons = item::seasons(table),45        );46        let rows = collect(connection, &sql, &[&library, &name], |row| {47            Ok((48                Keys {49                    rank: row.get(item::WIDTH + 1)?,50                    added: row.get(item::WIDTH + 2)?,51                    sort_key: row.get(item::WIDTH + 3)?,52                },53                Slot {54                    seasons: row.get(item::WIDTH)?,55                    ..Slot::of(library, kind, item::title(row)?)56                },57            ))58        })?;59        found.extend(rows);60    }61    found.sort_by(|(keys, slot), (other_keys, other)| {62        ordered(sort, order, (keys, slot), (other_keys, other))63            .then_with(|| slot.library.cmp(&other.library))64            .then_with(|| slot.id.cmp(&other.id))65    });66    Ok(found.into_iter().map(|(_, slot)| slot).collect())67}6869// The three keys a genre row carries beyond its slot, which the four70// orders compare on.71struct Keys {72    rank: i64,73    added: i64,74    sort_key: String,75}7677// How one sort compares two genre rows. `Leads` compares the rank78// first and then the order's own column. The three plain sorts drop the79// rank.80fn ordered(81    sort: GenreSort,82    order: Order,83    one: (&Keys, &Slot),84    other: (&Keys, &Slot),85) -> std::cmp::Ordering {86    let (keys, slot) = one;87    let (other_keys, other_slot) = other;88    let newest = || match order {89        Order::Released => other_slot.released.cmp(&slot.released),90        Order::Added => other_keys.added.cmp(&keys.added),91    };92    match sort {93        GenreSort::Leads => keys.rank.cmp(&other_keys.rank).then_with(newest),94        GenreSort::By(Sort::Newest) => other_slot.released.cmp(&slot.released),95        GenreSort::By(Sort::Oldest) => slot.released.cmp(&other_slot.released),96        GenreSort::By(Sort::Title) => keys.sort_key.cmp(&other_keys.sort_key),97    }98}99100// One candidate poster of a genre: whether the title leads with the101// genre, its release, its id, and the poster with the library it resolves102// against. The first three order the candidates of the whole namespace.103struct Candidate {104    main: bool,105    released: String,106    item: String,107    library: String,108    art: String,109}110111// What one library's read adds to a genre: the count of its titles, and112// the candidate posters the tile draws from.113#[derive(Default)]114struct Found {115    titles: u64,116    candidates: Vec<Candidate>,117}118119/// Every genre with its count of titles and its art, in name order. One120/// grouped read per library through the `(library, genre)` index, then121/// the fold across libraries in this process, because a genre spans122/// libraries and each library has one item table.123pub fn entries(124    connection: &Connection,125    kinds: &HashMap<String, String>,126) -> rusqlite::Result<Vec<GenreEntry>> {127    let mut kinds: Vec<(&String, &String)> = kinds.iter().collect();128    kinds.sort();129    let mut found: BTreeMap<String, Found> = BTreeMap::new();130    for (library, kind) in kinds {131        let Some(table) = item_table(kind) else {132            continue;133        };134        counted(connection, library, table, &mut found)?;135        for (genre, candidate) in candidates(connection, library, table)? {136            found.entry(genre).or_default().candidates.push(candidate);137        }138    }139    let mut entries: Vec<GenreEntry> = found140        .into_iter()141        .map(|(name, found)| GenreEntry {142            name,143            titles: found.titles,144            art: chosen(found.candidates),145        })146        .collect();147    unrepeated(&mut entries);148    Ok(entries)149}150151// One library's count of the titles that carry each genre, added to the152// counts the earlier libraries answered.153fn counted(154    connection: &Connection,155    library: &str,156    table: &str,157    found: &mut BTreeMap<String, Found>,158) -> rusqlite::Result<()> {159    let sql = format!(160        "SELECT genres.genre, COUNT(DISTINCT genres.item) \161         FROM genres JOIN {table} ON {table}.library = genres.library \162         AND {table}.id = genres.item \163         WHERE genres.library = ?1 AND genres.genre != '' \164         GROUP BY genres.genre"165    );166    let rows = collect(connection, &sql, &[&library], |row| {167        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))168    })?;169    for (genre, titles) in rows {170        found.entry(genre).or_default().titles += titles.max(0) as u64;171    }172    Ok(())173}174175// The candidate posters one library holds for each of its genres: the176// titles that lead with the genre first, and the newest release next.177// The window numbers each genre's own rows, so one read answers every178// genre and no genre answers more rows than the tiles can use.179fn candidates(180    connection: &Connection,181    library: &str,182    table: &str,183) -> rusqlite::Result<Vec<(String, Candidate)>> {184    let sql = format!(185        "SELECT genre, rank, released, item, art FROM (\186           SELECT genres.genre AS genre, genres.rank AS rank, {table}.released AS released, \187                  genres.item AS item, {table}.art AS art, \188                  ROW_NUMBER() OVER (PARTITION BY genres.genre \189                    ORDER BY genres.rank != 0, {table}.released DESC, genres.item) AS place \190           FROM genres JOIN {table} ON {table}.library = genres.library \191           AND {table}.id = genres.item \192           WHERE genres.library = ?1 AND genres.genre != '' AND {table}.art != ''\193         ) WHERE place <= {TILE_CANDIDATES}"194    );195    collect(connection, &sql, &[&library], |row| {196        Ok((197            row.get::<_, String>(0)?,198            Candidate {199                main: row.get::<_, i64>(1)? == 0,200                released: row.get(2)?,201                item: row.get(3)?,202                library: library.to_string(),203                art: row.get(4)?,204            },205        ))206    })207}208209// The candidates of one genre across every library, in the order the210// tile fills from: the titles that lead with the genre first, then the211// newest release, then the library and the id, so a read answers the212// same order every time.213fn chosen(mut candidates: Vec<Candidate>) -> Vec<(String, String)> {214    candidates.sort_by(|one, other| {215        other216            .main217            .cmp(&one.main)218            .then_with(|| other.released.cmp(&one.released))219            .then_with(|| one.library.cmp(&other.library))220            .then_with(|| one.item.cmp(&other.item))221    });222    candidates223        .into_iter()224        .take(TILE_CANDIDATES)225        .map(|candidate| (candidate.library, candidate.art))226        .collect()227}
src/catalog/sidecar/item.rs 100.0%
1// The reads every item table shares: the header columns a list draws,2// the fields of the body column, and the files a page reads by role3// through file_items.45use rusqlite::{Connection, Row};6use serde_json::Value;78use super::collect;9use crate::catalog::{Credit, Title};1011/// The columns every list of titles reads, in the order [`title`] takes12/// them. The last is the tagline a film's card leads with.13pub const COLUMNS: &str = "id, title, released, art, duration, \14                           json_extract(body, '$.contentRating'), \15                           json_extract(body, '$.tagline')";1617/// How many columns [`COLUMNS`] names, so a read that selects more after18/// them counts its own from there.19pub const WIDTH: usize = 7;2021// The seasons column of a slot read: a correlated count over the22// covering index (library, series, season, episode) for the series23// table, and the literal zero for every other item table, so the two24// halves of a union carry the same columns.25pub fn seasons(table: &str) -> String {26    if table != "series" {27        return "0".to_string();28    }29    format!(30        "(SELECT COUNT(DISTINCT episodes.season) FROM episodes \31          WHERE episodes.library = {table}.library AND episodes.series = {table}.id)"32    )33}3435/// One title from those columns.36pub fn title(row: &Row<'_>) -> rusqlite::Result<Title> {37    Ok(Title {38        id: row.get(0)?,39        title: row.get(1)?,40        released: row.get(2)?,41        art: row.get(3)?,42        duration: row.get(4)?,43        rating: text(row, 5)?,44        tagline: text(row, 6)?,45    })46}4748/// An item's files by role, as pairs of the role and the path: the three49/// roles a page reads. A title with a second file in one role holds more50/// than one row, so MIN(path) picks one, the way the main file's read51/// does.52pub fn art(53    connection: &Connection,54    library: &str,55    id: &str,56) -> rusqlite::Result<Vec<(String, String)>> {57    let sql = "SELECT files.role, MIN(files.path) \58               FROM file_items INDEXED BY file_items_library_item \59               JOIN files ON files.library = file_items.library \60               AND files.path = file_items.path \61               WHERE file_items.library = ? AND file_items.item = ? \62               AND ((files.type = 'image' AND files.role IN ('backdrop', 'logo')) \63                    OR (files.type = 'video' AND files.role = 'trailer')) \64               GROUP BY files.role";65    collect(connection, sql, &[&library, &id], |row| {66        Ok((row.get(0)?, row.get(1)?))67    })68}6970/// One text column of a row. A body that names no such field answers71/// NULL, and this reads it as an empty string instead of a row that fails72/// to map.73pub fn text(row: &Row<'_>, index: usize) -> rusqlite::Result<String> {74    Ok(row.get::<_, Option<String>>(index)?.unwrap_or_default())75}7677/// One array of the body. json_extract answers it as JSON text.78pub fn strings(json: &str) -> Vec<String> {79    serde_json::from_str(json).unwrap_or_default()80}8182/// The body's ratings, as pairs of the sidecar's own name for the site and83/// the score on that site's scale. A score that is not a number is left84/// out.85pub fn ratings(json: &str) -> Vec<(String, f64)> {86    let Ok(Value::Object(sites)) = serde_json::from_str::<Value>(json) else {87        return Vec::new();88    };89    sites90        .iter()91        .filter_map(|(name, score)| Some((name.clone(), score.as_f64()?)))92        .collect()93}9495/// The body's cast. A member with no name is left out, because a role96/// with no person in front of it reads as damage.97pub fn credits(json: &str) -> Vec<Credit> {98    let Ok(Value::Array(members)) = serde_json::from_str::<Value>(json) else {99        return Vec::new();100    };101    members102        .iter()103        .map(|member| Credit {104            name: field(member, "name"),105            role: field(member, "role"),106        })107        .filter(|credit| !credit.name.is_empty())108        .collect()109}110111fn field(member: &Value, name: &str) -> String {112    member113        .get(name)114        .and_then(Value::as_str)115        .unwrap_or_default()116        .to_string()117}118119#[cfg(test)]120mod tests {121    use super::*;122123    #[test]124    fn an_array_the_body_does_not_hold_reads_as_nothing() {125        assert!(strings("").is_empty());126        assert!(strings("null").is_empty());127        assert_eq!(strings(r#"["Drama","Mystery"]"#), ["Drama", "Mystery"]);128    }129130    #[test]131    fn a_cast_carries_its_names_and_parts() {132        let cast = credits(r#"[{"name":"A Player","role":"The Part"},{"name":"Another"}]"#);133        assert_eq!(134            cast,135            [136                Credit {137                    name: "A Player".into(),138                    role: "The Part".into(),139                },140                Credit {141                    name: "Another".into(),142                    role: String::new(),143                },144            ]145        );146    }147148    #[test]149    fn a_ratings_block_reads_as_one_pair_for_every_score() {150        assert_eq!(151            ratings(r#"{"imdb":6.5,"metacritic":80}"#),152            [("imdb".to_string(), 6.5), ("metacritic".to_string(), 80.0)]153        );154    }155156    #[test]157    fn a_ratings_block_the_body_does_not_hold_reads_as_nothing() {158        assert!(ratings("").is_empty());159        assert!(ratings("null").is_empty());160        assert!(ratings(r#"{"imdb":"6.5"}"#).is_empty());161    }162163    #[test]164    fn a_cast_the_body_does_not_hold_reads_as_nothing() {165        assert!(credits("").is_empty());166        assert!(credits(r#"{"name":"A Player"}"#).is_empty());167        assert!(credits(r#"[{"role":"The Part"}]"#).is_empty());168    }169}
src/catalog/sidecar/people.rs 98.5%
1// The reads behind the people of a catalog: one title's credits, one2// person's entry, and every title that person is credited in. A person3// has one row per library, and the ids in contributor_aliases are what4// join two libraries' copies of them.56use std::collections::HashMap;78use rusqlite::Connection;910use super::{collect, item, item_table};11use crate::catalog::{CreditSlot, Credits, Person, Slot, Title};1213/// One title's credits, split into the three stripes, as a list of one.14/// The join to contributors is what says whether a slot has a headshot,15/// and a credit with no contributor path never matches an entry.16pub fn credits(connection: &Connection, library: &str, id: &str) -> rusqlite::Result<Vec<Credits>> {17    let sql = "SELECT credits.part, credits.name, credits.role, credits.contributor, \18                      COALESCE(contributors.headshot, 0) \19               FROM credits \20               LEFT JOIN contributors ON contributors.library = credits.library \21               AND contributors.path = credits.contributor \22               WHERE credits.library = ? AND credits.item = ? \23               ORDER BY credits.billing";24    let rows = collect(connection, sql, &[&library, &id], |row| {25        let contributor: String = row.get(3)?;26        let headshot = !contributor.is_empty() && row.get::<_, i64>(4)? != 0;27        Ok((28            row.get::<_, String>(0)?,29            CreditSlot {30                name: row.get(1)?,31                role: row.get(2)?,32                contributor,33                headshot,34            },35        ))36    })?;3738    let mut credits = Credits::default();39    for (part, slot) in rows {40        match part.as_str() {41            "director" => credits.directors.push(slot),42            "writer" => credits.writers.push(slot),43            "actor" => credits.cast.push(slot),44            _ => {}45        }46    }47    Ok(vec![credits])48}4950/// One person's entry in the opening library, as a list of one, or an51/// empty list where that library holds no entry under that path.52pub fn local_person(53    connection: &Connection,54    library: &str,55    path: &str,56) -> rusqlite::Result<Vec<Person>> {57    let sql = "SELECT name, born, died, biography, headshot \58               FROM contributors WHERE library = ? AND path = ?";59    collect(connection, sql, &[&library, &path], |row| {60        let biography = row.get::<_, i64>(3)? != 0;61        let headshot = row.get::<_, i64>(4)? != 0;62        let biography_entry = if biography {63            (library.to_string(), path.to_string())64        } else {65            (String::new(), String::new())66        };67        let headshot_entry = if headshot {68            (library.to_string(), path.to_string())69        } else {70            (String::new(), String::new())71        };72        Ok(Person {73            library: library.to_string(),74            path: path.to_string(),75            name: row.get(0)?,76            born: row.get(1)?,77            died: row.get(2)?,78            biography,79            headshot,80            biography_library: biography_entry.0,81            biography_path: biography_entry.1,82            headshot_library: headshot_entry.0,83            headshot_path: headshot_entry.1,84        })85    })86}8788/// Fill a person's missing files from the first resolved entry that holds89/// each one, with the opening library first.90pub fn person(91    connection: &Connection,92    mut person: Person,93    entries: &[(String, String)],94) -> rusqlite::Result<Vec<Person>> {95    for (other, elsewhere) in entries {96        if person.biography && person.headshot {97            break;98        }99        if other == &person.library && elsewhere == &person.path {100            continue;101        }102        let files = collect(103            connection,104            "SELECT biography, headshot FROM contributors WHERE library = ? AND path = ?",105            &[other, elsewhere],106            |row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, i64>(1)? != 0)),107        )?;108        let Some((biography, headshot)) = files.into_iter().next() else {109            continue;110        };111        if biography && !person.biography {112            person.biography = true;113            person.biography_library = other.clone();114            person.biography_path = elsewhere.clone();115        }116        if headshot && !person.headshot {117            person.headshot = true;118            person.headshot_library = other.clone();119            person.headshot_path = elsewhere.clone();120        }121    }122    Ok(vec![person])123}124125/// The person's name as a list of one, or an empty list where the126/// library holds no entry under that path. It is the name a wall's heading127/// carries.128pub fn name(connection: &Connection, library: &str, path: &str) -> rusqlite::Result<Vec<String>> {129    collect(130        connection,131        "SELECT name FROM contributors WHERE library = ? AND path = ?",132        &[&library, &path],133        |row| row.get(0),134    )135}136137/// Every title the person is credited in, across every library that holds138/// them, newest release first and a title with no release last. A title139/// the person holds more than one credit on is one row, with the parts140/// joined.141pub fn works(142    connection: &Connection,143    entries: &[(String, String)],144    kinds: &HashMap<String, String>,145) -> rusqlite::Result<Vec<Slot>> {146    let mut works: Vec<Slot> = Vec::new();147    let mut parts: Vec<Vec<(u8, String)>> = Vec::new();148    let mut placed: HashMap<(String, String), usize> = HashMap::new();149150    for (other, elsewhere) in entries {151        let Some(kind) = kinds.get(other) else {152            continue;153        };154        let Some(table) = item_table(kind) else {155            continue;156        };157        // The library's kind names the item table, so a credit joins to158        // the one table that holds its titles.159        // The read carries the duration and the content rating, because a160        // card of a person's strip draws the facts line every other strip161        // draws where the credit leaves no character behind.162        // The read carries the tagline as every other slot read does,163        // because a film's card leads with it.164        let sql = format!(165            "SELECT credits.item, credits.part, credits.role, \166                    {table}.title, {table}.released, {table}.art, \167                    {table}.duration, json_extract({table}.body, '$.contentRating'), \168                    {seasons} AS seasons, json_extract({table}.body, '$.tagline') \169             FROM credits JOIN {table} ON {table}.library = credits.library \170             AND {table}.id = credits.item \171             WHERE credits.library = ? AND credits.contributor = ? \172             ORDER BY credits.billing",173            seasons = item::seasons(table),174        );175        let rows = collect(connection, &sql, &[other, elsewhere], |row| {176            Ok((177                row.get::<_, String>(1)?,178                row.get::<_, String>(2)?,179                row.get::<_, i64>(8)?,180                Title {181                    id: row.get(0)?,182                    title: row.get(3)?,183                    released: row.get(4)?,184                    art: row.get(5)?,185                    duration: row.get(6)?,186                    rating: item::text(row, 7)?,187                    tagline: item::text(row, 9)?,188                },189            ))190        })?;191192        for (part, role, seasons, title) in rows {193            let Some(named) = named(&part, &role) else {194                continue;195            };196            let slot = *placed197                .entry((other.clone(), title.id.clone()))198                .or_insert_with(|| {199                    works.push(Slot {200                        seasons,201                        ..Slot::of(other, kind, title)202                    });203                    parts.push(Vec::new());204                    works.len() - 1205                });206            parts[slot].push(named);207        }208    }209210    for (work, mut list) in works.iter_mut().zip(parts) {211        list.sort_by_key(|(rank, _)| *rank);212        let joined: Vec<String> = list.into_iter().map(|(_, named)| named).collect();213        work.parts = joined.join(", ");214    }215    works.sort_by(|one, other| {216        one.released217            .is_empty()218            .cmp(&other.released.is_empty())219            .then_with(|| other.released.cmp(&one.released))220            .then_with(|| one.title.cmp(&other.title))221    });222    Ok(works)223}224225// What one credit reads as in a person's wall, and where it sorts among226// the parts of one title. A part the credits fact never writes reads as227// nothing at all.228fn named(part: &str, role: &str) -> Option<(u8, String)> {229    match part {230        "director" => Some((0, "Director".to_string())),231        "writer" => Some((1, "Writer".to_string())),232        "actor" if role.is_empty() => Some((2, "Actor".to_string())),233        "actor" => Some((2, format!("as {role}"))),234        _ => None,235    }236}237238// Every entry of one person, the opening library's first and then every239// other library that holds an id this person's aliases carry.240pub fn entries(241    connection: &Connection,242    library: &str,243    path: &str,244) -> rusqlite::Result<Vec<(String, String)>> {245    let mut found = vec![(library.to_string(), path.to_string())];246    let ids = collect(247        connection,248        "SELECT scheme, id FROM contributor_aliases \249         INDEXED BY contributor_aliases_library_path \250         WHERE library = ? AND path = ? ORDER BY scheme, id",251        &[&library, &path],252        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),253    )?;254    for (scheme, id) in ids {255        let elsewhere = collect(256            connection,257            "SELECT library, path FROM contributor_aliases \258             WHERE scheme = ? AND id = ? AND library <> ? ORDER BY library, path",259            &[&scheme, &id, &library],260            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),261        )?;262        for entry in elsewhere {263            if !found.contains(&entry) {264                found.push(entry);265            }266        }267    }268    Ok(found)269}270271// Each library's kind, from the same union the libraries strip reads:272// the item table a library has rows in is its kind.273pub fn kinds(connection: &Connection) -> rusqlite::Result<HashMap<String, String>> {274    let sql = "SELECT library, 'movies' AS kind FROM movies GROUP BY library \275               UNION ALL \276               SELECT library, 'series' AS kind FROM series GROUP BY library";277    let rows = collect(connection, sql, &[], |row| {278        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))279    })?;280    let mut kinds = HashMap::new();281    for (library, kind) in rows {282        kinds.entry(library).or_insert(kind);283    }284    Ok(kinds)285}
src/catalog/sidecar/play.rs 99.0%
1// The reads that turn a choice into a play list. The main file of a2// title is its `files` row reached through `file_items`, typed `video`3// and in the `primary` role, and its trickplay path comes with it.4// Every read is parameterised, like every other read of this source.56use rusqlite::Connection;78use super::{collect, item};9use crate::catalog::{PlayItem, Presentation, art};1011// The join from an item to one of its video files. A title with a12// second encoding holds more than one file in a role, so MIN(path) picks13// one, and the bare trickplay column comes from that same row, which14// is SQLite's rule for a bare column beside a single min or max.15//16// The role is a literal in this file and never a caller's word, so no17// string from outside reaches the query text.18fn video(role: &'static str) -> String {19    format!(20        "JOIN file_items ON file_items.library = item.library AND file_items.item = item.id \21         JOIN files ON files.library = file_items.library AND files.path = file_items.path \22         AND files.type = 'video' AND files.role = '{role}'"23    )24}2526/// One movie's play list: the one item it resolves to, or nothing when27/// the movie holds no main file.28pub fn movie(connection: &Connection, library: &str, id: &str) -> rusqlite::Result<Vec<PlayItem>> {29    let sql = format!(30        "SELECT item.title, item.released, item.art, MIN(files.path), files.trickplay, \31                item.slug \32         FROM movies item {} \33         WHERE item.library = ? AND item.id = ? GROUP BY item.id",34        video("primary")35    );36    collect(connection, &sql, &[&library, &id], |row| {37        let released: String = row.get(1)?;38        Ok(PlayItem {39            path: row.get(3)?,40            slug: row.get(5)?,41            presentation: Presentation {42                kind: "video".into(),43                hint: "movie".into(),44                title: row.get(0)?,45                year: year(&released),46                art: row.get(2)?,47                trickplay: row.get(4)?,48                ..Presentation::default()49            },50        })51    })52}5354/// One movie's trailer: the trailer file's path, the movie's own55/// presentation, and no trickplay, because a trailer has none. The56/// film's display then shows the movie the person was looking at.57pub fn trailer(58    connection: &Connection,59    library: &str,60    id: &str,61) -> rusqlite::Result<Vec<PlayItem>> {62    let sql = format!(63        "SELECT item.title, item.released, item.art, MIN(files.path), item.slug \64         FROM movies item {} \65         WHERE item.library = ? AND item.id = ? GROUP BY item.id",66        video("trailer")67    );68    collect(connection, &sql, &[&library, &id], |row| {69        let released: String = row.get(1)?;70        Ok(PlayItem {71            path: row.get(3)?,72            slug: row.get(4)?,73            presentation: Presentation {74                kind: "video".into(),75                hint: "movie".into(),76                title: row.get(0)?,77                year: year(&released),78                art: row.get(2)?,79                ..Presentation::default()80            },81        })82    })83}8485/// The chosen episode and every later episode of its season, in86/// episode order. An episode with no main file drops out of the join,87/// and a list that does not start with the chosen episode is no list88/// at all. The series row carries the art an episode with no still of89/// its own is presented with.90pub fn episodes(91    connection: &Connection,92    library: &str,93    series: &str,94    season: i64,95    chosen: i64,96) -> rusqlite::Result<Vec<PlayItem>> {97    let sql = format!(98        "SELECT item.episode, item.title, item.released, item.art, IFNULL(parent.title, ''), \99                MIN(files.path), files.trickplay, item.slug, \100                IFNULL(parent.art, ''), IFNULL(parent.arts, '[]') \101         FROM episodes item {} \102         LEFT JOIN series parent ON parent.library = item.library AND parent.id = item.series \103         WHERE item.library = ? AND item.series = ? AND item.season = ? AND item.episode >= ? \104         GROUP BY item.id ORDER BY item.episode",105        video("primary")106    );107    let found = collect(108        connection,109        &sql,110        &[&library, &series, &season, &chosen],111        |row| {112            let number: i64 = row.get(0)?;113            let released: String = row.get(2)?;114            let mut presentation = Presentation {115                kind: "video".into(),116                hint: "series".into(),117                series: row.get(4)?,118                season,119                episode: number,120                episode_title: row.get(1)?,121                art: art::still(122                    &item::text(row, 3)?,123                    &item::text(row, 8)?,124                    &item::strings(&item::text(row, 9)?),125                ),126                trickplay: row.get(6)?,127                ..Presentation::default()128            };129            dated(&mut presentation, released);130            Ok((131                number,132                PlayItem {133                    path: row.get(5)?,134                    slug: row.get(7)?,135                    presentation,136                },137            ))138        },139    )?;140    if found.first().map(|(number, _)| *number) != Some(chosen) {141        return Ok(Vec::new());142    }143    Ok(found.into_iter().map(|(_, item)| item).collect())144}145146// An episode carries the release the catalog holds: a full ISO date147// where the provider gave one, and the year alone otherwise. The film's148// display shows the date when it has one.149fn dated(presentation: &mut Presentation, released: String) {150    if is_date(&released) {151        presentation.date = released;152        return;153    }154    presentation.year = year(&released);155}156157// The year, the first four digits of the released column. A column that158// holds neither a year nor a date answers zero, which the request leaves159// out.160fn year(released: &str) -> i64 {161    released162        .get(..4)163        .and_then(|digits| digits.parse().ok())164        .unwrap_or(0)165}166167// Whether the released column holds a whole date, yyyy-mm-dd, and not a168// year alone.169fn is_date(released: &str) -> bool {170    let mut parts = released.split('-');171    matches!(172        (parts.next(), parts.next(), parts.next(), parts.next()),173        (Some(year), Some(month), Some(day), None)174            if year.len() == 4 && month.len() == 2 && day.len() == 2175    )176}
src/catalog/sidecar/pool.rs 100.0%
1// The three grouped reads behind the pool, each over an index the2// catalog already holds: `genres` by `(library, genre)`, `credits` by3// `(library, contributor, item)`, and `movies` by `(library, set_id)`.45use rusqlite::Connection;67use super::collect;8use crate::catalog::pool::Candidate;9use crate::catalog::recency::WORKS_FLOOR;10use crate::catalog::{GenreSort, Order, Query};1112/// Every candidate: the genres, then the people, then the sets, each13/// with its weight. The order of the answer is fixed by name, so the draw14/// sees the same pool on every read.15pub fn candidates(connection: &Connection) -> rusqlite::Result<Vec<Candidate>> {16    let mut pool = genres(connection)?;17    pool.extend(people(connection)?);18    pool.extend(sets(connection)?);19    Ok(pool)20}2122// Every genre with the count of titles that carry it, a title that23// leads with it counted twice, so a genre that leads is weightier than24// one that trails.25fn genres(connection: &Connection) -> rusqlite::Result<Vec<Candidate>> {26    let sql = "SELECT genre, COUNT(*) + SUM(rank = 0) FROM genres \27               WHERE genre != '' GROUP BY genre ORDER BY genre";28    collect(connection, sql, &[], |row| {29        let name: String = row.get(0)?;30        Ok(Candidate {31            query: Query::Genre {32                name: name.clone(),33                order: Order::Released,34                sort: GenreSort::default(),35            },36            name,37            weight: weight(row.get(1)?),38        })39    })40}4142// Every person with an entry and more than `WORKS_FLOOR` distinct43// titles credited in one library, weighed by that count. A person's44// works read joins their entries across libraries; the pool counts one45// library's entry, so this read stays one group over the credits46// index.47fn people(connection: &Connection) -> rusqlite::Result<Vec<Candidate>> {48    let sql = "SELECT credited.library, credited.contributor, contributors.name, credited.works \49               FROM (\50                 SELECT library, contributor, COUNT(DISTINCT item) AS works \51                 FROM credits WHERE contributor != '' \52                 GROUP BY library, contributor HAVING works > ?1\53               ) AS credited \54               JOIN contributors ON contributors.library = credited.library \55               AND contributors.path = credited.contributor \56               ORDER BY contributors.name, credited.library";57    collect(connection, sql, &[&(WORKS_FLOOR as i64)], |row| {58        Ok(Candidate {59            query: Query::Person {60                library: row.get(0)?,61                path: row.get(1)?,62            },63            name: row.get(2)?,64            weight: weight(row.get(3)?),65        })66    })67}6869// Every set with at least two members, weighed by its member count,70// because a set of one is its one film.71fn sets(connection: &Connection) -> rusqlite::Result<Vec<Candidate>> {72    let sql = "SELECT sets.library, sets.id, sets.title, COUNT(*) AS members \73               FROM sets JOIN movies ON movies.library = sets.library \74               AND movies.set_id = sets.id \75               GROUP BY sets.library, sets.id \76               HAVING members >= 2 \77               ORDER BY sets.title, sets.library, sets.id";78    collect(connection, sql, &[], |row| {79        Ok(Candidate {80            query: Query::Set {81                library: row.get(0)?,82                id: row.get(1)?,83            },84            name: row.get(2)?,85            weight: weight(row.get(3)?),86        })87    })88}8990fn weight(count: i64) -> u64 {91    count.max(0) as u6492}
src/catalog/sidecar/recent.rs 100.0%
1// The one read behind the two recency queries: movies and episodes off2// every library in one union, newest first by the query's own column.3// The bounded key selection runs before the payload reads, so titles,4// art, and JSON are read only for candidates that can enter the fold.56use rusqlite::{Connection, Row};78use super::{collect, item};9use crate::catalog::recency::{CANDIDATES, Candidate, PAGES};10use crate::catalog::{Order, Slot, Title, art};1112/// The column an order names. The closed match is the whole of what may13/// reach the SQL text.14pub fn column(order: Order) -> &'static str {15    match order {16        Order::Released => "released",17        Order::Added => "added",18    }19}2021/// At most `PAGES * CANDIDATES` candidates, newest first. The key union22/// joins episodes to their series before the limit, so an orphan cannot23/// displace a candidate that the payload read can return. `kind` keeps a24/// movie first when the order column, `library`, and `id` are equal.25pub fn candidates(connection: &Connection, order: Order) -> rusqlite::Result<Vec<Candidate>> {26    let sql = candidate_sql(order);27    let limit = (PAGES * CANDIDATES) as i64;28    collect(connection, &sql, &[&limit], candidate)29}3031fn candidate_sql(order: Order) -> String {32    format!(33        "WITH candidate_keys AS MATERIALIZED (\34           SELECT movies.library, movies.id, movies.released, movies.added, \35                  0 AS kind, '' AS series, 0 AS season, 0 AS episode \36           FROM movies \37           UNION ALL \38           SELECT episodes.library, episodes.id, episodes.released, episodes.added, \39                  1 AS kind, episodes.series, episodes.season, episodes.episode \40           FROM episodes JOIN series ON series.library = episodes.library \41           AND series.id = episodes.series \42           ORDER BY {key} DESC, library, id, kind LIMIT ?1\43         ) \44         SELECT * FROM (\45           SELECT {movie_columns}, keys.library, keys.added, 'movies' AS kind, \46                  '' AS series, 0 AS season, 0 AS episode, \47                  '' AS series_title, '' AS series_art, '' AS series_released, \48                  0 AS series_duration, '' AS series_rating, '[]' AS series_arts, \49                  keys.{key} AS ordering, keys.kind AS tie_kind \50           FROM candidate_keys AS keys \51           JOIN movies ON movies.library = keys.library AND movies.id = keys.id \52           WHERE keys.kind = 0 \53           UNION ALL \54           SELECT {episode_columns}, keys.library, keys.added, 'episodes' AS kind, \55                  keys.series, keys.season, keys.episode, \56                  series.title, series.art, series.released, series.duration, \57                  json_extract(series.body, '$.contentRating'), series.arts, \58                  keys.{key} AS ordering, keys.kind AS tie_kind \59           FROM candidate_keys AS keys \60           JOIN episodes ON episodes.library = keys.library AND episodes.id = keys.id \61           JOIN series ON series.library = keys.library AND series.id = keys.series \62           WHERE keys.kind = 1\63         ) ORDER BY ordering DESC, library, id, tie_kind",64        movie_columns = MOVIE_COLUMNS,65        episode_columns = EPISODE_COLUMNS,66        key = column(order),67    )68}6970// These payload columns have the order `item::title` reads. An episode71// has no content rating or tagline of its own, so those positions are72// empty strings.73const MOVIE_COLUMNS: &str = "movies.id, movies.title, movies.released, movies.art, \74                             movies.duration, \75                             json_extract(movies.body, '$.contentRating'), \76                             json_extract(movies.body, '$.tagline')";77const EPISODE_COLUMNS: &str = "episodes.id, episodes.title, episodes.released, episodes.art, \78                               episodes.duration, '', ''";7980fn candidate(row: &Row<'_>) -> rusqlite::Result<Candidate> {81    let mut title = item::title(row)?;82    // The columns the union selects after the ones every list reads.83    let at = |column: usize| item::WIDTH + column;84    let library: String = row.get(at(0))?;85    let kind: String = row.get(at(2))?;86    if kind == "movies" {87        return Ok(Candidate::Movie {88            slot: Slot::of(&library, "movies", title),89        });90    }91    let series = Title {92        id: row.get(at(3))?,93        title: row.get(at(6))?,94        art: row.get(at(7))?,95        released: row.get(at(8))?,96        duration: row.get(at(9))?,97        rating: item::text(row, at(10))?,98        tagline: String::new(),99    };100    // The series row comes with every episode candidate, so the still an101    // episode with no art of its own draws is decided here, once, for102    // every strip a recency query feeds.103    title.art = art::still(104        &title.art,105        &series.art,106        &item::strings(&item::text(row, at(11))?),107    );108    Ok(Candidate::Episode {109        library,110        episode: title,111        added: row.get(at(1))?,112        season: row.get(at(4))?,113        number: row.get(at(5))?,114        series,115    })116}
src/catalog/sidecar/search.rs 100.0%
1// The search index over the sidecar's replica. The sidecar owns it2// because the sidecar owns the replica and already follows the updates3// feed, so the signal that a row changed is the signal to build again.4// The build runs on a thread of its own with a read-only connection of5// its own, never on the frame, and a `Search` answers an empty wall6// until the first build lands.78use std::path::{Path, PathBuf};9use std::sync::{Arc, Mutex};10use std::thread;11use std::time::{Duration, Instant};1213use rusqlite::{Connection, OpenFlags};1415use super::updates::Shared;16use crate::catalog::Slot;17use crate::catalog::search::Index;1819// The one read that builds an index off the replica.20pub(super) mod read;2122/// How long the updates feed must be quiet before a build starts. A scan23/// writes thousands of rows and the feed signals each one, so a build24/// per signal would hold the CPU for the length of the scan. Two seconds25/// of quiet turns a burst into one build.26pub const QUIET: Duration = Duration::from_secs(2);2728// How often a waiting thread reads the stop flag, so a dropped source29// ends the thread within this long.30const STOP_POLL: Duration = Duration::from_millis(50);3132/// The index as the frame reads it and the build thread replaces it. The33/// frame's source and its `reader()` are two sources over one replica,34/// and both hold one shelf so one build serves both.35#[derive(Debug, Default)]36pub struct Shelf {37    index: Mutex<Option<Arc<Index>>>,38}3940impl Shelf {41    /// The ranked hits for this text, or nothing until the first build42    /// lands.43    pub fn find(&self, text: &str) -> Vec<Slot> {44        let index = self.held();45        index.map_or_else(Vec::new, |index| index.find(text))46    }4748    /// The index as it stands, or nothing before the first build.49    pub fn held(&self) -> Option<Arc<Index>> {50        self.lock().clone()51    }5253    fn put(&self, index: Index) {54        *self.lock() = Some(Arc::new(index));55    }5657    fn lock(&self) -> std::sync::MutexGuard<'_, Option<Arc<Index>>> {58        self.index.lock().unwrap()59    }60}6162/// Build the index now, and again after every change followed by `quiet`63/// with no further change. The thread runs until the shared state is64/// halted, which the source's `Drop` does.65pub fn follow(shelf: Arc<Shelf>, shared: Arc<Shared>, database: PathBuf, quiet: Duration) {66    thread::spawn(move || {67        let mut built = 0;68        loop {69            if let Some(index) = read(&database) {70                shelf.put(index);71            }72            match wait(&shared, built, quiet) {73                Some(revision) => built = revision,74                None => return,75            }76        }77    });78}7980// One build off a read-only connection of its own. A replica that is81// not there yet, or a read that fails, builds nothing and keeps the82// index that stands; the failure is logged because nothing else reports83// it, and a search that answers nothing looks the same either way.84fn read(database: &Path) -> Option<Index> {85    let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;86    let connection = Connection::open_with_flags(database, flags)87        .inspect_err(|error| {88            eprintln!(89                "media-browser: cannot open the catalog {} to index it: {error}",90                database.display()91            );92        })93        .ok()?;94    read::index(&connection)95        .inspect_err(|error| eprintln!("media-browser: cannot index the catalog: {error}"))96        .ok()97}9899// Wait for a change past the last build, then for the quiet period. The100// answer is the revision the next build covers, or nothing when the101// source stopped.102fn wait(shared: &Shared, built: u64, quiet: Duration) -> Option<u64> {103    let mut revision = shared.revision.lock().unwrap();104    while !shared.stopping() && *revision <= built {105        revision = shared.signal.wait_timeout(revision, STOP_POLL).unwrap().0;106    }107    // Every further change moves the deadline out again, so a scan that108    // signals every few hundred milliseconds gets one build at its end.109    let mut deadline = Instant::now() + quiet;110    loop {111        if shared.stopping() {112            return None;113        }114        let mark = *revision;115        let now = Instant::now();116        if now >= deadline {117            return Some(mark);118        }119        revision = shared120            .signal121            .wait_timeout(revision, (deadline - now).min(STOP_POLL))122            .unwrap()123            .0;124        if *revision != mark {125            deadline = Instant::now() + quiet;126        }127    }128}129130#[cfg(test)]131mod tests {132    use super::*;133134    // A quiet period short enough to measure in a test.135    const BRIEF: Duration = Duration::from_millis(120);136137    #[test]138    fn a_shelf_with_no_index_answers_nothing() {139        let shelf = Shelf::default();140        assert!(shelf.find("batman").is_empty());141        assert!(shelf.held().is_none());142    }143144    #[test]145    fn a_change_rebuilds_only_after_the_feed_falls_quiet() {146        let shared = Shared::default();147        shared.mark();148        let started = Instant::now();149        assert_eq!(wait(&shared, 0, BRIEF), Some(1));150        assert!(started.elapsed() >= BRIEF);151    }152153    #[test]154    fn a_further_change_puts_the_quiet_period_out_again() {155        let shared = Arc::new(Shared::default());156        shared.mark();157        let again = shared.clone();158        thread::spawn(move || {159            thread::sleep(BRIEF / 2);160            again.mark();161        });162        let started = Instant::now();163        assert_eq!(wait(&shared, 0, BRIEF), Some(2));164        assert!(started.elapsed() >= BRIEF + BRIEF / 2);165    }166167    #[test]168    fn a_stopped_source_ends_the_wait() {169        let shared = Arc::new(Shared::default());170        let stopping = shared.clone();171        thread::spawn(move || {172            thread::sleep(BRIEF / 4);173            stopping.halt();174        });175        assert_eq!(wait(&shared, 0, BRIEF), None);176    }177178    #[test]179    fn a_stop_during_the_quiet_period_ends_the_wait() {180        let shared = Arc::new(Shared::default());181        shared.mark();182        let stopping = shared.clone();183        thread::spawn(move || {184            thread::sleep(BRIEF / 4);185            stopping.halt();186        });187        assert_eq!(wait(&shared, 0, BRIEF), None);188    }189190    #[test]191    fn a_catalog_that_is_not_there_indexes_as_nothing() {192        assert!(read(Path::new("/nonexistent/catalog.db")).is_none());193    }194}
src/catalog/sidecar/search/read.rs 100.0%
1// The one read that builds an index off the replica. It walks the2// tables in rung order: titles and their plots, sets and franchises,3// then the episodes and aliases that fold onto them, then the people.4// Every row streams into the builder as it is read, because collecting5// 100,000 rows into Vecs first would double the build's peak memory.67use std::collections::HashMap;89use rusqlite::Connection;1011use crate::catalog::Slot;12use crate::catalog::search::{Builder, Index, Item, Kind, Person, Place, Where};13use crate::catalog::sidecar::item;1415// The place of each item, keyed by library and id, so an episode or an16// alias read later finds the item its strings fold onto.17type Places = HashMap<(String, String), Place>;1819/// The index over one replica: movies, series, sets, franchises, their20/// episodes' titles and plots, their aliases, and every contributor.21pub fn index(connection: &Connection) -> rusqlite::Result<Index> {22    let mut builder = Builder::new();23    let mut places = Places::new();24    titles(connection, &mut builder, &mut places, "movies")?;25    titles(connection, &mut builder, &mut places, "series")?;26    collections(connection, &mut builder, &mut places, "sets")?;27    collections(connection, &mut builder, &mut places, "franchises")?;28    episodes(connection, &mut builder, &places)?;29    aliases(connection, &mut builder, &places)?;30    contributors(connection, &mut builder)?;31    Ok(builder.finish())32}3334// The rows of one item table. The table name is formatted into the SQL,35// and it is a literal from `index`, never a value from outside.36fn titles(37    connection: &Connection,38    builder: &mut Builder,39    places: &mut Places,40    table: &'static str,41) -> rusqlite::Result<()> {42    let sql = format!(43        "SELECT library, id, title, sort_key, released, art, duration, \44                json_extract(body, '$.contentRating'), \45                json_extract(body, '$.tagline'), \46                json_extract(body, '$.plot'), {seasons} \47         FROM {table}",48        seasons = item::seasons(table),49    );50    let mut statement = connection.prepare(&sql)?;51    let mut rows = statement.query([])?;52    while let Some(row) = rows.next()? {53        let library: String = row.get(0)?;54        let id: String = row.get(1)?;55        let title: String = row.get(2)?;56        let slot = Slot {57            library: library.clone(),58            kind: table.to_string(),59            id: id.clone(),60            title: title.clone(),61            released: row.get(4)?,62            art: row.get(5)?,63            duration: row.get(6)?,64            rating: item::text(row, 7)?,65            tagline: item::text(row, 8)?,66            seasons: row.get(10)?,67            ..Slot::default()68        };69        let mut strings = vec![(Where::Title, title)];70        let plot = item::text(row, 9)?;71        if !plot.is_empty() {72            strings.push((Where::Plot, plot));73        }74        let place = builder.add(Item {75            slot,76            sort_key: row.get(3)?,77            kind: Kind::Title,78            strings,79        });80        places.insert((library, id), place);81    }82    Ok(())83}8485// The sets and the franchises. The kind word is the one the wall opens86// each by. A franchise has no release, so its slot's date is empty and87// it ties after every dated title.88fn collections(89    connection: &Connection,90    builder: &mut Builder,91    places: &mut Places,92    table: &'static str,93) -> rusqlite::Result<()> {94    let word = match table {95        "sets" => "sets",96        _ => "franchise",97    };98    let sql = format!("SELECT library, id, title, sort_key, released, art FROM {table}");99    let mut statement = connection.prepare(&sql)?;100    let mut rows = statement.query([])?;101    while let Some(row) = rows.next()? {102        let library: String = row.get(0)?;103        let id: String = row.get(1)?;104        let title: String = row.get(2)?;105        let place = builder.add(Item {106            slot: Slot {107                library: library.clone(),108                kind: word.to_string(),109                id: id.clone(),110                title: title.clone(),111                released: row.get(4)?,112                art: row.get(5)?,113                ..Slot::default()114            },115            sort_key: row.get(3)?,116            kind: Kind::Collection,117            strings: vec![(Where::Title, title)],118        });119        places.insert((library, id), place);120    }121    Ok(())122}123124// Every episode's title and plot, folded onto its series, because a hit125// on an episode opens the series' page. An episode whose series is not126// in the replica is skipped.127fn episodes(128    connection: &Connection,129    builder: &mut Builder,130    places: &Places,131) -> rusqlite::Result<()> {132    let sql = "SELECT library, series, title, json_extract(body, '$.plot') FROM episodes";133    let mut statement = connection.prepare(sql)?;134    let mut rows = statement.query([])?;135    while let Some(row) = rows.next()? {136        let key = (row.get(0)?, row.get(1)?);137        let Some(place) = places.get(&key) else {138            continue;139        };140        builder.fold(*place, Where::EpisodeTitle, &row.get::<_, String>(2)?);141        let plot = item::text(row, 3)?;142        if !plot.is_empty() {143            builder.fold(*place, Where::EpisodePlot, &plot);144        }145    }146    Ok(())147}148149// Every alias, folded onto the item it names. An alias whose item is not150// in the replica is skipped.151fn aliases(152    connection: &Connection,153    builder: &mut Builder,154    places: &Places,155) -> rusqlite::Result<()> {156    let mut statement = connection.prepare("SELECT library, item, alias FROM aliases")?;157    let mut rows = statement.query([])?;158    while let Some(row) = rows.next()? {159        let key = (row.get(0)?, row.get(1)?);160        let Some(place) = places.get(&key) else {161            continue;162        };163        builder.fold(*place, Where::Alias, &row.get::<_, String>(2)?);164    }165    Ok(())166}167168// Every contributor: the path their page reads by, the name a search169// lands on, and whether a headshot file exists under the path.170// One person is one entry across libraries, because their page already171// merges their work across libraries. The read orders by path so every172// library's row for one person arrives together. The first row names173// the person, and a headshot in any library counts.174fn contributors(connection: &Connection, builder: &mut Builder) -> rusqlite::Result<()> {175    let sql = "SELECT library, path, name, headshot FROM contributors \176               ORDER BY path, library";177    let mut statement = connection.prepare(sql)?;178    let mut rows = statement.query([])?;179    let mut held: Option<Person> = None;180    while let Some(row) = rows.next()? {181        let person = Person {182            library: row.get(0)?,183            path: row.get(1)?,184            name: row.get(2)?,185            headshot: row.get::<_, i64>(3)? != 0,186        };187        match held.as_mut() {188            Some(first) if first.path == person.path => first.headshot |= person.headshot,189            _ => {190                if let Some(first) = held.replace(person) {191                    builder.person(first);192                }193            }194        }195    }196    if let Some(first) = held {197        builder.person(first);198    }199    Ok(())200}
src/catalog/sidecar/series.rs 100.0%
1// The two reads behind a series' page. The first reads the series row,2// its body, and the count of its seasons in one statement. The second3// lists every episode in aired order, with the plot the header draws4// while that episode has focus.56use rusqlite::Connection;78use super::collect;9use super::item;10use crate::catalog::{Episode, SeriesDetails, art};1112/// One series' details, as a list of one, or an empty list where the13/// library holds no series under that id.14pub fn series(15    connection: &Connection,16    library: &str,17    id: &str,18) -> rusqlite::Result<Vec<SeriesDetails>> {19    let sql = "SELECT item.title, item.released, item.duration, \20                      json_extract(item.body, '$.contentRating'), \21                      json_extract(item.body, '$.tagline'), \22                      json_extract(item.body, '$.plot'), \23                      json_extract(item.body, '$.genres'), \24                      json_extract(item.body, '$.creators'), \25                      json_extract(item.body, '$.cast'), \26                      json_extract(item.body, '$.studios'), \27                      json_extract(item.body, '$.ratings'), \28                      (SELECT COUNT(DISTINCT episodes.season) FROM episodes \29                        WHERE episodes.library = item.library \30                        AND episodes.series = item.id) \31               FROM series item WHERE item.library = ? AND item.id = ?";32    let mut found = collect(connection, sql, &[&library, &id], |row| {33        Ok(SeriesDetails {34            title: row.get(0)?,35            released: row.get(1)?,36            duration: row.get(2)?,37            rating: item::text(row, 3)?,38            tagline: item::text(row, 4)?,39            plot: item::text(row, 5)?,40            genres: item::strings(&item::text(row, 6)?),41            creators: item::strings(&item::text(row, 7)?),42            cast: item::credits(&item::text(row, 8)?),43            studios: item::strings(&item::text(row, 9)?),44            ratings: item::ratings(&item::text(row, 10)?),45            seasons: row.get(11)?,46            backdrop: String::new(),47            logo: String::new(),48        })49    })?;5051    // A series page draws no trailer button, so the two image roles are52    // all it keeps of the read by role.53    if let Some(details) = found.first_mut() {54        for (role, path) in item::art(connection, library, id)? {55            match role.as_str() {56                "backdrop" => details.backdrop = path,57                "logo" => details.logo = path,58                _ => {}59            }60        }61    }62    Ok(found)63}6465/// Every episode of one series, in aired order, through the index on66/// (library, series, season, episode). The series' own art comes with67/// every row, because an episode the catalog holds no still for draws68/// its series' art in the still's place.69pub fn episodes(70    connection: &Connection,71    library: &str,72    id: &str,73) -> rusqlite::Result<Vec<Episode>> {74    let sql = "SELECT episodes.season, episodes.episode, episodes.title, \75                      episodes.released, episodes.duration, \76                      json_extract(episodes.body, '$.plot'), episodes.art, episodes.id, \77                      IFNULL(series.art, ''), IFNULL(series.arts, '[]') \78               FROM episodes \79               LEFT JOIN series ON series.library = episodes.library \80               AND series.id = episodes.series \81               WHERE episodes.library = ? AND episodes.series = ? \82               ORDER BY episodes.season, episodes.episode";83    collect(connection, sql, &[&library, &id], |row| {84        let still = art::still(85            &item::text(row, 6)?,86            &item::text(row, 8)?,87            &item::strings(&item::text(row, 9)?),88        );89        Ok(Episode {90            season: row.get(0)?,91            episode: row.get(1)?,92            title: row.get(2)?,93            released: row.get(3)?,94            duration: row.get(4)?,95            plot: item::text(row, 5)?,96            art: still,97            id: row.get(7)?,98        })99    })100}
src/catalog/sidecar/updates.rs 95.7%
1// One stream per item table follows `/v1/updates/<table>` and folds2// every event into one changed flag. The stream sends nothing while3// the catalog is quiet, not even a heartbeat, so a read timeout means4// idleness, and only EOF or a real error means the stream dropped.56use std::io::{BufRead, BufReader, ErrorKind};7use std::sync::atomic::{AtomicBool, Ordering};8use std::sync::{Arc, Condvar, Mutex};9use std::thread;10use std::time::{Duration, Instant};1112use crate::harness::Waker;1314// The connect timeout bounds one connection attempt. The read timeout15// bounds one socket read; on a quiet stream it is how often the thread16// polls the stop flag, and it says nothing about liveness.17const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);18const READ_TIMEOUT: Duration = Duration::from_secs(30);19const BACKOFF_FLOOR: Duration = Duration::from_millis(250);20const BACKOFF_CEILING: Duration = Duration::from_secs(5);21const STOP_POLL: Duration = Duration::from_millis(50);2223// The one changed flag and the one waker that all three streams share24// with the source, plus the revision and the signal the search index's25// build thread waits on. The browser clears `changed` when it re-reads,26// so the build thread cannot share that flag without one side losing a27// change. A count of changes lets the build thread compare against the28// revision it last built, and the condvar wakes it on each change.29pub(super) struct Shared {30    pub changed: AtomicBool,31    pub wake: Mutex<Option<Waker>>,32    pub stop: AtomicBool,33    pub revision: Mutex<u64>,34    pub signal: Condvar,35}3637impl Default for Shared {38    fn default() -> Self {39        Self {40            changed: AtomicBool::new(false),41            wake: Mutex::new(None),42            stop: AtomicBool::new(false),43            revision: Mutex::new(0),44            signal: Condvar::new(),45        }46    }47}4849impl Shared {50    pub(super) fn stopping(&self) -> bool {51        self.stop.load(Ordering::Acquire)52    }5354    // Raise the stop flag and wake every waiting thread, so a dropped55    // source ends its build thread now instead of at the next poll.56    pub(super) fn halt(&self) {57        self.stop.store(true, Ordering::Release);58        self.signal.notify_all();59    }6061    // The flag is set before the waker fires, so a woken loop always62    // reads changed as true.63    pub(super) fn mark(&self) {64        self.changed.store(true, Ordering::Release);65        *self66            .revision67            .lock()68            .unwrap_or_else(|held| held.into_inner()) += 1;69        self.signal.notify_all();70        let wake = self.wake.lock().unwrap().clone();71        if let Some(wake) = wake {72            wake();73        }74    }75}7677// The thread runs for the life of the source. The backoff resets once78// a stream answers, so a healthy sidecar is rejoined at the floor79// after a single drop.80pub(super) fn follow(shared: Arc<Shared>, base: String, table: &'static str) {81    thread::spawn(move || {82        let agent = ureq::AgentBuilder::new()83            .timeout_connect(CONNECT_TIMEOUT)84            .timeout_read(READ_TIMEOUT)85            .build();86        let url = format!("{base}/v1/updates/{table}");87        let mut backoff = BACKOFF_FLOOR;88        while !shared.stopping() {89            if stream(&agent, &url, &shared) {90                backoff = BACKOFF_FLOOR;91            }92            pause(&shared, backoff);93            backoff = (backoff * 2).min(BACKOFF_CEILING);94        }95    });96}9798// A stream that ends marks changed, because the events between its end99// and the next stream are gone, and only a full re-read covers them. A100// failed connect does not mark, because the end that preceded it101// already did.102fn stream(agent: &ureq::Agent, url: &str, shared: &Shared) -> bool {103    let Ok(response) = agent.post(url).call() else {104        return false;105    };106    let mut reader = BufReader::new(response.into_reader());107    let mut line = String::new();108    loop {109        if shared.stopping() {110            return true;111        }112        match reader.read_line(&mut line) {113            Ok(0) => break,114            Ok(_) => {115                if !line.trim().is_empty() {116                    shared.mark();117                }118                line.clear();119            }120            Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {121                continue;122            }123            Err(_) => break,124        }125    }126    shared.mark();127    true128}129130// The pause polls the stop flag, so a dropped source ends a131// backing-off thread within one poll interval.132fn pause(shared: &Shared, backoff: Duration) {133    let deadline = Instant::now() + backoff;134    while !shared.stopping() && Instant::now() < deadline {135        thread::sleep(STOP_POLL);136    }137}138139#[cfg(test)]140mod tests;
src/clock.rs 100.0%
1// The wall-clock reading, in the zone the pod's `TZ` names, and the one2// libc read this crate makes of the local time. The day's draw reads its3// date through the same call.45/// A wall-clock reading, to the minute. The browser redraws once a6/// minute, so the reading turns.7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]8pub struct Time {9    pub hour: u8,10    pub minute: u8,11}1213impl Time {14    /// A twelve-hour reading with no leading zero and a lowercase suffix,15    /// as in "3:01 pm". The room's idle screen draws the same reading, so16    /// the two screens read the same at the same minute.17    pub fn twelve_hour(self) -> String {18        let suffix = if self.hour < 12 { "am" } else { "pm" };19        let twelve = match self.hour % 12 {20            0 => 12,21            hour => hour,22        };23        format!("{twelve}:{:02} {suffix}", self.minute)24    }25}2627/// The time now, in the zone `TZ` names.28pub fn now() -> Time {29    let local = local();30    Time {31        hour: local.tm_hour as u8,32        minute: local.tm_min as u8,33    }34}3536/// The seconds from now until the minute turns, so a caller schedules37/// one redraw on the turn and none between.38pub fn seconds_to_next_minute() -> f64 {39    to_next_minute(local().tm_sec)40}4142// The seconds from one second of a minute to the next minute. A leap43// second reads 60 and takes the wait of the second before it, so no44// reading schedules a frame at the second it is already on.45fn to_next_minute(second: i32) -> f64 {46    f64::from(60 - second.clamp(0, 59))47}4849/// The broken-down local time. The standard library has no zones, and50/// glibc's `localtime_r` reads `TZ` and `/etc/localtime`, which the operator51/// sets on the pod, so this is one libc call and not a date crate.52pub(crate) fn local() -> libc::tm {53    let now = unsafe { libc::time(std::ptr::null_mut()) };54    let mut local: libc::tm = unsafe { std::mem::zeroed() };55    unsafe { libc::localtime_r(&now, &mut local) };56    local57}5859#[cfg(test)]60mod tests {61    use super::*;6263    fn at(hour: u8, minute: u8) -> String {64        Time { hour, minute }.twelve_hour()65    }6667    #[test]68    fn the_afternoon_reads_with_no_leading_zero() {69        assert_eq!(at(15, 1), "3:01 pm");70        assert_eq!(at(13, 45), "1:45 pm");71    }7273    #[test]74    fn the_morning_reads_the_same_way() {75        assert_eq!(at(9, 30), "9:30 am");76        assert_eq!(at(11, 59), "11:59 am");77    }7879    #[test]80    fn both_ends_of_the_day_read_twelve() {81        assert_eq!(at(0, 0), "12:00 am");82        assert_eq!(at(12, 0), "12:00 pm");83    }8485    #[test]86    fn the_reading_is_a_time_of_day() {87        let time = now();88        assert!(time.hour < 24);89        assert!(time.minute < 60);90    }9192    #[test]93    fn the_wait_runs_to_the_turn_of_the_minute() {94        for (second, wait) in [(0, 60.0), (1, 59.0), (30, 30.0), (59, 1.0), (60, 1.0)] {95            assert_eq!(to_next_minute(second), wait, "{second}");96        }97        let wait = seconds_to_next_minute();98        assert!(wait > 0.0 && wait <= 60.0, "{wait}");99    }100}
src/focus.rs 100.0%
1// Focus movement as pure functions of an index and a count, so the2// arrow logic is tested with numbers and never a window.34/// The next focus on a wall of `columns` columns; left and right walk5/// the whole order, up and down move by a row, and every move clamps inside6/// the count.7pub fn wall(index: usize, count: usize, columns: usize, key: &str) -> usize {8    if count == 0 {9        return 0;10    }11    let last = count - 1;12    match key {13        "left" => index.saturating_sub(1),14        "right" => (index + 1).min(last),15        "up" => {16            if index >= columns {17                index - columns18            } else {19                index20            }21        }22        "down" => {23            // A move down from the last row would leave the wall, and24            // a move into a shorter last row lands on its last slot.25            if index / columns < last / columns {26                (index + columns).min(last)27            } else {28                index29            }30        }31        _ => index,32    }33}3435/// One run of slots in a wall of runs, such as one season's episodes:36/// where the run starts in the whole order, and how many slots it holds.37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]38pub struct Run {39    /// The index the run's first slot has in the whole order.40    pub first: usize,41    /// How many slots the run holds.42    pub count: usize,43}4445impl Run {46    // The index of the run's last slot.47    fn last(&self) -> usize {48        self.first + self.count - 149    }5051    // The slot of this run at this row and column, or the run's last slot52    // where that row is shorter.53    fn column(&self, row: usize, column: usize, columns: usize) -> usize {54        (self.first + row * columns + column).min(self.last())55    }5657    // How many rows the run fills at this column count.58    fn rows(&self, columns: usize) -> usize {59        self.count.div_ceil(columns)60    }61}6263/// The next focus on a wall of runs. Left and right stay inside the run64/// that holds focus. Up and down move by a row and cross into the run65/// above or below at the same column, so a divider between two runs stops66/// nothing.67pub fn sectioned(index: usize, runs: &[Run], columns: usize, key: &str) -> usize {68    let Some(here) = runs69        .iter()70        .position(|run| run.count > 0 && index >= run.first && index <= run.last())71    else {72        return index;73    };74    let run = runs[here];75    let local = index - run.first;76    let (row, column) = (local / columns, local % columns);77    match key {78        "left" => index.saturating_sub(1).max(run.first),79        "right" => (index + 1).min(run.last()),80        "up" if row > 0 => index - columns,81        "up" => match runs[..here].iter().rev().find(|run| run.count > 0) {82            Some(above) => above.column(above.rows(columns) - 1, column, columns),83            None => index,84        },85        "down" if row + 1 < run.rows(columns) => run.column(row + 1, column, columns),86        "down" => match runs[here + 1..].iter().find(|run| run.count > 0) {87            Some(below) => below.column(0, column, columns),88            None => index,89        },90        _ => index,91    }92}9394/// The next focus on a row of buttons, controls, or strip posters; only95/// left and right move, clamped inside the count.96pub fn row(index: usize, count: usize, key: &str) -> usize {97    if count == 0 {98        return 0;99    }100    match key {101        "left" => index.saturating_sub(1),102        "right" => (index + 1).min(count - 1),103        _ => index,104    }105}106107/// The next focus on a list; only up and down move, clamped inside108/// the count.109pub fn list(index: usize, count: usize, key: &str) -> usize {110    if count == 0 {111        return 0;112    }113    match key {114        "up" => index.saturating_sub(1),115        "down" => (index + 1).min(count - 1),116        _ => index,117    }118}119120#[cfg(test)]121mod tests {122    use super::*;123124    #[test]125    fn right_and_left_walk_the_wall() {126        assert_eq!(wall(0, 10, 3, "right"), 1);127        assert_eq!(wall(2, 10, 3, "right"), 3);128        assert_eq!(wall(9, 10, 3, "right"), 9);129        assert_eq!(wall(3, 10, 3, "left"), 2);130        assert_eq!(wall(0, 10, 3, "left"), 0);131    }132133    #[test]134    fn up_and_down_move_by_a_row() {135        assert_eq!(wall(4, 10, 3, "down"), 7);136        assert_eq!(wall(4, 10, 3, "up"), 1);137        assert_eq!(wall(1, 10, 3, "up"), 1);138    }139140    #[test]141    fn down_into_a_short_last_row_lands_on_the_last_slot() {142        assert_eq!(wall(8, 10, 3, "down"), 9);143    }144145    #[test]146    fn down_from_the_last_row_stays() {147        assert_eq!(wall(9, 10, 3, "down"), 9);148    }149150    #[test]151    fn an_empty_wall_holds_focus_at_zero() {152        assert_eq!(wall(0, 0, 3, "down"), 0);153    }154155    #[test]156    fn an_unknown_key_moves_nothing_on_the_wall() {157        assert_eq!(wall(4, 10, 3, "x"), 4);158    }159160    // Three seasons of eight, nine, and ten episodes, four across, which161    // is what a series page holds.162    fn seasons() -> Vec<Run> {163        vec![164            Run { first: 0, count: 8 },165            Run { first: 8, count: 9 },166            Run {167                first: 17,168                count: 10,169            },170        ]171    }172173    #[test]174    fn left_and_right_stay_inside_one_run() {175        let runs = seasons();176        assert_eq!(sectioned(0, &runs, 4, "right"), 1);177        assert_eq!(sectioned(7, &runs, 4, "right"), 7);178        assert_eq!(sectioned(3, &runs, 4, "left"), 2);179        assert_eq!(sectioned(8, &runs, 4, "left"), 8);180    }181182    #[test]183    fn up_and_down_move_by_a_row_inside_a_run() {184        let runs = seasons();185        assert_eq!(sectioned(0, &runs, 4, "down"), 4);186        assert_eq!(sectioned(6, &runs, 4, "up"), 2);187    }188189    #[test]190    fn down_and_up_cross_into_the_next_run_at_the_same_column() {191        let runs = seasons();192        assert_eq!(sectioned(5, &runs, 4, "down"), 9);193        assert_eq!(sectioned(9, &runs, 4, "up"), 5);194        assert_eq!(sectioned(16, &runs, 4, "down"), 17);195    }196197    #[test]198    fn a_crossing_into_a_shorter_row_lands_on_its_last_slot() {199        let runs = seasons();200        assert_eq!(sectioned(15, &runs, 4, "down"), 16);201        assert_eq!(sectioned(3, &runs, 4, "down"), 7);202        assert_eq!(sectioned(24, &runs, 4, "down"), 26);203    }204205    #[test]206    fn the_first_and_the_last_row_of_a_page_hold_focus() {207        let runs = seasons();208        assert_eq!(sectioned(2, &runs, 4, "up"), 2);209        assert_eq!(sectioned(26, &runs, 4, "down"), 26);210        assert_eq!(sectioned(2, &runs, 4, "x"), 2);211    }212213    #[test]214    fn a_run_with_no_slots_is_crossed_over() {215        let runs = vec![216            Run { first: 0, count: 4 },217            Run { first: 4, count: 0 },218            Run { first: 4, count: 4 },219        ];220        assert_eq!(sectioned(0, &runs, 4, "down"), 4);221        assert_eq!(sectioned(4, &runs, 4, "up"), 0);222    }223224    #[test]225    fn a_page_with_no_runs_holds_focus() {226        assert_eq!(sectioned(0, &[], 4, "down"), 0);227    }228229    #[test]230    fn a_row_moves_left_and_right_and_clamps() {231        assert_eq!(row(0, 3, "right"), 1);232        assert_eq!(row(2, 3, "right"), 2);233        assert_eq!(row(1, 3, "left"), 0);234        assert_eq!(row(0, 3, "left"), 0);235    }236237    #[test]238    fn a_row_ignores_up_and_down() {239        assert_eq!(row(1, 3, "up"), 1);240        assert_eq!(row(1, 3, "down"), 1);241    }242243    #[test]244    fn an_empty_row_holds_focus_at_zero() {245        assert_eq!(row(0, 0, "right"), 0);246    }247248    #[test]249    fn a_list_moves_up_and_down_and_clamps() {250        assert_eq!(list(0, 3, "down"), 1);251        assert_eq!(list(2, 3, "down"), 2);252        assert_eq!(list(1, 3, "up"), 0);253        assert_eq!(list(0, 3, "up"), 0);254    }255256    #[test]257    fn a_list_ignores_left_and_right() {258        assert_eq!(list(1, 3, "left"), 1);259        assert_eq!(list(1, 3, "right"), 1);260    }261262    #[test]263    fn an_empty_list_holds_focus_at_zero() {264        assert_eq!(list(0, 0, "down"), 0);265    }266}
src/harness.rs 92.9%
1//! The media browser's harness: the flags, the frame loop, and the2//! measurements.3//!4//! A screen here is a piece of state with a clock and a view. The harness owns5//! everything around it: the winit window, the wgpu surface, the iced renderer,6//! the scripted key timeline, the frame capture, and the statistics file.7//!8//! The harness drives its own winit loop instead of calling `iced::application`9//! because it must reach the renderer directly. A frame capture and a frame10//! clock are not part of the high-level entry point.1112mod app;13pub mod capture;14pub mod frame;15pub mod graphics;16pub mod options;17pub mod stats;18pub mod timeline;19pub mod watchdog;2021use std::path::PathBuf;22use std::sync::Arc;2324use iced_wgpu::graphics::Viewport;25use iced_wgpu::{Renderer, wgpu};26use iced_winit::Clipboard;27use iced_winit::core::{Color, Element, Event, Theme};28use iced_winit::runtime::user_interface;29use iced_winit::winit;3031use winit::event_loop::EventLoop;32use winit::keyboard::{Key, ModifiersState, NamedKey};3334use app::{App, State};35use capture::Captures;36pub use options::{Invocation, Options};37use stats::Stats;38use timeline::Timeline;39use watchdog::Watchdog;4041use crate::catalog::search::Size;42use crate::posters::PosterCounts;4344/// The word that ends a run, from the keyboard or from a script. It is a45/// word no screen binds and `key_of` never produces, so no remote can46/// end a run. A letter cannot be the word because every letter opens or47/// types into the search wall. On a laptop the Escape key gives this48/// word and the forward slash gives back; a remote's KEY_BACK and49/// KEY_ESC still reach the browser as its own escape.50pub const QUIT: &str = "quit";5152/// A handle that wakes the screen's event loop from any thread. It is53/// the crate's own type, because the bus hands one back to the browser54/// through [`media_screen::Bus::wake_on_delivery`], and two aliases of55/// one shape would drift.56pub use media_screen::Waker;5758/// What the harness needs from a screen. The harness advances the clock, hands59/// over each key the script or the keyboard produced, and asks for a view.60pub trait Screen {61    /// The messages the screen's own widgets emit.62    type Message: std::fmt::Debug + Send + 'static;6364    /// The color behind everything. It is the clear color of the frame, so it65    /// also fills a capture.66    fn background(&self) -> Color {67        Color::BLACK68    }6970    /// One key press, named the way the script names it: a single71    /// character, or one of the names `key_name` gives the arrows,72    /// `enter`, `escape`, `backspace`, `home`, and `search`. The answer73    /// is whether the press changed the screen, the way `pump` answers74    /// whether a delivery did, so a press that moves nothing draws no75    /// frame.76    fn key(&mut self, name: &str) -> bool;7778    /// Fold in what the screen's own sources delivered since the last call,79    /// at `at` seconds on the clock. The answer is whether anything folded,80    /// so the harness drops a stale schedule and asks the screen again.81    ///82    /// The harness calls this on every wake of the loop, not only on a frame.83    /// A covered Wayland surface receives no frame callbacks, so a screen84    /// that read its sources only when it drew would go deaf for exactly as85    /// long as something covers it.86    fn pump(&mut self, _at: f64) -> bool {87        false88    }8990    /// Take a handle that wakes the loop from any thread. A screen with a91    /// source of its own hands it to that source, so a delivery wakes the92    /// loop the moment it lands and [`Screen::pump`] folds it in93    /// milliseconds. Without the wake, a message waits for the next94    /// scheduled second, and a person's press shows up to a second late.95    fn wake_by(&mut self, _wake: Waker) {}9697    /// Move the screen's clock to `at` seconds since the first frame. Every98    /// animation reads that clock, so a frame is a pure function of it.99    fn tick(&mut self, at: f64);100101    /// The view for the clock's current position.102    fn view(&self) -> Element<'_, Self::Message, Theme, Renderer>;103104    /// The second at which the screen next changes, on the same clock105    /// [`Screen::tick`] reads. `at` is what that clock reads now, at or after106    /// the second of the last frame. The harness sleeps until the second this107    /// answer names, so a screen whose clock draws no seconds redraws once a108    /// minute rather than sixty times a second.109    ///110    /// `None` says nothing on this screen is scheduled, and the loop then111    /// draws on an event alone. A screen that folds in a source of its own,112    /// such as a bus, must not answer `None`.113    ///114    /// The default answers `at`, which is a change on the frame already drawn,115    /// so a screen that states nothing draws every pass the loop takes.116    fn next_frame(&self, at: f64) -> Option<f64> {117        Some(at)118    }119120    /// Handle a message from a widget.121    fn update(&mut self, _message: Self::Message) {}122123    /// Whether the screen asked for a fresh Wayland surface. The harness reads124    /// this on every wake of the loop, and the read clears the request, so one125    /// ask maps one new surface.126    fn surface_due(&mut self) -> bool {127        false128    }129130    /// Disk-cache hits and source decode attempts for this run.131    fn poster_counts(&self) -> PosterCounts {132        PosterCounts::default()133    }134135    /// How large the screen's search index is, or nothing where the136    /// screen holds none. The stats file reports it at exit.137    fn index_size(&mut self) -> Option<Size> {138        None139    }140141    /// The new surface is up, on the frame at `at` seconds.142    fn surfaced(&mut self, _at: f64) {}143}144145/// Run a screen to the end of its script and write what it measured.146pub fn run<S: Screen + 'static>(mut screen: S, options: Options) -> Result<(), String> {147    // The brand's faces go into the toolkit's font system before anything148    // shapes a run of text. `views::text::measured` runs the shaper on the149    // read path, and a measurement taken against a fallback face would150    // place every line after it wrong.151    liken_iced::font::load();152153    // The launch is measured from here, so the time to the first frame154    // counts the whole life of the process: the wgpu setup, the first155    // window, and the first draw.156    //157    // the watchdog's grace runs from the same moment, because a client158    // that never reaches a window has drawn nothing since the launch.159    let launched = std::time::Instant::now();160    let watchdog = Watchdog::new(options.window_grace, launched);161162    let event_loop = match EventLoop::new() {163        Ok(event_loop) => event_loop,164        // A client with no connection to a compositor has no window.165        Err(error) => {166            watchdog.expire(&format!("no connection to the compositor: {error}"));167            return Err(error.to_string());168        }169    };170171    // The screen's own sources wake the loop through this proxy, so a172    // delivery folds the moment it lands. The event it sends carries173    // nothing: the wake is the message, and `about_to_wait` pumps on it.174    // The browser hands it to its catalog source and its poster store.175    let proxy = event_loop.create_proxy();176    screen.wake_by(Arc::new(move || {177        let _ = proxy.send_event(());178    }));179180    let mut app = App {181        watchdog,182        state: State::Loading {183            screen: Some(screen),184            // The options are boxed so the state of a run that has not185            // opened a window yet is no larger than the state of one that has.186            options: Box::new(options),187            launched,188        },189    };190191    event_loop192        .run_app(&mut app)193        .map_err(|error| error.to_string())194}195196/// The run, once the compositor has given the process a window.197pub struct Ready<S: Screen> {198    pub(crate) screen: S,199    pub(crate) timeline: Timeline,200    // Where the measurements go at exit, from --stats.201    pub(crate) stats_path: Option<PathBuf>,202    pub(crate) window: Arc<winit::window::Window>,203    /// The instance every surface of this run comes from, held because the204    /// re-present creates a second one.205    pub(crate) instance: wgpu::Instance,206    pub(crate) device: wgpu::Device,207    pub(crate) surface: wgpu::Surface<'static>,208    pub(crate) format: wgpu::TextureFormat,209    pub(crate) renderer: Renderer,210    pub(crate) viewport: Viewport,211    pub(crate) cache: user_interface::Cache,212    pub(crate) clipboard: Clipboard,213    pub(crate) modifiers: ModifiersState,214    pub(crate) events: Vec<Event>,215    pub(crate) resized: bool,216    /// The app-id every window of this run asks for, held because the217    /// re-present maps a second window.218    pub(crate) app_id: String,219    /// Whether a present is still waiting on a window the compositor has not220    /// given yet.221    pub(crate) surface_pending: bool,222    /// When the process began, for the time to the first frame.223    pub(crate) launched: std::time::Instant,224    /// The second the screen named for its next change, while the loop sleeps225    /// toward it. The harness holds that second rather than asking again on226    /// every pass, because a fresh answer names the change after it and the227    /// frame would never be drawn.228    pub(crate) scheduled: Option<f64>,229    /// The timeline's zero: the first frame, not the launch. A compositor230    /// can take seconds to give a window, and a script or a capture that231    /// counted from the launch would fire on the first frame, before a232    /// resize arrived and before anything was drawn.233    pub(crate) start: Option<std::time::Instant>,234    /// The second of the last frame. The pace holds the next one at least235    /// [`frame::STEP`] after it, because nothing else caps the rate: the236    /// surface presents without vsync, so an animation that asked for a237    /// frame on every pass would draw as fast as the loop can spin.238    pub(crate) drawn: f64,239    /// Whether the frame on the glass shows old state. A fold and a key both240    /// set it, because the elements schedule their own motion and not the241    /// content: a level that changes while its row stands still would242    /// otherwise wait for the next scheduled second, and a press must show243    /// on the next frame.244    pub(crate) stale: bool,245    /// The frames this run writes to disk, from `--capture`. A run that named246    /// no directory captures nothing.247    pub(crate) captures: Option<Captures>,248    pub(crate) stats: Stats,249    pub(crate) finished: bool,250}251252/// The script's name for a key. The local layout in full: letters and253/// digits type, the space bar types a space, Backspace deletes, the254/// arrows move, Enter selects, the forward slash is back, the backtick255/// and the Home key are home, F3 is search, and Escape ends the run.256pub fn key_name(key: &Key) -> Option<String> {257    match key {258        // A local run needs one key that ends it, and every letter now259        // reaches the search wall, so Escape ends the run and the slash260        // is back.261        Key::Character(text) if text == "/" => Some("escape".into()),262        // A laptop keyboard may have no Home key, so the backtick is home263        // as well.264        Key::Character(text) if text == "`" => Some("home".into()),265        Key::Character(text) => Some(text.to_lowercase()),266        Key::Named(NamedKey::ArrowUp) => Some("up".into()),267        Key::Named(NamedKey::ArrowDown) => Some("down".into()),268        Key::Named(NamedKey::ArrowLeft) => Some("left".into()),269        Key::Named(NamedKey::ArrowRight) => Some("right".into()),270        Key::Named(NamedKey::Enter) => Some("enter".into()),271        Key::Named(NamedKey::Escape) => Some(QUIT.into()),272        Key::Named(NamedKey::Backspace) => Some("backspace".into()),273        // A remote sends KEY_HOMEPAGE and KEY_SEARCH, and a keyboard has274        // no key of either name, so the Home key and the search keys a275        // keyboard does have stand in for them.276        Key::Named(NamedKey::Home) => Some("home".into()),277        Key::Named(NamedKey::F3 | NamedKey::BrowserSearch | NamedKey::Find) => {278            Some("search".into())279        }280        // The space bar gives the word KEY_SPACE gives, so a space from a281        // keyboard and a space from a remote are one word.282        Key::Named(NamedKey::Space) => Some(" ".into()),283        _ => None,284    }285}286287#[cfg(test)]288mod tests {289    use super::*;290    use iced_winit::winit::keyboard::SmolStr;291292    #[test]293    fn a_letter_names_itself() {294        assert_eq!(295            key_name(&Key::Character(SmolStr::new("Q"))),296            Some("q".to_string())297        );298    }299300    #[test]301    fn an_arrow_carries_the_script_name() {302        assert_eq!(303            key_name(&Key::Named(NamedKey::ArrowLeft)),304            Some("left".to_string())305        );306    }307308    #[test]309    fn the_navigation_keys_carry_their_script_names() {310        assert_eq!(311            key_name(&Key::Named(NamedKey::Enter)),312            Some("enter".to_string())313        );314        assert_eq!(315            key_name(&Key::Named(NamedKey::Backspace)),316            Some("backspace".to_string())317        );318    }319320    // The two characters a local run binds a word to, and one letter to321    // show that every other character is itself.322    const TYPED: [(&str, &str); 3] = [("/", "escape"), ("`", "home"), ("q", "q")];323324    #[test]325    fn escape_ends_the_run_and_the_two_bound_characters_carry_their_words() {326        assert_eq!(key_name(&Key::Named(NamedKey::Escape)), Some(QUIT.into()));327        for (character, word) in TYPED {328            assert_eq!(329                key_name(&Key::Character(SmolStr::new(character))),330                Some(word.to_string()),331                "{character}"332            );333        }334    }335336    #[test]337    fn the_letter_the_run_once_ended_on_reaches_the_screen_as_a_letter() {338        assert_eq!(339            key_name(&Key::Character(SmolStr::new("Q"))),340            Some("q".to_string())341        );342        assert_ne!(QUIT, "q");343    }344345    // The named keys a laptop reaches home, search, and the space346    // through, each with the word a remote's key gives.347    const NAMED: [(NamedKey, &str); 5] = [348        (NamedKey::Home, "home"),349        (NamedKey::F3, "search"),350        (NamedKey::BrowserSearch, "search"),351        (NamedKey::Find, "search"),352        (NamedKey::Space, " "),353    ];354355    #[test]356    fn the_home_and_search_keys_carry_the_words_a_remote_sends() {357        for (key, name) in NAMED {358            assert_eq!(359                key_name(&Key::Named(key)),360                Some(name.to_string()),361                "{key:?}"362            );363        }364    }365366    #[test]367    fn a_key_with_no_script_name_is_ignored() {368        assert_eq!(key_name(&Key::Named(NamedKey::F1)), None);369    }370371    struct Still;372373    impl Screen for Still {374        type Message = ();375376        fn key(&mut self, _name: &str) -> bool {377            true378        }379380        fn tick(&mut self, _at: f64) {}381382        fn view(&self) -> Element<'_, (), Theme, Renderer> {383            iced_widget::Space::new().into()384        }385    }386387    #[test]388    fn a_screen_that_states_nothing_draws_on_black_folds_nothing_and_redraws_every_pass() {389        let mut still = Still;390        assert_eq!(still.background(), Color::BLACK);391        assert!(!still.pump(1.0));392        assert_eq!(still.next_frame(3.5), Some(3.5));393        assert!(!still.surface_due());394        assert_eq!(still.poster_counts(), PosterCounts::default());395        assert_eq!(still.index_size(), None);396        still.wake_by(Arc::new(|| {}));397        still.update(());398        still.surfaced(2.0);399        assert!(still.key("q"));400        assert!(still.key(QUIT));401        still.tick(2.0);402    }403}
src/harness/app.rs 80.8%
1// The winit application handler: the window the compositor gives, the events2// it sends, and the pass the loop takes between them. The frame itself is in3// `frame.rs`, and this file is what calls it.45use iced_wgpu::graphics::Viewport;6use iced_winit::core::Size;7use iced_winit::runtime::user_interface;8use iced_winit::winit;9use iced_winit::{Clipboard, conversion};1011use winit::event::WindowEvent;12use winit::event_loop::ControlFlow;13use winit::keyboard::ModifiersState;14use winit::window::WindowId;1516use super::capture::Captures;17use super::stats::Stats;18use super::timeline::Timeline;19use super::watchdog::Watchdog;20use super::{Options, Ready, Screen, graphics, key_name};2122/// The run and the one thing that outlives its window: the watchdog,23/// which runs before the first window and after the last one.24pub(super) struct App<S: Screen> {25    pub(super) watchdog: Watchdog,26    pub(super) state: State<S>,27}2829pub(super) enum State<S: Screen> {30    Loading {31        screen: Option<S>,32        options: Box<Options>,33        launched: std::time::Instant,34    },35    Ready(Box<Ready<S>>),36    /// The run is over and the graphics are already gone.37    Done,38}3940impl<S: Screen> winit::application::ApplicationHandler for App<S> {41    fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {42        let App { watchdog, state } = self;43        let State::Loading {44            screen,45            options,46            launched,47        } = state48        else {49            return;50        };51        let launched = *launched;5253        // The window comes first, so a compositor that gives none54        // leaves the screen and the flags where they are and the watchdog55        // running.56        let Some(graphics) = graphics::open(event_loop, options.size, &options.app_id) else {57            return;58        };59        watchdog.present();6061        let Options {62            script,63            capture_dir,64            capture_at,65            stats: stats_path,66            quit_after,67            app_id,68            // The binary reads the catalog flags before the run, so69            // the harness carries them and uses none of them.70            ..71        } = *std::mem::take(options);7273        let viewport = Viewport::with_physical_size(74            Size::new(graphics.size.0, graphics.size.1),75            graphics.window.scale_factor() as f32,76        );77        let stats = Stats::new(78            graphics.backend.clone(),79            graphics.adapter.clone(),80            graphics.size,81        );8283        let screen = screen.take().expect("one window per run");84        *state = State::Ready(Box::new(Ready {85            screen,86            timeline: Timeline::new(script, quit_after),87            stats_path,88            window: graphics.window,89            instance: graphics.instance,90            device: graphics.device,91            surface: graphics.surface,92            format: graphics.format,93            renderer: graphics.renderer,94            viewport,95            cache: user_interface::Cache::new(),96            clipboard: Clipboard::unconnected(),97            modifiers: ModifiersState::default(),98            events: Vec::new(),99            resized: false,100            app_id,101            surface_pending: false,102            launched,103            scheduled: None,104            start: None,105            drawn: 0.0,106            stale: false,107            captures: Captures::requested(capture_dir, capture_at),108            stats,109            finished: false,110        }));111    }112113    fn window_event(114        &mut self,115        event_loop: &winit::event_loop::ActiveEventLoop,116        id: WindowId,117        event: WindowEvent,118    ) {119        let App { watchdog, state } = self;120        let State::Ready(ready) = state else {121            return;122        };123124        match &event {125            WindowEvent::RedrawRequested => {126                ready.frame(event_loop);127                return;128            }129            // The window went away, which is what a compositor restart130            // under a running pod leaves behind. The grace starts again, and131            // nothing in this process opens the connection a second time.132            other if lost_its_window(other, id, ready.window.id()) => {133                watchdog.missing(std::time::Instant::now());134                return;135            }136            WindowEvent::Resized(_) => ready.resized = true,137            WindowEvent::CloseRequested => {138                ready.stop(event_loop);139                return;140            }141            WindowEvent::ModifiersChanged(new) => ready.modifiers = new.state(),142            WindowEvent::KeyboardInput { event, .. } => {143                if event.state.is_pressed()144                    && let Some(name) = key_name(&event.logical_key)145                    && ready.press(&name)146                {147                    ready.stop(event_loop);148                    return;149                }150            }151            _ => {}152        }153154        if let Some(event) =155            conversion::window_event(event, ready.window.scale_factor() as f32, ready.modifiers)156        {157            ready.events.push(event);158        }159    }160161    fn about_to_wait(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {162        // The grace is checked here rather than in the frame, because a163        // client with no window draws no frame.164        self.watchdog.expire_if_late(std::time::Instant::now());165166        // A client waiting for a window has nothing to draw and a grace167        // to check, so the loop takes every pass it can until one is up. Winit168        // waits for an event otherwise, and a compositor that gives no window169        // sends none.170        if self.watchdog.counting() {171            event_loop.set_control_flow(ControlFlow::Poll);172            return;173        }174175        let State::Ready(ready) = &mut self.state else {176            return;177        };178179        // The deadline is checked here as well as in the frame, so a run ends180        // even if the compositor stops asking for frames. Before the first181        // frame there is no timeline yet, so there is no deadline.182        if let Some(start) = ready.start183            && ready.timeline.past_deadline(start.elapsed().as_secs_f64())184        {185            ready.stop(event_loop);186            return;187        }188189        // The sources are pumped here, on every wake of the loop, because a190        // covered client draws no frame: the compositor sends a hidden191        // surface no frame callbacks.192        // `present` is the one message that lets a covered browser map the193        // surface that reveals it, so the bus is read on a path the194        // compositor cannot starve.195        if let Some(start) = ready.start {196            let at = start.elapsed().as_secs_f64();197            if ready.screen.pump(at) {198                // What arrived changed the screen, so the frame on the glass199                // is stale whatever the animations schedule, and the second200                // scheduled before it no longer holds.201                ready.scheduled = None;202                ready.stale = true;203            }204            if ready.screen.surface_due() {205                ready.surface_pending = true;206            }207            if ready.surface_pending && ready.represent(event_loop) {208                ready.surface_pending = false;209                ready.screen.surfaced(at);210                // A Wayland surface is not on screen until its first buffer211                // arrives, so the new window gets a draw whatever the212                // schedule says.213                ready.window.request_redraw();214            }215        }216217        ready.pace(event_loop);218    }219220    fn exiting(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {221        if let State::Ready(ready) = &mut self.state {222            ready.finish();223        }224        // Wgpu builds an instance for every backend it can reach, and the one225        // for GL holds an EGL display on the compositor's connection. Its226        // destructor speaks Wayland, so it has to run while the connection is227        // open. winit closes the connection after this call and never before228        // it, so the graphics are dropped here rather than where the loop229        // returns.230        self.state = State::Done;231    }232}233234/// Whether one window event says the window the run draws on now went235/// away. A `present` maps a new window before it drops the old one, so236/// a `Destroyed` arrives for a window the run has already replaced.237/// Without this guard the grace starts on that stale window, and the238/// browser exits 7 fifteen seconds after every `Play`, which puts a239/// person back at the home page. The idle client's harness in240/// `media-operator` reads the same rule.241fn lost_its_window(event: &WindowEvent, destroyed: WindowId, drawing: WindowId) -> bool {242    matches!(event, WindowEvent::Destroyed) && destroyed == drawing243}244245#[cfg(test)]246mod tests {247    use super::*;248249    // Two identifiers of a run's own, because a test opens no window.250    fn ids() -> (WindowId, WindowId) {251        (WindowId::from(1), WindowId::from(2))252    }253254    #[test]255    fn the_window_the_run_draws_on_going_away_is_a_loss() {256        let (drawing, _replaced) = ids();257        assert!(lost_its_window(&WindowEvent::Destroyed, drawing, drawing));258    }259260    #[test]261    fn a_window_the_present_already_replaced_going_away_is_no_loss() {262        let (drawing, replaced) = ids();263        assert!(!lost_its_window(&WindowEvent::Destroyed, replaced, drawing));264    }265266    #[test]267    fn an_event_that_is_not_a_destroyed_is_no_loss() {268        let (drawing, _replaced) = ids();269        assert!(!lost_its_window(270            &WindowEvent::CloseRequested,271            drawing,272            drawing273        ));274    }275}
src/harness/capture.rs 100.0%
1// The frames a run writes to disk: which frame is due, where it goes, and the2// PNG the renderer's readback becomes.3//4// The schedule is a function of the clock alone, so a test drives it with5// numbers and never opens a window.67use std::path::{Path, PathBuf};89/// The directory the frames go in, the seconds they were asked for, and a10/// cursor into that list. The cursor only moves forward, so a capture fires11/// once.12#[derive(Debug)]13pub struct Captures {14    dir: PathBuf,15    at: Vec<f64>,16    next: usize,17}1819impl Captures {20    /// The captures `--capture` and `--capture-at` asked for, or nothing. The21    /// directory is where a frame goes, so a run that named none captures22    /// nothing whatever seconds it listed.23    pub fn requested(dir: Option<PathBuf>, at: Vec<f64>) -> Option<Self> {24        Some(Self {25            dir: dir?,26            at,27            next: 0,28        })29    }3031    /// The path this frame is written to, if the frame is the first one at or32    /// after the next capture second.33    pub fn due(&mut self, at: f64) -> Option<PathBuf> {34        let when = *self.at.get(self.next)?;35        if at < when {36            return None;37        }38        self.next += 1;39        Some(self.dir.join(format!("{when:06.2}.png")))40    }4142    /// The second of the next capture still to come. The loop folds it into43    /// the wake time, so a capturing run sleeps to the second it needs rather44    /// than drawing every pass toward it.45    pub fn next_due(&self) -> Option<f64> {46        self.at.get(self.next).copied()47    }4849    /// Whether a capture is still to come.50    pub fn pending(&self) -> bool {51        self.next < self.at.len()52    }5354    /// Whether the run has taken every capture it asked for. A run that named55    /// a directory and no second takes none and ends its own way.56    pub fn taken(&self) -> bool {57        !self.at.is_empty() && !self.pending()58    }59}6061/// Write one captured frame. `rgba` is what the renderer read back.62pub fn write_png(path: &Path, width: u32, height: u32, rgba: &[u8]) {63    if let Some(dir) = path.parent() {64        let _ = std::fs::create_dir_all(dir);65    }66    let Some(buffer) = image::RgbaImage::from_raw(width, height, rgba.to_vec()) else {67        eprintln!(68            "capture {}: {} bytes is not {width}x{height}",69            path.display(),70            rgba.len()71        );72        return;73    };74    if let Err(error) = buffer.save(path) {75        eprintln!("capture {}: {error}", path.display());76    }77}7879#[cfg(test)]80mod tests {81    use super::*;8283    fn capturing(dir: &str, at: Vec<f64>) -> Captures {84        Captures::requested(Some(PathBuf::from(dir)), at).expect("a directory captures")85    }8687    #[test]88    fn a_run_with_no_directory_captures_nothing() {89        assert!(Captures::requested(None, vec![0.5]).is_none());90    }9192    #[test]93    fn a_capture_is_due_on_the_first_frame_at_or_after_its_second() {94        let mut captures = capturing("/frames", vec![0.5]);95        assert_eq!(captures.due(0.49), None);96        assert_eq!(97            captures.due(0.52),98            Some(PathBuf::from("/frames/000.50.png"))99        );100        assert_eq!(captures.due(0.53), None);101    }102103    #[test]104    fn a_capture_is_named_for_the_second_it_was_asked_for() {105        let mut captures = capturing("/frames", vec![12.25, 100.0]);106        assert_eq!(107            captures.due(20.0),108            Some(PathBuf::from("/frames/012.25.png"))109        );110        assert_eq!(111            captures.due(200.0),112            Some(PathBuf::from("/frames/100.00.png"))113        );114    }115116    #[test]117    fn the_next_capture_is_due_until_it_is_taken() {118        let mut captures = capturing("/frames", vec![0.5, 1.5]);119        assert_eq!(captures.next_due(), Some(0.5));120121        captures.due(0.5);122        assert_eq!(captures.next_due(), Some(1.5));123124        captures.due(1.5);125        assert_eq!(captures.next_due(), None);126    }127128    #[test]129    fn a_run_has_taken_its_captures_after_the_last_one() {130        let mut captures = capturing("/frames", vec![0.5, 1.5]);131        assert!(captures.pending());132        assert!(!captures.taken());133134        captures.due(0.5);135        assert!(!captures.taken());136137        captures.due(1.5);138        assert!(captures.taken());139        assert!(!captures.pending());140    }141142    #[test]143    fn a_directory_with_no_seconds_captures_nothing_and_ends_nothing() {144        let captures = capturing("/frames", Vec::new());145        assert!(!captures.pending());146        assert!(!captures.taken());147    }148149    #[test]150    fn a_frame_of_the_wrong_size_or_at_a_path_that_cannot_be_written_is_dropped() {151        let dir = tempfile::TempDir::new().unwrap();152        let path = dir.path().join("frames").join("short.png");153        write_png(&path, 4, 4, &[0; 12]);154        assert!(!path.exists());155156        let unwritable = std::path::Path::new("/dev/null/frame.png");157        write_png(unwritable, 1, 1, &[0; 4]);158        assert!(!unwritable.exists());159    }160}
src/harness/frame.rs 82.8%
1// One pass of the frame loop: the script's keys, the draw, the capture, and2// the numbers. The pass ends by setting the pace of the next one.34use std::sync::Arc;56use iced_wgpu::graphics::Viewport;7use iced_wgpu::wgpu;8use iced_winit::core::time::Instant;9use iced_winit::core::{Color, Event, Size, Theme, mouse, renderer, window};10use iced_winit::runtime::user_interface::UserInterface;11use iced_winit::winit::event_loop::{ActiveEventLoop, ControlFlow};1213use super::capture::{self, Captures};14use super::graphics::{self, configure};15use super::stats::millis;16use super::timeline::{self, Wake};17use super::{QUIT, Ready, Screen};1819/// The least time between two frames, one sixtieth of a second. The surface20/// presents without vsync, so this floor is the whole of the frame-rate cap:21/// an animation that answers "now" on every ask draws sixty frames a second22/// and not as many as the loop can spin.23pub const STEP: f64 = 1.0 / 60.0;2425impl<S: Screen> Ready<S> {26    /// Hand one key to the screen. The answer is true when the key ends the27    /// run. Both the keyboard and the script arrive here, so the key that28    /// ends a run is decided once for the two of them.29    pub(crate) fn press(&mut self, name: &str) -> bool {30        if name == QUIT {31            return true;32        }33        // The screen's clock moves before the key lands. A screen at rest34        // draws no frames, so its clock stands at the last frame, which35        // can be seconds old, and a motion the key starts would begin in36        // the past and land fully run on its first frame. Before the first37        // frame there is no clock, and the key waits on nothing.38        if let Some(start) = self.start {39            self.screen.tick(start.elapsed().as_secs_f64());40        }41        // A press that changes the screen makes the frame on the glass42        // stale and drops the second the screen named before it. A press43        // that changes nothing, such as an arrow at the edge of the44        // keyboard grid, leaves both alone and draws no frame.45        if self.screen.key(name) {46            self.scheduled = None;47            self.stale = true;48        }49        false50    }5152    /// Write the numbers and leave the loop.53    pub(crate) fn stop(&mut self, event_loop: &ActiveEventLoop) {54        self.finish();55        event_loop.exit();56    }5758    /// Build, draw, capture, and present one frame.59    pub(crate) fn frame(&mut self, event_loop: &ActiveEventLoop) {60        // This frame is the one the schedule asked for, so the schedule is61        // spent and the next pass asks the screen again. It draws every fold62        // so far, so the glass is current again.63        self.scheduled = None;64        self.stale = false;65        let loop_start = std::time::Instant::now();66        let at = match self.start {67            Some(start) => start.elapsed().as_secs_f64(),68            None => {69                self.start = Some(loop_start);70                self.stats71                    .first_frame(millis(self.launched.elapsed()) / 1000.0);72                0.073            }74        };7576        self.drawn = at;7778        self.screen.tick(at);7980        for key in self.timeline.due(at) {81            if self.press(&key) {82                self.stop(event_loop);83                return;84            }85        }86        self.stats.sample_rss(at);8788        if self.resized {89            let size = self.window.inner_size();90            let (width, height) = (size.width.max(1), size.height.max(1));91            self.viewport = Viewport::with_physical_size(92                Size::new(width, height),93                self.window.scale_factor() as f32,94            );95            configure(&self.surface, &self.device, self.format, width, height);96            self.stats.resized((width, height));97            self.resized = false;98        }99100        let frame = match self.surface.get_current_texture() {101            Ok(frame) => frame,102            Err(wgpu::SurfaceError::OutOfMemory) => {103                eprintln!("surface out of memory");104                self.stop(event_loop);105                return;106            }107            Err(_) => {108                self.resized = true;109                return;110            }111        };112113        // The clock starts after the swapchain image is in hand, so the frame114        // time measures the work of a frame and not the wait for the display.115        let build_start = std::time::Instant::now();116        let view = frame117            .texture118            .create_view(&wgpu::TextureViewDescriptor::default());119120        let mut interface = UserInterface::build(121            self.screen.view(),122            self.viewport.logical_size(),123            std::mem::take(&mut self.cache),124            &mut self.renderer,125        );126127        let mut messages = Vec::new();128        self.events.push(Event::Window(129            window::Event::RedrawRequested(Instant::now()),130        ));131        let _ = interface.update(132            &self.events,133            mouse::Cursor::Unavailable,134            &mut self.renderer,135            &mut self.clipboard,136            &mut messages,137        );138        self.events.clear();139140        interface.draw(141            &mut self.renderer,142            &Theme::Dark,143            &renderer::Style::default(),144            mouse::Cursor::Unavailable,145        );146        self.cache = interface.into_cache();147148        for message in messages {149            self.screen.update(message);150        }151152        let background = self.screen.background();153        // The frame is built. A capture writes a file and blocks on a readback,154        // so the clock stops here and starts again for the submit.155        let drawn_ms = millis(build_start.elapsed());156157        let captured = self.capture(at, background);158159        let submit_start = std::time::Instant::now();160        if !captured {161            let _ = self162                .renderer163                .present(Some(background), self.format, &view, &self.viewport);164        }165        // The frame time is the work of a frame: build the interface, draw it,166        // and submit the commands. It stops before the surface is presented,167        // because that call waits for the compositor and measures the screen's168        // rate rather than this program's cost.169        let build_ms = drawn_ms + millis(submit_start.elapsed());170171        frame.present();172173        // A captured frame draws twice and blocks on a readback, so it says174        // nothing about the cost of a frame and stays out of the numbers.175        self.stats176            .frame(build_ms, millis(loop_start.elapsed()), !captured);177178        if self.timeline.past_deadline(at) || self.captured_everything() {179            self.stop(event_loop);180        }181    }182183    /// Set the pace of the loop, and ask for the frame that pace calls for.184    ///185    /// The loop sleeps until the earliest second anything is due, so a screen186    /// at rest builds one frame a change rather than one a display refresh. A187    /// second the screen has already named holds until the clock reaches it,188    /// because a fresh answer after the clock arrived would name the change189    /// after it, and the frame would never be drawn.190    pub(crate) fn pace(&mut self, event_loop: &ActiveEventLoop) {191        // Before the first frame there is no clock to schedule against, and192        // the first frame is what starts it.193        let Some(start) = self.start else {194            event_loop.set_control_flow(ControlFlow::Poll);195            self.window.request_redraw();196            return;197        };198199        let at = start.elapsed().as_secs_f64();200        let screen_next = match self.scheduled {201            Some(scheduled) => Some(scheduled),202            None => self.screen.next_frame(at),203        };204        self.scheduled = None;205206        // The wake is the earliest second anything is due: a stale frame is207        // due now, and after it the screen's own change, the next script key,208        // the deadline, or the next capture. The harness's own seconds come209        // from forward-only cursors, so they are asked again on every pass,210        // and only the screen's answer is held. The floor holds every answer211        // at least [`STEP`] after the last frame, which is the frame-rate212        // cap: a burst of folds coalesces to sixty frames a second and no213        // press waits past the next one.214        let stale_now = self.stale.then_some(at);215        let next = [216            stale_now,217            screen_next,218            self.timeline.next_due(),219            self.next_capture(),220        ]221        .into_iter()222        .flatten()223        .min_by(f64::total_cmp)224        .map(|next| next.max(self.drawn + STEP));225226        match timeline::wake(self.resized, at, next) {227            Wake::Now => {228                event_loop.set_control_flow(ControlFlow::Poll);229                self.window.request_redraw();230            }231            Wake::At(next) => {232                self.scheduled = screen_next;233                event_loop.set_control_flow(ControlFlow::WaitUntil(234                    start + std::time::Duration::from_secs_f64(next),235                ));236            }237            Wake::Never => event_loop.set_control_flow(ControlFlow::Wait),238        }239    }240241    /// Write this frame to a file, if a capture is due at this second. The242    /// answer is whether one was written.243    ///244    /// `Renderer::screenshot` renders the frame that was just drawn into an245    /// offscreen texture and reads it back as RGBA. It is iced's own path off246    /// the GPU, and it draws the same layers the surface would get. The247    /// surface itself is left alone on a capture frame, because one drawn248    /// frame must not be submitted twice.249    fn capture(&mut self, at: f64, background: Color) -> bool {250        let Some(path) = self.captures.as_mut().and_then(|captures| captures.due(at)) else {251            return false;252        };253254        let pixels = self.renderer.screenshot(&self.viewport, background);255        let size = self.viewport.physical_size();256        capture::write_png(&path, size.width, size.height, &pixels);257        eprintln!("captured {} at {at:.3}s", path.display());258        true259    }260261    /// The second of the next capture, folded into the wake time.262    fn next_capture(&self) -> Option<f64> {263        self.captures.as_ref().and_then(Captures::next_due)264    }265266    /// Whether the run has taken every capture it asked for, which ends it.267    fn captured_everything(&self) -> bool {268        self.captures.as_ref().is_some_and(Captures::taken)269    }270271    /// Map a fresh Wayland surface, and report whether one went up.272    ///273    /// Weston's kiosk-shell reveals a lower surface only along a code path274    /// gated on a seat, and `liken`'s compositor has none, so a browser that a275    /// film covered stays hidden until it maps a new surface. A newly mapped276    /// toplevel is revealed along a seat-independent path.277    ///278    /// The new window is created before the old one is dropped, so the279    /// browser is never without a surface and the screen never shows the280    /// compositor's background. The assignments drop the old surface and then281    /// the last reference to its window, in that order, because a surface282    /// holds the window it was created from.283    ///284    /// A compositor that gives no second window leaves the first one drawing.285    pub(crate) fn represent(&mut self, event_loop: &ActiveEventLoop) -> bool {286        let size = self.viewport.physical_size();287        let Some(window) = graphics::window(event_loop, (size.width, size.height), &self.app_id)288        else {289            return false;290        };291292        let surface = match self.instance.create_surface(Arc::clone(&window)) {293            Ok(surface) => surface,294            Err(error) => {295                eprintln!("media-browser: no surface on the new window: {error}");296                return false;297            }298        };299300        configure(301            &surface,302            &self.device,303            self.format,304            size.width.max(1),305            size.height.max(1),306        );307        self.surface = surface;308        self.window = window;309        eprintln!("media-browser: a new surface is up");310        true311    }312313    /// Write the statistics file once, whichever way the run ends.314    pub(crate) fn finish(&mut self) {315        if self.finished {316            return;317        }318        self.finished = true;319        self.stats.poster_counts(self.screen.poster_counts());320        self.stats.index_size(self.screen.index_size());321        if let Some(path) = &self.stats_path {322            self.stats.write(path);323        }324    }325}
src/harness/graphics.rs 94.3%
1// The window and the graphics device the frame loop draws through, kept apart2// from the loop itself.34use std::sync::Arc;56use iced_wgpu::graphics::Shell;7use iced_wgpu::{Engine, Renderer, wgpu};8use iced_winit::core::{Font, Pixels};9use iced_winit::winit;1011use winit::event_loop::ActiveEventLoop;12use winit::platform::wayland::WindowAttributesExtWayland;1314/// Everything that exists only after the compositor gives the process a window.15pub struct Graphics {16    pub window: Arc<winit::window::Window>,17    /// The wgpu instance the first surface came from. A re-present creates18    /// the next surface from the same one.19    pub instance: wgpu::Instance,20    pub device: wgpu::Device,21    pub surface: wgpu::Surface<'static>,22    pub format: wgpu::TextureFormat,23    pub renderer: Renderer,24    pub backend: String,25    pub adapter: String,26    pub size: (u32, u32),27}2829/// Ask the compositor for a window. The answer is `None` when it gave30/// none, and the watchdog reads that as a client with nothing to draw on.31///32/// `app_id` is the Wayland app-id the display claim delivered, and the33/// compositor places the window on the claimed screen by it. An empty id asks34/// for none, which is a run on a workstation where no claim named one.35pub fn window(36    event_loop: &ActiveEventLoop,37    size: (u32, u32),38    app_id: &str,39) -> Option<Arc<winit::window::Window>> {40    let mut attributes = winit::window::WindowAttributes::default()41        .with_title("liken media browser")42        // A kiosk client draws no title bar. winit's Wayland43        // backend draws one otherwise, and it takes 35 rows off44        // the surface the compositor gave the window.45        .with_decorations(false)46        .with_inner_size(winit::dpi::PhysicalSize::new(size.0, size.1));4748    if !app_id.is_empty() {49        // The general name is the Wayland app-id. The instance name is50        // the second half of the same protocol field, and the compositor reads51        // neither of the two for anything this client needs.52        attributes = attributes.with_name(app_id, "");53    }5455    match event_loop.create_window(attributes) {56        Ok(window) => Some(Arc::new(window)),57        Err(error) => {58            eprintln!("media-browser: the compositor gave no window: {error}");59            None60        }61    }62}6364/// Open the window, pick an adapter, and build the renderer that draws into it.65///66/// a compositor that gives no window answers `None` here, so the run67/// leaves the watchdog counting and the kubelet reads the exit code the68/// watchdog states.69pub fn open(event_loop: &ActiveEventLoop, size: (u32, u32), app_id: &str) -> Option<Graphics> {70    let window = window(event_loop, size, app_id)?;7172    let physical = window.inner_size();73    let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {74        backends: wgpu::Backends::from_env().unwrap_or_default(),75        ..Default::default()76    });77    let surface = instance78        .create_surface(window.clone())79        .expect("create surface");8081    let (format, adapter, device, queue) = block_on(async {82        let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, Some(&surface))83            .await84            .expect("no wgpu adapter for this surface");8586        let capabilities = surface.get_capabilities(&adapter);87        let (device, queue) = adapter88            .request_device(&wgpu::DeviceDescriptor {89                label: None,90                required_features: adapter.features() & wgpu::Features::default(),91                required_limits: wgpu::Limits::default(),92                memory_hints: wgpu::MemoryHints::MemoryUsage,93                trace: wgpu::Trace::Off,94                experimental_features: wgpu::ExperimentalFeatures::disabled(),95            })96            .await97            .expect("request device");9899        let format = capabilities100            .formats101            .iter()102            .copied()103            .find(wgpu::TextureFormat::is_srgb)104            .or_else(|| capabilities.formats.first().copied())105            .expect("no surface format");106107        (format, adapter, device, queue)108    });109110    // Cage takes the next free `wayland-N`, which is not `wayland-1` when111    // another compositor already holds that name, so the local script reads the112    // name out of this line rather than guessing it.113    println!(114        "wayland: {}",115        std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "none".into())116    );117118    let info = adapter.get_info();119    eprintln!(120        "wgpu: {:?} on {} ({:?}), surface {:?} at {}x{}",121        info.backend, info.name, info.device_type, format, physical.width, physical.height122    );123124    configure(125        &surface,126        &device,127        format,128        physical.width.max(1),129        physical.height.max(1),130    );131132    let engine = Engine::new(133        &adapter,134        device.clone(),135        queue,136        format,137        None,138        Shell::headless(),139    );140141    Some(Graphics {142        window,143        instance,144        device,145        surface,146        format,147        renderer: Renderer::new(engine, Font::default(), Pixels::from(16)),148        backend: format!("{:?}", info.backend),149        adapter: info.name.clone(),150        size: (physical.width.max(1), physical.height.max(1)),151    })152}153154/// Point the swapchain at a size. Every resize runs through here.155pub fn configure(156    surface: &wgpu::Surface<'static>,157    device: &wgpu::Device,158    format: wgpu::TextureFormat,159    width: u32,160    height: u32,161) {162    surface.configure(163        device,164        &wgpu::SurfaceConfiguration {165            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,166            format,167            width,168            height,169            // Mailbox rather than FIFO, because of what acquire does on a170            // hidden Wayland surface: FIFO waits in mesa's poll for a buffer171            // release the compositor never sends while a film covers the172            // screen, and the whole loop stops with it, bus and all. Mailbox173            // keeps spare images, so acquire never waits on the compositor.174            // The loop's own pace is what caps the rate instead: it never175            // asks for frames faster than `frame::STEP`.176            present_mode: wgpu::PresentMode::AutoNoVsync,177            alpha_mode: wgpu::CompositeAlphaMode::Auto,178            view_formats: vec![],179            desired_maximum_frame_latency: 2,180        },181    );182}183184/// Wait for one future on this thread. Only wgpu's setup is asynchronous here,185/// and the futures crate ships its executor behind a feature iced does not186/// enable, so the harness parks the thread and lets the waker unpark it.187fn block_on<F: Future>(future: F) -> F::Output {188    struct Unpark(std::thread::Thread);189190    impl std::task::Wake for Unpark {191        fn wake(self: Arc<Self>) {192            self.0.unpark();193        }194    }195196    let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));197    let mut context = std::task::Context::from_waker(&waker);198    // The future never moves after this point, and it lives on this stack199    // frame until it resolves, so pinning it here is sound.200    let mut future = std::pin::pin!(future);201202    loop {203        match future.as_mut().poll(&mut context) {204            std::task::Poll::Ready(output) => return output,205            std::task::Poll::Pending => std::thread::park(),206        }207    }208}
src/harness/options.rs 99.1%
1// The flags the media browser accepts, and the parsers behind them. A2// headless run has no keyboard and no screenshot tool, so the flags stand in3// for both.45use std::path::PathBuf;6use std::time::Duration;78/// The Wayland app-id the surface must ask for. The display claim9/// delivers it into the container at run time, and the compositor places the10/// window on the claimed output by it. An empty value asks for no app-id,11/// which is a run on a workstation where no claim named one.12pub const APP_ID: &str = "DISPLAY_APP_ID";1314/// The seconds the browser waits for a window before it exits. An unset15/// or non-positive value leaves the watchdog off, so a run outside a pod never16/// exits for a missing window. The operator sets it on the browser container of17/// every screen pod.18pub const WINDOW_GRACE: &str = "WINDOW_GRACE_SECONDS";1920/// The topic the library operator reads this `Player`'s play requests21/// on. It is the library operator's own variable, not media-operator's,22/// because that operator names the topic, and the browser knows neither23/// the topic base nor the `Player`'s name.24pub const PLAY_TOPIC: &str = "LIBRARY_PLAY_TOPIC";2526/// The help the binary prints for `--help`.27pub const HELP: &str = "\28media-browser [FLAGS]2930  --catalog PATH           the sidecar's SQLite file; without it, the sample31  --updates URL            the agent's HTTP API base32  --library-root NAME=PATH where a library's volume is read; repeatable33  --cache-dir PATH         where scaled posters are cached; without it, no disk cache34  --script \"0.0:p,3.0:o\"   key events at seconds from the first frame35  --capture DIR            where captured PNGs go36  --capture-at \"0.5,3.2\"   one PNG of the rendered frame at each second listed;37                           the run ends after the last one38  --stats FILE             the JSON measurements, written at exit39  --quit-after SECONDS     when to exit40  --size WxH               the window size to ask for; the default is 1920x108041  --help                   print this and exit4243The binary takes the same keys from a real keyboard, so it runs on a44workstation with no flags at all. Escape ends the run, the forward slash45is back, and the backtick is home.46";4748/// What the command line asked for.49#[derive(Debug, PartialEq)]50pub enum Invocation {51    // The options are boxed because they are much the larger of the two52    // answers, and every caller of the parse would carry that size.53    /// Open a window with these options.54    Run(Box<Options>),55    /// Print [`HELP`] and stop.56    Help,57}5859/// The flags the media browser accepts.60#[derive(Debug, PartialEq)]61pub struct Options {62    /// The sidecar's SQLite file. Without it the binary browses the63    /// sample catalog and reads no volume.64    pub catalog: Option<PathBuf>,65    /// The base of the agent's HTTP API, where the update streams are.66    pub updates: Option<String>,67    /// Where each library's volume is read, keyed by the catalog's68    /// library column, `namespace/name`.69    pub library_roots: Vec<(String, PathBuf)>,70    /// Where scaled posters are cached. `None` leaves the disk cache off.71    pub cache_dir: Option<PathBuf>,72    /// Key events at seconds from the first frame, in the order they fire.73    pub script: Vec<(f64, String)>,74    /// Where captured PNGs go, and at what seconds.75    pub capture_dir: Option<PathBuf>,76    pub capture_at: Vec<f64>,77    /// Where the JSON measurements go at exit.78    pub stats: Option<PathBuf>,79    /// When to exit, in seconds. A capture run also exits after its last frame.80    pub quit_after: Option<f64>,81    /// The window size to ask the compositor for.82    pub size: (u32, u32),83    /// The Wayland app-id every window this run maps asks for, from84    /// [`APP_ID`]. Nothing on the command line sets it.85    pub app_id: String,86    /// How long the run waits for a window before it exits, from87    /// [`WINDOW_GRACE`]. Nothing leaves the watchdog off.88    pub window_grace: Option<Duration>,89    /// The play topic, from [`PLAY_TOPIC`]. A run that misses it browses90    /// and starts nothing.91    pub play_topic: String,92}9394impl Default for Options {95    fn default() -> Self {96        Self {97            catalog: None,98            updates: None,99            library_roots: Vec::new(),100            cache_dir: None,101            script: Vec::new(),102            capture_dir: None,103            capture_at: Vec::new(),104            stats: None,105            quit_after: None,106            size: (1920, 1080),107            app_id: String::new(),108            window_grace: None,109            play_topic: String::new(),110        }111    }112}113114impl Options {115    /// Parse the command line. The flags are few and fixed, so the parser is a116    /// loop over the arguments rather than a dependency.117    pub fn parse<I>(args: I) -> Result<Invocation, String>118    where119        I: IntoIterator<Item = String>,120    {121        let mut options = Options::default();122        let mut args = args.into_iter();123124        while let Some(arg) = args.next() {125            let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));126127            match arg.as_str() {128                "--help" => return Ok(Invocation::Help),129                "--catalog" => options.catalog = Some(PathBuf::from(value()?)),130                "--updates" => options.updates = Some(value()?),131                "--library-root" => options.library_roots.push(parse_root(&value()?)?),132                "--cache-dir" => options.cache_dir = Some(PathBuf::from(value()?)),133                "--script" => options.script = parse_script(&value()?)?,134                "--capture" => options.capture_dir = Some(PathBuf::from(value()?)),135                "--capture-at" => options.capture_at = parse_times(&value()?)?,136                "--stats" => options.stats = Some(PathBuf::from(value()?)),137                "--quit-after" => {138                    let raw = value()?;139                    options.quit_after = Some(140                        raw.trim()141                            .parse()142                            .map_err(|_| format!("bad --quit-after {raw}"))?,143                    );144                }145                "--size" => options.size = parse_size(&value()?)?,146                other => return Err(format!("unknown flag {other}")),147            }148        }149150        // The stream and the volumes are read for a catalog, so151        // either flag without one is a run that could not do what it asked152        // for.153        if options.catalog.is_none() {154            if options.updates.is_some() {155                return Err("--updates needs --catalog".to_string());156            }157            if !options.library_roots.is_empty() {158                return Err("--library-root needs --catalog".to_string());159            }160        }161162        Ok(Invocation::Run(Box::new(options)))163    }164}165166impl Options {167    /// Read what the container was told. A pod cannot discover the168    /// app-id its display claim delivered, the grace the operator set,169    /// or the topic this operator reads play requests on, so all three170    /// arrive in the environment and none is a flag. The bus wiring171    /// arrives the same way and `media-screen` reads it, so none of it172    /// is here.173    pub fn from_environment(&mut self) {174        self.read_environment(|name| std::env::var(name).ok());175    }176177    /// The same read against any source of values. The environment is178    /// global to a process, so a test states the variables here instead of179    /// setting them and racing every other test in the binary.180    pub fn read_environment(&mut self, value: impl Fn(&str) -> Option<String>) {181        self.app_id = value(APP_ID).unwrap_or_default();182        self.window_grace = grace(&value(WINDOW_GRACE).unwrap_or_default());183        self.play_topic = value(PLAY_TOPIC).unwrap_or_default();184    }185}186187/// The window grace, in seconds. Anything but a positive number leaves188/// the watchdog off.189fn grace(text: &str) -> Option<Duration> {190    let seconds: f64 = text.trim().parse().ok()?;191    if seconds <= 0.0 || !seconds.is_finite() {192        return None;193    }194    Some(Duration::from_secs_f64(seconds))195}196197/// One library root, written `NAME=PATH`, where the name is the198/// catalog's library column and the path is where that volume is read.199pub fn parse_root(raw: &str) -> Result<(String, PathBuf), String> {200    let (name, path) = raw201        .split_once('=')202        .ok_or_else(|| format!("bad --library-root {raw}"))?;203    if name.is_empty() || path.is_empty() {204        return Err(format!("bad --library-root {raw}"));205    }206207    Ok((name.to_string(), PathBuf::from(path)))208}209210/// A scripted timeline: `SECONDS:KEY` steps, comma separated, sorted by time so211/// the frame loop reads them in order and never looks back.212pub fn parse_script(raw: &str) -> Result<Vec<(f64, String)>, String> {213    let mut script = Vec::new();214215    for step in raw.split(',') {216        let step = step.trim();217        if step.is_empty() {218            continue;219        }220        let (at, key) = step221            .split_once(':')222            .ok_or_else(|| format!("bad script step {step}"))?;223        let at: f64 = at224            .trim()225            .parse()226            .map_err(|_| format!("bad script time {at}"))?;227        let key = key.trim();228        if key.is_empty() {229            return Err(format!("bad script step {step}"));230        }231        script.push((at, key.to_string()));232    }233234    script.sort_by(|a, b| a.0.total_cmp(&b.0));235    Ok(script)236}237238/// A list of seconds, comma separated, sorted for the same reason.239pub fn parse_times(raw: &str) -> Result<Vec<f64>, String> {240    let mut times = Vec::new();241242    for at in raw.split(',') {243        let at = at.trim();244        if at.is_empty() {245            continue;246        }247        times.push(at.parse().map_err(|_| format!("bad capture time {at}"))?);248    }249250    times.sort_by(f64::total_cmp);251    Ok(times)252}253254/// A window size, written `WIDTHxHEIGHT`.255pub fn parse_size(raw: &str) -> Result<(u32, u32), String> {256    let (width, height) = raw257        .trim()258        .split_once('x')259        .ok_or_else(|| format!("bad size {raw}"))?;260261    Ok((262        width.parse().map_err(|_| format!("bad width {width}"))?,263        height.parse().map_err(|_| format!("bad height {height}"))?,264    ))265}266267#[cfg(test)]268#[path = "options/tests.rs"]269mod tests;
src/harness/stats.rs 99.4%
1// What the harness measures, and the JSON it writes at exit.23use std::path::Path;45use serde_json::json;67use crate::catalog::search::Size;8use crate::posters::PosterCounts;910pub struct Stats {11    backend: String,12    adapter: String,13    size: (u32, u32),14    frames: u64,15    first_frame: Option<f64>,16    /// Milliseconds to build, draw, and submit a frame, one entry per frame.17    build_ms: Vec<f64>,18    /// Milliseconds for the whole loop pass, which adds the wait for the next19    /// swapchain image. Under a compositor that waits for the display, this is20    /// the frame interval and the build time is the work inside it.21    loop_ms: Vec<f64>,22    rss_mib: Vec<f64>,23    next_rss_at: f64,24    poster_counts: PosterCounts,25    /// How large the search index is, or nothing on a run whose source26    /// holds none.27    index: Option<Size>,28}2930impl Stats {31    pub fn new(backend: String, adapter: String, size: (u32, u32)) -> Self {32        Self {33            backend,34            adapter,35            size,36            frames: 0,37            first_frame: None,38            build_ms: Vec::new(),39            loop_ms: Vec::new(),40            rss_mib: Vec::new(),41            next_rss_at: 0.0,42            poster_counts: PosterCounts::default(),43            index: None,44        }45    }4647    /// The surface changed size. The frame times collected so far belong to48    /// another size, and the frames around the change pay for a new swapchain,49    /// so the series starts again here.50    pub fn resized(&mut self, size: (u32, u32)) {51        self.size = size;52        self.build_ms.clear();53        self.loop_ms.clear();54    }5556    /// The first frame arrived, `seconds` after the launch.57    pub fn first_frame(&mut self, seconds: f64) {58        self.first_frame = Some(seconds);59    }6061    /// Record one frame. `counted` is false for a captured frame, which draws62    /// twice and waits on a readback.63    pub fn frame(&mut self, build_ms: f64, loop_ms: f64, counted: bool) {64        self.frames += 1;65        if counted {66            self.build_ms.push(build_ms);67            self.loop_ms.push(loop_ms);68        }69    }7071    /// Take one resident-set sample a second. The value is the VmRSS line of72    /// this process's own status file, which is what a machine with a gigabyte73    /// of memory has to fit.74    pub fn sample_rss(&mut self, at: f64) {75        if at < self.next_rss_at {76            return;77        }78        self.next_rss_at = at.floor() + 1.0;79        if let Some(mib) = read_rss_mib() {80            self.rss_mib.push(mib);81        }82    }8384    /// Record the poster counts at the end of the run.85    pub fn poster_counts(&mut self, counts: PosterCounts) {86        self.poster_counts = counts;87    }8889    /// Record how large the search index was at the end of the run. A90    /// source that holds no index records nothing, and the report then91    /// leaves both numbers out.92    pub fn index_size(&mut self, size: Option<Size>) {93        self.index = size;94    }9596    /// The measurements, as the file holds them.97    pub fn report(&self) -> serde_json::Value {98        let mut report = json!({99            "backend": self.backend,100            "adapter": self.adapter,101            "width": self.size.0,102            "height": self.size.1,103            "frames": self.frames,104            "posters_from_cache": self.poster_counts.from_cache,105            "posters_from_source": self.poster_counts.from_source,106            "seconds_to_first_frame": rounded(self.first_frame.unwrap_or(f64::NAN), 4),107            "frame_ms_p50": rounded(percentile(&self.build_ms, 0.50), 3),108            "frame_ms_p99": rounded(percentile(&self.build_ms, 0.99), 3),109            "frame_ms_max": rounded(percentile(&self.build_ms, 1.0), 3),110            "loop_ms_p50": rounded(percentile(&self.loop_ms, 0.50), 3),111            "loop_ms_p99": rounded(percentile(&self.loop_ms, 0.99), 3),112            "rss_mib": self.rss_mib.iter().map(|value| rounded(*value, 1)).collect::<Vec<_>>(),113            "rss_mib_max": rounded(self.rss_mib.iter().copied().fold(0.0_f64, f64::max), 1),114        });115        // The two search numbers stand in the report only where the run116        // had an index, so a run on a source with none says nothing about117        // one rather than reporting a zero it never measured.118        if let Some(size) = self.index {119            report["search_entries"] = json!(size.entries);120            report["search_mib"] = rounded(size.bytes as f64 / MIB, 2);121        }122        report123    }124125    pub fn write(&self, path: &Path) {126        let json = format!("{:#}\n", self.report());127        if let Err(error) = std::fs::write(path, json) {128            eprintln!("stats {}: {error}", path.display());129        }130    }131}132133// The bytes in a mebibyte, the unit the index's size reports in.134const MIB: f64 = 1024.0 * 1024.0;135136/// A measured duration in milliseconds, the unit the frame numbers are kept in.137pub fn millis(elapsed: std::time::Duration) -> f64 {138    elapsed.as_secs_f64() * 1000.0139}140141/// The nearest-rank percentile of a sample. The sample is copied and sorted142/// here, because this runs once at exit and the frame path must not sort.143pub fn percentile(values: &[f64], fraction: f64) -> f64 {144    if values.is_empty() {145        return f64::NAN;146    }147    let mut sorted = values.to_vec();148    sorted.sort_by(|a, b| a.total_cmp(b));149    let rank = ((sorted.len() as f64 - 1.0) * fraction).round() as usize;150    sorted[rank]151}152153/// A number at the precision the file reports it, so a reader is not given154/// digits the measurement does not carry. A value that is not a number reads as155/// JSON's null, which is what serde_json does with a NaN.156fn rounded(value: f64, places: u32) -> serde_json::Value {157    let scale = 10_f64.powi(places as i32);158    json!((value * scale).round() / scale)159}160161fn read_rss_mib() -> Option<f64> {162    let status = std::fs::read_to_string("/proc/self/status").ok()?;163    let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;164    let kib: f64 = line.split_whitespace().nth(1)?.parse().ok()?;165    Some(kib / 1024.0)166}167168#[cfg(test)]169mod tests {170    use super::*;171172    #[test]173    fn a_duration_reads_as_milliseconds() {174        assert_eq!(millis(std::time::Duration::from_micros(1500)), 1.5);175    }176177    #[test]178    fn a_percentile_takes_the_nearest_rank() {179        let sample: Vec<f64> = (1..=101).map(f64::from).collect();180        assert_eq!(percentile(&sample, 0.0), 1.0);181        assert_eq!(percentile(&sample, 0.50), 51.0);182        assert_eq!(percentile(&sample, 0.99), 100.0);183        assert_eq!(percentile(&sample, 1.0), 101.0);184    }185186    #[test]187    fn a_percentile_sorts_first() {188        assert_eq!(percentile(&[9.0, 1.0, 5.0], 0.50), 5.0);189        assert_eq!(percentile(&[9.0, 1.0, 5.0], 1.0), 9.0);190    }191192    #[test]193    fn one_value_is_every_percentile() {194        assert_eq!(percentile(&[4.0], 0.50), 4.0);195        assert_eq!(percentile(&[4.0], 0.99), 4.0);196    }197198    #[test]199    fn an_empty_sample_has_no_percentile() {200        assert!(percentile(&[], 0.50).is_nan());201    }202203    fn measured() -> Stats {204        let mut stats = Stats::new("Vulkan".into(), "an adapter".into(), (1920, 1080));205        stats.first_frame(1.0);206        for step in 1..=10 {207            stats.frame(f64::from(step), 16.0, true);208        }209        stats.frame(40.0, 40.0, false);210        stats211    }212213    #[test]214    fn the_report_counts_every_frame_and_times_only_the_counted_ones() {215        let report = measured().report();216        assert_eq!(report["frames"], json!(11));217        assert_eq!(report["frame_ms_max"], json!(10.0));218        assert_eq!(report["loop_ms_p50"], json!(16.0));219        assert_eq!(report["seconds_to_first_frame"], json!(1.0));220        assert_eq!(report["posters_from_cache"], json!(0));221        assert_eq!(report["posters_from_source"], json!(0));222    }223224    #[test]225    fn the_report_carries_the_search_index_only_where_the_run_had_one() {226        let mut stats = measured();227        assert_eq!(stats.report()["search_entries"], json!(null));228        assert_eq!(stats.report()["search_mib"], json!(null));229230        stats.index_size(Some(Size {231            entries: 4_096,232            items: 900,233            words: 3_000,234            bytes: 3 * 1024 * 1024 / 2,235        }));236237        let report = stats.report();238        assert_eq!(report["search_entries"], json!(4_096));239        assert_eq!(report["search_mib"], json!(1.5));240    }241242    #[test]243    fn the_report_records_the_final_poster_counts() {244        let mut stats = measured();245        stats.poster_counts(PosterCounts {246            from_cache: 17,247            from_source: 23,248        });249250        let report = stats.report();251        assert_eq!(report["posters_from_cache"], json!(17));252        assert_eq!(report["posters_from_source"], json!(23));253    }254255    #[test]256    fn a_resize_drops_the_frame_times_before_it() {257        let mut stats = measured();258        stats.resized((1280, 720));259        let report = stats.report();260        assert_eq!(report["width"], json!(1280));261        assert_eq!(report["frames"], json!(11));262        assert_eq!(report["frame_ms_p50"], json!(null));263    }264265    #[test]266    fn a_resident_set_sample_lands_once_a_second() {267        let mut stats = Stats::new("Vulkan".into(), "an adapter".into(), (1920, 1080));268        for step in 0..30 {269            stats.sample_rss(f64::from(step) * 0.1);270        }271        let samples = stats.report()["rss_mib"].as_array().unwrap().len();272        assert_eq!(samples, 3);273    }274}
src/harness/timeline.rs 100.0%
1// The timed decisions of a run, kept out of the code that needs a window:2// which script keys are due, whether the run has reached its deadline, and3// when the loop draws its next frame. Every decision is a function of the4// clock, so a test drives it with numbers and never opens a window.56/// The script and the deadline, with a cursor into the script. The cursor only7/// moves forward, so a step fires once.8#[derive(Debug, Default)]9pub struct Timeline {10    script: Vec<(f64, String)>,11    quit_after: Option<f64>,12    next_step: usize,13}1415impl Timeline {16    /// The schedule the flags asked for, at second zero.17    pub fn new(script: Vec<(f64, String)>, quit_after: Option<f64>) -> Self {18        Self {19            script,20            quit_after,21            next_step: 0,22        }23    }2425    /// The keys at or before `at`, in the order the script names them. The26    /// cursor moves past every key this call returned, so a later call never27    /// looks back. The harness hands each one to the same call a keyboard28    /// press takes, and that call holds the rule for the key that ends a run.29    pub fn due(&mut self, at: f64) -> Vec<String> {30        let mut keys = Vec::new();3132        while let Some((when, key)) = self.script.get(self.next_step)33            && *when <= at34        {35            keys.push(key.clone());36            self.next_step += 1;37        }3839        keys40    }4142    /// True once the clock reaches the `--quit-after` second.43    pub fn past_deadline(&self, at: f64) -> bool {44        self.quit_after.is_some_and(|limit| at >= limit)45    }4647    /// The next second the run itself must catch: the next script key, or the48    /// deadline. The loop folds it into the wake time, so a scripted or49    /// deadlined run sleeps between its seconds instead of drawing every50    /// pass, and a measurement under `--quit-after` reads a paced run.51    pub fn next_due(&self) -> Option<f64> {52        let step = self.script.get(self.next_step).map(|(when, _)| *when);53        [step, self.quit_after]54            .into_iter()55            .flatten()56            .min_by(f64::total_cmp)57    }58}5960/// What the loop does until it draws again.61#[derive(Debug, PartialEq)]62pub enum Wake {63    /// Draw now, and take the next pass of the loop as soon as it comes.64    Now,65    /// Draw at this second on the screen's clock, and sleep until then.66    At(f64),67    /// Draw when an event arrives, and on nothing else.68    Never,69}7071/// When the loop draws its next frame. `at` is the second of the frame that72/// was drawn last, and `next` is the earliest second anything is due: the73/// screen's own change, the next script key, the next capture, or the74/// deadline, whichever comes first.75///76/// A screen at rest changes on its own schedule, once a minute for a clock77/// that draws no seconds, and a loop that drew at the rate of the display78/// would build sixty identical frames a second for it. `immediate` is the79/// exception: a surface that changed size holds a stale frame, and the loop80/// draws now whatever the schedule says.81pub fn wake(immediate: bool, at: f64, next: Option<f64>) -> Wake {82    if immediate {83        return Wake::Now;84    }85    match next {86        // A second the clock never reaches is a screen with nothing scheduled,87        // and it is also the one value a wake time cannot hold.88        Some(next) if next.is_infinite() => Wake::Never,89        Some(next) if next > at => Wake::At(next),90        Some(_) => Wake::Now,91        None => Wake::Never,92    }93}9495#[cfg(test)]96mod tests {97    use super::*;9899    fn scripted(steps: &[(f64, &str)]) -> Timeline {100        Timeline::new(101            steps102                .iter()103                .map(|(at, key)| (*at, key.to_string()))104                .collect(),105            None,106        )107    }108109    fn keys(names: &[&str]) -> Vec<String> {110        names.iter().map(|name| name.to_string()).collect()111    }112113    #[test]114    fn a_step_is_due_at_its_second_and_not_before() {115        let mut timeline = scripted(&[(1.0, "p")]);116        assert_eq!(timeline.due(0.999), keys(&[]));117        assert_eq!(timeline.due(1.0), keys(&["p"]));118    }119120    #[test]121    fn every_step_the_clock_has_passed_comes_out_in_order_and_once() {122        let mut timeline = scripted(&[(0.1, "up"), (0.2, "down"), (0.3, "p")]);123        assert_eq!(timeline.due(0.25), keys(&["up", "down"]));124        assert_eq!(timeline.due(0.25), keys(&[]));125        assert_eq!(timeline.due(9.0), keys(&["p"]));126    }127128    #[test]129    fn a_run_ends_at_its_deadline() {130        let timeline = Timeline::new(Vec::new(), Some(3.0));131        assert!(!timeline.past_deadline(2.999));132        assert!(timeline.past_deadline(3.0));133    }134135    #[test]136    fn a_run_with_no_script_and_no_deadline_has_nothing_due() {137        assert_eq!(Timeline::default().next_due(), None);138        assert!(!Timeline::default().past_deadline(86_400.0));139    }140141    #[test]142    fn the_next_step_is_due_until_it_fires_and_then_the_one_after_it() {143        let mut timeline = scripted(&[(1.0, "p"), (2.5, "q")]);144        assert_eq!(timeline.next_due(), Some(1.0));145146        timeline.due(1.0);147148        assert_eq!(timeline.next_due(), Some(2.5));149    }150151    #[test]152    fn the_deadline_is_due_when_it_comes_before_the_next_step() {153        let timeline = Timeline::new(vec![(5.0, "p".into())], Some(3.0));154        assert_eq!(timeline.next_due(), Some(3.0));155    }156157    #[test]158    fn a_resized_surface_draws_now_whatever_the_schedule_says() {159        assert_eq!(wake(true, 4.0, Some(60.0)), Wake::Now);160        assert_eq!(wake(true, 4.0, None), Wake::Now);161    }162163    #[test]164    fn a_screen_that_changes_later_sleeps_until_then() {165        assert_eq!(wake(false, 4.0, Some(60.0)), Wake::At(60.0));166    }167168    #[test]169    fn a_screen_that_has_changed_draws_now() {170        assert_eq!(wake(false, 4.0, Some(4.0)), Wake::Now);171        assert_eq!(wake(false, 4.0, Some(3.5)), Wake::Now);172    }173174    #[test]175    fn a_screen_with_nothing_scheduled_waits_for_an_event() {176        assert_eq!(wake(false, 4.0, None), Wake::Never);177        assert_eq!(wake(false, 4.0, Some(f64::INFINITY)), Wake::Never);178    }179}
src/harness/watchdog.rs 95.2%
1// The window watchdog. A client with no window draws nothing while the screen2// shows the compositor's background, and that is what a compositor restart3// under a running screen pod leaves behind. Nothing inside the process can open4// the connection again, so the client exits and the kubelet restarts the5// container with backoff until the compositor answers.6//7// `WINDOW_GRACE_SECONDS` arms it, and the operator sets that variable on8// the browser container of every screen pod. A run outside a pod sets it9// nowhere and the watchdog stays off.1011use std::time::{Duration, Instant};1213/// The exit code a client with no window leaves. The code is a contract with14/// whoever reads a container's last state: 7 means the compositor gave no15/// window, whichever client the image runs.16pub const NO_WINDOW: i32 = 7;1718/// The grace, and the moment the window went away.19#[derive(Debug)]20pub struct Watchdog {21    grace: Option<Duration>,22    missing_since: Option<Instant>,23}2425impl Watchdog {26    /// The watchdog the grace arms. A client starts with no window, so the27    /// grace runs from `now`.28    pub fn new(grace: Option<Duration>, now: Instant) -> Self {29        Self {30            grace,31            missing_since: Some(now),32        }33    }3435    /// The window went away, or one never arrived. A grace already running is36    /// left alone, so a second failure while it runs does not extend it.37    pub fn missing(&mut self, now: Instant) {38        self.missing_since.get_or_insert(now);39    }4041    /// A window is up. The grace stops.42    pub fn present(&mut self) {43        self.missing_since = None;44    }4546    /// Whether the grace is running. The loop takes every pass it can while it47    /// is, because a client with no window gets no event, and48    /// [`Watchdog::expire_if_late`] runs between passes.49    pub fn counting(&self) -> bool {50        self.grace.is_some() && self.missing_since.is_some()51    }5253    /// Leave the process when the grace has run out with no window. The54    /// message names the grace, so a person reading the container's log reads55    /// the number the operator set.56    pub fn expire_if_late(&self, now: Instant) {57        let Some(grace) = self.grace else {58            return;59        };60        if self.late(now) {61            self.expire(&format!("no window after {} seconds", grace.as_secs_f64()));62        }63    }6465    /// Whether the grace has run out with no window.66    fn late(&self, now: Instant) -> bool {67        match (self.grace, self.missing_since) {68            (Some(grace), Some(since)) => now.duration_since(since) >= grace,69            _ => false,70        }71    }7273    /// Leave the process, because there is no window and none is coming. A74    /// watchdog that no grace armed returns instead, so a run outside a pod75    /// carries on and its caller reports the failure its own way.76    ///77    /// One line says what happened and that the exit is deliberate, because a78    /// non-zero exit in a log reads as a crash otherwise.79    pub fn expire(&self, reason: &str) {80        if self.grace.is_none() {81            return;82        }83        // The line names this binary, so a person reading the pod's log84        // tells it from the other containers there.85        eprintln!(86            "media-browser: {reason}; exiting {NO_WINDOW} so the kubelet restarts this container"87        );88        std::process::exit(NO_WINDOW)89    }90}9192#[cfg(test)]93mod tests {94    use super::*;9596    fn armed(seconds: u64) -> (Watchdog, Instant) {97        let now = Instant::now();98        (Watchdog::new(Some(Duration::from_secs(seconds)), now), now)99    }100101    // `expire_if_late` leaves the process, so these tests read the grace102    // through `late`, which is the same answer that call acts on.103    #[test]104    fn an_unarmed_watchdog_never_expires() {105        let now = Instant::now();106        let watchdog = Watchdog::new(None, now);107        assert!(!watchdog.counting());108        assert!(!watchdog.late(now + Duration::from_secs(86_400)));109    }110111    #[test]112    fn the_grace_runs_from_the_launch() {113        let (watchdog, now) = armed(15);114        assert!(watchdog.counting());115        assert!(!watchdog.late(now + Duration::from_secs(14)));116        assert!(watchdog.late(now + Duration::from_secs(15)));117    }118119    #[test]120    fn a_window_stops_the_grace() {121        let (mut watchdog, now) = armed(15);122        watchdog.present();123        assert!(!watchdog.counting());124        assert!(!watchdog.late(now + Duration::from_secs(60)));125    }126127    #[test]128    fn a_window_that_goes_away_starts_the_grace_again() {129        let (mut watchdog, now) = armed(15);130        watchdog.present();131        watchdog.missing(now + Duration::from_secs(60));132133        assert!(watchdog.counting());134        assert!(!watchdog.late(now + Duration::from_secs(74)));135        assert!(watchdog.late(now + Duration::from_secs(75)));136    }137138    #[test]139    fn a_second_failure_does_not_extend_a_running_grace() {140        let (mut watchdog, now) = armed(15);141        watchdog.missing(now + Duration::from_secs(10));142        assert!(watchdog.late(now + Duration::from_secs(15)));143    }144145    #[test]146    fn a_grace_that_has_not_run_out_leaves_the_process_alone() {147        let (watchdog, now) = armed(15);148        watchdog.expire_if_late(now + Duration::from_secs(14));149    }150151    #[test]152    fn an_unarmed_watchdog_leaves_the_process_alone() {153        Watchdog::new(None, Instant::now()).expire("no window");154        Watchdog::new(None, Instant::now()).expire_if_late(Instant::now());155    }156157    #[test]158    fn an_unarmed_watchdog_never_runs_late() {159        let start = Instant::now();160        let watchdog = Watchdog::new(None, start);161        watchdog.expire_if_late(start + Duration::from_secs(3600));162        assert!(!watchdog.counting());163    }164}
src/look.rs 100.0%
1// The liken look in one place. Every view reads its colors and its measures2// from here, so a change to either lands in one file.3//4// The colors are the brand's, through the liken-iced crate, which parses them5// out of liken.css. The one measure below is this screen's own: a size for a6// person reading from a couch, which no stylesheet for a page states.78use iced_winit::core::Color;9use liken_iced::palette;1011/// The ground the client fills. It is the clear color of every frame and every12/// capture.13///14/// The ground is black rather than the brand's `--page`, because the browser15/// shares one output with a film. A film's black and the browser's black have16/// to be the same black, or the panel shows a seam where one ends.17pub const BACKGROUND: Color = Color::BLACK;1819/// The color of every line of text on the screen, the dark scheme's `--ink`.20pub fn text() -> Color {21    palette::dark().ink22}2324/// The color of secondary text, the dark scheme's `--ink-muted`.25pub fn muted() -> Color {26    palette::dark().ink_muted27}2829/// The color of the part under a name in a stripe: the muted ink at a30/// lower opacity, so a character's name reads as an aside to the31/// person's.32pub fn faint() -> Color {33    Color { a: 0.72, ..muted() }34}3536/// The accent that marks focus, the dark scheme's `--link`.37pub fn accent() -> Color {38    palette::dark().link39}4041/// The track the volume row's fill runs over: the light scheme's `--link`,42/// the darkest green of the family, so the fill over it reads at a glance.43pub fn track() -> Color {44    palette::light().link45}4647/// The fill of a placeholder art slot and a focused row's ground: the48/// dark scheme's `--page`, slightly lighter than the black ground.49pub fn slot() -> Color {50    palette::dark().page51}5253/// The darkest end of the scrim panel, at the left edge where the text54/// column starts.55pub fn shade() -> Color {56    Color::from_rgba(0.0, 0.0, 0.0, 0.94)57}5859/// The opacity art draws at where a screen drew it but the person did not60/// choose it, such as the siblings in a set strip. The dim is the image's61/// own opacity and not a veil over it, because a veil is a fill and a fill62/// draws under every image of its layer.63pub const DIM: f32 = 0.42;6465/// The width of the stroke that marks focus, in logical pixels, thick66/// enough to read from a couch and thin enough that the art it frames67/// stays the larger thing.68pub const MARK: f32 = 4.0;6970/// The space between the art and the inner edge of the focus stroke, so71/// the stroke frames the art and does not touch it.72pub const MARK_GAP: f32 = 4.0;7374/// The color of the focus stroke and of the underline that marks the75/// current member of a strip: the accent, a little translucent, so the76/// art shows through it.77pub fn mark() -> Color {78    Color {79        a: 0.75,80        ..accent()81    }82}8384/// The ground under a page's own art, such as the episode wall of a85/// series. It is nearly opaque, so the stills sit on near-black and the86/// backdrop shows through only as a trace.87pub fn ground() -> Color {88    Color::from_rgba(0.0, 0.0, 0.0, 0.9)89}9091/// The far end of every gradient over art. It leaves the art as it is.92pub const CLEAR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.0);9394/// The size of the focused title's name under its poster, in logical95/// pixels, large enough to read from a couch.96pub const NAME: f32 = 23.0;9798/// The size of the one line under every slot of a wall.99pub const CAPTION: f32 = 18.0;100101/// The size of the two lines under a headshot in a stripe. A headshot is102/// narrower than a poster, so its lines are smaller than a wall's caption103/// to fit a full name.104pub const FACE: f32 = 16.0;105106/// The size of a page's title, where the item has no logo.107pub const TITLE: f32 = 53.0;108109/// The size of a title inside a header of a fixed height, where the item110/// has no logo.111pub const HEAD_TITLE: f32 = 38.0;112113/// The size of a page's facts line.114pub const FACTS: f32 = 21.0;115116/// The size of a page's tagline.117pub const TAGLINE: f32 = 23.0;118119/// The size of a page's plot.120pub const PLOT: f32 = 20.0;121122/// The size of the word in a button.123pub const BUTTON: f32 = 21.0;124125/// The size of the heading over a strip, over a stripe, and over a season's126/// divider.127pub const HEADING: f32 = 20.0;128129/// The size of the word in a control of a wall's band.130pub const CONTROL: f32 = 18.0;131132/// The size of a score on the ratings line, a step over the facts line133/// it sits under, so the number reads first and the site's scale after134/// it reads second.135pub const SCORE: f32 = 24.0;136137/// The size of the credits and the cast on a page.138pub const CREDITS: f32 = 18.0;139140/// The size of the number in the volume row.141pub const ROW_NAME: f32 = 28.0;142143/// The size of secondary text: details and placeholder titles.144pub const DETAIL: f32 = 20.0;145146/// How long the page takes to leave when a person chooses a title, in147/// seconds.148pub const DEPARTURE: f64 = 0.35;149150/// How long the page takes to return, in seconds: about a third of the151/// departure, so the way back is quicker than the way out.152pub const RETURN: f64 = 0.12;153154/// The one family the whole display draws in, and the italic face of that155/// family, which the second caption line of a two-line card draws in. Both156/// come from the brand crate, which carries the files and loads them into157/// the toolkit at startup, so this screen and every other liken display158/// draw the same face.159pub use liken_iced::font::{FAMILY as FONT, ITALIC};160161#[cfg(test)]162mod tests {163    use super::*;164165    #[test]166    fn the_ground_is_black() {167        assert_eq!(BACKGROUND, Color::from_rgb(0.0, 0.0, 0.0));168    }169170    #[test]171    fn the_text_is_the_brand_ink() {172        assert_eq!(text(), palette::dark().ink);173    }174175    #[test]176    fn the_secondary_colors_come_from_the_dark_scheme() {177        assert_eq!(muted(), palette::dark().ink_muted);178        assert_eq!(accent(), palette::dark().link);179        assert_eq!(slot(), palette::dark().page);180    }181182    #[test]183    fn the_veil_over_art_is_black_and_only_the_alpha_differs() {184        assert_eq!(shade().r, 0.0);185        assert_eq!(shade().g, 0.0);186        assert_eq!(shade().b, 0.0);187        assert!(shade().a > CLEAR.a);188        assert_eq!(CLEAR.a, 0.0);189    }190191    #[test]192    fn the_way_back_is_shorter_than_the_way_out() {193        const { assert!(RETURN > 0.0) };194        const { assert!(RETURN * 2.0 < DEPARTURE) };195    }196197    #[test]198    fn art_the_person_did_not_choose_draws_under_full_brightness() {199        const { assert!(DIM > 0.0) };200        const { assert!(DIM < 1.0) };201    }202}
src/main.rs 87.8%
1// The binary reads the flags. A bad flag stops the run before a window opens.23use media_screen::reader::{self, Reader};4use media_screen::{Bus, Wiring};56use media_browser::browser::Browser;7use media_browser::catalog::sidecar::SidecarSource;8use media_browser::harness::options::HELP;9use media_browser::harness::{self, Invocation, Options};10use media_browser::posters::volumes::{self, Volumes};11use media_browser::sample;1213// The identifier this client connects under, before the hostname the14// crate appends. A broker closes the older connection when two arrive15// under one name, so the idle client and this one carry different16// prefixes on a machine where both run.17const CLIENT_PREFIX: &str = "media-browser";1819// The two glibc allocator thresholds, in bytes. A block above the mmap20// threshold comes from mmap and returns to the kernel when it is freed,21// so a page-size decode does not dirty an arena the process keeps. The22// trim threshold returns the top of an arena as soon as that much is23// free. Without the pin, the browser held up to 300 MiB of decode dirt24// on the workstation and gave none of it back.25#[cfg(all(target_os = "linux", target_env = "gnu"))]26const MMAP_THRESHOLD: libc::c_int = 128 * 1024;27#[cfg(all(target_os = "linux", target_env = "gnu"))]28const TRIM_THRESHOLD: libc::c_int = 128 * 1024;2930// glibc raises both thresholds on its own as the program frees large31// blocks. Pinning them holds the decode buffers on mmap for the whole32// run.33#[cfg(all(target_os = "linux", target_env = "gnu"))]34fn pin_allocator_thresholds() {35    // mallopt takes two integers and no pointer. A failure leaves the36    // defaults in place, and there is nothing this binary can do about37    // it, so the result is dropped.38    unsafe {39        libc::mallopt(libc::M_MMAP_THRESHOLD, MMAP_THRESHOLD);40        libc::mallopt(libc::M_TRIM_THRESHOLD, TRIM_THRESHOLD);41    }42}4344// A build against another libc has no mallopt to call.45#[cfg(not(all(target_os = "linux", target_env = "gnu")))]46fn pin_allocator_thresholds() {}4748fn main() {49    // This runs before the flags are parsed, so every allocation after50    // it sees the pinned thresholds.51    pin_allocator_thresholds();5253    // The bus wiring is read once here, beside the flags. The crate54    // reads the broker, the Player's name, and every topic. The55    // browser's own read takes the app-id, the window grace, and the56    // play topic.57    let wiring = Wiring::from_environment();5859    match Options::parse(std::env::args().skip(1)) {60        Ok(Invocation::Help) => print!("{HELP}"),61        Ok(Invocation::Run(mut options)) => {62            // The app-id, the window grace, and the play topic are not63            // flags. The display claim delivers the first into the64            // container and the operator sets the rest, so the binary65            // reads them here, after the flags and before the window.66            options.from_environment();67            if let Err(error) = run(*options, &wiring) {68                eprintln!("media-browser: {error}");69                std::process::exit(1);70            }71        }72        Err(error) => {73            eprintln!("media-browser: {error}");74            std::process::exit(2);75        }76    }77}7879// A run with a catalog reads the sidecar's file and the volumes the80// library roots name. A run without one browses the invented sample, so the81// client opens on a workstation with no cluster.82fn run(options: Options, wiring: &Wiring) -> Result<(), String> {83    let bus = bus(wiring);84    let play_topic = options.play_topic.clone();8586    let Some(catalog) = options.catalog.clone() else {87        return harness::run(88            Browser::new(sample::Catalog, sample::NoArt)89                .with_page(options.size)90                .with_timing(options.stats.is_some())91                .with_bus(bus, play_topic),92            options,93        );94    };9596    // A run with no update stream reads the file alone, and a title97    // that lands after it opens waits for the next re-read.98    let updates = options.updates.clone().unwrap_or_default();99    let source = SidecarSource::new(catalog, &updates);100    let roots = options.library_roots.iter().cloned().collect();101    let posters = Volumes::with_cache_dir(102        roots,103        volumes::budget(options.size),104        options.cache_dir.clone(),105    );106107    harness::run(108        Browser::new(source, posters)109            .with_page(options.size)110            .with_timing(options.stats.is_some())111            .with_bus(bus, play_topic),112        options,113    )114}115116// The connection the wiring describes. A wiring that names no broker,117// or no topic to read, opens none, and the browser then takes the118// keyboard alone, which is how it runs on a workstation.119fn bus(wiring: &Wiring) -> Option<Box<dyn Bus>> {120    let client_id = reader::client_id(CLIENT_PREFIX, &reader::hostname());121122    Some(Box::new(Reader::open(wiring, &client_id)?))123}
src/posters.rs 100.0%
1// Posters live on the volume, not in the catalog, so the views ask for2// them through this seam and draw a placeholder until one arrives.34use std::path::PathBuf;56use crate::harness::Waker;78// Below the seam: a bounded cache that decodes art files into RGBA9// buffers at the size they are drawn, and the adapter that wraps those10// buffers in the handles the views draw.11mod art;12mod cache;13mod decode;14mod disk;15mod key;16mod queue;17pub mod store;18pub mod volumes;1920#[cfg(test)]21mod tests;2223pub use art::Art;24pub use store::PosterCounts;2526/// How a decode fills the box it is asked for.27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]28pub enum Fit {29    /// Scale to cover the box and crop the overflow at its center. A poster30    /// slot is drawn at the poster's own ratio, so the crop takes nothing.31    Cover,32    /// Scale to fit inside the box at the art's own ratio, so the whole33    /// image survives. The answer is no larger than the box.34    Contain,35}3637/// The poster store the views draw from.38///39/// `art` is the catalog's art path, relative to the library root, and40/// `width` and `height` are the pixels the poster is drawn at, so the41/// store decodes and scales once per drawn size and holds the results42/// under a bound. `None` says the poster is not decoded yet, or the item43/// has no art; the views draw a placeholder and ask again on a later44/// frame. A store that decodes in the background wakes the loop when a45/// poster lands, so the next ask finds it.46pub trait Posters {47    /// The poster for one item at the size it is drawn.48    fn poster(&mut self, library: &str, art: &str, width: u32, height: u32) -> Option<Art>;4950    /// The art fitted inside the box at its own ratio. A logo is wide and51    /// would lose its ends to a cover crop. A store with no fit answers52    /// nothing.53    fn fitted(&mut self, _library: &str, _art: &str, _width: u32, _height: u32) -> Option<Art> {54        None55    }5657    /// True once when a decode landed since the last call, so the58    /// harness redraws the frame that asked for the poster.59    /// The answer says nothing about the catalog and never asks the60    /// source to read the rows again.61    fn delivered(&mut self) -> bool {62        false63    }6465    /// Disk-cache hits and source decode attempts for this run.66    fn counts(&self) -> PosterCounts {67        PosterCounts::default()68    }6970    /// The path of one file of a library's volume on this machine, or nothing where the store holds no root for that library71    /// or the path leaves its root. A page reads a file the catalog names72    /// but does not hold, such as a person's biography, through the same73    /// roots the art resolves against. A store over no volume answers74    /// nothing.75    fn file(&self, _library: &str, _path: &str) -> Option<PathBuf> {76        None77    }7879    /// Take the handle that wakes the loop, for a store that decodes in80    /// the background. A store that answers on the calling thread takes81    /// it and does nothing.82    fn wake_by(&mut self, _wake: Waker) {}83}
src/posters/art.rs 98.2%
1// Decoded art as the handles the canvas draws, cut into horizontal bands2// so that every handle uploads on the frame that asks for it.34use std::ops::Range;56use iced_widget::core::{Bytes, Rectangle};7use iced_widget::image::Handle;89// iced_wgpu 0.14.0 uploads an image of fewer than this many bytes on the10// frame that asks for it. It hands a longer one to a worker thread and draws11// nothing until a later frame asks again. This client draws only on events,12// so that later frame never comes, and a full-frame backdrop stayed black.13// A band under this cap always takes the first path.14const SYNC_UPLOAD_BYTES: usize = 2 * 1024 * 1024;1516/// One decoded image, ready to draw: its pixel size, and the bands the17/// canvas draws it as. A small image is one band.18#[derive(Clone, Debug, PartialEq)]19pub struct Art {20    width: u32,21    height: u32,22    bands: Vec<Band>,23}2425// The rows this band holds, and the handle over those rows.26#[derive(Clone, Debug, PartialEq)]27struct Band {28    rows: Range<u32>,29    handle: Handle,30}3132impl Art {33    /// Cut a row-major RGBA buffer into bands under the upload cap. A band34    /// is a contiguous run of rows, so each handle is a view of the one35    /// buffer and no pixel is copied.36    pub fn new(width: u32, height: u32, pixels: Bytes) -> Self {37        let row = width as usize * 4;38        let mut bands = Vec::new();39        let per_band = (SYNC_UPLOAD_BYTES - 1)40            .checked_div(row)41            .unwrap_or_default()42            .max(1) as u32;43        let mut top = 0;44        while row > 0 && top < height {45            let bottom = height.min(top + per_band);46            let rows = top..bottom;47            let bytes = top as usize * row..bottom as usize * row;48            bands.push(Band {49                handle: Handle::from_rgba(width, bottom - top, pixels.slice(bytes)),50                rows,51            });52            top = bottom;53        }54        Self {55            width,56            height,57            bands,58        }59    }6061    /// The pixel size the decode landed at. A contain fit answers a size62    /// smaller than the box it was asked for.63    pub fn size(&self) -> (u32, u32) {64        (self.width, self.height)65    }6667    /// Each band with the share of the target rectangle it draws in. A band68    /// ends where the next one starts, so the bands tile the target with no69    /// seam.70    pub fn bands(&self, into: Rectangle) -> impl Iterator<Item = (Rectangle, Handle)> + '_ {71        let height = self.height as f32;72        self.bands.iter().map(move |band| {73            let top = into.y + into.height * band.rows.start as f32 / height;74            let bottom = into.y + into.height * band.rows.end as f32 / height;75            (76                Rectangle {77                    x: into.x,78                    y: top,79                    width: into.width,80                    height: bottom - top,81                },82                band.handle.clone(),83            )84        })85    }86}8788#[cfg(test)]89mod tests {90    use super::*;9192    // A buffer in which every pixel carries the number of its row, so a93    // test reads which rows a band holds.94    fn numbered(width: u32, height: u32) -> Bytes {95        let mut pixels = Vec::with_capacity((width * height * 4) as usize);96        for row in 0..height {97            pixels.extend(std::iter::repeat_n((row % 251) as u8, (width * 4) as usize));98        }99        Bytes::from_owner(pixels)100    }101102    fn drawn(art: &Art, into: Rectangle) -> Vec<(Rectangle, Handle)> {103        art.bands(into).collect()104    }105106    fn area(art: &Art) -> Rectangle {107        let (width, height) = art.size();108        Rectangle {109            x: 40.0,110            y: 10.0,111            width: width as f32,112            height: height as f32,113        }114    }115116    #[test]117    fn a_small_image_draws_as_one_band_over_the_whole_rectangle() {118        let art = Art::new(300, 450, numbered(300, 450));119        let bands = drawn(&art, area(&art));120        assert_eq!(bands.len(), 1);121        assert_eq!(bands[0].0, area(&art));122    }123124    #[test]125    fn a_frame_sized_image_draws_as_bands_under_the_upload_cap() {126        let art = Art::new(1920, 1080, numbered(1920, 1080));127        let bands = drawn(&art, area(&art));128        assert!(bands.len() > 1);129        for (_, handle) in &bands {130            let Handle::Rgba { pixels, .. } = handle else {131                panic!("a band is an Rgba handle");132            };133            assert!(pixels.len() < SYNC_UPLOAD_BYTES, "{} bytes", pixels.len());134        }135    }136137    #[test]138    fn the_bands_tile_the_target_rectangle_with_no_seam() {139        let art = Art::new(1920, 1080, numbered(1920, 1080));140        let into = Rectangle {141            x: 0.0,142            y: 0.0,143            width: 1920.0,144            height: 1080.0,145        };146        let bands = drawn(&art, into);147        assert_eq!(bands[0].0.y, into.y);148        for pair in bands.windows(2) {149            assert_eq!(pair[0].0.y + pair[0].0.height, pair[1].0.y);150            assert_eq!(pair[0].0.x, into.x);151            assert_eq!(pair[0].0.width, into.width);152        }153        let last = bands.last().expect("a band").0;154        assert_eq!(last.y + last.height, into.y + into.height);155    }156157    #[test]158    fn every_band_holds_the_rows_it_draws() {159        let art = Art::new(1920, 1080, numbered(1920, 1080));160        let mut top = 0u32;161        for (_, handle) in drawn(&art, area(&art)) {162            let Handle::Rgba {163                width,164                height,165                pixels,166                ..167            } = handle168            else {169                panic!("a band is an Rgba handle");170            };171            assert_eq!(width, 1920);172            assert_eq!(pixels.len(), (width * height * 4) as usize);173            assert_eq!(pixels[0], (top % 251) as u8);174            top += height;175        }176        assert_eq!(top, 1080);177    }178179    #[test]180    fn an_empty_size_draws_nothing() {181        let art = Art::new(0, 0, Bytes::from_owner(Vec::new()));182        assert_eq!(art.size(), (0, 0));183        assert_eq!(drawn(&art, area(&art)).len(), 0);184    }185}
src/posters/cache.rs 99.1%
1// The cache's bound is a byte budget over decoded buffers. When a new2// poster lands over the budget, the least recently drawn one leaves3// first.45use std::collections::HashMap;6use std::hash::Hash;78use super::store::Poster;910pub(crate) enum Decoded {11    Ready(Poster),12    Failed,13}1415struct Slot {16    last_used: u64,17    value: Decoded,18}1920pub(crate) struct Cache<K> {21    slots: HashMap<K, Slot>,22    used: usize,23    budget: usize,24    tick: u64,25}2627impl<K: Clone + Eq + Hash> Cache<K> {28    pub(crate) fn new(budget: usize) -> Self {29        Self {30            slots: HashMap::new(),31            used: 0,32            budget,33            tick: 0,34        }35    }3637    pub(crate) fn get(&mut self, key: &K) -> Option<&Decoded> {38        self.tick += 1;39        let slot = self.slots.get_mut(key)?;40        slot.last_used = self.tick;41        Some(&slot.value)42    }4344    // A failed decode is cached at zero bytes, so the wall does not45    // decode a bad file again. The byte eviction skips zero-byte46    // entries, because removing one frees no budget, so the failed47    // entries have a bound of their own: past FAILED, the least recently48    // asked one leaves, and a library of bad files cannot grow the map49    // without end.50    pub(crate) fn insert(&mut self, key: K, value: Decoded) {51        if let Some(old) = self.slots.remove(&key) {52            self.used -= bytes(&old.value);53        }54        if bytes(&value) == 0 {55            self.forget_a_failure();56        }57        let incoming = bytes(&value);58        while self.used + incoming > self.budget {59            let Some(evict) = self60                .slots61                .iter()62                .filter(|(_, slot)| bytes(&slot.value) > 0)63                .min_by_key(|(_, slot)| slot.last_used)64                .map(|(key, _)| key.clone())65            else {66                break;67            };68            let gone = self.slots.remove(&evict).expect("the key was just found");69            self.used -= bytes(&gone.value);70        }71        self.tick += 1;72        self.used += incoming;73        self.slots.insert(74            key,75            Slot {76                last_used: self.tick,77                value,78            },79        );80    }81}8283// How many failed decodes the cache remembers at most.84const FAILED: usize = 1024;8586impl<K: Clone + Eq + Hash> Cache<K> {87    // Drops the least recently asked failed entry once the failed entries88    // reach their bound, so the next one has a place.89    fn forget_a_failure(&mut self) {90        let failed = self91            .slots92            .values()93            .filter(|slot| bytes(&slot.value) == 0)94            .count();95        if failed < FAILED {96            return;97        }98        let Some(oldest) = self99            .slots100            .iter()101            .filter(|(_, slot)| bytes(&slot.value) == 0)102            .min_by_key(|(_, slot)| slot.last_used)103            .map(|(key, _)| key.clone())104        else {105            return;106        };107        self.slots.remove(&oldest);108    }109}110111fn bytes(value: &Decoded) -> usize {112    match value {113        Decoded::Ready(poster) => poster.rgba.len(),114        Decoded::Failed => 0,115    }116}117118#[cfg(test)]119mod tests {120    use super::super::store::Poster;121    use super::{Cache, Decoded, FAILED};122123    fn poster(bytes: usize) -> Decoded {124        Decoded::Ready(Poster::new(1, 1, vec![0u8; bytes].into()))125    }126127    fn is_ready(entry: Option<&Decoded>) -> bool {128        matches!(entry, Some(Decoded::Ready(_)))129    }130131    #[test]132    fn eviction_takes_the_least_recently_used() {133        let mut cache = Cache::new(512);134        cache.insert("a", poster(256));135        cache.insert("b", poster(256));136        assert!(is_ready(cache.get(&"b")));137        assert!(is_ready(cache.get(&"a")));138        cache.insert("c", poster(256));139        assert!(is_ready(cache.get(&"a")));140        assert!(is_ready(cache.get(&"c")));141        assert!(cache.get(&"b").is_none());142    }143144    #[test]145    fn failed_entries_consume_no_budget() {146        let mut cache = Cache::new(256);147        cache.insert("a", poster(256));148        cache.insert("bad", Decoded::Failed);149        assert!(is_ready(cache.get(&"a")));150        assert!(matches!(cache.get(&"bad"), Some(Decoded::Failed)));151    }152153    #[test]154    fn failed_entries_are_bounded_and_the_oldest_leaves_first() {155        let mut cache = Cache::new(256);156        for key in 0..FAILED {157            cache.insert(key, Decoded::Failed);158        }159        assert!(matches!(cache.get(&0), Some(Decoded::Failed)));160        cache.insert(FAILED, Decoded::Failed);161        assert!(matches!(cache.get(&0), Some(Decoded::Failed)));162        assert!(cache.get(&1).is_none());163        assert!(matches!(cache.get(&FAILED), Some(Decoded::Failed)));164        assert_eq!(cache.slots.len(), FAILED);165    }166167    #[test]168    fn replacing_a_key_releases_its_old_bytes() {169        let mut cache = Cache::new(512);170        cache.insert("a", poster(512));171        cache.insert("a", poster(256));172        cache.insert("b", poster(256));173        assert!(is_ready(cache.get(&"a")));174        assert!(is_ready(cache.get(&"b")));175    }176177    #[test]178    fn an_entry_larger_than_the_budget_still_lands() {179        let mut cache = Cache::new(256);180        cache.insert("a", poster(512));181        assert!(is_ready(cache.get(&"a")));182    }183}
src/posters/decode.rs 100.0%
1// One decode: guess the format from the file's bytes, scale the image2// into the drawn box the way the fit asks for, and return straight-alpha3// RGBA at the size the scale landed at.4//5// Triangle is the filter because its kernel widens with the downscale6// ratio, so a poster shrink averages the source pixels like a box7// filter, at about a third of Lanczos3's cost. The decode runs once8// per drawn size, so the cheaper filter is enough.910use std::path::Path;1112use image::ImageReader;13use image::imageops::FilterType;1415use super::Fit;16use super::store::Poster;1718pub(crate) fn decode_art(path: &Path, width: u32, height: u32, fit: Fit) -> Option<Poster> {19    let reader = ImageReader::open(path).ok()?.with_guessed_format().ok()?;20    let decoded = reader.decode().ok()?;21    let scaled = match fit {22        Fit::Cover => decoded.resize_to_fill(width, height, FilterType::Triangle),23        Fit::Contain => decoded.resize(width, height, FilterType::Triangle),24    };25    Some(Poster::new(26        scaled.width(),27        scaled.height(),28        scaled.to_rgba8().into_raw().into(),29    ))30}
src/posters/disk.rs 88.8%
1// The disk cache stores the scaled result under the same key as memory.2// Each request still stats its source before a hit, so replacement invalidates3// the local result without reading the source bytes.45use std::fs::{self, File, OpenOptions};6use std::io::{ErrorKind, Write};7use std::path::{Path, PathBuf};8use std::sync::Mutex;9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};1011use super::key::Key;12use super::store::Poster;1314mod format;15mod trim;1617use format::{FileRead, ReadOutcome, SourceStamp};18use trim::Index;1920/// The default disk cache holds 512 MiB of scaled posters.21pub const DEFAULT_BUDGET: usize = 512 * 1024 * 1024;2223static WARNED: AtomicBool = AtomicBool::new(false);24static TEMP_ID: AtomicU64 = AtomicU64::new(0);2526pub(super) enum Result {27    Cache(Poster),28    Source(Option<Poster>),29}3031struct State {32    version: PathBuf,33    index: Mutex<Index>,34    writes: AtomicBool,35}3637pub(super) struct DiskCache {38    state: Option<State>,39}4041impl DiskCache {42    pub(super) fn new(root: PathBuf) -> Self {43        Self::with_budget(root, DEFAULT_BUDGET)44    }4546    fn with_budget(root: PathBuf, budget: usize) -> Self {47        match open(root, budget) {48            Ok(state) => Self { state: Some(state) },49            Err(error) => {50                warn_once(&error);51                Self { state: None }52            }53        }54    }5556    pub(super) fn resolve<F>(&self, key: &Key, source: &Path, mut decode: F) -> Result57    where58        F: FnMut() -> Option<Poster>,59    {60        let Some(state) = &self.state else {61            return Result::Source(decode());62        };63        let first = source_stamp(source);64        match first {65            Ok(Some(stamp)) => {66                if let Some(poster) = state.load(key, Some(stamp)) {67                    return Result::Cache(poster);68                }69                let (poster, stable) = decode_stable(stamp, &mut decode, || source_stamp(source));70                if let (Some(stamp), Some(poster)) = (stable, poster.as_ref()) {71                    state.store(key, stamp, poster);72                }73                Result::Source(poster)74            }75            Err(error) if error.kind() == ErrorKind::NotFound => {76                // A deleted source can use the last result. Every other metadata77                // error refuses stale data because it cannot prove the identity.78                if let Some(poster) = state.load(key, None) {79                    return Result::Cache(poster);80                }81                Result::Source(decode())82            }83            Ok(None) | Err(_) => Result::Source(decode()),84        }85    }86}8788impl State {89    fn load(&self, key: &Key, source: Option<SourceStamp>) -> Option<Poster> {90        let path = trim::path(&self.version, key)?;91        let file = {92            let mut index = self93                .index94                .lock()95                .expect("the disk cache mutex is never poisoned");96            if !index.contains(&path) {97                return None;98            }99            match format::read_file(&path, key) {100                Ok(FileRead::Bytes(bytes)) => bytes,101                Ok(FileRead::Invalid) => {102                    remove_invalid(&path, &mut index);103                    return None;104                }105                Err(error) if error.kind() == ErrorKind::NotFound => {106                    index.forget(&path);107                    return None;108                }109                Err(_) => return None,110            }111        };112        match format::parse(&file, key, source) {113            ReadOutcome::Hit(poster) => Some(poster),114            ReadOutcome::MetadataMiss => None,115            ReadOutcome::Invalid => {116                let mut index = self117                    .index118                    .lock()119                    .expect("the disk cache mutex is never poisoned");120                remove_invalid(&path, &mut index);121                None122            }123        }124    }125126    fn store(&self, key: &Key, stamp: SourceStamp, poster: &Poster) {127        if !self.writes.load(Ordering::Acquire) {128            return;129        }130        let Some(bytes) = format::encode(key, stamp, poster) else {131            return;132        };133        if let Err(error) = self.publish(key, &bytes) {134            self.writes.store(false, Ordering::Release);135            warn_once(&error);136        }137    }138139    fn publish(&self, key: &Key, bytes: &[u8]) -> std::io::Result<()> {140        self.publish_with_flush(key, bytes, File::sync_all)141    }142143    fn publish_with_flush<F>(&self, key: &Key, bytes: &[u8], flush: F) -> std::io::Result<()>144    where145        F: FnOnce(&File) -> std::io::Result<()>,146    {147        let Some(path) = trim::path(&self.version, key) else {148            self.writes.store(false, Ordering::Release);149            return Err(std::io::Error::other("the poster cache key is too long"));150        };151        let Some(parent) = path.parent() else {152            self.writes.store(false, Ordering::Release);153            return Err(std::io::Error::other("the poster cache path has no parent"));154        };155        let prepared = (|| {156            fs::create_dir_all(parent)?;157            validate_shard(parent)?;158            let temp_path = parent.join(format!(159                ".tmp-{}-{}",160                std::process::id(),161                TEMP_ID.fetch_add(1, Ordering::Relaxed)162            ));163            let mut temp = TempFile::create(temp_path)?;164            temp.file.write_all(bytes)?;165            flush(&temp.file)?;166            Ok(temp)167        })();168        let mut temp = match prepared {169            Ok(temp) => temp,170            Err(error) => {171                self.writes.store(false, Ordering::Release);172                return Err(error);173            }174        };175        let mut index = self176            .index177            .lock()178            .expect("the disk cache mutex is never poisoned");179        if !self.writes.load(Ordering::Acquire) {180            return Ok(());181        }182        let committed = (|| {183            validate_shard(parent)?;184            fs::rename(&temp.path, &path)?;185            temp.published = true;186            let metadata = path.symlink_metadata()?;187            if !metadata.file_type().is_file() {188                return Err(std::io::Error::other(189                    "the poster cache entry is not a file",190                ));191            }192            index.published(path.clone(), metadata)193        })();194        if committed.is_err() {195            self.writes.store(false, Ordering::Release);196        }197        committed198    }199}200201fn open(root: PathBuf, budget: usize) -> std::io::Result<State> {202    let version = root.join("v1");203    fs::create_dir_all(&version)?;204    let file_type = version.symlink_metadata()?.file_type();205    if !file_type.is_dir() || file_type.is_symlink() {206        return Err(std::io::Error::other(207            "the poster cache version is not a directory",208        ));209    }210    let index = Index::scan(&version, budget)?;211    Ok(State {212        version,213        index: Mutex::new(index),214        writes: AtomicBool::new(true),215    })216}217218fn validate_shard(path: &Path) -> std::io::Result<()> {219    let file_type = path.symlink_metadata()?.file_type();220    if !file_type.is_dir() || file_type.is_symlink() {221        return Err(std::io::Error::other(222            "the poster cache shard is not a directory",223        ));224    }225    Ok(())226}227228fn source_stamp(path: &Path) -> std::io::Result<Option<SourceStamp>> {229    Ok(SourceStamp::from_metadata(&path.metadata()?))230}231232fn decode_stable<F, S>(233    before: SourceStamp,234    decode: &mut F,235    mut stamp: S,236) -> (Option<Poster>, Option<SourceStamp>)237where238    F: FnMut() -> Option<Poster>,239    S: FnMut() -> std::io::Result<Option<SourceStamp>>,240{241    let first = decode();242    let Ok(Some(after)) = stamp() else {243        return (first, None);244    };245    if after == before {246        let stable = first.as_ref().map(|_| after);247        return (first, stable);248    }249    let second = decode();250    let stable = match stamp() {251        Ok(Some(final_stamp)) if final_stamp == after && second.is_some() => Some(final_stamp),252        _ => None,253    };254    (second, stable)255}256257fn remove_invalid(path: &Path, index: &mut Index) {258    let _ = fs::remove_file(path);259    index.forget(path);260}261262fn warn_once(error: &std::io::Error) {263    if !WARNED.swap(true, Ordering::Relaxed) {264        eprintln!("media-browser: the poster disk cache failed: {error}");265    }266}267268struct TempFile {269    path: PathBuf,270    file: File,271    published: bool,272}273274impl TempFile {275    fn create(path: PathBuf) -> std::io::Result<Self> {276        let file = OpenOptions::new()277            .write(true)278            .create_new(true)279            .open(&path)?;280        Ok(Self {281            path,282            file,283            published: false,284        })285    }286}287288impl Drop for TempFile {289    fn drop(&mut self) {290        if !self.published {291            let _ = fs::remove_file(&self.path);292        }293    }294}295296#[cfg(test)]297mod tests;
src/posters/disk/format.rs 93.0%
1use std::fs;2use std::io::Cursor;3use std::path::Path;4use std::sync::Arc;5use std::time::{SystemTime, UNIX_EPOCH};67use image::codecs::jpeg::JpegEncoder;8use image::codecs::png::PngEncoder;9use image::{ExtendedColorType, ImageEncoder, ImageFormat, ImageReader, Limits};10use sha2::{Digest, Sha256};1112use super::super::Fit;13use super::super::key::Key;14use super::super::store::Poster;1516const MAGIC: &[u8; 8] = b"LPSTRV1\0";17const FIXED_HEADER: usize = 89;18const MAX_HEADER: usize = 16 * 1024;19const ENCODED_OVERHEAD: u64 = 64 * 1024;2021#[derive(Clone, Copy, Debug, PartialEq, Eq)]22pub(super) struct SourceStamp {23    pub(super) size: u64,24    pub(super) modified_ns: i128,25}2627impl SourceStamp {28    pub(super) fn from_metadata(metadata: &fs::Metadata) -> Option<Self> {29        Some(Self {30            size: metadata.len(),31            modified_ns: system_time_ns(metadata.modified().ok()?)?,32        })33    }34}3536fn system_time_ns(time: SystemTime) -> Option<i128> {37    match time.duration_since(UNIX_EPOCH) {38        Ok(after) => i128::try_from(after.as_nanos()).ok(),39        Err(before) => i128::try_from(before.duration().as_nanos())40            .ok()?41            .checked_neg(),42    }43}4445#[derive(Clone, Copy, Debug, PartialEq, Eq)]46enum Encoding {47    Jpeg = 0,48    Png = 1,49}5051pub(super) enum ReadOutcome {52    Hit(Poster),53    MetadataMiss,54    Invalid,55}5657pub(super) enum FileRead {58    Bytes(Vec<u8>),59    Invalid,60}6162pub(super) fn read_file(path: &Path, key: &Key) -> std::io::Result<FileRead> {63    let Some(max_payload) = max_payload(key) else {64        return Ok(FileRead::Invalid);65    };66    let metadata = path.symlink_metadata()?;67    if !metadata.file_type().is_file() {68        return Ok(FileRead::Invalid);69    }70    let max_file = u64::try_from(MAX_HEADER)71        .ok()72        .and_then(|header| header.checked_add(max_payload));73    if Some(metadata.len()) > max_file {74        return Ok(FileRead::Invalid);75    }76    Ok(FileRead::Bytes(fs::read(path)?))77}7879pub(super) fn parse(bytes: &[u8], key: &Key, source: Option<SourceStamp>) -> ReadOutcome {80    let Some(max_payload) = max_payload(key) else {81        return ReadOutcome::Invalid;82    };83    let Some(fixed) = bytes.get(..FIXED_HEADER) else {84        return ReadOutcome::Invalid;85    };86    if &fixed[..8] != MAGIC {87        return ReadOutcome::Invalid;88    }89    let header_len = usize::try_from(u32::from_be_bytes(fixed[8..12].try_into().unwrap())).unwrap();90    let payload_len = u64::from_be_bytes(fixed[12..20].try_into().unwrap());91    if !(FIXED_HEADER..=MAX_HEADER).contains(&header_len) || payload_len > max_payload {92        return ReadOutcome::Invalid;93    }94    let Some(payload_len) = usize::try_from(payload_len).ok() else {95        return ReadOutcome::Invalid;96    };97    if header_len.checked_add(payload_len) != Some(bytes.len()) {98        return ReadOutcome::Invalid;99    }100101    let stamp = SourceStamp {102        size: u64::from_be_bytes(fixed[20..28].try_into().unwrap()),103        modified_ns: i128::from_be_bytes(fixed[28..44].try_into().unwrap()),104    };105    if source.is_some_and(|current| current != stamp) {106        return ReadOutcome::MetadataMiss;107    }108    let width = u32::from_be_bytes(fixed[44..48].try_into().unwrap());109    let height = u32::from_be_bytes(fixed[48..52].try_into().unwrap());110    if !valid_dimensions(key, width, height) {111        return ReadOutcome::Invalid;112    }113    let encoding = match fixed[52] {114        0 => Encoding::Jpeg,115        1 => Encoding::Png,116        _ => return ReadOutcome::Invalid,117    };118    let key_len = usize::try_from(u32::from_be_bytes(fixed[53..57].try_into().unwrap())).unwrap();119    if FIXED_HEADER.checked_add(key_len) != Some(header_len) {120        return ReadOutcome::Invalid;121    }122    let expected_key = match key.bytes() {123        Some(bytes) => bytes,124        None => return ReadOutcome::Invalid,125    };126    if bytes.get(FIXED_HEADER..header_len) != Some(expected_key.as_slice()) {127        return ReadOutcome::Invalid;128    }129    let payload = &bytes[header_len..];130    let checksum = entry_checksum(&fixed[..57], &expected_key, payload);131    if fixed[57..89] != checksum {132        return ReadOutcome::Invalid;133    }134    let Some(poster) = decode(payload, encoding, width, height, key) else {135        return ReadOutcome::Invalid;136    };137    ReadOutcome::Hit(poster)138}139140pub(super) fn encode(key: &Key, stamp: SourceStamp, poster: &Poster) -> Option<Vec<u8>> {141    if !valid_dimensions(key, poster.width, poster.height) {142        return None;143    }144    let expected_rgba = rgba_len(poster.width, poster.height)?;145    if poster.rgba.len() != expected_rgba {146        return None;147    }148    let (encoding, payload) = encode_pixels(poster)?;149    if u64::try_from(payload.len()).ok()? > max_payload(key)? {150        return None;151    }152    let key_bytes = key.bytes()?;153    let header_len = FIXED_HEADER.checked_add(key_bytes.len())?;154    if header_len > MAX_HEADER {155        return None;156    }157    let mut bytes = Vec::with_capacity(header_len.checked_add(payload.len())?);158    bytes.extend_from_slice(MAGIC);159    bytes.extend_from_slice(&u32::try_from(header_len).ok()?.to_be_bytes());160    bytes.extend_from_slice(&u64::try_from(payload.len()).ok()?.to_be_bytes());161    bytes.extend_from_slice(&stamp.size.to_be_bytes());162    bytes.extend_from_slice(&stamp.modified_ns.to_be_bytes());163    bytes.extend_from_slice(&poster.width.to_be_bytes());164    bytes.extend_from_slice(&poster.height.to_be_bytes());165    bytes.push(encoding as u8);166    bytes.extend_from_slice(&u32::try_from(key_bytes.len()).ok()?.to_be_bytes());167    let checksum = entry_checksum(&bytes, &key_bytes, &payload);168    bytes.extend_from_slice(&checksum);169    bytes.extend_from_slice(&key_bytes);170    bytes.extend_from_slice(&payload);171    Some(bytes)172}173174fn entry_checksum(prefix: &[u8], key: &[u8], payload: &[u8]) -> [u8; 32] {175    let mut digest = Sha256::new();176    digest.update(prefix);177    digest.update(key);178    digest.update(payload);179    digest.finalize().into()180}181182fn encode_pixels(poster: &Poster) -> Option<(Encoding, Vec<u8>)> {183    let mut payload = Vec::new();184    if poster.rgba.chunks_exact(4).any(|pixel| pixel[3] < u8::MAX) {185        PngEncoder::new(&mut payload)186            .write_image(187                &poster.rgba,188                poster.width,189                poster.height,190                ExtendedColorType::Rgba8,191            )192            .ok()?;193        return Some((Encoding::Png, payload));194    }195    let rgb: Vec<u8> = poster196        .rgba197        .chunks_exact(4)198        .flat_map(|pixel| pixel[..3].iter().copied())199        .collect();200    JpegEncoder::new_with_quality(&mut payload, 90)201        .write_image(&rgb, poster.width, poster.height, ExtendedColorType::Rgb8)202        .ok()?;203    Some((Encoding::Jpeg, payload))204}205206fn decode(207    payload: &[u8],208    encoding: Encoding,209    width: u32,210    height: u32,211    key: &Key,212) -> Option<Poster> {213    let mut reader = ImageReader::new(Cursor::new(payload));214    reader.set_format(match encoding {215        Encoding::Jpeg => ImageFormat::Jpeg,216        Encoding::Png => ImageFormat::Png,217    });218    let mut limits = Limits::default();219    limits.max_image_width = Some(key.width);220    limits.max_image_height = Some(key.height);221    limits.max_alloc = u64::try_from(rgba_len(width, height)?).ok();222    reader.limits(limits);223    let decoded = reader.decode().ok()?;224    if decoded.width() != width || decoded.height() != height {225        return None;226    }227    let rgba = decoded.to_rgba8().into_raw();228    if rgba.len() != rgba_len(width, height)? {229        return None;230    }231    Some(Poster::new(width, height, Arc::from(rgba)))232}233234fn valid_dimensions(key: &Key, width: u32, height: u32) -> bool {235    match key.fit {236        Fit::Cover => width == key.width && height == key.height,237        Fit::Contain => width > 0 && height > 0 && width <= key.width && height <= key.height,238    }239}240241fn rgba_len(width: u32, height: u32) -> Option<usize> {242    usize::try_from(243        u64::from(width)244            .checked_mul(u64::from(height))?245            .checked_mul(4)?,246    )247    .ok()248}249250fn max_payload(key: &Key) -> Option<u64> {251    u64::from(key.width)252        .checked_mul(u64::from(key.height))?253        .checked_mul(4)?254        .checked_add(ENCODED_OVERHEAD)255}256257#[cfg(test)]258mod tests;
src/posters/disk/trim.rs 97.5%
1use std::collections::HashMap;2use std::fs;3use std::path::{Path, PathBuf};4use std::time::SystemTime;56use super::super::key::Key;78#[derive(Clone, Copy)]9struct Entry {10    bytes: u64,11    modified: SystemTime,12}1314pub(super) struct Index {15    entries: HashMap<PathBuf, Entry>,16    used: u64,17    budget: u64,18}1920impl Index {21    pub(super) fn scan(version: &Path, budget: usize) -> std::io::Result<Self> {22        let mut index = Self {23            entries: HashMap::new(),24            used: 0,25            budget: u64::try_from(budget).unwrap_or(u64::MAX),26        };27        for shard in fs::read_dir(version)? {28            let shard = shard?;29            let file_type = shard.file_type()?;30            if !file_type.is_dir() || !valid_shard(&shard.file_name().to_string_lossy()) {31                continue;32            }33            for file in fs::read_dir(shard.path())? {34                let file = file?;35                let file_type = file.file_type()?;36                let name = file.file_name();37                let name = name.to_string_lossy();38                if file_type.is_file() && name.starts_with(".tmp-") {39                    let _ = fs::remove_file(file.path());40                    continue;41                }42                if !file_type.is_file() || !valid_name(&name) {43                    continue;44                }45                let metadata = file.path().symlink_metadata()?;46                if !metadata.file_type().is_file() {47                    continue;48                }49                index.record(file.path(), metadata.len(), metadata.modified()?);50            }51        }52        index.trim()?;53        Ok(index)54    }5556    pub(super) fn contains(&self, path: &Path) -> bool {57        self.entries.contains_key(path)58    }5960    pub(super) fn forget(&mut self, path: &Path) {61        if let Some(entry) = self.entries.remove(path) {62            self.used = self.used.saturating_sub(entry.bytes);63        }64    }6566    pub(super) fn published(67        &mut self,68        path: PathBuf,69        metadata: fs::Metadata,70    ) -> std::io::Result<()> {71        self.forget(&path);72        self.record(path, metadata.len(), metadata.modified()?);73        self.trim()74    }7576    fn record(&mut self, path: PathBuf, bytes: u64, modified: SystemTime) {77        self.used = self.used.saturating_add(bytes);78        self.entries.insert(path, Entry { bytes, modified });79    }8081    fn trim(&mut self) -> std::io::Result<()> {82        while self.used > self.budget {83            let Some(oldest) = self84                .entries85                .iter()86                .min_by_key(|(_, entry)| entry.modified)87                .map(|(path, _)| path.clone())88            else {89                break;90            };91            fs::remove_file(&oldest)?;92            self.forget(&oldest);93        }94        Ok(())95    }9697    #[cfg(test)]98    pub(super) fn used(&self) -> u64 {99        self.used100    }101}102103pub(super) fn path(version: &Path, key: &Key) -> Option<PathBuf> {104    let digest = key.digest()?;105    let mut hex = String::with_capacity(64);106    for byte in digest {107        use std::fmt::Write;108        write!(&mut hex, "{byte:02x}").ok()?;109    }110    Some(version.join(&hex[..2]).join(&hex[2..]))111}112113fn valid_shard(name: &str) -> bool {114    name.len() == 2 && name.bytes().all(|byte| byte.is_ascii_hexdigit())115}116117fn valid_name(name: &str) -> bool {118    name.len() == 62 && name.bytes().all(|byte| byte.is_ascii_hexdigit())119}
src/posters/key.rs 100.0%
1use sha2::{Digest, Sha256};23use super::Fit;45const MAX_KEY_BYTES: usize = 16 * 1024;67#[derive(Clone, Debug, PartialEq, Eq, Hash)]8pub(super) struct Key {9    pub(super) library: String,10    pub(super) art: String,11    pub(super) width: u32,12    pub(super) height: u32,13    pub(super) fit: Fit,14}1516impl Key {17    pub(super) fn bytes(&self) -> Option<Vec<u8>> {18        let fields: [&[u8]; 5] = [19            self.library.as_bytes(),20            self.art.as_bytes(),21            &self.width.to_be_bytes(),22            &self.height.to_be_bytes(),23            &[match self.fit {24                Fit::Cover => 0,25                Fit::Contain => 1,26            }],27        ];28        let capacity = fields29            .iter()30            .try_fold(0usize, |size, field| size.checked_add(4 + field.len()))?;31        if capacity > MAX_KEY_BYTES {32            return None;33        }34        let mut bytes = Vec::with_capacity(capacity);35        for field in fields {36            let length = u32::try_from(field.len()).ok()?;37            bytes.extend_from_slice(&length.to_be_bytes());38            bytes.extend_from_slice(field);39        }40        Some(bytes)41    }4243    pub(super) fn digest(&self) -> Option<[u8; 32]> {44        Some(Sha256::digest(self.bytes()?).into())45    }46}4748#[cfg(test)]49mod tests {50    use super::*;5152    fn key(library: &str, art: &str, width: u32, height: u32, fit: Fit) -> Key {53        Key {54            library: library.to_owned(),55            art: art.to_owned(),56            width,57            height,58            fit,59        }60    }6162    #[test]63    fn every_field_has_its_own_disk_identity() {64        let base = key("movies", "poster.jpg", 240, 360, Fit::Cover);65        assert_ne!(66            base.digest(),67            key("shows", "poster.jpg", 240, 360, Fit::Cover).digest()68        );69        assert_ne!(70            base.digest(),71            key("movies", "other.jpg", 240, 360, Fit::Cover).digest()72        );73        assert_ne!(74            base.digest(),75            key("movies", "poster.jpg", 241, 360, Fit::Cover).digest()76        );77        assert_ne!(78            base.digest(),79            key("movies", "poster.jpg", 240, 361, Fit::Cover).digest()80        );81        assert_ne!(82            base.digest(),83            key("movies", "poster.jpg", 240, 360, Fit::Contain).digest()84        );85    }8687    #[test]88    fn the_disk_identity_has_a_stable_encoding() {89        assert_eq!(90            key("movies", "poster.jpg", 240, 360, Fit::Cover).digest(),91            Some([92                0x9b, 0xbb, 0xbd, 0xf1, 0xb7, 0x26, 0xdd, 0x19, 0xab, 0xae, 0x11, 0x31, 0x30, 0xff,93                0x3d, 0xcb, 0x00, 0x9f, 0xd3, 0x35, 0x09, 0xef, 0xd1, 0xaa, 0x41, 0xb3, 0xba, 0x55,94                0x69, 0xe5, 0x16, 0xf1,95            ])96        );97    }9899    #[test]100    fn field_boundaries_cannot_alias() {101        assert_ne!(102            key("ab", "c", 1, 1, Fit::Cover).digest(),103            key("a", "bc", 1, 1, Fit::Cover).digest()104        );105    }106107    #[test]108    fn an_unbounded_key_has_no_disk_identity() {109        assert_eq!(110            key(&"x".repeat(MAX_KEY_BYTES), "art", 1, 1, Fit::Cover).digest(),111            None112        );113    }114}
src/posters/queue.rs 100.0%
1// The queue serves the newest request first. A fast scroll then fills2// the posters under the focus before the ones it scrolled past.3//4// Page-size decodes hold one lane of their own: at most one is in flight,5// and a slot-size request passes a page-size one that waits. A page-size6// decode reads a source of up to several megapixels, and four of them at7// once dirtied four arenas on a one-gigabyte box.89use std::collections::HashSet;10use std::hash::Hash;1112// One queued request and the lane it belongs to.13struct Queued<K> {14    key: K,15    page: bool,16}1718pub(crate) struct RequestQueue<K> {19    stack: Vec<Queued<K>>,20    in_flight: HashSet<K>,21    // The page-size key a worker is decoding. It blocks the page lane22    // until finish clears it.23    page_in_flight: Option<K>,24}2526impl<K> Default for RequestQueue<K> {27    fn default() -> Self {28        Self {29            stack: Vec::new(),30            in_flight: HashSet::new(),31            page_in_flight: None,32        }33    }34}3536impl<K: Clone + Eq + Hash> RequestQueue<K> {37    // A key already queued moves to the top, a key already decoding is38    // left alone, and only a new key returns true to ask for a worker.39    pub(crate) fn request(&mut self, key: K, page: bool) -> bool {40        if self.in_flight.contains(&key) {41            if let Some(at) = self.stack.iter().position(|queued| queued.key == key) {42                let queued = self.stack.remove(at);43                self.stack.push(queued);44            }45            return false;46        }47        self.in_flight.insert(key.clone());48        self.stack.push(Queued { key, page });49        true50    }5152    // The key stays in flight until finish, so a repeat request during53    // the decode does not queue it twice.54    //55    // The newest takeable request wins. A page-size request is not56    // takeable while another page-size decode runs.57    pub(crate) fn take(&mut self) -> Option<K> {58        let lane_free = self.page_in_flight.is_none();59        let at = self60            .stack61            .iter()62            .rposition(|queued| lane_free || !queued.page)?;63        let queued = self.stack.remove(at);64        if queued.page {65            self.page_in_flight = Some(queued.key.clone());66        }67        Some(queued.key)68    }6970    pub(crate) fn finish(&mut self, key: &K) {71        self.in_flight.remove(key);72        if self.page_in_flight.as_ref() == Some(key) {73            self.page_in_flight = None;74        }75    }76}7778#[cfg(test)]79mod tests {80    use super::RequestQueue;8182    #[test]83    fn serves_the_newest_request_first() {84        let mut queue = RequestQueue::default();85        assert!(queue.request("a", false));86        assert!(queue.request("b", false));87        assert!(queue.request("c", false));88        assert_eq!(queue.take(), Some("c"));89        assert_eq!(queue.take(), Some("b"));90        assert_eq!(queue.take(), Some("a"));91        assert_eq!(queue.take(), None);92    }9394    #[test]95    fn a_repeat_request_moves_to_the_top() {96        let mut queue = RequestQueue::default();97        assert!(queue.request("a", false));98        assert!(queue.request("b", false));99        assert!(!queue.request("a", false));100        assert_eq!(queue.take(), Some("a"));101        assert_eq!(queue.take(), Some("b"));102        assert_eq!(queue.take(), None);103    }104105    #[test]106    fn a_key_being_decoded_is_not_queued_again() {107        let mut queue = RequestQueue::default();108        assert!(queue.request("a", false));109        assert_eq!(queue.take(), Some("a"));110        assert!(!queue.request("a", false));111        assert_eq!(queue.take(), None);112        queue.finish(&"a");113        assert!(queue.request("a", false));114        assert_eq!(queue.take(), Some("a"));115    }116117    #[test]118    fn one_page_size_request_is_in_flight_at_a_time() {119        let mut queue = RequestQueue::default();120        assert!(queue.request("first", true));121        assert!(queue.request("second", true));122        assert_eq!(queue.take(), Some("second"));123        assert_eq!(queue.take(), None);124        queue.finish(&"second");125        assert_eq!(queue.take(), Some("first"));126        assert_eq!(queue.take(), None);127    }128129    #[test]130    fn a_slot_request_passes_a_waiting_page_size_one() {131        let mut queue = RequestQueue::default();132        assert!(queue.request("slot", false));133        assert!(queue.request("page", true));134        assert!(queue.request("newer page", true));135        assert_eq!(queue.take(), Some("newer page"));136        assert_eq!(queue.take(), Some("slot"));137        assert_eq!(queue.take(), None);138        queue.finish(&"newer page");139        assert_eq!(queue.take(), Some("page"));140    }141142    #[test]143    fn a_finished_slot_leaves_the_page_lane_alone() {144        let mut queue = RequestQueue::default();145        assert!(queue.request("page", true));146        assert!(queue.request("slot", false));147        assert_eq!(queue.take(), Some("slot"));148        assert_eq!(queue.take(), Some("page"));149        queue.finish(&"slot");150        assert!(queue.request("other page", true));151        assert_eq!(queue.take(), None);152    }153}
src/posters/store.rs 100.0%
1// The store's contract with the views: a hit answers at once, a miss2// queues a background decode and answers None, and the wake handle3// tells the loop to ask again once the decode lands.45use std::collections::HashMap;6use std::path::PathBuf;7use std::sync::{Arc, Condvar, Mutex, OnceLock};8use std::thread::{self, JoinHandle};910use super::cache::{Cache, Decoded};11use super::decode::decode_art;12use super::disk::{DiskCache, Result as DiskResult};13use super::key::Key;14use super::queue::RequestQueue;15use super::{Art, Fit};16use crate::harness::Waker;1718// The pixel buffer is shared. A hit on every frame clones a pointer,19// not the pixels.20#[derive(Clone)]21pub struct Poster {22    pub width: u32,23    pub height: u32,24    pub rgba: Arc<[u8]>,25    // The handles over these pixels, built on the first ask and held with26    // the cache entry. The renderer keys its uploads by handle id, so a27    // frame that draws the same handles draws uploads it already holds.28    pub art: Arc<OnceLock<Art>>,29}3031impl Poster {32    pub(crate) fn new(width: u32, height: u32, rgba: Arc<[u8]>) -> Self {33        Self {34            width,35            height,36            rgba,37            art: Arc::new(OnceLock::new()),38        }39    }40}4142/// The worker requests that read a valid disk entry or fell back to the43/// source. A memory hit changes neither count. A failed source read still44/// counts because it performed the source I/O.45#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]46pub struct PosterCounts {47    pub from_cache: u64,48    pub from_source: u64,49}5051// A decode of more than this many pixels takes the page lane. A poster52// slot is about 0.1 megapixels and a backdrop at 1920x1080 is 2.07, so53// one megapixel separates the two with room either way.54const PAGE_PIXELS: u64 = 1_000_000;5556struct Shared {57    cache: Cache<Key>,58    queue: RequestQueue<Key>,59    // A worker sets this after every insert, a success and a failure60    // alike, and the mark stands until a reader takes it, so a decode61    // that landed between two frames is never missed.62    delivered: bool,63    counts: PosterCounts,64    stop: bool,65}6667pub struct ArtStore {68    roots: Arc<HashMap<String, PathBuf>>,69    shared: Arc<(Mutex<Shared>, Condvar)>,70    workers: Vec<JoinHandle<()>>,71}7273impl ArtStore {74    // The pool caps at four workers, so decode work leaves cores for75    // the compositor and the rest of the machine.76    pub fn new(roots: HashMap<String, PathBuf>, budget: usize, waker: Waker) -> Self {77        Self::initialize(roots, budget, waker, worker_count(), None)78    }7980    pub fn with_cache_dir(81        roots: HashMap<String, PathBuf>,82        budget: usize,83        waker: Waker,84        cache_dir: Option<PathBuf>,85    ) -> Self {86        Self::initialize(roots, budget, waker, worker_count(), cache_dir)87    }8889    /// The root of one library's volume, or nothing where the store90    /// holds none for it.91    pub fn root(&self, library: &str) -> Option<&PathBuf> {92        self.roots.get(library)93    }9495    pub fn with_workers(96        roots: HashMap<String, PathBuf>,97        budget: usize,98        waker: Waker,99        workers: usize,100    ) -> Self {101        Self::initialize(roots, budget, waker, workers, None)102    }103104    fn initialize(105        roots: HashMap<String, PathBuf>,106        budget: usize,107        waker: Waker,108        workers: usize,109        cache_dir: Option<PathBuf>,110    ) -> Self {111        let roots = Arc::new(roots);112        let disk = cache_dir.map(DiskCache::new).map(Arc::new);113        let shared = Arc::new((114            Mutex::new(Shared {115                cache: Cache::new(budget),116                queue: RequestQueue::default(),117                delivered: false,118                counts: PosterCounts::default(),119                stop: false,120            }),121            Condvar::new(),122        ));123        let workers = (0..workers.max(1))124            .map(|_| spawn_worker(roots.clone(), shared.clone(), disk.clone(), waker.clone()))125            .collect();126        Self {127            roots,128            shared,129            workers,130        }131    }132133    // An item with no art, and a library the store holds no root for,134    // can never produce a poster, so neither reaches the queue.135    pub fn poster(136        &mut self,137        library: &str,138        art: &str,139        width: u32,140        height: u32,141        fit: Fit,142    ) -> Option<Poster> {143        if art.is_empty() || width == 0 || height == 0 {144            return None;145        }146        if !self.roots.contains_key(library) {147            return None;148        }149        let page = u64::from(width) * u64::from(height) > PAGE_PIXELS;150        let key = Key {151            library: library.to_owned(),152            art: art.to_owned(),153            width,154            height,155            fit,156        };157        let (lock, signal) = &*self.shared;158        let mut shared = lock.lock().expect("the store mutex is never poisoned");159        match shared.cache.get(&key) {160            Some(Decoded::Ready(poster)) => return Some(poster.clone()),161            Some(Decoded::Failed) => return None,162            None => {}163        }164        if shared.queue.request(key, page) {165            signal.notify_one();166        }167        None168    }169170    // Take the mark a worker left, so the caller reads that a decode171    // landed and the mark is clear for the decodes after it.172    pub fn delivered(&mut self) -> bool {173        let (lock, _) = &*self.shared;174        let mut shared = lock.lock().expect("the store mutex is never poisoned");175        std::mem::take(&mut shared.delivered)176    }177178    pub fn counts(&self) -> PosterCounts {179        let (lock, _) = &*self.shared;180        lock.lock()181            .expect("the store mutex is never poisoned")182            .counts183    }184}185186// Dropping the store stops and joins the workers, so no decode outlives187// the screen that asked for it.188impl Drop for ArtStore {189    fn drop(&mut self) {190        let (lock, signal) = &*self.shared;191        lock.lock().expect("the store mutex is never poisoned").stop = true;192        signal.notify_all();193        for worker in self.workers.drain(..) {194            let _ = worker.join();195        }196    }197}198199fn worker_count() -> usize {200    thread::available_parallelism()201        .map(std::num::NonZeroUsize::get)202        .unwrap_or(1)203        .min(4)204}205206// The worker's loop: sleep until a request lands, decode with the lock207// released, insert the result, and wake the event loop.208fn spawn_worker(209    roots: Arc<HashMap<String, PathBuf>>,210    shared: Arc<(Mutex<Shared>, Condvar)>,211    disk: Option<Arc<DiskCache>>,212    waker: Waker,213) -> JoinHandle<()> {214    thread::spawn(move || {215        loop {216            let key = {217                let (lock, signal) = &*shared;218                let mut state = lock.lock().expect("the store mutex is never poisoned");219                loop {220                    if state.stop {221                        return;222                    }223                    if let Some(key) = state.queue.take() {224                        break key;225                    }226                    state = signal227                        .wait(state)228                        .expect("the store mutex is never poisoned");229                }230            };231            let path = roots[&key.library].join(&key.art);232            let result = match &disk {233                Some(disk) => disk.resolve(&key, &path, || {234                    decode_art(&path, key.width, key.height, key.fit)235                }),236                None => DiskResult::Source(decode_art(&path, key.width, key.height, key.fit)),237            };238            let (value, from_cache) = match result {239                DiskResult::Cache(poster) => (Decoded::Ready(poster), true),240                DiskResult::Source(Some(poster)) => (Decoded::Ready(poster), false),241                DiskResult::Source(None) => (Decoded::Failed, false),242            };243            {244                let (lock, _) = &*shared;245                let mut state = lock.lock().expect("the store mutex is never poisoned");246                if from_cache {247                    state.counts.from_cache += 1;248                } else {249                    state.counts.from_source += 1;250                }251                state.cache.insert(key.clone(), value);252                state.queue.finish(&key);253                // The mark is set under the same lock as the insert254                // and before the wake fires, so the loop the wake255                // starts reads a mark the insert already left.256                state.delivered = true;257            }258            (*waker)();259        }260    })261}262263#[cfg(test)]264mod tests;
src/posters/volumes.rs 99.6%
1// The adapter between the store and the views. It holds one volume2// root per library, refuses an art path that leaves its root, and3// wraps a decoded buffer in the handle the canvas draws.45use std::collections::HashMap;6use std::path::{Component, Path, PathBuf};7use std::sync::{Arc, Mutex};89use iced_widget::core::Bytes;1011use super::store::ArtStore;12use super::{Art, Fit, PosterCounts, Posters};13use crate::harness::Waker;14use crate::views::wall;1516// The cache size in posters, from the head-to-head: its wall held 9617// decoded posters inside the 115 MB the whole client used.18pub const CACHED_POSTERS: usize = 96;1920/// How many page-size backdrops the cache holds beside the posters. A21/// backdrop at 1920x1080 is 8.3 MB decoded, so three of them cost 24.922/// MB. Plan 22's proof measures what that costs on the box.23pub const CACHED_BACKDROPS: usize = 3;2425/// The cache bound in bytes for a window this size: the head-to-head's26/// poster count at the size this wall draws one slot, and the backdrops27/// a page draws at the size of the window, four bytes to the pixel.28pub fn budget(size: (u32, u32)) -> usize {29    let (width, height) = size;30    let cells = wall::cells(width as f32, wall::POSTER, wall::COLUMNS);31    CACHED_POSTERS * cells.poster_width as usize * cells.poster_height as usize * 432        + CACHED_BACKDROPS * width as usize * height as usize * 433}3435/// The poster store as the views see it: the library roots, the decode36/// cache under them, and the loop's wake handle.37pub struct Volumes {38    store: ArtStore,39    // The wake handle arrives after the store is built, because the40    // harness owns the loop it wakes. Every worker fires through this41    // cell, so a handle set late still reaches decodes queued early.42    wake: Arc<Mutex<Option<Waker>>>,43}4445impl Volumes {46    /// A store over these library roots, keyed by the catalog's47    /// `library` column, holding decoded posters under `budget` bytes.48    pub fn new(roots: HashMap<String, PathBuf>, budget: usize) -> Self {49        Self::with_cache_dir(roots, budget, None)50    }5152    /// A store with a disk cache where `cache_dir` names one.53    pub fn with_cache_dir(54        roots: HashMap<String, PathBuf>,55        budget: usize,56        cache_dir: Option<PathBuf>,57    ) -> Self {58        let wake = Arc::new(Mutex::new(None::<Waker>));59        let held = wake.clone();60        let waker: Waker = Arc::new(move || {61            let wake = held62                .lock()63                .expect("the wake cell is never poisoned")64                .clone();65            if let Some(wake) = wake {66                wake();67            }68        });69        Self {70            store: ArtStore::with_cache_dir(roots, budget, waker, cache_dir),71            wake,72        }73    }7475    // The handles borrow the same pixels the cache holds, so the frame76    // copies none of them. A cached decode builds its handles once and77    // holds them beside its pixels, so a redraw hands the renderer the78    // ids it already uploaded and uploads nothing again.79    fn decoded(80        &mut self,81        library: &str,82        art: &str,83        width: u32,84        height: u32,85        fit: Fit,86    ) -> Option<Art> {87        if !contained(art) {88            return None;89        }90        let poster = self.store.poster(library, art, width, height, fit)?;91        let built = poster.art.get_or_init(|| {92            Art::new(93                poster.width,94                poster.height,95                Bytes::from_owner(poster.rgba.clone()),96            )97        });98        Some(built.clone())99    }100}101102// The catalog's art path is data from a volume this client does not103// control, so a path that leaves its library root is refused, never104// joined onto the root and opened. `..` climbs out of the root, and an105// absolute path makes `Path::join` discard the root entirely.106fn contained(art: &str) -> bool {107    Path::new(art)108        .components()109        .all(|part| matches!(part, Component::Normal(_) | Component::CurDir))110}111112impl Posters for Volumes {113    fn poster(&mut self, library: &str, art: &str, width: u32, height: u32) -> Option<Art> {114        self.decoded(library, art, width, height, Fit::Cover)115    }116117    fn fitted(&mut self, library: &str, art: &str, width: u32, height: u32) -> Option<Art> {118        self.decoded(library, art, width, height, Fit::Contain)119    }120121    fn file(&self, library: &str, path: &str) -> Option<PathBuf> {122        if !contained(path) {123            return None;124        }125        Some(self.store.root(library)?.join(path))126    }127128    fn delivered(&mut self) -> bool {129        self.store.delivered()130    }131132    fn counts(&self) -> PosterCounts {133        self.store.counts()134    }135136    fn wake_by(&mut self, wake: Waker) {137        *self.wake.lock().expect("the wake cell is never poisoned") = Some(wake);138    }139}140141#[cfg(test)]142mod tests {143    use std::sync::mpsc;144    use std::time::Duration;145146    use iced_widget::core::Rectangle;147    use iced_widget::image::Handle;148    use image::{Rgb, RgbImage};149    use tempfile::TempDir;150151    use super::*;152153    const DEADLINE: Duration = Duration::from_secs(10);154155    fn handles(art: &Art) -> Vec<Handle> {156        let (width, height) = art.size();157        art.bands(Rectangle {158            x: 0.0,159            y: 0.0,160            width: width as f32,161            height: height as f32,162        })163        .map(|(_, handle)| handle)164        .collect()165    }166167    fn ids(art: &Art) -> Vec<iced_widget::core::image::Id> {168        handles(art).iter().map(Handle::id).collect()169    }170171    fn volume(dir: &TempDir) -> Volumes {172        let art = RgbImage::from_pixel(120, 180, Rgb([40, 90, 160]));173        art.save(dir.path().join("poster.jpg")).unwrap();174        let roots = HashMap::from([("local/movies".to_owned(), dir.path().to_path_buf())]);175        Volumes::new(roots, 1 << 20)176    }177178    #[test]179    fn a_decoded_poster_becomes_a_handle_of_the_drawn_size() {180        let dir = TempDir::new().unwrap();181        let mut volumes = volume(&dir);182        let (sender, receiver) = mpsc::channel();183        volumes.wake_by(Arc::new(move || {184            let _ = sender.send(());185        }));186187        assert!(188            volumes189                .poster("local/movies", "poster.jpg", 40, 60)190                .is_none()191        );192        receiver.recv_timeout(DEADLINE).unwrap();193194        let art = volumes195            .poster("local/movies", "poster.jpg", 40, 60)196            .expect("the decode landed");197        assert_eq!(art.size(), (40, 60));198        let drawn = handles(&art);199        assert_eq!(drawn.len(), 1);200        let Handle::Rgba {201            width,202            height,203            pixels,204            ..205        } = &drawn[0]206        else {207            panic!("a decoded poster is an Rgba handle");208        };209        assert_eq!((*width, *height), (40, 60));210        assert_eq!(pixels.len(), 40 * 60 * 4);211        assert_eq!(212            volumes.counts(),213            PosterCounts {214                from_cache: 0,215                from_source: 1,216            }217        );218    }219220    #[test]221    fn two_asks_for_one_decode_answer_the_same_handles() {222        let dir = TempDir::new().unwrap();223        let mut volumes = volume(&dir);224        let (sender, receiver) = mpsc::channel();225        volumes.wake_by(Arc::new(move || {226            let _ = sender.send(());227        }));228229        assert!(230            volumes231                .poster("local/movies", "poster.jpg", 40, 60)232                .is_none()233        );234        receiver.recv_timeout(DEADLINE).unwrap();235236        let first = volumes237            .poster("local/movies", "poster.jpg", 40, 60)238            .unwrap();239        let again = volumes240            .poster("local/movies", "poster.jpg", 40, 60)241            .unwrap();242        assert_eq!(ids(&first), ids(&again));243    }244245    #[test]246    fn a_fitted_ask_keeps_the_whole_image_inside_its_box() {247        let dir = TempDir::new().unwrap();248        let mut volumes = volume(&dir);249        let wide = RgbImage::from_pixel(120, 40, Rgb([200, 200, 200]));250        wide.save(dir.path().join("logo.png")).unwrap();251        let (sender, receiver) = mpsc::channel();252        volumes.wake_by(Arc::new(move || {253            let _ = sender.send(());254        }));255256        assert!(volumes.fitted("local/movies", "logo.png", 60, 60).is_none());257        receiver.recv_timeout(DEADLINE).unwrap();258        let fitted = volumes.fitted("local/movies", "logo.png", 60, 60).unwrap();259        assert_eq!(fitted.size(), (60, 20));260261        assert!(volumes.poster("local/movies", "logo.png", 60, 60).is_none());262        receiver.recv_timeout(DEADLINE).unwrap();263        let covered = volumes.poster("local/movies", "logo.png", 60, 60).unwrap();264        assert_eq!(covered.size(), (60, 60));265    }266267    #[test]268    fn a_decode_marks_one_delivery_and_the_mark_clears() {269        let dir = TempDir::new().unwrap();270        let mut volumes = volume(&dir);271        let (sender, receiver) = mpsc::channel();272        volumes.wake_by(Arc::new(move || {273            let _ = sender.send(());274        }));275276        assert!(!volumes.delivered());277        assert!(278            volumes279                .poster("local/movies", "poster.jpg", 24, 36)280                .is_none()281        );282        receiver.recv_timeout(DEADLINE).unwrap();283284        assert!(volumes.delivered());285        assert!(!volumes.delivered());286    }287288    #[test]289    fn a_path_that_leaves_its_root_is_refused() {290        let dir = TempDir::new().unwrap();291        let mut volumes = volume(&dir);292        assert!(!contained("../poster.jpg"));293        assert!(!contained("art/../../poster.jpg"));294        assert!(!contained("/etc/hosts"));295        assert!(contained("art/./poster.jpg"));296        assert!(297            volumes298                .poster("local/movies", "../poster.jpg", 40, 60)299                .is_none()300        );301        assert!(302            volumes303                .fitted("local/movies", "../poster.jpg", 40, 60)304                .is_none()305        );306    }307308    #[test]309    fn a_wake_before_the_handle_arrives_is_dropped() {310        let dir = TempDir::new().unwrap();311        let mut volumes = volume(&dir);312        assert!(313            volumes314                .poster("local/movies", "poster.jpg", 8, 12)315                .is_none()316        );317        let (sender, receiver) = mpsc::channel();318        volumes.wake_by(Arc::new(move || {319            let _ = sender.send(());320        }));321        assert!(volumes.poster("local/movies", "other.jpg", 8, 12).is_none());322        receiver.recv_timeout(DEADLINE).unwrap();323    }324325    #[test]326    fn a_file_beside_the_art_resolves_against_the_librarys_root() {327        let dir = TempDir::new().unwrap();328        let volumes = volume(&dir);329        assert_eq!(330            volumes.file("local/movies", ".contributors/One/biography.txt"),331            Some(dir.path().join(".contributors/One/biography.txt"))332        );333        assert_eq!(volumes.file("local/movies", "../escape.txt"), None);334        assert_eq!(volumes.file("local/none", "biography.txt"), None);335    }336337    #[test]338    fn the_budget_holds_the_head_to_heads_posters_and_a_few_backdrops() {339        let cells = wall::cells(1920.0, wall::POSTER, wall::COLUMNS);340        let one = cells.poster_width as usize * cells.poster_height as usize * 4;341        let backdrops = CACHED_BACKDROPS * 1920 * 1080 * 4;342        assert_eq!(budget((1920, 1080)), CACHED_POSTERS * one + backdrops);343        assert!(budget((1280, 720)) < budget((1920, 1080)));344    }345}
src/sample.rs 99.7%
1// A deterministic invented catalog, so the binary browses something2// before the sidecar source lands. Every name here is synthesized; nothing3// resembles a real library.45use crate::catalog::franchise;6use crate::catalog::recency::{self, Candidate};7use crate::catalog::{8    Answer, Credit, Credits, Episode, FileFacts, Franchise, FranchiseEntry, GenreEntry,9    LibraryEntry, Membership, MovieDetails, MovieSet, Person, PlayItem, Query, Selection,10    SeriesDetails, Slot, Sort, Source, TILES, Title, library_name, pool,11};12use crate::harness::Waker;13use crate::posters::{Art, Posters};1415// The invented people of the sample catalog.16pub mod people;1718// The invented genres and the invented pool the day's draw reads.19mod draw;2021// The invented franchises: the two orders the strip and the franchise22// page draw.23mod orders;2425// The search index over the invented rows, so a local run with no26// catalog and a test search the way a run over a sidecar does.27mod search;2829// Enough movies to exercise the wall's culling, near the30// head-to-head's five thousand.31const MOVIES: i64 = 2987;3233// A small series library, enough to walk every level.34const SERIALS: i64 = 12;3536// How many of the first movies belong to a set, and how many members a37// set holds. The sets sit at the front of the wall, so a headless run38// reaches a page with a strip in two presses.39const IN_SETS: i64 = 12;40const PER_SET: i64 = 3;4142// The two libraries' names, which every invented row carries.43const FEATURES: &str = "sample/features";44const SERIALS_LIBRARY: &str = "sample/serials";4546// The invented arrivals. They count from one day and the movies fall47// over a spread of days, so the Added strip differs from the Released48// strip. The last three episodes of the last serial's last season arrive49// two days after each airs, so the home page shows standalone episodes50// beside a folded series and titles on one screen. Every other episode51// of a serial arrived on that serial's own import day, one step apart52// per serial, so its series folds. The last serial's import day is later53// than every movie's arrival and more than the fold window before its54// first episode of the season, so under Added the folded series comes55// right after the airing episodes.56const ARRIVALS_FROM: i64 = 1_640_995_200;57const ARRIVAL_SPREAD_DAYS: i64 = 1_400;58const AIRING_EPISODES: i64 = 3;59const AIRING_LAG_DAYS: i64 = 2;60const IMPORT_STEP_DAYS: i64 = 120;61const DAY: i64 = 86_400;6263/// The invented catalog. It holds no state; every answer is a64/// function of its arguments.65#[derive(Debug, Default)]66pub struct Catalog;6768impl Source for Catalog {69    fn libraries(&mut self) -> Vec<LibraryEntry> {70        vec![71            LibraryEntry {72                library: FEATURES.into(),73                kind: "movies".into(),74                items: MOVIES as u64,75                art: newest_movies(),76            },77            LibraryEntry {78                library: SERIALS_LIBRARY.into(),79                kind: "series".into(),80                items: SERIALS as u64,81                // A serial's import day climbs with its number, so the82                // newest-added serials are the last ones invented.83                art: (0..TILES as i64)84                    .map(|step| serial(SERIALS - step).art)85                    .collect(),86            },87        ]88    }8990    fn genres(&mut self) -> Vec<GenreEntry> {91        draw::genres()92    }9394    fn franchises(&mut self) -> Vec<FranchiseEntry> {95        orders::entries()96    }9798    fn wall(&mut self, query: &Query) -> Answer {99        match query {100            Query::Library { library, sort } => Answer {101                name: library_name(library).to_string(),102                slots: titles(library, *sort),103            },104            Query::Person { library, path } => Answer {105                name: people::person(library, path)106                    .map(|person| person.name)107                    .unwrap_or_default(),108                slots: people::works(library, path),109            },110            Query::Set { library, id } => match self.set(library, id) {111                Some(set) => Answer {112                    name: set.title,113                    slots: set114                        .members115                        .into_iter()116                        .map(|member| Slot::of(library, "movies", member))117                        .collect(),118                },119                None => Answer::default(),120            },121            Query::Released { fold } => Answer {122                name: String::new(),123                slots: recency::filled(124                    *fold,125                    recent(|candidate| released_of(candidate).to_string()),126                ),127            },128            Query::Added { fold } => Answer {129                name: String::new(),130                slots: recency::filled(*fold, recent(added_of)),131            },132            Query::Genre { name, order, sort } => Answer {133                name: name.clone(),134                slots: draw::titles(name, *order, *sort),135            },136            Query::Franchise { library, id } => franchise::answer(self.franchise(library, id)),137            Query::Search { text } => Answer {138                name: String::new(),139                slots: search::index().find(text),140            },141        }142    }143144    fn index_size(&mut self) -> Option<crate::catalog::search::Size> {145        Some(search::index().size())146    }147148    fn pool(&mut self) -> Vec<pool::Candidate> {149        draw::pool()150    }151152    // The invented details of one series, so a run with no catalog opens153    // a series page with a header, dividers, and stills.154    fn series(&mut self, _library: &str, id: &str) -> Option<SeriesDetails> {155        let number = trailing(id);156        if !(1..=SERIALS).contains(&number) {157            return None;158        }159        Some(SeriesDetails {160            title: format!("Serial {number:02}"),161            released: serial_year(number).to_string(),162            duration: 0,163            rating: "TV-14".into(),164            genres: draw::serial_genres(),165            tagline: serial(number).tagline,166            plot: PLOT.repeat(2),167            creators: vec![format!("Creator {number:02}")],168            cast: (1..=6)169                .map(|part| Credit {170                    name: format!("Player {number:02}-{part}"),171                    role: format!("Part {part}"),172                })173                .collect(),174            studios: vec![format!("Studio {number:02}")],175            ratings: ratings(number),176            backdrop: format!("backdrops/serial-{number:02}.jpg"),177            logo: String::new(),178            seasons: seasons(number),179        })180    }181182    fn episodes(&mut self, _library: &str, id: &str) -> Vec<Episode> {183        let number = trailing(id);184        if !(1..=SERIALS).contains(&number) {185            return Vec::new();186        }187        (1..=seasons(number))188            .flat_map(|season| {189                (1..=episodes_in(number, season)).map(move |episode| Episode {190                    id: format!("episode:sample:{number:02}-{season:02}-{episode:02}"),191                    season,192                    episode,193                    title: format!("Segment {episode:02}"),194                    released: format!(195                        "{}-{:02}-{:02}",196                        serial_year(number) + season - 1,197                        1 + episode % 12,198                        1 + episode % 28199                    ),200                    duration: 2_400 + (episode % 7) * 60,201                    plot: format!("Segment {episode:02} of season {season}. {PLOT}"),202                    art: format!("stills/serial-{number:02}-s{season:02}e{episode:02}.jpg"),203                })204            })205            .collect()206    }207208    // The invented details of one movie. Every field is a function of the209    // movie's number, so a run draws the same page every time.210    fn movie(&mut self, _library: &str, id: &str) -> Option<MovieDetails> {211        let number = trailing(id);212        if !(1..=MOVIES).contains(&number) {213            return None;214        }215        let title = movie(number);216        Some(MovieDetails {217            title: title.title,218            released: title.released,219            duration: title.duration,220            rating: title.rating,221            genres: draw::movie_genres(number),222            tagline: tagline(number),223            plot: PLOT.repeat(2),224            directors: vec![format!("Director {number:04}")],225            writers: vec![format!("Writer {number:04}"), "A Second Writer".into()],226            cast: (1..=6)227                .map(|part| Credit {228                    name: format!("Player {number:04}-{part}"),229                    role: format!("Part {part}"),230                })231                .collect(),232            studios: vec![format!("Studio {number:04}"), "A Second Studio".into()],233            ratings: ratings(number),234            set_id: set_of(number),235            backdrop: format!("backdrops/specimen-{number:04}.jpg"),236            logo: String::new(),237            trailer: format!("Specimen {number:04}/trailer.mkv"),238        })239    }240241    fn set(&mut self, _library: &str, id: &str) -> Option<MovieSet> {242        let set = trailing(id);243        let mut members: Vec<Title> = (1..=IN_SETS)244            .filter(|number| set_of(*number) == format!("set:sample:{set:02}"))245            .map(movie)246            .collect();247        if members.is_empty() {248            return None;249        }250        // The catalog answers a set in release order, so the sample251        // answers in that order too.252        members.sort_by(|one, other| (&one.released, &one.id).cmp(&(&other.released, &other.id)));253        Some(MovieSet {254            title: format!("The Specimen Cycle {set:02}"),255            members,256        })257    }258259    fn franchises_of(&mut self, _library: &str, id: &str) -> Vec<Membership> {260        orders::memberships(id)261    }262263    fn franchise(&mut self, library: &str, id: &str) -> Option<Franchise> {264        orders::franchise(library, id)265    }266267    fn credits(&mut self, _library: &str, id: &str) -> Credits {268        people::credits(id)269    }270271    // The invented files of one title: the video the page draws a line272    // for, and two subtitle files beside it.273    fn files(&mut self, _library: &str, item: &str) -> Vec<FileFacts> {274        let number = trailing(item);275        if number == 0 {276            return Vec::new();277        }278        let mut files = vec![FileFacts {279            role: "primary".into(),280            kind: "video".into(),281            container: "mkv".into(),282            video_codec: "x265".into(),283            audio_codec: "AC3".into(),284            width: 1_920,285            height: 804,286            size_bytes: 4_200_000_000 + number * 1_000_000,287            language: String::new(),288        }];289        for language in ["en", "fr"] {290            files.push(FileFacts {291                role: "subtitle".into(),292                kind: "subtitle".into(),293                container: "srt".into(),294                language: language.into(),295                ..FileFacts::default()296            });297        }298        files299    }300301    fn person(&mut self, library: &str, path: &str) -> Option<Person> {302        people::person(library, path)303    }304305    // The sample invents titles and no files, so a select on one starts306    // nothing. A workstation run browses and plays nothing.307    fn play(&mut self, _library: &str, _selection: &Selection) -> Vec<PlayItem> {308        Vec::new()309    }310311    fn changed(&mut self) -> bool {312        false313    }314315    fn wake_by(&mut self, _wake: Waker) {}316}317318// The invented plot, long enough that a page cuts it at its last line.319const PLOT: &str = "A survey party reaches the coppice at dusk and finds the ground already \320     turned. What they take for a season of quiet work becomes a study of the \321     people who left the marks, and of the reason the marks were left at all. ";322323// One library's invented slots: the movies for the features library,324// and the serials for any other, each slot stamped with that library and325// its kind.326// How one plain sort compares two invented slots, in the order the327// sidecar's ORDER BY answers: the release, then the title, which takes328// the place of the catalog's sort key here.329pub(super) fn sorted(sort: Sort, one: &Slot, other: &Slot) -> std::cmp::Ordering {330    let key = |slot: &Slot| slot.title.to_lowercase();331    let title = || key(one).cmp(&key(other));332    match sort {333        Sort::Title => title(),334        Sort::Newest => other.released.cmp(&one.released).then_with(title),335        Sort::Oldest => one.released.cmp(&other.released).then_with(title),336    }337}338339// The sample has no SQL, so it sorts its invented rows here, in the340// same order the sidecar's ORDER BY answers.341fn titles(library: &str, sort: Sort) -> Vec<Slot> {342    let mut slots: Vec<Slot> = match library == FEATURES {343        true => (1..=MOVIES)344            .map(|number| Slot::of(library, "movies", movie(number)))345            .collect(),346        false => (1..=SERIALS)347            .map(|number| serial_slot(library, number))348            .collect(),349    };350    slots.sort_by(|one, other| sorted(sort, one, other));351    slots352}353354// The tagline of one invented movie. Every third movie has none, so a355// card of one falls back to its title.356pub(super) fn tagline(number: i64) -> String {357    match number % 3 == 0 {358        true => String::new(),359        false => format!("The {number:04}th of its kind."),360    }361}362363// One invented serial as a slot, with the season count the sidecar's364// series read carries.365pub(super) fn serial_slot(library: &str, number: i64) -> Slot {366    Slot {367        seasons: seasons(number),368        ..Slot::of(library, "series", serial(number))369    }370}371372// One invented serial, as a title row.373fn serial(number: i64) -> Title {374    Title {375        id: format!("series:sample:{number:02}"),376        title: format!("Serial {number:02}"),377        released: serial_year(number).to_string(),378        art: format!("art/serial-{number:02}.jpg"),379        duration: 0,380        rating: "TV-14".into(),381        tagline: format!("Serial {number:02}, in its own seasons."),382    }383}384385// The year an invented serial started. The last serial starts in the386// year the newest movies came out, so its seasons air after them and the387// home page's strips show episodes.388fn serial_year(number: i64) -> i64 {389    1965 + number * 5390}391392// The recency candidates: every movie and every episode with the393// arrival the sample invents for it, sorted newest first by the key. The394// paging and the fold over them are the catalog's own.395fn recent<K: Ord>(key: impl Fn(&Candidate) -> K) -> Vec<Candidate> {396    let mut catalog = Catalog;397    let mut candidates: Vec<Candidate> = (1..=MOVIES)398        .map(|number| Candidate::Movie {399            slot: Slot::of(FEATURES, "movies", movie(number)),400        })401        .collect();402    for number in 1..=SERIALS {403        for episode in catalog.episodes(SERIALS_LIBRARY, &serial(number).id) {404            candidates.push(Candidate::Episode {405                library: SERIALS_LIBRARY.into(),406                added: arrival(number, &episode),407                season: episode.season,408                number: episode.episode,409                episode: Title {410                    id: episode.id,411                    title: episode.title,412                    released: episode.released,413                    art: episode.art,414                    duration: episode.duration,415                    rating: String::new(),416                    tagline: String::new(),417                },418                series: serial(number),419            });420        }421    }422    candidates.sort_by_key(|candidate| std::cmp::Reverse(key(candidate)));423    candidates424}425426fn released_of(candidate: &Candidate) -> &str {427    match candidate {428        Candidate::Movie { slot } => &slot.released,429        Candidate::Episode { episode, .. } => &episode.released,430    }431}432433fn added_of(candidate: &Candidate) -> i64 {434    match candidate {435        Candidate::Movie { slot } => movie_arrival(trailing(&slot.id)),436        Candidate::Episode { added, .. } => *added,437    }438}439440// The invented arrival of one movie, spread over the years after the441// first arrival day.442fn movie_arrival(number: i64) -> i64 {443    ARRIVALS_FROM + (number * 7_919) % ARRIVAL_SPREAD_DAYS * DAY444}445446// The invented arrival of one episode: two days after it aired for the447// last episodes of the last serial's last season, and the serial's own448// import day everywhere else.449fn arrival(number: i64, episode: &Episode) -> i64 {450    let season = seasons(number);451    let airing = number == SERIALS452        && episode.season == season453        && episode.episode + AIRING_EPISODES > episodes_in(number, season);454    match airing {455        true => recency::date_seconds(&episode.released).unwrap_or(0) + AIRING_LAG_DAYS * DAY,456        false => ARRIVALS_FROM + number * IMPORT_STEP_DAYS * DAY,457    }458}459460// The posters of the newest-added invented movies, which the libraries461// strip draws the features library as, in the order the Added query462// answers them.463fn newest_movies() -> Vec<String> {464    let mut numbers: Vec<i64> = (1..=MOVIES).collect();465    numbers.sort_by_key(|number| std::cmp::Reverse(movie_arrival(*number)));466    numbers467        .into_iter()468        .take(TILES)469        .map(|number| movie(number).art)470        .collect()471}472473// One invented movie, the same row every time.474fn movie(number: i64) -> Title {475    Title {476        id: format!("movie:sample:{number:04}"),477        title: format!("Specimen {number:04}"),478        released: (1900 + (number * 37) % 126).to_string(),479        art: format!("posters/specimen-{number:04}.jpg"),480        duration: 4_800 + (number % 47) * 60,481        rating: "PG-13".into(),482        tagline: tagline(number),483    }484}485486// The invented scores of one title, on the three sites the page draws487// and on TMDb, which it leaves off.488fn ratings(number: i64) -> Vec<(String, f64)> {489    [490        ("imdb", 5.0 + (number % 50) as f64 / 10.0),491        ("metacritic", (30 + number % 70) as f64),492        ("themoviedb", 6.0 + (number % 40) as f64 / 10.0),493        ("tomatometerallcritics", (20 + number % 80) as f64),494    ]495    .into_iter()496    .map(|(name, score)| (name.to_string(), score))497    .collect()498}499500// How many seasons an invented serial holds.501fn seasons(number: i64) -> i64 {502    2 + number % 3503}504505// How many episodes one season of an invented serial holds.506fn episodes_in(number: i64, season: i64) -> i64 {507    6 + (number + season) % 5508}509510// The set a movie belongs to. The first movies fall into sets of three,511// and every movie after them belongs to none.512fn set_of(number: i64) -> String {513    if number > IN_SETS {514        return String::new();515    }516    format!("set:sample:{:02}", (number - 1) / PER_SET + 1)517}518519// The digits at the end of a sample id seed that item's structure,520// so every serial gets its own season and episode counts and gets the same521// ones on every run.522fn trailing(id: &str) -> i64 {523    id.rsplit(':')524        .next()525        .and_then(|digits| digits.parse().ok())526        .unwrap_or(0)527}528529/// A poster store with nothing in it, so every slot draws the530/// placeholder until the real store lands.531#[derive(Debug, Default)]532pub struct NoArt;533534impl Posters for NoArt {535    fn poster(&mut self, _library: &str, _art: &str, _width: u32, _height: u32) -> Option<Art> {536        None537    }538}539540#[cfg(test)]541mod tests;
src/sample/draw.rs 100.0%
1// The invented genres and the invented pool of the sample catalog, so2// a run with no catalog opens a home page with drawn strips.34use super::{5    ARRIVALS_FROM, DAY, FEATURES, IMPORT_STEP_DAYS, IN_SETS, MOVIES, PER_SET, SERIALS,6    SERIALS_LIBRARY, movie, movie_arrival, people, serial_slot, sorted,7};8use crate::catalog::pool::Candidate;9use crate::catalog::{GenreEntry, GenreSort, Order, Query, Slot, TILE_CANDIDATES, unrepeated};1011// The invented genres. Every movie carries one or two of them by its12// number, so every genre has titles that lead with it and titles that13// trail with it.14const GENRES: [&str; 5] = ["Drama", "Mystery", "Western", "Comedy", "Thriller"];1516/// The genres of one invented movie in the sidecar's order: the lead by17/// the number, and a second where it differs from the lead.18pub fn movie_genres(number: i64) -> Vec<String> {19    let lead = GENRES[(number % 5) as usize];20    let second = GENRES[((number / 5) % 5) as usize];21    let mut genres = vec![lead.to_string()];22    if second != lead {23        genres.push(second.to_string());24    }25    genres26}2728/// Every invented serial carries the same two genres, as its page29/// says.30pub fn serial_genres() -> Vec<String> {31    vec!["Drama".into(), "Mystery".into()]32}3334// The invented arrival of one serial: its import day.35fn serial_arrival(number: i64) -> i64 {36    ARRIVALS_FROM + number * IMPORT_STEP_DAYS * DAY37}3839/// The genre query as the sample answers it: every movie and serial40/// that carries the genre, in the order the sort names, then by library41/// and id, which is the sidecar's own order.42// `Leads` keeps the rank-first order. The three plain sorts drop the43// rank and read the way the library wall's do.44pub fn titles(name: &str, order: Order, sort: GenreSort) -> Vec<Slot> {45    let mut found: Vec<(usize, i64, Slot)> = Vec::new();46    for number in 1..=MOVIES {47        if let Some(rank) = movie_genres(number).iter().position(|genre| genre == name) {48            found.push((49                rank,50                movie_arrival(number),51                Slot::of(FEATURES, "movies", movie(number)),52            ));53        }54    }55    for number in 1..=SERIALS {56        if let Some(rank) = serial_genres().iter().position(|genre| genre == name) {57            found.push((58                rank,59                serial_arrival(number),60                serial_slot(SERIALS_LIBRARY, number),61            ));62        }63    }64    found.sort_by(|(rank, added, slot), (other_rank, other_added, other)| {65        match sort {66            GenreSort::Leads => rank.cmp(other_rank).then_with(|| match order {67                Order::Released => other.released.cmp(&slot.released),68                Order::Added => other_added.cmp(added),69            }),70            GenreSort::By(plain) => sorted(plain, slot, other),71        }72        .then_with(|| slot.library.cmp(&other.library))73        .then_with(|| slot.id.cmp(&other.id))74    });75    found.into_iter().map(|(_, _, slot)| slot).collect()76}7778/// Every invented genre as the genres strip draws it, in name order: the79/// count of the titles that carry it, and the posters of its candidates,80/// which the genre read already orders the way the sidecar orders them.81pub fn genres() -> Vec<GenreEntry> {82    let mut names = GENRES;83    names.sort_unstable();84    let mut entries: Vec<GenreEntry> = names85        .into_iter()86        .map(|name| {87            let slots = titles(name, Order::Released, GenreSort::Leads);88            GenreEntry {89                name: name.to_string(),90                titles: slots.len() as u64,91                art: slots92                    .iter()93                    .filter(|slot| !slot.art.is_empty())94                    .take(TILE_CANDIDATES)95                    .map(|slot| (slot.library.clone(), slot.art.clone()))96                    .collect(),97            }98        })99        .collect();100    unrepeated(&mut entries);101    entries102}103104/// The invented pool: every genre weighed as the catalog weighs it, the105/// one invented person over the works floor, and every invented set with106/// its members.107pub fn pool() -> Vec<Candidate> {108    let mut pool: Vec<Candidate> = GENRES109        .iter()110        .map(|name| {111            let mut weight = 0;112            for number in 1..=MOVIES {113                weight += weight_of(&movie_genres(number), name);114            }115            weight += SERIALS as u64 * weight_of(&serial_genres(), name);116            Candidate {117                query: Query::Genre {118                    name: name.to_string(),119                    order: Order::Released,120                    sort: GenreSort::default(),121                },122                name: name.to_string(),123                weight,124            }125        })126        .collect();127    pool.push(Candidate {128        query: Query::Person {129            library: FEATURES.into(),130            path: people::PROLIFIC.to_string(),131        },132        name: people::PROLIFIC_NAME.into(),133        weight: people::works(FEATURES, people::PROLIFIC).len() as u64,134    });135    for set in 1..=IN_SETS / PER_SET {136        pool.push(Candidate {137            query: Query::Set {138                library: FEATURES.into(),139                id: format!("set:sample:{set:02}"),140            },141            name: format!("The Specimen Cycle {set:02}"),142            weight: PER_SET as u64,143        });144    }145    pool146}147148// What one title adds to a genre's weight: two where it leads with the149// genre, one where it trails with it, none otherwise.150fn weight_of(genres: &[String], name: &str) -> u64 {151    match genres.iter().position(|genre| genre == name) {152        Some(0) => 2,153        Some(_) => 1,154        None => 0,155    }156}157158#[cfg(test)]159mod tests {160    use super::*;161    use crate::catalog::pool::Kind;162    use crate::catalog::recency::WORKS_FLOOR;163    use crate::catalog::{Sort, Source};164    use crate::sample::Catalog;165166    #[test]167    fn a_movies_genres_lead_by_its_number_and_never_repeat() {168        assert_eq!(movie_genres(1), ["Mystery", "Drama"]);169        assert_eq!(movie_genres(5), ["Drama", "Mystery"]);170        assert_eq!(movie_genres(6), ["Mystery"]);171        assert_eq!(movie_genres(12), ["Western"]);172    }173174    #[test]175    fn a_genre_wall_leads_with_the_titles_whose_first_genre_it_is() {176        let mut catalog = Catalog;177        let answer = catalog.wall(&Query::Genre {178            name: "Western".into(),179            order: Order::Released,180            sort: GenreSort::Leads,181        });182        assert_eq!(answer.name, "Western");183        assert!(!answer.slots.is_empty());184        let ranks: Vec<usize> = answer185            .slots186            .iter()187            .map(|slot| {188                movie_genres(crate::sample::trailing(&slot.id))189                    .iter()190                    .position(|genre| genre == "Western")191                    .expect("every slot carries the genre")192            })193            .collect();194        assert!(ranks.windows(2).all(|pair| pair[0] <= pair[1]));195        assert_eq!(ranks[0], 0);196        assert_eq!(*ranks.last().unwrap(), 1);197        assert!(answer.slots.iter().all(|slot| slot.kind == "movies"));198    }199200    #[test]201    fn drama_reads_the_serials_beside_the_movies_and_by_arrival_on_request() {202        let released = titles("Drama", Order::Released, GenreSort::Leads);203        assert!(released.iter().any(|slot| slot.kind == "series"));204        let leading: Vec<&Slot> = released205            .iter()206            .take_while(|slot| {207                slot.kind == "series"208                    || movie_genres(crate::sample::trailing(&slot.id))[0] == "Drama"209            })210            .collect();211        assert!(212            leading213                .windows(2)214                .all(|pair| pair[0].released >= pair[1].released)215        );216        let added = titles("Drama", Order::Added, GenreSort::Leads);217        assert_eq!(added.len(), released.len());218        assert_ne!(added[0].id, released[0].id);219        assert!(titles("Musical", Order::Released, GenreSort::Leads).is_empty());220    }221222    #[test]223    fn a_genre_wall_answers_each_of_its_four_orders() {224        let ids = |sort| -> Vec<String> {225            titles("Western", Order::Released, sort)226                .into_iter()227                .map(|slot| slot.id)228                .collect()229        };230        let leads = ids(GenreSort::Leads);231        let newest = ids(GenreSort::By(Sort::Newest));232        let oldest = ids(GenreSort::By(Sort::Oldest));233        let titled = ids(GenreSort::By(Sort::Title));234        assert_eq!(leads.len(), newest.len());235        assert_ne!(leads, newest);236237        let released = |id: &str| movie(crate::sample::trailing(id)).released;238        assert!(239            newest240                .windows(2)241                .all(|pair| released(&pair[0]) >= released(&pair[1]))242        );243        assert!(244            oldest245                .windows(2)246                .all(|pair| released(&pair[0]) <= released(&pair[1]))247        );248        let titles = |id: &str| movie(crate::sample::trailing(id)).title.to_lowercase();249        assert!(250            titled251                .windows(2)252                .all(|pair| titles(&pair[0]) <= titles(&pair[1]))253        );254    }255256    #[test]257    fn the_pool_holds_every_genre_the_prolific_writer_and_every_set() {258        let pool = Catalog.pool();259        let genres: Vec<&str> = pool260            .iter()261            .filter(|candidate| candidate.kind() == Kind::Genre)262            .map(|candidate| candidate.name.as_str())263            .collect();264        assert_eq!(genres, GENRES);265        assert!(pool.iter().all(|candidate| candidate.weight > 0));266267        let people: Vec<&Candidate> = pool268            .iter()269            .filter(|candidate| candidate.kind() == Kind::Person)270            .collect();271        assert_eq!(people.len(), 1);272        assert_eq!(people[0].name, people::PROLIFIC_NAME);273        assert!(people[0].weight > WORKS_FLOOR);274        assert_eq!(275            Catalog.wall(&people[0].query).slots.len() as u64,276            people[0].weight277        );278279        let sets: Vec<&Candidate> = pool280            .iter()281            .filter(|candidate| candidate.kind() == Kind::Set)282            .collect();283        assert_eq!(sets.len() as i64, IN_SETS / PER_SET);284        assert_eq!(sets[0].name, "The Specimen Cycle 01");285        assert_eq!(Catalog.wall(&sets[0].query).name, sets[0].name);286    }287288    #[test]289    fn every_invented_genre_carries_its_count_and_a_mosaic_of_its_own_posters() {290        let entries = Catalog.genres();291        let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();292        let mut sorted = GENRES;293        sorted.sort_unstable();294        assert_eq!(names, sorted);295        assert!(296            entries297                .iter()298                .all(|entry| entry.art.len() == crate::catalog::TILES)299        );300        assert!(301            entries302                .iter()303                .flat_map(|entry| &entry.art)304                .all(305                    |(library, art)| (library == FEATURES || library == SERIALS_LIBRARY)306                        && !art.is_empty()307                )308        );309        let western = entries310            .iter()311            .find(|entry| entry.name == "Western")312            .expect("the sample invents Western");313        assert_eq!(314            western.titles,315            titles("Western", Order::Released, GenreSort::Leads).len() as u64316        );317        assert!(western.art.iter().all(|(library, _)| library == FEATURES));318    }319320    #[test]321    fn a_genres_weight_counts_a_leading_title_twice() {322        let western = Catalog323            .pool()324            .into_iter()325            .find(|candidate| candidate.name == "Western")326            .expect("the pool holds Western");327        let leading = (1..=MOVIES)328            .filter(|number| movie_genres(*number)[0] == "Western")329            .count() as u64;330        let carrying = titles("Western", Order::Released, GenreSort::Leads).len() as u64;331        assert_eq!(western.weight, carrying + leading);332    }333}
src/sample/orders.rs 100.0%
1// The invented franchises of the sample catalog, so the strip and the2// franchise page draw before any git repository is scanned. The saga holds a3// calendar, nested eras, three universes, an entry in two of them, one serial4// cut into two runs, and two gaps. The cycle holds no calendar and no5// universes, which is the page with no rail and no time. Every name here is6// invented, as everything else in the sample is.78use crate::catalog::FranchiseEntry;9use crate::catalog::franchise::{Calendar, Entry, Era, Franchise, Held, MOVIE, Membership, SERIES};1011use super::{FEATURES, SERIALS_LIBRARY, movie, serial};1213/// The `Library` of kind franchises the sample invents.14pub const ORDERS: &str = "sample/orders";1516/// The two franchises the sample holds.17pub const SAGA: &str = "franchise:name:the-specimen-saga";18pub const CYCLE: &str = "franchise:name:the-marsh-cycle";1920// The three universes of the saga: its own, and the two an entry names.21const COPPICE: &str = "The Coppice";22const FEN: &str = "The Fen";23const MARSH: &str = "The Marsh";2425// The release years the two gaps of the saga carry. One is far enough26// ahead of any run to draw as coming, and the other is behind every run27// and draws as missing.28const AHEAD: i64 = 2099;29const BEHIND: i64 = 1961;3031/// The two franchises, in the order a wall of them draws.32pub fn franchises() -> Vec<Franchise> {33    vec![saga(), cycle()]34}3536/// The two invented franchises as the home page's strip draws them. The saga37/// carries art and the cycle carries none, so the strip draws one poster and38/// one tile of words.39pub fn entries() -> Vec<FranchiseEntry> {40    franchises()41        .into_iter()42        .map(|franchise| {43            // The saga carries its own art, and the cycle carries none,44            // so the cycle draws the poster of its first held member the45            // way the catalog's own read answers one.46            let first = franchise47                .entries48                .iter()49                .find_map(|entry| entry.held.clone())50                .unwrap_or_default();51            let (art, art_library) = match franchise.art.is_empty() {52                true => (first.art, first.library),53                false => (franchise.art, franchise.library.clone()),54            };55            FranchiseEntry {56                movies: counted(&franchise.entries, MOVIE),57                series: counted(&franchise.entries, SERIES),58                library: franchise.library,59                id: franchise.id,60                title: franchise.title,61                art,62                art_library,63                slug: String::new(),64            }65        })66        .collect()67}6869/// One invented franchise by its id, and nothing where the sample invented70/// none.71pub fn franchise(library: &str, id: &str) -> Option<Franchise> {72    if library != ORDERS {73        return None;74    }75    franchises().into_iter().find(|held| held.id == id)76}7778/// Every franchise one invented title belongs to, with the members the79/// sample's own libraries hold.80pub fn memberships(id: &str) -> Vec<Membership> {81    franchises()82        .into_iter()83        .filter(|franchise| {84            franchise85                .entries86                .iter()87                .any(|entry| entry.held.as_ref().is_some_and(|held| held.id == id))88        })89        .map(|franchise| Membership {90            movies: counted(&franchise.entries, MOVIE),91            series: counted(&franchise.entries, SERIES),92            library: franchise.library,93            id: franchise.id,94            title: franchise.title,95            members: franchise96                .entries97                .into_iter()98                .filter(|entry| entry.held.is_some())99                .collect(),100        })101        .collect()102}103104// How many entries of one order are of this kind, which is the scope a105// strip's heading carries.106fn counted(entries: &[Entry], kind: &str) -> i64 {107    entries.iter().filter(|entry| entry.kind == kind).count() as i64108}109110// The saga: the franchise that exercises every part of the page.111fn saga() -> Franchise {112    Franchise {113        library: ORDERS.into(),114        id: SAGA.into(),115        title: "The Specimen Saga".into(),116        art: "art/the-specimen-saga.jpg".into(),117        universe: COPPICE.into(),118        calendar: Some(Calendar {119            unit: "years".into(),120            zero: "the Survey".into(),121            before: "BS".into(),122            after: "AS".into(),123        }),124        // The wider era holds the narrower one, so the rail draws two125        // lanes and the phase nests inside the saga.126        eras: vec![127            Era {128                name: "The Long Survey".into(),129                from: -40.0,130                to: 40.0,131            },132            Era {133                name: "The Coppice Years".into(),134                from: -5.0,135                to: 5.0,136            },137        ],138        entries: vec![139            film(1, 1, (-32.0, -32.0), &[]),140            // These two spans overlap in different universes, so the141            // page packs them onto one row.142            film(2, 2, (-30.0, -28.0), &[FEN]),143            film(3, 3, (-29.0, -27.0), &[MARSH]),144            // One serial the story cuts into two runs: the same member145            // at two positions, each with its own span and its own146            // count of episodes.147            run(4, 1, (-22.0, -20.0), 9),148            run(5, 1, (-19.0, -18.0), 10),149            gap(6, MOVIE, "Specimen 9001", AHEAD, Some((0.0, 0.0))),150            gap(7, MOVIE, "Specimen 9002", BEHIND, Some((2.0, 2.0))),151            // One entry in three universes, which draws as a banner152            // across their columns.153            film(8, 4, (10.0, 12.0), &[COPPICE, FEN, MARSH]),154            // An entry with no time joins no era on the rail.155            gap(9, SERIES, "Serial 99", 0, None),156        ],157    }158}159160// The cycle: a franchise with no clock and one universe, which is the161// page with no time and no rail.162fn cycle() -> Franchise {163    Franchise {164        library: ORDERS.into(),165        id: CYCLE.into(),166        title: "The Marsh Cycle".into(),167        art: String::new(),168        universe: String::new(),169        calendar: None,170        eras: Vec::new(),171        entries: (5..=7)172            .map(|number| Entry {173                timed: false,174                ..film(number - 4, number, (0.0, 0.0), &[])175            })176            .collect(),177    }178}179180// One entry the features library holds, by the movie's own number.181fn film(position: i64, number: i64, span: (f64, f64), universes: &[&str]) -> Entry {182    let title = movie(number);183    Entry {184        position,185        kind: MOVIE.into(),186        alias: format!("movie:sample:{number}"),187        title: title.title.clone(),188        released: title.released.clone(),189        release_year: title.released.parse().unwrap_or_default(),190        timed: true,191        from: span.0,192        to: span.1,193        universes: universes.iter().map(|name| name.to_string()).collect(),194        held: Some(Held {195            library: FEATURES.into(),196            id: title.id,197            kind: "movies".into(),198            title: title.title,199            arts: vec![200                title.art.clone(),201                format!("backdrops/specimen-{number:04}.jpg"),202            ],203            art: title.art,204            released: title.released,205            slug: format!("specimen-{number:04}"),206            tagline: super::tagline(number),207            plot: super::PLOT.repeat(2),208            duration: title.duration,209        }),210        episodes: 0,211    }212}213214// One run of a serial the serials library holds, with the episodes the215// run counts.216fn run(position: i64, number: i64, span: (f64, f64), episodes: i64) -> Entry {217    let title = serial(number);218    Entry {219        position,220        kind: SERIES.into(),221        alias: format!("series:sample:{number}"),222        title: title.title.clone(),223        released: title.released.clone(),224        release_year: title.released.parse().unwrap_or_default(),225        timed: true,226        from: span.0,227        to: span.1,228        universes: Vec::new(),229        held: Some(Held {230            library: SERIALS_LIBRARY.into(),231            id: title.id,232            kind: "series".into(),233            title: title.title,234            arts: vec![235                title.art.clone(),236                format!("backdrops/serial-{number:02}.jpg"),237            ],238            art: title.art,239            released: title.released,240            slug: format!("serial-{number:02}"),241            tagline: format!("Serial {number:02}, in its own seasons."),242            plot: super::PLOT.repeat(2),243            duration: 0,244        }),245        episodes,246    }247}248249// One entry no sample library holds, which draws as a gap in the order.250// A gap with a release year of 0 is one the file gives no year.251fn gap(252    position: i64,253    kind: &str,254    title: &str,255    release_year: i64,256    span: Option<(f64, f64)>,257) -> Entry {258    let (timed, from, to) = match span {259        Some((from, to)) => (true, from, to),260        None => (false, 0.0, 0.0),261    };262    Entry {263        position,264        kind: kind.into(),265        alias: format!("{kind}:sample:{position:02}"),266        title: title.into(),267        released: match release_year > 0 {268            true => release_year.to_string(),269            false => String::new(),270        },271        release_year,272        timed,273        from,274        to,275        universes: Vec::new(),276        held: None,277        episodes: 0,278    }279}280281#[cfg(test)]282mod tests {283    use super::*;284    use crate::catalog::franchise::Standing;285286    #[test]287    fn the_saga_holds_a_calendar_nested_eras_and_three_universes() {288        let saga = franchise(ORDERS, SAGA).expect("the sample invents the saga");289        assert!(saga.calendar.is_some());290        assert_eq!(saga.universe, COPPICE);291        assert_eq!(saga.eras.len(), 2);292        assert!(saga.eras[0].holds(&saga.eras[1]));293        let named: Vec<&str> = saga294            .entries295            .iter()296            .flat_map(|entry| entry.universes.iter().map(String::as_str))297            .collect();298        assert!(named.contains(&FEN));299        assert!(named.contains(&MARSH));300    }301302    #[test]303    fn the_saga_cuts_one_serial_into_two_runs() {304        let saga = franchise(ORDERS, SAGA).expect("the sample invents the saga");305        let runs: Vec<&Entry> = saga306            .entries307            .iter()308            .filter(|entry| entry.alias == "series:sample:1")309            .collect();310        assert_eq!(runs.len(), 2);311        assert_eq!((runs[0].position, runs[1].position), (4, 5));312        assert_eq!((runs[0].episodes, runs[1].episodes), (9, 10));313    }314315    #[test]316    fn the_saga_holds_a_gap_ahead_of_today_and_one_behind() {317        let saga = franchise(ORDERS, SAGA).expect("the sample invents the saga");318        let gaps: Vec<Standing> = saga319            .entries320            .iter()321            .filter(|entry| entry.held.is_none())322            .map(|entry| entry.standing("2026-06-15"))323            .collect();324        assert_eq!(325            gaps,326            [Standing::Coming, Standing::Missing, Standing::Missing]327        );328    }329330    #[test]331    fn one_entry_of_the_saga_names_three_universes_and_one_names_none() {332        let saga = franchise(ORDERS, SAGA).expect("the sample invents the saga");333        assert_eq!(saga.entries[7].universes, [COPPICE, FEN, MARSH]);334        assert!(saga.entries[0].universes.is_empty());335    }336337    #[test]338    fn the_cycle_holds_no_calendar_no_universes_and_no_time() {339        let cycle = franchise(ORDERS, CYCLE).expect("the sample invents the cycle");340        assert_eq!(cycle.calendar, None);341        assert!(cycle.eras.is_empty());342        assert!(cycle.universe.is_empty());343        assert!(cycle.entries.iter().all(|entry| !entry.timed));344        assert!(345            cycle346                .entries347                .iter()348                .all(|entry| entry.universes.is_empty() && entry.held.is_some())349        );350    }351352    #[test]353    fn the_home_page_reads_both_invented_franchises() {354        let entries = entries();355        let named: Vec<(&str, &str)> = entries356            .iter()357            .map(|entry| (entry.id.as_str(), entry.title.as_str()))358            .collect();359        assert_eq!(360            named,361            [(SAGA, "The Specimen Saga"), (CYCLE, "The Marsh Cycle")]362        );363        assert!(entries.iter().all(|entry| entry.library == ORDERS));364        // The saga carries its own art, and the cycle draws the poster365        // of the first film its libraries hold.366        assert_eq!(entries[0].art, "art/the-specimen-saga.jpg");367        assert_eq!(entries[0].art_library, ORDERS);368        assert_eq!(entries[1].art, "posters/specimen-0005.jpg");369        assert_eq!(entries[1].art_library, "sample/features");370    }371372    #[test]373    fn a_film_belongs_to_the_franchise_that_names_it() {374        let held = memberships("movie:sample:0001");375        assert_eq!(held.len(), 1);376        assert_eq!(held[0].id, SAGA);377        assert_eq!(held[0].title, "The Specimen Saga");378        assert_eq!(memberships("movie:sample:0005")[0].id, CYCLE);379        assert!(memberships("movie:sample:0099").is_empty());380    }381382    #[test]383    fn a_strip_holds_only_the_members_the_libraries_hold() {384        let strip = memberships("movie:sample:0001").remove(0);385        assert!(strip.members.iter().all(|entry| entry.held.is_some()));386        assert_eq!(strip.members.len(), 6);387    }388389    #[test]390    fn a_franchise_the_sample_never_invented_has_no_page() {391        assert_eq!(franchise(ORDERS, "franchise:name:none"), None);392        assert_eq!(franchise("sample/features", SAGA), None);393    }394}
src/sample/people.rs 98.8%
1// The invented people of the sample catalog: the stripes of a title, one2// person's page, and the wall of what they are credited in.34use super::{movie, trailing};5use crate::catalog::{CreditSlot, Credits, Person, Slot};67// The directory every invented person's entry sits under, the way a8// library's contributor store names them.9const CONTRIBUTORS: &str = ".contributors/";1011// The one invented person credited in more works than the pool's12// floor: the second writer of every movie, whose wall holds the first13// six.14pub const PROLIFIC_NAME: &str = "A Second Writer";15pub const PROLIFIC: &str = ".contributors/A Second Writer";16const PROLIFIC_WORKS: i64 = 6;1718// One invented slot of a stripe. Every sample person has an entry and a19// headshot, so every slot draws one.20fn slot(name: &str, role: &str) -> CreditSlot {21    CreditSlot {22        name: name.to_string(),23        role: role.to_string(),24        contributor: format!("{CONTRIBUTORS}{name}"),25        headshot: true,26    }27}2829// The invented credits of one title, so a run with no catalog opens a30// page with all three stripes.31pub fn credits(id: &str) -> Credits {32    let number = trailing(id);33    if number == 0 {34        return Credits::default();35    }36    Credits {37        directors: vec![slot(&format!("Director {number:04}"), "")],38        writers: vec![slot(&format!("Writer {number:04}"), "")],39        cast: (1..=6)40            .map(|part| {41                slot(42                    &format!("Player {number:04}-{part}"),43                    &format!("Part {part}"),44                )45            })46            .collect(),47    }48}4950// Every invented person has an entry, both files, and the same dates.51pub fn person(library: &str, path: &str) -> Option<Person> {52    let name = path.strip_prefix(CONTRIBUTORS)?;53    Some(Person {54        library: library.to_string(),55        path: path.to_string(),56        name: name.to_string(),57        born: "1950-01-02".into(),58        died: String::new(),59        biography: true,60        headshot: true,61        biography_library: library.to_string(),62        biography_path: path.to_string(),63        headshot_library: library.to_string(),64        headshot_path: path.to_string(),65    })66}6768/// Every invented person acts in the first three movies, so a person's69/// page opens on a wall of three, and the prolific writer's wall holds70/// the first `PROLIFIC_WORKS` movies as their writer, so the sample's pool71/// has one person over the floor.72/// The slots carry the duration and the rating, as the sidecar's works73/// read does, so a card under a one-role heading draws the facts line74/// every other strip draws.75pub fn works(library: &str, path: &str) -> Vec<Slot> {76    if !path.starts_with(CONTRIBUTORS) {77        return Vec::new();78    }79    let (count, parts) = match path == PROLIFIC {80        true => (PROLIFIC_WORKS, "Writer"),81        false => (3, "as Part 1"),82    };83    let mut works: Vec<Slot> = (1..=count)84        .map(movie)85        .map(|title| Slot {86            parts: parts.into(),87            ..Slot::of(library, "movies", title)88        })89        .collect();90    works.sort_by(|one, other| other.released.cmp(&one.released));91    works92}9394#[cfg(test)]95mod tests {96    use super::*;9798    #[test]99    fn a_title_carries_all_three_stripes() {100        let credits = credits("movie:sample:0001");101        assert_eq!(credits.directors.len(), 1);102        assert_eq!(credits.writers.len(), 1);103        assert_eq!(credits.cast.len(), 6);104        assert_eq!(credits.cast[0].contributor, ".contributors/Player 0001-1");105        assert!(credits.cast[0].headshot);106    }107108    #[test]109    fn a_person_carries_a_page_and_a_wall_in_release_order() {110        let person = person("sample/features", ".contributors/Player 0001-1")111            .expect("the sample invents every person");112        assert_eq!(person.name, "Player 0001-1");113        assert!(person.headshot);114115        let works = works("sample/features", ".contributors/Player 0001-1");116        let released: Vec<&str> = works.iter().map(|work| work.released.as_str()).collect();117        assert_eq!(released, ["2011", "1974", "1937"]);118        assert_eq!(works[0].parts, "as Part 1");119    }120121    #[test]122    fn the_prolific_writer_wrote_more_than_three() {123        let works = works("sample/features", PROLIFIC);124        assert_eq!(works.len() as i64, PROLIFIC_WORKS);125        assert!(works.iter().all(|work| work.parts == "Writer"));126        assert_eq!(127            person("sample/features", PROLIFIC).map(|person| person.name),128            Some(PROLIFIC_NAME.to_string())129        );130    }131132    #[test]133    fn a_person_the_sample_never_invented_has_no_page() {134        assert_eq!(person("sample/features", "nonsense"), None);135        assert!(works("sample/features", "nonsense").is_empty());136    }137}
src/sample/search.rs 100.0%
1// The search index over the invented catalog. `Catalog` is a unit2// struct that every caller constructs fresh, so it can hold no index;3// the index is built once, on the first search, and shared.45use std::collections::HashSet;6use std::sync::OnceLock;78use super::{9    Catalog, FEATURES, IN_SETS, MOVIES, PER_SET, PLOT, SERIALS, SERIALS_LIBRARY, movie, orders,10    serial, serial_slot,11};12use crate::catalog::search::{Builder, Index, Item, Kind, Person, Where};13use crate::catalog::{Slot, Source};1415/// The index over the invented catalog, built on first use.16pub fn index() -> &'static Index {17    static INDEX: OnceLock<Index> = OnceLock::new();18    INDEX.get_or_init(build)19}2021fn build() -> Index {22    let mut builder = Builder::new();23    let mut catalog = Catalog;2425    for number in 1..=MOVIES {26        let slot = Slot::of(FEATURES, "movies", movie(number));27        builder.add(found(slot, Kind::Title, PLOT));28    }2930    for number in 1..=SERIALS {31        let slot = serial_slot(SERIALS_LIBRARY, number);32        let id = slot.id.clone();33        let place = builder.add(found(slot, Kind::Title, PLOT));34        for episode in catalog.episodes(SERIALS_LIBRARY, &id) {35            builder.fold(place, Where::EpisodeTitle, &episode.title);36            builder.fold(place, Where::EpisodePlot, &episode.plot);37        }38    }3940    let sets: Vec<(String, String)> = (1..=IN_SETS / PER_SET)41        .map(|number| format!("set:sample:{number:02}"))42        .filter_map(|id| catalog.set(FEATURES, &id).map(|set| (id, set.title)))43        .collect();44    for (id, title) in sets {45        let slot = Slot {46            library: FEATURES.into(),47            kind: "sets".into(),48            id,49            title,50            ..Slot::default()51        };52        builder.add(named(slot, Kind::Collection, Where::Title));53    }5455    for entry in orders::entries() {56        let slot = Slot {57            library: entry.library,58            kind: "franchise".into(),59            id: entry.id,60            title: entry.title,61            art: entry.art,62            ..Slot::default()63        };64        builder.add(named(slot, Kind::Collection, Where::Title));65    }6667    for (path, name) in contributors() {68        builder.person(Person {69            library: FEATURES.into(),70            path,71            name,72            headshot: true,73        });74    }7576    builder.finish()77}7879// One invented title with its plot, as the index holds it.80fn found(slot: Slot, kind: Kind, plot: &str) -> Item {81    let mut item = named(slot, kind, Where::Title);82    item.strings.push((Where::Plot, plot.to_string()));83    item84}8586// One item found by its title alone.87fn named(slot: Slot, kind: Kind, rung: Where) -> Item {88    Item {89        sort_key: slot.title.to_lowercase(),90        strings: vec![(rung, slot.title.clone())],91        slot,92        kind,93    }94}9596// Every invented person as path and name, each once.97fn contributors() -> Vec<(String, String)> {98    let mut catalog = Catalog;99    let mut seen: HashSet<String> = HashSet::new();100    let mut found: Vec<(String, String)> = Vec::new();101    let titles = (1..=MOVIES)102        .map(|number| movie(number).id)103        .chain((1..=SERIALS).map(|number| serial(number).id));104    for id in titles {105        let credits = catalog.credits(FEATURES, &id);106        let stripes = credits107            .directors108            .into_iter()109            .chain(credits.writers)110            .chain(credits.cast);111        for credit in stripes {112            if seen.insert(credit.contributor.clone()) {113                found.push((credit.contributor, credit.name));114            }115        }116    }117    found118}119120#[cfg(test)]121mod tests {122    use super::*;123124    #[test]125    fn the_invented_catalog_searches_by_title() {126        let hits = index().find("Specimen 0007");127        assert_eq!(128            hits.first().map(|hit| hit.title.as_str()),129            Some("Specimen 0007")130        );131        assert_eq!(hits[0].kind, "movies");132        assert_eq!(hits[0].library, FEATURES);133    }134135    #[test]136    fn the_invented_catalog_searches_by_person_and_by_serial() {137        let hits = index().find("Serial 03");138        assert_eq!(139            hits.first().map(|hit| hit.id.as_str()),140            Some("series:sample:03")141        );142143        let people = index().find("Player 0001-1");144        assert_eq!(people.first().map(|hit| hit.kind.as_str()), Some("people"));145        assert_eq!(people[0].id, ".contributors/Player 0001-1");146    }147148    #[test]149    fn the_invented_index_reports_a_size() {150        let size = index().size();151        assert!(size.items > MOVIES as usize);152        assert!(size.entries > size.items);153        assert!(size.bytes > 0);154    }155}
src/screens.rs 99.4%
1// A kind is a plugin in the scanner and a screen design in the browser.2// This module holds the entries of the navigation stack: the home page3// at the bottom, and the screens each kind descends into. A screen holds4// the rows it read, decides what a press does, and composes the5// primitives in the views module. A new kind adds a screen here and,6// where it needs one, a primitive there. It adds no row to a table.78pub mod credits;9pub mod facts;10pub mod foot;11pub mod franchise;12pub mod home;13pub mod loading;14pub mod movie;15pub mod person;16pub mod series;17pub mod slots;18pub mod stripes;19pub mod volume;20pub mod wall;2122use std::cell::RefCell;23use std::convert::Infallible;2425use iced_wgpu::Renderer;26use iced_winit::core::{Element, Theme};2728use self::series::seasons_of;29use crate::catalog::{InSeries, Query, Selection, Slot, Source};30use crate::posters::Posters;31use crate::views::curtain::Curtain;32use crate::views::field::TextField;33use crate::views::{34    Card, card, strip,35    wall::{POSTER, STILL},36};3738/// One entry of the navigation stack.39pub enum Screen {40    /// The home page, the screen the browser opens on and the bottom of41    /// the stack.42    Home(home::Home),43    /// The slots one query answers, as a wall of art under a band that44    /// carries the query's heading. It is boxed for the reason a page is.45    Wall(Box<wall::Wall>),46    /// One movie's page. Every page and the wall are boxed, so no stack47    /// entry carries the size of the largest of them.48    Movie(Box<movie::Movie>),49    /// One series' page, boxed for the reason a movie's page is.50    Series(Box<series::Series>),51    /// One person's page, boxed for the reason a movie's page is.52    Person(Box<person::Person>),53    /// One franchise's page, boxed for the reason a movie's page is.54    Franchise(Box<franchise::Franchise>),55}5657/// What a press asks the browser to do. A screen reads the catalog and58/// moves its own focus. Only the browser holds the stack and the bus, so59/// a screen names the screen it opens and never pushes one itself.60pub enum Step {61    /// The press changed the screen alone.62    Stay,63    /// The press changed nothing at all, so the frame on the glass still64    /// draws what the screen holds. Only a press that moves no focus65    /// answers it, such as an arrow at the edge of the keyboard grid.66    Still,67    /// Push this screen over the one that answered.68    Open(Screen),69    /// Put this screen in the place of the one that answered, so back70    /// climbs to the screen that one was opened from.71    Replace(Screen),72    /// Ask the operator to play what the person chose.73    Play {74        /// The library the choice resolves against.75        library: String,76        /// What the person chose.77        selection: Selection,78    },79}8081impl Screen {82    /// Fold one press into the screen and answer what it asks the83    /// browser for.84    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {85        match self {86            Self::Home(screen) => screen.key(key, source),87            Self::Wall(screen) => screen.key(key, source),88            Self::Movie(screen) => screen.key(key, source),89            Self::Series(screen) => screen.key(key, source),90            Self::Person(screen) => screen.key(key, source),91            Self::Franchise(screen) => screen.key(key, source),92        }93    }9495    /// Fold in the press that leaves a screen. Only a search wall takes96    /// one for itself: backspace removes a character of the text and97    /// escape clears it. Every other screen answers nothing, and the98    /// browser then goes back.99    pub fn escape(&mut self, key: &str, source: &mut dyn Source) -> Option<Step> {100        match self {101            Self::Wall(screen) => screen.escape(key, source),102            _ => None,103        }104    }105106    /// Whether this screen is the search wall. A letter opens the search107    /// wall from every other screen, and types on this one.108    pub fn searching(&self) -> bool {109        matches!(self, Self::Wall(screen) if screen.search.is_some())110    }111112    /// The search wall's field, which the browser's strip draws, or113    /// nothing on every other screen.114    pub fn field(&self) -> Option<&TextField> {115        match self {116            Self::Wall(screen) => screen.search.as_ref().map(|search| &search.field),117            _ => None,118        }119    }120121    /// Show the search wall's keyboard grid, which is what select on the122    /// strip's field does. Every other screen shows nothing.123    pub fn show_grid(&mut self) {124        if let Self::Wall(screen) = self {125            screen.show_grid();126        }127    }128129    /// Read this screen's rows again. A change that landed while the130    /// screen was covered was folded into the screen shown at the time,131    /// so the uncovered screen reads for itself.132    pub fn reread(&mut self, source: &mut dyn Source) {133        match self {134            Self::Home(screen) => screen.reread(source),135            Self::Wall(screen) => screen.reread(source),136            Self::Movie(screen) => screen.reread(source),137            Self::Series(screen) => screen.reread(source),138            Self::Person(screen) => screen.reread(source),139            Self::Franchise(screen) => screen.reread(source),140        }141    }142143    /// Whether a rest of focus on this screen is worth a prefetch. Only a144    /// wall whose select opens a page over art answers true, so a press145    /// on any other screen schedules no frame.146    /// Whether the screen asks for a backdrop while focus rests. The home147    /// page answers as a wall does while a strip holds focus.148    pub fn prefetches(&self) -> bool {149        match self {150            Self::Home(screen) => screen.prefetches(),151            Self::Wall(screen) => screen.prefetches(),152            Self::Person(_) => true,153            _ => false,154        }155    }156157    /// The library and the backdrop path of the page under the focused158    /// item, so the store decodes it while focus rests and the page159    /// opens with it drawn. A screen that opens no page over art answers160    /// nothing.161    pub fn resting(&self, source: &mut dyn Source) -> Option<(String, String)> {162        match self {163            Self::Home(screen) => screen.resting(source),164            Self::Wall(screen) => screen.resting(source),165            Self::Person(screen) => screen.resting(source),166            _ => None,167        }168    }169170    /// Read the files this screen draws that live on a library171    /// volume and not in the catalog. Only a person's page holds one, and172    /// every other screen reads nothing.173    pub fn volume<P: Posters>(&mut self, posters: &P) {174        if let Self::Person(screen) = self {175            screen.read_biography(posters);176        }177    }178179    /// The view of this screen, with its art drawn from the store. Only180    /// the two screens a title plays from draw the loading state, and181    /// every other screen ignores it.182    /// `held` is whether the screen holds focus. The browser's strip183    /// takes focus off the screen under it, and a screen that drew its184    /// own mark then would put two marks on the glass.185    pub fn view<'a, P: Posters>(186        &'a self,187        posters: &'a RefCell<P>,188        curtain: Option<Curtain>,189        held: bool,190    ) -> Element<'a, Infallible, Theme, Renderer> {191        match self {192            Self::Home(screen) => screen.view(posters, held),193            Self::Wall(screen) => screen.view(posters, held),194            Self::Movie(screen) => screen.view(posters, curtain, held),195            Self::Series(screen) => screen.view(posters, curtain, held),196            Self::Person(screen) => screen.view(posters, held),197            Self::Franchise(screen) => screen.view(posters, held),198        }199    }200}201202/// One title, as a wall slot or a strip poster draws it. Every item203/// carries its own library and kind, because a select opens the page for204/// the slot's kind and a wall may span libraries. The id is what a descent205/// carries. `episode` is what an206/// episode slot carries: the series a select opens and the numbers it207/// opens on, and an item that holds one draws as a still. `art_library` is208/// the library the art resolves against, where that is not the item's own:209/// a franchise slot opens in the `Library` of kind franchises, and its art210/// may be a member's poster on the member's own volume.211/// The caption is the words the card leads with, `under` is its second212/// line, and `line` is what the focused slot of a one-line wall shows.213/// `fitted` and `under_fitted` are those two lines cut by the shaper to214/// the band the card draws them in, so no frame measures them and no215/// line runs past its cell. A strip cuts every card at its read, and a216/// wall cuts the page of rows around its focus.217#[derive(Debug, Clone, PartialEq, Eq)]218pub struct Item {219    pub library: String,220    pub art_library: String,221    pub kind: String,222    pub id: String,223    pub name: String,224    /// The item's release date as the catalog holds it, which the wall's225    /// rail reads its years and decades off.226    pub released: String,227    pub caption: String,228    pub fitted: String,229    pub line: facts::Line,230    pub under: String,231    pub under_fitted: String,232    /// Whether the card's first line is the film's tagline, which draws233    /// in the italic face, and not the title, which draws in the roman234    /// one.235    pub tagline: bool,236    pub art: String,237    /// The posters a shelf draws as a mosaic, each with the library it238    /// resolves against. Only a library and a genre carry any; every239    /// other item draws the one art beside it.240    pub tiles: Vec<(String, String)>,241    pub episode: Option<InSeries>,242    /// How many episodes of a folded show are current, and zero on every243    /// other item.244    pub new: usize,245}246247impl Item {248    /// One slot as an item, with both caption lines built once here, at249    /// the read, and not on every frame.250    /// A still reads as its episode title over the series, the numbers,251    /// and the runtime. A work whose parts are all `as` runs reads as the252    /// character over the title and the year.253    /// Every other slot leads with the words its kind leads with, a254    /// film's tagline or a title.255    /// The second line under those words is one of four: the parts a256    /// person's strip credits with the year; the kind word with the year257    /// where that strip credits none; the year and the runtime on a set's258    /// strip, where every member is a film and the kind word says nothing;259    /// or the facts line: the year, a series' season count, the runtime,260    /// and the rating.261    pub fn of(query: &Query, slot: Slot) -> Self {262        let year = facts::year(&slot.released);263        let numbers = slot264            .episode265            .as_ref()266            .map(|place| format!("S{:02} · E{:02}", place.season, place.episode))267            .unwrap_or_default();268        let series = slot269            .episode270            .as_ref()271            .map(|place| place.name.as_str())272            .unwrap_or_default();273        let runtime = facts::runtime(slot.duration);274        let line = match slot.episode.is_some() {275            true => facts::Line::of(&[series, &numbers]),276            false => facts::Line::of(&[&slot.title, year, &runtime, &slot.rating]),277        };278        // The facts line. Only a series slot carries a season count, and279        // it stands between the year and the runtime.280        let facts = facts::joined(&[year, &seasons_of(slot.seasons), &runtime, &slot.rating]);281        // A whole show folded into one still reads the way one episode of282        // it does, so every still of a strip reads the same. The character283        // leads a work only where every part left is an `as` run.284        let leads = leading(&slot);285        let tagged = tagged(&slot);286        let (caption, under, tagline) = match (&slot.episode, query, played(&slot.parts)) {287            (Some(_), _, _) => (288                slot.title.clone(),289                facts::joined(&[series, &numbers, &runtime]),290                false,291            ),292            (None, Query::Person { .. }, Some(character)) => {293                (character, facts::joined(&[&slot.title, year]), false)294            }295            (None, Query::Person { .. }, None) => (296                leads,297                match slot.parts.is_empty() {298                    true => facts::joined(&[facts::kind_word(&slot.kind), year]),299                    false => facts::joined(&[&slot.parts, year]),300                },301                tagged,302            ),303            (None, Query::Set { .. }, _) => (leads, facts::joined(&[year, &runtime]), tagged),304            (None, _, _) => (leads, facts, tagged),305        };306        Self {307            library: slot.library,308            art_library: String::new(),309            kind: slot.kind,310            id: slot.id,311            name: slot.title,312            released: slot.released,313            fitted: caption.clone(),314            caption,315            line,316            under_fitted: under.clone(),317            under,318            tagline,319            art: slot.art,320            tiles: Vec::new(),321            episode: slot.episode,322            new: slot.new,323        }324    }325326    /// Both lines of the card cut by the shaper to a band of this width,327    /// each at the size it draws at, so the smaller second line keeps328    /// the words the first would have lost.329    pub fn fit(&mut self, band: f32) {330        self.fitted = card::cut(&self.caption, band);331        self.under_fitted = card::under_cut(&self.under, band);332    }333}334335/// Every card of a strip, cut at the read to the band its own ratio336/// draws it in. A strip slot is one poster or one still wide, so the band337/// is a constant of the strip and not of the frame.338pub fn fitted_strip(items: &mut [Item]) {339    for item in items.iter_mut() {340        item.fit(strip::caption_width(item.ratio()));341    }342}343344// The words a card leads with: a film's tagline where the sidecar wrote345// one, and the title everywhere else. A film's poster carries its title,346// so the card says something the poster cannot; a series is known by its347// name.348fn leading(slot: &Slot) -> String {349    match tagged(slot) {350        true => slot.tagline.clone(),351        false => slot.title.clone(),352    }353}354355// Whether the words a card leads with are the film's tagline.356fn tagged(slot: &Slot) -> bool {357    slot.kind == MOVIES && !slot.tagline.is_empty()358}359360// The kind a movie row carries, the one kind whose card leads with a361// tagline.362const MOVIES: &str = "movies";363364// The run that names a character in a parts line, and never a role word.365const AS: &str = "as ";366367// The characters of a parts line without the `as `, joined the way the368// parts were, and nothing where a part is not an `as` run, because then369// the parts line still says which role was which.370fn played(parts: &str) -> Option<String> {371    let runs: Vec<&str> = parts.split(", ").filter(|part| !part.is_empty()).collect();372    if runs.is_empty() || !runs.iter().all(|run| run.starts_with(AS)) {373        return None;374    }375    Some(376        runs.iter()377            .map(|run| &run[AS.len()..])378            .collect::<Vec<&str>>()379            .join(", "),380    )381}382383impl Card for Item {384    fn art(&self) -> &str {385        &self.art386    }387388    fn ratio(&self) -> f32 {389        match self.episode.is_some() {390            true => STILL,391            false => POSTER,392        }393    }394395    // The art resolves against the library that holds it, which is the396    // item's own unless the item names another.397    fn library(&self) -> &str {398        match self.art_library.is_empty() {399            true => &self.library,400            false => &self.art_library,401        }402    }403404    fn name(&self) -> &str {405        &self.name406    }407408    fn caption(&self) -> &str {409        &self.caption410    }411412    fn fitted(&self) -> &str {413        &self.fitted414    }415416    fn under_fitted(&self) -> &str {417        &self.under_fitted418    }419420    fn under(&self) -> &str {421        &self.under422    }423424    fn line_fitting(&self, chars: usize) -> &str {425        self.line.fitting(chars)426    }427428    fn leads_with_tagline(&self) -> bool {429        self.tagline430    }431432    fn tiles(&self) -> &[(String, String)] {433        &self.tiles434    }435436    fn new_episodes(&self) -> usize {437        self.new438    }439}440441#[cfg(test)]442mod tests {443    use super::*;444    use crate::catalog::{Fold, GenreSort, Sort};445446    const LIBRARY: &str = "sample/features";447448    fn library() -> Query {449        Query::Library {450            library: LIBRARY.into(),451            sort: Sort::default(),452        }453    }454455    fn specimen() -> Slot {456        Slot {457            library: LIBRARY.into(),458            kind: "movies".into(),459            id: "movie:sample:1".into(),460            title: "Specimen 0001".into(),461            released: "1987-04-02".into(),462            art: "posters/1.jpg".into(),463            duration: 5_820,464            rating: "PG-13".into(),465            tagline: String::new(),466            parts: String::new(),467            episode: None,468            new: 0,469            seasons: 0,470        }471    }472473    #[test]474    fn a_slot_carries_the_facts_its_row_holds() {475        let item = Item::of(&library(), specimen());476        assert_eq!(item.line.words(), "Specimen 0001 · 1987 · 1h 37m · PG-13");477        assert_eq!(item.name, "Specimen 0001");478        assert_eq!(item.caption(), "Specimen 0001");479        assert_eq!(item.under(), "1987 · 1h 37m · PG-13");480        assert_eq!(item.art, "posters/1.jpg");481        assert_eq!(item.library(), LIBRARY);482        assert_eq!(item.kind, "movies");483        assert_eq!(item.ratio(), POSTER);484    }485486    #[test]487    fn an_episode_leads_with_its_own_title_over_its_show_and_draws_as_a_still() {488        let query = Query::Released {489            fold: crate::catalog::Fold::Airing,490        };491        let item = Item::of(492            &query,493            Slot {494                kind: "episodes".into(),495                id: "episode:sample:1".into(),496                title: "Segment 04".into(),497                released: "2026-09-01".into(),498                duration: 2_760,499                episode: Some(InSeries {500                    series: "series:sample:03".into(),501                    name: "Serial 03".into(),502                    season: 3,503                    episode: 4,504                }),505                ..specimen()506            },507        );508        assert_eq!(item.caption(), "Segment 04");509        assert_eq!(item.line.words(), "Serial 03 · S03 · E04");510        assert_eq!(item.under(), "Serial 03 · S03 · E04 · 46m");511        assert_eq!(item.ratio(), STILL);512        assert_eq!(item.kind, "episodes");513    }514515    #[test]516    fn a_folded_show_reads_the_way_one_episode_of_it_does() {517        let query = Query::Released {518            fold: Fold::Shows { today: 0 },519        };520        let item = Item::of(521            &query,522            Slot {523                kind: "episodes".into(),524                id: "series:sample:03".into(),525                title: "Segment 08".into(),526                released: "2026-09-01".into(),527                duration: 3_120,528                new: 2,529                episode: Some(InSeries {530                    series: "series:sample:03".into(),531                    name: "Serial 03".into(),532                    season: 4,533                    episode: 8,534                }),535                ..specimen()536            },537        );538        assert_eq!(item.caption(), "Segment 08");539        assert_eq!(item.under(), "Serial 03 · S04 · E08 · 52m");540        assert_eq!(item.line.words(), "Serial 03 · S04 · E08");541        assert_eq!(item.ratio(), STILL);542        assert_eq!(item.new_episodes(), 2);543    }544545    #[test]546    fn a_films_card_leads_with_its_tagline_and_keeps_its_title_as_its_name() {547        let item = Item::of(548            &library(),549            Slot {550                tagline: "One of a kind.".into(),551                ..specimen()552            },553        );554        assert_eq!(item.caption(), "One of a kind.");555        assert_eq!(item.under(), "1987 · 1h 37m · PG-13");556        assert_eq!(item.name(), "Specimen 0001");557        assert_eq!(item.line.words(), "Specimen 0001 · 1987 · 1h 37m · PG-13");558    }559560    #[test]561    fn a_film_the_sidecar_wrote_no_tagline_for_leads_with_its_title() {562        assert_eq!(Item::of(&library(), specimen()).caption(), "Specimen 0001");563    }564565    #[test]566    fn a_series_card_leads_with_its_title_whatever_its_tagline_says() {567        let item = Item::of(568            &library(),569            Slot {570                tagline: "One of a kind.".into(),571                ..serial()572            },573        );574        assert_eq!(item.caption(), "Serial 03");575        assert_eq!(item.under(), "2004 · 5 seasons · TV-14");576    }577578    #[test]579    fn a_persons_card_of_a_film_leads_with_the_tagline_over_the_parts_and_the_year() {580        let item = Item::of(581            &person(),582            Slot {583                tagline: "One of a kind.".into(),584                parts: "Director, Writer".into(),585                ..specimen()586            },587        );588        assert_eq!(item.caption(), "One of a kind.");589        assert_eq!(item.under(), "Director, Writer · 1987");590    }591592    #[test]593    fn a_card_cuts_both_its_lines_to_the_band_it_is_given() {594        let mut item = Item::of(595            &library(),596            Slot {597                title: "W".repeat(60),598                ..specimen()599            },600        );601        item.fit(200.0);602        assert!(item.fitted().ends_with('\u{2026}'));603        assert!(crate::views::text::measured(item.fitted(), crate::look::CAPTION) <= 200.0);604        assert_eq!(item.under_fitted(), item.under());605    }606607    #[test]608    fn every_card_of_a_strip_is_cut_to_the_band_its_own_ratio_draws_in() {609        let mut items = vec![610            Item::of(611                &library(),612                Slot {613                    title: "W".repeat(60),614                    ..specimen()615                },616            ),617            Item::of(618                &library(),619                Slot {620                    title: "W".repeat(60),621                    episode: Some(InSeries::default()),622                    ..specimen()623                },624            ),625        ];626        fitted_strip(&mut items);627        let poster = crate::views::text::measured(items[0].fitted(), crate::look::CAPTION);628        let still = crate::views::text::measured(items[1].fitted(), crate::look::CAPTION);629        assert!(poster <= strip::caption_width(POSTER));630        assert!(still > poster);631    }632633    #[test]634    fn a_recency_slot_of_a_title_is_captioned_with_the_title() {635        let query = Query::Added {636            fold: crate::catalog::Fold::Titles,637        };638        assert_eq!(Item::of(&query, specimen()).caption(), "Specimen 0001");639    }640641    fn person() -> Query {642        Query::Person {643            library: LIBRARY.into(),644            path: ".contributors/A Player".into(),645        }646    }647648    #[test]649    fn a_persons_work_is_captioned_with_its_title_over_its_parts_and_its_year() {650        let item = Item::of(651            &person(),652            Slot {653                parts: "Director, as The Part".into(),654                duration: 0,655                rating: String::new(),656                ..specimen()657            },658        );659        assert_eq!(item.caption(), "Specimen 0001");660        assert_eq!(item.line_fitting(80), "Specimen 0001 · 1987");661        assert_eq!(item.under(), "Director, as The Part · 1987");662    }663664    #[test]665    fn a_persons_work_with_no_parts_left_reads_its_kind_and_its_year() {666        let item = Item::of(667            &person(),668            Slot {669                parts: String::new(),670                ..specimen()671            },672        );673        assert_eq!(item.caption(), "Specimen 0001");674        assert_eq!(item.under(), "Film · 1987");675    }676677    #[test]678    fn a_title_on_a_recency_strip_carries_its_facts_under_it() {679        let item = Item::of(&Query::Added { fold: Fold::Airing }, specimen());680        assert_eq!(item.caption(), "Specimen 0001");681        assert_eq!(item.under(), "1987 · 1h 37m · PG-13");682    }683684    // One serial as a slot of the strip that read it: five seasons, and685    // the columns a series row carries.686    fn serial() -> Slot {687        Slot {688            kind: "series".into(),689            id: "series:sample:03".into(),690            title: "Serial 03".into(),691            released: "2004-09-22".into(),692            duration: 0,693            rating: "TV-14".into(),694            seasons: 5,695            ..specimen()696        }697    }698699    #[test]700    fn a_series_carries_its_season_count_between_its_year_and_its_rating() {701        let genre = Query::Genre {702            name: "Mystery".into(),703            order: crate::catalog::Order::Released,704            sort: GenreSort::default(),705        };706        let item = Item::of(&genre, serial());707        assert_eq!(item.caption(), "Serial 03");708        assert_eq!(item.under(), "2004 · 5 seasons · TV-14");709        assert_eq!(item.ratio(), POSTER);710    }711712    #[test]713    fn a_series_of_one_season_and_a_series_of_none_read_in_their_own_words() {714        let genre = Query::Genre {715            name: "Mystery".into(),716            order: crate::catalog::Order::Released,717            sort: GenreSort::default(),718        };719        let one = Item::of(720            &genre,721            Slot {722                seasons: 1,723                ..serial()724            },725        );726        assert_eq!(one.under(), "2004 · 1 season · TV-14");727        let none = Item::of(728            &genre,729            Slot {730                seasons: 0,731                ..serial()732            },733        );734        assert_eq!(none.under(), "2004 · TV-14");735    }736737    #[test]738    fn a_persons_card_of_a_series_leads_with_the_character_it_credits() {739        let item = Item::of(740            &person(),741            Slot {742                parts: "as The Part".into(),743                ..serial()744            },745        );746        assert_eq!(item.caption(), "The Part");747        assert_eq!(item.under(), "Serial 03 · 2004");748    }749750    #[test]751    fn a_card_of_two_roles_keeps_its_title_over_the_parts_it_credits() {752        let item = Item::of(753            &person(),754            Slot {755                parts: "Director, as The Lead".into(),756                ..specimen()757            },758        );759        assert_eq!(item.caption(), "Specimen 0001");760        assert_eq!(item.under(), "Director, as The Lead · 1987");761    }762763    #[test]764    fn a_card_of_two_characters_leads_with_both_of_them() {765        let item = Item::of(766            &person(),767            Slot {768                parts: "as One, as Two".into(),769                ..specimen()770            },771        );772        assert_eq!(item.caption(), "One, Two");773        assert_eq!(item.under(), "Specimen 0001 · 1987");774    }775776    #[test]777    fn a_persons_card_of_a_series_with_no_parts_left_reads_its_kind_and_its_year() {778        let item = Item::of(&person(), serial());779        assert_eq!(item.under(), "Series · 2004");780    }781782    #[test]783    fn a_title_on_a_genre_strip_carries_its_facts_under_it() {784        let genre = Query::Genre {785            name: "Western".into(),786            order: crate::catalog::Order::Released,787            sort: GenreSort::default(),788        };789        let item = Item::of(&genre, specimen());790        assert_eq!(item.caption(), "Specimen 0001");791        assert_eq!(item.under(), "1987 · 1h 37m · PG-13");792    }793794    #[test]795    fn a_film_on_a_set_strip_carries_its_year_and_its_runtime_under_it() {796        let set = Query::Set {797            library: LIBRARY.into(),798            id: "set:sample:01".into(),799        };800        let item = Item::of(&set, specimen());801        assert_eq!(item.caption(), "Specimen 0001");802        assert_eq!(item.under(), "1987 · 1h 37m");803    }804805    #[test]806    fn a_card_that_leads_with_a_tagline_says_so_and_every_other_card_does_not() {807        let tagged = Slot {808            tagline: "One of a kind.".into(),809            ..specimen()810        };811        let item = Item::of(&library(), tagged);812        assert_eq!(item.caption(), "One of a kind.");813        assert!(item.leads_with_tagline());814        assert!(!Item::of(&library(), specimen()).leads_with_tagline());815        assert!(!Item::of(&library(), serial()).leads_with_tagline());816    }817818    #[test]819    fn a_narrow_band_drops_a_slot_s_facts_from_the_end() {820        let item = Item::of(&library(), specimen());821        assert_eq!(822            item.line_fitting(37),823            "Specimen 0001 · 1987 · 1h 37m · PG-13"824        );825        assert_eq!(item.line_fitting(36), "Specimen 0001 · 1987 · 1h 37m");826        assert_eq!(item.line_fitting(24), "Specimen 0001 · 1987");827        assert_eq!(item.line_fitting(19), "Specimen 0001");828        assert_eq!(item.line_fitting(4), "Specimen 0001");829    }830831    #[test]832    fn a_slot_leaves_both_its_lines_as_the_words_they_are_until_a_strip_cuts_them() {833        let item = Item::of(834            &library(),835            Slot {836                title: "W".repeat(60),837                ..specimen()838            },839        );840        assert_eq!(item.fitted(), item.caption());841        assert_eq!(item.under_fitted(), item.under());842    }843844    #[test]845    fn a_slot_leaves_out_what_its_row_does_not_hold() {846        let item = Item::of(847            &library(),848            Slot {849                id: "movie:sample:2".into(),850                title: "Specimen 0002".into(),851                ..Slot::default()852            },853        );854        assert_eq!(item.line.words(), "Specimen 0002");855        assert_eq!(item.line_fitting(4), "Specimen 0002");856    }857}
src/screens/credits.rs 100.0%
1// What a person's works say about the person, and what one work's card2// says about the part they took in it. A person's strip on the home page3// and a person's own page read the same works, so both credit them here:4// the roles head the strip, and every work's parts line loses what the5// heading already says.67use crate::catalog::{Query, Slot};89/// The person's roles across these works as role words in lower case,10/// comma separated, most frequent first. A work's parts line is what the11/// works read wrote: the role words and `as <character>` runs, comma12/// separated, and an `as` run is the actor role, never the character's13/// name. A role counts once per work even where a work credits it twice.14/// A tie in frequency keeps the order the roles first came in, so the15/// heading is stable between frames.16pub fn roles(slots: &[Slot]) -> String {17    let mut counted: Vec<(String, usize)> = Vec::new();18    for slot in slots {19        let mut seen: Vec<String> = Vec::new();20        for part in slot.parts.split(", ").filter(|part| !part.is_empty()) {21            let role = match part.starts_with(AS) {22                true => "actor".to_string(),23                false => part.to_lowercase(),24            };25            if seen.contains(&role) {26                continue;27            }28            seen.push(role.clone());29            match counted.iter_mut().find(|(named, _)| *named == role) {30                Some((_, count)) => *count += 1,31                None => counted.push((role, 1)),32            }33        }34    }35    counted.sort_by_key(|(_, count)| std::cmp::Reverse(*count));36    counted37        .into_iter()38        .map(|(role, _)| role)39        .collect::<Vec<String>>()40        .join(", ")41}4243/// Credit every work of a person as its card draws it, and answer the44/// roles those works give the person, which head the strip. A query about45/// anything but a person leaves every slot as the read answered it and46/// names no roles.47pub fn credit(query: &Query, slots: &mut [Slot]) -> String {48    if !matches!(query, Query::Person { .. }) {49        return String::new();50    }51    let roles = roles(slots);52    for slot in slots.iter_mut() {53        slot.parts = credited(&slot.parts, &roles);54    }55    roles56}5758// A work's parts as its card draws them: the "as <character>" runs alone59// where the heading over the strip names one role, and every part where60// the heading names more than one, because then the heading cannot say61// which work was which.62fn credited(parts: &str, roles: &str) -> String {63    match one_role(roles) {64        true => characters(parts),65        false => parts.to_string(),66    }67}6869// Whether the strip's heading names one role. Every card would repeat70// that one word.71fn one_role(roles: &str) -> bool {72    roles.split(", ").filter(|role| !role.is_empty()).count() == 173}7475// The run that names a character in a parts line, and never a role word.76const AS: &str = "as ";7778// The "as <character>" runs of a parts line, without the role words the79// heading over the strip names. The character is per work, so it stays.80fn characters(parts: &str) -> String {81    parts82        .split(", ")83        .filter(|part| part.starts_with(AS))84        .collect::<Vec<&str>>()85        .join(", ")86}8788#[cfg(test)]89mod tests {90    use super::*;91    use crate::catalog::Sort;9293    const LIBRARY: &str = "sample/features";9495    fn work(parts: &str) -> Slot {96        Slot {97            parts: parts.into(),98            ..Slot::default()99        }100    }101102    fn person() -> Query {103        Query::Person {104            library: LIBRARY.into(),105            path: ".contributors/A Player".into(),106        }107    }108109    #[test]110    fn a_persons_roles_read_most_frequent_first_in_lower_case() {111        let works = [112            work("as Ripley"),113            work("Writer"),114            work("as Dallas"),115            work("as Kane"),116            work("as Ash"),117            work("as Parker"),118        ];119        assert_eq!(roles(&works), "actor, writer");120    }121122    #[test]123    fn roles_of_one_frequency_keep_the_order_they_first_came_in() {124        let works = [work("Writer"), work("Director"), work("as Someone")];125        assert_eq!(roles(&works), "writer, director, actor");126        let works = [work("Director"), work("Writer")];127        assert_eq!(roles(&works), "director, writer");128    }129130    #[test]131    fn a_work_that_credits_a_person_twice_counts_once_per_role() {132        let works = [133            work("as One, as Two"),134            work("Writer, Director"),135            work("Writer"),136        ];137        assert_eq!(roles(&works), "writer, actor, director");138    }139140    #[test]141    fn one_role_reads_as_one_word_and_no_work_reads_as_nothing() {142        assert_eq!(roles(&[work("Writer"), work("Writer")]), "writer");143        assert_eq!(roles(&[work("as The Part")]), "actor");144        assert_eq!(roles(&[]), "");145        assert_eq!(roles(&[work("")]), "");146    }147148    #[test]149    fn a_strip_of_one_role_leaves_every_card_its_characters_alone() {150        let mut works = [work("as One"), work("Actor"), work("as Two, as Three")];151        assert_eq!(credit(&person(), &mut works), "actor");152        assert_eq!(works[0].parts, "as One");153        assert_eq!(works[1].parts, "");154        assert_eq!(works[2].parts, "as Two, as Three");155    }156157    #[test]158    fn a_strip_of_two_roles_leaves_every_card_all_of_its_parts() {159        let mut works = [work("Director, Writer"), work("Writer")];160        assert_eq!(credit(&person(), &mut works), "writer, director");161        assert_eq!(works[0].parts, "Director, Writer");162        assert_eq!(works[1].parts, "Writer");163    }164165    #[test]166    fn a_query_about_no_person_credits_nothing() {167        let mut slots = [work("Director")];168        let library = Query::Library {169            library: LIBRARY.into(),170            sort: Sort::default(),171        };172        assert_eq!(credit(&library, &mut slots), "");173        assert_eq!(slots[0].parts, "Director");174    }175}
src/screens/facts.rs 99.6%
1// The words a screen makes of an item's columns: the year out of a2// release, the runtime out of a duration, and the one line those join3// into. Every function here is pure over the columns, so a screen builds4// its lines once at a read, and the tests need no window.56use crate::catalog::franchise::SERIES;78// The separator between two facts on one line.9const BETWEEN: &str = " · ";1011/// The word a card reads a kind as. The catalog's series library and a12/// franchise file's series entry carry the same kind word; everything13/// else a card draws is a film.14pub fn kind_word(kind: &str) -> &'static str {15    match kind == SERIES {16        true => "Series",17        false => "Film",18    }19}2021/// The year: the first four digits of a `released` column. A column that22/// holds neither a year nor a date answers nothing, which leaves the23/// year out of the line.24pub fn year(released: &str) -> &str {25    match released.get(..4) {26        Some(digits) if digits.chars().all(|digit| digit.is_ascii_digit()) => digits,27        _ => "",28    }29}3031/// One date spelling for the whole browser: `September 22, 2004`,32/// `March 2027`, `2027`, by the precision the ISO value holds. A date33/// inside the months ahead a person reads as soon drops its year,34/// because the month and the day are what that person waits for. Every35/// date at or before today keeps its year, and a value that holds no36/// date reads as nothing.37pub fn date_worded(released: &str, today: &str) -> String {38    let year = year(released);39    if year.is_empty() {40        return String::new();41    }42    let mut parts = released43        .split('-')44        .skip(1)45        .map(|part| part.parse::<usize>().ok());46    let month = parts47        .next()48        .flatten()49        .filter(|month| (1..=12).contains(month));50    let day = parts.next().flatten().filter(|day| (1..=31).contains(day));51    let near = near(released, today);52    match (month, day) {53        (Some(month), Some(day)) => match near {54            true => format!("{} {day}", MONTHS[month - 1]),55            false => format!("{} {day}, {year}", MONTHS[month - 1]),56        },57        (Some(month), None) => match near {58            true => MONTHS[month - 1].to_string(),59            false => format!("{} {year}", MONTHS[month - 1]),60        },61        _ => year.to_string(),62    }63}6465// How many calendar months ahead of today a date drops its year.66const NEAR_MONTHS: i64 = 9;6768// Whether a date stands after today and no further ahead than the69// months a date drops its year inside.70fn near(released: &str, today: &str) -> bool {71    let today = parted(today);72    let released = parted(released);73    released > today && released <= horizon(today)74}7576// Today, moved ahead by the months a near date runs to. A day the month77// it lands in does not hold stands past every date of that month, which78// is the answer the comparison wants.79fn horizon(today: (i64, i64, i64)) -> (i64, i64, i64) {80    let (year, month, day) = today;81    match month + NEAR_MONTHS > 12 {82        true => (year + 1, month + NEAR_MONTHS - 12, day),83        false => (year, month + NEAR_MONTHS, day),84    }85}8687// The year, the month, and the day of an ISO value as numbers, with a88// part the value leaves out as zero, so two dates of different precision89// compare.90fn parted(iso: &str) -> (i64, i64, i64) {91    let mut parts = iso92        .split('-')93        .map(|part| part.parse::<i64>().unwrap_or_default());94    (95        parts.next().unwrap_or_default(),96        parts.next().unwrap_or_default(),97        parts.next().unwrap_or_default(),98    )99}100101/// One count of one thing, spelled once for every screen: the count with102/// a comma every three digits, and the noun in the singular where the103/// count is one. `nouns` is the plural, the word the catalog's own104/// columns carry.105pub fn counted(count: i64, nouns: &str) -> String {106    let noun = match count {107        1 => singular(nouns),108        _ => nouns,109    };110    format!("{} {noun}", thousands(count))111}112113// A noun that ends in "series" reads the same either way; every other114// noun a count carries drops its "s".115fn singular(nouns: &str) -> &str {116    match nouns.ends_with("series") {117        true => nouns,118        false => nouns.strip_suffix('s').unwrap_or(nouns),119    }120}121122// A count as a person reads it, with a comma every three digits from123// the right.124fn thousands(count: i64) -> String {125    let digits = count.to_string();126    let mut read = String::with_capacity(digits.len() + digits.len() / 3);127    for (place, digit) in digits.char_indices() {128        if place > 0 && (digits.len() - place).is_multiple_of(3) {129            read.push(',');130        }131        read.push(digit);132    }133    read134}135136const MONTHS: [&str; 12] = [137    "January",138    "February",139    "March",140    "April",141    "May",142    "June",143    "July",144    "August",145    "September",146    "October",147    "November",148    "December",149];150151/// A duration in seconds as hours and minutes, or nothing where the152/// catalog holds none.153pub fn runtime(seconds: i64) -> String {154    let minutes = seconds / 60;155    if minutes <= 0 {156        return String::new();157    }158    match (minutes / 60, minutes % 60) {159        (0, minutes) => format!("{minutes}m"),160        (hours, 0) => format!("{hours}h"),161        (hours, minutes) => format!("{hours}h {minutes}m"),162    }163}164165/// The facts that are present, joined into one line. A fact the row does166/// not carry leaves no gap and no separator behind.167pub fn joined(parts: &[&str]) -> String {168    Line::of(parts).words169}170171/// One line of facts, with the end of each whole fact recorded, so a band172/// that cannot hold the whole line draws whole facts and never a dangling173/// separator.174#[derive(Debug, Clone, Default, PartialEq, Eq)]175pub struct Line {176    words: String,177    cuts: Vec<Cut>,178}179180// Where one run of whole facts ends: the bytes it takes, and the181// characters the width estimate counts.182#[derive(Debug, Clone, Copy, PartialEq, Eq)]183struct Cut {184    bytes: usize,185    chars: usize,186}187188impl Line {189    /// The facts that are present, joined, with a cut after each one.190    pub fn of(parts: &[&str]) -> Self {191        let mut words = String::new();192        let mut cuts = Vec::new();193        for part in parts.iter().filter(|part| !part.is_empty()) {194            if !words.is_empty() {195                words.push_str(BETWEEN);196            }197            words.push_str(part);198            cuts.push(Cut {199                bytes: words.len(),200                chars: words.chars().count(),201            });202        }203        Self { words, cuts }204    }205206    /// The whole line, every fact it holds.207    pub fn words(&self) -> &str {208        &self.words209    }210211    /// The longest run of whole facts that this many characters hold,212    /// dropping facts from the end. The first fact is the floor, and the213    /// band clips it where even that is longer.214    pub fn fitting(&self, chars: usize) -> &str {215        let cut = self.cuts.iter().rev().find(|cut| cut.chars <= chars);216        match cut.or(self.cuts.first()) {217            Some(cut) => &self.words[..cut.bytes],218            None => "",219        }220    }221}222223#[cfg(test)]224mod tests {225    use super::*;226227    #[test]228    fn a_series_reads_as_a_series_and_every_other_kind_as_a_film() {229        assert_eq!(kind_word("series"), "Series");230        assert_eq!(kind_word("movies"), "Film");231        assert_eq!(kind_word("movie"), "Film");232        assert_eq!(kind_word(""), "Film");233    }234235    // The day every date in these tests is read on.236    const TODAY: &str = "2026-09-05";237238    #[test]239    fn a_date_reads_in_full_and_a_year_reads_alone() {240        let cases = [241            ("2004-09-22", "September 22, 2004"),242            ("1999-03-31", "March 31, 1999"),243            ("2004", "2004"),244            ("2004-13-01", "2004"),245            ("2004-09", "September 2004"),246            ("soon", ""),247            ("", ""),248        ];249        for (released, want) in cases {250            assert_eq!(date_worded(released, TODAY), want, "{released}");251        }252    }253254    #[test]255    fn a_date_that_is_not_one_reads_as_the_year_it_holds() {256        let cases = [257            ("2027-13", "2027"),258            ("2027-xx-01", "2027"),259            ("2027-00", "2027"),260            ("", ""),261        ];262        for (released, want) in cases {263            assert_eq!(date_worded(released, TODAY), want, "{released}");264        }265    }266267    #[test]268    fn a_date_inside_the_nine_months_ahead_drops_its_year() {269        let cases = [270            ("2027-03-12", "March 12"),271            ("2027-03", "March"),272            ("2026-09-06", "September 6"),273            ("2027-06-05", "June 5"),274            ("2027-06", "June"),275            ("2027", "2027"),276        ];277        for (released, want) in cases {278            assert_eq!(date_worded(released, TODAY), want, "{released}");279        }280    }281282    #[test]283    fn a_date_past_the_nine_months_and_a_date_behind_today_keep_their_year() {284        let cases = [285            ("2027-06-06", "June 6, 2027"),286            ("2027-07", "July 2027"),287            ("2026-09-05", "September 5, 2026"),288            ("2026-09-04", "September 4, 2026"),289        ];290        for (released, want) in cases {291            assert_eq!(date_worded(released, TODAY), want, "{released}");292        }293    }294295    #[test]296    fn nine_months_from_december_lands_in_the_next_year() {297        assert_eq!(date_worded("2027-06-30", "2026-12-31"), "June 30");298        assert_eq!(date_worded("2027-10-01", "2026-12-31"), "October 1, 2027");299    }300301    #[test]302    fn a_count_reads_its_noun_in_the_singular_where_it_is_one() {303        let cases = [304            (1, "movies", "1 movie"),305            (1_422, "movies", "1,422 movies"),306            (1, "series", "1 series"),307            (165, "series", "165 series"),308            (1, "titles", "1 title"),309            (42, "titles", "42 titles"),310            (1, "films", "1 film"),311            (40, "films", "40 films"),312            (1, "episodes", "1 episode"),313            (30, "episodes", "30 episodes"),314            (1, "franchises", "1 franchise"),315            (32, "franchises", "32 franchises"),316            (40, "films and series", "40 films and series"),317        ];318        for (count, nouns, want) in cases {319            assert_eq!(counted(count, nouns), want, "{count} {nouns}");320        }321    }322323    #[test]324    fn a_count_marks_its_thousands() {325        let cases = [326            (0, "0 films"),327            (999, "999 films"),328            (1_000, "1,000 films"),329            (12_345, "12,345 films"),330            (1_234_567, "1,234,567 films"),331        ];332        for (count, want) in cases {333            assert_eq!(counted(count, "films"), want, "{count}");334        }335    }336337    #[test]338    fn a_year_and_a_date_both_give_the_year() {339        assert_eq!(year("1999"), "1999");340        assert_eq!(year("2004-09-22"), "2004");341    }342343    #[test]344    fn a_release_that_holds_no_year_gives_nothing() {345        assert_eq!(year(""), "");346        assert_eq!(year("soon"), "");347        assert_eq!(year("199"), "");348    }349350    #[test]351    fn a_runtime_reads_as_hours_and_minutes() {352        assert_eq!(runtime(8_160), "2h 16m");353        assert_eq!(runtime(2_700), "45m");354        assert_eq!(runtime(7_200), "2h");355    }356357    #[test]358    fn a_duration_under_a_minute_gives_nothing() {359        assert_eq!(runtime(0), "");360        assert_eq!(runtime(-1), "");361        assert_eq!(runtime(59), "");362    }363364    #[test]365    fn a_line_that_carries_no_facts_is_empty_at_any_width() {366        assert_eq!(Line::of(&["", ""]).words(), "");367        assert_eq!(Line::of(&["", ""]).fitting(40), "");368    }369370    #[test]371    fn the_line_carries_only_the_facts_that_are_there() {372        assert_eq!(joined(&["Specimen", "", "1h 37m", ""]), "Specimen · 1h 37m");373        assert_eq!(joined(&["", ""]), "");374        assert_eq!(joined(&["Specimen"]), "Specimen");375    }376}
src/screens/foot.rs 100.0%
1// The foot of a title's page: the studios, and one line for every video2// file the title holds. Every function here is pure over the rows, so the3// two pages share one model and the tests need no window.45use crate::catalog::FileFacts;6use crate::look;7use crate::screens::facts;8use crate::views::text;910/// The size the production line draws at, and the smaller size the file11/// lines draw at under it, so the technical detail reads second.12pub const PRODUCTION: f32 = look::CAPTION;13pub const DETAIL: f32 = look::FACE;1415// The space between the production line and the first file line.16const GAP: f32 = 8.0;1718// The space after the leading words of a line, because the average19// advance the width is measured by undercounts a trailing space.20const AFTER_PREFIX: f32 = 4.0;2122/// The words that lead the studios on the production line, in the faint23/// face, so the names read first.24pub const PRODUCED_BY: &str = "Produced by ";2526// The two categories of the files table the foot reads, and the role a27// title's own video file holds. A trailer is a video of the title too,28// and the foot names the files the title itself is.29const VIDEO: &str = "video";30const SUBTITLE: &str = "subtitle";31const PRIMARY: &str = "primary";3233// The word that leads the subtitle languages on a file's line.34const SUBTITLES: &str = "Subtitles: ";3536/// The foot of one title: the studios on one line, then one line for every37/// video file.38#[derive(Debug, Clone, Default, PartialEq, Eq)]39pub struct Foot {40    studios: String,41    files: Vec<String>,42}4344/// One line of the foot as a page draws it: the faint words that lead it,45/// its text, its size, whether the text draws in the faint face rather46/// than the full one, and the space over it. The names are values and47/// draw at full brightness; the words that name them, and the technical48/// detail of the files, are asides and draw faint.49#[derive(Debug, Clone, Copy, PartialEq)]50pub struct Row<'a> {51    pub prefix: &'static str,52    pub content: &'a str,53    pub size: f32,54    pub faint: bool,55    pub lead: f32,56}5758impl Row<'_> {59    /// The width the leading words take, which the text starts after.60    pub fn indent(&self) -> f32 {61        match self.prefix.is_empty() {62            true => 0.0,63            false => text::width(self.prefix, self.size) + AFTER_PREFIX,64        }65    }6667    /// The height the row takes at this width, its lead included.68    pub fn height(&self, width: f32) -> f32 {69        let lines = text::lines(self.content, self.size, width - self.indent());70        self.lead + text::height(lines, self.size)71    }72}7374impl Foot {75    /// The foot of one title, from the studios its body names and the files76    /// the catalog holds for it.77    pub fn of(studios: &[String], files: &[FileFacts]) -> Self {78        let subtitles = subtitles(files);79        let files = files80            .iter()81            .filter(|file| file.kind == VIDEO && file.role == PRIMARY)82            .map(|file| {83                facts::joined(&[84                    &frame(file),85                    &file.video_codec,86                    &file.audio_codec,87                    &size(file.size_bytes),88                    &subtitles,89                ])90            })91            .filter(|line| !line.is_empty())92            .collect();93        Self {94            studios: studios.join(", "),95            files,96        }97    }9899    /// The lines the block draws, in order: the production line, then a100    /// gap, then the file lines. A line the title carries nothing for is101    /// left out, and so is the gap over a file line with no production102    /// line above it.103    pub fn rows(&self) -> impl Iterator<Item = Row<'_>> {104        let production = (!self.studios.is_empty()).then_some(Row {105            prefix: PRODUCED_BY,106            content: self.studios.as_str(),107            size: PRODUCTION,108            faint: false,109            lead: 0.0,110        });111        let files = self.files.iter().enumerate().map(move |(index, file)| Row {112            prefix: "",113            content: file.as_str(),114            size: DETAIL,115            faint: true,116            lead: match index == 0 && production.is_some() {117                true => GAP,118                false => 0.0,119            },120        });121        production.into_iter().chain(files)122    }123124    /// The height the block takes at this width, and zero where the title125    /// carries no line at all.126    pub fn height(&self, width: f32) -> f32 {127        self.rows().map(|row| row.height(width)).sum()128    }129}130131// The frame of one file, and nothing where the scanner read no size.132fn frame(file: &FileFacts) -> String {133    match file.width > 0 && file.height > 0 {134        true => format!("{}\u{d7}{}", file.width, file.height),135        false => String::new(),136    }137}138139// One file's size, in the units a person reads a film's size in, and140// nothing where the scanner read none.141fn size(bytes: i64) -> String {142    const GB: f64 = 1_000_000_000.0;143    const MB: f64 = 1_000_000.0;144    if bytes <= 0 {145        return String::new();146    }147    let bytes = bytes as f64;148    match bytes >= GB {149        true => format!("{:.1} GB", bytes / GB),150        false => format!("{:.0} MB", bytes / MB),151    }152}153154// The languages the title's subtitle files carry, in the order the155// files came back, each named once.156fn subtitles(files: &[FileFacts]) -> String {157    let mut named: Vec<&str> = Vec::new();158    for file in files {159        if file.kind != SUBTITLE || file.language.is_empty() {160            continue;161        }162        let language = named_language(&file.language);163        if !named.contains(&language) {164            named.push(language);165        }166    }167    match named.is_empty() {168        true => String::new(),169        false => format!("{SUBTITLES}{}", named.join(", ")),170    }171}172173// The English name of a language tag. A tag this table does not name174// draws as the file itself carries it, because a tag a person can read175// is better than nothing at all.176fn named_language(tag: &str) -> &str {177    const NAMES: [(&str, &str); 40] = [178        ("ar", "Arabic"),179        ("ara", "Arabic"),180        ("cs", "Czech"),181        ("ces", "Czech"),182        ("cze", "Czech"),183        ("da", "Danish"),184        ("dan", "Danish"),185        ("de", "German"),186        ("deu", "German"),187        ("ger", "German"),188        ("el", "Greek"),189        ("en", "English"),190        ("eng", "English"),191        ("es", "Spanish"),192        ("spa", "Spanish"),193        ("fi", "Finnish"),194        ("fin", "Finnish"),195        ("fr", "French"),196        ("fra", "French"),197        ("fre", "French"),198        ("he", "Hebrew"),199        ("hi", "Hindi"),200        ("hu", "Hungarian"),201        ("it", "Italian"),202        ("ita", "Italian"),203        ("ja", "Japanese"),204        ("jpn", "Japanese"),205        ("ko", "Korean"),206        ("kor", "Korean"),207        ("nl", "Dutch"),208        ("nld", "Dutch"),209        ("no", "Norwegian"),210        ("pl", "Polish"),211        ("pt", "Portuguese"),212        ("por", "Portuguese"),213        ("ru", "Russian"),214        ("rus", "Russian"),215        ("sv", "Swedish"),216        ("tr", "Turkish"),217        ("zh", "Chinese"),218    ];219    NAMES220        .iter()221        .find(|(code, _)| *code == tag)222        .map_or(tag, |(_, name)| *name)223}224225#[cfg(test)]226mod tests {227    use super::*;228229    fn video() -> FileFacts {230        FileFacts {231            role: "primary".into(),232            kind: "video".into(),233            container: "mkv".into(),234            video_codec: "x265".into(),235            audio_codec: "AC3".into(),236            width: 1_920,237            height: 804,238            size_bytes: 4_200_000_000,239            language: String::new(),240        }241    }242243    fn subtitle(language: &str) -> FileFacts {244        FileFacts {245            role: "subtitle".into(),246            kind: "subtitle".into(),247            language: language.into(),248            ..FileFacts::default()249        }250    }251252    #[test]253    fn a_title_names_its_studios_and_then_every_video_file_it_holds() {254        let foot = Foot::of(255            &["A Studio".to_string(), "Another".to_string()],256            &[video(), subtitle("en"), subtitle("fr")],257        );258        let rows: Vec<&str> = foot.rows().map(|row| row.content).collect();259        assert_eq!(260            rows,261            [262                "A Studio, Another",263                "1920×804 · x265 · AC3 · 4.2 GB · Subtitles: English, French",264            ]265        );266    }267268    #[test]269    fn a_language_the_table_does_not_name_draws_as_the_file_carries_it() {270        let foot = Foot::of(&[], &[video(), subtitle("qq"), subtitle("eng")]);271        let rows: Vec<&str> = foot.rows().map(|row| row.content).collect();272        assert_eq!(273            rows,274            ["1920×804 · x265 · AC3 · 4.2 GB · Subtitles: qq, English"]275        );276    }277278    #[test]279    fn a_file_the_scanner_read_no_frame_or_codec_of_shows_what_it_has() {280        let bare = FileFacts {281            size_bytes: 700_000_000,282            ..video()283        };284        let foot = Foot::of(285            &[],286            &[FileFacts {287                width: 0,288                height: 0,289                video_codec: String::new(),290                ..bare291            }],292        );293        let rows: Vec<&str> = foot.rows().map(|row| row.content).collect();294        assert_eq!(rows, ["AC3 · 700 MB"]);295    }296297    #[test]298    fn the_foot_names_the_title_s_own_video_files_and_no_other_file() {299        let trailer = FileFacts {300            role: "trailer".into(),301            ..video()302        };303        let art = FileFacts {304            role: "backdrop".into(),305            kind: "image".into(),306            ..FileFacts::default()307        };308        let foot = Foot::of(&[], &[video(), trailer, art]);309        assert_eq!(foot.rows().count(), 1);310    }311312    #[test]313    fn a_title_with_no_studio_and_no_file_takes_no_height() {314        let foot = Foot::of(&[], &[]);315        assert_eq!(foot.rows().count(), 0);316        assert_eq!(foot.height(900.0), 0.0);317    }318319    #[test]320    fn a_foot_is_as_tall_as_its_lines_and_the_gap_between_the_two_kinds() {321        let foot = Foot::of(&["A Studio".to_string()], &[video()]);322        assert_eq!(323            foot.height(1_680.0),324            text::height(1, PRODUCTION) + GAP + text::height(1, DETAIL)325        );326    }327328    #[test]329    fn the_file_lines_draw_smaller_and_fainter_than_the_production_line() {330        let foot = Foot::of(&["A Studio".to_string()], &[video()]);331        let rows: Vec<Row> = foot.rows().collect();332        assert_eq!(rows[0].prefix, PRODUCED_BY);333        assert!(!rows[0].faint);334        assert_eq!(rows[1].prefix, "");335        assert!(rows[1].faint);336        assert!(rows[1].size < rows[0].size);337        assert_eq!(rows[1].lead, GAP);338    }339340    #[test]341    fn a_file_line_with_no_production_line_over_it_takes_no_gap() {342        let foot = Foot::of(&[], &[video()]);343        let rows: Vec<Row> = foot.rows().collect();344        assert_eq!(rows[0].lead, 0.0);345        assert_eq!(foot.height(1_680.0), text::height(1, DETAIL));346    }347}
src/screens/franchise.rs 97.0%
1// One franchise's page: the order of a story across films and series, as a2// wall of rows from first to last. The lines beside the rows are the3// universes, the rail at the left is the eras, and the label beside a4// row is its time on the franchise's own clock, under a caption that5// names what that clock counts. A press opens the film or the series,6// the way the set strip's press does, and a press on a gap opens7// nothing. Story order is the one order the page draws, because it is8// the one order a franchise has.910mod card;11mod metro;12mod page;13pub mod strips;14mod wall;1516use std::cell::RefCell;17use std::convert::Infallible;1819use iced_wgpu::Renderer;20use iced_widget::canvas;21use iced_winit::core::{Element, Length, Theme};2223use super::{Screen, Step, movie, series};24use crate::catalog::Source;25use crate::catalog::draw::Date;26use crate::focus;27use crate::posters::Posters;28use crate::views::{band, rail};2930pub use metro::Run;31pub use wall::{Cell, Row};3233/// Where focus is on the page: one row, or one bar of the rail.34#[derive(Debug, Clone, Copy, PartialEq, Eq)]35pub enum Focus {36    Row(usize),37    Rail(usize),38}3940/// The franchise page: the universes, the rows in story order, the runs41/// of the strip, the bars of the rail, and where focus is. Every row,42/// every run, and every bar is built once here, at the read, and not on43/// every frame.44#[derive(Debug)]45pub struct Franchise {46    /// The catalog's library column of the `Library` of kind47    /// franchises, `namespace/name`.48    pub library: String,49    /// The franchise's id inside that library.50    pub id: String,51    /// The name a person reads, which the band carries.52    pub title: String,53    /// The universes, in the order the file names them. A cell and a run54    /// both name one by its place in this list.55    pub universes: Vec<String>,56    /// The lines of the metro strip: one run per universe some row57    /// names, in the order the runs start.58    pub runs: Vec<Run>,59    /// The wall in story order, which is the one order a franchise has.60    pub rows: Vec<Row>,61    /// The width of the time column, from the widest label the rows62    /// carry, and none where no row carries one.63    pub time: f32,64    /// The caption over that column, in the lines the column holds, and65    /// none where the file's calendar names no zero.66    pub caption: Vec<String>,67    /// The eras, as the bars of the rail beside the rows.68    pub eras: Vec<rail::Bar>,69    /// Where focus is.70    pub focus: Focus,71}7273impl Franchise {74    /// Read one franchise's page, or nothing where that `Library` holds no75    /// franchise under that id. Focus lands on the first row, so a press opens76    /// the first entry of the story.77    pub fn open(library: &str, id: &str, source: &mut dyn Source) -> Option<Self> {78        let read = source.franchise(library, id)?;79        let today = Date::today().iso();80        let universes = wall::columns(&read);81        let rows = wall::story(&read, &universes, &today);82        let runs = metro::runs(&rows, &universes);83        let eras = wall::bars(&read.eras, &rows);84        // The column's width and its caption are measured once, at the85        // read, because they answer the same words on every frame.86        let time = wall::time_width(&rows);87        let caption = wall::caption(&read.calendar, time);88        Some(Self {89            library: library.to_string(),90            id: id.to_string(),91            title: read.title,92            universes,93            runs,94            rows,95            time,96            caption,97            eras,98            focus: Focus::Row(0),99        })100    }101102    /// Read the page again, because a scan can write the order while103    /// the page is open. Focus stays where it was, inside what the read104    /// answered.105    pub fn reread(&mut self, source: &mut dyn Source) {106        let Some(fresh) = Self::open(&self.library, &self.id, source) else {107            return;108        };109        let focus = self.focus;110        *self = fresh;111        self.focus = self.hold(focus);112    }113114    /// Fold one press in. Down walks forward in story order and up walks115    /// back, one row at a time whatever universe the next row is in, and116    /// up from the first row holds it. Left lands on the rail, where up117    /// and down move a bar and right returns to the first row of that118    /// bar. Right on a row does nothing, because there is no rail on the119    /// right yet. A press opens the film or the series, and opens nothing120    /// on a gap or on a bar.121    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {122        match self.focus {123            Focus::Row(row) => self.on_row(row, key, source),124            Focus::Rail(bar) => self.on_rail(bar, key),125        }126    }127128    /// The view: the wall under the band, and the band as a layer over129    /// it.130    pub fn view<'a, P: Posters>(131        &'a self,132        posters: &'a RefCell<P>,133        held: bool,134    ) -> Element<'a, Infallible, Theme, Renderer> {135        let wall = canvas(page::Page {136            franchise: self,137            posters,138            held,139        })140        .width(Length::Fill)141        .height(Length::Fill)142        .into();143        let band = band::layer(&self.title);144        iced_widget::Stack::with_children(vec![wall, band])145            .width(Length::Fill)146            .height(Length::Fill)147            .into()148    }149150    // One press on a cell of the wall.151    fn on_row(&mut self, row: usize, key: &str, source: &mut dyn Source) -> Step {152        if key == "enter" {153            return self.opened(row, source);154        }155        // Up from the first row moves nothing, which is how a press156        // reaches the browser's strip.157        if key == "up" && row == 0 {158            return Step::Still;159        }160        self.focus = match key {161            "up" => Focus::Row(row.saturating_sub(1)),162            "down" if row + 1 < self.rows.len() => Focus::Row(row + 1),163            "left" => match rail::covering(&self.eras, row) {164                Some(bar) => Focus::Rail(bar),165                None => Focus::Row(row),166            },167            _ => Focus::Row(row),168        };169        Step::Stay170    }171172    // One press on the rail. Up and down move a bar, and right and a173    // select both jump to the first row the bar covers, which is what174    // the rail is for.175    fn on_rail(&mut self, bar: usize, key: &str) -> Step {176        if key == "up" && bar == 0 {177            return Step::Still;178        }179        self.focus = match key {180            "up" | "down" => Focus::Rail(focus::list(bar, self.eras.len(), key)),181            "right" | "enter" => match self.eras.get(bar) {182                Some(bar) => Focus::Row(bar.first),183                None => Focus::Rail(bar),184            },185            _ => Focus::Rail(bar),186        };187        Step::Stay188    }189190    // The page one cell opens: the film's or the series' own. A member191    // stands in the same story as the page, so it replaces the page and192    // does not cover it, the way a sibling in a set strip does.193    fn opened(&self, row: usize, source: &mut dyn Source) -> Step {194        let Some((library, kind, id)) = self.rows.get(row).and_then(|row| row.cell.opens()) else {195            return Step::Stay;196        };197        let opened =198            match kind {199                "movies" => movie::Movie::open(library, id, source)200                    .map(|page| Screen::Movie(Box::new(page))),201                _ => series::Series::open(library, id, source)202                    .map(|page| Screen::Series(Box::new(page))),203            };204        match opened {205            Some(screen) => Step::Replace(screen),206            None => Step::Stay,207        }208    }209210    // Where focus lands after a re-read: where it was, unless the row,211    // the cell, or the bar it was on went away.212    fn hold(&self, focus: Focus) -> Focus {213        match focus {214            Focus::Row(..) if self.rows.is_empty() => Focus::Row(0),215            Focus::Row(row) => Focus::Row(row.min(self.rows.len() - 1)),216            Focus::Rail(..) if self.eras.is_empty() => Focus::Row(0),217            Focus::Rail(bar) => Focus::Rail(bar.min(self.eras.len() - 1)),218        }219    }220}221222#[cfg(test)]223mod tests;
src/screens/franchise/card.rs 100.0%
1// One held entry of the franchise wall as a card the whole width of the2// lane. A card draws three layers: a ground made from the entry's own3// art, the sharp 16:9 art at the left at the cap, and the words beside4// it: the title, the year, the tagline or the first lines of the plot,5// and the note. The ground is the art decoded again at about 24 pixels6// wide and drawn to cover the card with linear filtering at a low7// opacity over black. The linear upscale of a tiny image is the blur, so8// the browser needs no blur crate.910use iced_winit::core::Rectangle;1112use super::wall::{GAP, poster_width};13use crate::views::{area, wall};1415/// The width the ground decodes at; the height follows the art's ratio.16/// A decode this small costs under a kilobyte in the cache and takes the17/// poster lane.18pub const GROUND: u32 = 24;1920/// The opacity the ground draws at over the black card, so the art behind21/// the words reads as a dark backdrop and never fights the sharp art. It22/// is the image's own opacity and not a veil over it, because a fill23/// draws under every image of its layer. The surface blends in linear24/// light, so 0.18 reads as about 46% brightness on a white backdrop and25/// about 21% on a mid-grey one; 0.4 left a white backdrop light enough26/// to hide a title.27pub const GROUND_TONE: f32 = 0.18;2829/// The box a card's 16:9 art draws in: a gap in from the card's left and30/// top, at the height the wall measured.31pub fn art_box(card: Rectangle, art: f32) -> Rectangle {32    area(card.x + GAP, card.y + GAP, art / wall::STILL, art)33}3435/// The box a card's poster draws in when the item holds no landscape art:36/// the poster's own ratio at the art's height, at the left of the art37/// box, so the words start beside the poster.38pub fn poster_box(art: Rectangle) -> Rectangle {39    area(art.x, art.y, poster_width(art.height), art.height)40}4142/// The box a card's words draw in: from a gap right of the art to a gap43/// short of the card's edge, as tall as the art.44pub fn words_box(card: Rectangle, art: Rectangle) -> Rectangle {45    let x = art.x + art.width + GAP;46    area(47        x,48        art.y,49        (card.x + card.width - GAP - x).max(0.0),50        art.height,51    )52}5354/// The pixel size the ground decodes at for art of this ratio, height55/// over width.56pub fn ground(ratio: f32) -> (u32, u32) {57    (GROUND, (GROUND as f32 * ratio).round().max(1.0) as u32)58}5960/// The box the ground draws into: art of this ratio scaled to cover the61/// card, centered on it. The card clips it, so the overflow past the62/// card's edge never shows.63pub fn covering(card: Rectangle, ratio: f32) -> Rectangle {64    let (width, height) = match card.height / card.width > ratio {65        true => (card.height / ratio, card.height),66        false => (card.width, card.width * ratio),67    };68    area(69        card.center_x() - width / 2.0,70        card.center_y() - height / 2.0,71        width,72        height,73    )74}7576#[cfg(test)]77mod tests;
src/screens/franchise/metro.rs 98.8%
1// The metro strip between the time labels and the cards: one vertical2// line per universe, drawn the way a git graph draws branches. A3// universe's line is one unbroken run from the middle of its first4// entry's row to the middle of its last, through the gaps between rows5// and through the thin rows; above and below it there is nothing. Runs6// that never overlap share a lane, so the strip is as wide as the story7// is at its widest and not as wide as the count of universes. A row8// takes a dot on the line of every universe it names, a filled circle in9// the line's color, and a bar across the dots where it names several: a10// pill in the ink color behind the dots. The dot of a thin row is11// smaller. Each run's name reads upward beside its own line, in the12// line's color, so a person reads the name at the point where the line13// starts and no legend has to be matched to a lane. With one run the14// strip has no width and is not drawn, because a line every row stands15// on says nothing. The strip scrolls with the rows. The lines, the bars,16// and the dots are all fills, so they draw in that order inside one17// layer, and the names draw last, because rotated words are meshes too.1819use iced_wgpu::Renderer;20use iced_widget::canvas;21use iced_winit::core::{Color, Point, Rectangle};2223use super::wall::{GAP, Row};24use crate::look;25use crate::views::{area, rounded, stack, text};2627/// The space one lane takes across the strip. It holds a dot, and the28/// gutter beside the dot holds a name, so a name never covers the next29/// lane's line.30pub const PITCH: f32 = 40.0;3132/// The width of a line.33pub const LINE: f32 = 4.0;3435/// The radius of a card's dot, and of a thin row's smaller one.36pub const DOT: f32 = 9.0;37pub const SMALL: f32 = 6.0;3839// The opacity a line draws at, so a dot reads over it.40const RUN: f32 = 0.85;4142// The lightness and the chroma every line shares, and the hues the runs43// take in turn. The palette is fixed and the runs cycle through it, so a44// franchise of twenty universes draws in colors a person tells apart45// while a franchise of three keeps the same three it had before the46// twentieth arrived. Two runs of a long story share a hue, and the name47// beside each line tells them apart.48const LIGHTNESS: f32 = 0.78;49const CHROMA: f32 = 0.12;50const HUES: [f32; 8] = [20.0, 65.0, 110.0, 155.0, 200.0, 245.0, 290.0, 335.0];5152// The size a run's name draws at, the space between a run's first dot53// and the foot of its name, and the room a name has across the strip:54// the gutter between the dots of two lanes.55const NAME: f32 = 17.0;56const NAME_GAP: f32 = 8.0;57const NAME_ROOM: f32 = PITCH - 2.0 * DOT;5859/// One universe's line on the strip: the rows it reaches, the lane it60/// draws in, the hue it takes, and the name it carries. `universe` is61/// the universe's place in the page's own list, which is what a row's62/// cell names.63#[derive(Debug, Clone, Default, PartialEq, Eq)]64pub struct Run {65    pub name: String,66    pub universe: usize,67    pub first: usize,68    pub last: usize,69    pub lane: usize,70    pub hue: usize,71}7273/// The runs of these rows, in the order they start. A universe no row74/// names has no run. A run takes the lowest lane that is free where it75/// starts, and its lane is free again for a run that starts below its76/// last row, so a franchise of twenty universes told one after another77/// draws in one lane. Each run takes the next hue of the palette in that78/// same order.79pub fn runs(rows: &[Row], universes: &[String]) -> Vec<Run> {80    let mut spans: Vec<Option<(usize, usize)>> = vec![None; universes.len()];81    for (index, row) in rows.iter().enumerate() {82        for universe in &row.cell.universes {83            let Some(span) = spans.get_mut(*universe) else {84                continue;85            };86            *span = match *span {87                None => Some((index, index)),88                Some((first, _)) => Some((first, index)),89            };90        }91    }9293    let mut runs: Vec<Run> = spans94        .iter()95        .enumerate()96        .filter_map(|(universe, span)| {97            span.map(|(first, last)| Run {98                name: universes[universe].clone(),99                universe,100                first,101                last,102                lane: 0,103                hue: 0,104            })105        })106        .collect();107    runs.sort_by_key(|run| (run.first, run.universe));108109    // The last row of the run each lane holds. A lane is free where its110    // run ended above the row the next one starts on, because two runs111    // alive on one row would draw as one line.112    let mut ends: Vec<usize> = Vec::new();113    for (order, run) in runs.iter_mut().enumerate() {114        run.hue = order % HUES.len();115        run.lane = match ends.iter().position(|end| *end < run.first) {116            Some(free) => free,117            None => {118                ends.push(run.last);119                ends.len() - 1120            }121        };122        ends[run.lane] = run.last;123    }124    runs125}126127/// How many lanes these runs fill: the most runs the story has alive at128/// one time.129pub fn lanes(runs: &[Run]) -> usize {130    runs.iter().map(|run| run.lane + 1).max().unwrap_or(0)131}132133/// The width the strip takes for these runs, and none for one run. Every134/// row of a franchise of one universe stands on that one line, and a135/// line every row stands on tells a person nothing.136pub fn width(runs: &[Run]) -> f32 {137    match runs.len() > 1 {138        true => lanes(runs) as f32 * PITCH,139        false => 0.0,140    }141}142143/// Where one lane's line stands across the strip: the middle of its144/// pitch.145pub fn line_x(strip: Rectangle, lane: usize) -> f32 {146    strip.x + lane as f32 * PITCH + PITCH / 2.0147}148149/// Where one lane's name stands across the strip: the middle of the150/// gutter right of its line. The name covers no dot of its own lane and151/// none of the next, and it draws in its line's own color, which is what152/// ties the two together.153pub fn name_x(strip: Rectangle, lane: usize) -> f32 {154    line_x(strip, lane) + PITCH / 2.0155}156157/// The color of a line: the palette's hue at the shared lightness and158/// chroma. The palette cycles, so a run past the eighth takes the first159/// hue again.160pub fn color(hue: usize) -> Color {161    oklch(LIGHTNESS, CHROMA, HUES[hue % HUES.len()])162}163164// One OKLCH color as the sRGB the canvas draws, clamped to the gamut:165// OKLab to linear sRGB by the matrices of the OKLab definition, then the166// sRGB transfer curve. No crate, because this is the only color the167// browser computes.168fn oklch(lightness: f32, chroma: f32, hue: f32) -> Color {169    let (a, b) = (170        chroma * hue.to_radians().cos(),171        chroma * hue.to_radians().sin(),172    );173    let l = (lightness + 0.396_337_8 * a + 0.215_803_8 * b).powi(3);174    let m = (lightness - 0.105_561_3 * a - 0.063_854_2 * b).powi(3);175    let s = (lightness - 0.089_484_2 * a - 1.291_485_5 * b).powi(3);176    let red = 4.076_741_7 * l - 3.307_711_6 * m + 0.230_97 * s;177    let green = -1.268_438 * l + 2.609_757_4 * m - 0.341_319_4 * s;178    let blue = -0.004_196_1 * l - 0.703_418_6 * m + 1.707_614_7 * s;179    Color::from_rgb(gamma(red), gamma(green), gamma(blue))180}181182// One linear channel as the sRGB curve encodes it, clamped to the unit183// range.184fn gamma(linear: f32) -> f32 {185    let linear = linear.clamp(0.0, 1.0);186    match linear <= 0.003_130_8 {187        true => 12.92 * linear,188        false => 1.055 * linear.powf(1.0 / 2.4) - 0.055,189    }190}191192/// The middle of one row, in frame space after the scroll.193pub fn middle(strip: Rectangle, row: usize, tops: &[f32], down: f32) -> f32 {194    let top = tops.get(row).copied().unwrap_or_default();195    let next = tops.get(row + 1).copied().unwrap_or(top + GAP);196    strip.y + (top + next - GAP) / 2.0 - down197}198199/// The runs one row takes a dot on, in the order the row names their200/// universes. A row names no run where the page's list holds no such201/// universe.202pub fn dotted<'a>(runs: &'a [Run], row: &Row) -> Vec<&'a Run> {203    row.cell204        .universes205        .iter()206        .filter_map(|universe| runs.iter().find(|run| run.universe == *universe))207        .collect()208}209210/// The bar across the dots of one row: a pill from the leftmost lane the211/// row's runs occupy to the rightmost. A row on one lane draws none,212/// because its own dot says as much.213pub fn bar(strip: Rectangle, lanes: &[usize], y: f32, radius: f32) -> Option<Rectangle> {214    let (low, high) = (lanes.iter().min()?, lanes.iter().max()?);215    if low == high {216        return None;217    }218    Some(area(219        line_x(strip, *low) - radius,220        y - radius,221        line_x(strip, *high) - line_x(strip, *low) + 2.0 * radius,222        2.0 * radius,223    ))224}225226/// The box one run's name draws in: the gutter beside its line, over the227/// run's first dot, so the name's foot touches the space above the dot228/// and the words read up from it. While the first dot is above the strip229/// the name holds the top of the strip, the way a jump rail's label230/// holds the top of its bar, and the run's last dot pushes the name off231/// with it, so a name never leaves its own run. `length` is the name's232/// own length along the line.233///234/// A name draws only where the strip reaches it. A run whose first dot235/// is still under the foot of the strip draws none, because a name grows236/// up out of that dot and would otherwise show as its last few letters237/// along the bottom edge. A run whose own last dot has passed over the238/// head of the strip draws none either.239pub fn name_box(240    strip: Rectangle,241    run: &Run,242    tops: &[f32],243    down: f32,244    length: f32,245) -> Option<Rectangle> {246    let first = middle(strip, run.first, tops, down);247    if first - DOT > strip.y + strip.height {248        return None;249    }250    let top = first - DOT - NAME_GAP - length;251    let section = area(252        name_x(strip, run.lane) - NAME_ROOM / 2.0,253        top,254        NAME_ROOM,255        middle(strip, run.last, tops, down) - top,256    );257    let at = stack::held(section, strip, length);258    match at.y + at.height < strip.y {259        true => None,260        false => Some(at),261    }262}263264/// The strip: every line first, then every bar, then every dot, then265/// every name. A dot lies over a bar, a bar over the lines, and a name266/// over all of them. Only the rows and the names the strip reaches build267/// geometry, because a wall of a hundred rows is drawn on every frame.268pub fn draw(269    frame: &mut canvas::Frame<Renderer>,270    strip: Rectangle,271    runs: &[Run],272    rows: &[Row],273    tops: &[f32],274    down: f32,275) {276    if width(runs) <= 0.0 {277        return;278    }279    for run in runs {280        let x = line_x(strip, run.lane);281        let (top, bottom) = (282            middle(strip, run.first, tops, down),283            middle(strip, run.last, tops, down),284        );285        frame.fill_rectangle(286            Point::new(x - LINE / 2.0, top),287            iced_winit::core::Size::new(LINE, bottom - top),288            Color {289                a: RUN,290                ..color(run.hue)291            },292        );293    }294    for (index, row) in rows.iter().enumerate() {295        let y = middle(strip, index, tops, down);296        if y < strip.y - DOT || y > strip.y + strip.height + DOT {297            continue;298        }299        let radius = match row.cell.held() {300            true => DOT,301            false => SMALL,302        };303        let named = dotted(runs, row);304        let lanes: Vec<usize> = named.iter().map(|run| run.lane).collect();305        if let Some(bar) = bar(strip, &lanes, y, radius) {306            frame.fill(&rounded(bar, radius), look::text());307        }308        for run in named {309            frame.fill(310                &canvas::Path::circle(Point::new(line_x(strip, run.lane), y), radius),311                color(run.hue),312            );313        }314    }315    for run in runs {316        let shown = text::cut(&run.name, NAME, strip.height);317        let Some(at) = name_box(strip, run, tops, down, text::measured(&shown, NAME)) else {318            continue;319        };320        text::upward(frame, &shown, at, NAME, color(run.hue));321    }322}323324#[cfg(test)]325mod tests;
src/screens/franchise/page.rs 94.6%
1// The franchise page's one canvas: the rail and the time labels at the2// left, and the metro strip and one lane of cards beside them. The wall3// scrolls inside its own region and is clipped to it, so no row draws4// over the band. A row the region does not reach builds no geometry, so5// a wall of a hundred rows costs only the rows a person sees.67use std::cell::RefCell;8use std::convert::Infallible;910use iced_wgpu::Renderer;11use iced_widget::canvas;12use iced_winit::core::image::FilterMethod;13use iced_winit::core::{Point, Rectangle, Theme, mouse};1415use super::card::{self, GROUND_TONE};16use super::metro;17use super::wall::{self, Cell};18use super::{Focus, Franchise};19use crate::catalog::franchise::Standing;20use crate::look;21use crate::posters::Posters;22use crate::views::{Tone, area, artwork, band, extent, mark, rail, rounded, text, wall as still};2324// The margin at both sides of the page.25const MARGIN: f32 = 80.0;2627// The space between the band and the first row.28const TOP: f32 = 24.0;2930// The space between a title's last line and the note under it.31const LEAD: f32 = 2.0;3233// The space inside a thin row, from its edge to its words.34const INSET: f32 = 12.0;3536// The dash and the space of a thin row's outline, and the width of its37// stroke.38const DASH: [f32; 2] = [6.0, 4.0];39const OUTLINE: f32 = 1.0;4041// The radius of a thin row's corners.42const ROUND: f32 = 4.0;4344/// The part of the frame the wall scrolls in: under the band, and inside45/// the margins.46pub fn region(bounds: Rectangle) -> Rectangle {47    let top = band::HEIGHT + TOP;48    area(49        MARGIN,50        top,51        (bounds.width - 2.0 * MARGIN).max(0.0),52        (bounds.height - top).max(0.0),53    )54}5556/// The page's one canvas.57pub struct Page<'a, P> {58    /// The franchise the page is about.59    pub franchise: &'a Franchise,60    /// The store the entries' art comes from.61    pub posters: &'a RefCell<P>,62    /// Whether the page holds focus, or the browser's strip over it does.63    pub held: bool,64}6566impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Page<'_, P> {67    type State = ();6869    fn draw(70        &self,71        _state: &Self::State,72        renderer: &Renderer,73        _theme: &Theme,74        bounds: Rectangle,75        _cursor: mouse::Cursor,76    ) -> Vec<canvas::Geometry<Renderer>> {77        let page = self.franchise;78        // The page's focus while the page holds it, and none while the79        // browser's strip does, so one mark draws on the glass. The scroll80        // still follows the page's own focus, so the strip never moves the81        // wall under it.82        let focus = self.held.then_some(page.focus);83        let mut frame = canvas::Frame::new(renderer, bounds.size());84        let posters = &mut *self.posters.borrow_mut();8586        let region = region(bounds);87        let rows = &page.rows;88        // The lane measures where the rail leaves off, the time column,89        // the strip, and the cards, and centers the cards where nothing90        // stands at the left.91        let wall::Lane {92            wall,93            columned,94            strip,95            cards,96        } = wall::Lane::of(region, &page.eras, &page.runs, page.time);97        let head = wall::head(&page.caption);98        let art = wall::art_height(region.height - head);99        let tops = wall::tops(rows, art, head);100        let down = match page.focus {101            Focus::Row(row) => wall::scroll(row, &tops, region.height),102            Focus::Rail(bar) => match page.eras.get(bar) {103                Some(bar) => wall::scroll(bar.first, &tops, region.height),104                None => 0.0,105            },106        };107108        frame.with_clip(region, |frame| {109            // The rows start under the head the caption and the first110            // row's focus mark need, and the tops carry that head, so111            // the rail reads them as they are.112            rail::draw(113                frame,114                region,115                &page.eras,116                &tops,117                down,118                match focus {119                    Some(Focus::Rail(bar)) => Some(bar),120                    _ => None,121                },122            );123124            // The caption holds the top of the column, and the times125            // draw under it, so a time that scrolls up leaves the column126            // under the caption and never draws through it.127            let caption = wall::caption_box(wall, page.time, &page.caption, &tops, down);128            times(frame, page, wall, &tops, down, under(caption, region));129            stacked(frame, &page.caption, caption.position(), look::faint());130131            // The strip and the rows are clipped to their own part of132            // the region, so a row that has scrolled up draws nothing133            // over the time labels beside it.134            let cells = wall::clipped(columned);135            frame.with_clip(cells, |frame| {136                metro::draw(frame, strip, &page.runs, rows, &tops, down);137                for (index, row) in rows.iter().enumerate() {138                    let bounds = wall::cell_box(cards, index, &tops, down);139                    if outside(bounds, cells) {140                        continue;141                    }142                    match row.cell.held() {143                        true => entry(frame, posters, &row.cell, bounds, cells, art),144                        false => thin(frame, &row.cell, bounds),145                    }146                    if focus == Some(Focus::Row(index)) {147                        mark(frame, bounds);148                    }149                }150            });151        });152153        vec![frame.into_geometry()]154    }155}156157// Whether a row falls above or below the part of the frame the lane158// draws in, which then builds no geometry for it.159fn outside(cell: Rectangle, columned: Rectangle) -> bool {160    cell.y + cell.height < columned.y || cell.y > columned.y + columned.height161}162163// Every row's time label, in the column under the caption. A label the164// column does not reach builds no geometry, and a label the row above165// carries too draws nothing, so a run of rows in one year prints the166// year once.167fn times(168    frame: &mut canvas::Frame<Renderer>,169    page: &Franchise,170    wall: Rectangle,171    tops: &[f32],172    down: f32,173    column: Rectangle,174) {175    frame.with_clip(column, |frame| {176        for index in 0..page.rows.len() {177            let label = wall::time_box(wall, page.time, index, tops, down);178            if label.y + label.height < column.y || label.y > column.y + column.height {179                continue;180            }181            let (first, second) = wall::stacked(wall::label_at(&page.rows, index));182            stacked(frame, &[first, second], label.position(), look::muted());183        }184    });185}186187// A stack of lines at the caption size, one line to a line. Every line188// was measured against the column's own width before it reached here, so189// each draws unbounded and takes one line's height. A width would let190// the shaper break a word the column cannot hold across two lines, and191// the second of them would draw over the line under it.192fn stacked(193    frame: &mut canvas::Frame<Renderer>,194    lines: &[String],195    at: Point,196    color: iced_winit::core::Color,197) {198    let mut y = at.y;199    for line in lines {200        text::line(201            frame,202            line,203            Point::new(at.x, y),204            look::CAPTION,205            color,206            f32::INFINITY,207        );208        y += text::height(1, look::CAPTION);209    }210}211212// The part of the time column the times draw in: everything under the213// caption's own band.214fn under(caption: Rectangle, region: Rectangle) -> Rectangle {215    let top = caption.y + caption.height;216    area(217        caption.x,218        top,219        caption.width,220        (region.y + region.height - top).max(0.0),221    )222}223224// One held entry of the order as a card of three layers: the ground made225// from its own art, the sharp art at the left, and the words beside the226// art. The ground draws first, then the art, then the words; the images227// of one layer draw in the order the canvas drew them, so the sharp art228// lands over the ground.229fn entry<P: Posters>(230    frame: &mut canvas::Frame<Renderer>,231    posters: &mut P,232    cell: &Cell,233    bounds: Rectangle,234    clip: Rectangle,235    art: f32,236) {237    ground(frame, posters, cell, bounds, clip);238    let box_of = card::art_box(bounds, art);239    let art = match cell.wide {240        true => box_of,241        false => card::poster_box(box_of),242    };243    artwork(244        frame,245        posters,246        &cell.library,247        &cell.art,248        art,249        "",250        Tone::Full,251    );252    words(frame, cell, card::words_box(bounds, art));253}254255// A thin row for an entry no library holds: a dashed outline, the title256// and the year from the left, and the note at the right. The note is the257// accent for a title still to come, so a person reads at a glance what258// is not out yet.259fn thin(frame: &mut canvas::Frame<Renderer>, cell: &Cell, bounds: Rectangle) {260    frame.stroke(261        &rounded(bounds, ROUND),262        canvas::Stroke {263            line_dash: canvas::LineDash {264                segments: &DASH,265                offset: 0,266            },267            ..canvas::Stroke::default()268                .with_color(look::faint())269                .with_width(OUTLINE)270        },271    );272    let title = text::cut(&cell.name, look::DETAIL, bounds.width / 2.0);273    let (at, facts) = thin_words(&title, bounds);274    text::line(275        frame,276        &title,277        at,278        look::DETAIL,279        look::muted(),280        bounds.width / 2.0,281    );282    text::line(283        frame,284        &cell.facts,285        facts,286        look::FACE,287        look::muted(),288        bounds.width / 2.0,289    );290    let (note, band) = thin_note(&cell.note, &cell.facts, facts, bounds);291    text::line(292        frame,293        &note,294        band.position(),295        look::FACE,296        noted(cell),297        bounds.width,298    );299}300301/// The note of a thin row and the band it draws in: as wide as the shaper302/// sets it, with its right edge the row's own inset in from the border,303/// and cut by the shaper to the room left beside the facts, so the words304/// end inside the outline whatever the title and the note are. A note the305/// room cannot hold at all draws nothing.306pub fn thin_note(note: &str, facts: &str, at: Point, bounds: Rectangle) -> (String, Rectangle) {307    let right = bounds.x + bounds.width - INSET;308    let room = (right - at.x - text::measured(facts, look::FACE) - wall::GAP).max(0.0);309    let cut = text::measured_cut(note, look::FACE, room);310    let height = text::height(1, look::FACE);311    let (note, width) = match text::measured(&cut, look::FACE) {312        drawn if drawn <= room => (cut, drawn),313        _ => (String::new(), 0.0),314    };315    (316        note,317        area(318            right - width,319            bounds.center_y() - height / 2.0,320            width,321            height,322        ),323    )324}325326// Where a thin row's title and its facts start: the title at the row's327// left inset, centered on the row, and the facts a gap right of the328// title's drawn width. The shaper measures the title, because an329// estimate drifts with the glyphs, and the facts then run into one330// title and stand far from another. The facts line's top is the title's331// top plus the difference of the two line heights, so the two share a332// baseline.333pub fn thin_words(title: &str, bounds: Rectangle) -> (Point, Point) {334    let top = bounds.center_y() - text::height(1, look::DETAIL) / 2.0;335    let at = Point::new(bounds.x + INSET, top);336    let facts = Point::new(337        at.x + text::measured(title, look::DETAIL) + wall::GAP,338        top + text::height(1, look::DETAIL) - text::height(1, look::FACE),339    );340    (at, facts)341}342343// The card's ground: the entry's art decoded at a few pixels wide, scaled344// to cover the card through the linear filter, at a low opacity over345// black. The linear upscale is the blur, so the ground reads as a346// backdrop and never as pixels. The ground clips to the card and to the347// rows' own clip, because an image carries one clip, and a card that has348// scrolled past the top of the lane must not draw over what is above it.349// A card with no art keeps the plain slot ground.350fn ground<P: Posters>(351    frame: &mut canvas::Frame<Renderer>,352    posters: &mut P,353    cell: &Cell,354    bounds: Rectangle,355    clip: Rectangle,356) {357    let ratio = match cell.wide {358        true => still::STILL,359        false => still::POSTER,360    };361    let (width, height) = card::ground(ratio);362    let tiny = match cell.art.is_empty() {363        true => None,364        false => posters.poster(&cell.library, &cell.art, width, height),365    };366    let Some(tiny) = tiny else {367        frame.fill_rectangle(bounds.position(), extent(bounds), look::slot());368        return;369    };370    let Some(clip) = bounds.intersection(&clip) else {371        return;372    };373    frame.fill_rectangle(bounds.position(), extent(bounds), look::BACKGROUND);374    frame.with_clip(clip, |frame| {375        for (band, handle) in tiny.bands(card::covering(bounds, ratio)) {376            frame.draw_image(377                band,378                canvas::Image::new(handle)379                    .opacity(GROUND_TONE)380                    .filter_method(FilterMethod::Linear),381            );382        }383    });384}385386// The color of a note, on a card or a thin row: the accent for a title387// still to come, faint otherwise.388fn noted(cell: &Cell) -> iced_winit::core::Color {389    match cell.standing {390        Standing::Coming => look::accent(),391        _ => look::faint(),392    }393}394395// The words of a card, stacked from the top of the art: the title on up396// to two lines at the name size, the year, the blurb, and the note. The397// blurb takes the lines left over the note, so the note always shows and398// the blurb is what the art's height has room for.399fn words(frame: &mut canvas::Frame<Renderer>, cell: &Cell, words: Rectangle) {400    let mut y = words.y;401    let (first, second) = wall::titled(&cell.name, words.width);402    for line in [first, second] {403        y += text::line(404            frame,405            &line,406            Point::new(words.x, y),407            look::NAME,408            look::text(),409            words.width,410        );411    }412    y += text::line(413        frame,414        &cell.facts,415        Point::new(words.x, y),416        look::CAPTION,417        look::muted(),418        words.width,419    );420421    let note = match cell.note.is_empty() {422        true => 0.0,423        false => text::height(1, look::CAPTION) + LEAD,424    };425    let room = words.y + words.height - y - note - LEAD;426    let cap = (room / text::height(1, look::CAPTION)).floor().max(0.0) as usize;427    if cap > 0 {428        y += LEAD;429        // A tagline draws in the italic, as it does everywhere; the430        // plot keeps the roman face.431        let face = match cell.tagline {432            true => look::ITALIC,433            false => iced_winit::core::Font::with_name(look::FONT),434        };435        y += text::block_in(436            frame,437            &cell.blurb,438            Point::new(words.x, y),439            (look::CAPTION, face),440            look::muted(),441            words.width,442            cap,443        );444    }445    text::line(446        frame,447        &cell.note,448        Point::new(words.x, y + LEAD),449        look::CAPTION,450        noted(cell),451        words.width,452    );453}454455#[cfg(test)]456mod tests {457    use super::*;458459    const FRAME: Rectangle = Rectangle {460        x: 0.0,461        y: 0.0,462        width: 1920.0,463        height: 1080.0,464    };465466    #[test]467    fn the_wall_starts_under_the_band_inside_the_margins() {468        let region = region(FRAME);469        assert!(region.y > band::HEIGHT);470        assert_eq!(region.y + region.height, FRAME.height);471        assert_eq!(region.x, MARGIN);472        assert_eq!(region.width, FRAME.width - 2.0 * MARGIN);473    }474475    #[test]476    fn a_thin_rows_facts_stand_a_gap_right_of_the_drawn_title_on_its_baseline() {477        let row = area(100.0, 200.0, 1500.0, wall::THIN);478        for title in ["Inhumans", "Cloak & Dagger", "Agents of S.H.I.E.L.D."] {479            let (at, facts) = thin_words(title, row);480            let drawn = text::measured(title, look::DETAIL);481            assert!(drawn > 0.0, "{title}");482            assert_eq!(facts.x, at.x + drawn + wall::GAP, "{title}");483            assert!(484                facts.x >= at.x + text::width(title, look::DETAIL) * 0.5,485                "{title}"486            );487            assert_eq!(488                facts.y + text::height(1, look::FACE),489                at.y + text::height(1, look::DETAIL),490                "{title}"491            );492        }493        let (at, _) = thin_words("Inhumans", row);494        assert_eq!(at.x, row.x + INSET);495        assert_eq!(at.y + text::height(1, look::DETAIL) / 2.0, row.center_y());496    }497498    #[test]499    fn a_thin_rows_note_ends_inside_the_rows_own_border() {500        let row = area(100.0, 200.0, 1500.0, wall::THIN);501        for (name, facts) in [502            ("Inhumans", "2027"),503            ("Agents of S.H.I.E.L.D.", "2027"),504            ("A Franchise Entry Whose Name Runs On", ""),505        ] {506            let title = text::cut(name, look::DETAIL, row.width / 2.0);507            let (_, at) = thin_words(&title, row);508            let (note, band) = thin_note("Coming 15 December 2027", facts, at, row);509            assert_eq!(note, "Coming 15 December 2027", "{name}");510            assert_eq!(band.width, text::measured(&note, look::FACE), "{name}");511            assert!(band.x + band.width <= row.x + row.width - INSET, "{name}");512            assert!(band.x > at.x + text::measured(facts, look::FACE), "{name}");513            assert_eq!(band.center_y(), row.center_y(), "{name}");514        }515    }516517    #[test]518    fn a_note_the_room_beside_the_title_cannot_hold_is_cut_by_the_shaper() {519        let row = area(100.0, 200.0, 250.0, wall::THIN);520        let title = text::cut("Inhumans", look::DETAIL, row.width / 2.0);521        let (_, at) = thin_words(&title, row);522        let (note, band) = thin_note("Coming 15 December 2027", "2027", at, row);523        assert!(note.ends_with('\u{2026}'), "{note}");524        assert!(band.x + band.width <= row.x + row.width - INSET);525        assert_eq!(band.width, text::measured(&note, look::FACE));526    }527528    #[test]529    fn a_row_with_no_room_left_draws_no_note() {530        let row = area(100.0, 200.0, 2.0 * INSET, wall::THIN);531        let (_, at) = thin_words("A Name", row);532        let (note, band) = thin_note("Coming 15 December 2027", "2027", at, row);533        assert_eq!(note, "");534        assert_eq!(band.width, 0.0);535        assert_eq!(band.x, row.x + row.width - INSET);536    }537538    #[test]539    fn a_row_outside_the_lane_builds_no_geometry() {540        let columned = wall::columned(area(100.0, 200.0, 1000.0, 800.0), 120.0);541        assert!(!outside(542            area(columned.x, columned.y, 100.0, 100.0),543            columned544        ));545        assert!(outside(546            area(columned.x, columned.y - 300.0, 100.0, 100.0),547            columned548        ));549        assert!(outside(550            area(551                columned.x,552                columned.y + columned.height + 10.0,553                100.0,554                100.0555            ),556            columned557        ));558    }559}
src/screens/franchise/strips.rs 99.0%
1// The franchise strips a film's page and a series' page both draw, and the2// focus ladder those strips are rungs of. One strip per franchise the title3// belongs to: the whole order in story order with the members some library4// holds, centered on the title the page is about. A strip's heading is a rung5// of its own over its members, and a press on it opens the franchise's page.6// Every function here is pure over the rows, so the two pages share one7// ladder.89use super::wall;10use crate::catalog::franchise::Entry;11use crate::catalog::{Query, Source, franchise};12use crate::focus;13use crate::screens::{self, Item, facts};14#[cfg(test)]15use crate::views::Card;1617/// Where focus is inside one strip: on the heading over it, or on one of its18/// members.19#[derive(Debug, Clone, Copy, PartialEq, Eq)]20pub enum Place {21    Heading,22    Member(usize),23}2425/// Which strip, and where in it.26pub type Rung = (usize, Place);2728/// What a press does to focus: it moves inside the strips, or it leaves them29/// for the block over them or the block under them.30#[derive(Debug, Clone, Copy, PartialEq, Eq)]31pub enum Move {32    To(Rung),33    Above,34    Below,35}3637/// One franchise the title belongs to. `library` and `id` name the `Library`38/// of kind franchises and the franchise in it, which is what the heading39/// opens. `heading` is the franchise's title with the count of every entry40/// of the order and the kinds it holds. `current` is the member this page is41/// about, which draws marked, and nothing where the page's own title is not42/// among the held members.43#[derive(Debug, Clone, PartialEq, Eq)]44pub struct Strip {45    pub library: String,46    pub id: String,47    pub heading: String,48    pub members: Vec<Item>,49    pub current: Option<usize>,50}5152// One held member as the card the strip draws: the words its kind leads53// with, over the line the franchise page draws under the same member:54// the kind word, the year, and a film's running time or a series run's55// episodes.56fn carded(query: &Query, entry: &Entry) -> Option<Item> {57    let held = entry.held.as_ref()?;58    let mut item = Item::of(query, franchise::slot(held.clone()));59    item.under = wall::facts(entry, held);60    Some(item)61}6263/// The count of every entry of the order, held or not, and the kinds the64/// order holds: `40 films and series`, `12 films`, `1 film`, `3 series`.65/// A strip heading and the tile of the franchises strip both draw it, so66/// it is spelled once.67pub fn counted(movies: i64, series: i64) -> String {68    let kinds = match (movies > 0, series > 0) {69        (true, true) => "films and series",70        (false, true) => "series",71        _ => "films",72    };73    facts::counted(movies + series, kinds)74}7576/// The words after a franchise's name on a strip heading. The count is77/// every entry of the order, held or not, because the scope of the order78/// is what tells a franchise from a set. The kinds the order holds name79/// themselves: films alone, series alone, or both. One film reads in the80/// singular, and series reads the same either way.81pub fn scope(movies: i64, series: i64) -> String {82    format!("a franchise of {}", counted(movies, series))83}8485/// Every franchise strip of one title, in the order the pages draw them.86#[derive(Debug, Clone, Default, PartialEq, Eq)]87pub struct Strips {88    bands: Vec<Strip>,89}9091impl Strips {92    /// The strips of one title, from the franchises read. A franchise the93    /// namespace holds one member of still draws its strip, because the94    /// heading over it opens the page that holds the rest of the order as95    /// gaps.96    pub fn of(library: &str, id: &str, source: &mut dyn Source) -> Self {97        let bands = source98            .franchises_of(library, id)99            .into_iter()100            .map(|membership| {101                let query = Query::Franchise {102                    library: membership.library.clone(),103                    id: membership.id.clone(),104                };105                let mut members: Vec<Item> = membership106                    .members107                    .iter()108                    .filter_map(|entry| carded(&query, entry))109                    .collect();110                screens::fitted_strip(&mut members);111                let current = members.iter().position(|member| member.id == id);112                Strip {113                    library: membership.library,114                    id: membership.id,115                    heading: facts::joined(&[116                        &membership.title,117                        &scope(membership.movies, membership.series),118                    ]),119                    members,120                    current,121                }122            })123            .collect();124        Self { bands }125    }126127    /// The strips in draw order.128    pub fn bands(&self) -> &[Strip] {129        &self.bands130    }131132    /// Whether the title belongs to no franchise.133    pub fn is_empty(&self) -> bool {134        self.bands.is_empty()135    }136137    /// The rung a move down from the block over the strips lands on, or138    /// nothing where the title belongs to no franchise.139    pub fn first(&self) -> Option<Rung> {140        (!self.bands.is_empty()).then_some((0, Place::Heading))141    }142143    /// The rung a move up from the block under the strips lands on: the144    /// member the last strip is about.145    pub fn last(&self) -> Option<Rung> {146        let index = self.bands.len().checked_sub(1)?;147        Some((index, self.at(index)))148    }149150    /// The strip at one rung, or nothing where the rung is past what151    /// the title belongs to.152    pub fn band(&self, (strip, _): Rung) -> Option<&Strip> {153        self.bands.get(strip)154    }155156    /// The member at one rung, and nothing while the heading holds157    /// focus.158    pub fn member(&self, (strip, place): Rung) -> Option<&Item> {159        match place {160            Place::Heading => None,161            Place::Member(index) => self.bands.get(strip)?.members.get(index),162        }163    }164165    /// The rung this press moves to. Up from a member reaches the strip's own166    /// heading, and up from the first heading leaves the strips. Down from a167    /// heading reaches the member the strip is about, and down from a member168    /// reaches the next strip's heading or leaves the strips. Left and right169    /// move across the members of one strip, and move nothing on a heading.170    pub fn key(&self, (strip, place): Rung, key: &str) -> Move {171        let Some(band) = self.bands.get(strip) else {172            return Move::Above;173        };174        match (place, key) {175            (Place::Heading, "up") if strip == 0 => Move::Above,176            (Place::Heading, "up") => Move::To((strip - 1, self.at(strip - 1))),177            (Place::Heading, "down") => Move::To((strip, self.at(strip))),178            (Place::Heading, _) => Move::To((strip, Place::Heading)),179            (Place::Member(_), "up") => Move::To((strip, Place::Heading)),180            (Place::Member(_), "down") if strip + 1 < self.bands.len() => {181                Move::To((strip + 1, Place::Heading))182            }183            (Place::Member(_), "down") => Move::Below,184            (Place::Member(index), _) => Move::To((185                strip,186                Place::Member(focus::row(index, band.members.len(), key)),187            )),188        }189    }190191    /// The rung focus holds after a re-read: the same one, unless the192    /// strip it was on grew shorter or went away.193    pub fn held(&self, (strip, place): Rung) -> Option<Rung> {194        let strip = strip.min(self.bands.len().checked_sub(1)?);195        let place = match place {196            Place::Heading => Place::Heading,197            Place::Member(index) => {198                Place::Member(index.min(self.bands[strip].members.len().checked_sub(1)?))199            }200        };201        Some((strip, place))202    }203204    // The place a move into one strip lands on: the member the page is205    // about, and the first member where the strip is about none.206    fn at(&self, strip: usize) -> Place {207        Place::Member(208            self.bands209                .get(strip)210                .and_then(|band| band.current)211                .unwrap_or(0),212        )213    }214}215216#[cfg(test)]217mod tests {218    use super::*;219    use crate::screens::franchise::tests::{CYCLE, ORDERS, Orders};220221    // The fake's order holds five members some library holds, and the222    // page is about the second of them.223    fn strips() -> Strips {224        Strips::of("screening/films", "movies:2", &mut Orders::default())225    }226227    #[test]228    fn a_strip_holds_the_order_and_marks_the_title_the_page_is_about() {229        let strips = strips();230        assert_eq!(strips.bands().len(), 1);231        assert_eq!(232            strips.bands()[0].heading,233            "The Cycle · a franchise of 6 films and series"234        );235        assert_eq!(strips.bands()[0].library, ORDERS);236        assert_eq!(strips.bands()[0].id, CYCLE);237        assert_eq!(strips.bands()[0].members.len(), 5);238        assert_eq!(strips.bands()[0].current, Some(1));239        assert_eq!(240            strips.bands()[0].members[0].caption(),241            "Title 1, in one line."242        );243        assert_eq!(strips.bands()[0].members[0].under(), "Film · 1971 · 2h 7m");244    }245246    #[test]247    fn a_heading_says_the_scope_of_the_order_and_the_kinds_it_holds() {248        let cases = [249            (3, 2, "a franchise of 5 films and series"),250            (26, 14, "a franchise of 40 films and series"),251            (10, 0, "a franchise of 10 films"),252            (1, 0, "a franchise of 1 film"),253            (0, 3, "a franchise of 3 series"),254            (0, 1, "a franchise of 1 series"),255        ];256        for (movies, series, words) in cases {257            assert_eq!(scope(movies, series), words, "{movies} and {series}");258        }259    }260261    #[test]262    fn a_tile_says_the_same_scope_without_the_words_that_head_a_strip() {263        let cases = [264            (26, 14, "40 films and series"),265            (12, 0, "12 films"),266            (1, 0, "1 film"),267            (0, 3, "3 series"),268            (0, 0, "0 films"),269        ];270        for (movies, series, words) in cases {271            assert_eq!(counted(movies, series), words, "{movies} and {series}");272            assert_eq!(scope(movies, series), format!("a franchise of {words}"));273        }274    }275276    #[test]277    fn a_title_in_no_franchise_draws_no_strip() {278        let strips = Strips::of("screening/films", "movies:2", &mut Orders { empty: true });279        assert!(strips.is_empty());280        assert_eq!(strips.first(), None);281        assert_eq!(strips.last(), None);282        assert_eq!(strips.held((0, Place::Heading)), None);283        assert_eq!(strips.key((0, Place::Heading), "down"), Move::Above);284    }285286    #[test]287    fn a_move_into_the_strips_lands_on_the_first_heading() {288        let strips = strips();289        assert_eq!(strips.first(), Some((0, Place::Heading)));290        assert_eq!(strips.last(), Some((0, Place::Member(1))));291    }292293    #[test]294    fn down_from_a_heading_lands_on_the_title_the_page_is_about() {295        let strips = strips();296        assert_eq!(297            strips.key((0, Place::Heading), "down"),298            Move::To((0, Place::Member(1)))299        );300    }301302    #[test]303    fn up_from_a_member_reaches_its_heading_and_leaves_the_first_strip() {304        let strips = strips();305        assert_eq!(306            strips.key((0, Place::Member(1)), "up"),307            Move::To((0, Place::Heading))308        );309        assert_eq!(strips.key((0, Place::Heading), "up"), Move::Above);310    }311312    #[test]313    fn down_from_a_member_of_the_last_strip_leaves_the_strips() {314        let strips = strips();315        assert_eq!(strips.key((0, Place::Member(0)), "down"), Move::Below);316    }317318    #[test]319    fn left_and_right_move_across_one_strips_members() {320        let strips = strips();321        assert_eq!(322            strips.key((0, Place::Member(1)), "right"),323            Move::To((0, Place::Member(2)))324        );325        assert_eq!(326            strips.key((0, Place::Member(4)), "right"),327            Move::To((0, Place::Member(4)))328        );329        assert_eq!(330            strips.key((0, Place::Member(1)), "left"),331            Move::To((0, Place::Member(0)))332        );333        assert_eq!(334            strips.key((0, Place::Heading), "right"),335            Move::To((0, Place::Heading))336        );337    }338339    #[test]340    fn a_strip_about_no_member_of_its_own_lands_on_its_first() {341        let strips = Strips::of("screening/films", "movies:99", &mut Orders::default());342        assert_eq!(strips.bands()[0].current, None);343        assert_eq!(strips.last(), Some((0, Place::Member(0))));344    }345346    #[test]347    fn a_rung_past_the_strips_leaves_them() {348        let strips = strips();349        assert_eq!(strips.key((9, Place::Heading), "down"), Move::Above);350        assert_eq!(strips.member((9, Place::Member(0))), None);351        assert_eq!(strips.member((0, Place::Heading)), None);352        assert_eq!(strips.band((9, Place::Heading)), None);353    }354355    #[test]356    fn a_reread_holds_the_rung_inside_what_the_title_still_belongs_to() {357        let strips = strips();358        assert_eq!(359            strips.held((0, Place::Member(9))),360            Some((0, Place::Member(4)))361        );362        assert_eq!(strips.held((9, Place::Heading)), Some((0, Place::Heading)));363    }364365    #[test]366    fn a_member_answers_the_item_it_draws() {367        let strips = strips();368        let member = strips369            .member((0, Place::Member(4)))370            .expect("the strip holds five");371        assert_eq!(member.id, "movies:6");372        assert_eq!(member.kind, "movies");373        assert_eq!(member.library, "screening/films");374        let serial = strips375            .member((0, Place::Member(3)))376            .expect("the strip holds a serial");377        assert_eq!(serial.kind, "series");378    }379}
src/screens/franchise/wall.rs 100.0%
1// The franchise page's wall, measured before anything draws. The wall is2// one lane of rows in story order, one row per entry, first to last; two3// entries the story tells at once each take a row of their own. The rows4// follow the order and never the times, because a franchise runs from5// 1260 BC to 2028 with most of it in twenty years, and a time scale is6// then one dot and an empty rail. The universes are the lines of the7// metro strip beside the lane: the franchise's own first, then every8// other universe an entry names, in first-seen order, and the strip packs9// their runs into lanes. A held entry is a card as tall as its art and10// the gaps around it, and an entry no library holds is a thin row, so the11// rows are not one height, and every measure of the wall reads the row12// tops the wall lays out once.1314use iced_winit::core::Rectangle;1516use super::metro;17use crate::catalog::franchise::{Entry, Era, Franchise, Held, SERIES, SPAN, Standing};18use crate::catalog::{Calendar, art};19use crate::look;20use crate::screens::facts;21use crate::views::{REACH, area, rail, stack, text, wall};2223/// The space under a row, inside a card, and between the strip and the24/// cards.25pub const GAP: f32 = 16.0;2627/// The narrowest the time column is: the room a four-digit year takes,28/// and the gap between the column and the strip. A franchise numbered in29/// one or two digits keeps that floor, so its cards start about where the30/// cards of a franchise numbered in years do.31pub fn floor() -> f32 {32    text::measured(YEAR, look::CAPTION) + GAP33}3435// The widest number a plain calendar writes, which sets the floor.36const YEAR: &str = "2026";3738/// The width the time column takes for these rows: the widest label the39/// wall draws, on the wider of the two lines it stacks a span on, and the40/// column's own gap, never under the floor. A wall no row labels takes no41/// column at all.42pub fn time_width(rows: &[Row]) -> f32 {43    if !labelled(rows) {44        return 0.0;45    }46    let widest = rows.iter().fold(0.0_f32, |wide, row| {47        let (first, second) = stacked(&row.time);48        wide.max(text::measured(&first, look::CAPTION))49            .max(text::measured(&second, look::CAPTION))50    });51    (widest + GAP).max(floor())52}5354/// One time label as the column draws it: the first time on one line, and55/// "to" with the second time on the next. A span stacked this way never56/// widens the column past one time and its mark, and the column is as57/// narrow as the times it holds. A label of one time takes the first line58/// alone.59pub fn stacked(time: &str) -> (String, String) {60    match time.split_once(SPAN) {61        Some((first, second)) => (first.to_string(), format!("to {second}")),62        None => (time.to_string(), String::new()),63    }64}6566/// The time label one row draws: nothing where the row above it carries67/// the same label. A run of rows in one year prints the year once, on the68/// row where the year starts, so the column reads as the times the story69/// passes through and not as one number repeated.70pub fn label_at(rows: &[Row], row: usize) -> &str {71    let Some(here) = rows.get(row) else {72        return "";73    };74    let above = row.checked_sub(1).and_then(|prior| rows.get(prior));75    match above.is_some_and(|above| above.time == here.time) {76        true => "",77        false => &here.time,78    }79}8081/// The caption over the time column, in the lines the column holds:82/// "Years from the Battle of Yavin". The column is as wide as one time83/// and its mark, so the caption wraps into a short stack of lines over84/// the times it names. A calendar with no zero carries none, and so does85/// a wall with no column.86pub fn caption(calendar: &Option<Calendar>, time: f32) -> Vec<String> {87    let Some(calendar) = calendar else {88        return Vec::new();89    };90    match time > 0.0 {91        true => text::wrapped(&calendar.caption(), look::CAPTION, time - GAP),92        false => Vec::new(),93    }94}9596/// The space over the first row: the caption's own lines at the head of97/// the time column, and the gap under them. A wall with no caption keeps98/// the room the mark of a focused first row reaches into, and no more.99pub fn head(caption: &[String]) -> f32 {100    match caption.is_empty() {101        true => HEAD,102        false => text::height(caption.len(), look::CAPTION) + GAP,103    }104}105106/// The space over the first row of a wall with no caption.107pub const HEAD: f32 = REACH;108109/// The space under the last row.110pub const TAIL: f32 = 36.0;111112/// The height of the thin row an entry no library holds draws as.113pub const THIN: f32 = 56.0;114115/// The height of every card's art: a share of the room the rows have.116/// The width follows the height at 16:9. The share puts a little under117/// three cards on a screen, so the art is large and the wall still reads118/// as a list.119pub fn art_height(rows: f32) -> f32 {120    (rows * CAP).max(0.0)121}122123/// The share of the rows' room one card's art takes.124const CAP: f32 = 0.3;125126/// The width of the poster a cell falls back to, at the wall's own poster127/// ratio and the art's own height.128pub fn poster_width(art: f32) -> f32 {129    art / wall::POSTER130}131132/// The height one card takes: the art and a gap over and under it.133pub fn card_height(art: f32) -> f32 {134    art + 2.0 * GAP135}136137/// One entry's title as the two lines beside its art, at the name size.138/// The first line breaks on a word where the width holds one, and the139/// second ends in an ellipsis where the title runs past it.140pub fn titled(name: &str, width: f32) -> (String, String) {141    let room = text::fits(look::NAME, width).max(1);142    if name.chars().count() <= room {143        return (name.to_string(), String::new());144    }145    let at = name146        .char_indices()147        .nth(room)148        .map_or(name.len(), |(index, _)| index);149    let broken = name[..at]150        .rfind(' ')151        .map_or(room, |space| name[..space].chars().count());152    let first: String = name.chars().take(broken).collect();153    let rest: String = name.chars().skip(broken).collect();154    (155        first.trim_end().to_string(),156        text::cut(rest.trim_start(), look::NAME, width),157    )158}159160/// One entry as the wall draws it. `universes` is the index of every161/// universe the entry names, in the order the file names them, and the162/// franchise's own where it names none; the strip takes a dot on each of163/// their lines. `library`, `kind`, and `id` are the item a press opens,164/// and all three are empty for a gap, which opens nothing. `wide` says165/// whether the art fills the card's 16:9 box: the landscape art of a166/// title fills it, and the poster a card falls back to draws at its own167/// ratio at the left of it. `year` is the release year the card or the168/// thin row draws beside the title, and `blurb` is the item's tagline,169/// or its plot where it has no tagline, empty for a gap.170#[derive(Debug, Clone, Default, PartialEq, Eq)]171pub struct Cell {172    pub universes: Vec<usize>,173    pub library: String,174    pub kind: String,175    pub id: String,176    pub art: String,177    pub wide: bool,178    pub name: String,179    /// The facts line beside the title: the kind, the year, and a film's180    /// running time or a series run's episodes, dots between, the way the181    /// film's page words them.182    pub facts: String,183    pub blurb: String,184    /// Whether the blurb is the item's tagline, which draws in the185    /// italic, and not the first lines of its plot.186    pub tagline: bool,187    pub note: String,188    pub standing: Standing,189}190191impl Cell {192    /// The library, the kind, and the id a press opens, and nothing for a gap.193    pub fn opens(&self) -> Option<(&str, &str, &str)> {194        match self.id.is_empty() {195            true => None,196            false => Some((&self.library, &self.kind, &self.id)),197        }198    }199200    /// Whether some library holds the entry. A held entry is a card, and201    /// any other is a thin row.202    pub fn held(&self) -> bool {203        self.standing == Standing::Held204    }205}206207/// One row of the wall: its one cell, and the span the row covers on the208/// franchise's clock. `time` is the label the row draws, empty where the209/// entry carries no time or the file names no calendar.210#[derive(Debug, Clone, Default, PartialEq)]211pub struct Row {212    pub cell: Cell,213    pub time: String,214    pub timed: bool,215    pub from: f64,216    pub to: f64,217}218219impl Row {220    /// The height this row takes: a card for a held entry, and the thin221    /// row for a gap.222    pub fn height(&self, art: f32) -> f32 {223        match self.cell.held() {224            true => card_height(art),225            false => THIN,226        }227    }228}229230/// The universes, as the lines of the strip. A run and a cell both name231/// one by its place in this list. The franchise's own is the first, even232/// where the file names none, because an entry with no universes is in233/// it. Every other universe an entry names follows, in first-seen order.234pub fn columns(franchise: &Franchise) -> Vec<String> {235    let mut named = vec![franchise.universe.clone()];236    for entry in &franchise.entries {237        for universe in &entry.universes {238            if !named.contains(universe) {239                named.push(universe.clone());240            }241        }242    }243    named244}245246/// The wall in story order: one row per entry, first to last. `today` is247/// the ISO date the standing of a gap reads.248pub fn story(franchise: &Franchise, columns: &[String], today: &str) -> Vec<Row> {249    franchise250        .entries251        .iter()252        .map(|entry| row(entry, &franchise.calendar, cell(entry, columns, today)))253        .collect()254}255256/// The eras as the bars of the jump rail. A row is in an era where the row257/// carries a time and its span meets the era's, so the file writes no row and258/// the rail is derived. The widest era takes the outer lane, and an era a259/// wider one holds whole takes the inner one, so a phase nests inside its260/// saga. An era no row meets draws no bar.261pub fn bars(eras: &[Era], rows: &[Row]) -> Vec<rail::Bar> {262    let mut widest: Vec<&Era> = eras.iter().collect();263    widest.sort_by(|one, other| {264        other265            .width()266            .partial_cmp(&one.width())267            .unwrap_or(std::cmp::Ordering::Equal)268    });269270    let mut outer: Vec<&Era> = Vec::new();271    let mut bars = Vec::new();272    for era in widest {273        let mut covered = rows274            .iter()275            .enumerate()276            .filter(|(_, row)| row.timed && era.meets(row.from, row.to))277            .map(|(index, _)| index);278        let Some(first) = covered.next() else {279            continue;280        };281        let last = covered.next_back().unwrap_or(first);282        // An era that meets any era already in the outer lane takes the283        // inner one, held whole or not, because two bars in one lane draw284        // over each other, and the earlier one's label, pinned at the top285        // of the region, hides the later one's.286        let lane = usize::from(outer.iter().any(|wider| wider.meets(era.from, era.to)));287        if lane == 0 {288            outer.push(era);289        }290        bars.push(rail::Bar {291            label: era.name.clone(),292            first,293            last,294            lane,295        });296    }297    bars298}299300// One entry as a cell. An entry that names several universes takes a dot301// on each of their lines, and the strip draws a bar across the dots.302fn cell(entry: &Entry, columns: &[String], today: &str) -> Cell {303    let mut universes: Vec<usize> = entry304        .universes305        .iter()306        .filter_map(|universe| columns.iter().position(|column| column == universe))307        .collect();308    if universes.is_empty() {309        universes.push(0);310    }311    let held = entry.held.clone().unwrap_or_default();312    let (art, wide) = drawn(&held);313    let facts = facts(entry, &held);314    Cell {315        universes,316        library: held.library,317        kind: held.kind,318        id: held.id,319        art,320        wide,321        name: entry.name().to_string(),322        facts,323        tagline: !held.tagline.is_empty(),324        blurb: match held.tagline.is_empty() {325            true => held.plot,326            false => held.tagline,327        },328        note: note(entry, today),329        standing: entry.standing(today),330    }331}332333// The year a card or a thin row draws beside its title: the file's334// release year, or the year of the held item's own release date where335// the file gives none.336fn dated(entry: &Entry, held: &Held) -> String {337    match entry.release_year > 0 {338        true => entry.release_year.to_string(),339        false => held.released.chars().take(4).collect(),340    }341}342343/// The facts line: Film or Series, the year, and then a film's running344/// time or a series run's episodes where the catalog holds them. A fact345/// the entry does not carry leaves no gap and no dot behind.346/// The facts under one entry: the kind word, the year, and a film's347/// running time or a series run's episodes. The franchise strip on a348/// film's page and on a series' page draws the same line under its349/// members, so the words are spelled once.350pub fn facts(entry: &Entry, held: &Held) -> String {351    let series = entry.kind == SERIES;352    let held_facts = match (entry.held.is_some(), series) {353        (true, true) => facts::counted(entry.episodes, "episodes"),354        (true, false) => facts::runtime(held.duration),355        (false, _) => String::new(),356    };357    facts::joined(&[358        facts::kind_word(&entry.kind),359        &dated(entry, held),360        &held_facts,361    ])362}363364// The art one cell draws, and whether it fills the cell's 16:9 box: the365// item's own 16:9 art, and its poster where it holds none. A poster does366// not fill a landscape box, so the cell says so and the page draws it at367// its own ratio. A gap holds no item and names no art.368fn drawn(held: &Held) -> (String, bool) {369    match art::landscape(&held.arts) {370        Some(art) => (art.to_string(), true),371        None => (held.art.clone(), false),372    }373}374375/// The note of an entry, under the blurb on a card and at the right of a376/// thin row: how many episodes the catalog holds for a series run, and377/// the standing of an entry no library holds. A film the catalog holds378/// carries none.379pub fn note(entry: &Entry, today: &str) -> String {380    // A gap the file dates after today reads Coming and as much of the381    // date as the file knows, in words, because the date is what a person382    // waits for, and every other gap reads Missing. A held entry carries383    // no note, because its episodes stand on the facts line.384    match entry.standing(today) {385        Standing::Held => String::new(),386        Standing::Coming => format!("{COMING} {}", facts::date_worded(&entry.released, today)),387        Standing::Missing => MISSING.to_string(),388    }389}390391// The word under an entry no library holds and the file does not date392// ahead.393const MISSING: &str = "Missing";394395// The word before the year of an entry no library holds and the file396// dates ahead.397const COMING: &str = "Coming";398399// One cell as a row of its own, with the time label the calendar gives400// its span.401fn row(entry: &Entry, calendar: &Option<crate::catalog::Calendar>, cell: Cell) -> Row {402    Row {403        cell,404        time: timed(calendar, entry.timed, entry.from, entry.to),405        timed: entry.timed,406        from: entry.from,407        to: entry.to,408    }409}410411// The time label of one span: nothing where the entry carries no time,412// and nothing where the file names no calendar, because the numbers413// alone say nothing without one.414fn timed(calendar: &Option<crate::catalog::Calendar>, timed: bool, from: f64, to: f64) -> String {415    match (timed, calendar) {416        (true, Some(calendar)) => calendar.label(from, to),417        _ => String::new(),418    }419}420421/// Where every row starts, from the top of the wall, and where the last422/// one ends: one more top than there are rows. The first row starts under423/// `head`, the room the caption and the focus mark need, and every row424/// after it starts under the one before it and the space under that.425/// Every measure of the wall that names a row reads these, because a card426/// and a thin row are not one height.427pub fn tops(rows: &[Row], art: f32, head: f32) -> Vec<f32> {428    let mut tops = Vec::with_capacity(rows.len() + 1);429    let mut top = head;430    for row in rows {431        tops.push(top);432        top += row.height(art) + GAP;433    }434    tops.push(top);435    tops436}437438/// The part of the wall the strip and the cards draw in: everything to439/// the right of the time column. A wall with no column, whose `time` is440/// no width at all, gives them the whole of it.441pub fn columned(wall: Rectangle, time: f32) -> Rectangle {442    area(443        wall.x + time,444        wall.y,445        (wall.width - time).max(0.0),446        wall.height,447    )448}449450/// Whether any row carries a time label, which is what earns the time451/// label its column.452pub fn labelled(rows: &[Row]) -> bool {453    rows.iter().any(|row| !row.time.is_empty())454}455456/// The lane of the wall: where the rail leaves off, the part the strip and457/// the cards share, the strip, and the cards. The left-hand room is only458/// what is used: the rail takes lanes only with eras, the time label its459/// column only where a row carries one, and the strip a pitch for every460/// lane its runs fill. Where none of them stands at the left, the cards461/// keep the width they have beside a time column at its floor and stand462/// centered in the region, so a page of one universe with no calendar463/// does not sit off to the right.464#[derive(Debug, Clone, Copy, PartialEq)]465pub struct Lane {466    pub wall: Rectangle,467    pub columned: Rectangle,468    pub strip: Rectangle,469    pub cards: Rectangle,470}471472impl Lane {473    /// The lane for these eras and runs over the region, beside a time474    /// column of this width.475    pub fn of(region: Rectangle, eras: &[rail::Bar], runs: &[metro::Run], time: f32) -> Self {476        let wall = rail::beside(region, eras);477        let columned = columned(wall, time);478        let strip = area(columned.x, columned.y, metro::width(runs), columned.height);479        let gap = match strip.width > 0.0 {480            true => GAP,481            false => 0.0,482        };483        let bare = eras.is_empty() && time <= 0.0 && strip.width == 0.0;484        let cards = match bare {485            true => area(486                region.x + floor() / 2.0,487                region.y,488                (region.width - floor()).max(0.0),489                region.height,490            ),491            false => area(492                strip.x + strip.width + gap,493                columned.y,494                (columned.width - strip.width - gap).max(0.0),495                columned.height,496            ),497        };498        Self {499            wall,500            columned,501            strip,502            cards,503        }504    }505}506507/// The box one row draws in, in frame space after the scroll: from the508/// right of the strip to the right of the wall, as tall as the row. A row509/// past the tops draws nothing.510pub fn cell_box(cards: Rectangle, row: usize, tops: &[f32], down: f32) -> Rectangle {511    let top = tops.get(row).copied().unwrap_or_default();512    let next = tops.get(row + 1).copied().unwrap_or(top + GAP);513    area(514        cards.x,515        cards.y + top - down,516        cards.width,517        (next - top - GAP).max(0.0),518    )519}520521/// The part of the frame the strip and the rows draw in. It reaches the522/// focus stroke's own width past the strip, the cards, and the first523/// row, so the mark of a focused row is whole wherever the row is.524pub fn clipped(columned: Rectangle) -> Rectangle {525    area(526        columned.x - REACH,527        columned.y,528        columned.width + 2.0 * REACH,529        columned.height,530    )531}532533/// The box one row's time label draws in, in frame space after the534/// scroll, at the left of the wall. `time` is the width of the column.535pub fn time_box(wall: Rectangle, time: f32, row: usize, tops: &[f32], offset: f32) -> Rectangle {536    let top = tops.get(row).copied().unwrap_or_default();537    let next = tops.get(row + 1).copied().unwrap_or(top + GAP);538    area(539        wall.x,540        wall.y + top - offset,541        (time - GAP).max(0.0),542        next - top - GAP,543    )544}545546/// The box the caption draws in: the time column at the head of the wall,547/// held at the top of the region while the wall scrolls under it, the way548/// a jump rail holds the label of a bar. The times move and the caption549/// stays, because it names what every one of them counts.550pub fn caption_box(551    wall: Rectangle,552    time: f32,553    caption: &[String],554    tops: &[f32],555    offset: f32,556) -> Rectangle {557    let section = area(558        wall.x,559        wall.y - offset,560        (time - GAP).max(0.0),561        content(tops),562    );563    stack::held(section, wall, text::height(caption.len(), look::CAPTION))564}565566/// The length of the wall these tops lay out, the space over the first567/// row and under the last one included.568pub fn content(tops: &[f32]) -> f32 {569    tops.last().copied().unwrap_or_default() + TAIL570}571572/// How far the wall has scrolled with focus on this row. The last row573/// pulls the space under it into view, so the wall stops at its own574/// foot and not a row short of it.575pub fn scroll(row: usize, tops: &[f32], height: f32) -> f32 {576    let count = tops.len().saturating_sub(1);577    let top = tops.get(row).copied().unwrap_or_default();578    let next = tops.get(row + 1).copied().unwrap_or(top);579    let block = area(0.0, top, 0.0, next - top);580    let tail = match row + 1 >= count {581        true => TAIL,582        false => 0.0,583    };584    crate::views::stack::offset(block, tail, content(tops), height)585}586587#[cfg(test)]588mod tests;
src/screens/home.rs 98.1%
1// The home page: the screen the shade lifts to, and the bottom of the2// stack. It draws the band across the top, then the rows top to bottom:3// the banner, what was released, what arrived, the strips the day drew4// from the pool, the libraries as the floor the page never loses, and5// every genre to close the page. A row that holds nothing draws nothing,6// and focus skips it.78pub mod banner;9mod layout;10mod page;11mod recent;12mod rows;1314use std::cell::RefCell;15use std::convert::Infallible;1617use iced_wgpu::Renderer;18use iced_widget::{Stack, canvas};19use iced_winit::core::{Element, Length, Rectangle, Theme, mouse};2021use self::banner::Banner;22use self::layout::Layout;23pub use self::page::{Page, read};24use self::rows::{GENRE, LIBRARY, rows};25pub use self::rows::{Last, Row, Strip};26use super::{Step, slots};27use crate::catalog::Source;28use crate::catalog::draw::Date;29use crate::focus;30use crate::posters::Posters;31use crate::views::{self, area, band, strip};3233// The band's heading on the home page is the word "Home" and not the34// namespace, because a namespace is a cluster's word and the person on35// the couch has one home.36const HEADING: &str = "Home";3738/// One row of the page as it is read: the banner, or one strip.39#[derive(Debug)]40pub enum Block {41    Banner(Banner),42    Strip(Strip),43}4445impl Block {46    // The empty block one row reads into. Nothing is read here, because the47    // strips are read in the page's order, and the banner off the strips48    // after them.49    fn new(row: Row) -> Self {50        match row {51            Row::Banner => Self::Banner(Banner::default()),52            row => Self::Strip(Strip::new(row)),53        }54    }5556    fn row(&self) -> Row {57        match self {58            Self::Banner(_) => Row::Banner,59            Self::Strip(strip) => strip.row.clone(),60        }61    }6263    fn is_empty(&self) -> bool {64        match self {65            Self::Banner(banner) => banner.is_empty(),66            Self::Strip(strip) => strip.is_empty(),67        }68    }6970    /// The strip this block is, or nothing for the banner.71    pub fn strip(&self) -> Option<&Strip> {72        match self {73            Self::Strip(strip) => Some(strip),74            Self::Banner(_) => None,75        }76    }77}7879/// The home page: the heading as the band draws it, the rows in the80/// page's order, and the row that holds focus.81#[derive(Debug)]82pub struct Home {83    pub heading: String,84    pub blocks: Vec<Block>,85    pub focus: usize,86}8788impl Home {89    /// Read every row, with focus on the first row that holds anything.90    pub fn open(source: &mut dyn Source) -> Self {91        let mut home = Self {92            heading: HEADING.to_string(),93            blocks: Vec::new(),94            focus: 0,95        };96        home.apply(read(source, Date::today()));97        home98    }99100    /// Read every strip again and keep focus where it was, because a change101    /// can empty the strip that held it. The draw runs again, so a strip the102    /// day no longer draws goes, a new one is read, and a strip that stays103    /// keeps its focus. Focus follows the row it was on where that row104    /// stays.105    pub fn reread(&mut self, source: &mut dyn Source) {106        self.apply(read(source, Date::today()));107    }108109    /// Take a page the reader answered. A row the page holds and the110    /// screen already has keeps its focus, focus follows the row it was on111    /// where that row stays, and a strip the day no longer draws goes.112    pub fn apply(&mut self, page: Page) {113        let focused = self.blocks.get(self.focus).map(Block::row);114        let mut banner: Option<Banner> = None;115        let mut kept: Vec<Strip> = Vec::new();116        for block in std::mem::take(&mut self.blocks) {117            match block {118                Block::Banner(held) => banner = Some(held),119                Block::Strip(strip) => kept.push(strip),120            }121        }122        self.blocks = page123            .blocks124            .into_iter()125            .map(|block| match block {126                Block::Banner(fresh) => {127                    let mut held = banner.take().unwrap_or_default();128                    held.reread(fresh.titles);129                    Block::Banner(held)130                }131                Block::Strip(mut fresh) => {132                    if let Some(index) = kept.iter().position(|strip| strip.row == fresh.row) {133                        let held = kept.remove(index);134                        fresh.focus = held.focus.min(fresh.count().saturating_sub(1));135                    }136                    Block::Strip(fresh)137                }138            })139            .collect();140        if let Some(index) =141            focused.and_then(|row| self.blocks.iter().position(|block| block.row() == row))142        {143            self.focus = index;144        }145        self.settle();146    }147148    /// The banner the page holds, or nothing where the rows name none.149    pub fn banner(&self) -> Option<&Banner> {150        self.blocks.iter().find_map(|block| match block {151            Block::Banner(banner) => Some(banner),152            Block::Strip(_) => None,153        })154    }155156    // Where focus lands after a read: the row it was on, or the nearest157    // row below or above it that holds anything, or where it was when158    // no row holds anything.159    fn settle(&mut self) {160        if self.holds(self.focus) {161            return;162        }163        if let Some(index) = self.below(self.focus).or_else(|| self.above(self.focus)) {164            self.focus = index;165        }166    }167168    fn holds(&self, index: usize) -> bool {169        self.blocks170            .get(index)171            .is_some_and(|block| !block.is_empty())172    }173174    // The nearest strip above this one that holds anything.175    fn above(&self, index: usize) -> Option<usize> {176        (0..index).rev().find(|index| self.holds(*index))177    }178179    // The nearest strip below this one that holds anything.180    fn below(&self, index: usize) -> Option<usize> {181        (index + 1..self.blocks.len()).find(|index| self.holds(*index))182    }183184    /// Focus back on the first row, the banner. Home pressed on the home185    /// page lands here, so the key means the top everywhere.186    pub fn top(&mut self) {187        self.focus = 0;188    }189190    /// Fold one press in. Up and down move between the rows, left and191    /// right move inside one, and select opens what the row names. Up192    /// from the first row moves nothing, which is how a press reaches193    /// the browser's strip.194    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {195        match key {196            "up" => match self.above(self.focus) {197                Some(index) => self.focus = index,198                None => return Step::Still,199            },200            "down" => {201                if let Some(index) = self.below(self.focus) {202                    self.focus = index;203                }204            }205            _ => match self.blocks.get_mut(self.focus) {206                Some(Block::Banner(banner)) => return banner.key(key, source),207                Some(Block::Strip(strip)) if key == "enter" => return strip.select(source),208                Some(Block::Strip(strip)) => {209                    strip.focus = focus::row(strip.focus, strip.count(), key);210                }211                None => {}212            },213        }214        Step::Stay215    }216217    /// Whether a rest of focus on this page is worth a prefetch: while the218    /// banner or a title holds focus, because a select opens a page over a219    /// backdrop.220    pub fn prefetches(&self) -> bool {221        match self.blocks.get(self.focus) {222            Some(Block::Banner(banner)) => !banner.is_empty(),223            Some(Block::Strip(strip)) => strip224                .focused()225                .is_some_and(|item| item.kind != LIBRARY && item.kind != GENRE),226            None => false,227        }228    }229230    /// The library and the backdrop the focused title's page draws over,231    /// so the store decodes it while focus rests.232    pub fn resting(&self, source: &mut dyn Source) -> Option<(String, String)> {233        if !self.prefetches() {234            return None;235        }236        match self.blocks.get(self.focus)? {237            Block::Banner(banner) => banner.resting(),238            Block::Strip(strip) => slots::backdrop(strip.focused()?, source),239        }240    }241242    /// The view, in three layers: the banner's backdrop, the rows over it,243    /// and the band over both. A mesh draws under every image of its layer,244    /// so the banner's scrim needs the backdrop on a layer of its own, and245    /// the band needs a layer of its own so a row that scrolled up under246    /// it never shows through.247    pub fn view<'a, P: Posters>(248        &'a self,249        posters: &'a RefCell<P>,250        held: bool,251    ) -> Element<'a, Infallible, Theme, Renderer> {252        let ground = canvas(Ground {253            home: self,254            posters,255        })256        .width(Length::Fill)257        .height(Length::Fill)258        .into();259        let front = canvas(Program {260            home: self,261            posters,262            held,263        })264        .width(Length::Fill)265        .height(Length::Fill)266        .into();267        let band = band::layer(&self.heading);268        Stack::with_children(vec![ground, front, band])269            .width(Length::Fill)270            .height(Length::Fill)271            .into()272    }273274    // The layout and the scroll at these bounds, which both layers draw275    // from.276    fn placed(&self, bounds: Rectangle) -> (Layout, f32, Rectangle) {277        let layout = Layout::of(self, bounds.height);278        let viewport = bounds.height - band::HEIGHT;279        let offset = layout.scroll(self, bounds.height, viewport);280        let clip = area(0.0, band::HEIGHT, bounds.width, viewport);281        (layout, offset, clip)282    }283}284285// The under layer: the banner's backdrop alone, clipped under the286// band.287struct Ground<'a, P> {288    home: &'a Home,289    posters: &'a RefCell<P>,290}291292impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Ground<'_, P> {293    type State = ();294295    fn draw(296        &self,297        _state: &Self::State,298        renderer: &Renderer,299        _theme: &Theme,300        bounds: Rectangle,301        _cursor: mouse::Cursor,302    ) -> Vec<canvas::Geometry<Renderer>> {303        let home = self.home;304        let mut frame = canvas::Frame::new(renderer, bounds.size());305        let (layout, offset, clip) = home.placed(bounds);306        frame.with_clip(clip, |frame| {307            let posters = &mut *self.posters.borrow_mut();308            for (index, block) in home.blocks.iter().enumerate() {309                let Block::Banner(banner) = block else {310                    continue;311                };312                let Some(title) = banner.focused() else {313                    continue;314                };315                let Some(region) = layout.region(home, index, offset, bounds.width, bounds.height)316                else {317                    continue;318                };319                views::banner::backdrop(320                    frame,321                    posters,322                    &title.item.library,323                    &title.backdrop,324                    region,325                );326            }327        });328        vec![frame.into_geometry()]329    }330}331332// The middle layer: the rows, on one frame.333struct Program<'a, P> {334    home: &'a Home,335    posters: &'a RefCell<P>,336    // Whether the page holds focus, or the browser's strip over it does.337    held: bool,338}339340impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Program<'_, P> {341    type State = ();342343    fn draw(344        &self,345        _state: &Self::State,346        renderer: &Renderer,347        _theme: &Theme,348        bounds: Rectangle,349        _cursor: mouse::Cursor,350    ) -> Vec<canvas::Geometry<Renderer>> {351        let home = self.home;352        let mut frame = canvas::Frame::new(renderer, bounds.size());353        let (layout, offset, clip) = home.placed(bounds);354        frame.with_clip(clip, |frame| {355            let posters = &mut *self.posters.borrow_mut();356            for (index, block) in home.blocks.iter().enumerate() {357                let Some(region) = layout.region(home, index, offset, bounds.width, bounds.height)358                else {359                    continue;360                };361                if region.y + region.height < band::HEIGHT || region.y > bounds.height {362                    continue;363                }364                let focused = self.held && home.focus == index;365                match block {366                    Block::Banner(banner) => {367                        let Some(title) = banner.focused() else {368                            continue;369                        };370                        views::banner::draw(371                            frame,372                            posters,373                            &views::banner::Banner {374                                library: &title.item.library,375                                logo: &title.logo,376                                name: &title.name,377                                facts: &title.facts,378                                genres: &title.genres,379                                ratings: &title.ratings,380                                tagline: &title.tagline,381                                count: banner.titles.len(),382                                current: banner.focus,383                                focused,384                                region,385                            },386                        );387                    }388                    Block::Strip(strip) => strip::draw(389                        frame,390                        posters,391                        &strip::Strip {392                            headed: false,393                            members: &strip.items,394                            current: None,395                            focus: focused.then_some(strip.focus),396                            heading: &strip.heading,397                            library: "",398                            last: strip.last.as_ref().map(Last::view),399                            lines: strip.lines,400                            region,401                        },402                    ),403                }404            }405        });406        vec![frame.into_geometry()]407    }408}409410#[cfg(test)]411mod tests {412    use super::*;413    use crate::sample::Catalog;414415    #[test]416    fn a_reread_keeps_the_rows_and_the_row_focus_was_on() {417        let mut home = Home::open(&mut Catalog);418        home.key("down", &mut Catalog);419        let focus = home.focus;420        let rows = home.blocks.len();421        home.reread(&mut Catalog);422        assert_eq!(home.focus, focus);423        assert_eq!(home.blocks.len(), rows);424    }425426    #[test]427    fn a_press_moves_over_a_drawn_row_that_holds_nothing() {428        let mut home = Home::open(&mut Catalog);429        home.blocks.insert(1, Block::Strip(Strip::new(Row::Genres)));430        home.focus = 0;431        home.key("down", &mut Catalog);432        assert_eq!(home.focus, 2);433        home.key("up", &mut Catalog);434        assert_eq!(home.focus, 0);435    }436437    #[test]438    fn a_press_past_the_rows_prefetches_nothing_and_moves_nothing() {439        let mut home = Home::open(&mut Catalog);440        home.focus = 99;441        assert!(!home.prefetches());442        assert!(matches!(home.key("enter", &mut Catalog), Step::Stay));443        assert_eq!(home.focus, 99);444    }445}
src/screens/home/banner.rs 98.5%
1// The banner reads its titles off the strips and not off the catalog.2// It shows one title from each strip the day drew, then the newest3// release and the newest arrival, so it is a view of the page under it4// and never a random pick. This module holds the titles, the focus, the5// read, the presses, and the backdrop the rest prefetches.67use super::Strip;8use crate::catalog::Source;9use crate::focus;10use crate::screens::{Item, Step, movie, series, slots};11use crate::views::ratings;1213/// The most titles the banner holds. Four drawn strips and two recency14/// strips feed it, and a longer row of indicators reads as noise.15pub const MOST: usize = 6;1617/// One title of the banner: the item a select opens, and the words and18/// art paths the frame draws.19#[derive(Debug, Clone, PartialEq)]20pub struct Title {21    /// The slot the title came from, which a select opens the page for.22    pub item: Item,23    /// The name a person reads. It is held apart from the item because an24    /// episode slot names its episode, and the banner shows its series.25    pub name: String,26    /// The logo path, empty where the title has none.27    pub logo: String,28    /// The backdrop path, never empty in the banner.29    pub backdrop: String,30    /// The facts line under the head: the date, the runtime, and the31    /// content rating of a movie, or the year, the season count, and the32    /// content rating of a series. The genres take the line under it.33    pub facts: String,34    /// The genres on one line, empty where the sidecar named none.35    pub genres: String,36    /// The scores the ratings row draws, in the order it draws them.37    pub ratings: Vec<ratings::Score>,38    /// The tagline, empty where the sidecar named none.39    pub tagline: String,40}4142impl Title {43    // The title of one item, or nothing where its page draws over no art.44    // The details are read by the slot's kind, because an episode and a45    // folded series both open the series' page and show its backdrop.46    fn of(item: &Item, source: &mut dyn Source) -> Option<Self> {47        let series = match (item.kind.as_str(), &item.episode) {48            ("episodes", Some(place)) => Some(place.series.as_str()),49            ("series", _) => Some(item.id.as_str()),50            _ => None,51        };52        let title = match series {53            Some(id) => {54                let details = source.series(&item.library, id)?;55                let facts = series::facts_without_genres(&details);56                let genres = details.genres.join(", ");57                let ratings = ratings::scores(&details.ratings);58                Self {59                    item: item.clone(),60                    name: details.title,61                    logo: details.logo,62                    backdrop: details.backdrop,63                    facts,64                    genres,65                    ratings,66                    tagline: details.tagline,67                }68            }69            None => {70                let details = source.movie(&item.library, &item.id)?;71                let facts = movie::facts_without_genres(&details);72                let genres = details.genres.join(", ");73                let ratings = ratings::scores(&details.ratings);74                Self {75                    item: item.clone(),76                    name: details.title,77                    logo: details.logo,78                    backdrop: details.backdrop,79                    facts,80                    genres,81                    ratings,82                    tagline: details.tagline,83                }84            }85        };86        if title.backdrop.is_empty() {87            return None;88        }89        Some(title)90    }9192    // The library and the id of the page a select opens. An episode and its93    // series open one page, and the banner shows a page once.94    fn page(&self) -> (&str, &str) {95        let id = match &self.item.episode {96            Some(place) => place.series.as_str(),97            None => self.item.id.as_str(),98        };99        (&self.item.library, id)100    }101}102103/// The banner: its titles in the page's order, and the one that holds104/// focus.105#[derive(Debug, Default)]106pub struct Banner {107    pub titles: Vec<Title>,108    pub focus: usize,109}110111impl Banner {112    /// The first title of each strip whose page draws over art and is not113    /// in the banner yet, at most `MOST`. Each strip gives one title and a114    /// page appears once, because the banner is a row of the strips under115    /// it, and the newest release is often the newest arrival.116    pub fn read<'a>(117        strips: impl Iterator<Item = &'a Strip>,118        source: &mut dyn Source,119    ) -> Vec<Title> {120        let mut titles: Vec<Title> = Vec::new();121        for strip in strips {122            let found = strip.items.iter().find_map(|item| {123                let title = Title::of(item, source)?;124                match titles.iter().any(|held| held.page() == title.page()) {125                    true => None,126                    false => Some(title),127                }128            });129            if let Some(title) = found {130                titles.push(title);131            }132            if titles.len() >= MOST {133                break;134            }135        }136        titles137    }138139    /// Take the titles read again and keep focus on the page it was on, or140    /// in range. Focus follows the page and not the index, because a reread141    /// can move a title along the row.142    pub fn reread(&mut self, titles: Vec<Title>) {143        let held = self.titles.get(self.focus).map(|title| {144            let (library, id) = title.page();145            (library.to_string(), id.to_string())146        });147        self.titles = titles;148        self.focus = held149            .and_then(|(library, id)| {150                self.titles151                    .iter()152                    .position(|title| title.page() == (library.as_str(), id.as_str()))153            })154            .unwrap_or(self.focus.min(self.titles.len().saturating_sub(1)));155    }156157    pub fn is_empty(&self) -> bool {158        self.titles.is_empty()159    }160161    /// The title the frame shows, or nothing while the banner holds162    /// none.163    pub fn focused(&self) -> Option<&Title> {164        self.titles.get(self.focus)165    }166167    /// Fold one press in. Left and right move across the titles, and select168    /// opens the current title's page.169    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {170        if key == "enter" {171            return match self.focused() {172                Some(title) => slots::opened(&title.item, source),173                None => Step::Stay,174            };175        }176        self.focus = focus::row(self.focus, self.titles.len(), key);177        Step::Stay178    }179180    /// The library and the backdrop of the current title's page. The181    /// backdrop was read with the title, so the rest costs no read.182    pub fn resting(&self) -> Option<(String, String)> {183        let title = self.focused()?;184        Some((title.item.library.clone(), title.backdrop.clone()))185    }186}187188#[cfg(test)]189mod tests {190    use super::*;191    use crate::sample::Catalog;192    use crate::screens::home::Home;193194    #[test]195    fn the_sample_home_page_opens_on_a_banner_of_titles_with_backdrops() {196        let mut catalog = Catalog;197        let home = Home::open(&mut catalog);198        let banner = home.banner().expect("the home page holds a banner");199        assert!(!banner.is_empty());200        assert!(banner.titles.len() <= MOST);201        assert!(banner.titles.iter().all(|title| !title.backdrop.is_empty()));202        assert!(banner.titles.iter().all(|title| !title.facts.is_empty()));203        assert!(banner.titles.iter().all(|title| !title.name.is_empty()));204        let mut pages: Vec<(&str, &str)> = banner.titles.iter().map(Title::page).collect();205        pages.sort_unstable();206        pages.dedup();207        assert_eq!(pages.len(), banner.titles.len());208        assert_eq!(home.focus, 0);209    }210211    #[test]212    fn a_reread_keeps_focus_on_the_page_it_held_or_clamps() {213        let mut catalog = Catalog;214        let home = Home::open(&mut catalog);215        let titles = home.banner().expect("a banner").titles.clone();216        let mut banner = Banner {217            titles: titles.clone(),218            focus: 1,219        };220221        let mut moved = titles.clone();222        moved.rotate_left(1);223        banner.reread(moved);224        assert_eq!(banner.focus, 0);225        assert_eq!(banner.focused(), titles.get(1));226227        banner.reread(titles[..1].to_vec());228        assert_eq!(banner.focus, 0);229230        banner.reread(Vec::new());231        assert_eq!(banner.focus, 0);232        assert_eq!(banner.resting(), None);233    }234}
src/screens/home/layout.rs 99.3%
1// The page is measured before anything draws, because the scroll needs2// every row's place, and the two layers of the view must agree on it.3// This module answers where every row that holds anything lands under4// the band, how tall the page is, and how far it has scrolled.56use iced_winit::core::Rectangle;78use super::{Block, Home};9use crate::views::{area, banner, strip, wall};1011// The margin at both sides of the strips, which is the band's own inset,12// so the headings line up.13pub const MARGIN: f32 = 32.0;1415// The space between two strips.16const GAP: f32 = 28.0;1718// The height one row takes on a page this tall.19fn height(block: &Block, page: f32) -> f32 {20    match block {21        Block::Banner(_) => banner::height(page),22        Block::Strip(strip) => strip::height(strip.lines),23    }24}2526pub struct Layout {27    /// The top of every row in the page's own space before the scroll, and28    /// nothing for a row that holds nothing.29    pub tops: Vec<Option<f32>>,30    /// How tall the page is.31    pub content: f32,32}3334impl Layout {35    pub fn of(home: &Home, page: f32) -> Self {36        let mut at = wall::HEAD;37        let tops = home38            .blocks39            .iter()40            .map(|block| {41                if block.is_empty() {42                    return None;43                }44                let top = at;45                at += height(block, page) + GAP;46                Some(top)47            })48            .collect();49        Self {50            tops,51            content: at - GAP + wall::HEAD,52        }53    }5455    /// The region one row draws in at this scroll, inside a frame this wide,56    /// on a page this tall.57    pub fn region(58        &self,59        home: &Home,60        index: usize,61        offset: f32,62        width: f32,63        page: f32,64    ) -> Option<Rectangle> {65        let top = self.tops.get(index).copied().flatten()?;66        Some(area(67            MARGIN,68            crate::views::band::HEIGHT + top - offset,69            width - 2.0 * MARGIN,70            height(&home.blocks[index], page),71        ))72    }7374    /// How far the page has scrolled. None while the banner holds75    /// focus. The first strip keeps as much of the banner over it as76    /// the viewport holds, and scrolls only as far as it needs to stand77    /// whole in view, because a short viewport holds less than the banner78    /// and one strip. For every later row, the row's heading sits directly79    /// under the band, so the rows under it fill the rest of the viewport:80    /// a person presses down far more than up, and what is next matters81    /// more than what was passed. The scroll stops at the foot of the82    /// page, so the last rows never leave a gap under them.83    pub fn scroll(&self, home: &Home, page: f32, viewport: f32) -> f32 {84        let Some(top) = self.tops.get(home.focus).copied().flatten() else {85            return 0.0;86        };87        if home.focus == 0 {88            return 0.0;89        }90        let under_band = top - wall::HEAD;91        let foot = (self.content - viewport).max(0.0);92        if self.head() == Some(home.focus) {93            let bottom = top + height(&home.blocks[home.focus], page) + wall::HEAD;94            return (bottom - viewport).clamp(0.0, under_band.min(foot).max(0.0));95        }96        under_band.clamp(0.0, foot)97    }9899    // The first row after the banner that holds anything. A row that100    // holds nothing takes no room, so it is never the first strip.101    fn head(&self) -> Option<usize> {102        self.tops103            .iter()104            .skip(1)105            .position(Option::is_some)106            .map(|index| index + 1)107    }108}109110#[cfg(test)]111mod tests {112    use super::super::{Row, Strip};113    use super::*;114    use crate::sample::Catalog;115    use crate::views::band;116117    const PAGE: f32 = 1080.0;118119    fn home() -> Home {120        Home::open(&mut Catalog)121    }122123    #[test]124    fn the_banner_is_the_first_row_and_the_first_strip_stands_under_it() {125        let home = home();126        let layout = Layout::of(&home, PAGE);127        let banner = layout.tops[0].expect("the banner holds titles");128        let first = layout.tops[1].expect("the released strip holds slots");129        assert_eq!(banner, wall::HEAD);130        assert_eq!(first, banner + banner::height(PAGE) + GAP);131    }132133    #[test]134    fn focus_on_the_first_strip_shows_the_whole_banner_over_it() {135        let mut home = home();136        home.focus = 1;137        let layout = Layout::of(&home, PAGE);138        let offset = layout.scroll(&home, PAGE, PAGE - band::HEIGHT);139        assert_eq!(offset, 0.0);140        let region = layout141            .region(&home, 0, offset, 1920.0, PAGE)142            .expect("the banner has a region");143        assert_eq!(region.x, MARGIN);144        assert_eq!(region.width, 1920.0 - 2.0 * MARGIN);145        assert_eq!(region.y, band::HEIGHT + wall::HEAD);146        assert_eq!(region.height, banner::height(PAGE));147    }148149    #[test]150    fn the_first_strip_that_holds_anything_stands_under_the_whole_banner() {151        let mut home = home();152        home.blocks.insert(1, Block::Strip(Strip::new(Row::Genres)));153        home.focus = 2;154        let layout = Layout::of(&home, PAGE);155        assert_eq!(layout.tops[1], None);156        assert_eq!(layout.scroll(&home, PAGE, PAGE - band::HEIGHT), 0.0);157    }158159    #[test]160    fn a_short_viewport_scrolls_the_first_strip_whole_into_view_and_no_further() {161        let mut home = home();162        home.focus = 1;163        let layout = Layout::of(&home, PAGE);164        let strip = layout.tops[1].expect("the released strip holds slots");165        let bottom = strip + strip::height(2) + wall::HEAD;166        let short = bottom - 100.0;167        let offset = layout.scroll(&home, PAGE, short);168        assert_eq!(offset, 100.0);169        let region = layout170            .region(&home, 1, offset, 1920.0, PAGE)171            .expect("the released strip has a region");172        assert_eq!(region.y + region.height + wall::HEAD, band::HEIGHT + short);173        assert_eq!(174            layout.scroll(&home, PAGE, wall::HEAD + strip::height(2)),175            strip - wall::HEAD176        );177    }178179    #[test]180    fn focus_on_the_second_strip_scrolls_the_banner_up_under_the_band() {181        let mut home = home();182        home.focus = 2;183        let layout = Layout::of(&home, PAGE);184        let offset = layout.scroll(&home, PAGE, PAGE - band::HEIGHT);185        assert!(offset > 0.0);186        let region = layout187            .region(&home, 0, offset, 1920.0, PAGE)188            .expect("the banner has a region");189        assert!(region.y < band::HEIGHT);190        let region = layout191            .region(&home, 2, offset, 1920.0, PAGE)192            .expect("the second strip has a region");193        assert_eq!(region.y, band::HEIGHT + wall::HEAD);194    }195196    #[test]197    fn the_last_row_scrolls_no_further_than_the_foot_of_the_page() {198        let mut home = home();199        home.focus = home.blocks.len() - 1;200        let layout = Layout::of(&home, PAGE);201        let viewport = PAGE - band::HEIGHT;202        assert!(layout.tops[home.focus].is_some());203        assert_eq!(204            layout.scroll(&home, PAGE, viewport),205            layout.content - viewport206        );207        assert_eq!(layout.scroll(&home, PAGE, layout.content + 100.0), 0.0);208    }209}
src/screens/home/page.rs 100.0%
1// The home page as one read. Every row of the page comes off the source2// here and nowhere else, so the read runs on a thread of its own and the3// page it answers is applied to the screen on a later frame.45use super::banner::{Banner, Title};6use super::{Block, Row, Strip, rows};7use crate::catalog::draw::{self, Date};8use crate::catalog::{Query, Source};9use crate::screens::Item;1011/// Every row of the home page as one read answered them, with the date12/// the draw was seeded by.13#[derive(Debug)]14pub struct Page {15    /// The date the day's draw was seeded by.16    pub date: Date,17    /// The rows in the page's order, each one read, with no focus in any18    /// of them.19    pub blocks: Vec<Block>,20}2122// The scope ends through `Drop`, including when one of its reads panics.23struct PageRead<'a> {24    source: &'a mut dyn Source,25}2627impl<'a> PageRead<'a> {28    fn begin(source: &'a mut dyn Source) -> Self {29        source.begin_page_read();30        Self { source }31    }32}3334impl Drop for PageRead<'_> {35    fn drop(&mut self) {36        self.source.end_page_read();37    }38}3940/// Read every row of the home page on this date: the pool and the day's41/// draw, then each strip in the page's order, then the banner off the42/// strips. It touches no screen, so it runs wherever the caller puts43/// it.44pub fn read(source: &mut dyn Source, today: Date) -> Page {45    let scope = PageRead::begin(source);46    let source = &mut *scope.source;47    let seconds = today.seconds();48    let mut blocks: Vec<Block> = rows(seconds, draw::draw(today, &source.pool()))49        .into_iter()50        .map(Block::new)51        .collect();52    // The released strip's items are in hand while the added strip reads,53    // because the added strip drops what the released strip shows, and the54    // released strip stands before it.55    for index in 0..blocks.len() {56        let released = released(&blocks);57        if let Block::Strip(strip) = &mut blocks[index] {58            strip.reread(source, seconds, &released);59        }60    }61    let titles = titles(&blocks, source);62    if let Some(Block::Banner(banner)) = blocks63        .iter_mut()64        .find(|block| matches!(block, Block::Banner(_)))65    {66        banner.reread(titles);67    }68    Page {69        date: today,70        blocks,71    }72}7374// The items of the released strip, or nothing until it is read.75fn released(blocks: &[Block]) -> Vec<Item> {76    blocks77        .iter()78        .filter_map(Block::strip)79        .find(|strip| matches!(strip.row, Row::Query(Query::Released { .. })))80        .map(|strip| strip.items.clone())81        .unwrap_or_default()82}8384// The banner's titles from the drawn strips, then the two recency85// strips. The banner is read after the strips because it holds one title86// from each of them.87fn titles(blocks: &[Block], source: &mut dyn Source) -> Vec<Title> {88    let strips: Vec<&Strip> = blocks.iter().filter_map(Block::strip).collect();89    let (recency, drawn): (Vec<&Strip>, Vec<&Strip>) = strips90        .into_iter()91        .filter(|strip| !matches!(strip.row, Row::Libraries | Row::Genres | Row::Franchises))92        .partition(|strip| strip.row.recency());93    Banner::read(drawn.into_iter().chain(recency), source)94}
src/screens/home/recent.rs 100.0%
1// The two recency strips differ from the walls behind them. Both2// queries fold the same shows, so the released strip keeps what is new in3// the world, and the added strip keeps what arrived and is not. The walls4// answer whole. This module holds the two rules, applied to the strips on5// the home page alone.67use crate::catalog::Slot;8use crate::catalog::recency::{SHOWN, current};9use crate::screens::Item;1011/// The released strip's slots: the ones inside the window of today, in12/// seconds, and a folded show that holds an episode inside it whatever13/// the date of the newest episode it draws, newest first, cut to `SHOWN`.14pub fn released(slots: Vec<Slot>, today: i64) -> Vec<Slot> {15    slots16        .into_iter()17        .filter(|slot| slot.new > 0 || current(&slot.released, today))18        .take(SHOWN)19        .collect()20}2122/// The added strip's slots: every one the released strip shows left23/// out, then cut to `SHOWN`. The subtraction runs before the cut, because24/// a strip that dropped its first slots after the cut would come up25/// short.26pub fn added(slots: Vec<Slot>, released: &[Item]) -> Vec<Slot> {27    slots28        .into_iter()29        .filter(|slot| {30            !released31                .iter()32                .any(|shown| shown.library == slot.library && shown.id == slot.id)33        })34        .take(SHOWN)35        .collect()36}3738#[cfg(test)]39mod tests {40    use super::*;41    use crate::catalog::Query;42    use crate::catalog::recency::date_seconds;4344    fn today() -> i64 {45        date_seconds("2026-09-03").expect("a full date")46    }4748    fn slot(id: &str, released: &str) -> Slot {49        Slot {50            library: "screening/films".into(),51            kind: "movies".into(),52            id: id.into(),53            title: id.into(),54            released: released.into(),55            ..Slot::default()56        }57    }5859    fn ids(slots: &[Slot]) -> Vec<&str> {60        slots.iter().map(|slot| slot.id.as_str()).collect()61    }6263    #[test]64    fn the_released_strip_keeps_only_dates_inside_the_window() {65        for (date, kept) in [66            ("2026-09-03", true),67            ("2026-09-01", true),68            ("2026-08-04", true),69            ("2026-08-03", false),70            ("2026-09-20", true),71            ("2025-09-03", false),72            ("2026", false),73            ("", false),74        ] {75            let slots = released(vec![slot("one", date)], today());76            assert_eq!(!slots.is_empty(), kept, "{date}");77        }78    }7980    #[test]81    fn the_released_strip_keeps_the_order_and_cuts_to_shown() {82        let slots: Vec<Slot> = (0..SHOWN + 5)83            .map(|number| slot(&format!("movie:{number}"), "2026-09-01"))84            .collect();85        let shown = released(slots, today());86        assert_eq!(shown.len(), SHOWN);87        assert_eq!(shown[0].id, "movie:0");88        assert_eq!(shown[SHOWN - 1].id, format!("movie:{}", SHOWN - 1));89    }9091    #[test]92    fn the_added_strip_drops_what_the_released_strip_shows_before_the_cut() {93        let query = Query::Released {94            fold: crate::catalog::Fold::Airing,95        };96        let shown: Vec<Item> = (0..3)97            .map(|number| Item::of(&query, slot(&format!("movie:{number}"), "2026-09-01")))98            .collect();99        let arrived: Vec<Slot> = (0..SHOWN + 3)100            .map(|number| slot(&format!("movie:{number}"), "1980"))101            .collect();102        let left = added(arrived, &shown);103        assert_eq!(left.len(), SHOWN);104        assert_eq!(ids(&left)[0], "movie:3");105        assert!(!ids(&left).contains(&"movie:0"));106    }107108    // One show as the `Shows` fold answers it: the newest episode's109    // still under the series' own id.110    fn show() -> Slot {111        Slot {112            library: "screening/serials".into(),113            kind: "episodes".into(),114            id: "series:1".into(),115            title: "Segment 08".into(),116            released: "2026-09-01".into(),117            new: 2,118            episode: Some(crate::catalog::InSeries {119                series: "series:1".into(),120                name: "The Serial".into(),121                season: 4,122                episode: 8,123            }),124            ..Slot::default()125        }126    }127128    #[test]129    fn the_released_strip_keeps_a_show_that_holds_a_new_episode() {130        let ahead = Slot {131            released: "2027-01-01".into(),132            new: 2,133            ..show()134        };135        assert_eq!(ids(&released(vec![ahead], today())), ["series:1"]);136        let none = Slot {137            released: "2027-01-01".into(),138            new: 0,139            ..show()140        };141        assert!(released(vec![none], today()).is_empty());142    }143144    #[test]145    fn the_added_strip_drops_a_folded_show_the_released_strip_shows() {146        let query = Query::Released {147            fold: crate::catalog::Fold::Shows { today: today() },148        };149        let shown = vec![Item::of(&query, show())];150        assert_eq!(shown[0].id, "series:1");151        let left = added(vec![show(), slot("movie:1", "1980")], &shown);152        assert_eq!(ids(&left), ["movie:1"]);153    }154155    #[test]156    fn a_slot_of_another_library_with_the_same_id_stays_in_the_added_strip() {157        let query = Query::Released {158            fold: crate::catalog::Fold::Airing,159        };160        let shown = vec![Item::of(&query, slot("series:1", "2026-09-01"))];161        let elsewhere = Slot {162            library: "screening/serials".into(),163            ..slot("series:1", "2026-09-01")164        };165        assert_eq!(ids(&added(vec![elsewhere], &shown)), ["series:1"]);166    }167}
src/screens/home/rows.rs 99.8%
1// The rows of the home page and the strip that reads one. A row names2// what a strip reads: the slots of one query, the libraries, or the3// genres. The strip holds the read's items, the focus, and what a select4// opens. The page's order is fixed here, and the home page and its read5// both take it from here.67use crate::catalog::pool::Candidate;8use crate::catalog::recency::SHOWN;9use crate::catalog::{10    Fold, FranchiseEntry, GenreEntry, GenreSort, LibraryEntry, Order, Query, Sort, Source,11    library_name,12};13use crate::screens;14use crate::screens::wall::Wall;15use crate::screens::{Item, Screen, Step, credits, facts, franchise, person, slots};16use crate::views::{card, strip};1718// The fewest slots a drawn strip shows. A strip the day drew that19// reads fewer holds nothing, so a whole row never stands on two or20// three posters. The recency strips, the libraries, the genres, and the21// franchises show whatever they hold, because they are not a draw.22const FLOOR: usize = 4;2324// The kind an item of the libraries strip carries, so a select on it25// opens the library's wall and never a page.26pub(super) const LIBRARY: &str = "library";2728// The kind an item of the genres strip carries, so a select on it opens29// the genre's page and never a title's.30pub(super) const GENRE: &str = "genre";3132// The kind an item of the franchises strip carries. It is the slots33// module's word because a search hit on a franchise carries the same34// one, and both open the franchise's page.35pub(super) use slots::FRANCHISE;3637/// One row of the page as a read: the banner, the slots of one query,38/// the libraries themselves, the genres themselves, or the franchises39/// themselves.40#[derive(Debug, Clone, PartialEq, Eq)]41pub enum Row {42    Banner,43    Query(Query),44    Libraries,45    Genres,46    Franchises,47}4849impl Row {50    // Whether the row is one of the two recency strips. They are told51    // apart because they feed the banner after the drawn strips, in the52    // page's order.53    pub(super) fn recency(&self) -> bool {54        matches!(55            self,56            Self::Query(Query::Released { .. } | Query::Added { .. })57        )58    }59}6061// The rows of the page, top to bottom: the banner, the two recency62// strips under the `Shows` fold on `today` in seconds, so a show takes63// one slot however many episodes it holds, the strips the day drew in64// the drawn order, the libraries, the genres, and the franchises to close65// the page.66pub(super) fn rows(today: i64, drawn: Vec<Candidate>) -> Vec<Row> {67    let fold = Fold::Shows { today };68    let mut rows = vec![69        Row::Banner,70        Row::Query(Query::Released { fold }),71        Row::Query(Query::Added { fold }),72    ];73    rows.extend(74        drawn75            .into_iter()76            .map(|candidate| Row::Query(candidate.query)),77    );78    rows.push(Row::Libraries);79    rows.push(Row::Genres);80    rows.push(Row::Franchises);81    rows82}8384/// The slot that ends a strip: its words, and the art it draws as with85/// the library that art resolves against, both empty where it draws its86/// words alone.87#[derive(Debug, Clone, PartialEq, Eq)]88pub struct Last {89    pub words: String,90    pub library: String,91    pub art: String,92}9394impl Last {95    // A slot of words alone.96    fn words(words: String) -> Self {97        Self {98            words,99            library: String::new(),100            art: String::new(),101        }102    }103104    // The slot about a person: their name in the words, and their105    // headshot as the art, where a library holds one.106    fn about(library: &str, path: &str, name: &str, source: &mut dyn Source) -> Self {107        let (library, art) = source108            .person(library, path)109            .map(|entry| person::headshot(&entry))110            .unwrap_or_default();111        Self {112            words: format!("About {name}"),113            library,114            art,115        }116    }117118    /// The slot as the strip view draws it.119    pub fn view(&self) -> strip::Last<'_> {120        strip::Last {121            words: &self.words,122            library: &self.library,123            art: &self.art,124        }125    }126}127128/// One strip of the page: the row it reads, the heading over it, the129/// items in the read's order, the focused index, the words on a slot that130/// ends it, or nothing, and the caption lines under each slot.131#[derive(Debug)]132pub struct Strip {133    pub row: Row,134    pub heading: String,135    pub items: Vec<Item>,136    pub focus: usize,137    pub last: Option<Last>,138    pub lines: usize,139}140141impl Strip {142    // A strip of this row with nothing read yet. Every strip is built empty143    // and read in the page's order, because the added strip reads what the144    // released strip shows.145    pub(super) fn new(row: Row) -> Self {146        Self {147            heading: String::new(),148            items: Vec::new(),149            focus: 0,150            last: None,151            lines: card::LINES,152            row,153        }154    }155156    // Read the strip's row again and keep focus in range. A query strip157    // shows the first `SHOWN` slots and leaves the rest to the wall, and158    // it ends in a "see all" slot only where the read answered more than159    // the strip shows. A person's strip always ends in a slot about them,160    // because their page holds the headshot and the biography and not161    // only the works. The released strip keeps the window of today, and162    // the added strip drops what the released strip shows.163    // A drawn strip under `FLOOR` holds nothing at all, not even its164    // "see all" or "about" slot, so a two-film set the day drew takes165    // no row.166    pub(super) fn reread(&mut self, source: &mut dyn Source, today: i64, released: &[Item]) {167        match &self.row {168            Row::Query(query) => {169                let mut answer = source.wall(query);170                // A person's heading is two-tone: the name bright, and171                // after the dot the person's roles across the strip's172                // works, most frequent first.173                let roles = credits::credit(query, &mut answer.slots);174                self.heading = match query {175                    Query::Person { .. } => facts::joined(&[&answer.name, &roles]),176                    _ => query.name(&answer.name),177                };178                let answered = answer.slots.len();179                let slots = match query {180                    Query::Released { .. } => super::recent::released(answer.slots, today),181                    Query::Added { .. } => super::recent::added(answer.slots, released),182                    _ => answer.slots.into_iter().take(SHOWN).collect(),183                };184                let floored = !self.row.recency() && slots.len() < FLOOR;185                self.last = match (floored, query) {186                    (true, _) => None,187                    (_, Query::Person { library, path }) => {188                        Some(Last::about(library, path, &answer.name, source))189                    }190                    _ => (slots.len() < answered).then(|| Last::words(strip::SEE_ALL.to_string())),191                };192                self.items = match floored {193                    true => Vec::new(),194                    false => slots195                        .into_iter()196                        .map(|slot| Item::of(query, slot))197                        .collect(),198                };199            }200            // The genres row reads every genre. It has no "see all", because201            // it is all of them and not a draw.202            Row::Genres => {203                self.heading = "Genres".to_string();204                self.items = source.genres().into_iter().map(genre_item).collect();205            }206            // The franchises row reads every franchise of the namespace, in207            // sort order, and has no "see all" for the reason the genres row208            // has none. The heading carries the count, as a library band does,209            // because the count is the size of the shelf.210            Row::Franchises => {211                let items: Vec<Item> = source212                    .franchises()213                    .into_iter()214                    .map(franchise_item)215                    .collect();216                self.heading = format!("Franchises · {}", items.len());217                self.items = items;218            }219            // The libraries row reads the libraries. The banner row is here only220            // because the match is exhaustive: a strip never carries it, because221            // the banner is read off the strips and not from a row of its own.222            Row::Libraries | Row::Banner => {223                self.heading = "Libraries".to_string();224                self.items = source.libraries().into_iter().map(library_item).collect();225            }226        }227        screens::fitted_strip(&mut self.items);228        self.focus = self.focus.min(self.count().saturating_sub(1));229    }230231    // The slots a press can reach: the items, and the "see all" slot232    // after them.233    pub(super) fn count(&self) -> usize {234        self.items.len() + usize::from(self.last.is_some())235    }236237    pub(super) fn is_empty(&self) -> bool {238        self.items.is_empty()239    }240241    // The item that holds focus, or nothing while "see all" holds it.242    pub(super) fn focused(&self) -> Option<&Item> {243        self.items.get(self.focus)244    }245246    // What a select opens. "See all" opens the page the strip is about:247    // a person's own page for a person's strip, and the wall of everything248    // the query answers for every other. A library opens its wall, and a249    // title opens its page by its kind.250    pub(super) fn select(&self, source: &mut dyn Source) -> Step {251        let Some(item) = self.focused() else {252            return match &self.row {253                Row::Query(query) if self.last.is_some() => slots::see_all(query, source),254                _ => Step::Stay,255            };256        };257        if item.kind == LIBRARY {258            let query = Query::Library {259                library: item.library.clone(),260                sort: Sort::default(),261            };262            return Step::Open(Screen::Wall(Box::new(Wall::open(query, source))));263        }264        if item.kind == GENRE {265            let query = Query::Genre {266                name: item.id.clone(),267                order: Order::Released,268                sort: GenreSort::default(),269            };270            return slots::see_all(&query, source);271        }272        if item.kind == FRANCHISE {273            return match franchise::Franchise::open(&item.library, &item.id, source) {274                Some(page) => Step::Open(Screen::Franchise(Box::new(page))),275                None => Step::Stay,276            };277        }278        slots::opened(item, source)279    }280}281282// One library as a slot of the libraries strip: its name as the caption,283// how many of what it holds under it, and the posters of its newest-added284// titles as the mosaic.285fn library_item(entry: LibraryEntry) -> Item {286    let name = library_name(&entry.library).to_string();287    let under = facts::counted(entry.items as i64, &entry.kind);288    let tiles = entry289        .art290        .into_iter()291        .map(|art| (entry.library.clone(), art))292        .collect();293    Item {294        id: entry.library.clone(),295        library: entry.library,296        art_library: String::new(),297        kind: LIBRARY.to_string(),298        fitted: name.clone(),299        caption: name.clone(),300        line: facts::Line::of(&[&name]),301        name,302        released: String::new(),303        under_fitted: under.clone(),304        under,305        tagline: false,306        art: String::new(),307        tiles,308        episode: None,309        new: 0,310    }311}312313// One genre as a slot of the genres strip: the genre as the caption, the314// count of its titles under it, and the posters of its newest titles as315// the mosaic. No poster stands on two tiles of the row.316fn genre_item(entry: GenreEntry) -> Item {317    let titles = facts::counted(entry.titles as i64, "titles");318    Item {319        id: entry.name.clone(),320        library: String::new(),321        art_library: String::new(),322        kind: GENRE.to_string(),323        fitted: entry.name.clone(),324        caption: entry.name.clone(),325        line: facts::Line::of(&[&entry.name]),326        name: entry.name,327        released: String::new(),328        under_fitted: titles.clone(),329        under: titles,330        tagline: false,331        art: String::new(),332        tiles: entry.art,333        episode: None,334        new: 0,335    }336}337338// One franchise as a slot of the franchises strip: its title as the caption,339// and the art beside its franchise.yaml as the poster. The slot draws its340// title on the tile where the row carries no art, which is what every slot341// with no art draws. The library and the id are the ones a press opens the342// page by.343// The second line is the scope of the order, in the words a franchise344// strip's heading carries after the name.345fn franchise_item(entry: FranchiseEntry) -> Item {346    let under = franchise::strips::counted(entry.movies, entry.series);347    Item {348        id: entry.id,349        library: entry.library,350        art_library: entry.art_library,351        kind: FRANCHISE.to_string(),352        fitted: entry.title.clone(),353        caption: entry.title.clone(),354        line: facts::Line::of(&[&entry.title]),355        name: entry.title,356        released: String::new(),357        under_fitted: under.clone(),358        under,359        tagline: false,360        art: entry.art,361        tiles: Vec::new(),362        episode: None,363        new: 0,364    }365}366367#[cfg(test)]368mod tests {369    use super::*;370    use crate::catalog::recency::{WORKS_FLOOR, date_seconds};371    use crate::catalog::{Slot, TILES};372    use crate::sample::Catalog;373    use crate::views::Card;374375    fn work(parts: &str) -> Slot {376        Slot {377            parts: parts.into(),378            ..Slot::default()379        }380    }381382    fn credited() -> Query {383        Query::Person {384            library: "sample/features".into(),385            path: ".contributors/A Player".into(),386        }387    }388389    // One work of a person's read as its card draws it: a film of 1987,390    // 1h 37m long, rated PG-13, credited with these parts, under a391    // heading the whole read wrote. Both lines, because the parts left392    // decide which of the two the title stands on.393    fn carded(read: &[&str], parts: &str) -> (String, String) {394        let mut works: Vec<Slot> = read.iter().map(|parts| work(parts)).collect();395        works.push(Slot {396            kind: "movies".into(),397            title: "The Show".into(),398            released: "1987".into(),399            duration: 5_820,400            rating: "PG-13".into(),401            parts: parts.into(),402            ..Slot::default()403        });404        credits::credit(&credited(), &mut works);405        let item = Item::of(&credited(), works.pop().expect("the work under test"));406        (item.caption, item.under)407    }408409    #[test]410    fn a_card_drops_the_role_a_one_role_heading_names_and_keeps_every_other_part() {411        let cases: [(&[&str], &str, (&str, &str)); 8] = [412            (413                &["Director", "Director"],414                "Director",415                ("The Show", "Film · 1987"),416            ),417            (418                &["as Samara Morgan"],419                "as Samara Morgan",420                ("Samara Morgan", "The Show · 1987"),421            ),422            (423                &["Director", "Writer"],424                "Director, Writer",425                ("The Show", "Director, Writer · 1987"),426            ),427            (428                &["Director", "Writer"],429                "Writer",430                ("The Show", "Writer · 1987"),431            ),432            (433                &["as The Lead", "Director"],434                "as The Lead",435                ("The Lead", "The Show · 1987"),436            ),437            (438                &["as The Lead", "Director"],439                "Director",440                ("The Show", "Director · 1987"),441            ),442            (443                &["as The Lead", "Director"],444                "Director, as The Lead",445                ("The Show", "Director, as The Lead · 1987"),446            ),447            (448                &["as One, as Two"],449                "as One, as Two",450                ("One, Two", "The Show · 1987"),451            ),452        ];453        for (read, parts, (caption, under)) in cases {454            let carded = carded(read, parts);455            assert_eq!(carded, (caption.to_string(), under.to_string()), "{parts}");456        }457    }458459    // One slot of a title too long for a poster's caption band, with parts460    // too long for the line under it.461    fn wide(episode: Option<crate::catalog::InSeries>) -> Slot {462        Slot {463            title: "W".repeat(60),464            released: "1987".into(),465            parts: "as ".to_string() + &"W".repeat(60),466            episode,467            ..Slot::default()468        }469    }470471    #[test]472    fn a_card_carries_both_its_lines_cut_to_the_band_its_ratio_draws() {473        let query = Query::Person {474            library: "sample/features".into(),475            path: ".contributors/A Player".into(),476        };477        let mut items = vec![Item::of(&query, wide(None))];478        screens::fitted_strip(&mut items);479        let band = strip::caption_width(crate::views::wall::POSTER);480        assert!(items[0].fitted.ends_with('\u{2026}'));481        assert!(items[0].under_fitted.ends_with('\u{2026}'));482        assert!(crate::views::text::measured(&items[0].fitted, crate::look::CAPTION) <= band);483        assert!(crate::views::text::measured(&items[0].under_fitted, crate::look::FACE) <= band);484    }485486    #[test]487    fn a_still_card_cuts_its_lines_to_the_wider_band_a_still_draws_under() {488        let query = Query::Released { fold: Fold::Airing };489        let mut items = vec![Item::of(490            &query,491            wide(Some(crate::catalog::InSeries {492                series: "series:1".into(),493                name: "W".repeat(60),494                season: 3,495                episode: 4,496            })),497        )];498        screens::fitted_strip(&mut items);499        let still = strip::caption_width(crate::views::wall::STILL);500        let poster = strip::caption_width(crate::views::wall::POSTER);501        let drawn = crate::views::text::measured(&items[0].fitted, crate::look::CAPTION);502        assert!(drawn <= still);503        assert!(drawn > poster);504    }505506    #[test]507    fn a_card_whose_lines_fit_the_band_is_cut_nowhere() {508        let mut items = vec![library_item(LibraryEntry {509            library: "screening/features".into(),510            kind: "movies".into(),511            items: 42,512            art: Vec::new(),513        })];514        screens::fitted_strip(&mut items);515        assert_eq!(items[0].fitted, "features");516        assert_eq!(items[0].under_fitted, "42 movies");517    }518519    #[test]520    fn a_drawn_set_of_three_films_holds_nothing() {521        let mut strip = Strip::new(Row::Query(Query::Set {522            library: "sample/features".into(),523            id: "set:sample:01".into(),524        }));525        strip.reread(&mut Catalog, 0, &[]);526        assert!(strip.is_empty());527        assert_eq!(strip.last, None);528        assert_eq!(strip.count(), 0);529    }530531    #[test]532    fn a_drawn_person_of_three_works_holds_nothing_and_loses_the_slot_about_them() {533        let mut strip = Strip::new(Row::Query(Query::Person {534            library: "sample/features".into(),535            path: ".contributors/Player 0001-1".into(),536        }));537        strip.reread(&mut Catalog, 0, &[]);538        assert!(strip.is_empty());539        assert_eq!(strip.last, None);540    }541542    #[test]543    fn a_drawn_strip_over_the_floor_reads_every_slot_it_answered() {544        let mut strip = Strip::new(Row::Query(Query::Person {545            library: "sample/features".into(),546            path: ".contributors/A Second Writer".into(),547        }));548        strip.reread(&mut Catalog, 0, &[]);549        assert_eq!(strip.items.len(), 6);550        assert!(strip.items.len() >= FLOOR);551        assert!(strip.last.is_some());552    }553554    #[test]555    fn a_person_the_pool_admits_always_meets_the_draw_floor() {556        const { assert!(FLOOR as u64 == WORKS_FLOOR + 1) };557    }558559    #[test]560    fn a_recency_strip_under_the_floor_still_holds_its_slots() {561        let today = date_seconds("2026-09-05").expect("a full date reads");562        let mut strip = Strip::new(Row::Query(Query::Released { fold: Fold::Airing }));563        strip.reread(&mut Catalog, today, &[]);564        assert!(!strip.is_empty());565        assert!(strip.items.len() < FLOOR);566    }567568    #[test]569    fn the_libraries_row_of_two_libraries_still_holds_them() {570        let mut strip = Strip::new(Row::Libraries);571        strip.reread(&mut Catalog, 0, &[]);572        assert_eq!(strip.items.len(), 2);573        assert!(strip.items.len() < FLOOR);574    }575576    #[test]577    fn the_genres_and_the_franchises_rows_hold_what_they_read() {578        let mut genres = Strip::new(Row::Genres);579        genres.reread(&mut Catalog, 0, &[]);580        assert_eq!(genres.items.len(), 5);581        let mut franchises = Strip::new(Row::Franchises);582        franchises.reread(&mut Catalog, 0, &[]);583        assert!(!franchises.is_empty());584    }585586    #[test]587    fn a_persons_strip_is_headed_by_their_name_and_their_roles() {588        let mut strip = Strip::new(Row::Query(Query::Person {589            library: "sample/features".into(),590            path: ".contributors/A Second Writer".into(),591        }));592        strip.reread(&mut Catalog, 0, &[]);593        assert_eq!(strip.heading, "A Second Writer · writer");594        let (name, rest) = crate::views::strip::split(&strip.heading);595        assert_eq!((name, rest), ("A Second Writer", " · writer"));596    }597598    // The strip of one invented person, read from the sample catalog.599    fn person(path: &str) -> Strip {600        let mut strip = Strip::new(Row::Query(Query::Person {601            library: "sample/features".into(),602            path: path.into(),603        }));604        strip.reread(&mut Catalog, 0, &[]);605        strip606    }607608    #[test]609    fn a_card_under_a_one_role_heading_drops_that_role_from_its_second_line() {610        let strip = person(".contributors/A Second Writer");611        assert_eq!(strip.heading, "A Second Writer · writer");612        assert_eq!(strip.items[0].caption, strip.items[0].name);613        assert_eq!(strip.items[0].under, "Film · 2011");614    }615616    #[test]617    fn a_card_under_an_actors_heading_leads_with_the_character() {618        let query = Query::Person {619            library: "sample/features".into(),620            path: ".contributors/Player 0001-1".into(),621        };622        let mut works =623            crate::sample::people::works("sample/features", ".contributors/Player 0001-1");624        assert_eq!(credits::credit(&query, &mut works), "actor");625        let item = Item::of(&query, works.remove(0));626        assert_eq!(item.caption, "Part 1");627        assert_eq!(item.under, "Specimen 0003 · 2011");628    }629630    #[test]631    fn the_page_reads_the_banner_the_recency_rows_the_draw_the_libraries_the_genres_then_the_franchises()632     {633        let western = Query::Genre {634            name: "Western".into(),635            order: crate::catalog::Order::Released,636            sort: GenreSort::default(),637        };638        let drawn = vec![Candidate {639            query: western.clone(),640            name: "Western".into(),641            weight: 7,642        }];643        let fold = Fold::Shows { today: 0 };644        assert_eq!(645            rows(0, drawn),646            [647                Row::Banner,648                Row::Query(Query::Released { fold }),649                Row::Query(Query::Added { fold }),650                Row::Query(western),651                Row::Libraries,652                Row::Genres,653                Row::Franchises,654            ]655        );656        assert_eq!(rows(0, Vec::new()).len(), 6);657    }658659    #[test]660    fn only_the_two_recency_rows_are_recency() {661        assert!(Row::Query(Query::Released { fold: Fold::Airing }).recency());662        assert!(Row::Query(Query::Added { fold: Fold::Titles }).recency());663        assert!(!Row::Banner.recency());664        assert!(!Row::Libraries.recency());665        assert!(!Row::Genres.recency());666        assert!(!Row::Franchises.recency());667        assert!(668            !Row::Query(Query::Library {669                library: "sample/features".into(),670                sort: Sort::default(),671            })672            .recency()673        );674    }675676    #[test]677    fn a_library_is_a_slot_with_its_name_its_count_and_a_mosaic_of_its_newest_posters() {678        let item = library_item(LibraryEntry {679            library: "screening/features".into(),680            kind: "movies".into(),681            items: 1_422,682            art: vec!["posters/one.jpg".into(), "posters/two.jpg".into()],683        });684        assert_eq!(item.kind, LIBRARY);685        assert_eq!(item.id, "screening/features");686        assert_eq!(item.library, "screening/features");687        assert_eq!(item.name, "features");688        assert_eq!(item.caption, "features");689        assert_eq!(item.fitted, "features");690        assert_eq!(item.line.words(), "features");691        assert_eq!(item.under, "1,422 movies");692        assert_eq!(item.art, "");693        assert_eq!(694            item.tiles,695            [696                (697                    "screening/features".to_string(),698                    "posters/one.jpg".to_string()699                ),700                (701                    "screening/features".to_string(),702                    "posters/two.jpg".to_string()703                ),704            ]705        );706        assert_eq!(item.episode, None);707    }708709    #[test]710    fn a_library_of_one_counts_it_in_the_singular_and_a_library_of_series_never_does() {711        let under = |items, kind: &str| {712            library_item(LibraryEntry {713                library: "screening/features".into(),714                kind: kind.into(),715                items,716                art: Vec::new(),717            })718            .under719        };720        assert_eq!(under(1, "movies"), "1 movie");721        assert_eq!(under(1, "series"), "1 series");722        assert_eq!(under(2, "movies"), "2 movies");723        assert_eq!(under(165, "series"), "165 series");724    }725726    #[test]727    fn a_genre_is_a_slot_with_its_name_its_count_and_a_mosaic_of_its_own_posters() {728        let item = genre_item(GenreEntry {729            name: "Western".into(),730            titles: 42,731            art: vec![732                ("screening/features".into(), "posters/one.jpg".into()),733                ("screening/serials".into(), "posters/two.jpg".into()),734            ],735        });736        assert_eq!(item.kind, GENRE);737        assert_eq!(item.id, "Western");738        assert_eq!(item.library, "");739        assert_eq!(item.name, "Western");740        assert_eq!(item.caption, "Western");741        assert_eq!(item.fitted, "Western");742        assert_eq!(item.line.words(), "Western");743        assert_eq!(item.under, "42 titles");744        assert_eq!(item.art, "");745        assert_eq!(746            item.tiles,747            [748                (749                    "screening/features".to_string(),750                    "posters/one.jpg".to_string()751                ),752                (753                    "screening/serials".to_string(),754                    "posters/two.jpg".to_string()755                ),756            ]757        );758        assert_eq!(item.episode, None);759    }760761    #[test]762    fn a_franchise_is_a_slot_with_its_title_and_the_art_beside_its_file() {763        let item = franchise_item(FranchiseEntry {764            library: "screening/orders".into(),765            id: "franchise:name:the-cycle".into(),766            title: "The Cycle".into(),767            art: "the-cycle/poster.jpg".into(),768            art_library: "screening/films".into(),769            slug: "the-cycle".into(),770            movies: 26,771            series: 14,772        });773        assert_eq!(item.kind, FRANCHISE);774        assert_eq!(item.id, "franchise:name:the-cycle");775        assert_eq!(item.library, "screening/orders");776        // The art of a franchise is a member's poster on the member's777        // own volume, so the slot resolves it there.778        assert_eq!(item.art_library, "screening/films");779        assert_eq!(Card::library(&item), "screening/films");780        assert_eq!(item.name, "The Cycle");781        assert_eq!(item.caption, "The Cycle");782        assert_eq!(item.fitted, "The Cycle");783        assert_eq!(item.line.words(), "The Cycle");784        assert_eq!(item.under, "40 films and series");785        assert_eq!(item.art, "the-cycle/poster.jpg");786        assert_eq!(item.episode, None);787    }788789    #[test]790    fn a_franchise_with_no_art_draws_the_tile_of_its_title() {791        let item = franchise_item(FranchiseEntry {792            title: "The Saga".into(),793            movies: 1,794            ..FranchiseEntry::default()795        });796        assert_eq!(item.art, "");797        assert_eq!(item.name, "The Saga");798    }799800    #[test]801    fn every_tile_of_the_franchises_row_says_the_scope_of_its_order() {802        let mut strip = Strip::new(Row::Franchises);803        strip.reread(&mut Catalog, 0, &[]);804        let scopes: Vec<&str> = strip805            .items806            .iter()807            .map(|item| item.under_fitted.as_str())808            .collect();809        assert_eq!(scopes, ["9 films and series", "3 films"]);810    }811812    #[test]813    fn every_still_of_a_recency_row_reads_its_episode_over_its_show() {814        let today = date_seconds("2026-09-05").expect("a full date reads");815        let mut strip = Strip::new(Row::Query(Query::Released { fold: Fold::Airing }));816        strip.reread(&mut Catalog, today, &[]);817        let stills: Vec<&Item> = strip818            .items819            .iter()820            .filter(|item| item.episode.is_some())821            .collect();822        assert!(!stills.is_empty());823        for still in stills {824            assert!(still.caption.starts_with("Segment "), "{}", still.caption);825            let (show, rest) = still826                .under827                .split_once(" · ")828                .expect("a still carries its show and its numbers under it");829            assert!(show.starts_with("Serial "), "{}", still.under);830            assert!(rest.starts_with('S'), "{}", still.under);831            assert!(still.under.ends_with('m'), "{}", still.under);832        }833    }834835    #[test]836    fn a_series_of_a_genre_row_carries_its_season_count_under_it() {837        let mut strip = Strip::new(Row::Query(Query::Genre {838            name: "Drama".into(),839            order: Order::Released,840            sort: GenreSort::default(),841        }));842        strip.reread(&mut Catalog, 0, &[]);843        let serial = strip844            .items845            .iter()846            .find(|item| item.kind == "series")847            .expect("the sample's serials all lead with Drama");848        assert_eq!(serial.under, "2025 · 2 seasons · TV-14");849    }850851    #[test]852    fn a_genre_one_title_carries_counts_it_in_the_singular() {853        let item = genre_item(GenreEntry {854            name: "Silent".into(),855            titles: 1,856            art: Vec::new(),857        });858        assert_eq!(item.under, "1 title");859        assert_eq!(item.art, "");860        assert!(item.tiles.is_empty());861    }862863    #[test]864    fn every_shelf_of_the_sample_draws_a_mosaic_and_no_title_does() {865        let mut strip = Strip::new(Row::Libraries);866        strip.reread(&mut Catalog, 0, &[]);867        assert!(strip.items.iter().all(|item| item.tiles.len() == TILES));868        let mut genres = Strip::new(Row::Genres);869        genres.reread(&mut Catalog, 0, &[]);870        assert!(genres.items.iter().all(|item| item.tiles.len() == TILES));871        let mut franchises = Strip::new(Row::Franchises);872        franchises.reread(&mut Catalog, 0, &[]);873        assert!(franchises.items.iter().all(|item| item.tiles.is_empty()));874    }875876    #[test]877    fn no_poster_of_the_genres_row_stands_on_two_tiles() {878        let mut strip = Strip::new(Row::Genres);879        strip.reread(&mut Catalog, 0, &[]);880        let mut drawn: Vec<&(String, String)> =881            strip.items.iter().flat_map(|item| &item.tiles).collect();882        let posters = drawn.len();883        drawn.sort();884        drawn.dedup();885        assert_eq!(drawn.len(), posters);886    }887}
src/screens/loading.rs 100.0%
1// The state a page is in between the select that asks for a film and the2// film that covers the page. It is a pure function of the clock: the second3// it was entered, and the second the exit began.45use crate::look;6use crate::views::curtain::Curtain;78/// The loading state one page is in, as a function of the clock.9#[derive(Debug, Clone, Copy, PartialEq)]10pub struct Loading {11    // The second the press entered the state.12    entered: f64,13    // The exit, once it has begun.14    left: Option<Exit>,15}1617// The exit: the second it ends, and how far away the page stood when it18// began, so the motion runs back from there.19#[derive(Debug, Clone, Copy, PartialEq)]20struct Exit {21    until: f64,22    from: f32,23}2425impl Loading {26    /// The state a press enters at this second.27    pub fn entered(at: f64) -> Self {28        Self {29            entered: at,30            left: None,31        }32    }3334    /// Start the exit at this second. A state that is already leaving keeps35    /// the exit it runs, so a second ask does not restart it.36    pub fn leave(&mut self, at: f64) {37        if self.left.is_some() {38            return;39        }40        self.left = Some(Exit {41            until: at + look::RETURN,42            from: self.away(at),43        });44    }4546    /// Whether the exit has begun.47    pub fn leaving(&self) -> bool {48        self.left.is_some()49    }5051    /// How far the page has gone, from 0 whole to 1 fully away.52    pub fn away(&self, at: f64) -> f32 {53        match self.left {54            None => eased(share(at - self.entered, look::DEPARTURE)),55            // The share left is measured from the second the exit ends56            // and not from the second it began, so the last frame of the57            // exit lands on exactly zero.58            Some(exit) => exit.from * share(exit.until - at, look::RETURN),59        }60    }6162    /// Whether the exit has run its length. The browser then drops the63    /// state and the page is whole again.64    pub fn done(&self, at: f64) -> bool {65        matches!(self.left, Some(exit) if at >= exit.until)66    }6768    /// What one frame draws at this second.69    pub fn curtain(&self, at: f64) -> Curtain {70        Curtain {71            away: self.away(at),72            phase: at,73        }74    }75}7677// How far into a length of time this many seconds is, from 0 to 1.78fn share(since: f64, length: f64) -> f32 {79    (since / length).clamp(0.0, 1.0) as f3280}8182// The smoothstep the departure runs on, so the page leaves and settles83// rather than starting and stopping on a hard edge.84fn eased(share: f32) -> f32 {85    share * share * (3.0 - 2.0 * share)86}8788#[cfg(test)]89mod tests {90    use super::*;9192    // The second the press lands on, which every case measures from.93    const PRESS: f64 = 3.0;9495    #[test]96    fn the_press_enters_the_state_with_the_page_whole() {97        let state = Loading::entered(PRESS);98        assert_eq!(state.away(PRESS), 0.0);99        assert!(!state.leaving());100        assert!(!state.done(PRESS));101    }102103    #[test]104    fn the_page_is_fully_away_after_the_departure() {105        let state = Loading::entered(PRESS);106        assert_eq!(state.away(PRESS + look::DEPARTURE), 1.0);107    }108109    #[test]110    fn the_page_leaves_without_going_back() {111        let state = Loading::entered(PRESS);112        let mut last = 0.0;113        for step in 1..=10 {114            let away = state.away(PRESS + look::DEPARTURE * f64::from(step) / 10.0);115            assert!(away > last, "{away} at step {step}");116            last = away;117        }118    }119120    #[test]121    fn the_state_holds_with_no_ceiling() {122        let state = Loading::entered(PRESS);123        for held in [1.0, 60.0, 3_600.0] {124            assert_eq!(state.away(PRESS + look::DEPARTURE + held), 1.0);125            assert!(!state.done(PRESS + look::DEPARTURE + held));126        }127    }128129    #[test]130    fn the_exit_runs_the_page_back_and_ends() {131        let held = PRESS + 10.0;132        let mut state = Loading::entered(PRESS);133        state.leave(held);134135        assert!(state.leaving());136        assert_eq!(state.away(held), 1.0);137        assert_eq!(state.away(held + look::RETURN / 2.0), 0.5);138        assert_eq!(state.away(held + look::RETURN), 0.0);139        assert!(state.done(held + look::RETURN));140    }141142    #[test]143    fn an_exit_before_the_page_is_away_runs_back_from_where_it_stood() {144        let part = PRESS + look::DEPARTURE / 2.0;145        let mut state = Loading::entered(PRESS);146        let stood = state.away(part);147        state.leave(part);148149        assert_eq!(state.away(part), stood);150        assert_eq!(state.away(part + look::RETURN), 0.0);151    }152153    #[test]154    fn a_second_ask_to_leave_does_not_restart_the_exit() {155        let held = PRESS + 10.0;156        let mut state = Loading::entered(PRESS);157        state.leave(held);158        state.leave(held + look::RETURN / 2.0);159160        assert!(state.done(held + look::RETURN));161    }162163    #[test]164    fn the_curtain_carries_the_clock_the_mark_pulses_on() {165        let state = Loading::entered(PRESS);166        let curtain = state.curtain(PRESS + look::DEPARTURE);167        assert_eq!(curtain.away, 1.0);168        assert_eq!(curtain.phase, PRESS + look::DEPARTURE);169    }170}
src/screens/movie.rs 98.0%
1// A movie's page. The backdrop draws full bleed under the text, and the2// page reads down: the logo or the title, the facts, the tagline, the3// plot, the buttons, the set strip, a strip for each franchise the movie4// belongs to, and the stripes of credited people.5// Focus lands on Play, so a film is two presses from the wall, as it was6// when the wall played it on select.78mod page;910use std::cell::RefCell;11use std::convert::Infallible;1213use iced_wgpu::Renderer;14use iced_winit::core::{Element, Rectangle, Theme};1516use super::franchise::strips::{self, Move, Place, Strips};17use super::{Item, Screen, Step, facts, foot, franchise, person, stripes};18use crate::catalog::draw::Date;19use crate::catalog::{MovieDetails, MovieSet, Query, Selection, Slot, Source};20use crate::focus;21use crate::posters::Posters;22use crate::views::curtain::{Curtain, Head, Layer};23use crate::views::{layers, ratings};2425/// Where focus is on the page.26#[derive(Debug, Clone, Copy, PartialEq, Eq)]27pub enum Focus {28    /// One button of the row.29    Buttons(usize),30    /// One member of the set strip.31    Strip(usize),32    /// One rung of the franchise strips: which strip, and the heading or33    /// the member in it.34    Franchise(usize, Place),35    /// One headshot of one stripe: the stripe, and the slot in it.36    Stripe(usize, usize),37}3839/// The words after a set's name on its strip heading. The count is the40/// members of the set, which is every film it holds. A set is narrower41/// than a franchise, and the heading says which of the two a strip is. A42/// set of one never reaches a page, because a set needs two members.43fn films(count: usize) -> String {44    format!("a {count}-film set")45}4647/// The set the movie belongs to, as the strip draws it.48#[derive(Debug)]49pub struct Set {50    /// The strip's heading: the set's own title and the count of its51    /// films.52    pub heading: String,53    /// Every movie in the set, in release order.54    pub members: Vec<Item>,55    /// The index of the member this page is about.56    pub current: usize,57}5859impl Set {60    // The members are the slots of a `Set` query, so the strip draws the61    // same slots a wall of the set would. `id` names the member this page is62    // about.63    fn of(set: MovieSet, query: &Query, id: &str) -> Option<Self> {64        let Query::Set { library, .. } = query else {65            return None;66        };67        let mut members: Vec<Item> = set68            .members69            .into_iter()70            .map(|member| Item::of(query, Slot::of(library, "movies", member)))71            .collect();72        // The strip draws the card's two lines, so both are cut by the73        // shaper here at the read, and not on every frame.74        super::fitted_strip(&mut members);75        let current = members.iter().position(|member| member.id == id)?;76        Some(Self {77            heading: facts::joined(&[&set.title, &films(members.len())]),78            members,79            current,80        })81    }82}8384/// The movie page: the words it draws, the art it draws them over, and85/// where focus is. Every line is built once here, at the read, and not86/// on every frame.87#[derive(Debug)]88pub struct Movie {89    /// The catalog's library column, `namespace/name`.90    pub library: String,91    /// The movie's id inside that library.92    pub id: String,93    /// The name a person reads. The page draws it where the movie has no94    /// logo.95    pub title: String,96    /// The path of the logo file, empty where the movie has none.97    pub logo: String,98    /// The path of the backdrop file, empty where the movie has none.99    pub backdrop: String,100    /// Whether the movie holds a trailer file. That is what puts the101    /// second button on the row.102    pub trailer: bool,103    /// The year, the runtime, the content rating, and the genres, on one104    /// line.105    pub facts: String,106    /// The scores the ratings line draws, in the order it draws them.107    pub ratings: Vec<ratings::Score>,108    /// The tagline, empty where the sidecar named none.109    pub tagline: String,110    /// The plot. The page cuts it to four lines.111    pub plot: String,112    /// The credited people, as the stripes at the end of the page.113    pub stripes: stripes::Stripes,114    /// The studios and the files, as the block after the last stripe.115    pub foot: foot::Foot,116    /// The set the movie belongs to, or nothing where it belongs to none.117    pub set: Option<Set>,118    /// The franchises the movie belongs to, one strip each, under the set119    /// strip.120    pub franchises: Strips,121    /// Where focus is.122    pub focus: Focus,123}124125impl Movie {126    /// Read one movie's page, or nothing where the library holds no127    /// movie under that id. Focus lands on Play.128    pub fn open(library: &str, id: &str, source: &mut dyn Source) -> Option<Self> {129        let details = source.movie(library, id)?;130        let set = set_of(library, id, &details, source);131        Some(Self {132            library: library.to_string(),133            id: id.to_string(),134            title: details.title.clone(),135            logo: details.logo.clone(),136            backdrop: details.backdrop.clone(),137            trailer: !details.trailer.is_empty(),138            facts: facts_of(&details),139            ratings: ratings::scores(&details.ratings),140            tagline: details.tagline.clone(),141            plot: details.plot.clone(),142            stripes: stripes::Stripes::of(source.credits(library, id)),143            foot: foot::Foot::of(&details.studios, &source.files(library, id)),144            set,145            franchises: Strips::of(library, id, source),146            focus: Focus::Buttons(0),147        })148    }149150    /// Read the page again, because the scanner can write the movie or151    /// its set while the page is open. Focus stays where it was.152    pub fn reread(&mut self, source: &mut dyn Source) {153        let Some(fresh) = Self::open(&self.library, &self.id, source) else {154            return;155        };156        let focus = self.focus;157        *self = fresh;158        self.focus = self.hold(focus);159    }160161    /// The buttons this page draws. Play is always there. Trailer joins162    /// it where the `files` table holds a trailer for the movie.163    pub fn buttons(&self) -> &'static [&'static str] {164        if self.trailer {165            &["Play", "Trailer"]166        } else {167            &["Play"]168        }169    }170171    /// Fold one press in. Left and right move across the row that holds172    /// focus, down reaches the set strip, then the franchise strips, and173    /// then the stripes, and up climbs back to the buttons. A franchise174    /// strip's heading is a rung over its members.175    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {176        match self.focus {177            Focus::Buttons(index) => self.on_button(index, key),178            Focus::Strip(index) => self.on_strip(index, key, source),179            Focus::Franchise(strip, place) => self.on_franchise((strip, place), key, source),180            Focus::Stripe(stripe, slot) => self.on_stripe((stripe, slot), key, source),181        }182    }183184    /// The view: the backdrop, the scrim over it, the page over both, and185    /// the loading state's curtain over the page while that state runs.186    pub fn view<'a, P: Posters>(187        &'a self,188        posters: &'a RefCell<P>,189        curtain: Option<Curtain>,190        held: bool,191    ) -> Element<'a, Infallible, Theme, Renderer> {192        layers::Page {193            library: &self.library,194            art: &self.backdrop,195            posters,196            ground: layers::Ground::None,197            front: page::Page {198                movie: self,199                posters,200                lifted: curtain.is_some(),201                held,202            },203            over: curtain.map(|curtain| Layer {204                library: &self.library,205                art: &self.backdrop,206                logo: &self.logo,207                name: &self.title,208                posters,209                head: self,210                curtain,211            }),212        }213        .view()214    }215216    fn on_button(&mut self, index: usize, key: &str) -> Step {217        match key {218            "enter" => Step::Play {219                library: self.library.clone(),220                selection: self.chosen(index),221            },222            // The buttons are the topmost focus, so up moves nothing and223            // the press reaches the browser's strip.224            "up" => Step::Still,225            "down" => {226                self.focus = self.below(index);227                Step::Stay228            }229            _ => {230                self.focus = Focus::Buttons(focus::row(index, self.buttons().len(), key));231                Step::Stay232            }233        }234    }235236    fn on_strip(&mut self, index: usize, key: &str, source: &mut dyn Source) -> Step {237        let Some(set) = &self.set else {238            self.focus = Focus::Buttons(0);239            return Step::Stay;240        };241        match key {242            "enter" => {243                let Some(member) = set.members.get(index) else {244                    return Step::Stay;245                };246                if member.id == self.id {247                    return Step::Stay;248                }249                // A sibling replaces this page and does not cover it, so250                // back from any film of a set returns to the wall. The251                // strip is a way to move inside a set, not a screen of252                // its own.253                match Self::open(&self.library, &member.id, source) {254                    Some(page) => Step::Replace(Screen::Movie(Box::new(page))),255                    None => Step::Stay,256                }257            }258            "up" => {259                self.focus = Focus::Buttons(0);260                Step::Stay261            }262            "down" => {263                self.focus = self.under_strip();264                Step::Stay265            }266            _ => {267                self.focus = Focus::Strip(focus::row(index, set.members.len(), key));268                Step::Stay269            }270        }271    }272273    // One press on a stripe. Select opens the person's page, and a274    // name the credits could not resolve opens nothing.275    fn on_stripe(&mut self, rung: stripes::Rung, key: &str, source: &mut dyn Source) -> Step {276        if key == "enter" {277            let Some(face) = self.stripes.face(rung) else {278                return Step::Stay;279            };280            if face.contributor.is_empty() {281                return Step::Stay;282            }283            return match person::Person::open(&self.library, &face.contributor, source) {284                Some(page) => Step::Open(Screen::Person(Box::new(page))),285                None => Step::Stay,286            };287        }288        self.focus = match self.stripes.key(rung, key) {289            Some((stripe, slot)) => Focus::Stripe(stripe, slot),290            None => self.above(),291        };292        Step::Stay293    }294295    // The rung under the buttons: the set strip where the movie is in a296    // set, then the franchise strips, then the first stripe, and the297    // buttons themselves where the page holds none of the three.298    fn below(&self, index: usize) -> Focus {299        if let Some(set) = &self.set {300            return Focus::Strip(set.current);301        }302        match self.franchises.first() {303            Some((strip, place)) => Focus::Franchise(strip, place),304            None => match self.stripes.first() {305                Some((stripe, slot)) => Focus::Stripe(stripe, slot),306                None => Focus::Buttons(index),307            },308        }309    }310311    // The rung under the set strip: the first franchise strip, then the312    // first stripe, and the set strip itself where the page holds313    // neither.314    fn under_strip(&self) -> Focus {315        match self.franchises.first() {316            Some((strip, place)) => Focus::Franchise(strip, place),317            None => match self.stripes.first() {318                Some((stripe, slot)) => Focus::Stripe(stripe, slot),319                None => self.focus,320            },321        }322    }323324    // The rung over the first stripe: the last franchise strip, the set325    // strip where the movie is in a set, and the buttons where the page326    // holds neither.327    fn above(&self) -> Focus {328        if let Some((strip, place)) = self.franchises.last() {329            return Focus::Franchise(strip, place);330        }331        match &self.set {332            Some(set) => Focus::Strip(set.current),333            None => Focus::Buttons(0),334        }335    }336337    // The rung over the franchise strips: the set strip where the movie338    // is in a set, and the buttons where it is not.339    fn over_franchises(&self) -> Focus {340        match &self.set {341            Some(set) => Focus::Strip(set.current),342            None => Focus::Buttons(0),343        }344    }345346    // One press on a franchise strip. A select on the heading opens the347    // franchise's page, and a select on a member replaces this page348    // with that member's, the way a sibling in a set strip does.349    fn on_franchise(&mut self, rung: strips::Rung, key: &str, source: &mut dyn Source) -> Step {350        if key == "enter" {351            return franchise_press(&self.franchises, rung, source);352        }353        self.focus = match self.franchises.key(rung, key) {354            Move::To((strip, place)) => Focus::Franchise(strip, place),355            Move::Above => self.over_franchises(),356            Move::Below => match self.stripes.first() {357                Some((stripe, slot)) => Focus::Stripe(stripe, slot),358                None => Focus::Franchise(rung.0, rung.1),359            },360        };361        Step::Stay362    }363364    // The choice a button stands for. Only a movie with a trailer file365    // has a second button, so index one is always the trailer.366    fn chosen(&self, index: usize) -> Selection {367        match index {368            0 => Selection::Movie {369                id: self.id.clone(),370            },371            _ => Selection::Trailer {372                id: self.id.clone(),373            },374        }375    }376377    // Where focus lands after a re-read: where it was, unless the row it378    // was on grew shorter or the set went away.379    fn hold(&self, focus: Focus) -> Focus {380        match focus {381            Focus::Buttons(index) => Focus::Buttons(index.min(self.buttons().len() - 1)),382            Focus::Strip(index) => match &self.set {383                Some(set) => Focus::Strip(index.min(set.members.len() - 1)),384                None => Focus::Buttons(0),385            },386            Focus::Franchise(strip, place) => match self.franchises.held((strip, place)) {387                Some((strip, place)) => Focus::Franchise(strip, place),388                None => Focus::Buttons(0),389            },390            Focus::Stripe(stripe, slot) => match self.stripes.held((stripe, slot)) {391                Some((stripe, slot)) => Focus::Stripe(stripe, slot),392                None => Focus::Buttons(0),393            },394        }395    }396}397398impl Head for Movie {399    fn head(&self, bounds: Rectangle) -> Rectangle {400        page::head(self, bounds)401    }402}403404/// What a select on a franchise strip opens. The heading opens the405/// franchise's own page, which covers this one, so back returns here. A406/// member replaces this page, because it is another title of the same407/// story and not a screen of its own. The movie page and the series page408/// share this one rule.409pub(crate) fn franchise_press(410    franchises: &Strips,411    rung: strips::Rung,412    source: &mut dyn Source,413) -> Step {414    if let Place::Heading = rung.1 {415        let Some(band) = franchises.band(rung) else {416            return Step::Stay;417        };418        return match franchise::Franchise::open(&band.library, &band.id, source) {419            Some(page) => Step::Open(Screen::Franchise(Box::new(page))),420            None => Step::Stay,421        };422    }423    let Some(member) = franchises.member(rung) else {424        return Step::Stay;425    };426    let opened = match member.kind.as_str() {427        "movies" => Movie::open(&member.library, &member.id, source)428            .map(|page| Screen::Movie(Box::new(page))),429        _ => super::series::Series::open(&member.library, &member.id, source)430            .map(|page| Screen::Series(Box::new(page))),431    };432    match opened {433        Some(screen) => Step::Replace(screen),434        None => Step::Stay,435    }436}437438fn set_of(library: &str, id: &str, details: &MovieDetails, source: &mut dyn Source) -> Option<Set> {439    if details.set_id.is_empty() {440        return None;441    }442    let query = Query::Set {443        library: library.to_string(),444        id: details.set_id.clone(),445    };446    Set::of(source.set(library, &details.set_id)?, &query, id)447}448449/// The facts line of one movie's page: the date, the runtime, the450/// content rating, and the genres, on one line.451pub(crate) fn facts_of(details: &MovieDetails) -> String {452    facts::joined(&[&facts_without_genres(details), &details.genres.join(", ")])453}454455/// The date, the runtime, and the content rating. The banner reads this456/// line, because it draws the genres on a line of their own.457/// The date is spelled against today, which the line reads at the read458/// and not on every frame.459pub(crate) fn facts_without_genres(details: &MovieDetails) -> String {460    facts::joined(&[461        &facts::date_worded(&details.released, &Date::today().iso()),462        &facts::runtime(details.duration),463        &details.rating,464    ])465}466467#[cfg(test)]468mod tests;
src/screens/movie/page.rs 98.4%
1// The movie page's front layer: a scrolled stack of blocks over the2// backdrop and the scrim the page stacks under it. Every block is measured3// before anything draws, so a movie with no tagline, no set, or no credits4// leaves no hole where they would have been. The measure gives every block5// its place, and the focused block decides how far the stack has scrolled.67use std::cell::RefCell;8use std::convert::Infallible;910use iced_wgpu::Renderer;11use iced_widget::canvas;12use iced_winit::core::{Point, Rectangle, Theme, mouse};1314use super::super::franchise::strips::Place;15use super::{Focus, Movie};16use crate::look;17use crate::posters::Posters;18use crate::views::stack::{self, Stack};19use crate::views::{area, buttons, card, header, people, ratings, strip, text};2021// The margin at both sides of the page.22const MARGIN: f32 = 120.0;2324// The share of the width the column of text takes. The column ends inside25// the part of the scrim that holds its full shade, so every line reads26// over the art whatever the art holds.27const COLUMN: f32 = 0.42;2829// The share of the height above the first block.30const TOP: f32 = 0.12;3132// The space between two blocks.33const GAP: f32 = 16.0;3435// The box a logo draws in, at the proportions the metadata tools write a36// logo file in.37const LOGO_WIDTH: f32 = 460.0;38const LOGO_HEIGHT: f32 = 128.0;3940// The lines the plot is cut to.41const PLOT_LINES: usize = 4;4243// How much of the block under the focused one the scroll keeps in44// view, as a share of a stripe, so a person sees that there is more45// below.46const TRAIL: f32 = 0.2;4748// The space under the last stripe, so its caption lines sit clear of the49// bottom edge of the frame when the page has scrolled to its end.50const FOOT: f32 = 36.0;5152// The extra space over a stripe and over the foot, on top of the gap53// between two blocks, so each stands clear of the block over it.54const STRIPE_LEAD: f32 = 16.0;5556/// The box the movie's logo draws in at these bounds, scroll included,57/// which is where the loading state starts the logo's move.58pub fn head(movie: &Movie, bounds: Rectangle) -> Rectangle {59    let blocks = Blocks::of(60        movie,61        bounds.width * COLUMN,62        bounds.width - 2.0 * MARGIN,63        bounds.height * TOP,64    );65    let offset = blocks.scroll(movie, bounds.height);66    area(MARGIN, blocks.title.top - offset, LOGO_WIDTH, LOGO_HEIGHT)67}6869/// The page's front layer as one canvas.70pub struct Page<'a, P> {71    /// The movie the page is about.72    pub movie: &'a Movie,73    /// The store the logo and the strip's posters come from.74    pub posters: &'a RefCell<P>,75    /// Whether the loading state has lifted the logo off the page, so the76    /// head leaves its box empty.77    pub lifted: bool,78    /// Whether the page holds focus, or the browser's strip over it does.79    pub held: bool,80}8182impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Page<'_, P> {83    type State = ();8485    fn draw(86        &self,87        _state: &Self::State,88        renderer: &Renderer,89        _theme: &Theme,90        bounds: Rectangle,91        _cursor: mouse::Cursor,92    ) -> Vec<canvas::Geometry<Renderer>> {93        let movie = self.movie;94        let mut frame = canvas::Frame::new(renderer, bounds.size());95        let posters = &mut *self.posters.borrow_mut();9697        let column = bounds.width * COLUMN;98        let blocks = Blocks::of(99            movie,100            column,101            bounds.width - 2.0 * MARGIN,102            bounds.height * TOP,103        );104        let offset = blocks.scroll(movie, bounds.height);105106        header::title(107            &mut frame,108            posters,109            &header::Title {110                library: &movie.library,111                logo: &movie.logo,112                name: &movie.title,113                at: blocks.title.at(offset),114                logo_box: (LOGO_WIDTH, LOGO_HEIGHT),115                width: column,116                size: look::TITLE,117                lifted: self.lifted,118            },119        );120121        // The tagline is the film's own words, so it draws in the italic,122        // as a card's tagline does.123        // The facts line is one line, cut with an ellipsis where a long124        // list of genres runs past the column, so it never ends on a comma.125        let facts = text::measured_cut(&movie.facts, look::FACTS, column);126        for (block, content, face, color) in [127            (128                blocks.facts,129                &facts,130                (look::FACTS, iced_winit::core::Font::with_name(look::FONT)),131                look::muted(),132            ),133            (134                blocks.tagline,135                &movie.tagline,136                (look::TAGLINE, look::ITALIC),137                look::text(),138            ),139        ] {140            text::line_in(&mut frame, content, block.at(offset), face, color, column);141        }142143        ratings::draw(&mut frame, &movie.ratings, blocks.ratings.at(offset));144145        text::block(146            &mut frame,147            &movie.plot,148            blocks.plot.at(offset),149            look::PLOT,150            look::text(),151            column,152            PLOT_LINES,153        );154155        // The page's focus while the page holds it, and none while the156        // browser's strip does, so one mark draws on the glass.157        let focus = self.held.then_some(movie.focus);158159        buttons::draw(160            &mut frame,161            movie.buttons(),162            blocks.buttons.at(offset),163            match focus {164                Some(Focus::Buttons(index)) => Some(index),165                _ => None,166            },167        );168169        if let (Some(set), Some(block)) = (&movie.set, blocks.strip) {170            strip::draw(171                &mut frame,172                posters,173                &strip::Strip {174                    members: &set.members,175                    current: Some(set.current),176                    focus: match focus {177                        Some(Focus::Strip(index)) => Some(index),178                        _ => None,179                    },180                    heading: &set.heading,181                    library: &movie.library,182                    last: None,183                    lines: card::LINES,184                    headed: false,185                    region: area(186                        MARGIN,187                        block.top - offset,188                        bounds.width - 2.0 * MARGIN,189                        strip::height(card::LINES),190                    ),191                },192            );193        }194195        for (index, (band, block)) in movie196            .franchises197            .bands()198            .iter()199            .zip(&blocks.franchises)200            .enumerate()201        {202            strip::draw(203                &mut frame,204                posters,205                &strip::Strip {206                    members: &band.members,207                    current: band.current,208                    focus: match focus {209                        Some(Focus::Franchise(strip, Place::Member(member))) if strip == index => {210                            Some(member)211                        }212                        _ => None,213                    },214                    heading: &band.heading,215                    library: &movie.library,216                    last: None,217                    lines: card::LINES,218                    headed: matches!(219                        focus,220                        Some(Focus::Franchise(strip, Place::Heading)) if strip == index221                    ),222                    region: area(223                        MARGIN,224                        block.top - offset,225                        bounds.width - 2.0 * MARGIN,226                        strip::height(card::LINES),227                    ),228                },229            );230        }231232        for (index, (band, block)) in movie233            .stripes234            .bands()235            .iter()236            .zip(&blocks.stripes)237            .enumerate()238        {239            people::draw(240                &mut frame,241                posters,242                &people::Stripe {243                    people: &band.faces,244                    focus: match focus {245                        Some(Focus::Stripe(stripe, slot)) if stripe == index => Some(slot),246                        _ => None,247                    },248                    heading: band.heading,249                    library: &movie.library,250                    region: area(251                        MARGIN,252                        block.top - offset,253                        bounds.width - 2.0 * MARGIN,254                        people::HEIGHT,255                    ),256                },257            );258        }259260        let mut at = Point::new(MARGIN, blocks.foot.top - offset);261        let width = bounds.width - 2.0 * MARGIN;262        for row in movie.foot.rows() {263            at.y += row.lead;264            let color = match row.faint {265                true => look::faint(),266                false => look::text(),267            };268            text::line(&mut frame, row.prefix, at, row.size, look::faint(), width);269            let after = Point::new(at.x + row.indent(), at.y);270            at.y += text::line(271                &mut frame,272                row.content,273                after,274                row.size,275                color,276                width - row.indent(),277            );278        }279280        vec![frame.into_geometry()]281    }282}283284// One block of the page: where it starts in the stack's own space, and285// how tall it is.286#[derive(Debug, Clone, Copy, PartialEq)]287struct Block {288    top: f32,289    height: f32,290}291292impl Block {293    // Where the block draws at this scroll.294    fn at(&self, offset: f32) -> Point {295        Point::new(MARGIN, self.top - offset)296    }297298    fn bottom(&self) -> f32 {299        self.top + self.height300    }301302    fn region(&self) -> Rectangle {303        area(0.0, self.top, 0.0, self.height)304    }305}306307// Every block of the page, measured before anything draws. A movie with308// no logo takes the height of its title text. A movie with a logo takes309// the box the logo draws in, so the blocks under it stand still while the310// decode lands.311struct Blocks {312    title: Block,313    facts: Block,314    ratings: Block,315    tagline: Block,316    plot: Block,317    buttons: Block,318    strip: Option<Block>,319    franchises: Vec<Block>,320    stripes: Vec<Block>,321    foot: Block,322    content: f32,323}324325impl Blocks {326    fn of(movie: &Movie, column: f32, width: f32, top: f32) -> Self {327        let mut cursor = Stack::new(Point::new(MARGIN, top), GAP);328        let mut place = |lead: f32, height: f32| {329            cursor.skip(lead);330            let block = Block {331                top: cursor.at().y,332                height,333            };334            cursor.add(height);335            block336        };337338        let title = place(339            0.0,340            match movie.logo.is_empty() {341                true => lines(&movie.title, look::TITLE, column, 0),342                false => LOGO_HEIGHT,343            },344        );345        let facts = place(0.0, text::height(1, look::FACTS));346        let ratings = place(347            0.0,348            match movie.ratings.is_empty() {349                true => 0.0,350                false => ratings::HEIGHT,351            },352        );353        let tagline = place(0.0, lines(&movie.tagline, look::TAGLINE, column, 0));354        let plot = place(0.0, lines(&movie.plot, look::PLOT, column, PLOT_LINES));355        let buttons = place(0.0, buttons::HEIGHT);356        let strip = movie357            .set358            .as_ref()359            .map(|_| place(0.0, strip::height(card::LINES)));360        let franchises: Vec<Block> = movie361            .franchises362            .bands()363            .iter()364            .map(|_| place(STRIPE_LEAD, strip::height(card::LINES)))365            .collect();366        let stripes: Vec<Block> = movie367            .stripes368            .bands()369            .iter()370            .map(|_| place(STRIPE_LEAD, people::HEIGHT))371            .collect();372373        let foot = place(STRIPE_LEAD, movie.foot.height(width));374        let last = stripes375            .last()376            .or(franchises.last())377            .copied()378            .or(strip)379            .unwrap_or(buttons);380        let content = match foot.height > 0.0 {381            true => foot.bottom() + FOOT,382            false => match stripes.is_empty() && franchises.is_empty() {383                true => last.bottom(),384                false => last.bottom() + FOOT,385            },386        };387        Self {388            title,389            facts,390            ratings,391            tagline,392            plot,393            buttons,394            strip,395            franchises,396            stripes,397            foot,398            content,399        }400    }401402    // How far the page has scrolled: enough to hold the focused403    // block and the head of the block under it, so a person sees that404    // there is more below.405    fn scroll(&self, movie: &Movie, height: f32) -> f32 {406        let block = match movie.focus {407            Focus::Stripe(stripe, _) => self.stripes.get(stripe).copied(),408            Focus::Franchise(strip, _) => self.franchises.get(strip).copied(),409            Focus::Strip(_) => self.strip,410            Focus::Buttons(_) => None,411        }412        .unwrap_or(self.buttons);413        let tail = (self.after(block) - block.bottom() + TRAIL * people::HEIGHT)414            .min(self.content - block.bottom());415        stack::offset(block.region(), tail, self.content, height)416    }417418    // The top of the first block under this one, and the foot of419    // the page where nothing follows it.420    fn after(&self, block: Block) -> f32 {421        self.strip422            .into_iter()423            .chain(self.franchises.iter().copied())424            .chain(self.stripes.iter().copied())425            .map(|under| under.top)426            .find(|top| *top > block.top)427            .unwrap_or(self.content)428    }429}430431// The height a block of text takes at this size and width, cut to `cap`432// lines where the caller cuts it, and zero where the movie carries no433// such line.434fn lines(content: &str, size: f32, width: f32, cap: usize) -> f32 {435    let taken = text::lines(content, size, width);436    match cap {437        0 => text::height(taken, size),438        cap => text::height(taken.min(cap), size),439    }440}441442#[cfg(test)]443mod tests;
src/screens/person.rs 98.8%
1// One person's page. It reads down: the headshot at the left, and2// beside it the name, the dates, and the biography; under both, a wall of3// every title the person is credited in. Focus is always on a slot of4// that wall, so a select opens a title's page, which carries stripes of5// its own.67mod page;89use std::cell::RefCell;10use std::convert::Infallible;1112use iced_wgpu::Renderer;13use iced_winit::core::{Element, Length, Theme};1415use super::slots::Slots;16use super::{Step, facts};17use crate::catalog::{self, Query, Source};18use crate::posters::Posters;19use crate::views::wall;2021// The file every contributor entry holds their headshot in, and22// the file it holds their biography in.23const HEADSHOT: &str = "headshot.jpg";24const BIOGRAPHY: &str = "biography.txt";2526// How much of a biography file the page reads. The page draws a27// few lines of it, so a file longer than this is cut before it is held.28const BIOGRAPHY_CHARS: usize = 2_000;2930/// The person's page: the entry the page opened from, the files it31/// draws, the wall of works, and where focus is on that wall.32#[derive(Debug)]33pub struct Person {34    /// The library the page opened from, as `namespace/name`.35    pub library: String,36    /// The person's directory in that library.37    pub path: String,38    /// The name a person reads.39    pub name: String,40    /// The born and died dates on one line, empty where the entry41    /// holds neither.42    pub dates: String,43    /// The library the headshot resolves against, empty where no44    /// library holds one.45    pub headshot_library: String,46    /// The path of the headshot file, empty where no library holds47    /// one.48    pub headshot: String,49    /// The library the biography file resolves against, empty where50    /// no library holds one.51    pub biography_library: String,52    /// The path of the biography file, empty where no library holds53    /// one.54    pub biography_path: String,55    /// The biography, once the page read it off the volume. The56    /// page cuts it to a few lines.57    pub biography: String,58    /// The person's works as the slots of a `Person` query, drawn through59    /// the same code path as the library wall, with the focus inside it.60    pub works: Slots,61}6263impl Person {64    /// Read one person's page, or nothing where the library holds65    /// no entry under that directory. Focus lands on the first work.66    pub fn open(library: &str, path: &str, source: &mut dyn Source) -> Option<Self> {67        let entry = source.person(library, path)?;68        let works = Slots::open(69            Query::Person {70                library: library.to_string(),71                path: path.to_string(),72            },73            source,74        );75        Some(Self {76            library: library.to_string(),77            path: path.to_string(),78            name: entry.name,79            dates: dates(&entry.born, &entry.died),80            headshot: beside(&entry.headshot_library, &entry.headshot_path, HEADSHOT),81            headshot_library: entry.headshot_library,82            biography_path: beside(&entry.biography_library, &entry.biography_path, BIOGRAPHY),83            biography_library: entry.biography_library,84            biography: String::new(),85            works,86        })87    }8889    /// Read the page again, because the scanner can write the90    /// entry or its credits while the page is open. Focus stays where it91    /// was, inside what the read answered.92    pub fn reread(&mut self, source: &mut dyn Source) {93        let Some(fresh) = Self::open(&self.library, &self.path, source) else {94            return;95        };96        let focus = self.works.focus;97        *self = fresh;98        self.works.focus = focus.min(self.works.items.len().saturating_sub(1));99    }100101    /// Read the biography off the library's volume. It is a file102    /// beside the entry and not a column of the catalog, so it arrives103    /// through the store that resolves every other path of that volume.104    pub fn read_biography<P: Posters>(&mut self, posters: &P) {105        self.biography = String::new();106        if self.biography_path.is_empty() {107            return;108        }109        let Some(file) = posters.file(&self.biography_library, &self.biography_path) else {110            return;111        };112        let Ok(text) = std::fs::read_to_string(file) else {113            return;114        };115        self.biography = text.chars().take(BIOGRAPHY_CHARS).collect();116    }117118    /// Fold one press in. The arrows move across the wall and select119    /// opens the title's own page. Up from the first row moves nothing,120    /// which is how a press reaches the browser's strip.121    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {122        if key == "up" && self.works.focus < wall::COLUMNS {123            return Step::Still;124        }125        self.works.key(key, source)126    }127128    /// The library and the backdrop the focused work's page draws129    /// over, so the store decodes it while focus rests.130    pub fn resting(&self, source: &mut dyn Source) -> Option<(String, String)> {131        self.works.resting(source)132    }133134    /// The view: the head and the wall of works, on one canvas.135    pub fn view<'a, P: Posters>(136        &'a self,137        posters: &'a RefCell<P>,138        held: bool,139    ) -> Element<'a, Infallible, Theme, Renderer> {140        iced_widget::canvas(page::Page {141            person: self,142            posters,143            held,144        })145        .width(Length::Fill)146        .height(Length::Fill)147        .into()148    }149}150151/// The library and the path of a person's headshot, both empty where no152/// library holds one. A person's strip draws it on the slot about them.153pub fn headshot(entry: &catalog::Person) -> (String, String) {154    (155        entry.headshot_library.clone(),156        beside(&entry.headshot_library, &entry.headshot_path, HEADSHOT),157    )158}159160// The path of one file inside a person's entry, and nothing where161// no library holds that file.162fn beside(library: &str, path: &str, file: &str) -> String {163    match library.is_empty() {164        true => String::new(),165        false => format!("{path}/{file}"),166    }167}168169/// The born and died years on one line, and nothing where the170/// entry holds neither date.171pub fn dates(born: &str, died: &str) -> String {172    match (facts::year(born), facts::year(died)) {173        ("", "") => String::new(),174        (born, "") => format!("born {born}"),175        ("", died) => format!("died {died}"),176        (born, died) => format!("{born} to {died}"),177    }178}179180#[cfg(test)]181mod tests;
src/screens/person/page.rs 95.6%
1// The person's page as one canvas: a head of a fixed height at the2// top, holding the headshot and the words beside it, and under it the3// region the wall of works scrolls in. The head stands still, because it4// carries the person and not the work focus is on.56use std::cell::RefCell;7use std::convert::Infallible;89use iced_wgpu::Renderer;10use iced_widget::canvas;11use iced_winit::core::{Point, Rectangle, Theme, mouse};1213use super::Person;14use crate::look;15use crate::posters::Posters;16use crate::views::stack::Stack;17use crate::views::{area, card, extent, text, wall};1819// The margin at both sides of the head.20const MARGIN: f32 = 120.0;2122// The space over the headshot, and the space under it before the23// wall's region.24const TOP: f32 = 56.0;25const FOOT: f32 = 36.0;2627// The height of the headshot, and the space between it and the28// words beside it.29const HEADSHOT: f32 = 300.0;30const BESIDE: f32 = 40.0;3132// The space between two blocks of the words beside the headshot.33const GAP: f32 = 12.0;3435// The lines the biography is cut to.36const BIOGRAPHY_LINES: usize = 4;3738// The lines under each work: the card's own two.39const LINES: usize = card::LINES;4041/// The width of the headshot: the height at the wall's poster42/// ratio.43pub fn headshot_width() -> f32 {44    HEADSHOT / wall::POSTER45}4647/// The height the head takes, whatever the entry holds, so the48/// wall under it starts at the same place on every person.49pub fn head() -> f32 {50    TOP + HEADSHOT + FOOT51}5253/// The part of the frame the wall scrolls in: under the head, and under54/// the space that keeps the first row's mark off it. The scroll clamps55/// to this region, so the last row's lines end inside it.56pub fn region(bounds: Rectangle) -> Rectangle {57    let top = (head() + wall::HEAD).min(bounds.height);58    area(bounds.x, bounds.y + top, bounds.width, bounds.height - top)59}6061/// The page as one canvas.62pub struct Page<'a, P> {63    /// The person the page is about.64    pub person: &'a Person,65    /// The store the headshot and the posters come from.66    pub posters: &'a RefCell<P>,67    /// Whether the page holds focus, or the browser's strip over it does.68    pub held: bool,69}7071impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Page<'_, P> {72    type State = ();7374    fn draw(75        &self,76        _state: &Self::State,77        renderer: &Renderer,78        _theme: &Theme,79        bounds: Rectangle,80        _cursor: mouse::Cursor,81    ) -> Vec<canvas::Geometry<Renderer>> {82        let person = self.person;83        let mut frame = canvas::Frame::new(renderer, bounds.size());84        let posters = &mut *self.posters.borrow_mut();8586        let headshot = area(MARGIN, TOP, headshot_width(), HEADSHOT);87        drawn(&mut frame, posters, person, headshot);8889        let left = headshot.x + headshot.width + BESIDE;90        let column = bounds.width - left - MARGIN;91        let mut words = Stack::new(Point::new(left, TOP), GAP);92        for (content, size, color, cap) in [93            (&person.name, look::TITLE, look::text(), 1),94            (&person.dates, look::FACTS, look::muted(), 1),95            (&person.biography, look::PLOT, look::text(), BIOGRAPHY_LINES),96        ] {97            let taken = text::block(&mut frame, content, words.at(), size, color, column, cap);98            words.add(taken);99        }100101        let region = region(bounds);102        // The clip reaches up into the space over the first row, so the103        // mark of a focused slot there draws whole and the head stays104        // clear.105        let clip = area(106            region.x,107            region.y - wall::HEAD,108            region.width,109            region.height + wall::HEAD,110        );111        frame.with_clip(clip, |frame| {112            person.works.draw(frame, posters, region, self.held, LINES);113        });114115        vec![frame.into_geometry()]116    }117}118119// The headshot in its box, and the ground under it until the120// decode lands. The art draws band by band, because the renderer uploads121// a large image on a later frame and this client draws no later frame122// until an event.123fn drawn<P: Posters>(124    frame: &mut canvas::Frame<Renderer>,125    posters: &mut P,126    person: &Person,127    slot: Rectangle,128) {129    if !person.headshot.is_empty()130        && let Some(art) = posters.poster(131            &person.headshot_library,132            &person.headshot,133            slot.width as u32,134            slot.height as u32,135        )136    {137        for (band, handle) in art.bands(slot) {138            frame.draw_image(band, canvas::Image::new(handle));139        }140        return;141    }142    frame.fill_rectangle(slot.position(), extent(slot), look::slot());143}144145#[cfg(test)]146mod tests {147    use super::*;148149    const WIDTH: f32 = 1920.0;150    const HEIGHT: f32 = 1080.0;151152    fn frame() -> Rectangle {153        area(0.0, 0.0, WIDTH, HEIGHT)154    }155156    #[test]157    fn the_head_takes_the_top_of_the_frame_and_the_wall_takes_the_rest() {158        let region = region(frame());159        assert_eq!(region.y, head() + wall::HEAD);160        assert_eq!(region.y + region.height, HEIGHT);161        assert!(head() < HEIGHT / 2.0, "{}", head());162    }163164    #[test]165    fn the_headshot_keeps_the_walls_poster_ratio() {166        assert_eq!(HEADSHOT / headshot_width(), wall::POSTER);167    }168169    #[test]170    fn a_row_of_posters_fits_under_the_head() {171        let cells = wall::lined(WIDTH, wall::POSTER, wall::COLUMNS, LINES);172        assert!(cells.height <= region(frame()).height);173    }174175    #[test]176    fn the_last_rows_second_line_ends_inside_the_region() {177        let region = region(frame());178        let cells = wall::lined(region.width, wall::POSTER, wall::COLUMNS, LINES);179        let count = 40;180        let last = count - 1;181        let offset = wall::scrolled(last, count, wall::COLUMNS, &cells, region.height);182        let slot = wall::slot(&cells, last, offset, wall::COLUMNS);183        let under = wall::under(&cells, slot);184        assert!(under.y + under.height <= region.height, "{under:?}");185    }186}
src/screens/series.rs 93.4%
1// A series' page. One screen holds the whole series: a header over the2// backdrop, and under it a wall of episode stills in aired order, with a3// divider before each season's first row, a strip for each franchise the4// series belongs to after the last season, and the stripes of credited5// people after those. Focus is on a still or on a headshot, and the6// header stays at the top of the frame, because it shows the focused7// episode's facts and plot.89mod layout;10mod page;11mod seasons;1213use std::cell::RefCell;14use std::convert::Infallible;1516use iced_wgpu::Renderer;17use iced_winit::core::{Element, Rectangle, Theme};1819use super::franchise::strips::{self, Move, Place, Strips};20use super::movie::franchise_press;21use super::{Screen, Step, facts, foot, person, stripes};22use crate::catalog::draw::Date;23use crate::catalog::{Selection, SeriesDetails, Source};24use crate::focus::{self, Run};25use crate::posters::Posters;26use crate::views::curtain::{Curtain, Head, Layer};27use crate::views::{Card, layers, rail, ratings};2829/// How many stills a row of the episode wall holds. A still is wider30/// than a poster, so the wall holds fewer across.31pub const COLUMNS: usize = 4;3233/// Where focus is on the page.34#[derive(Debug, Clone, Copy, PartialEq, Eq)]35pub enum Focus {36    /// One still of the episode wall.37    Still(usize),38    /// One bar of the seasons rail.39    Rail(usize),40    /// One rung of the franchise strips: which strip, and the heading or41    /// the member in it.42    Franchise(usize, Place),43    /// One headshot of one stripe: the stripe, and the slot in it.44    Stripe(usize, usize),45}4647/// One season, as the divider before its first row draws it.48#[derive(Debug, Clone, PartialEq, Eq)]49pub struct Season {50    /// The aired season number.51    pub number: i64,52    /// The heading at the divider's left: the season, and its year where53    /// the first episode of the season holds one.54    pub name: String,55    /// Where the season's episodes sit in the wall's one order.56    pub run: Run,57}5859/// One episode, as a still of the wall and as the facts the header shows60/// while that still has focus.61#[derive(Debug, Clone, PartialEq, Eq)]62pub struct Still {63    /// The episode's id inside its library, which its files are read by.64    pub id: String,65    /// The aired season number. A play request carries it.66    pub season: i64,67    /// The aired episode number. A play request carries it.68    pub episode: i64,69    /// The name a person reads. A still with no art shows it.70    pub name: String,71    /// The card's first line under the still, the episode's own name,72    /// cut by the shaper at the read to one cell of the wall.73    pub fitted: String,74    /// The card's second line: the episode number in the page's own75    /// spelling, then the runtime, cut to the same cell.76    pub under: String,77    /// The header's line while this still has focus: the season and78    /// episode numbers and the name. The header cuts it to one line.79    pub facts: String,80    /// The header's second line while this still has focus: the runtime81    /// and the air date. It is a line of its own so a long name never82    /// pushes the date out of the header.83    pub aired: String,84    /// The episode's plot. The header draws it in place of the series'85    /// plot while this still has focus.86    pub plot: String,87    /// The path the still draws: the episode's own still, or the art of88    /// its series where the catalog holds no still for the episode.89    /// Empty where the series holds no art either.90    pub art: String,91}9293impl Card for Still {94    fn art(&self) -> &str {95        &self.art96    }9798    fn name(&self) -> &str {99        &self.name100    }101102    fn fitted(&self) -> &str {103        &self.fitted104    }105106    fn under(&self) -> &str {107        &self.under108    }109}110111/// The series page: the words it draws, the art it draws them over, its112/// episodes in aired order, and where focus is. Every line is built once113/// here, at the read, and not on every frame.114#[derive(Debug)]115pub struct Series {116    /// The catalog's library column, `namespace/name`.117    pub library: String,118    /// The series' id inside that library.119    pub id: String,120    /// The name a person reads. The page draws it where the series has no121    /// logo.122    pub title: String,123    /// The path of the logo file, empty where the series has none.124    pub logo: String,125    /// The path of the backdrop file, empty where the series has none.126    pub backdrop: String,127    /// The year, the season count, and the content rating, on one line.128    pub facts: String,129    /// The scores the ratings line draws, in the order it draws them. They130    /// are the series' own, so the line stays while a still holds focus.131    pub ratings: Vec<ratings::Score>,132    /// The tagline, empty where the sidecar named none.133    pub tagline: String,134    /// The series' plot. The header draws it while no still holds focus:135    /// on a series with no episodes, and while a stripe holds focus.136    pub plot: String,137    /// The franchises the series belongs to, one strip each, between the138    /// last season and the stripes.139    pub franchises: Strips,140    /// The credited people, as the stripes after the last season.141    pub stripes: stripes::Stripes,142    /// The studios the series' body names. The foot draws them whatever143    /// episode holds focus.144    pub studios: Vec<String>,145    /// The studios and the focused episode's files, as the block after the146    /// last stripe.147    pub foot: foot::Foot,148    /// The seasons, in aired order, one divider each.149    pub seasons: Vec<Season>,150    /// Every episode of the series, in aired order.151    pub stills: Vec<Still>,152    /// The bars of the rail beside the wall, one per season or per range153    /// of seasons, and none on a series of four seasons or fewer.154    pub bars: Vec<rail::Bar>,155    /// The still the wall last held, which a left press on the rail156    /// returns to.157    entered: usize,158    /// Where focus is.159    pub focus: Focus,160}161162impl Series {163    /// Read one series' page, or nothing where the library holds no164    /// series under that id. Focus lands on the first episode, so the165    /// header shows that episode's plot from the start, and a film is166    /// two presses from the wall.167    pub fn open(library: &str, id: &str, source: &mut dyn Source) -> Option<Self> {168        let mut page = Self::read(library, id, source)?;169        page.refoot(source);170        Some(page)171    }172173    /// The page with focus on the episode these aired numbers name, which174    /// is where a select on an episode still lands. Focus stays on the first175    /// episode where the series holds no such episode.176    pub fn open_at(177        library: &str,178        id: &str,179        numbers: (i64, i64),180        source: &mut dyn Source,181    ) -> Option<Self> {182        let mut page = Self::read(library, id, source)?;183        let (season, episode) = numbers;184        if let Some(index) = page185            .stills186            .iter()187            .position(|still| still.season == season && still.episode == episode)188        {189            page.focus = Focus::Still(index);190        }191        page.refoot(source);192        Some(page)193    }194195    // The page before its foot is read, with focus on the first episode.196    fn read(library: &str, id: &str, source: &mut dyn Source) -> Option<Self> {197        let details = source.series(library, id)?;198        let (stills, seasons) =199            seasons::wall_of(source.episodes(library, id), &Date::today().iso());200        let bars = seasons::bars(&seasons, layout::rail_region());201        Some(Self {202            library: library.to_string(),203            id: id.to_string(),204            title: details.title.clone(),205            logo: details.logo.clone(),206            backdrop: details.backdrop.clone(),207            facts: facts_of(&details),208            ratings: ratings::scores(&details.ratings),209            tagline: details.tagline.clone(),210            plot: details.plot.clone(),211            franchises: Strips::of(library, id, source),212            stripes: stripes::Stripes::of(source.credits(library, id)),213            studios: details.studios.clone(),214            foot: foot::Foot::default(),215            seasons,216            stills,217            bars,218            entered: 0,219            focus: Focus::Still(0),220        })221    }222223    // The foot follows the focused episode, so its lines change with focus224    // as the header's do. Focus on a stripe keeps the lines of the episode225    // the wall last held.226    fn refoot(&mut self, source: &mut dyn Source) {227        let Some(id) = self.focused().map(|still| still.id.clone()) else {228            return;229        };230        self.foot = foot::Foot::of(&self.studios, &source.files(&self.library, &id));231    }232233    /// Read the page again, because the scanner can write the series or234    /// its episodes while the page is open. Focus stays where it was,235    /// inside what the read answered.236    pub fn reread(&mut self, source: &mut dyn Source) {237        let Some(fresh) = Self::open(&self.library, &self.id, source) else {238            return;239        };240        let focus = self.focus;241        *self = fresh;242        self.focus = self.hold(focus);243    }244245    // Where focus lands after a re-read: where it was, unless the246    // wall or the stripe it was on grew shorter.247    fn hold(&self, focus: Focus) -> Focus {248        match focus {249            Focus::Still(index) => Focus::Still(index.min(self.stills.len().saturating_sub(1))),250            Focus::Rail(..) if self.bars.is_empty() => Focus::Still(0),251            Focus::Rail(bar) => Focus::Rail(bar.min(self.bars.len() - 1)),252            Focus::Franchise(strip, place) => match self.franchises.held((strip, place)) {253                Some((strip, place)) => Focus::Franchise(strip, place),254                None => Focus::Still(0),255            },256            Focus::Stripe(stripe, slot) => match self.stripes.held((stripe, slot)) {257                Some((stripe, slot)) => Focus::Stripe(stripe, slot),258                None => Focus::Still(0),259            },260        }261    }262263    /// The still the header draws the facts and the plot of, or264    /// nothing while a stripe holds focus and on a series whose episodes265    /// have not landed.266    pub fn focused(&self) -> Option<&Still> {267        match self.focus {268            Focus::Still(index) => self.stills.get(index),269            Focus::Rail(..) | Focus::Franchise(..) | Focus::Stripe(..) => None,270        }271    }272273    /// Fold one press in. Left and right move inside one season, up and274    /// down move by a row and cross the dividers, down from the last row275    /// reaches the franchise strips and then the stripes, and select plays276    /// the episode and the rest of its season.277    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {278        let held = self.focus;279        let step = match self.focus {280            Focus::Still(index) => self.on_still(index, key, source),281            Focus::Rail(bar) => self.on_rail(bar, key, source),282            Focus::Franchise(strip, place) => self.on_franchise((strip, place), key, source),283            Focus::Stripe(stripe, slot) => self.on_stripe((stripe, slot), key, source),284        };285        // An up that moved nothing is the browser's: the strip over every286        // screen takes focus on it.287        match key == "up" && self.focus == held && matches!(step, Step::Stay) {288            true => Step::Still,289            false => step,290        }291    }292293    fn on_still(&mut self, index: usize, key: &str, source: &mut dyn Source) -> Step {294        if key != "enter" {295            self.entered = index;296            self.focus = match key {297                "right" => match seasons::onto(self, index) {298                    Some(bar) => Focus::Rail(bar),299                    None => self.moved(index, key),300                },301                _ => self.moved(index, key),302            };303            self.refoot(source);304            return Step::Stay;305        }306        let Some(still) = self.stills.get(index) else {307            return Step::Stay;308        };309        Step::Play {310            library: self.library.clone(),311            selection: Selection::Episode {312                series: self.id.clone(),313                season: still.season,314                episode: still.episode,315            },316        }317    }318319    // Where one press inside the wall lands: a still, or the rung under320    // the wall where down leaves the last row.321    fn moved(&self, index: usize, key: &str) -> Focus {322        let runs: Vec<Run> = self.seasons.iter().map(|season| season.run).collect();323        let moved = focus::sectioned(index, &runs, COLUMNS, key);324        match (key, moved == index) {325            ("down", true) => self.under_wall(moved),326            _ => Focus::Still(moved),327        }328    }329330    // One press while a bar of the rail holds focus. The foot is read331    // again because a left or a select puts focus back on a still.332    fn on_rail(&mut self, bar: usize, key: &str, source: &mut dyn Source) -> Step {333        self.focus = seasons::key(self, bar, key);334        self.refoot(source);335        Step::Stay336    }337338    // The rung under the last row of stills: the first franchise strip,339    // then the first stripe, and the still itself where the page holds340    // neither.341    fn under_wall(&self, index: usize) -> Focus {342        if let Some((strip, place)) = self.franchises.first() {343            return Focus::Franchise(strip, place);344        }345        match self.stripes.first() {346            Some((stripe, slot)) => Focus::Stripe(stripe, slot),347            None => Focus::Still(index),348        }349    }350351    // The rung over the first stripe: the last franchise strip, and the352    // wall's last still where the page holds none.353    fn over_stripes(&self, rung: stripes::Rung) -> Focus {354        if let Some((strip, place)) = self.franchises.last() {355            return Focus::Franchise(strip, place);356        }357        match self.stills.len() {358            0 => Focus::Stripe(rung.0, rung.1),359            count => Focus::Still(count - 1),360        }361    }362363    // One press on a franchise strip. A select on the heading opens the364    // franchise's page, and a select on a member opens that member's, the365    // way it does from a film's page.366    fn on_franchise(&mut self, rung: strips::Rung, key: &str, source: &mut dyn Source) -> Step {367        if key == "enter" {368            return franchise_press(&self.franchises, rung, source);369        }370        self.focus = match self.franchises.key(rung, key) {371            Move::To((strip, place)) => Focus::Franchise(strip, place),372            Move::Above => match self.stills.len() {373                0 => Focus::Franchise(rung.0, rung.1),374                count => Focus::Still(count - 1),375            },376            Move::Below => match self.stripes.first() {377                Some((stripe, slot)) => Focus::Stripe(stripe, slot),378                None => Focus::Franchise(rung.0, rung.1),379            },380        };381        self.refoot(source);382        Step::Stay383    }384385    // One press on a stripe. Select opens the person's page, and a386    // name the credits could not resolve opens nothing.387    fn on_stripe(&mut self, rung: stripes::Rung, key: &str, source: &mut dyn Source) -> Step {388        if key == "enter" {389            let Some(face) = self.stripes.face(rung) else {390                return Step::Stay;391            };392            if face.contributor.is_empty() {393                return Step::Stay;394            }395            return match person::Person::open(&self.library, &face.contributor, source) {396                Some(page) => Step::Open(Screen::Person(Box::new(page))),397                None => Step::Stay,398            };399        }400        self.focus = match self.stripes.key(rung, key) {401            Some((stripe, slot)) => Focus::Stripe(stripe, slot),402            // Up from the first stripe returns to the last franchise403            // strip, then to the wall's last row, and stays where the404            // series holds neither.405            None => self.over_stripes(rung),406        };407        self.refoot(source);408        Step::Stay409    }410411    /// The view: the backdrop behind the header, the scrim over it, the412    /// header and the wall over both, and the loading state's curtain413    /// over the page while that state runs.414    pub fn view<'a, P: Posters>(415        &'a self,416        posters: &'a RefCell<P>,417        curtain: Option<Curtain>,418        held: bool,419    ) -> Element<'a, Infallible, Theme, Renderer> {420        layers::Page {421            library: &self.library,422            art: &self.backdrop,423            posters,424            ground: layers::Ground::Below(layout::head()),425            front: page::Page {426                series: self,427                posters,428                lifted: curtain.is_some(),429                held,430            },431            over: curtain.map(|curtain| Layer {432                library: &self.library,433                art: &self.backdrop,434                logo: &self.logo,435                name: &self.title,436                posters,437                head: self,438                curtain,439            }),440        }441        .view()442    }443}444445impl Head for Series {446    fn head(&self, bounds: Rectangle) -> Rectangle {447        page::head(bounds)448    }449}450451/// The facts line of one series: the year, the season count, the452/// content rating, and the genres, the way a film's line reads.453pub(crate) fn facts_of(details: &SeriesDetails) -> String {454    facts::joined(&[&facts_without_genres(details), &details.genres.join(", ")])455}456457/// The same line without the genres, for the home page's banner, which458/// draws the genres on a line of its own.459pub(crate) fn facts_without_genres(details: &SeriesDetails) -> String {460    facts::joined(&[461        facts::year(&details.released),462        &seasons_of(details.seasons),463        &details.rating,464    ])465}466467// The season count as a person reads it, and nothing at all for a series468// whose episodes have not landed yet.469pub(crate) fn seasons_of(seasons: i64) -> String {470    match seasons {471        0 => String::new(),472        1 => "1 season".to_string(),473        count => format!("{count} seasons"),474    }475}476477#[cfg(test)]478mod tests;
src/screens/series/layout.rs 99.3%
1// The series page's geometry: a header of a fixed height at the top, and2// under it the region the wall of stills scrolls in. The header stays3// because it carries the focused episode's line and its plot, and a person4// reads them while moving across the wall. Every measure here is pure over5// numbers, so the fit at 1080 and the scroll are tested without a window.67use super::{COLUMNS, Focus, Season};8use crate::look;9use crate::views::{10    REACH, area, card, clip_marked, divider, people, ratings, scroll, stack, strip, text, wall,11};12use iced_winit::core::Rectangle;1314/// The space between the foot of the header and the first divider.15pub const HEAD: f32 = 20.0;1617/// The space over the header's first block.18pub const TOP: f32 = 28.0;1920/// The space between the plot's last line and the first row of stills,21/// so the header's text never touches the wall.22pub const FOOT: f32 = 28.0;2324/// The space between two blocks of the header.25pub const GAP: f32 = 14.0;2627/// The box a logo draws in, at the proportions the metadata tools write a28/// logo file in.29pub const LOGO_WIDTH: f32 = 460.0;30pub const LOGO_HEIGHT: f32 = 96.0;3132/// The lines the header cuts the episode's plot to.33pub const PLOT_LINES: usize = 2;3435/// The space between the foot of the wall and the first stripe,36/// and between two stripes.37pub const STRIPE_GAP: f32 = 24.0;3839/// The height one franchise strip takes on the page. Its slots draw the40/// card every other strip draws, which is two lines.41pub fn strip_height() -> f32 {42    strip::height(card::LINES)43}4445// How much of the row under the focused one the scroll keeps in view, as46// a share of a row, so a person sees that there is more below.47const TRAIL: f32 = 0.25;4849/// The part of the frame the header draws in: the height its blocks take,50/// whatever this series carries, so the wall under it starts at the same51/// place on every series.52pub fn header(bounds: Rectangle) -> Rectangle {53    area(bounds.x, bounds.y, bounds.width, head().min(bounds.height))54}5556// The height of the screen the browser is drawn for. The rail's bars57// are built at the read, before any frame exists, so the slots are58// counted at this height the way a card's lines are cut at the screen's59// width.60const SCREEN: f32 = 1080.0;6162// The region the rail's bars are counted against at the read. The63// header is one height on every series, so this is the region drawn on64// a screen of SCREEN, and a shorter window draws the same bars in a65// shorter rail.66pub fn rail_region() -> Rectangle {67    region(area(0.0, 0.0, 0.0, SCREEN))68}6970// The part of the frame the rail draws in. The mark on a focused bar71// draws outside the bar's box, so a clip of the region alone cuts the72// stroke on the top bar and on the bar at the foot.73pub fn rail_clip(region: Rectangle) -> Rectangle {74    clip_marked(region)75}7677/// The part of the frame the wall scrolls in, under the header.78pub fn region(bounds: Rectangle) -> Rectangle {79    let header = header(bounds);80    area(81        bounds.x,82        bounds.y + header.height,83        bounds.width,84        bounds.height - header.height,85    )86}8788/// The height the header's blocks take with every one of them present.89/// Each block is cut to its own lines, so this is the height of any90/// series' header and not of one series'.91pub fn head() -> f32 {92    TOP + LOGO_HEIGHT93        + GAP94        + text::height(1, look::FACTS)95        + GAP96        + ratings::HEIGHT97        + GAP98        + text::height(2, look::FACTS)99        + GAP100        + text::height(PLOT_LINES, look::PLOT)101        + FOOT102}103104/// The box one season's divider draws in, in frame space after the105/// scroll. The divider stands at the top of its own band while that top106/// is in view, holds at the top of the region while the season's rows107/// scroll under it, and the next season's band pushes it off: the rule108/// the jump rail's era labels follow.109pub fn divider_box(region: Rectangle, band: &Band, offset: f32) -> Rectangle {110    stack::held(111        area(112            region.x,113            region.y + band.top - offset,114            region.width,115            band.height,116        ),117        region,118        divider::HEIGHT,119    )120}121122/// The part of the region the stills draw in: under the band a held123/// divider keeps at the top. A still that scrolls into that band draws124/// nothing, so art never crosses the divider, which the renderer would125/// otherwise draw the art over.126pub fn stills(region: Rectangle) -> Rectangle {127    area(128        region.x,129        region.y + divider::HEIGHT,130        region.width,131        (region.height - divider::HEIGHT).max(0.0),132    )133}134135/// One season's place in the wall: the divider, the rows of stills under136/// it, and the height of both.137#[derive(Debug, Clone, Copy, PartialEq)]138pub struct Band {139    /// The top of the divider, in the wall's own space.140    pub top: f32,141    /// The top of the season's first row of stills.142    pub rows_top: f32,143    /// How many rows the season's episodes fill.144    pub rows: usize,145    /// The height of the divider and the rows together.146    pub height: f32,147}148149impl Band {150    /// Whether any part of this band falls inside a region this tall151    /// at this scroll.152    pub fn shows(&self, offset: f32, height: f32) -> bool {153        self.top < offset + height && self.top + self.height > offset154    }155}156157/// The whole wall: the cell the stills draw in, one band for each season,158/// and how long the wall is.159#[derive(Debug, Clone, PartialEq)]160pub struct Layout {161    /// The measures of one still's cell.162    pub cells: wall::Cells,163    /// One band for each season, in aired order.164    pub bands: Vec<Band>,165    /// The top of each franchise strip, in the wall's own space.166    pub franchises: Vec<f32>,167    /// The top of each stripe, in the wall's own space.168    pub stripes: Vec<f32>,169    /// The top of the foot block, in the wall's own space.170    pub foot: f32,171    /// The length of the wall, the gap under the header included.172    pub content: f32,173}174175impl Layout {176    /// The wall for these seasons, with stills of this cell. The first177    /// divider starts a gap below the header, and the gap scrolls away178    /// with the rows.179    pub fn of(180        seasons: &[Season],181        cells: wall::Cells,182        franchises: usize,183        stripes: usize,184        foot: f32,185    ) -> Self {186        let mut bands = Vec::with_capacity(seasons.len());187        let mut top = HEAD;188        for season in seasons {189            // The room under the divider is what the mark of a focused190            // slot in the first row reaches into.191            let rows = scroll::rows(season.run.count, COLUMNS);192            let head = divider::HEIGHT + REACH;193            let height = head + rows as f32 * cells.height;194            bands.push(Band {195                top,196                rows_top: top + head,197                rows,198                height,199            });200            top += height;201        }202        let mut strips = Vec::with_capacity(franchises);203        for _ in 0..franchises {204            top += STRIPE_GAP;205            strips.push(top);206            top += strip_height();207        }208        let mut tops = Vec::with_capacity(stripes);209        for _ in 0..stripes {210            top += STRIPE_GAP;211            tops.push(top);212            top += people::HEIGHT;213        }214        if stripes > 0 || franchises > 0 {215            top += STRIPE_GAP;216        }217        let mut block = top;218        if foot > 0.0 {219            if stripes == 0 && franchises == 0 {220                block += STRIPE_GAP;221            }222            top = block + foot + STRIPE_GAP;223        }224        Self {225            cells,226            bands,227            franchises: strips,228            stripes: tops,229            foot: block,230            content: top,231        }232    }233234    /// How far the wall has scrolled with focus on this still. The wall235    /// stands at its top until the focused row would leave the foot of the236    /// region, and the header above the region never moves.237    pub fn scroll(&self, focus: Focus, seasons: &[Season], height: f32) -> f32 {238        let (region, tail) = match focus {239            // The page passes the still a bar's select lands on instead240            // of the bar, because the layout does not read the rail.241            Focus::Rail(..) => return 0.0,242            Focus::Franchise(strip, _) => match self.franchises.get(strip) {243                Some(top) => {244                    // A franchise strip is never the last block, because245                    // the stripes and the foot follow it, so it pulls246                    // the gap under it into view and no more.247                    let below = self.content - top - strip_height();248                    (area(0.0, *top, 0.0, strip_height()), STRIPE_GAP.min(below))249                }250                None => return 0.0,251            },252            Focus::Stripe(stripe, _) => match self.stripes.get(stripe) {253                Some(top) => {254                    // The last stripe pulls everything under it into view,255                    // because the foot takes no focus of its own.256                    let below = self.content - top - people::HEIGHT;257                    let tail = match stripe + 1 == self.stripes.len() {258                        true => below,259                        false => STRIPE_GAP.min(below),260                    };261                    (area(0.0, *top, 0.0, people::HEIGHT), tail)262                }263                None => return 0.0,264            },265            Focus::Still(index) => {266                let Some(band) = self.band(index, seasons) else {267                    return 0.0;268                };269                let row = (index - seasons[band].run.first) / COLUMNS;270                (271                    area(272                        0.0,273                        self.bands[band].rows_top + row as f32 * self.cells.height,274                        0.0,275                        self.cells.height,276                    ),277                    TRAIL * self.cells.height,278                )279            }280        };281        stack::offset(region, tail, self.content, height)282    }283284    // The band that holds this still, or nothing on a page with no285    // episodes.286    fn band(&self, focus: usize, seasons: &[Season]) -> Option<usize> {287        seasons.iter().position(|season| {288            season.run.count > 0289                && focus >= season.run.first290                && focus < season.run.first + season.run.count291        })292    }293}294295#[cfg(test)]296mod tests;
src/screens/series/page.rs 86.2%
1// The series page's front layer: the header at the top of the frame, and2// the wall of episode stills in the region under it. The header stands3// still, because it carries the focused episode's line and its plot, and4// the wall scrolls inside its own region, clipped to it, so no still and5// no divider draws over the header.6//7// The backdrop and the scrim under this layer cover the header alone, so8// the wall draws on the black ground and no art sits over art.910use std::cell::RefCell;11use std::convert::Infallible;1213use iced_wgpu::Renderer;14use iced_widget::canvas;15use iced_winit::core::{Point, Rectangle, Theme, mouse};1617use super::super::franchise::strips::Place;18use super::layout::{self, Layout};19use super::{COLUMNS, Focus, Series, seasons};20use crate::look;21use crate::posters::Posters;22use crate::views::stack::Stack;23use crate::views::{area, card, divider, header, people, rail, ratings, strip, text, wall};2425// The margin at both sides of the header's text.26const MARGIN: f32 = 120.0;2728// The share of the width the column of text takes. The column ends inside29// the part of the scrim that holds its full shade, so every line reads30// over the art whatever the art holds.31const COLUMN: f32 = 0.42;3233/// The page's front layer as one canvas.34pub struct Page<'a, P> {35    /// The series the page is about.36    pub series: &'a Series,37    /// The store the logo and the stills come from.38    pub posters: &'a RefCell<P>,39    /// Whether the loading state has lifted the logo off the page, so the40    /// head leaves its box empty.41    pub lifted: bool,42    /// Whether the page holds focus, or the browser's strip over it does.43    pub held: bool,44}4546impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Page<'_, P> {47    type State = ();4849    fn draw(50        &self,51        _state: &Self::State,52        renderer: &Renderer,53        _theme: &Theme,54        bounds: Rectangle,55        _cursor: mouse::Cursor,56    ) -> Vec<canvas::Geometry<Renderer>> {57        let series = self.series;58        // The page's focus while the page holds it, and none while the59        // browser's strip does, so one mark draws on the glass.60        let focus = self.held.then_some(series.focus);61        let mut frame = canvas::Frame::new(renderer, bounds.size());62        let posters = &mut *self.posters.borrow_mut();6364        self.header(&mut frame, posters, layout::header(bounds));6566        // The rail takes the right edge of the region, and the wall keeps67        // the rest.68        let whole = layout::region(bounds);69        let region = rail::beside_at(whole, &series.bars, rail::Side::Right);70        let cells = wall::lined(region.width, wall::STILL, COLUMNS, card::LINES);71        let inset = (cells.width - cells.poster_width) / 2.0;72        let width = region.width - 2.0 * inset;73        let layout = Layout::of(74            &series.seasons,75            cells,76            series.franchises.bands().len(),77            series.stripes.bands().len(),78            series.foot.height(width),79        );80        let offset = layout.scroll(seasons::standing(series), &series.seasons, region.height);8182        // The stills draw under the band a held divider keeps, and the83        // dividers over their own clip, because the renderer draws every84        // image of a layer over every fill of it, and a still that reached85        // the band would cross the divider's rule. The band stands over the86        // stills' own region, so it counts as content the wall has87        // scrolled past.88        let stills = layout::stills(region);89        let scrolled = offset + (stills.y - region.y);90        frame.with_clip(stills, |frame| {91            for (season, band) in series.seasons.iter().zip(&layout.bands) {92                if !band.shows(offset, region.height) {93                    continue;94                }95                let run = season.run;96                wall::draw(97                    frame,98                    posters,99                    &wall::Grid {100                        items: &series.stills[run.first..run.first + run.count],101                        focus: match focus {102                            Some(Focus::Still(index))103                                if index >= run.first && index < run.first + run.count =>104                            {105                                Some(index - run.first)106                            }107                            _ => None,108                        },109                        marked: true,110                        library: &series.library,111                        ratio: wall::STILL,112                        columns: COLUMNS,113                        lines: card::LINES,114                        region: stills,115                        offset: scrolled - band.rows_top,116                    },117                );118            }119        });120121        let dividers = area(inset, region.y, width, region.height);122        frame.with_clip(region, |frame| {123            for (season, band) in series.seasons.iter().zip(&layout.bands) {124                if !band.shows(offset, region.height) {125                    continue;126                }127                divider::draw(128                    frame,129                    layout::divider_box(dividers, band, offset),130                    &season.name,131                );132            }133134            for (index, (band, top)) in series135                .franchises136                .bands()137                .iter()138                .zip(&layout.franchises)139                .enumerate()140            {141                strip::draw(142                    frame,143                    posters,144                    &strip::Strip {145                        members: &band.members,146                        current: band.current,147                        focus: match focus {148                            Some(Focus::Franchise(strip, Place::Member(member)))149                                if strip == index =>150                            {151                                Some(member)152                            }153                            _ => None,154                        },155                        heading: &band.heading,156                        library: &series.library,157                        last: None,158                        lines: card::LINES,159                        headed: matches!(160                            focus,161                            Some(Focus::Franchise(strip, Place::Heading)) if strip == index162                        ),163                        region: area(164                            inset,165                            region.y + top - offset,166                            region.width - 2.0 * inset,167                            layout::strip_height(),168                        ),169                    },170                );171            }172173            for (index, (band, top)) in series174                .stripes175                .bands()176                .iter()177                .zip(&layout.stripes)178                .enumerate()179            {180                people::draw(181                    frame,182                    posters,183                    &people::Stripe {184                        people: &band.faces,185                        focus: match focus {186                            Some(Focus::Stripe(stripe, slot)) if stripe == index => Some(slot),187                            _ => None,188                        },189                        heading: band.heading,190                        library: &series.library,191                        region: area(192                            inset,193                            region.y + top - offset,194                            region.width - 2.0 * inset,195                            people::HEIGHT,196                        ),197                    },198                );199            }200201            let mut at = Point::new(inset, region.y + layout.foot - offset);202            for row in series.foot.rows() {203                at.y += row.lead;204                let color = match row.faint {205                    true => look::faint(),206                    false => look::text(),207                };208                text::line(frame, row.prefix, at, row.size, look::faint(), width);209                let after = Point::new(at.x + row.indent(), at.y);210                at.y += text::line(211                    frame,212                    row.content,213                    after,214                    row.size,215                    color,216                    width - row.indent(),217                );218            }219        });220221        frame.with_clip(layout::rail_clip(whole), |frame| {222            rail::draw_at(223                frame,224                whole,225                &series.bars,226                match focus {227                    Some(Focus::Rail(bar)) => Some(bar),228                    _ => None,229                },230                rail::Side::Right,231                rail::Fit::Fitted,232            );233        });234235        vec![frame.into_geometry()]236    }237}238239/// The box the series' logo draws in at these bounds, which is where the240/// loading state starts the logo's move. The header stands at the top of241/// the frame whatever the wall under it has scrolled to.242pub fn head(bounds: Rectangle) -> Rectangle {243    area(244        MARGIN,245        bounds.y + layout::TOP,246        layout::LOGO_WIDTH,247        layout::LOGO_HEIGHT,248    )249}250251impl<P: Posters> Page<'_, P> {252    // The header's blocks, from the logo down to the plot. Every block is253    // cut to its own lines, so a long title, a long line, or a long plot254    // never pushes the header past its fixed height. The focused255    // episode's line and plot stand in the place the series' plot takes on256    // a series whose episodes have not landed, and while a stripe holds257    // focus.258    fn header(&self, frame: &mut canvas::Frame<Renderer>, posters: &mut P, region: Rectangle) {259        let series = self.series;260        let column = region.width * COLUMN;261        let mut stack = Stack::new(Point::new(MARGIN, region.y + layout::TOP), layout::GAP);262263        let title = area(stack.at().x, stack.at().y, column, layout::LOGO_HEIGHT);264        frame.with_clip(title, |frame| {265            header::title(266                frame,267                posters,268                &header::Title {269                    library: &series.library,270                    logo: &series.logo,271                    name: &series.title,272                    at: stack.at(),273                    logo_box: (layout::LOGO_WIDTH, layout::LOGO_HEIGHT),274                    width: column,275                    size: look::HEAD_TITLE,276                    lifted: self.lifted,277                },278            );279        });280        stack.add(layout::LOGO_HEIGHT);281282        // The facts line is one line, cut with an ellipsis where a long283        // list of genres runs past the column, so it never ends on a comma.284        let taken = text::block(285            frame,286            &text::measured_cut(&series.facts, look::FACTS, column),287            stack.at(),288            look::FACTS,289            look::muted(),290            column,291            1,292        );293        stack.add(taken);294295        let taken = ratings::draw(frame, &series.ratings, stack.at());296        stack.add(taken);297298        let (line, aired, plot) = match series.focused() {299            Some(still) => (300                still.facts.as_str(),301                still.aired.as_str(),302                still.plot.as_str(),303            ),304            None => ("", "", series.plot.as_str()),305        };306        // The episode's name is cut to one line with an ellipsis, and the307        // runtime and the air date take a line of their own under it, so308        // a long name never pushes the date out of the header.309        let taken = text::block(310            frame,311            &text::cut(line, look::FACTS, column),312            stack.at(),313            look::FACTS,314            look::text(),315            column,316            1,317        );318        stack.add(taken);319        let taken = text::block(320            frame,321            aired,322            stack.at(),323            look::FACTS,324            look::muted(),325            column,326            1,327        );328        stack.add(taken);329        let taken = text::block(330            frame,331            plot,332            stack.at(),333            look::PLOT,334            look::text(),335            column,336            layout::PLOT_LINES,337        );338        stack.add(taken);339    }340}
src/screens/series/seasons.rs 100.0%
1// The seasons of a series: the dividers and the stills one read of the2// episodes builds, and the bars of the rail beside a long wall.34use super::{COLUMNS, Focus, Season, Series, Still};5use crate::catalog::Episode;6use crate::focus::{self, Run};7use crate::screens::facts;8use crate::views::{card, rail, scroll, wall};9use iced_winit::core::Rectangle;1011/// How many seasons a series holds and still draws no rail.12pub const SHORT: usize = 4;1314// The wall and its dividers out of one read of the episodes. The rows15// arrive in aired order, so a season starts wherever the season number16// changes, and its year is the year of the first episode that aired in17// it.18pub fn wall_of(episodes: Vec<Episode>, today: &str) -> (Vec<Still>, Vec<Season>) {19    let band = wall::band(COLUMNS);20    let mut stills = Vec::with_capacity(episodes.len());21    let mut seasons: Vec<Season> = Vec::new();22    let mut years: Vec<String> = Vec::new();23    for (index, episode) in episodes.into_iter().enumerate() {24        match seasons.last_mut() {25            Some(season) if season.number == episode.season => season.run.count += 1,26            _ => {27                seasons.push(Season {28                    number: episode.season,29                    name: String::new(),30                    run: Run {31                        first: index,32                        count: 1,33                    },34                });35                years.push(facts::year(&episode.released).to_string());36            }37        }38        stills.push(still_of(episode, today, band));39    }40    // The heading is written after the read, because it counts the41    // season's episodes, and the count is known only once the next42    // season starts or the episodes run out.43    for (season, year) in seasons.iter_mut().zip(&years) {44        season.name = named(season.number, year, season.run.count);45    }46    (stills, seasons)47}4849// The divider's heading: the season, the year of its first episode50// where the catalog holds one, and how many episodes it holds.51fn named(season: i64, year: &str, episodes: usize) -> String {52    facts::joined(&[&format!("Season {season}"), year, &counted(episodes)])53}5455// The episode count as a person reads it, singular at one.56fn counted(episodes: usize) -> String {57    match episodes {58        1 => "1 episode".to_string(),59        count => format!("{count} episodes"),60    }61}6263pub fn still_of(episode: Episode, today: &str, band: f32) -> Still {64    let season = format!("S{:02}", episode.season);65    let numbered = format!("E{:02}", episode.episode);66    let runtime = facts::runtime(episode.duration);67    let under = facts::joined(&[&numbered, &runtime]);68    Still {69        id: episode.id,70        fitted: card::cut(&episode.title, band),71        under: card::under_cut(&under, band),72        facts: facts::joined(&[&season, &numbered, &episode.title]),73        aired: facts::joined(&[&runtime, &facts::date_worded(&episode.released, today)]),74        season: episode.season,75        episode: episode.episode,76        name: episode.title,77        plot: episode.plot,78        art: episode.art,79    }80}8182/// The bars of the rail: one per season while the seasons fit the83/// region, and even ranges of neighbouring seasons where they do not, so84/// every bar is on the screen at once. A range's label is longer than a85/// season's, and a longer label fits fewer bars, so the count is asked86/// again until the labels of the count answered fit it. None on a series87/// of four seasons or fewer.88pub fn bars(seasons: &[Season], region: Rectangle) -> Vec<rail::Bar> {89    if seasons.len() <= SHORT {90        return Vec::new();91    }92    let tops = rows(seasons);93    let mut count = seasons.len();94    loop {95        let labels = labels(seasons, count);96        let held = rail::fits(region, longest(&labels));97        if held >= count {98            return labels99                .into_iter()100                .enumerate()101                .map(|(index, label)| {102                    let (start, end) = range(seasons.len(), count, index);103                    rail::Bar {104                        label,105                        first: tops[start],106                        last: tops[end] - 1,107                        lane: 0,108                    }109                })110                .collect();111        }112        count = held;113    }114}115116// The labels of a rail of `count` bars over these seasons, in bar order.117fn labels(seasons: &[Season], count: usize) -> Vec<String> {118    (0..count)119        .map(|index| {120            let (start, end) = range(seasons.len(), count, index);121            numbered(&seasons[start], &seasons[end - 1])122        })123        .collect()124}125126// The longest label, which is the one the rail has to hold.127fn longest(labels: &[String]) -> &str {128    labels129        .iter()130        .max_by_key(|label| label.chars().count())131        .map(String::as_str)132        .unwrap_or_default()133}134135// The seasons one bar of a rail of `count` bars covers, as the first and136// one past the last, so the bars share the seasons evenly and in order.137fn range(seasons: usize, count: usize, bar: usize) -> (usize, usize) {138    (bar * seasons / count, (bar + 1) * seasons / count)139}140141// A bar's label: the season's number alone, or the first and last142// numbers of a range. A bar is one lane wide, so the label is the143// number with no word beside it; the divider over the rows keeps the144// heading that names the season in full.145fn numbered(first: &Season, last: &Season) -> String {146    match first.number == last.number {147        true => first.number.to_string(),148        false => format!("{}\u{2013}{}", first.number, last.number),149    }150}151152// The first row of every season, and the row after the last, so a bar153// over a range of seasons reads its rows off the ends.154fn rows(seasons: &[Season]) -> Vec<usize> {155    let mut row = 0;156    let mut tops = Vec::with_capacity(seasons.len() + 1);157    for season in seasons {158        tops.push(row);159        row += scroll::rows(season.run.count, COLUMNS);160    }161    tops.push(row);162    tops163}164165/// The bar a right press from this still moves onto, or nothing where166/// the still is not at the wall's right edge or the page draws no rail.167pub fn onto(page: &Series, index: usize) -> Option<usize> {168    let (at, season) = page.seasons.iter().enumerate().find(|(_, season)| {169        index >= season.run.first && index < season.run.first + season.run.count170    })?;171    let local = index - season.run.first;172    if local % COLUMNS != COLUMNS - 1 && local + 1 != season.run.count {173        return None;174    }175    rail::covering(&page.bars, rows(&page.seasons)[at] + local / COLUMNS)176}177178/// One press while a bar holds focus: up and down along the bars, select179/// onto the first still the bar covers, and left back to the wall.180pub fn key(page: &Series, bar: usize, key: &str) -> Focus {181    let Some(run) = covers(page, bar) else {182        return Focus::Rail(bar);183    };184    match key {185        "up" | "down" => Focus::Rail(focus::list(bar, page.bars.len(), key)),186        "enter" => Focus::Still(run.first),187        "left" => Focus::Still(back(run, page.entered)),188        _ => Focus::Rail(bar),189    }190}191192/// Where the wall stands while a bar holds focus: at the still a select193/// on that bar lands on, so the scroll reads the rail's focus as a still.194pub fn standing(page: &Series) -> Focus {195    match page.focus {196        Focus::Rail(bar) => match covers(page, bar) {197            Some(run) => Focus::Still(run.first),198            None => Focus::Still(0),199        },200        held => held,201    }202}203204// The stills one bar covers, as one run, or nothing for a bar the rail205// does not hold.206fn covers(page: &Series, bar: usize) -> Option<Run> {207    let (start, end) = range(page.seasons.len(), page.bars.len().max(1), bar);208    let first = page.seasons.get(start)?;209    let last = page.seasons.get(end.saturating_sub(1))?;210    Some(Run {211        first: first.run.first,212        count: last.run.first + last.run.count - first.run.first,213    })214}215216// The still a left press lands on: the one the rail was entered from217// while the focused bar still covers it, else the bar's first still.218fn back(run: Run, entered: usize) -> usize {219    let inside = entered >= run.first && entered < run.first + run.count;220    match inside {221        true => entered,222        false => run.first,223    }224}
src/screens/slots.rs 97.3%
1// The slots of one query are one piece of code every wall shares. The2// library wall and a person's works were two copies of one grid, and the3// home page's walls would have been a third. This module holds the query,4// the answer's name, the items, the focus, the arrows and select over5// them, the prefetch under focus, and the drawing into a region.6// The select and the prefetch over one item are functions of their own7// here, because the home page's strips open the same pages a wall8// does.910use std::ops::Range;1112use iced_wgpu::Renderer;13use iced_widget::canvas;14use iced_winit::core::Rectangle;1516use super::wall::Wall;17use super::{Item, Screen, Step, credits, franchise, movie, person, series};18use crate::catalog::search::PEOPLE;19use crate::catalog::{Counts, Query, Source};20use crate::focus;21use crate::posters::Posters;22use crate::views::wall;2324/// The slots one query answered: the query, the name the answer25/// carried, the items in the answer's order, and the focused item's26/// index.27#[derive(Debug)]28pub struct Slots {29    pub query: Query,30    pub name: String,31    pub items: Vec<Item>,32    pub focus: usize,33    // The run of items whose cards the shaper has already cut. A wall of34    // a whole library is thousands of slots, and cutting them all costs35    // more than a press may take, so the read cuts the page around the36    // focus and a move cuts what it reaches.37    cut: Range<usize>,38}3940impl Slots {41    /// Read the query's answer, with focus on the first slot.42    pub fn open(query: Query, source: &mut dyn Source) -> Self {43        let mut slots = Self {44            query,45            name: String::new(),46            items: Vec::new(),47            focus: 0,48            cut: 0..0,49        };50        slots.reread(source);51        slots52    }5354    /// Read the answer again and keep focus in range, because a change can55    /// remove the focused slot.56    pub fn reread(&mut self, source: &mut dyn Source) {57        let mut answer = source.wall(&self.query);58        credits::credit(&self.query, &mut answer.slots);59        self.name = answer.name;60        self.items = answer61            .slots62            .into_iter()63            .map(|slot| Item::of(&self.query, slot))64            .collect();65        self.cut = 0..0;66        self.focus = self.focus.min(self.items.len().saturating_sub(1));67        self.stand(self.focus);68    }6970    /// Cut the cards of the page around this slot. The rail scrolls the71    /// wall to rows the focus has not reached, and every card a frame72    /// draws is cut at the read and never on the frame.73    pub fn stand(&mut self, index: usize) {74        self.fitted(index);75    }7677    // Cut the cards of the page around one slot to the band one cell78    // holds, and leave the cards already cut as they are.79    fn fitted(&mut self, index: usize) {80        let page = page(index, self.items.len());81        let band = wall::band(wall::COLUMNS);82        let cut = self.cut.clone();83        for index in page.clone().filter(|index| !cut.contains(index)) {84            self.items[index].fit(band);85        }86        self.cut = match cut.is_empty() || page.start > cut.end || cut.start > page.end {87            true => page,88            false => page.start.min(cut.start)..page.end.max(cut.end),89        };90    }9192    /// The heading the band draws over these slots: the query's heading93    /// over the name and the count.94    pub fn heading(&self) -> String {95        let kinds = self.items.iter().map(|item| item.kind.as_str());96        self.query.heading(&self.name, Counts::of(kinds))97    }9899    /// Fold one press in. The arrows move across the grid, and select opens100    /// the page for the focused slot's kind.101    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {102        if key != "enter" {103            self.focus = focus::wall(self.focus, self.items.len(), wall::COLUMNS, key);104            self.fitted(self.focus);105            return Step::Stay;106        }107        match self.items.get(self.focus) {108            Some(item) => opened(item, source),109            None => Step::Stay,110        }111    }112113    /// The library and the backdrop of the page the focused slot opens, by114    /// the slot's kind, so the store decodes it while focus rests. Nothing115    /// where that page draws over no art.116    pub fn resting(&self, source: &mut dyn Source) -> Option<(String, String)> {117        backdrop(self.items.get(self.focus)?, source)118    }119120    /// Draw the grid of these slots in the region, scrolled so the focused121    /// row stays in view, with the mark on the focused slot only while122    /// `marked`, and `lines` caption lines under each slot.123    pub fn draw<P: Posters>(124        &self,125        frame: &mut canvas::Frame<Renderer>,126        posters: &mut P,127        region: Rectangle,128        marked: bool,129        lines: usize,130    ) {131        self.draw_at(frame, posters, region, self.focus, marked, lines);132    }133134    /// The same drawing, with the grid standing at the slot the caller135    /// names instead of the focus. A wall whose rail holds focus stands136    /// at the slot a select on the focused bar lands on, so the wall137    /// follows the bar.138    pub fn draw_at<P: Posters>(139        &self,140        frame: &mut canvas::Frame<Renderer>,141        posters: &mut P,142        region: Rectangle,143        standing: usize,144        marked: bool,145        lines: usize,146    ) {147        let cells = wall::lined(region.width, wall::POSTER, wall::COLUMNS, lines);148        wall::draw(149            frame,150            posters,151            &wall::Grid {152                items: &self.items,153                focus: Some(standing),154                marked,155                library: "",156                ratio: wall::POSTER,157                columns: wall::COLUMNS,158                lines,159                offset: wall::scrolled(160                    standing,161                    self.items.len(),162                    wall::COLUMNS,163                    &cells,164                    region.height,165                ),166                region,167            },168        );169    }170}171172// How many rows of cards on each side of the focused one the shaper173// cuts. It is far more than a screen holds, so a move of one row cuts one174// row, and a frame never draws a card the shaper has not measured.175const PAGE: usize = 12;176177// The run of items one focus asks the shaper for: the rows around the178// focused one, clamped to the wall.179fn page(focus: usize, count: usize) -> Range<usize> {180    let row = focus / wall::COLUMNS;181    let first = row.saturating_sub(PAGE) * wall::COLUMNS;182    first..((row + PAGE + 1) * wall::COLUMNS).min(count)183}184185/// The screen a "see all" on this query opens. A person opens their own186/// page, which draws the headshot and the dates over the same works.187/// Every other query opens the wall of everything it answers, and a188/// query with a page of its own is that page, because the wall carries189/// its head. Nothing where the catalog no longer holds the person.190pub fn see_all(query: &Query, source: &mut dyn Source) -> Step {191    if let Query::Person { library, path } = query {192        return match person::Person::open(library, path, source) {193            Some(page) => Step::Open(Screen::Person(Box::new(page))),194            None => Step::Stay,195        };196    }197    Step::Open(Screen::Wall(Box::new(Wall::open(198        query.all_titles(),199        source,200    ))))201}202203/// The kind word a franchise slot carries, the one kind that opens a204/// franchise page. The home page's franchises strip and a search hit both205/// carry it.206pub const FRANCHISE: &str = "franchise";207208/// The kind word a set's slot carries. A search answers one, and it209/// opens the wall of the set's members.210pub const SETS: &str = "sets";211212/// The page a select on one item opens, by the item's kind: a series213/// page for a series, the series page focused on the episode for an214/// episode, and a movie page for everything else. A search also answers215/// three kinds no other wall holds: a set opens the wall of its members,216/// a franchise opens its page, and a person opens theirs. Nothing where217/// the catalog no longer holds the item.218pub fn opened(item: &Item, source: &mut dyn Source) -> Step {219    if item.kind == SETS {220        let query = Query::Set {221            library: item.library.clone(),222            id: item.id.clone(),223        };224        return Step::Open(Screen::Wall(Box::new(Wall::open(query, source))));225    }226    let page = match (item.kind.as_str(), &item.episode) {227        ("episodes", Some(place)) => series::Series::open_at(228            &item.library,229            &place.series,230            (place.season, place.episode),231            source,232        )233        .map(|page| Screen::Series(Box::new(page))),234        ("series", _) => series::Series::open(&item.library, &item.id, source)235            .map(|page| Screen::Series(Box::new(page))),236        (FRANCHISE, _) => franchise::Franchise::open(&item.library, &item.id, source)237            .map(|page| Screen::Franchise(Box::new(page))),238        (PEOPLE, _) => person::Person::open(&item.library, &item.id, source)239            .map(|page| Screen::Person(Box::new(page))),240        _ => movie::Movie::open(&item.library, &item.id, source)241            .map(|page| Screen::Movie(Box::new(page))),242    };243    match page {244        Some(page) => Step::Open(page),245        None => Step::Stay,246    }247}248249/// The library and the backdrop of the page a select on this item250/// opens, so the store decodes it while focus rests. An episode opens its251/// series' page. Nothing where that page draws over no art.252pub fn backdrop(item: &Item, source: &mut dyn Source) -> Option<(String, String)> {253    let backdrop = match (item.kind.as_str(), &item.episode) {254        ("episodes", Some(place)) => source.series(&item.library, &place.series)?.backdrop,255        ("series", _) => source.series(&item.library, &item.id)?.backdrop,256        _ => source.movie(&item.library, &item.id)?.backdrop,257    };258    if backdrop.is_empty() {259        return None;260    }261    Some((item.library.clone(), backdrop))262}263264#[cfg(test)]265mod tests {266    use super::*;267    use crate::catalog::Sort;268    use crate::sample::Catalog;269270    const LIBRARY: &str = "sample/features";271272    #[test]273    fn the_shaper_cuts_the_rows_around_the_focus_and_no_more() {274        assert_eq!(page(0, 5_000), 0..(PAGE + 1) * wall::COLUMNS);275        assert_eq!(page(0, 12), 0..12);276        let far = page(100 * wall::COLUMNS, 5_000);277        assert_eq!(far.start, (100 - PAGE) * wall::COLUMNS);278        assert_eq!(far.end, (100 + PAGE + 1) * wall::COLUMNS);279        assert_eq!(page(0, 0), 0..0);280    }281282    #[test]283    fn a_move_across_the_wall_cuts_the_row_it_reached_and_leaves_the_rest() {284        let query = Query::Library {285            library: LIBRARY.into(),286            sort: Sort::default(),287        };288        let mut slots = Slots::open(query, &mut Catalog);289        assert_eq!(slots.cut, page(0, slots.items.len()));290        slots.key("down", &mut Catalog);291        assert_eq!(slots.cut, 0..page(slots.focus, slots.items.len()).end);292        assert_eq!(slots.cut.end, (PAGE + 2) * wall::COLUMNS);293    }294295    // The first hit of one search of the invented catalog, which296    // searches for real.297    fn hit(text: &str, kind: &str) -> Item {298        let query = Query::Search {299            text: text.to_string(),300        };301        let slots = Slots::open(query, &mut Catalog);302        slots303            .items304            .into_iter()305            .find(|item| item.kind == kind)306            .unwrap_or_else(|| panic!("{text} answers a {kind}"))307    }308309    #[test]310    fn a_set_a_search_answers_opens_the_wall_of_its_members() {311        let item = hit("specimen cycle", SETS);312313        let Step::Open(Screen::Wall(wall)) = opened(&item, &mut Catalog) else {314            panic!("a set opens a wall");315        };316317        assert_eq!(318            wall.slots.query,319            Query::Set {320                library: item.library,321                id: item.id,322            }323        );324        assert!(!wall.slots.items.is_empty());325    }326327    #[test]328    fn a_franchise_a_search_answers_opens_its_page() {329        let item = hit("marsh", FRANCHISE);330331        let Step::Open(Screen::Franchise(page)) = opened(&item, &mut Catalog) else {332            panic!("a franchise opens its page");333        };334335        assert_eq!(page.title, "The Marsh Cycle");336    }337338    #[test]339    fn a_person_a_search_answers_opens_their_page() {340        let item = hit("player 0001-1", PEOPLE);341342        let Step::Open(Screen::Person(page)) = opened(&item, &mut Catalog) else {343            panic!("a person opens their page");344        };345346        assert_eq!(page.name, item.name);347        assert_eq!(page.path, item.id);348    }349350    #[test]351    fn see_all_on_an_unknown_person_opens_nothing() {352        let query = Query::Person {353            library: LIBRARY.into(),354            path: "nobody".into(),355        };356        assert!(matches!(see_all(&query, &mut Catalog), Step::Stay));357    }358359    #[test]360    fn see_all_on_a_library_opens_a_wall_with_no_head() {361        let query = Query::Library {362            library: LIBRARY.into(),363            sort: Sort::default(),364        };365        let Step::Open(Screen::Wall(wall)) = see_all(&query, &mut Catalog) else {366            panic!("a library opens a wall");367        };368        assert_eq!(wall.slots.query, query);369        assert_eq!(wall.heading, wall.slots.heading());370    }371}
src/screens/stripes.rs 100.0%
1// The credited people of one title, as the three stripes a movie's2// page and a series' page both draw, and the focus ladder those stripes3// are the last rungs of. Every function here is pure over the rows, so4// the two pages share one model and the tests need no window.56use crate::catalog::{CreditSlot, Credits};7use crate::focus;8use crate::views::Card;910// The file every contributor entry holds their headshot in.11const HEADSHOT: &str = "headshot.jpg";1213/// One slot of a stripe: the person, what they did on this title,14/// and where their entry lives.15#[derive(Debug, Clone, Default, PartialEq, Eq)]16pub struct Face {17    /// The name a person reads.18    pub name: String,19    /// The character an actor played, empty for the crew.20    pub role: String,21    /// The person's directory relative to the library volume, empty22    /// where the library's store holds no entry for them.23    pub contributor: String,24    /// The path of the headshot file, empty where the entry holds25    /// none.26    pub art: String,27}2829impl Face {30    // One credit row as a slot. The headshot sits inside the31    // person's own entry, so its path is built once here, at the read.32    fn of(slot: CreditSlot) -> Self {33        let art = match slot.headshot && !slot.contributor.is_empty() {34            true => format!("{}/{HEADSHOT}", slot.contributor),35            false => String::new(),36        };37        Self {38            name: slot.name,39            role: slot.role,40            contributor: slot.contributor,41            art,42        }43    }44}4546impl Card for Face {47    fn art(&self) -> &str {48        &self.art49    }5051    fn name(&self) -> &str {52        &self.name53    }5455    fn detail(&self) -> &str {56        &self.role57    }58}5960// The crew as one list: the directors first, then the writers the directors61// do not already name, each with the parts they hold joined under the name.62// A person is the same person by their entry, or by their name where the63// store has no entry for them.64fn crew(directors: Vec<CreditSlot>, writers: Vec<CreditSlot>) -> Vec<CreditSlot> {65    let mut crew: Vec<CreditSlot> = Vec::with_capacity(directors.len() + writers.len());66    for (part, slots) in [("Director", directors), ("Writer", writers)] {67        for slot in slots {68            match crew.iter_mut().find(|held| same_person(held, &slot)) {69                Some(held) => held.role = format!("{}, {part}", held.role),70                None => crew.push(CreditSlot {71                    role: part.to_string(),72                    ..slot73                }),74            }75        }76    }77    crew78}7980fn same_person(a: &CreditSlot, b: &CreditSlot) -> bool {81    match (a.contributor.is_empty(), b.contributor.is_empty()) {82        (false, false) => a.contributor == b.contributor,83        _ => a.name.eq_ignore_ascii_case(&b.name),84    }85}8687/// One stripe: the part its heading names, and the people in it.88#[derive(Debug, Clone, PartialEq, Eq)]89pub struct Stripe {90    /// The heading drawn over the headshots.91    pub heading: &'static str,92    /// The people, in the order the catalog answered them.93    pub faces: Vec<Face>,94}9596/// Where focus is inside the stripes: which stripe, and which slot97/// of it.98pub type Rung = (usize, usize);99100/// The stripes of one title, in the order the pages draw them,101/// with the parts the title credits nobody in left out.102#[derive(Debug, Clone, Default, PartialEq, Eq)]103pub struct Stripes {104    bands: Vec<Stripe>,105}106107impl Stripes {108    /// The stripes of one title's credits: the cast first, then the crew. A109    /// person who both directed and wrote is one face of the crew, with110    /// both parts under their name where an actor's character goes.111    pub fn of(credits: Credits) -> Self {112        let bands = [113            ("Cast", credits.cast),114            ("Crew", crew(credits.directors, credits.writers)),115        ]116        .into_iter()117        .filter(|(_, slots)| !slots.is_empty())118        .map(|(heading, slots)| Stripe {119            heading,120            faces: slots.into_iter().map(Face::of).collect(),121        })122        .collect();123        Self { bands }124    }125126    /// The stripes in draw order.127    pub fn bands(&self) -> &[Stripe] {128        &self.bands129    }130131    /// Whether the title credits nobody at all.132    pub fn is_empty(&self) -> bool {133        self.bands.is_empty()134    }135136    /// The rung a move down from the rung above the stripes lands137    /// on, or nothing where the title credits nobody.138    pub fn first(&self) -> Option<Rung> {139        (!self.bands.is_empty()).then_some((0, 0))140    }141142    /// The person at one rung, or nothing where the rung is past143    /// what the title credits.144    pub fn face(&self, (stripe, slot): Rung) -> Option<&Face> {145        self.bands.get(stripe)?.faces.get(slot)146    }147148    /// The rung this press moves to, or nothing where up leaves the149    /// stripes for the rung above them.150    pub fn key(&self, (stripe, slot): Rung, key: &str) -> Option<Rung> {151        let count = self.bands.get(stripe).map_or(0, |band| band.faces.len());152        match key {153            "up" if stripe == 0 => None,154            "up" => Some((stripe - 1, 0)),155            "down" if stripe + 1 < self.bands.len() => Some((stripe + 1, 0)),156            "down" => Some((stripe, slot)),157            _ => Some((stripe, focus::row(slot, count, key))),158        }159    }160161    /// The rung focus holds after a re-read: the same one, unless162    /// the stripe it was on grew shorter or went away.163    pub fn held(&self, (stripe, slot): Rung) -> Option<Rung> {164        let stripe = stripe.min(self.bands.len().checked_sub(1)?);165        let count = self.bands[stripe].faces.len();166        Some((stripe, slot.min(count.checked_sub(1)?)))167    }168}169170#[cfg(test)]171mod tests {172    use super::*;173174    fn slot(name: &str, role: &str, entry: bool) -> CreditSlot {175        CreditSlot {176            name: name.to_string(),177            role: role.to_string(),178            contributor: match entry {179                true => format!(".contributors/{name}"),180                false => String::new(),181            },182            headshot: entry,183        }184    }185186    fn credits() -> Credits {187        Credits {188            directors: vec![slot("A Director", "", true)],189            writers: Vec::new(),190            cast: vec![191                slot("A Player", "The Part", true),192                slot("Another", "A Walk-on", false),193            ],194        }195    }196197    #[test]198    fn a_title_draws_only_the_parts_it_credits() {199        let stripes = Stripes::of(credits());200        let headings: Vec<&str> = stripes.bands().iter().map(|band| band.heading).collect();201        assert_eq!(headings, ["Cast", "Crew"]);202        assert_eq!(stripes.bands()[0].faces.len(), 2);203        assert_eq!(stripes.bands()[1].faces[0].role, "Director");204    }205206    #[test]207    fn a_person_who_directed_and_wrote_is_one_face_with_both_parts() {208        let stripes = Stripes::of(Credits {209            directors: vec![slot("A Director", "", true), slot("Both", "", true)],210            writers: vec![slot("Both", "", true), slot("A Writer", "", false)],211            cast: Vec::new(),212        });213        let crew: Vec<(&str, &str)> = stripes.bands()[0]214            .faces215            .iter()216            .map(|face| (face.name.as_str(), face.role.as_str()))217            .collect();218        assert_eq!(219            crew,220            [221                ("A Director", "Director"),222                ("Both", "Director, Writer"),223                ("A Writer", "Writer")224            ]225        );226    }227228    #[test]229    fn a_title_that_credits_nobody_draws_no_stripe() {230        let stripes = Stripes::of(Credits::default());231        assert!(stripes.is_empty());232        assert_eq!(stripes.first(), None);233    }234235    #[test]236    fn a_headshot_sits_inside_the_persons_own_entry() {237        let stripes = Stripes::of(credits());238        let face = stripes.face((1, 0)).expect("the title has a director");239        assert_eq!(face.art, ".contributors/A Director/headshot.jpg");240        assert_eq!(face.name, "A Director");241        assert_eq!(face.detail(), "Director");242    }243244    #[test]245    fn a_name_with_no_entry_carries_no_art_and_no_path() {246        let stripes = Stripes::of(credits());247        let face = stripes.face((0, 1)).expect("the title has a second player");248        assert_eq!(face.art, "");249        assert_eq!(face.contributor, "");250        assert_eq!(face.detail(), "A Walk-on");251    }252253    #[test]254    fn left_and_right_move_inside_one_stripe() {255        let stripes = Stripes::of(credits());256        assert_eq!(stripes.key((0, 0), "right"), Some((0, 1)));257        assert_eq!(stripes.key((0, 1), "right"), Some((0, 1)));258        assert_eq!(stripes.key((0, 1), "left"), Some((0, 0)));259    }260261    #[test]262    fn down_and_up_move_between_stripes_and_up_leaves_the_first() {263        let stripes = Stripes::of(credits());264        assert_eq!(stripes.first(), Some((0, 0)));265        assert_eq!(stripes.key((0, 0), "down"), Some((1, 0)));266        assert_eq!(stripes.key((1, 0), "down"), Some((1, 0)));267        assert_eq!(stripes.key((1, 0), "up"), Some((0, 0)));268        assert_eq!(stripes.key((0, 0), "up"), None);269    }270271    #[test]272    fn a_reread_holds_the_rung_inside_what_the_title_still_credits() {273        let stripes = Stripes::of(credits());274        assert_eq!(stripes.held((0, 1)), Some((0, 1)));275        assert_eq!(stripes.held((0, 9)), Some((0, 1)));276        assert_eq!(stripes.held((9, 9)), Some((1, 0)));277        assert_eq!(Stripes::default().held((0, 0)), None);278    }279}
src/screens/volume.rs 100.0%
1// The state the volume row is in, as a pure function of the clock: the2// level the bus last delivered, the second of the last press, and how far3// up the row stood when that press landed.45use media_screen::volume::Volume;67use crate::views::volume::Row;89// A fade takes this long to reach full, and this long to reach clear, so10// the row leaves more slowly than it arrives.11const FADE_IN: f64 = 0.35;12const FADE_OUT: f64 = 0.6;1314// The row leaves this many seconds after the last press. Each press15// restarts the wait, so a run of presses holds the row on screen.16const HOLD: f64 = 4.0;1718/// The listening level as the browser draws it.19#[derive(Debug, Clone, Copy, Default, PartialEq)]20pub struct Level {21    // The level and the muted flag the volume topic last carried.22    volume: Volume,23    // The second of the last press, or nothing while no press has arrived.24    pressed: Option<f64>,25    // How far up the row stood when that press landed, so a press on a26    // leaving row lifts it from where it stands.27    from: f32,28}2930impl Level {31    /// Fold one level in at this second. The operator marks a press, and32    /// the broker's retained catch-up carries none, so a browser that33    /// connects to a running unit draws no row.34    pub fn fold(&mut self, volume: Volume, pressed: bool, at: f64) {35        if pressed {36            self.from = self.fade(at);37            self.pressed = Some(at);38        }39        self.volume = volume;40    }4142    /// The row's own fade, from 0 off screen to 1 full.43    pub fn fade(&self, at: f64) -> f32 {44        let Some(pressed) = self.pressed else {45            return 0.0;46        };47        let since = at - pressed;48        let arriving = f64::from(self.from) + since / FADE_IN;49        let leaving = 1.0 - (since - HOLD) / FADE_OUT;50        arriving.min(leaving).clamp(0.0, 1.0) as f3251    }5253    // Whether the row has left. This is the one rule that says the row is54    // off screen: the fade at the last instant of a fade out is a float55    // away from zero, and a row is gone or it is not.56    fn gone(&self, at: f64) -> bool {57        self.pressed58            .is_none_or(|pressed| at >= pressed + HOLD + FADE_OUT)59    }6061    /// The second the row next changes, for the browser's frame schedule.62    /// The two fades change the row on every frame they cover and answer63    /// now. Between them the row is up and steady, so the answer is the64    /// second it starts to leave, and the loop sleeps through the hold. A65    /// row that has left states nothing.66    pub fn next_frame(&self, at: f64) -> Option<f64> {67        if self.gone(at) {68            return None;69        }70        let pressed = self.pressed?;71        // A row lifted from part way up reaches full sooner, by exactly72        // the part of the fade it started above.73        let full = pressed + (1.0 - f64::from(self.from)) * FADE_IN;74        let leaving = pressed + HOLD;7576        if at < full {77            Some(at)78        } else if at < leaving {79            Some(leaving)80        } else {81            Some(at)82        }83    }8485    /// What one frame draws at this second, and nothing while the row is86    /// off screen.87    pub fn row(&self, at: f64) -> Option<Row> {88        if self.gone(at) {89            return None;90        }91        let fade = self.fade(at);92        (fade > 0.0).then_some(Row {93            volume: self.volume,94            fade,95        })96    }97}9899#[cfg(test)]100mod tests {101    use super::*;102103    // The second the first press lands on, which every case measures104    // from.105    const PRESS: f64 = 1.0;106107    // The level the fake unit stands at, and the state that read it and108    // then a press of it.109    fn level() -> Volume {110        Volume {111            level: 40,112            muted: false,113        }114    }115116    fn pressed(at: f64) -> Level {117        let mut state = Level::default();118        state.fold(level(), false, 0.0);119        state.fold(level(), true, at);120        state121    }122123    #[test]124    fn the_catch_up_shows_no_row() {125        let mut state = Level::default();126        state.fold(level(), false, 1.0);127128        assert_eq!(state.fade(1.0), 0.0);129        assert_eq!(state.fade(2.0), 0.0);130        assert_eq!(state.row(2.0), None);131    }132133    #[test]134    fn a_press_brings_the_row_in_over_350_ms() {135        let state = pressed(PRESS);136137        assert_eq!(state.fade(PRESS), 0.0);138        assert!((state.fade(PRESS + 0.175) - 0.5).abs() < 1e-6);139        assert_eq!(state.fade(PRESS + 0.35), 1.0);140        assert_eq!(state.fade(PRESS + 3.0), 1.0);141    }142143    #[test]144    fn the_row_leaves_four_seconds_after_the_last_press() {145        let state = pressed(PRESS);146147        assert_eq!(state.fade(PRESS + 4.0), 1.0);148        assert!((state.fade(PRESS + 4.3) - 0.5).abs() < 1e-6);149        assert!(state.fade(PRESS + 4.6) < 1e-9);150        assert_eq!(state.fade(PRESS + 60.0), 0.0);151        assert_eq!(state.row(PRESS + 4.6), None);152    }153154    #[test]155    fn a_second_press_restarts_the_hold() {156        let mut state = pressed(PRESS);157        state.fold(level(), true, PRESS + 3.0);158159        assert_eq!(state.fade(PRESS + 4.6), 1.0);160        assert_eq!(state.fade(PRESS + 7.0), 1.0);161        assert!(state.fade(PRESS + 7.6) < 1e-9);162    }163164    #[test]165    fn a_press_that_lifts_a_leaving_row_shortens_its_fade_in() {166        let mut state = pressed(PRESS);167        // The row is halfway out at 5.3 seconds, and the press lifts it168        // from there, so it reaches full in half of the 350 ms fade.169        state.fold(level(), true, PRESS + 4.3);170171        assert!((state.fade(PRESS + 4.3) - 0.5).abs() < 1e-6);172        assert_eq!(state.fade(PRESS + 4.475), 1.0);173    }174175    #[test]176    fn the_row_carries_the_level_and_the_muted_flag() {177        let mut state = pressed(PRESS);178        let row = state.row(PRESS + 1.0).expect("the row is up");179        assert_eq!(row.volume, level());180        assert_eq!(row.fade, 1.0);181182        state.fold(183            Volume {184                level: 40,185                muted: true,186            },187            true,188            PRESS + 1.0,189        );190        let row = state.row(PRESS + 2.0).expect("the row is up");191        assert!(row.volume.muted);192    }193194    #[test]195    fn the_two_fades_ask_for_a_frame_now() {196        let state = pressed(10.0);197198        assert_eq!(state.next_frame(10.0), Some(10.0));199        assert_eq!(state.next_frame(10.34), Some(10.34));200        assert_eq!(state.next_frame(14.3), Some(14.3));201    }202203    #[test]204    fn the_row_sleeps_through_its_hold_and_wakes_to_leave() {205        let state = pressed(10.0);206207        assert_eq!(state.next_frame(10.35), Some(14.0));208        assert_eq!(state.next_frame(13.9), Some(14.0));209    }210211    #[test]212    fn a_row_that_has_left_asks_for_no_frame() {213        assert_eq!(pressed(10.0).next_frame(14.61), None);214        assert_eq!(Level::default().next_frame(600.0), None);215    }216}
src/screens/wall.rs 98.5%
1// The wall screen: the slots one query answers, as a wall of art under2// a band that carries the query's heading. A long wall draws a rail at3// its right edge whose bars jump through the list and whose button4// cycles the order. A wall over a `Search` holds the field a person5// types into, which the browser's strip draws, and the grid a remote6// with no keyboard picks letters from.78use std::cell::RefCell;9use std::convert::Infallible;1011use iced_wgpu::Renderer;12use iced_widget::canvas;13use iced_winit::core::{Element, Length, Rectangle, Theme, mouse};1415use super::Step;16use super::slots::Slots;17use crate::catalog::{Query, Source};18use crate::focus;19use crate::posters::Posters;20use crate::views::{area, band, card, clip_marked, wall};2122// The rail module: which walls draw one, what its bars say, and how it23// draws beside the slots.24mod rail;25// The search module: what a wall over a `Search` holds, the presses it26// takes for itself, and the layer it draws over the band.27mod search;2829pub use search::{Search, searched};30use search::{Typing, grid_height};3132/// Where focus is on a wall: on the slots, or on one cell of the rail,33/// which is the sort button where the wall has one and then the bars34/// under it.35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]36pub enum Focus {37    #[default]38    Slots,39    Rail(usize),40}4142/// The wall screen: the slots the query answered and the heading as the43/// band draws it. The heading is built at every read and not on every44/// frame. `search` is the field and the grid a wall over a `Search`45/// types on, and nothing on every other wall.46#[derive(Debug)]47pub struct Wall {48    pub slots: Slots,49    pub heading: String,50    pub search: Option<Search>,51    /// The bars of the rail beside the slots, and none on a wall that52    /// draws no rail.53    pub bars: Vec<rail::Jump>,54    /// Where focus is.55    pub focus: Focus,56}5758impl Wall {59    /// Read the query's slots, with focus on the first of them.60    pub fn open(query: Query, source: &mut dyn Source) -> Self {61        let mut wall = Self {62            slots: Slots::open(query, source),63            heading: String::new(),64            search: None,65            bars: Vec::new(),66            focus: Focus::Slots,67        };68        wall.headed();69        wall.railed();70        wall71    }7273    /// Read the titles again and keep focus in range, because a change74    /// can remove the focused title.75    pub fn reread(&mut self, source: &mut dyn Source) {76        self.slots.reread(source);77        self.headed();78        self.railed();79    }8081    // The bars again, because the answer they name has changed. Focus82    // returns to the slots where the rail no longer holds the cell it83    // was on.84    fn railed(&mut self) {85        self.bars = rail::bars(&self.slots.items, &self.slots.query, rail::region());86        if let Focus::Rail(cell) = self.focus {87            let cells = rail::cells(&self.bars, &self.slots.query);88            self.focus = match self.bars.is_empty() {89                true => Focus::Slots,90                false => Focus::Rail(cell.min(cells - 1)),91            };92        }93    }9495    // The band carries every query's heading, and a genre's carries the96    // counts by kind.97    fn headed(&mut self) {98        self.heading = self.slots.heading();99    }100101    /// Fold one press in. The arrows move across the slots, and up from102    /// the first row moves nothing, which is how a press reaches the103    /// browser's strip.104    ///105    /// On a search wall, a letter, a digit, or the space types and hides106    /// the grid. While the grid is shown, the arrows move its focus and107    /// select presses the focused cell into the field.108    pub fn key(&mut self, key: &str, source: &mut dyn Source) -> Step {109        if let Some(step) = self.typed(key, source) {110            return step;111        }112        match self.focus {113            Focus::Slots => self.on_slots(key, source),114            Focus::Rail(cell) => self.on_rail(cell, key, source),115        }116    }117118    // One press while the slots hold focus. A right press at the right119    // edge of a row moves onto the bar that covers the row.120    fn on_slots(&mut self, key: &str, source: &mut dyn Source) -> Step {121        if key == "right"122            && let Some(cell) = self.onto()123        {124            self.focus = Focus::Rail(cell);125            return Step::Stay;126        }127        if key == "up" && self.slots.focus < wall::COLUMNS {128            return Step::Still;129        }130        self.slots.key(key, source)131    }132133    // The cell of the rail a right press from the focused slot moves134    // onto, or nothing where the slot is not at the right edge of its135    // row or the wall draws no rail.136    fn onto(&self) -> Option<usize> {137        let last = self.slots.items.len().checked_sub(1)?;138        let index = self.slots.focus;139        if index % wall::COLUMNS != wall::COLUMNS - 1 && index != last {140            return None;141        }142        let bar = rail::covering(&self.bars, index / wall::COLUMNS)?;143        Some(bar + rail::first(&self.slots.query))144    }145146    // One press while a cell of the rail holds focus: up and down along147    // the cells, select on a bar onto the first slot it covers, select on148    // the button into the next order, and left back to the slots. Up from149    // the top cell moves nothing, and the browser's strip takes that150    // press.151    fn on_rail(&mut self, cell: usize, key: &str, source: &mut dyn Source) -> Step {152        if key == "up" && cell == 0 {153            return Step::Still;154        }155        match (key, rail::barred_at(cell, &self.slots.query)) {156            ("up" | "down", _) => {157                let cells = rail::cells(&self.bars, &self.slots.query);158                self.focus = Focus::Rail(focus::list(cell, cells, key));159                let standing = self.standing();160                self.slots.stand(standing);161            }162            ("left" | "enter", Some(bar)) => {163                let head = self.head(bar);164                self.focus = Focus::Slots;165                self.slots.focus = head;166            }167            ("enter", None) => self.resort(source),168            ("left", None) => self.focus = Focus::Slots,169            _ => {}170        }171        Step::Stay172    }173174    /// Whether a slot of the wall carries the focus mark: only while the175    /// slots hold focus and neither the keyboard grid, the rail, nor the176    /// browser's strip has taken it. `held` is whether the wall holds177    /// focus at all.178    pub fn marks(&self, held: bool) -> bool {179        held && self.grid().is_none() && self.focus == Focus::Slots180    }181182    /// The cell of the rail that carries the mark, or nothing while the183    /// slots or the browser's strip hold focus.184    pub fn marked_cell(&self, held: bool) -> Option<usize> {185        match (held, self.focus) {186            (true, Focus::Rail(cell)) => Some(cell),187            _ => None,188        }189    }190191    /// The slot the wall stands at: the focused slot, or the first item192    /// of the focused bar while a bar holds focus, so a move along the193    /// bars scrolls the wall to what a select on the bar lands on. The194    /// sort button moves the wall nowhere.195    pub fn standing(&self) -> usize {196        match self.focus {197            Focus::Slots => self.slots.focus,198            Focus::Rail(cell) => match rail::barred_at(cell, &self.slots.query) {199                Some(bar) => self.head(bar),200                None => self.slots.focus,201            },202        }203    }204205    // The slot a select on one bar lands on: the first item the bar206    // covers, which is not the first item of its first row where a letter207    // range or a run starts in the middle of a row.208    fn head(&self, bar: usize) -> usize {209        let item = self.bars.get(bar).map(|jump| jump.item).unwrap_or_default();210        item.min(self.slots.items.len().saturating_sub(1))211    }212213    // The same wall in the next order its button cycles to. The read214    // answers the new order, the heading counts it again, and the bars215    // are the new order's own.216    fn resort(&mut self, source: &mut dyn Source) {217        self.slots.query = self.slots.query.resorted();218        self.slots.focus = 0;219        self.reread(source);220    }221222    /// Fold in the press that leaves a screen. A search wall with text223    /// in its field takes it and clears the text, or removes one224    /// character on backspace. Otherwise the answer is nothing, and the225    /// browser goes back.226    pub fn escape(&mut self, key: &str, source: &mut dyn Source) -> Option<Step> {227        self.cleared(key, source)228    }229230    /// Whether a rest of focus on this wall is worth a prefetch. It is231    /// while the posters hold focus, because a select on either kind232    /// opens a page over a backdrop.233    pub fn prefetches(&self) -> bool {234        self.grid().is_none()235    }236237    /// The library and the backdrop the focused title's page draws over.238    /// The browser asks the store for it once focus rests. A wall whose239    /// select opens no page over art answers nothing.240    pub fn resting(&self, source: &mut dyn Source) -> Option<(String, String)> {241        if !self.prefetches() {242            return None;243        }244        self.slots.resting(source)245    }246247    // The height a layer of the wall's own takes under the band: the248    // keyboard grid on a search wall, and nothing otherwise.249    fn under_band(&self) -> f32 {250        grid_height(self.grid().is_some())251    }252253    /// The view: the wall of posters, and the band as a layer over them.254    /// A search wall showing its grid draws a third layer over the band,255    /// because a layer draws every fill before every text and the band256    /// paints its own ground.257    pub fn view<'a, P: Posters>(258        &'a self,259        posters: &'a RefCell<P>,260        held: bool,261    ) -> Element<'a, Infallible, Theme, Renderer> {262        let grid = canvas(Program {263            wall: self,264            posters,265            held,266        })267        .width(Length::Fill)268        .height(Length::Fill)269        .into();270        let band = band::layer(&self.heading);271        let mut layers = vec![grid, band];272        if let Some(keyboard) = self.grid() {273            layers.push(274                canvas(Typing { keyboard })275                    .width(Length::Fill)276                    .height(Length::Fill)277                    .into(),278            );279        }280        iced_widget::Stack::with_children(layers)281            .width(Length::Fill)282            .height(Length::Fill)283            .into()284    }285}286287// The wall's drawing under the band: the head and the grid, on one frame.288struct Program<'a, P> {289    wall: &'a Wall,290    posters: &'a RefCell<P>,291    // Whether the wall holds focus, or the browser's strip over it does.292    held: bool,293}294295impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Program<'_, P> {296    type State = ();297298    fn draw(299        &self,300        _state: &Self::State,301        renderer: &Renderer,302        _theme: &Theme,303        bounds: Rectangle,304        _cursor: mouse::Cursor,305    ) -> Vec<canvas::Geometry<Renderer>> {306        let mut frame = canvas::Frame::new(renderer, bounds.size());307        // The rail takes its lane off the right of the region, and the308        // slots keep the rest.309        let whole = region(bounds, self.wall.under_band());310        let region = rail::beside(whole, &self.wall.bars);311        self.wall.slots.draw_at(312            &mut frame,313            &mut *self.posters.borrow_mut(),314            region,315            self.wall.standing(),316            self.wall.marks(self.held),317            card::LINES,318        );319        // The clip reaches past the region by the gap and the whole focus320        // stroke, so the mark on the first bar and on the button draws321        // whole.322        frame.with_clip(clip_marked(whole), |frame| {323            rail::draw(324                frame,325                whole,326                &self.wall.bars,327                &self.wall.slots.query,328                self.wall.marked_cell(self.held),329            );330        });331        vec![frame.into_geometry()]332    }333}334335// The part of the frame the grid scrolls in: under the band, and under336// the space that keeps the mark of a focused slot in the first row off337// what is over it.338// `under` is the height a layer of the wall's own takes under the band,339// the keyboard grid's, so the slots start under that layer340// and never behind it.341fn region(bounds: Rectangle, under: f32) -> Rectangle {342    let top = band::HEIGHT + under;343    area(344        0.0,345        top + wall::HEAD,346        bounds.width,347        bounds.height - top - wall::HEAD,348    )349}350351#[cfg(test)]352mod tests {353    use super::*;354    use crate::sample::Catalog;355356    const WIDTH: f32 = 1920.0;357    const HEIGHT: f32 = 1080.0;358359    fn frame() -> Rectangle {360        area(0.0, 0.0, WIDTH, HEIGHT)361    }362363    #[test]364    fn every_wall_starts_its_grid_under_the_band() {365        let bare = region(frame(), 0.0);366        assert_eq!(bare.y, band::HEIGHT + wall::HEAD);367        assert_eq!(bare.y + bare.height, HEIGHT);368369        let under = region(frame(), 200.0);370        assert!(under.y > bare.y, "{under:?}");371        assert_eq!(under.y + under.height, HEIGHT);372    }373374    #[test]375    fn a_row_of_posters_fits_under_the_band() {376        let cells = wall::lined(WIDTH, wall::POSTER, wall::COLUMNS, 1);377        assert!(cells.height <= region(frame(), 0.0).height);378    }379380    #[test]381    fn a_wall_showing_its_keyboard_grid_prefetches_nothing() {382        let query = Query::Search { text: "a".into() };383        let mut wall = Wall::open(query, &mut Catalog);384        wall.search = Some(search::Search::default());385        wall.show_grid();386        assert!(!wall.prefetches());387        assert_eq!(wall.resting(&mut Catalog), None);388    }389}
src/screens/wall/rail.rs 98.8%
1// The wall's own rail: which walls draw one, what its bars say, and2// where the sort button goes over them. The bars are the wall's order in3// the finest unit whose labels fit the screen, so a person crosses4// thousands of titles in two presses.56use iced_wgpu::Renderer;7use iced_widget::canvas;8use iced_winit::core::Rectangle;910use crate::catalog::{GenreSort, Query, Sort};11use crate::look;12use crate::screens::{Item, facts};13use crate::views::{area, mark, rail, rounded, text, wall};1415use crate::views::rail::Bar;1617/// One bar of the wall's rail: the bar the primitive draws, whose18/// `first` and `last` are rows, and the first item the bar covers, which19/// a select on it lands on. A letter range starts in the middle of a row,20/// so the row's own first item is not the bar's.21#[derive(Debug, Clone, PartialEq, Eq)]22pub struct Jump {23    pub bar: Bar,24    pub item: usize,25}2627/// How many screens of wall draw no rail. A wall this short is walked28/// faster than jumped.29pub const SCREENS: usize = 4;3031/// How many rows of the wall one screen holds.32pub const ROWS: usize = 2;3334// The height of the screen the browser is drawn for. The bars are built35// at the read, before any frame exists, so they are fitted at this36// height the way the series page fits its own.37const SCREEN: f32 = 1080.0;3839/// Whether a wall this many rows long draws a rail.40pub fn shows(rows: usize) -> bool {41    rows > SCREENS * ROWS42}4344/// How many rows this many items fill.45pub fn rows(items: usize) -> usize {46    items.div_ceil(wall::COLUMNS)47}4849/// The region the bars are fitted against at the read, which is50/// the wall's own region on a screen of [`SCREEN`]. A shorter window51/// draws the same bars in a shorter rail.52pub fn region() -> Rectangle {53    super::region(area(0.0, 0.0, 0.0, SCREEN), 0.0)54}5556/// How many cells the rail's focus walks: one for each bar, and57/// one more for the sort button where the wall draws one.58pub fn cells(bars: &[Jump], query: &Query) -> usize {59    bars.len() + first(query)60}6162/// The cell the first bar takes: the second on a wall that draws63/// a sort button, and the first on a wall that draws none.64pub fn first(query: &Query) -> usize {65    usize::from(query.sort_word().is_some())66}6768/// The bar one cell of the rail names, and nothing for the cell69/// the sort button takes.70pub fn barred_at(cell: usize, query: &Query) -> Option<usize> {71    cell.checked_sub(first(query))72}7374/// The bar that covers one row of the wall, which a right press75/// from that row moves onto.76pub fn covering(bars: &[Jump], row: usize) -> Option<usize> {77    bars.iter()78        .position(|jump| jump.bar.first <= row && row <= jump.bar.last)79}8081/// The part of the region the wall's own slots draw in, with the82/// rail's lane taken off the right of it.83pub fn beside(region: Rectangle, bars: &[Jump]) -> Rectangle {84    rail::beside_at(region, &drawn(bars), rail::Side::Right)85}8687// The bars alone, which is what the rail primitive draws.88fn drawn(bars: &[Jump]) -> Vec<Bar> {89    bars.iter().map(|jump| jump.bar.clone()).collect()90}9192/// Draw the rail at the right of the region: the sort button over93/// the bars, with the mark on the cell that holds focus.94pub fn draw(95    frame: &mut canvas::Frame<Renderer>,96    region: Rectangle,97    bars: &[Jump],98    query: &Query,99    focus: Option<usize>,100) {101    if bars.is_empty() {102        return;103    }104    if let Some(word) = query.sort_word() {105        pressed(frame, button(region, query), word, focus == Some(0));106    }107    rail::draw_at(108        frame,109        under_button(region, query),110        &drawn(bars),111        focus.and_then(|cell| barred_at(cell, query)),112        rail::Side::Right,113        rail::Fit::Fitted,114    );115}116117// The sort button in its own cell, on the lighter ground with full ink,118// so it reads as a control and not as one more bar.119fn pressed(frame: &mut canvas::Frame<Renderer>, cell: Rectangle, word: &str, focused: bool) {120    let bounds = rail::fitted(cell, &[Bar::default()], rail::Side::Right)121        .pop()122        .unwrap_or(cell);123    frame.fill(&rounded(bounds, ROUND), look::track());124    let shown = text::cut(word, look::HEADING, bounds.height);125    text::downward(frame, &shown, bounds, look::HEADING, look::text());126    if focused {127        mark(frame, bounds);128    }129}130131// The radius the button is drawn with, the same as a bar's.132const ROUND: f32 = 8.0;133134/// The box the sort button draws in, over the bars, and no height135/// at all on a wall that draws no button.136pub fn button(region: Rectangle, query: &Query) -> Rectangle {137    area(region.x, region.y, region.width, taken(region, query))138}139140/// The part of the region the bars draw in: under the button and141/// the space that keeps the bars off it on a wall that has one, and the142/// whole of it on a wall that has none.143pub fn under_button(region: Rectangle, query: &Query) -> Rectangle {144    let taken = taken(region, query) + gap(query);145    area(146        region.x,147        region.y + taken,148        region.width,149        (region.height - taken).max(0.0),150    )151}152153// The space between the button and the first bar, so the button reads154// as one control and the bars under it as one list.155const BUTTON_GAP: f32 = 24.0;156157fn gap(query: &Query) -> f32 {158    match query.sort_word() {159        Some(_) => BUTTON_GAP,160        None => 0.0,161    }162}163164// The height the sort button takes off the region: one bar of a rail165// sized for the button's own word, so the word reads whole and the bars166// under it are fitted to what is left.167fn taken(region: Rectangle, query: &Query) -> f32 {168    match query.sort_word() {169        Some(word) => region.height / rail::fits(region, word) as f32,170        None => 0.0,171    }172}173174/// The bars of the rail beside these items, in the query's own175/// order, and none where the wall is short or the query draws no rail.176/// `region` is the whole region the wall and the rail share; the bars177/// are fitted to what the sort button leaves of it.178pub fn bars(items: &[Item], query: &Query, region: Rectangle) -> Vec<Jump> {179    let rows = rows(items.len());180    if !shows(rows) {181        return Vec::new();182    }183    let under = under_button(region, query);184    let units = match order(query) {185        Some(Order::Release) => dated(items, under),186        Some(Order::Title) => lettered(items, under),187        Some(Order::Leads) => leads(items, query.name("")),188        None => return Vec::new(),189    };190    barred(&units, rows)191}192193// The three shapes a wall's order takes on the rail.194enum Order {195    Release,196    Title,197    Leads,198}199200// Which shape one query's order takes, or none on a wall that draws no201// rail.202fn order(query: &Query) -> Option<Order> {203    match query {204        Query::Library { sort, .. } => Some(sorted(*sort)),205        Query::Genre {206            sort: GenreSort::Leads,207            ..208        } => Some(Order::Leads),209        Query::Genre {210            sort: GenreSort::By(sort),211            ..212        } => Some(sorted(*sort)),213        Query::Released { .. } | Query::Added { .. } => Some(Order::Release),214        _ => None,215    }216}217218fn sorted(sort: Sort) -> Order {219    match sort {220        Sort::Title => Order::Title,221        Sort::Newest | Sort::Oldest => Order::Release,222    }223}224225// One stretch of the wall's order before it is measured in rows:226// what it is called, where it starts, and how many items it holds.227#[derive(Debug, Clone, PartialEq, Eq)]228struct Unit {229    label: String,230    first: usize,231    count: usize,232}233234// The units of a wall in release order: years while the years235// fit the region, decades where they do not, and even ranges of decades236// where the decades do not fit either.237fn dated(items: &[Item], region: Rectangle) -> Vec<Unit> {238    let years = merged(runs(items, |item| facts::year(&item.released).to_string()));239    if years.len() <= rail::fits(region, longest(&years)) {240        return years;241    }242    fitted(merged(runs(items, |item| decade(&item.released))), region)243}244245// The units of a wall in title order: single letters while the246// letters fit the region, and even ranges of them where they do not.247fn lettered(items: &[Item], region: Rectangle) -> Vec<Unit> {248    fitted(merged(runs(items, |item| letter(&item.name))), region)249}250251// These units folded into as many bars as the region holds. A252// range's label is longer than one unit's, and a longer label fits253// fewer bars, so the count is asked again until the labels of the count254// answered fit the region.255fn fitted(units: Vec<Unit>, region: Rectangle) -> Vec<Unit> {256    let mut count = units.len();257    loop {258        let held = ranged(&units, count);259        let fits = rail::fits(region, longest(&held));260        if fits >= count {261            return held;262        }263        count = fits;264    }265}266267// The longest label of these units, which is the one the rail has268// to hold.269fn longest(units: &[Unit]) -> &str {270    units271        .iter()272        .max_by_key(|unit| unit.label.chars().count())273        .map(|unit| unit.label.as_str())274        .unwrap_or_default()275}276277// The two units of a genre wall in its leading order: the run278// that leads with the genre, then the rest.279fn leads(items: &[Item], genre: String) -> Vec<Unit> {280    let boundary = restarts(items).unwrap_or(items.len());281    let mut units = Vec::new();282    if boundary > 0 {283        units.push(Unit {284            label: format!("primarily {genre}"),285            first: 0,286            count: boundary,287        });288    }289    if boundary < items.len() {290        units.push(Unit {291            label: format!("other {genre}"),292            first: boundary,293            count: items.len() - boundary,294        });295    }296    units297}298299// Where the leading run ends. The order is two runs, each newest first,300// so the first item whose release is later than the one before it is301// the first item that does not lead with the genre. The rank the read302// ordered by does not reach the wall, so this is what the wall can see303// of it; it is wrong only when the second run's newest title is older304// than the first run's oldest.305fn restarts(items: &[Item]) -> Option<usize> {306    items307        .windows(2)308        .position(|pair| pair[1].released > pair[0].released)309        .map(|index| index + 1)310}311312// The runs of items that share one word, in the wall's own order.313fn runs<K: Fn(&Item) -> String>(items: &[Item], key: K) -> Vec<Unit> {314    let mut units: Vec<Unit> = Vec::new();315    for (index, item) in items.iter().enumerate() {316        let label = key(item);317        match units.last_mut() {318            Some(unit) if unit.label == label => unit.count += 1,319            _ => units.push(Unit {320                label,321                first: index,322                count: 1,323            }),324        }325    }326    units327}328329// The units with every unit of less than one row folded into its330// neighbour, so no bar jumps to a row another bar already holds. A unit331// the catalog named nothing folds the same way.332fn merged(units: Vec<Unit>) -> Vec<Unit> {333    let mut kept: Vec<Unit> = Vec::new();334    for unit in units {335        match kept.last_mut() {336            Some(last) if thin(&unit) => last.count += unit.count,337            _ => kept.push(unit),338        }339    }340    while kept.len() > 1 && thin(&kept[0]) {341        let next = kept.remove(1);342        kept[0].count += next.count;343        kept[0].label = next.label;344    }345    kept346}347348fn thin(unit: &Unit) -> bool {349    unit.count < wall::COLUMNS || unit.label.is_empty()350}351352// This many units folded into this many even ranges, each one353// named by the first and the last unit it holds.354fn ranged(units: &[Unit], slots: usize) -> Vec<Unit> {355    let count = slots.max(1).min(units.len());356    (0..count)357        .map(|index| {358            let start = index * units.len() / count;359            let end = (index + 1) * units.len() / count;360            let first = &units[start];361            let last = &units[end - 1];362            Unit {363                label: spanned(&first.label, &last.label),364                first: first.first,365                count: last.first + last.count - first.first,366            }367        })368        .collect()369}370371// What a range of units is called, and the one word where the372// range holds one unit.373fn spanned(first: &str, last: &str) -> String {374    match first == last {375        true => first.to_string(),376        false => format!("{first}\u{2013}{last}"),377    }378}379380// The units as bars of the rail: every bar starts on the row its381// unit starts on, no two bars start on one row, and the last bar runs to382// the foot of the wall, so the bars cover every row and no row twice.383fn barred(units: &[Unit], rows: usize) -> Vec<Jump> {384    let mut bars: Vec<Jump> = Vec::new();385    for unit in units {386        let start = match bars.last() {387            Some(last) => (unit.first / wall::COLUMNS).max(last.bar.first + 1),388            None => 0,389        };390        if start >= rows {391            continue;392        }393        if let Some(last) = bars.last_mut() {394            last.bar.last = start - 1;395        }396        bars.push(Jump {397            bar: Bar {398                label: unit.label.clone(),399                first: start,400                last: rows - 1,401                lane: 0,402            },403            item: unit.first.max(start * wall::COLUMNS),404        });405    }406    bars407}408409// The decade one release date falls in, and nothing where the410// catalog holds no date.411fn decade(released: &str) -> String {412    let year = facts::year(released);413    match year.len() {414        4 => format!("{}0s", &year[..3]),415        _ => String::new(),416    }417}418419// The letter one title is listed under: the first letter of the sort420// key, which is the title without its leading article, folded to upper421// case. A title that starts with anything but a letter is listed under the422// one word every other first character shares.423fn letter(title: &str) -> String {424    let key = keyed(title);425    match key.chars().next() {426        Some(first) if first.is_ascii_alphabetic() => first.to_ascii_uppercase().to_string(),427        _ => OTHER.to_string(),428    }429}430431// The label a title is listed under when its first character is not a432// letter.433const OTHER: &str = "#";434435// The title without its leading article, which is what the436// catalog sorts a title order by.437fn keyed(title: &str) -> &str {438    for article in ["The ", "A ", "An "] {439        if title440            .get(..article.len())441            .is_some_and(|head| head.eq_ignore_ascii_case(article))442        {443            return &title[article.len()..];444        }445    }446    title447}448449#[cfg(test)]450mod tests;
src/screens/wall/search.rs 97.8%
1// The search half of a wall: the field a person types in, which the2// browser's strip draws, and the grid a remote with no keyboard picks3// from, which this module draws as a layer over the band. A wall over4// any other query holds none of it.56use std::convert::Infallible;78use iced_wgpu::Renderer;9use iced_widget::canvas;10use iced_winit::core::{Point, Rectangle, Theme, mouse};1112use super::{Step, Wall};13use crate::catalog::{Query, Source};14use crate::screens::Screen;15use crate::views::band;16use crate::views::field::{self, TextField};17use crate::views::keyboard::{self, Keyboard};1819/// What a wall over a `Search` holds. A wall that types always has a20/// field and has a grid only while the grid is shown, so the type says21/// which of the two a press can reach.22#[derive(Debug, Default)]23pub struct Search {24    pub field: TextField,25    pub keyboard: Option<Keyboard>,26}2728/// The search wall over this text, with the grid shown or hidden, as the29/// screen the browser pushes. Every way in lands here: a letter on30/// another screen opens it seeded with the grid hidden, and the search31/// key and the strip's icon open it empty with the grid shown.32pub fn searched(text: &str, grid: bool, source: &mut dyn Source) -> Screen {33    let query = Query::Search {34        text: text.to_string(),35    };36    let mut wall = Wall::open(query, source);37    wall.search = Some(Search {38        field: TextField::of(text),39        keyboard: grid.then(Keyboard::default),40    });41    Screen::Wall(Box::new(wall))42}4344impl Wall {45    // The presses a search wall takes for itself: a letter, a digit, or46    // the space types and hides the grid, and while the grid is shown the47    // arrows move it and enter presses its cell. The answer is nothing48    // where the press was none of these, so it reaches the band and the49    // slots the way it does on every other wall.50    pub(super) fn typed(&mut self, key: &str, source: &mut dyn Source) -> Option<Step> {51        let search = self.search.as_mut()?;52        let changed = if field::edits(key) {53            search.keyboard = None;54            search.field.press(key)55        } else {56            match &mut search.keyboard {57                None => return None,58                Some(grid) if key == "enter" => {59                    let word = grid.pick();60                    search.field.press(word)61                }62                // An arrow at the edge of the grid moves nothing, and63                // the frame on the glass still draws it.64                Some(grid) => return Some(step(grid.key(key))),65            }66        };67        if changed {68            self.retyped(source);69        }70        Some(step(changed))71    }7273    // The press that leaves a screen, as a search wall reads it:74    // backspace removes a character and escape clears the field, and75    // both read the wall again. A wall that types nothing answers76    // nothing, and the browser then goes back.77    pub(super) fn cleared(&mut self, key: &str, source: &mut dyn Source) -> Option<Step> {78        let search = self.search.as_mut()?;79        let changed = match key {80            "backspace" => search.field.press(key),81            _ => {82                let held = !search.field.text().is_empty();83                search.field = TextField::default();84                held85            }86        };87        if !changed {88            return None;89        }90        self.retyped(source);91        Some(Step::Stay)92    }9394    // Read the wall again over the text the field now holds. The query95    // carries the text, so the read, the heading, and the count follow96    // from it, and focus starts at the best hit.97    fn retyped(&mut self, source: &mut dyn Source) {98        let Some(search) = &self.search else {99            return;100        };101        self.slots.query = Query::Search {102            text: search.field.text().to_string(),103        };104        self.slots.focus = 0;105        self.reread(source);106    }107108    /// Show the grid. The browser drops the strip's focus with it, so109    /// one place on the screen holds focus.110    pub fn show_grid(&mut self) {111        if let Some(search) = &mut self.search {112            search.keyboard = Some(Keyboard::default());113        }114    }115116    // Whether the grid a remote picks letters from is shown.117    pub(super) fn grid(&self) -> Option<&Keyboard> {118        self.search.as_ref()?.keyboard.as_ref()119    }120}121122// The search wall's own layer over the band: the grid, while it is123// shown. The field draws in the browser's strip.124pub(super) struct Typing<'a> {125    pub(super) keyboard: &'a Keyboard,126}127128impl canvas::Program<Infallible, Theme, Renderer> for Typing<'_> {129    type State = ();130131    fn draw(132        &self,133        _state: &Self::State,134        renderer: &Renderer,135        _theme: &Theme,136        bounds: Rectangle,137        _cursor: mouse::Cursor,138    ) -> Vec<canvas::Geometry<Renderer>> {139        let mut frame = canvas::Frame::new(renderer, bounds.size());140        keyboard::draw(&mut frame, grid_at(bounds.width), self.keyboard);141        vec![frame.into_geometry()]142    }143}144145// The step a press answers: a change the screen drew, or nothing at all.146fn step(changed: bool) -> Step {147    match changed {148        true => Step::Stay,149        false => Step::Still,150    }151}152153// Where the grid's first cell goes: centered across the frame, a space154// under the band.155pub(super) fn grid_at(width: f32) -> Point {156    Point::new((width - keyboard::width()) / 2.0, band::HEIGHT + GRID_TOP)157}158159// The space over the grid and the space under it, before the slots.160const GRID_TOP: f32 = 24.0;161const GRID_FOOT: f32 = 24.0;162163// The height the grid takes off the top of the slots' region, and none164// where no grid is shown.165pub(super) fn grid_height(shown: bool) -> f32 {166    match shown {167        true => GRID_TOP + keyboard::height() + GRID_FOOT,168        false => 0.0,169    }170}171172#[cfg(test)]173mod tests {174    use super::*;175    use crate::screens::wall::region;176    use crate::views::area;177178    const WIDTH: f32 = 1920.0;179    const HEIGHT: f32 = 1080.0;180181    #[test]182    fn the_shown_grid_stands_between_the_band_and_the_slots() {183        let at = grid_at(WIDTH);184        assert_eq!(at.x + keyboard::width(), WIDTH - at.x);185        assert!(at.y > band::HEIGHT);186187        let under = region(area(0.0, 0.0, WIDTH, HEIGHT), grid_height(true));188        assert!(under.y >= at.y + keyboard::height(), "{under:?}");189        assert!(under.height > 0.0, "{under:?}");190        assert_eq!(under.y + under.height, HEIGHT);191    }192}
src/views.rs 97.2%
1// The drawing layer: the primitives a screen composes, and the culling2// math they share. The primitives are a wall of art slots, a band across3// the top of a wall, a block of text, a row of buttons, a strip of4// posters and stills at one height with a "see all" slot where a screen5// asks for one, a divider between two runs of a wall, and the scrolled6// stack a page is. A screen chooses which of them it draws and where. No7// primitive reads a kind.8//9// A jump rail of rotated bars at the left of a long wall is one more.10//11// The text field a person types a search into, and the grid of letters12// a remote with no keyboard picks from, are two more.13//14// The two lines under a slot are one primitive of their own, the card,15// which the strip and the wall both draw.16//17// A page draws in the layers the `layers` module stacks, because inside18// one layer the renderer draws every mesh, then every image, then every19// text, whatever order the canvas drew them in. That one fact decides20// three rules here: a focus mark strokes outside the slot it marks, art21// the person did not choose dims by the image's own opacity, and a22// backdrop is a layer of its own under everything a screen draws.2324pub mod band;25pub mod banner;26pub mod buttons;27pub mod card;28pub mod clock;29pub mod curtain;30pub mod divider;31pub mod field;32pub mod header;33pub mod keyboard;34pub mod layers;35pub mod people;36pub mod rail;37pub mod ratings;38pub mod scroll;39pub mod stack;40pub mod strip;41pub mod text;42pub mod volume;43pub mod wall;4445use iced_wgpu::Renderer;46use iced_widget::canvas;47use iced_winit::core::alignment::Vertical;48use iced_winit::core::text::{Alignment, LineHeight, Shaping};49use iced_winit::core::{Color, Font, Pixels, Point, Rectangle, Size};5051use crate::look;52use crate::posters::{Art, Posters};5354/// What a primitive reads off one of a screen's items. A screen55/// implements it for the rows it holds, so a wall, a list, and a strip56/// draw the screen's own type and copy nothing.57pub trait Card {58    /// The art path the poster store resolves, empty where the item has59    /// none.60    fn art(&self) -> &str {61        ""62    }6364    /// The library the art path resolves against, empty where every item65    /// of the primitive is in the library the caller names. A person's66    /// works span libraries, so each of those slots names its own.67    fn library(&self) -> &str {68        ""69    }7071    /// The name a person reads. A slot whose art has not arrived shows72    /// it.73    fn name(&self) -> &str;7475    /// The second line under a headshot in a stripe: the part the person76    /// played.77    fn detail(&self) -> &str {78        ""79    }8081    /// The words a card leads with, which a wall of one line draws muted82    /// under every slot.83    fn caption(&self) -> &str {84        self.name()85    }8687    /// The caption cut by the shaper at the read to the band the card88    /// draws it in. An item that measures none answers its caption and89    /// takes the band's own clip.90    fn fitted(&self) -> &str {91        self.caption()92    }9394    /// The second line of a card, small, faint, and italic under the95    /// first. A person's works put the parts the person played here,96    /// except where every part left is an `as` run: then the character97    /// leads the card and the title and the year stand here. Every wall98    /// that draws one line leaves it empty.99    fn under(&self) -> &str {100        ""101    }102103    /// The second line cut to the same band, the way the caption is.104    fn under_fitted(&self) -> &str {105        self.under()106    }107108    /// Whether the words the card leads with are a film's tagline, which109    /// draws in the italic face. A title draws in the roman one.110    fn leads_with_tagline(&self) -> bool {111        false112    }113114    /// The line under the focused slot of a wall, drawn bright: the whole115    /// facts of the row that fit in this many characters.116    fn line_fitting(&self, _chars: usize) -> &str {117        self.caption()118    }119120    /// How many episodes of a folded show are current, which a strip121    /// draws as a pill over the still. Zero on every other item.122    fn new_episodes(&self) -> usize {123        0124    }125126    /// The height of the item's art as a share of its width, which a strip127    /// draws each slot at. A poster, unless the item says otherwise, and an128    /// episode's still says otherwise.129    fn ratio(&self) -> f32 {130        wall::POSTER131    }132133    /// The posters a shelf draws as a mosaic in its slot, each with the134    /// library it resolves against, and empty for every item that draws135    /// one art of its own.136    fn tiles(&self) -> &[(String, String)] {137        &[]138    }139}140141/// How bright a slot's art draws.142#[derive(Debug, Clone, Copy, PartialEq)]143pub enum Tone {144    /// The art as it is.145    Full,146    /// The art of a slot the screen drew but the person did not choose,147    /// such as a sibling in a set strip.148    Dimmed,149    /// The art at an opacity a motion states, from 0 clear to 1 as it is.150    At(f32),151}152153impl Tone {154    // The opacity the renderer draws the image at. The veil is the image's155    // own opacity and not a fill over it, because every fill of a layer156    // draws under every image of that layer.157    fn opacity(self) -> f32 {158        match self {159            Self::Full => 1.0,160            Self::Dimmed => look::DIM,161            Self::At(opacity) => opacity,162        }163    }164}165166// Both programs draw art through this one function, so one place167// enforces the rule: the store is asked only for a slot that is drawn,168// at the slot's exact pixel size, and never for a row with no art169// path. Until a poster arrives, the slot shows the ground color and170// the row's name.171pub(crate) fn artwork<P: Posters>(172    frame: &mut canvas::Frame<Renderer>,173    posters: &mut P,174    library: &str,175    art: &str,176    slot: Rectangle,177    name: &str,178    tone: Tone,179) {180    if !art.is_empty()181        && let Some(poster) = posters.poster(library, art, slot.width as u32, slot.height as u32)182    {183        // The ground under a dimmed slot is the black one, so a sibling184        // darkens by the same amount whatever art lies behind the slot.185        if tone == Tone::Dimmed {186            frame.fill_rectangle(slot.position(), slot.size(), look::BACKGROUND);187        }188        paint(frame, &poster, slot, tone);189        return;190    }191192    // The name in an empty frame shrinks with the frame, so a headshot193    // slot fits a whole word per line where a poster slot fits several.194    frame.fill_rectangle(slot.position(), slot.size(), look::slot());195    if !name.is_empty() {196        frame.fill_text(label(197            name,198            Point::new(slot.center_x(), slot.center_y()),199            look::DETAIL.min(slot.width / 8.0),200            look::muted(),201            Alignment::Center,202            Vertical::Center,203            slot.width - 16.0,204        ));205    }206}207208// The ground the slot shows between two cells of a mosaic.209const CELL_GAP: f32 = 2.0;210211/// The four cells of a mosaic in reading order: the slot halved across,212/// less the gap between the halves, and each cell at the poster's own213/// ratio, so a cell crops nothing. The grid centers in the slot where the214/// ratio leaves it a fraction of a pixel short.215pub fn quarters(slot: Rectangle) -> [Rectangle; 4] {216    let width = (slot.width - CELL_GAP) / 2.0;217    let height = width * wall::POSTER;218    let left = slot.x;219    let right = slot.x + width + CELL_GAP;220    let top = slot.y + (slot.height - 2.0 * height - CELL_GAP) / 2.0;221    let bottom = top + height + CELL_GAP;222    [223        area(left, top, width, height),224        area(right, top, width, height),225        area(left, bottom, width, height),226        area(right, bottom, width, height),227    ]228}229230/// The library and the path of one cell of a mosaic, and two empty231/// strings where the shelf holds fewer posters than the grid has cells.232pub fn cell(tiles: &[(String, String)], index: usize) -> (&str, &str) {233    match tiles.get(index) {234        Some((library, art)) => (library.as_str(), art.as_str()),235        None => ("", ""),236    }237}238239// A 2x2 of posters in one slot. Every cell goes through the call one240// poster goes through, so the store decodes a quarter-size poster the way241// it decodes a whole one, and a cell whose art has not landed draws the242// ground an empty slot draws.243pub(crate) fn mosaic<P: Posters>(244    frame: &mut canvas::Frame<Renderer>,245    posters: &mut P,246    tiles: &[(String, String)],247    slot: Rectangle,248    tone: Tone,249) {250    for (index, cell_of) in quarters(slot).into_iter().enumerate() {251        let (library, art) = cell(tiles, index);252        artwork(frame, posters, library, art, cell_of, "", tone);253    }254}255256// Art draws band by band, each band into its share of the rectangle,257// because the renderer uploads an image of two megabytes or more on a258// later frame, and this client draws no later frame until an event.259fn paint(frame: &mut canvas::Frame<Renderer>, art: &Art, into: Rectangle, tone: Tone) {260    for (band, handle) in art.bands(into) {261        frame.draw_image(band, canvas::Image::new(handle).opacity(tone.opacity()));262    }263}264265/// How far the focus mark reaches past the edge of the slot it marks: the266/// stroke's outer edge.267pub const REACH: f32 = look::MARK_GAP + look::MARK;268269// How far the center line of the focus stroke sits outside the slot: the270// gap, then half of the stroke, so the stroke's inner edge is the gap271// away from the art.272const OUTSET: f32 = look::MARK_GAP + look::MARK / 2.0;273274/// The rectangle the focus stroke follows, outside the slot's own edge. A275/// stroke is a mesh, and every mesh of a layer draws under every image of276/// that layer, so a stroke on the edge would lose its inner half.277pub fn marked(slot: Rectangle) -> Rectangle {278    area(279        slot.x - OUTSET,280        slot.y - OUTSET,281        slot.width + 2.0 * OUTSET,282        slot.height + 2.0 * OUTSET,283    )284}285286/// The rectangle a clip has to reach for the focus stroke on a slot to287/// draw whole. The stroke is centered on `marked`'s edge, so a clip of288/// that box cuts the stroke's outer half; this one reaches the gap and289/// the whole stroke.290pub fn clip_marked(slot: Rectangle) -> Rectangle {291    let reach = OUTSET + look::MARK / 2.0;292    area(293        slot.x - reach,294        slot.y - reach,295        slot.width + 2.0 * reach,296        slot.height + 2.0 * reach,297    )298}299300/// The bar that marks the current member of a strip: the bottom edge of301/// the focus rectangle alone, so it reads as a place and not as focus.302pub fn underlined(slot: Rectangle) -> Rectangle {303    let around = marked(slot);304    area(305        around.x - look::MARK / 2.0,306        around.y + around.height - look::MARK / 2.0,307        around.width + look::MARK,308        look::MARK,309    )310}311312// The one mark focus takes everywhere on this screen: a stroke of the313// accent outside the chosen slot.314pub(crate) fn mark(frame: &mut canvas::Frame<Renderer>, slot: Rectangle) {315    let around = marked(slot);316    frame.stroke_rectangle(317        around.position(),318        extent(around),319        canvas::Stroke::default()320            .with_color(look::mark())321            .with_width(look::MARK),322    );323}324325// The underline that marks the current member of a strip, in the same326// color as the focus stroke, so one word of the look says "here".327fn underline(frame: &mut canvas::Frame<Renderer>, slot: Rectangle) {328    let bar = underlined(slot);329    frame.fill_rectangle(bar.position(), extent(bar), look::mark());330}331332// One canvas text with the display's font and shaping, so every line333// on the screen is set the same way.334fn label(335    content: &str,336    position: Point,337    size: f32,338    color: Color,339    align_x: Alignment,340    align_y: Vertical,341    max_width: f32,342) -> canvas::Text {343    canvas::Text {344        content: content.to_string(),345        position,346        color,347        size: Pixels(size),348        // A block of text is measured in lines before it is drawn, so349        // the line height is stated in pixels here and not left to the350        // toolkit's ratio.351        line_height: LineHeight::Absolute(Pixels(size * text::LEADING)),352        font: Font::with_name(look::FONT),353        align_x,354        align_y,355        max_width,356        shaping: Shaping::Advanced,357    }358}359360// One rounded rectangle. A radius wider than half the shape has no meaning,361// so a bar with a few pixels of fill rounds by what it has.362pub(crate) fn rounded(shape: Rectangle, radius: f32) -> canvas::Path {363    let radius = radius.min(shape.width / 2.0).min(shape.height / 2.0);364    canvas::Path::rounded_rectangle(365        shape.position(),366        Size::new(shape.width, shape.height),367        radius.into(),368    )369}370371// A rectangle from its corner and its size, the shape every primitive372// builds its geometry from.373pub(crate) fn area(x: f32, y: f32, width: f32, height: f32) -> Rectangle {374    Rectangle {375        x,376        y,377        width,378        height,379    }380}381382// The size of a rectangle, for the fills that take one.383pub(crate) fn extent(area: Rectangle) -> Size {384    Size::new(area.width, area.height)385}386387#[cfg(test)]388mod tests {389    use super::*;390391    fn slot() -> Rectangle {392        area(100.0, 200.0, 300.0, 450.0)393    }394395    #[test]396    fn the_mark_lies_outside_the_slot_it_marks() {397        let slot = slot();398        let around = marked(slot);399        let inner = look::MARK / 2.0;400        assert!(around.x + inner < slot.x);401        assert!(around.y + inner < slot.y);402        assert!(around.x + around.width - inner > slot.x + slot.width);403        assert!(around.y + around.height - inner > slot.y + slot.height);404    }405406    #[test]407    fn the_mark_reaches_no_further_than_its_reach() {408        let slot = slot();409        let around = marked(slot);410        assert_eq!(slot.y - (around.y - look::MARK / 2.0), REACH);411        assert_eq!(around.center_x(), slot.center_x());412    }413414    #[test]415    fn a_mosaic_fills_its_slot_with_four_cells_at_the_posters_ratio() {416        let slot = area(10.0, 20.0, strip::poster_width(), strip::POSTER);417        let cells = quarters(slot);418        for cell in cells {419            assert_eq!(cell.width, (slot.width - CELL_GAP) / 2.0);420            assert_eq!(cell.height, cell.width * wall::POSTER);421        }422        assert_eq!(cells[0].x, slot.x);423        assert_eq!(cells[1].x, cells[0].x + cells[0].width + CELL_GAP);424        assert_eq!(cells[2].x, cells[0].x);425        assert_eq!(cells[3].x, cells[1].x);426        assert_eq!(cells[1].y, cells[0].y);427        assert_eq!(cells[2].y, cells[0].y + cells[0].height + CELL_GAP);428        assert_eq!(cells[3].y, cells[2].y);429        assert_eq!(cells[1].x + cells[1].width, slot.x + slot.width);430    }431432    #[test]433    fn a_mosaic_stays_inside_the_slot_it_draws_in() {434        let slot = area(10.0, 20.0, strip::poster_width(), strip::POSTER);435        let cells = quarters(slot);436        let over = cells[0].y - slot.y;437        let under = slot.y + slot.height - (cells[2].y + cells[2].height);438        assert!((0.0..1.0).contains(&over));439        assert!((over - under).abs() < 1e-3);440    }441442    #[test]443    fn a_cell_the_shelf_holds_no_poster_for_names_no_art() {444        let tiles = [("screening/films".to_string(), "posters/one.jpg".to_string())];445        assert_eq!(cell(&tiles, 0), ("screening/films", "posters/one.jpg"));446        assert_eq!(cell(&tiles, 1), ("", ""));447        assert_eq!(cell(&[], 0), ("", ""));448    }449450    #[test]451    fn art_the_person_chose_draws_at_full_opacity() {452        assert_eq!(Tone::Full.opacity(), 1.0);453        assert_eq!(Tone::Dimmed.opacity(), look::DIM);454        assert_eq!(Tone::At(0.25).opacity(), 0.25);455    }456457    struct Named;458459    impl Card for Named {460        fn name(&self) -> &str {461            "A Title"462        }463    }464465    #[test]466    fn a_card_states_nothing_but_its_name_unless_it_says_otherwise() {467        assert_eq!(Named.art(), "");468        assert_eq!(Named.library(), "");469        assert_eq!(Named.detail(), "");470        assert_eq!(Named.under(), "");471        assert_eq!(Named.caption(), "A Title");472        assert!(!Named.leads_with_tagline());473        assert_eq!(Named.line_fitting(3), "A Title");474        assert_eq!(Named.new_episodes(), 0);475        assert_eq!(Named.ratio(), wall::POSTER);476    }477}
src/views/band.rs 100.0%
1// The band across the top of a wall draws the heading alone. It is a2// layer of its own over the screen, because a row that scrolls up under3// it must not show through, and inside one layer the renderer draws4// every fill, then every image, then every text, whatever the order they5// were drawn in.67use std::convert::Infallible;89use iced_wgpu::Renderer;10use iced_widget::canvas;11use iced_winit::core::alignment::Vertical;12use iced_winit::core::text::Alignment;13use iced_winit::core::{Element, Length, Point, Rectangle, Theme, mouse};1415use super::{area, extent, label};16use crate::look;1718/// The height the band takes off the top of the frame.19pub const HEIGHT: f32 = 84.0;2021/// The margin at both ends of the band.22pub const PAD: f32 = 32.0;2324/// Draw the band. `heading` is what the screen is about.25pub fn draw(frame: &mut canvas::Frame<Renderer>, width: f32, heading: &str) {26    // The band paints its own ground, so nothing under its layer shows27    // through it.28    let ground = area(0.0, 0.0, width, HEIGHT);29    frame.fill_rectangle(ground.position(), extent(ground), look::BACKGROUND);30    frame.fill_text(label(31        heading,32        Point::new(PAD, HEIGHT / 2.0),33        look::NAME,34        look::text(),35        Alignment::Left,36        Vertical::Center,37        width / 2.0,38    ));3940    let rule = area(0.0, HEIGHT - 2.0, width, 2.0);41    frame.fill_rectangle(rule.position(), extent(rule), look::slot());42}4344/// The band as a layer over a screen: what it says.45pub struct Layer<'a> {46    pub heading: &'a str,47}4849impl canvas::Program<Infallible, Theme, Renderer> for Layer<'_> {50    type State = ();5152    fn draw(53        &self,54        _state: &Self::State,55        renderer: &Renderer,56        _theme: &Theme,57        bounds: Rectangle,58        _cursor: mouse::Cursor,59    ) -> Vec<canvas::Geometry<Renderer>> {60        let mut frame = canvas::Frame::new(renderer, bounds.size());61        draw(&mut frame, bounds.width, self.heading);62        vec![frame.into_geometry()]63    }64}6566/// The band's layer as an element a screen stacks over its own.67pub fn layer(heading: &str) -> Element<'_, Infallible, Theme, Renderer> {68    canvas(Layer { heading })69        .width(Length::Fill)70        .height(Length::Fill)71        .into()72}
src/views/banner.rs 100.0%
1// The banner is two primitives on two layers. A mesh in one layer2// draws under every image of that layer, so the scrim over the backdrop3// needs the backdrop on a layer of its own. This module holds the4// frame's geometry, the backdrop under it, and the scrim, the head, the5// facts, the genres, the scores, the tagline, the indicators, and the6// mark over it.78use iced_wgpu::Renderer;9use iced_widget::canvas;10use iced_winit::core::{Point, Rectangle};1112use super::stack::Stack;13use super::{Tone, area, extent, header, layers, mark, paint, ratings, text};14use crate::look;15use crate::posters::Posters;1617// The share of the page's height the frame takes.18const SHARE: f32 = 0.40;1920// The inset from the frame's left edge to its text and its indicators,21// and from its top edge to its first line.22const INSET: f32 = 64.0;23const TOP: f32 = 40.0;2425// The share of the frame's width the text column takes. The column26// ends inside the scrim's full shade, so every line reads over the art27// whatever the art holds.28const COLUMN: f32 = 0.42;2930// The space between two blocks of text.31const GAP: f32 = 10.0;3233// The box a logo draws in, at the proportions the metadata tools write34// a logo file in.35const LOGO_WIDTH: f32 = 460.0;36const LOGO_HEIGHT: f32 = 110.0;3738// The lines the tagline is cut to.39const TAGLINE_LINES: usize = 2;4041// One indicator's size, the gap between two, and the space under the42// row before the frame's foot.43const INDICATOR_WIDTH: f32 = 36.0;44const INDICATOR_HEIGHT: f32 = 4.0;45const INDICATOR_GAP: f32 = 10.0;46const FOOT: f32 = 28.0;4748/// The height the frame takes on a page this tall.49pub fn height(page: f32) -> f32 {50    (page * SHARE).max(least()).round()51}5253// The least the frame can be: the space over the head, the head, the54// facts line, the genres line, the ratings row, the tagline, the gaps55// between them, and the indicator row over the foot. Every size here is56// a fixed number of pixels, so a page under about 925 pixels tall takes57// this and not the share, and the column never runs into the indicators.58fn least() -> f32 {59    TOP + LOGO_HEIGHT60        + GAP61        + text::height(1, look::FACTS)62        + GAP63        + text::height(1, look::FACTS)64        + GAP65        + ratings::HEIGHT66        + GAP67        + text::height(TAGLINE_LINES, look::TAGLINE)68        + INDICATOR_HEIGHT69        + FOOT70}7172/// The indicator of one title, inside the frame's foot.73pub fn indicator(region: Rectangle, index: usize) -> Rectangle {74    area(75        region.x + INSET + index as f32 * (INDICATOR_WIDTH + INDICATOR_GAP),76        region.y + region.height - FOOT - INDICATOR_HEIGHT,77        INDICATOR_WIDTH,78        INDICATOR_HEIGHT,79    )80}8182/// The under layer: the backdrop over the frame, and the slot color83/// until it lands. The backdrop is decoded at the frame's size, so a84/// title costs one decode and never a page's.85pub fn backdrop<P: Posters>(86    frame: &mut canvas::Frame<Renderer>,87    posters: &mut P,88    library: &str,89    art: &str,90    region: Rectangle,91) {92    match posters.poster(library, art, region.width as u32, region.height as u32) {93        Some(image) => paint(frame, &image, region, Tone::Full),94        None => frame.fill_rectangle(region.position(), extent(region), look::slot()),95    }96}9798/// One banner to draw over its backdrop: the current title's words, the99/// count, the current index, the focus, and the frame.100pub struct Banner<'a> {101    /// The library the art paths resolve against.102    pub library: &'a str,103    /// The logo path, empty where the title has none.104    pub logo: &'a str,105    /// The name, drawn where the title has no logo.106    pub name: &'a str,107    /// The facts line under the head.108    pub facts: &'a str,109    /// The genres on one line, cut with an ellipsis where they run past110    /// the column.111    pub genres: &'a str,112    /// The scores the ratings row draws. An empty slice draws no row and113    /// takes no height.114    pub ratings: &'a [ratings::Score],115    /// The tagline, empty where the title has none.116    pub tagline: &'a str,117    /// How many titles the banner holds.118    pub count: usize,119    /// The index of the title the frame shows.120    pub current: usize,121    /// Whether the banner holds focus.122    pub focused: bool,123    /// The frame in the page's space.124    pub region: Rectangle,125}126127/// The over layer: the scrim, the head, the facts, the tagline, the128/// indicators, and the mark while focused.129pub fn draw<P: Posters>(frame: &mut canvas::Frame<Renderer>, posters: &mut P, banner: &Banner<'_>) {130    let region = banner.region;131    layers::scrim(frame, region);132133    let column = region.width * COLUMN;134    let mut stack = Stack::new(Point::new(region.x + INSET, region.y + TOP), GAP);135    let head = area(stack.at().x, stack.at().y, column, LOGO_HEIGHT);136    let taken = frame.with_clip(head, |frame| {137        header::title(138            frame,139            posters,140            &header::Title {141                library: banner.library,142                logo: banner.logo,143                name: banner.name,144                at: stack.at(),145                logo_box: (LOGO_WIDTH, LOGO_HEIGHT),146                width: column,147                size: look::HEAD_TITLE,148                lifted: false,149            },150        )151    });152    // The head takes the box's height with a logo and the text's height153    // without. The box holds only for a title with a logo path, so the154    // blocks under it stand still while the decode lands, and a title with155    // no logo path never gets one.156    stack.add(match banner.logo.is_empty() {157        true => taken.min(LOGO_HEIGHT),158        false => LOGO_HEIGHT,159    });160161    let taken = text::block(162        frame,163        banner.facts,164        stack.at(),165        look::FACTS,166        look::muted(),167        column,168        1,169    );170    stack.add(taken);171172    // The genres are cut to one line with an ellipsis, so a title with173    // many of them never pushes the blocks under it down.174    let taken = text::block(175        frame,176        &text::cut(banner.genres, look::FACTS, column),177        stack.at(),178        look::FACTS,179        look::muted(),180        column,181        1,182    );183    stack.add(taken);184185    let taken = ratings::draw(frame, banner.ratings, stack.at());186    stack.add(taken);187188    // A tagline is the film's own words, so it draws in the italic, as189    // a card's tagline does.190    text::block_in(191        frame,192        banner.tagline,193        stack.at(),194        (look::TAGLINE, look::ITALIC),195        look::text(),196        column,197        TAGLINE_LINES,198    );199200    for index in 0..banner.count {201        let bar = indicator(region, index);202        let color = match index == banner.current {203            true => look::text(),204            false => look::faint(),205        };206        frame.fill_rectangle(bar.position(), extent(bar), color);207    }208209    if banner.focused {210        mark(frame, region);211    }212}213214#[cfg(test)]215mod tests {216    use super::*;217218    fn region() -> Rectangle {219        area(32.0, 104.0, 1856.0, height(1080.0))220    }221222    #[test]223    fn the_frame_takes_about_four_tenths_of_the_page() {224        assert_eq!(height(1080.0), 432.0);225    }226227    #[test]228    fn a_short_page_gives_the_frame_the_height_its_text_needs() {229        assert_eq!(height(720.0), 370.0);230        assert!(height(720.0) > 720.0 * SHARE);231    }232233    #[test]234    fn the_indicators_stand_in_a_row_inside_the_frames_foot() {235        let region = region();236        let first = indicator(region, 0);237        let second = indicator(region, 1);238        assert_eq!(first.x, region.x + INSET);239        assert_eq!(second.x, first.x + INDICATOR_WIDTH + INDICATOR_GAP);240        assert_eq!(first.y, second.y);241        assert!(first.y + first.height < region.y + region.height);242        assert!(first.y > region.y + region.height / 2.0);243    }244245    // The tallest the text column gets: the head, the facts line, the246    // genres line, the ratings row, the tagline, and a gap between each247    // two of them.248    fn column() -> f32 {249        TOP + LOGO_HEIGHT250            + GAP251            + text::height(1, look::FACTS)252            + GAP253            + text::height(1, look::FACTS)254            + GAP255            + ratings::HEIGHT256            + GAP257            + text::height(TAGLINE_LINES, look::TAGLINE)258    }259260    // The room the frame leaves between the foot of the text column and261    // the indicator row on a page this tall.262    fn room(page: f32) -> f32 {263        let region = area(32.0, 104.0, 1856.0, height(page));264        indicator(region, 0).y - (region.y + column())265    }266267    #[test]268    fn the_text_and_the_indicators_stay_clear_of_each_other() {269        assert!(room(1080.0) >= 0.0, "at 1080p: {}", room(1080.0));270        assert!(room(720.0) >= 0.0, "at 720p: {}", room(720.0));271    }272}
src/views/buttons.rs 100.0%
1// The button row a page draws under its text. A button is a box with one2// word in it. The focused one carries the mark focus takes everywhere on3// this screen.45use iced_wgpu::Renderer;6use iced_widget::canvas;7use iced_winit::core::alignment::Vertical;8use iced_winit::core::text::Alignment;9use iced_winit::core::{Point, Rectangle};1011use super::{area, extent, label, mark};12use crate::look;1314/// The height of a button.15pub const HEIGHT: f32 = 68.0;1617// The gap between two buttons.18const GAP: f32 = 20.0;1920// The padding at both ends of a button's word, and the width of an21// average glyph as a share of its size, which sizes the box to the word.22const PAD: f32 = 34.0;23const ADVANCE: f32 = 0.62;2425/// The width of a button that holds this word.26pub fn width(name: &str) -> f32 {27    name.chars().count() as f32 * look::BUTTON * ADVANCE + 2.0 * PAD28}2930/// One button's box, in a row that starts at `at`.31pub fn button(names: &[&str], index: usize, at: Point) -> Rectangle {32    let left = names[..index]33        .iter()34        .fold(at.x, |left, name| left + width(name) + GAP);35    area(left, at.y, width(names[index]), HEIGHT)36}3738/// Draw the row. `focus` names the button that holds focus, or nothing39/// while another row of the page holds it. The answer is the row's40/// height, so the caller stacks the next block under it.41pub fn draw(42    frame: &mut canvas::Frame<Renderer>,43    names: &[&str],44    at: Point,45    focus: Option<usize>,46) -> f32 {47    for (index, name) in names.iter().enumerate() {48        let bounds = button(names, index, at);49        frame.fill_rectangle(bounds.position(), extent(bounds), look::slot());50        frame.fill_text(label(51            name,52            Point::new(bounds.center_x(), bounds.center_y()),53            look::BUTTON,54            look::text(),55            Alignment::Center,56            Vertical::Center,57            bounds.width,58        ));59        if focus == Some(index) {60            mark(frame, bounds);61        }62    }63    HEIGHT64}6566#[cfg(test)]67mod tests {68    use super::*;6970    #[test]71    fn a_longer_word_takes_a_wider_button() {72        assert!(width("Trailer") > width("Play"));73    }7475    #[test]76    fn the_buttons_sit_beside_each_other_in_order() {77        let names = ["Play", "Trailer"];78        let at = Point::new(120.0, 800.0);79        let first = button(&names, 0, at);80        let second = button(&names, 1, at);81        assert_eq!(first.x, 120.0);82        assert_eq!(second.x, first.x + first.width + GAP);83        assert_eq!(second.y, first.y);84        assert_eq!(first.height, HEIGHT);85    }86}
src/views/card.rs 100.0%
1// The card: the two lines under one piece of art, which a strip slot and2// a wall cell both draw here. Line one is bright at the caption size and3// says what the art cannot; line two is smaller, faint, and italic and4// says the facts. Focus is the mark alone, so a card adds no word and5// changes no color when it takes focus. Both lines are cut by the shaper6// at the read, each at its own size, and this module holds those cuts as7// well as the draw.89use iced_wgpu::Renderer;10use iced_widget::canvas;11use iced_winit::core::Rectangle;1213use super::{Card, area, text};14use crate::look;1516/// How many lines a card draws under its art.17pub const LINES: usize = 2;1819/// The height this many lines of a card take: the first at the caption20/// size and every line under it at the smaller face size.21pub fn height(lines: usize) -> f32 {22    match lines {23        0 => 0.0,24        lines => text::height(1, look::CAPTION) + text::height(lines - 1, look::FACE),25    }26}2728/// The band the second line draws in: right under the first, and as tall29/// as the smaller size it draws at.30pub fn under(band: Rectangle) -> Rectangle {31    area(32        band.x,33        band.y + band.height,34        band.width,35        text::height(1, look::FACE),36    )37}3839/// The first line cut by the shaper to a band of this width.40pub fn cut(caption: &str, width: f32) -> String {41    text::measured_cut(caption, look::CAPTION, width)42}4344/// The second line cut by the shaper to the same band at its own smaller45/// size. The measure runs in the roman face: the italic the line draws in46/// is a hair narrower, so a cut that fits the roman fits the italic.47pub fn under_cut(line: &str, width: f32) -> String {48    text::measured_cut(line, look::FACE, width)49}5051/// Draw one card's two lines: the first in the band it is given, and the52/// second in the band under it. Both are drawn as they stand, because the53/// read cut them to this band already.54pub fn draw<T: Card>(frame: &mut canvas::Frame<Renderer>, card: &T, band: Rectangle) {55    // A tagline is the film's own words and draws in the italic face,56    // bright at the caption size; a title draws in the roman one. The cut57    // measures in the roman face either way, the wider of the two.58    match card.leads_with_tagline() {59        true => text::faced(60            frame,61            card.fitted(),62            band,63            look::CAPTION,64            look::text(),65            look::ITALIC,66        ),67        false => text::shown(frame, card.fitted(), band, look::CAPTION, look::text()),68    }69    text::faced(70        frame,71        card.under_fitted(),72        under(band),73        look::FACE,74        look::faint(),75        look::ITALIC,76    );77}7879#[cfg(test)]80mod tests {81    use super::*;8283    const BAND: f32 = 200.0;8485    #[test]86    fn a_cards_second_line_is_shorter_than_its_first() {87        assert_eq!(height(0), 0.0);88        assert_eq!(height(1), text::height(1, look::CAPTION));89        assert_eq!(90            height(2),91            text::height(1, look::CAPTION) + text::height(1, look::FACE)92        );93        assert!(height(2) - height(1) < text::height(1, look::CAPTION));94    }9596    #[test]97    fn the_second_line_stands_under_the_first_at_the_smaller_size() {98        let band = area(10.0, 20.0, BAND, text::height(1, look::CAPTION));99        let under = under(band);100        assert_eq!(under.x, band.x);101        assert_eq!(under.width, band.width);102        assert_eq!(under.y, band.y + band.height);103        assert_eq!(under.height, text::height(1, look::FACE));104        assert_eq!(band.height + under.height, height(2));105    }106107    #[test]108    fn each_line_is_cut_at_the_size_it_draws_at() {109        let long = "W".repeat(60);110        let first = cut(&long, BAND);111        let second = under_cut(&long, BAND);112        assert!(first.ends_with('\u{2026}'));113        assert!(second.ends_with('\u{2026}'));114        assert!(text::measured(&first, look::CAPTION) <= BAND);115        assert!(text::measured(&second, look::FACE) <= BAND);116        assert!(second.chars().count() > first.chars().count());117        assert_eq!(cut("Film one", BAND), "Film one");118        assert_eq!(under_cut("1987 · 1h 37m", BAND), "1987 · 1h 37m");119    }120}
src/views/clock.rs 100.0%
1// The clock at the top right of every screen: the reading, the room it2// reserves, and the halo of dark ink it draws over, the way a subtitle3// does, so it reads over art of any brightness and nothing shows around4// it.5//6// The clock is drawn by the strip, the browser's layer over every7// screen, through `reading` here. This module keeps the glyph, the8// halo, and the room the reading reserves.910use iced_wgpu::Renderer;11use iced_widget::canvas;12use iced_winit::core::alignment::Vertical;13use iced_winit::core::text::Alignment;14use iced_winit::core::{Color, Point, Rectangle};1516use super::{band, label, text};17use crate::clock::Time;18use crate::look;1920// The strip module: the browser's layer over every screen, which draws21// the search icon, the field it expands into, and the reading.22pub mod strip;2324// The widest reading of the day. The clock reserves the room this one25// takes, so the controls beside it hold their place as the minute turns.26const WIDEST: &str = "12:00 pm";2728// What the estimate in `text::width` is multiplied by. That estimate is29// the font's average advance, the digits of a reading are wider than the30// average, and a reading wider than the room it was given wraps to a31// second line.32const SLACK: f32 = 1.5;3334// How far the dark copies of the reading draw from the bright one, in35// logical pixels: far enough to edge every glyph over white art, near36// enough that the copies stay behind the reading.37const HALO: f32 = 2.0;3839// The eight directions the halo draws in, as unit vectors, so every40// copy lies the same distance from the reading and the ring around a41// glyph has no gap.42const AROUND: [(f32, f32); 8] = [43    (1.0, 0.0),44    (-1.0, 0.0),45    (0.0, 1.0),46    (0.0, -1.0),47    (DIAGONAL, DIAGONAL),48    (DIAGONAL, -DIAGONAL),49    (-DIAGONAL, DIAGONAL),50    (-DIAGONAL, -DIAGONAL),51];5253// The length of each leg of a diagonal unit vector.54const DIAGONAL: f32 = std::f32::consts::FRAC_1_SQRT_2;5556/// Where the eight dark copies of a reading draw, around the point the57/// bright reading draws at. The pill over a still draws the same way,58/// because any plate under the words would draw under the art.59pub fn halo(at: Point) -> [Point; 8] {60    AROUND.map(|(x, y)| Point::new(at.x + x * HALO, at.y + y * HALO))61}6263/// The room the clock takes at the right edge of a frame.64pub fn room() -> f32 {65    text::width(WIDEST, look::CONTROL) * SLACK66}6768/// The left edge of the clock in a frame this wide. The strip's icon,69/// and the field it expands into, end a margin to the left of it.70pub fn left(width: f32) -> f32 {71    width - band::PAD - room()72}7374// The reading in the room the clock reserves at the right of the frame.75// The dark copies draw first and the bright reading over them, so the76// reading reads over art of any brightness and no shape shows around it.77pub(crate) fn reading(frame: &mut canvas::Frame<Renderer>, bounds: Rectangle, time: Time) {78    let reading = time.twelve_hour();79    let right = bounds.x + bounds.width - band::PAD;80    let middle = bounds.y + band::HEIGHT / 2.0;81    let at = Point::new(right, middle);82    let ink = |point: Point, color: Color| {83        label(84            &reading,85            point,86            look::CONTROL,87            color,88            Alignment::Right,89            Vertical::Center,90            room(),91        )92    };93    for point in halo(at) {94        frame.fill_text(ink(point, look::BACKGROUND));95    }96    frame.fill_text(ink(at, look::text()));97}9899#[cfg(test)]100mod tests {101    use super::*;102103    #[test]104    fn the_clock_hangs_off_the_right_edge() {105        assert_eq!(left(1920.0) + room() + band::PAD, 1920.0);106        assert_eq!(left(1280.0) + room() + band::PAD, 1280.0);107    }108109    #[test]110    fn every_copy_of_the_halo_lies_the_same_distance_from_the_reading() {111        let at = Point::new(100.0, 50.0);112        let distances =113            halo(at).map(|point| ((point.x - at.x).hypot(point.y - at.y) * 100.0).round());114        assert_eq!(distances, [(HALO * 100.0).round(); 8]);115    }116117    #[test]118    fn the_halo_draws_in_eight_directions() {119        let mut directions = halo(Point::new(0.0, 0.0))120            .map(|point| {121                (122                    (point.x * 100.0).round() as i32,123                    (point.y * 100.0).round() as i32,124                )125            })126            .to_vec();127        directions.sort_unstable();128        directions.dedup();129        assert_eq!(directions.len(), 8);130    }131132    #[test]133    fn the_room_holds_every_reading_of_the_day() {134        for hour in 0..24 {135            for minute in [0, 59] {136                let reading = Time { hour, minute }.twelve_hour();137                assert!(text::width(&reading, look::CONTROL) <= room(), "{reading}");138            }139        }140    }141}
src/views/clock/strip.rs 100.0%
1// The strip: the browser's own layer across the top of every screen,2// right-aligned. It draws a magnifying glass at the clock's left, then3// the clock. On a search wall the glass expands into the text field,4// which draws leftward from the clock with the glass inside its left5// end. The focus mark goes on the glass, or on the field, while the6// strip holds the browser's focus. It is the browser's layer and not a7// screen's, so every screen carries it in the same place and an up8// press that moves nothing on any screen reaches it.910use std::convert::Infallible;1112use iced_wgpu::Renderer;13use iced_widget::canvas;14use iced_winit::core::{Color, Point, Rectangle, Theme, mouse};1516use super::{left, reading};17use crate::clock::Time;18use crate::look;19use crate::views::field::{self, TextField};20use crate::views::{area, band, mark};2122/// The side of the icon's square box. The icon is the size of the23/// reading beside it, so the two read as one strip.24pub const GLASS: f32 = look::CONTROL;2526// The circle's radius and the center it turns about, as shares of the27// box, and where the handle ends, so the icon scales with the box.28const RADIUS: f32 = 0.32;29const CENTER: f32 = 0.38;30const HANDLE: f32 = 0.94;3132// The width of the circle's stroke and the handle's, thin enough that33// the icon stays lighter than the reading beside it.34const LINE: f32 = 2.0;3536/// The icon's box in a frame this wide, where no field expands it: a37/// margin to the left of the clock, on the same middle line.38pub fn glass_at(width: f32) -> Rectangle {39    area(40        left(width) - band::PAD - GLASS,41        (band::HEIGHT - GLASS) / 2.0,42        GLASS,43        GLASS,44    )45}4647// The magnifying glass in its box: a circle, and a handle out along the48// diagonal from the circle's edge to the box's corner.49fn glass(frame: &mut canvas::Frame<Renderer>, at: Rectangle, ink: Color) {50    let side = at.width.min(at.height);51    let center = Point::new(at.x + side * CENTER, at.y + side * CENTER);52    let radius = side * RADIUS;53    let stroke = || {54        canvas::Stroke::default()55            .with_color(ink)56            .with_width(LINE)57            .with_line_cap(canvas::LineCap::Round)58    };59    frame.stroke(&canvas::Path::circle(center, radius), stroke());60    let step = radius * std::f32::consts::FRAC_1_SQRT_2;61    frame.stroke(62        &canvas::Path::line(63            Point::new(center.x + step, center.y + step),64            Point::new(at.x + side * HANDLE, at.y + side * HANDLE),65        ),66        stroke(),67    );68}6970/// The strip as one frame draws it: the reading, the text of the search71/// wall on top of the stack or nothing where the top screen is not one,72/// and whether the strip holds the browser's focus.73pub struct Strip<'a> {74    pub time: Time,75    pub field: Option<&'a TextField>,76    pub focused: bool,77}7879impl canvas::Program<Infallible, Theme, Renderer> for Strip<'_> {80    type State = ();8182    fn draw(83        &self,84        _state: &Self::State,85        renderer: &Renderer,86        _theme: &Theme,87        bounds: Rectangle,88        _cursor: mouse::Cursor,89    ) -> Vec<canvas::Geometry<Renderer>> {90        let mut frame = canvas::Frame::new(renderer, bounds.size());91        // The mark goes around whichever of the two the strip is: the92        // icon alone, or the field the icon expanded into.93        let marked = match self.field {94            Some(field) => {95                let box_of = field::bounds(bounds.width);96                field::draw(&mut frame, bounds.width, field);97                glass(&mut frame, field::icon(box_of), look::muted());98                box_of99            }100            None => {101                let at = glass_at(bounds.width);102                glass(&mut frame, at, look::muted());103                at104            }105        };106        if self.focused {107            mark(&mut frame, marked);108        }109        reading(&mut frame, bounds, self.time);110        vec![frame.into_geometry()]111    }112}113114#[cfg(test)]115mod tests {116    use super::*;117    use crate::views::clock::room;118119    const WIDTH: f32 = 1920.0;120121    #[test]122    fn the_icon_stands_a_margin_to_the_left_of_the_clock() {123        let at = glass_at(WIDTH);124        assert_eq!(at.x + at.width + band::PAD, left(WIDTH));125        assert_eq!(at.width, GLASS);126        assert_eq!(at.height, GLASS);127        assert!(at.y > 0.0);128        assert!(at.y + at.height < band::HEIGHT);129    }130131    #[test]132    fn the_field_reaches_the_same_margin_and_holds_the_icon_at_its_left_end() {133        let box_of = field::bounds(WIDTH);134        assert_eq!(box_of.x + box_of.width + band::PAD, left(WIDTH));135        assert_eq!(136            box_of.x + box_of.width + band::PAD + room(),137            WIDTH - band::PAD138        );139140        let icon = field::icon(box_of);141        assert!(icon.x > box_of.x);142        assert!(icon.x + icon.width < box_of.x + box_of.width);143        assert_eq!(icon.width, GLASS);144        assert_eq!(icon.center_y(), box_of.center_y());145    }146}
src/views/curtain.rs 97.0%
1// The layers a page draws over itself while the loading state runs: the2// item's own backdrop again over everything the page drew, and over that3// the pool of shade, the mark, and the logo on its way to the centre.4//5// The backdrop is drawn a second time rather than the page being faded6// out block by block, because inside one layer the renderer draws every7// mesh, then every image, then every text, so nothing a page draws can8// cover its own text. A layer of its own can. The mark is the same rule9// again: it is a mesh, so a mark drawn beside the backdrop would be10// painted under it, and it takes a layer of its own over the art.1112use std::cell::RefCell;13use std::convert::Infallible;1415use iced_wgpu::Renderer;16use iced_widget::canvas;17use iced_winit::core::alignment::Vertical;18use iced_winit::core::text::Alignment;19use iced_winit::core::{Color, Point, Rectangle, Theme, Vector, mouse};2021use liken_iced::mark;2223use super::{Tone, area, extent, label, paint};24use crate::look;25use crate::posters::{Art, Posters};2627// The box the centred logo draws in, as a share of the frame. The logo28// keeps its own ratio inside it, so a wide logo takes the width and a29// tall one takes the height.30const LOGO_WIDTH: f32 = 0.34;31const LOGO_HEIGHT: f32 = 0.26;3233// Where the foot of the logo sits, as a share of the height. The logo and34// the mark under it straddle the middle of the frame.35const LOGO_FOOT: f32 = 0.5;3637// The span of the mark, as a share of the width.38const SPAN: f32 = 0.12;3940// The space between the foot of the logo and the top of the mark, as a41// share of the height.42const GAP: f32 = 0.05;4344// The width the centred name wraps in, as a share of the frame, where the45// item has no logo.46const NAME: f32 = 0.7;4748// The pool of shade under the logo and the mark, so both read over a49// bright backdrop: its centre and its two radii as shares of the frame,50// how dark it is at the centre, and how many rings it is built from.51//52// The canvas fills a shape in one colour and has no radial gradient, so53// the pool is a stack of ellipses, each a little smaller and each a54// little darker, and the shade deepens toward the centre in steps too55// small to see. The middle sits a little under the logo's foot, because56// the mark under the logo is taller than the gap over it.57const POOL_CENTRE: f32 = 0.5;58const POOL_WIDTH: f32 = 0.40;59const POOL_HEIGHT: f32 = 0.42;60const POOL_SHADE: f32 = 0.92;61const POOL_RINGS: u32 = 48;6263/// The loading state as one frame draws it.64#[derive(Debug, Clone, Copy, PartialEq)]65pub struct Curtain {66    /// How far the page has gone, from 0 whole to 1 fully away.67    pub away: f32,68    /// The clock the mark pulses on, in seconds.69    pub phase: f64,70}7172/// What the curtain reads off the page under it: the box that page draws73/// its logo in at this frame's bounds, which is where the logo starts its74/// move to the centre.75pub trait Head {76    /// The box the page's head draws in now, scroll included.77    fn head(&self, bounds: Rectangle) -> Rectangle;78}7980/// The art layer of the loading state over one page.81pub struct Layer<'a, P> {82    /// The library the art paths resolve against.83    pub library: &'a str,84    /// The path of the backdrop file, empty where the item has none.85    pub art: &'a str,86    /// The path of the logo file, empty where the item has none.87    pub logo: &'a str,88    /// The name a person reads, which the state centres where the item89    /// has no logo.90    pub name: &'a str,91    /// The store the backdrop and the logo come from.92    pub posters: &'a RefCell<P>,93    /// The page under this layer, which says where its logo sits now.94    pub head: &'a dyn Head,95    /// What this frame draws.96    pub curtain: Curtain,97}9899impl<P> Clone for Layer<'_, P> {100    fn clone(&self) -> Self {101        *self102    }103}104105impl<P> Copy for Layer<'_, P> {}106107// The layer over the art: the pool of shade, the mark, the logo on its way108// to the centre, and the name where the item has no logo. The pool and the109// mark are meshes and the logo is an image, so the logo draws over both,110// and the mark sits under the logo where the two never overlap.111pub struct Front<'a, P>(pub Layer<'a, P>);112113impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Layer<'_, P> {114    type State = ();115116    fn draw(117        &self,118        _state: &Self::State,119        renderer: &Renderer,120        _theme: &Theme,121        bounds: Rectangle,122        _cursor: mouse::Cursor,123    ) -> Vec<canvas::Geometry<Renderer>> {124        let mut frame = canvas::Frame::new(renderer, bounds.size());125        let away = self.away();126        if away <= 0.0 {127            return vec![frame.into_geometry()];128        }129        let posters = &mut *self.posters.borrow_mut();130131        // The art again, at the state's own opacity. It covers the page132        // and it clears the shade in the one move, because the shade is a133        // layer under this one.134        let backdrop = (!self.art.is_empty())135            .then(|| {136                posters.poster(137                    self.library,138                    self.art,139                    bounds.width as u32,140                    bounds.height as u32,141                )142            })143            .flatten();144        match backdrop {145            Some(image) => paint(&mut frame, &image, bounds, Tone::At(away)),146            None => frame.fill_rectangle(147                bounds.position(),148                extent(bounds),149                Color {150                    a: away,151                    ..look::BACKGROUND152                },153            ),154        }155156        vec![frame.into_geometry()]157    }158}159160impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Front<'_, P> {161    type State = ();162163    fn draw(164        &self,165        _state: &Self::State,166        renderer: &Renderer,167        _theme: &Theme,168        bounds: Rectangle,169        _cursor: mouse::Cursor,170    ) -> Vec<canvas::Geometry<Renderer>> {171        let mut frame = canvas::Frame::new(renderer, bounds.size());172        let layer = self.0;173        let away = layer.away();174175        let posters = &mut *layer.posters.borrow_mut();176        pool(&mut frame, bounds, away);177178        // The mark swells at the state's own energy, so it is at full179        // swing while the state holds and it stills over the exit.180        let box_ = mark::bounds();181        let span = bounds.width * SPAN;182        let height = span * box_.height / box_.width;183        let logo = layer.logo(posters, bounds);184        let foot = logo185            .as_ref()186            .map(|(_, at)| at.y + at.height)187            .unwrap_or_else(|| layer.centre(bounds));188        mark::draw(189            &mut frame,190            Point::new(bounds.center_x(), foot + bounds.height * GAP + height / 2.0),191            span,192            f64::from(away),193            layer.curtain.phase,194            away,195        );196197        // The logo draws whole, and not at the state's opacity, because198        // the page under these layers leaves its logo's box empty while199        // the state runs: there is one logo on the screen, and it moves.200        match logo {201            Some((image, at)) => paint(&mut frame, &image, at, Tone::Full),202            None => frame.fill_text(label(203                layer.name,204                Point::new(bounds.center_x(), layer.centre(bounds)),205                look::TITLE,206                Color {207                    a: away,208                    ..look::text()209                },210                Alignment::Center,211                Vertical::Bottom,212                bounds.width * NAME,213            )),214        }215216        vec![frame.into_geometry()]217    }218}219220impl<P: Posters> Layer<'_, P> {221    // How far the page has gone, from 0 whole to 1 fully away.222    fn away(&self) -> f32 {223        self.curtain.away.clamp(0.0, 1.0)224    }225226    // Where the foot of the logo, or the base line of the name, sits.227    fn centre(&self, bounds: Rectangle) -> f32 {228        bounds.y + bounds.height * LOGO_FOOT229    }230231    // The logo and the box it draws in at this point of the motion, and232    // nothing where the item has no logo or the decode has not landed.233    //234    // The box comes from the shares of the frame and not from the size235    // the store decoded at, so a small logo file draws at the same share236    // of the width as a large one. The store fits a decode inside the box237    // it is asked for and never scales one up, and this state is one238    // image over a still frame, so the scale costs nothing to draw.239    fn logo(&self, posters: &mut P, bounds: Rectangle) -> Option<(Art, Rectangle)> {240        if self.logo.is_empty() {241            return None;242        }243        let page = self.head.head(bounds);244        let image = posters.fitted(245            self.library,246            self.logo,247            (bounds.width * LOGO_WIDTH) as u32,248            (bounds.height * LOGO_HEIGHT) as u32,249        )?;250        let (width, height) = image.size();251        let ratio = height as f32 / width as f32;252253        let to = fitted(254            bounds.width * LOGO_WIDTH,255            bounds.height * LOGO_HEIGHT,256            ratio,257        );258        let from = fitted(page.width, page.height, ratio);259        let to = area(260            bounds.center_x() - to.0 / 2.0,261            self.centre(bounds) - to.1,262            to.0,263            to.1,264        );265        let from = area(page.x, page.y, from.0, from.1);266        Some((image, between(from, to, self.away())))267    }268}269270// The pool of shade, at this share of its full depth.271fn pool(frame: &mut canvas::Frame<Renderer>, bounds: Rectangle, away: f32) {272    if away <= 0.0 {273        return;274    }275    let centre = Point::new(bounds.center_x(), bounds.y + bounds.height * POOL_CENTRE);276    let radii = Vector::new(bounds.width * POOL_WIDTH, bounds.height * POOL_HEIGHT);277    // Each ring adds the same share, so the shade at the centre, where278    // every ring lies, is the whole of it.279    let ring = Color {280        a: 1.0 - (1.0 - POOL_SHADE * away).powf(1.0 / POOL_RINGS as f32),281        ..look::BACKGROUND282    };283    let unit = canvas::Path::circle(Point::ORIGIN, 1.0);284    for step in 0..POOL_RINGS {285        let share = 1.0 - step as f32 / POOL_RINGS as f32;286        frame.with_save(|frame| {287            frame.translate(Vector::new(centre.x, centre.y));288            frame.scale_nonuniform(Vector::new(radii.x * share, radii.y * share));289            frame.fill(&unit, ring);290        });291    }292}293294// The largest width and height at this ratio that fit inside a box.295fn fitted(width: f32, height: f32, ratio: f32) -> (f32, f32) {296    match width * ratio <= height {297        true => (width, width * ratio),298        false => (height / ratio, height),299    }300}301302// One rectangle on its way to another.303fn between(from: Rectangle, to: Rectangle, share: f32) -> Rectangle {304    let step = |from: f32, to: f32| from + (to - from) * share;305    area(306        step(from.x, to.x),307        step(from.y, to.y),308        step(from.width, to.width),309        step(from.height, to.height),310    )311}312313#[cfg(test)]314mod tests {315    use super::*;316317    fn from() -> Rectangle {318        area(120.0, 130.0, 400.0, 100.0)319    }320321    fn to() -> Rectangle {322        area(760.0, 340.0, 640.0, 160.0)323    }324325    #[test]326    fn the_start_of_the_move_is_where_the_page_draws_it() {327        assert_eq!(between(from(), to(), 0.0), from());328    }329330    #[test]331    fn the_end_of_the_move_is_the_centre() {332        assert_eq!(between(from(), to(), 1.0), to());333    }334335    #[test]336    fn the_middle_of_the_move_is_between_the_two() {337        let at = between(from(), to(), 0.5);338        assert_eq!(at, area(440.0, 235.0, 520.0, 130.0));339    }340341    #[test]342    fn a_wide_logo_takes_the_width_of_its_box() {343        assert_eq!(fitted(640.0, 216.0, 0.25), (640.0, 160.0));344    }345346    #[test]347    fn a_tall_logo_takes_the_height_of_its_box() {348        assert_eq!(fitted(640.0, 216.0, 1.0), (216.0, 216.0));349    }350351    #[test]352    fn a_logo_smaller_than_its_box_still_fills_it() {353        assert_eq!(fitted(1280.0, 432.0, 0.25), (1280.0, 320.0));354    }355}
src/views/divider.rs 100.0%
1// The divider: one thin rule with a heading at its left. A series page2// draws one before each season's first row of stills. It takes no focus, so3// a press crosses it as if it were not there.45use iced_wgpu::Renderer;6use iced_widget::canvas;7use iced_winit::core::alignment::Vertical;8use iced_winit::core::text::Alignment;9use iced_winit::core::{Point, Rectangle};1011use super::{area, extent, label};12use crate::look;1314/// The height a divider takes in the stack that holds it.15pub const HEIGHT: f32 = 78.0;1617// The thickness of the rule under the two words.18const RULE: f32 = 2.0;1920// The space between the words and the rule under them.21const LIFT: f32 = 14.0;2223/// Draw one divider in this region: the heading at the left, and the rule24/// under it.25pub fn draw(frame: &mut canvas::Frame<Renderer>, region: Rectangle, name: &str) {26    let baseline = region.y + region.height - LIFT - RULE;27    frame.fill_text(label(28        name,29        Point::new(region.x, baseline),30        look::HEADING,31        look::text(),32        Alignment::Left,33        Vertical::Bottom,34        region.width,35    ));3637    let rule = area(38        region.x,39        region.y + region.height - RULE,40        region.width,41        RULE,42    );43    frame.fill_rectangle(rule.position(), extent(rule), look::slot());44}
src/views/field.rs 100.0%
1// The text a person is typing, and the box it draws in. The field holds2// the text and nothing else, so the keyboard grid and a physical3// keyboard press into it by the same word.4//5// The field draws in the strip, leftward from the clock, and the search6// icon draws inside its left end. The band never draws it.78use iced_wgpu::Renderer;9use iced_widget::canvas;10use iced_winit::core::{Point, Rectangle};1112use super::clock::{self, strip};13use super::{area, band, extent, rounded, text};14use crate::look;1516// The word for the key that removes the last character.17const BACKSPACE: &str = "backspace";1819/// The text a person typed. `press` is the one way it changes.20#[derive(Debug, Clone, Default, PartialEq, Eq)]21pub struct TextField {22    text: String,23}2425impl TextField {26    /// A field holding this text, which a search wall opened by a letter27    /// starts with.28    pub fn of(text: &str) -> Self {29        Self {30            text: text.to_string(),31        }32    }3334    /// The text as typed so far.35    pub fn text(&self) -> &str {36        &self.text37    }3839    /// Fold one browser word in. A lowercase letter, a digit, or a space40    /// is appended. Backspace removes the last character. Every other41    /// word changes nothing. The answer is whether the text changed, so42    /// the caller rereads only on a change.43    pub fn press(&mut self, word: &str) -> bool {44        if word == BACKSPACE {45            return self.text.pop().is_some();46        }47        let Some(letter) = one(word) else {48            return false;49        };50        if letter != ' ' && !letter.is_ascii_lowercase() && !letter.is_ascii_digit() {51            return false;52        }53        self.text.push(letter);54        true55    }56}5758/// Whether the word is one letter or one digit. The browser opens a59/// search on such a word. The space and backspace are not included,60/// because neither starts a search.61pub fn typed(word: &str) -> bool {62    one(word).is_some_and(|letter| letter.is_ascii_lowercase() || letter.is_ascii_digit())63}6465/// Whether the word edits a field's text: a letter, a digit, or the66/// space. Backspace is not included, because the browser routes it67/// through the escape path before a field sees it.68pub fn edits(word: &str) -> bool {69    typed(word) || word == " "70}7172// The one character a word is, or nothing where the word is a name.73fn one(word: &str) -> Option<char> {74    let mut letters = word.chars();75    let letter = letters.next()?;76    letters.next().is_none().then_some(letter)77}7879// The margin at both ends of the text inside the box, the width of the80// cursor's bar, and the radius the box rounds by.81const PAD: f32 = 16.0;82const CURSOR: f32 = 3.0;83const RADIUS: f32 = 8.0;8485// The typed text draws at the size of a band's heading, because the86// typing is what the wall under it is about.87const SIZE: f32 = look::NAME;8889// The height of the field's box, and the share of the frame it takes90// across.91const BOX: f32 = 48.0;92const SHARE: f32 = 3.0;9394/// The field's box: its right edge a margin to the left of the clock,95/// and its width about a third of the frame.96pub fn bounds(width: f32) -> Rectangle {97    let across = width / SHARE;98    area(99        clock::left(width) - band::PAD - across,100        (band::HEIGHT - BOX) / 2.0,101        across,102        BOX,103    )104}105106/// The search icon's box inside the field's left end, where the icon107/// draws once the field has expanded from it.108pub fn icon(bounds: Rectangle) -> Rectangle {109    area(110        bounds.x + PAD,111        bounds.y + (bounds.height - strip::GLASS) / 2.0,112        strip::GLASS,113        strip::GLASS,114    )115}116117// Where the text starts inside the box: past the icon at the left end,118// with the same margin on both sides of it.119fn lead() -> f32 {120    PAD + strip::GLASS + PAD121}122123/// The cursor's bar, after the shaped text. Text wider than the box124/// holds the bar at the right margin, so the bar never leaves the box.125pub fn cursor(bounds: Rectangle, text: &str) -> Rectangle {126    let after = (bounds.x + lead() + text::measured(text, SIZE)).min(bounds.x + bounds.width - PAD);127    area(after, bounds.y + (bounds.height - SIZE) / 2.0, CURSOR, SIZE)128}129130/// Draw the field in the strip of a frame this wide.131pub fn draw(frame: &mut canvas::Frame<Renderer>, width: f32, field: &TextField) {132    let bounds = bounds(width);133    frame.fill(&rounded(bounds, RADIUS), look::slot());134    let bar = cursor(bounds, field.text());135    frame.fill_rectangle(bar.position(), extent(bar), look::mark());136    text::line(137        frame,138        field.text(),139        Point::new(bounds.x + lead(), bar.y),140        SIZE,141        look::text(),142        bounds.width - lead() - PAD,143    );144}145146#[cfg(test)]147mod tests {148    use super::*;149150    // One case: the text the field held, the word pressed, whether the151    // text changed, and the text after.152    const PRESSES: [(&str, &str, bool, &str); 12] = [153        ("", "a", true, "a"),154        ("bat", "m", true, "batm"),155        ("bat", "7", true, "bat7"),156        ("bat", " ", true, "bat "),157        ("bat", "backspace", true, "ba"),158        ("b", "backspace", true, ""),159        ("", "backspace", false, ""),160        ("bat", "up", false, "bat"),161        ("bat", "enter", false, "bat"),162        ("bat", "A", false, "bat"),163        ("bat", "·", false, "bat"),164        ("bat", "", false, "bat"),165    ];166167    #[test]168    fn a_press_appends_a_character_takes_one_back_or_changes_nothing() {169        for (held, word, changed, after) in PRESSES {170            let mut field = TextField::of(held);171            assert_eq!(field.press(word), changed, "{held:?} {word:?}");172            assert_eq!(field.text(), after, "{held:?} {word:?}");173        }174    }175176    #[test]177    fn a_new_field_holds_nothing() {178        assert_eq!(TextField::default().text(), "");179    }180181    // One case: the word, whether it opens a search, and whether it edits182    // a field.183    const WORDS: [(&str, bool, bool); 8] = [184        ("a", true, true),185        ("7", true, true),186        (" ", false, true),187        ("backspace", false, false),188        ("enter", false, false),189        ("up", false, false),190        ("A", false, false),191        ("", false, false),192    ];193194    #[test]195    fn a_letter_and_a_digit_open_a_search_and_the_space_only_edits() {196        for (word, opens, edits_it) in WORDS {197            assert_eq!(typed(word), opens, "{word:?}");198            assert_eq!(edits(word), edits_it, "{word:?}");199        }200    }201202    #[test]203    fn the_field_ends_a_margin_to_the_left_of_the_clock_and_takes_a_third_across() {204        let bounds = bounds(1920.0);205        assert_eq!(bounds.x + bounds.width, clock::left(1920.0) - band::PAD);206        assert_eq!(bounds.width, 1920.0 / SHARE);207        assert!(bounds.y > 0.0);208        assert!(bounds.y + bounds.height < band::HEIGHT);209    }210211    #[test]212    fn the_icon_sits_at_the_left_end_and_the_text_starts_past_it() {213        let bounds = bounds(1920.0);214        let icon = icon(bounds);215        assert_eq!(icon.x, bounds.x + PAD);216        assert!(icon.x + icon.width < bounds.x + lead());217        assert_eq!(icon.center_y(), bounds.center_y());218    }219220    #[test]221    fn the_cursor_follows_the_text_and_stays_inside_the_box() {222        let bounds = bounds(1920.0);223        let empty = cursor(bounds, "");224        assert_eq!(empty.x, bounds.x + lead());225        assert!(empty.y > bounds.y);226        assert!(empty.y + empty.height < bounds.y + bounds.height);227228        let typed = cursor(bounds, "batman");229        assert!(typed.x > empty.x);230231        let long = cursor(bounds, &"w".repeat(200));232        assert_eq!(long.x, bounds.x + bounds.width - PAD);233    }234}
src/views/header.rs 100.0%
1// What a page draws at its head: the item's logo where the volume holds2// one, and the item's title in large text where it does not.34use iced_wgpu::Renderer;5use iced_widget::canvas;6use iced_winit::core::alignment::Vertical;7use iced_winit::core::text::Alignment;8use iced_winit::core::{Point, Rectangle};910use super::{Tone, label, paint, text};11use crate::look;12use crate::posters::Posters;1314/// What a page draws at its head: the item's logo where the volume holds15/// one, and the item's title in large text where it does not.16pub struct Title<'a> {17    /// The library the art paths resolve against.18    pub library: &'a str,19    /// The path of the logo file, empty where the item has none.20    pub logo: &'a str,21    /// The name a person reads.22    pub name: &'a str,23    /// The top left corner the head draws from.24    pub at: Point,25    /// The box a logo draws in.26    pub logo_box: (f32, f32),27    /// The width the title text wraps in.28    pub width: f32,29    /// The size the title draws at where the item has no logo.30    pub size: f32,31    /// Whether the loading state has lifted the logo off the page. The32    /// state draws the logo itself on its way to the centre, so the head33    /// leaves the logo's box empty rather than draw a second one under34    /// it. A head with no logo draws its title as it always does, and35    /// the state fades that title under its own art.36    pub lifted: bool,37}3839/// Draw the head. The answer is the height it took, so the caller stacks40/// the facts under it.41pub fn title<P: Posters>(42    frame: &mut canvas::Frame<Renderer>,43    posters: &mut P,44    head: &Title<'_>,45) -> f32 {46    let (logo_width, logo_height) = head.logo_box;47    if !head.logo.is_empty()48        && let Some(image) = posters.fitted(49            head.library,50            head.logo,51            logo_width as u32,52            logo_height as u32,53        )54    {55        // The logo keeps its own ratio, so it takes the height the fit56        // landed at and not the height of the box.57        let (width, height) = image.size();58        if !head.lifted {59            paint(60                frame,61                &image,62                Rectangle {63                    x: head.at.x,64                    y: head.at.y,65                    width: width as f32,66                    height: height as f32,67                },68                Tone::Full,69            );70        }71        return height as f32;72    }7374    frame.fill_text(label(75        head.name,76        head.at,77        head.size,78        look::text(),79        Alignment::Left,80        Vertical::Top,81        head.width,82    ));83    text::height(text::lines(head.name, head.size, head.width), head.size)84}
src/views/keyboard.rs 99.1%
1// The grid of letters a remote with no keyboard types on. Every cell is2// a browser word, so a pick off the grid and a press on a physical3// keyboard reach the field by one path. The grid draws the way a wall4// does: rounded cells, and the focus mark on the one that holds focus.56use iced_wgpu::Renderer;7use iced_widget::canvas;8use iced_winit::core::{Point, Rectangle};910use super::{area, mark, rounded, text};11use crate::focus;12use crate::look;1314/// Every cell of the grid in reading order, each as the browser word a15/// pick on it presses.16pub const CELLS: [&str; 38] = [17    "a",18    "b",19    "c",20    "d",21    "e",22    "f",23    "g",24    "h",25    "i",26    "j",27    "k",28    "l",29    "m",30    "n",31    "o",32    "p",33    "q",34    "r",35    "s",36    "t",37    "u",38    "v",39    "w",40    "x",41    "y",42    "z",43    "0",44    "1",45    "2",46    "3",47    "4",48    "5",49    "6",50    "7",51    "8",52    "9",53    " ",54    "backspace",55];5657/// How many cells a row holds. The count is fixed, like a wall's, so the58/// focus arithmetic and the layout agree.59pub const COLUMNS: usize = 10;6061/// The grid and the cell that holds focus.62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]63pub struct Keyboard {64    focus: usize,65}6667impl Keyboard {68    /// Fold one press in. The arrows move focus across the grid, and69    /// every other word leaves it. The answer is whether focus moved, so70    /// the caller draws a frame only when the grid changed.71    pub fn key(&mut self, word: &str) -> bool {72        let moved = focus::wall(self.focus, CELLS.len(), COLUMNS, word);73        let changed = moved != self.focus;74        self.focus = moved;75        changed76    }7778    /// The word of the focused cell, which the caller presses into the79    /// field.80    pub fn pick(&self) -> &'static str {81        CELLS[self.focus]82    }83}8485// One cell's width and height, the gap between two, and the radius a86// cell rounds by. A cell has to hold "backspace", the widest word shown,87// at the control size, and ten cells have to fit under the band on a88// 1920-wide frame.89const CELL: f32 = 96.0;90const HEIGHT: f32 = 72.0;91const GAP: f32 = 12.0;92const RADIUS: f32 = 8.0;9394// How many rows the cells fill. The last row is short, and its cells95// keep the width of every other cell.96fn rows() -> usize {97    CELLS.len().div_ceil(COLUMNS)98}99100/// The width of the whole grid, which the caller centers it by.101pub fn width() -> f32 {102    COLUMNS as f32 * CELL + (COLUMNS - 1) as f32 * GAP103}104105/// The height of the whole grid.106pub fn height() -> f32 {107    rows() as f32 * HEIGHT + (rows() - 1) as f32 * GAP108}109110/// One cell's box, in a grid whose first cell's corner is `at`.111pub fn cell(at: Point, index: usize) -> Rectangle {112    let column = (index % COLUMNS) as f32;113    let row = (index / COLUMNS) as f32;114    area(115        at.x + column * (CELL + GAP),116        at.y + row * (HEIGHT + GAP),117        CELL,118        HEIGHT,119    )120}121122/// The label a cell shows. The space is the one cell whose word is not123/// readable as text.124pub fn shown(word: &str) -> &str {125    match word {126        " " => "space",127        word => word,128    }129}130131/// Draw the grid with its first cell's corner at `at`.132pub fn draw(frame: &mut canvas::Frame<Renderer>, at: Point, keyboard: &Keyboard) {133    for (index, word) in CELLS.iter().enumerate() {134        let bounds = cell(at, index);135        frame.fill(&rounded(bounds, RADIUS), look::slot());136        text::centered(137            frame,138            shown(word),139            band(bounds),140            look::CONTROL,141            look::text(),142        );143        if index == keyboard.focus {144            mark(frame, bounds);145        }146    }147}148149// The line a cell's label draws on, centered in the cell.150fn band(cell: Rectangle) -> Rectangle {151    let line = text::height(1, look::CONTROL);152    area(cell.x, cell.center_y() - line / 2.0, cell.width, line)153}154155#[cfg(test)]156mod tests {157    use super::*;158159    #[test]160    fn the_grid_holds_every_letter_every_digit_the_space_and_the_backspace() {161        let letters: Vec<&str> = CELLS[..26].to_vec();162        let expected: Vec<String> = (b'a'..=b'z')163            .map(|byte| (byte as char).to_string())164            .collect();165        assert_eq!(letters, expected);166        let digits: Vec<&str> = CELLS[26..36].to_vec();167        assert_eq!(digits, ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);168        assert_eq!(CELLS[36], " ");169        assert_eq!(CELLS[37], "backspace");170    }171172    // One case: the cell that holds focus, and the word a pick on it173    // presses.174    const PICKS: [(usize, &str); 6] = [175        (0, "a"),176        (9, "j"),177        (25, "z"),178        (26, "0"),179        (36, " "),180        (37, "backspace"),181    ];182183    #[test]184    fn every_cell_picks_the_word_it_carries() {185        for (focus, word) in PICKS {186            let keyboard = Keyboard { focus };187            assert_eq!(keyboard.pick(), word, "{focus}");188        }189    }190191    // One case: the cell that held focus, the word pressed, and the cell192    // that holds focus after.193    const MOVES: [(usize, &str, usize); 12] = [194        (0, "right", 1),195        (9, "right", 10),196        (10, "left", 9),197        (0, "left", 0),198        (37, "right", 37),199        (0, "down", 10),200        (10, "up", 0),201        (5, "up", 5),202        (29, "down", 37),203        (35, "down", 35),204        (37, "up", 27),205        (4, "enter", 4),206    ];207208    #[test]209    fn the_arrows_move_focus_across_the_grid_and_clamp_at_its_edges() {210        for (from, word, to) in MOVES {211            let mut keyboard = Keyboard { focus: from };212            assert_eq!(keyboard.key(word), from != to, "{from} {word}");213            assert_eq!(keyboard.focus, to, "{from} {word}");214        }215    }216217    #[test]218    fn a_new_keyboard_holds_focus_on_the_first_cell() {219        assert_eq!(Keyboard::default().pick(), "a");220    }221222    #[test]223    fn the_space_cell_shows_a_word_and_every_other_cell_shows_itself() {224        assert_eq!(shown(" "), "space");225        assert_eq!(shown("a"), "a");226        assert_eq!(shown("backspace"), "backspace");227    }228229    #[test]230    fn the_cells_sit_in_their_column_and_row() {231        let at = Point::new(120.0, 400.0);232        let first = cell(at, 0);233        assert_eq!(first.x, at.x);234        assert_eq!(first.y, at.y);235        assert_eq!(first.width, CELL);236        assert_eq!(first.height, HEIGHT);237238        let beside = cell(at, 1);239        assert_eq!(beside.x, first.x + CELL + GAP);240        assert_eq!(beside.y, first.y);241242        let below = cell(at, COLUMNS);243        assert_eq!(below.x, first.x);244        assert_eq!(below.y, first.y + HEIGHT + GAP);245    }246247    #[test]248    fn the_grid_is_as_wide_and_as_tall_as_the_cells_it_holds() {249        assert_eq!(rows(), 4);250        let last = cell(Point::ORIGIN, COLUMNS - 1);251        assert_eq!(width(), last.x + last.width);252        let bottom = cell(Point::ORIGIN, CELLS.len() - 1);253        assert_eq!(height(), bottom.y + bottom.height);254    }255256    #[test]257    fn a_cells_word_draws_on_one_line_inside_the_cell() {258        let cell = cell(Point::new(0.0, 0.0), 0);259        let band = band(cell);260        assert_eq!(band.height, text::height(1, look::CONTROL));261        assert_eq!(band.width, cell.width);262        assert_eq!(band.center_y(), cell.center_y());263        assert!(band.y > cell.y);264    }265266    #[test]267    fn the_widest_word_a_cell_shows_fits_inside_it() {268        for word in CELLS {269            assert!(270                text::measured(shown(word), look::CONTROL) < CELL,271                "{}",272                shown(word)273            );274        }275    }276}
src/views/layers.rs 99.2%
1// A page's canvases in the order the renderer draws them. The order is a2// type because of a renderer rule: inside one layer it draws every mesh,3// then every image, then every text, whatever order the canvas drew them4// in, so a fill drawn over a backdrop is painted under it. Each canvas of5// a stack is a layer of its own, and the stack gives the page its depth.67use std::cell::RefCell;8use std::convert::Infallible;910use iced_wgpu::Renderer;11use iced_widget::{Stack, canvas};12use iced_winit::core::{Element, Length, Point, Rectangle, Theme, mouse};1314use super::{Tone, area, curtain, extent, paint};15use crate::look;16use crate::posters::Posters;1718// The share of the width at which the scrim gives the art back, and the19// share of that run it holds the full shade for. The shade holds across20// the whole text column and falls off over the rest, because a line that21// ends in a half-cleared scrim is a line over the art itself.22const CLEARS_AT: f32 = 0.68;23const HOLDS_TO: f32 = 0.62;2425/// The ground under the part of a page that holds art of its own. A26/// movie's page has none, because nothing on it sits on the backdrop but27/// text and one strip. A series' page lays a near-black ground under its28/// episode wall, so the stills never fight the backdrop and the backdrop29/// still shows whole above them, heads included.30#[derive(Debug, Clone, Copy, PartialEq)]31pub enum Ground {32    /// No ground. The backdrop shows through the scrim alone.33    None,34    /// A ground from this many pixels down to the foot of the frame, with35    /// a short fade at its top edge.36    Below(f32),37}3839// The height of the fade at the top edge of a ground, so the backdrop40// does not end on a hard line under the header.41const FADE: f32 = 48.0;4243impl Ground {44    /// The part of these bounds the ground covers at full strength.45    pub fn of(self, bounds: Rectangle) -> Option<Rectangle> {46        match self {47            Self::None => None,48            Self::Below(top) => {49                let top = top.min(bounds.height);50                Some(area(51                    bounds.x,52                    bounds.y + top,53                    bounds.width,54                    bounds.height - top,55                ))56            }57        }58    }5960    /// The band above the ground where it fades in.61    pub fn fade(self, bounds: Rectangle) -> Option<Rectangle> {62        let ground = self.of(bounds)?;63        let top = (ground.y - FADE).max(bounds.y);64        Some(area(bounds.x, top, bounds.width, ground.y - top))65    }66}6768/// One page as its three layers: the backdrop, the scrim over it, and69/// everything the screen draws over both.70pub struct Page<'a, P, F> {71    /// The library the art paths resolve against.72    pub library: &'a str,73    /// The path of the backdrop file, empty where the item has none.74    pub art: &'a str,75    /// The store the backdrop comes from.76    pub posters: &'a RefCell<P>,77    /// The ground under the page's own art, where it has any.78    pub ground: Ground,79    /// The page itself: its fills, its art, and its text, in that draw80    /// order inside its own layer.81    pub front: F,82    /// The loading state's layers over the page, where a press has put the83    /// page into that state.84    pub over: Option<curtain::Layer<'a, P>>,85}8687impl<'a, P, F> Page<'a, P, F>88where89    P: Posters + 'a,90    F: canvas::Program<Infallible, Theme, Renderer> + 'a,91{92    /// The page as one element, its layers in depth order.93    pub fn view(self) -> Element<'a, Infallible, Theme, Renderer> {94        let mut layers = vec![95            whole(Backdrop {96                library: self.library,97                art: self.art,98                posters: self.posters,99            }),100            whole(Scrim {101                ground: self.ground,102            }),103            whole(self.front),104        ];105        if let Some(over) = self.over {106            layers.push(whole(over));107            layers.push(whole(curtain::Front(over)));108        }109        Stack::with_children(layers)110            .width(Length::Fill)111            .height(Length::Fill)112            .into()113    }114}115116// One layer over the whole frame.117fn whole<'a, Q>(program: Q) -> Element<'a, Infallible, Theme, Renderer>118where119    Q: canvas::Program<Infallible, Theme, Renderer> + 'a,120{121    canvas(program)122        .width(Length::Fill)123        .height(Length::Fill)124        .into()125}126127// The lowest layer: the item's backdrop over the whole frame. The store128// is asked at the size of the frame, which is the size the prefetch asked129// for while the wall held focus, so the page finds the decode in the130// cache.131struct Backdrop<'a, P> {132    library: &'a str,133    art: &'a str,134    posters: &'a RefCell<P>,135}136137impl<P: Posters> canvas::Program<Infallible, Theme, Renderer> for Backdrop<'_, P> {138    type State = ();139140    fn draw(141        &self,142        _state: &Self::State,143        renderer: &Renderer,144        _theme: &Theme,145        bounds: Rectangle,146        _cursor: mouse::Cursor,147    ) -> Vec<canvas::Geometry<Renderer>> {148        let mut frame = canvas::Frame::new(renderer, bounds.size());149        let art = self.posters.borrow_mut().poster(150            self.library,151            self.art,152            bounds.width as u32,153            bounds.height as u32,154        );155        match art {156            Some(image) => paint(&mut frame, &image, bounds, Tone::Full),157            None => frame.fill_rectangle(bounds.position(), extent(bounds), look::BACKGROUND),158        }159        vec![frame.into_geometry()]160    }161}162163/// One panel of shade over these bounds, dark at the left and clear164/// toward the right. One function draws every scrim, because a page and165/// the home page's banner shade their art the same way.166pub fn scrim(frame: &mut canvas::Frame<Renderer>, bounds: Rectangle) {167    frame.fill_rectangle(168        bounds.position(),169        extent(bounds),170        canvas::gradient::Linear::new(171            Point::new(bounds.x, bounds.y),172            Point::new(bounds.x + bounds.width * CLEARS_AT, bounds.y),173        )174        .add_stop(0.0, look::shade())175        .add_stop(HOLDS_TO, look::shade())176        .add_stop(1.0, look::CLEAR),177    );178}179180// The middle layer: one panel of shade that clears toward the right, so181// every line of the page reads over the art whatever the art holds, and182// the ground under the page's own art where the page has one.183struct Scrim {184    ground: Ground,185}186187impl canvas::Program<Infallible, Theme, Renderer> for Scrim {188    type State = ();189190    fn draw(191        &self,192        _state: &Self::State,193        renderer: &Renderer,194        _theme: &Theme,195        bounds: Rectangle,196        _cursor: mouse::Cursor,197    ) -> Vec<canvas::Geometry<Renderer>> {198        let mut frame = canvas::Frame::new(renderer, bounds.size());199        scrim(&mut frame, bounds);200        if let (Some(fade), Some(ground)) = (self.ground.fade(bounds), self.ground.of(bounds)) {201            frame.fill_rectangle(202                fade.position(),203                extent(fade),204                canvas::gradient::Linear::new(205                    Point::new(fade.x, fade.y),206                    Point::new(fade.x, fade.y + fade.height),207                )208                .add_stop(0.0, look::CLEAR)209                .add_stop(1.0, look::ground()),210            );211            frame.fill_rectangle(ground.position(), extent(ground), look::ground());212        }213        vec![frame.into_geometry()]214    }215}216217#[cfg(test)]218mod tests {219    use super::*;220221    fn bounds() -> Rectangle {222        area(0.0, 0.0, 1920.0, 1080.0)223    }224225    #[test]226    fn a_page_with_no_ground_lays_none() {227        assert_eq!(Ground::None.of(bounds()), None);228        assert_eq!(Ground::None.fade(bounds()), None);229    }230231    #[test]232    fn a_ground_runs_from_its_top_to_the_foot_of_the_frame() {233        let ground = Ground::Below(378.0).of(bounds()).unwrap();234        assert_eq!(ground.y, 378.0);235        assert_eq!(ground.width, 1920.0);236        assert_eq!(ground.y + ground.height, 1080.0);237    }238239    #[test]240    fn a_ground_fades_in_just_above_its_top() {241        let fade = Ground::Below(378.0).fade(bounds()).unwrap();242        assert_eq!(fade.y + fade.height, 378.0);243        assert_eq!(fade.height, FADE);244    }245246    #[test]247    fn a_ground_below_the_frame_covers_nothing() {248        let ground = Ground::Below(4000.0).of(bounds()).unwrap();249        assert_eq!(ground.height, 0.0);250    }251}
src/views/people.rs 100.0%
1// The stripe: one heading over a scrolled row of headshots, each with a2// name and a part under it. A title's page draws one stripe per part at3// its end, and the stripe is built the way the set strip is, so the two4// read as parts of one page.56use iced_wgpu::Renderer;7use iced_widget::canvas;8use iced_winit::core::alignment::Vertical;9use iced_winit::core::text::Alignment;10use iced_winit::core::{Point, Rectangle};1112use super::{Card, Tone, area, artwork, label, mark, scroll, text, wall};13use crate::look;14use crate::posters::Posters;1516/// The height of a headshot. It is smaller than a strip's poster, so17/// the three stripes of a page take less than the episode wall above18/// them.19pub const HEADSHOT: f32 = 195.0;2021/// The height the heading, the headshots, and the two caption22/// lines take together, so a page lays its stripes out before it draws.23pub const HEIGHT: f32 = HEADING + HEADSHOT + FOOT + CAPTIONS;2425// The height the heading takes over the headshots.26const HEADING: f32 = 46.0;2728// The gap between two headshots, wider than the focus mark29// reaches.30const GAP: f32 = 26.0;3132// The space between a headshot and the first caption line under33// it.34const FOOT: f32 = 12.0;3536// The height of the two caption lines, stated from the leading37// because the height a page reserves is a constant.38const CAPTIONS: f32 = 2.0 * look::FACE * text::LEADING;3940/// The width of one headshot: the height at the wall's poster41/// ratio.42pub fn headshot_width() -> f32 {43    HEADSHOT / wall::POSTER44}4546/// The distance from one headshot to the next.47pub fn pitch() -> f32 {48    headshot_width() + GAP49}5051/// The band the headshots draw in, under the heading, which a52/// page reads to place the row.53pub fn row(region: Rectangle) -> Rectangle {54    area(region.x, region.y + HEADING, region.width, HEADSHOT)55}5657/// The headshot of one index, in frame space after the scroll58/// that keeps the focused slot in view.59pub fn slot(region: Rectangle, count: usize, focus: Option<usize>, index: usize) -> Rectangle {60    let row = row(region);61    area(62        region.x + index as f32 * pitch() - offset(region, count, focus),63        row.y,64        headshot_width(),65        HEADSHOT,66    )67}6869// How far the row has scrolled: the offset that keeps the focused70// slot in view, and none where nothing holds focus.71fn offset(region: Rectangle, count: usize, focus: Option<usize>) -> f32 {72    scroll::offset(focus.unwrap_or(0), count, pitch(), region.width)73}7475/// One stripe to draw: the people, the one that holds focus, the76/// part the heading names, and the region it draws in.77pub struct Stripe<'a, T> {78    /// The people in the order the catalog answered them.79    pub people: &'a [T],80    /// The person that holds focus, or nothing while another row81    /// of the page holds it.82    pub focus: Option<usize>,83    /// The part the stripe is of, drawn over the headshots.84    pub heading: &'a str,85    /// The library the art paths resolve against.86    pub library: &'a str,87    /// The part of the frame the stripe draws in.88    pub region: Rectangle,89}9091/// Draw the stripe. Only the headshots inside the region become92/// geometry, so a cast of any length costs one row of slots.93pub fn draw<T: Card, P: Posters>(94    frame: &mut canvas::Frame<Renderer>,95    posters: &mut P,96    stripe: &Stripe<'_, T>,97) {98    frame.fill_text(label(99        stripe.heading,100        Point::new(stripe.region.x, stripe.region.y),101        look::HEADING,102        look::muted(),103        Alignment::Left,104        Vertical::Top,105        stripe.region.width,106    ));107108    let count = stripe.people.len();109    let offset = offset(stripe.region, count, stripe.focus);110    let range = scroll::visible(offset, stripe.region.width, pitch(), count, 1);111112    for index in range {113        let person = &stripe.people[index];114        let slot = slot(stripe.region, count, stripe.focus, index);115        artwork(116            frame,117            posters,118            stripe.library,119            person.art(),120            slot,121            person.name(),122            Tone::Full,123        );124        if stripe.focus == Some(index) {125            mark(frame, slot);126        }127        let name = captioned(slot);128        text::centered(frame, person.name(), name, look::FACE, look::text());129        // The second line draws the way a card's does, small, faint, and130        // italic, so a headshot and a poster read as one.131        let band = under(name);132        text::faced(133            frame,134            &text::cut(person.detail(), look::FACE, band.width),135            band,136            look::FACE,137            look::faint(),138            look::ITALIC,139        );140    }141}142143// The band the first caption line draws in, under the headshot144// and as wide as the pitch, so no name runs under its neighbour's.145fn captioned(slot: Rectangle) -> Rectangle {146    area(147        slot.center_x() - pitch() / 2.0,148        slot.y + slot.height + FOOT,149        pitch(),150        text::height(1, look::FACE),151    )152}153154// The band the second caption line draws in, under the first.155fn under(band: Rectangle) -> Rectangle {156    area(band.x, band.y + band.height, band.width, band.height)157}158159#[cfg(test)]160mod tests {161    use super::*;162    use crate::views::{REACH, strip};163164    // The region a page gives one stripe on a 1920 screen.165    fn region() -> Rectangle {166        area(120.0, 400.0, 1680.0, HEIGHT)167    }168169    #[test]170    fn a_headshot_keeps_the_walls_poster_ratio() {171        assert_eq!(HEADSHOT / headshot_width(), wall::POSTER);172        const { assert!(HEADSHOT < strip::POSTER) };173    }174175    #[test]176    fn the_height_is_the_heading_the_headshot_and_two_caption_lines() {177        assert_eq!(178            HEIGHT,179            HEADING + HEADSHOT + FOOT + text::height(2, look::FACE)180        );181    }182183    #[test]184    fn the_row_sits_under_the_heading() {185        let row = row(region());186        assert_eq!(row.y, region().y + HEADING);187        assert_eq!(row.height, HEADSHOT);188        assert_eq!(row.width, region().width);189    }190191    #[test]192    fn slots_sit_beside_each_other_by_the_pitch() {193        let first = slot(region(), 4, None, 0);194        let second = slot(region(), 4, None, 1);195        assert_eq!(first.x, region().x);196        assert_eq!(second.x, first.x + pitch());197        assert_eq!(second.y, first.y);198        assert_eq!(first.width, headshot_width());199        assert_eq!(first.height, HEADSHOT);200    }201202    #[test]203    fn a_stripe_that_fits_the_region_never_scrolls() {204        assert_eq!(slot(region(), 4, Some(3), 0).x, region().x);205    }206207    #[test]208    fn a_stripe_longer_than_the_region_scrolls_its_focus_into_view() {209        let last = slot(region(), 60, Some(59), 59);210        assert!(last.x + last.width <= region().x + region().width);211        assert!(slot(region(), 60, Some(59), 0).x < region().x);212    }213214    #[test]215    fn a_stripe_with_no_focus_starts_at_its_first_slot() {216        assert_eq!(slot(region(), 60, None, 0).x, region().x);217    }218219    #[test]220    fn a_headshot_has_room_beside_it_for_the_mark() {221        const { assert!(GAP / 2.0 > REACH) };222    }223224    #[test]225    fn the_two_caption_lines_sit_under_the_headshot_in_order() {226        let slot = slot(region(), 4, None, 0);227        let name = captioned(slot);228        let part = under(name);229        assert_eq!(name.width, pitch());230        assert!(name.y > slot.y + slot.height);231        assert_eq!(part.y, name.y + name.height);232        assert_eq!(part.y + part.height, region().y + HEIGHT);233    }234}
src/views/rail.rs 100.0%
1// The jump rail: one rotated bar per stretch of rows, at one edge of a2// long wall, so a person crosses a hundred rows in two presses. A bar names a3// stretch by its first and its last row, and the rail reads no further into4// what the stretch is: a franchise's eras, a series' seasons, and a wall's5// years are all bars here. Bars that overlap draw in lanes, the caller6// deciding which lane each one takes, and the rail draws at most LANES of7// them. The words read top to bottom, because a bar is tall and narrow.89use iced_wgpu::Renderer;10use iced_widget::canvas;11use iced_winit::core::{Color, Rectangle};1213use super::{area, mark, rounded, text};14use crate::look;1516/// One stretch of rows: the words on it, the first and the last row it covers,17/// and the lane it draws in. `first` and `last` are row indices in the wall18/// the rail stands beside.19#[derive(Debug, Clone, Default, PartialEq, Eq)]20pub struct Bar {21    pub label: String,22    pub first: usize,23    pub last: usize,24    pub lane: usize,25}2627/// The edge of the region the rail draws at. The franchise page draws28/// its eras at the left; a wall and a series page draw their jumps at29/// the right.30#[derive(Debug, Clone, Copy, PartialEq, Eq)]31pub enum Side {32    Left,33    Right,34}3536/// The two geometries a rail draws with. `Scrolled` is a map of the37/// whole wall: a bar covers the rows it names and moves with the scroll,38/// which is how the franchise page draws story time. `Fitted` holds39/// every bar on the screen at once, in equal shares of the region that40/// the scroll never moves, which is what a jump through a long list41/// needs.42#[derive(Debug, Clone, Copy, PartialEq)]43pub enum Fit<'a> {44    Scrolled { tops: &'a [f32], offset: f32 },45    Fitted,46}4748/// The width of one lane.49pub const LANE: f32 = 44.0;5051/// The most lanes a rail draws. A third lane leaves the wall no room,52/// so a bar the caller puts deeper draws in the last one.53pub const LANES: usize = 2;5455// The space under a bar and on the wall's side of its lane, so two bars56// in one lane read as two and the wall stands clear of the rail.57const GAP: f32 = 8.0;5859/// How far a rail at the right stands in from the region's edge: the60/// focus stroke's room and a little more, so the mark on a bar draws61/// whole and no part of it falls off the frame.62pub const EDGE: f32 = look::MARK_GAP + look::MARK + 4.0;6364// The radius the bars are drawn with.65const ROUND: f32 = 8.0;6667// The room the label's box holds past the length the average advance68// measures, so the shaper's own line always fits inside it.69const SLACK: f32 = 24.0;7071/// The width a rail of these bars takes, and none where it holds no72/// bars.73pub fn width(bars: &[Bar]) -> f32 {74    lanes(bars) as f32 * LANE75}7677/// How many lanes these bars fill, at most [`LANES`].78pub fn lanes(bars: &[Bar]) -> usize {79    bars.iter()80        .map(|bar| bar.lane.min(LANES - 1) + 1)81        .max()82        .unwrap_or(0)83}8485/// The box one bar draws in, in frame space after the scroll. `tops` is86/// where every row of the wall beside the rail starts, and where the last87/// one ends, from the region's top, because those rows are not one88/// height.89pub fn bar(region: Rectangle, held: &Bar, tops: &[f32], offset: f32) -> Rectangle {90    bar_at(region, held, tops, offset, Side::Left)91}9293/// The same box, with the rail at the side the caller names.94pub fn bar_at(region: Rectangle, held: &Bar, tops: &[f32], offset: f32, side: Side) -> Rectangle {95    let lane = held.lane.min(LANES - 1) as f32;96    let top = tops.get(held.first).copied().unwrap_or_default();97    let end = tops98        .get(held.last + 1)99        .or(tops.last())100        .copied()101        .unwrap_or(top);102    area(103        lane_x(region, lane, side),104        region.y + top - offset,105        LANE - GAP,106        (end - top - GAP).max(0.0),107    )108}109110// Where one lane starts, counted in from the rail's edge, so the gap111// falls on the wall's side of the lane at either edge.112fn lane_x(region: Rectangle, lane: f32, side: Side) -> f32 {113    match side {114        Side::Left => region.x + lane * LANE,115        Side::Right => region.x + region.width - EDGE - (lane + 1.0) * LANE + GAP,116    }117}118119/// How many bars fit the region once the longest of their labels has120/// room to read whole. A caller whose labels grow as its bars merge121/// asks again with the labels of the count this answered.122pub fn fits(region: Rectangle, longest: &str) -> usize {123    holding(124        region.height * TOLERANCE,125        text::width(longest, look::HEADING) + SLACK + GAP,126    )127}128129// The labels are fitted against nine tenths of the region. A caller130// counts its bars once, against the screen the browser is drawn for,131// and draws them in whatever window is open. The tenth held back keeps132// every label whole in a window up to a tenth shorter than that screen,133// and costs one bar in ten on the screen itself.134const TOLERANCE: f32 = 0.9;135136// How many bars of this height the room holds, and one for a room137// shorter than one bar, because a rail of no bars leaves nothing to jump138// by.139fn holding(room: f32, height: f32) -> usize {140    ((room / height) as usize).max(1)141}142143/// The box of every bar of a fitted rail: equal shares of the region144/// from its top, in list order, at the side the caller names.145pub fn fitted(region: Rectangle, bars: &[Bar], side: Side) -> Vec<Rectangle> {146    if bars.is_empty() {147        return Vec::new();148    }149    let height = region.height / bars.len() as f32;150    bars.iter()151        .enumerate()152        .map(|(index, held)| {153            let lane = held.lane.min(LANES - 1) as f32;154            area(155                lane_x(region, lane, side),156                region.y + index as f32 * height,157                LANE - GAP,158                (height - GAP).max(0.0),159            )160        })161        .collect()162}163164/// Draw the rail. `focus` names the bar that holds focus, or nothing165/// while the wall beside it holds focus. Only the bars the region166/// reaches become geometry.167pub fn draw(168    frame: &mut canvas::Frame<Renderer>,169    region: Rectangle,170    bars: &[Bar],171    tops: &[f32],172    offset: f32,173    focus: Option<usize>,174) {175    draw_at(176        frame,177        region,178        bars,179        focus,180        Side::Left,181        Fit::Scrolled { tops, offset },182    );183}184185/// The same draw, with the bars at the side and in the geometry the186/// caller names.187pub fn draw_at(188    frame: &mut canvas::Frame<Renderer>,189    region: Rectangle,190    bars: &[Bar],191    focus: Option<usize>,192    side: Side,193    fit: Fit,194) {195    for (index, (held, bounds)) in bars.iter().zip(boxes(region, bars, side, fit)).enumerate() {196        if bounds.y + bounds.height < region.y || bounds.y > region.y + region.height {197            continue;198        }199        frame.fill(&rounded(bounds, ROUND), ink(focus == Some(index)));200        written(frame, bounds, region, &held.label);201        if focus == Some(index) {202            mark(frame, bounds);203        }204    }205}206207// The fill under a bar's words. A rail draws over a page's art, so the208// fill is the near-black ground the art reads through as a trace, and209// the focused bar takes the lighter ground a focused row takes under its210// mark.211fn ink(focused: bool) -> Color {212    match focused {213        true => look::slot(),214        false => look::ground(),215    }216}217218/// The box of every bar in the geometry the caller names.219pub fn boxes(region: Rectangle, bars: &[Bar], side: Side, fit: Fit) -> Vec<Rectangle> {220    match fit {221        Fit::Scrolled { tops, offset } => bars222            .iter()223            .map(|held| bar_at(region, held, tops, offset, side))224            .collect(),225        Fit::Fitted => fitted(region, bars, side),226    }227}228229/// Where one bar's label draws: the middle of the part of the bar the230/// region shows, not the middle of the whole bar. An era of a hundred231/// rows is taller than any screen, so a label at the whole bar's middle232/// is off screen for most of the scroll and the rail reads blank.233/// The label stays inside its own bar at either end, so a bar that has234/// scrolled almost away carries its label to the edge and no further.235/// `length` is the label's own length along the bar, and a bar shorter236/// than that carries as much of the label as it holds.237pub fn label_box(bounds: Rectangle, region: Rectangle, length: f32) -> Rectangle {238    let length = length.min(bounds.height);239    let top = bounds.y.max(region.y);240    let foot = (bounds.y + bounds.height).min(region.y + region.height);241    let middle = (top + foot - length) / 2.0;242    let top = middle.max(bounds.y).min(bounds.y + bounds.height - length);243    area(bounds.x, top, bounds.width, length)244}245246// One bar's words, cut to the bar's own length and turned to read from247// its foot to its head.248fn written(249    frame: &mut canvas::Frame<Renderer>,250    bounds: Rectangle,251    region: Rectangle,252    content: &str,253) {254    let shown = text::cut(content, look::HEADING, bounds.height);255    let at = label_box(bounds, region, text::width(&shown, look::HEADING) + SLACK);256    text::downward(frame, &shown, at, look::HEADING, look::text());257}258259/// The part of the region the wall beside the rail draws in: everything260/// to the right of the last lane.261pub fn beside(region: Rectangle, bars: &[Bar]) -> Rectangle {262    beside_at(region, bars, Side::Left)263}264265/// The same part of the region, with the lanes taken off the edge the266/// caller names. A rail at the right also stands [`EDGE`] in from the267/// region, and the wall gives up that much along with the lanes.268pub fn beside_at(region: Rectangle, bars: &[Bar], side: Side) -> Rectangle {269    let taken = width(bars);270    match side {271        Side::Left => area(272            region.x + taken,273            region.y,274            region.width - taken,275            region.height,276        ),277        Side::Right => {278            let inset = match bars.is_empty() {279                true => 0.0,280                false => EDGE,281            };282            area(283                region.x,284                region.y,285                region.width - taken - inset,286                region.height,287            )288        }289    }290}291292/// The bar that covers one row, and the first one where two lanes cover293/// it, so a move onto the rail lands on the widest stretch.294pub fn covering(bars: &[Bar], row: usize) -> Option<usize> {295    bars.iter()296        .position(|bar| bar.first <= row && row <= bar.last)297}298299#[cfg(test)]300mod tests;
src/views/ratings.rs 99.3%
1// The ratings line: each site's own mark, then the score on that site's2// scale, in one row under the facts of a title. The marks are the sites'3// logos, baked into the binary, because a drawn imitation reads as a4// fake and a fetched one would need a network the screen does not have.56use std::sync::OnceLock;78use iced_wgpu::Renderer;9use iced_widget::canvas;10use iced_widget::core::Bytes;11use iced_widget::image::Handle;12use iced_winit::core::alignment::Vertical;13use iced_winit::core::text::Alignment;14use iced_winit::core::{Color, Point};1516use super::{area, label, text};17use crate::look;1819/// The height the line takes in the stack that holds it.20pub const HEIGHT: f32 = look::SCORE * text::LEADING;2122// The height of one mark, a little under the line's own height so the23// marks sit inside it.24const MARK: f32 = 22.0;2526// The space between a mark and the score beside it.27const GAP: f32 = 8.0;2829// The space between one entry and the next.30const BETWEEN: f32 = 28.0;3132// The space between a score and its scale, so the small face does not33// touch the large one.34const SCALE_GAP: f32 = 4.0;3536// The name the sidecar's ratings block writes for each of the three37// sites the line draws. Jellyfin reads a name holding "tomato" as the38// critic rating, and tomatometerallcritics is the name it writes.39const IMDB: &str = "imdb";40const TOMATOMETER: &str = "tomatometerallcritics";41const METACRITIC: &str = "metacritic";4243// The three marks, baked into the binary at 128 pixels tall, so the line44// draws the sites' own logos and asks no volume and no network for them.45// The renderer keys its uploads by handle id, so each mark decodes once46// and keeps its handle for the life of the process.47const IMDB_MARK: &[u8] = include_bytes!("../../assets/ratings/imdb.png");48const TOMATO_MARK: &[u8] = include_bytes!("../../assets/ratings/rotten-tomatoes.png");49const METACRITIC_MARK: &[u8] = include_bytes!("../../assets/ratings/metacritic.png");5051static MARKS: OnceLock<[Option<Logo>; 3]> = OnceLock::new();5253// One decoded mark: its handle, and the width it takes at the line's54// mark height, from its own ratio.55struct Logo {56    handle: Handle,57    width: f32,58}5960impl Logo {61    fn decode(png: &[u8]) -> Option<Self> {62        let decoded = image::load_from_memory(png).ok()?.to_rgba8();63        let (width, height) = decoded.dimensions();64        let handle = Handle::from_rgba(width, height, Bytes::from(decoded.into_raw()));65        Some(Self {66            handle,67            width: MARK * width as f32 / height as f32,68        })69    }70}7172fn logo(mark: Mark) -> Option<&'static Logo> {73    let marks = MARKS.get_or_init(|| {74        [75            Logo::decode(IMDB_MARK),76            Logo::decode(TOMATO_MARK),77            Logo::decode(METACRITIC_MARK),78        ]79    });80    marks[mark as usize].as_ref()81}8283/// Which site's mark draws before a score.84#[derive(Debug, Clone, Copy, PartialEq, Eq)]85pub enum Mark {86    /// IMDb's yellow wordmark.87    Imdb,88    /// The tomatometer's tomato.89    Tomato,90    /// Metacritic's round mark.91    Metacritic,92}9394/// One entry of the line: the site's mark, and its score on that site's95/// own scale.96#[derive(Debug, Clone, Copy, PartialEq)]97pub struct Score {98    /// The site's mark.99    pub mark: Mark,100    /// The score, on the site's own scale.101    pub value: f64,102}103104impl Score {105    /// The score in the site's own form: one decimal for IMDb, and a whole106    /// number for the tomatometer and the Metascore.107    pub fn shown(&self) -> String {108        match self.mark {109            Mark::Imdb => format!("{:.1}", self.value),110            Mark::Tomato | Mark::Metacritic => format!("{}", self.value.round()),111        }112    }113114    /// The site's scale, which draws small and dim after the score: out115    /// of ten for IMDb, a percentage for the tomatometer, and out of a116    /// hundred for the Metascore.117    pub fn scale(&self) -> &'static str {118        match self.mark {119            Mark::Imdb => "/10",120            Mark::Tomato => "%",121            Mark::Metacritic => "/100",122        }123    }124}125126/// The entries the line draws, in the one order it draws them: IMDb, the127/// tomatometer, then Metacritic. A site the body holds no score for is128/// left out, and TMDb's score is left off the line.129pub fn scores(ratings: &[(String, f64)]) -> Vec<Score> {130    [131        (Mark::Imdb, IMDB),132        (Mark::Tomato, TOMATOMETER),133        (Mark::Metacritic, METACRITIC),134    ]135    .into_iter()136    .filter_map(|(mark, name)| {137        let (_, value) = ratings.iter().find(|(held, _)| held == name)?;138        Some(Score {139            mark,140            value: *value,141        })142    })143    .collect()144}145146/// Draw the line with its left edge at `at`. The answer is the height it147/// took, and zero where the title holds no score, so the caller stacks148/// the next block under it.149pub fn draw(frame: &mut canvas::Frame<Renderer>, scores: &[Score], at: Point) -> f32 {150    if scores.is_empty() {151        return 0.0;152    }153    let mut left = at.x;154    let top = at.y + (HEIGHT - MARK) / 2.0;155    for score in scores {156        left += entry(frame, *score, Point::new(left, top)) + BETWEEN;157    }158    HEIGHT159}160161// One entry: the site's mark, then the score, then its scale. The answer162// is the width the entry took. A mark that did not decode leaves the163// score alone on the line.164fn entry(frame: &mut canvas::Frame<Renderer>, score: Score, at: Point) -> f32 {165    let width = match logo(score.mark) {166        Some(logo) => {167            frame.draw_image(168                area(at.x, at.y, logo.width, MARK),169                canvas::Image::new(logo.handle.clone()),170            );171            logo.width + GAP172        }173        None => 0.0,174    };175    let shown = beside(176        frame,177        Point::new(at.x + width, at.y),178        &score.shown(),179        look::SCORE,180        look::text(),181    );182    let scale = beside(183        frame,184        Point::new(at.x + width + shown + SCALE_GAP, at.y),185        score.scale(),186        look::CAPTION,187        look::faint(),188    );189    width + shown + SCALE_GAP + scale190}191192// One run of text beside a mark, centred on the mark's height. The answer193// is the width it took.194fn beside(195    frame: &mut canvas::Frame<Renderer>,196    at: Point,197    content: &str,198    size: f32,199    color: Color,200) -> f32 {201    let width = text::width(content, size);202    let band = area(at.x, at.y, width, MARK);203    frame.fill_text(label(204        content,205        Point::new(band.x, band.center_y()),206        size,207        color,208        Alignment::Left,209        Vertical::Center,210        f32::INFINITY,211    ));212    width213}214215#[cfg(test)]216mod tests {217    use super::*;218219    fn held() -> Vec<(String, f64)> {220        [221            ("imdb", 6.5),222            ("metacritic", 80.0),223            ("themoviedb", 7.1),224            ("tomatometerallcritics", 83.0),225        ]226        .into_iter()227        .map(|(name, value)| (name.to_string(), value))228        .collect()229    }230231    #[test]232    fn the_line_draws_three_sites_in_its_own_order_and_leaves_tmdb_off() {233        let scores = scores(&held());234        let marks: Vec<Mark> = scores.iter().map(|score| score.mark).collect();235        assert_eq!(marks, [Mark::Imdb, Mark::Tomato, Mark::Metacritic]);236        assert_eq!(scores[0].value, 6.5);237        assert_eq!(scores[2].value, 80.0);238    }239240    #[test]241    fn a_score_reads_in_the_sites_own_form_with_its_scale_after_it() {242        let scores = scores(&held());243        let parts: Vec<(String, &str)> = scores244            .iter()245            .map(|score| (score.shown(), score.scale()))246            .collect();247        assert_eq!(248            parts,249            [250                ("6.5".to_string(), "/10"),251                ("83".to_string(), "%"),252                ("80".to_string(), "/100"),253            ]254        );255    }256257    #[test]258    fn a_site_the_body_holds_no_score_for_is_left_out() {259        let held = vec![("tomatometerallcritics".to_string(), 83.0)];260        let scores = scores(&held);261        assert_eq!(scores.len(), 1);262        assert_eq!(scores[0].mark, Mark::Tomato);263    }264265    #[test]266    fn a_title_with_no_ratings_draws_no_line() {267        assert!(scores(&[]).is_empty());268    }269}
src/views/scroll.rs 100.0%
1// The culling math the wall and the lists share: where the viewport2// scrolls to, and which slots fall inside it. The functions are pure3// over numbers, so the tests prove the head-to-head's floor of five4// thousand titles without building a row.56use std::ops::Range;78/// The pixel offset that keeps the focused row centered, clamped to9/// the edges of the content, so focus never leaves the viewport.10pub fn offset(focus_row: usize, rows: usize, row_height: f32, height: f32) -> f32 {11    let content = rows as f32 * row_height;12    if content <= height {13        return 0.0;14    }15    let centered = (focus_row as f32 + 0.5) * row_height - height / 2.0;16    centered.clamp(0.0, content - height)17}1819/// How many rows `count` slots fill at `columns` a row.20pub fn rows(count: usize, columns: usize) -> usize {21    count.div_ceil(columns)22}2324/// The slot indices inside the scrolled viewport, partial rows25/// included; the views build nothing outside this range.26pub fn visible(27    offset: f32,28    height: f32,29    row_height: f32,30    count: usize,31    columns: usize,32) -> Range<usize> {33    if count == 0 || row_height <= 0.0 {34        return 0..0;35    }36    let first_row = (offset / row_height).floor().max(0.0) as usize;37    let past_row = ((offset + height) / row_height).ceil().max(0.0) as usize;38    let start = (first_row * columns).min(count);39    let end = (past_row * columns).min(count);40    start..end41}4243#[cfg(test)]44mod tests {45    use super::*;4647    #[test]48    fn content_that_fits_never_scrolls() {49        assert_eq!(offset(1, 2, 100.0, 500.0), 0.0);50    }5152    #[test]53    fn the_focused_row_sits_centered() {54        assert_eq!(offset(10, 100, 100.0, 500.0), 800.0);55    }5657    #[test]58    fn the_scroll_clamps_at_the_top_and_the_bottom() {59        assert_eq!(offset(0, 100, 100.0, 500.0), 0.0);60        assert_eq!(offset(99, 100, 100.0, 500.0), 9500.0);61    }6263    #[test]64    fn slots_fill_rows_with_a_remainder() {65        assert_eq!(rows(10, 3), 4);66        assert_eq!(rows(9, 3), 3);67        assert_eq!(rows(0, 3), 0);68    }6970    #[test]71    fn the_viewport_culls_a_wall_of_thousands() {72        let all = 5000;73        let range = visible(0.0, 1080.0, 458.0, all, 6);74        assert_eq!(range, 0..18);75    }7677    #[test]78    fn a_scrolled_viewport_starts_past_the_hidden_rows() {79        let range = visible(950.0, 1080.0, 458.0, 5000, 6);80        assert_eq!(range, 12..30);81    }8283    #[test]84    fn the_last_rows_end_at_the_count() {85        let range = visible(300.0, 500.0, 100.0, 25, 3);86        assert_eq!(range, 9..24);87        let range = visible(9500.0, 500.0, 100.0, 299, 3);88        assert_eq!(range, 285..299);89    }9091    #[test]92    fn nothing_is_visible_in_an_empty_level() {93        assert_eq!(visible(0.0, 1080.0, 100.0, 0, 6), 0..0);94    }95}
src/views/stack.rs 100.0%
1// The stack a page is: a column of blocks with a gap between two of them,2// and the scroll that keeps the block focus is on inside the viewport. A3// movie's page and a series' page are both this stack, so one rule4// decides what a press brings into view on either.56use iced_winit::core::{Point, Rectangle};78use super::area;910/// Where the next block of a page starts. A block that drew nothing moves11/// it nowhere and takes no gap, so a missing line leaves no hole.12pub struct Stack {13    at: Point,14    gap: f32,15}1617impl Stack {18    /// A stack that starts at this corner and leaves this much space19    /// between two blocks.20    pub fn new(at: Point, gap: f32) -> Self {21        Self { at, gap }22    }2324    /// Where the next block starts.25    pub fn at(&self) -> Point {26        self.at27    }2829    /// Move down by the height a block took.30    pub fn add(&mut self, taken: f32) {31        if taken > 0.0 {32            self.at.y += taken + self.gap;33        }34    }3536    /// Move down by this much with no gap, for a block that wants more37    /// room over it than the stack leaves between two blocks.38    pub fn skip(&mut self, extra: f32) {39        self.at.y += extra;40    }41}4243/// How far a page has scrolled. `region` is the block focus is on, in the44/// stack's own space. `tail` is the height of what follows it and takes45/// no focus of its own, so a block a press can never reach is brought46/// into view by the block above it. The page stands at its top until the47/// focused block and its tail would leave the foot of the viewport, and48/// the focused block never leaves the head of it.49pub fn offset(region: Rectangle, tail: f32, content: f32, height: f32) -> f32 {50    let most = region.y.min((content - height).max(0.0));51    (region.y + region.height + tail - height)52        .max(0.0)53        .min(most.max(0.0))54}5556/// Where the head of one section draws while the section scrolls through57/// a region: at the section's own top while that top is in view, held at58/// the top of the region while the section runs past it, and pushed off59/// by the foot of the section, so a head never leaves the section it60/// names. A section shorter than its head carries as much of it as it61/// holds. The jump rail's labels and a series page's season dividers are62/// the same rule.63pub fn held(section: Rectangle, region: Rectangle, head: f32) -> Rectangle {64    let head = head.min(section.height);65    let top = section66        .y67        .max(region.y)68        .min(section.y + section.height - head);69    area(section.x, top, section.width, head)70}7172#[cfg(test)]73mod tests {74    use super::*;7576    fn block(top: f32, height: f32) -> Rectangle {77        Rectangle {78            x: 0.0,79            y: top,80            width: 100.0,81            height,82        }83    }8485    #[test]86    fn a_stack_moves_down_by_what_a_block_took_and_its_gap() {87        let mut stack = Stack::new(Point::new(10.0, 20.0), 16.0);88        stack.add(30.0);89        assert_eq!(stack.at(), Point::new(10.0, 66.0));90        stack.add(0.0);91        assert_eq!(stack.at(), Point::new(10.0, 66.0));92    }9394    #[test]95    fn a_page_that_fits_stands_at_its_top() {96        assert_eq!(offset(block(100.0, 200.0), 0.0, 900.0, 1080.0), 0.0);97        assert_eq!(offset(block(100.0, 200.0), 400.0, 900.0, 1080.0), 0.0);98    }99100    #[test]101    fn a_focused_block_at_the_foot_scrolls_the_page() {102        assert_eq!(offset(block(900.0, 200.0), 0.0, 2000.0, 1080.0), 20.0);103    }104105    #[test]106    fn what_follows_the_focused_block_comes_into_view_with_it() {107        assert_eq!(offset(block(700.0, 200.0), 300.0, 2000.0, 1080.0), 120.0);108    }109110    #[test]111    fn the_scroll_stops_at_the_foot_of_the_page() {112        assert_eq!(offset(block(1800.0, 200.0), 0.0, 2000.0, 1080.0), 920.0);113    }114115    #[test]116    fn the_focused_block_never_leaves_the_head_of_the_viewport() {117        assert_eq!(offset(block(200.0, 900.0), 600.0, 4000.0, 1080.0), 200.0);118    }119}
src/views/strip.rs 97.7%
1// The strip: one row of art under a heading, at one height, left to2// right. A poster draws at 2:3 and a still at 16:9 side by side, and3// nothing grows or crops to a ratio it does not have. A "see all" slot4// may end the row. A set strip on a movie page marks the film the page is5// about and dims its siblings. The row scrolls so the focused slot stays6// in view.78use iced_wgpu::Renderer;9use iced_widget::canvas;10use iced_winit::core::alignment::Vertical;11use iced_winit::core::text::Alignment;12use iced_winit::core::{Color, Point, Rectangle};1314use super::{Card, Tone, area, artwork, card, clock, label, mark, mosaic, text, underline, wall};15use crate::look;16use crate::posters::Posters;1718/// The height of a poster in the strip.19/// Every slot of a strip is this tall, whatever its ratio.20pub const POSTER: f32 = 270.0;2122// The height the heading takes over the posters.23const HEADING: f32 = 46.0;2425// The gap between two posters.26const GAP: f32 = 26.0;2728// The space between a slot and the caption line under it, wider than29// the mark reaches.30const FOOT: f32 = 12.0;3132/// The words on the last slot of a strip whose read answered more than33/// the strip shows.34pub const SEE_ALL: &str = "See all";3536/// The height a strip takes with this many lines under each slot: none on37/// a set strip, and the card's two everywhere else.38pub fn height(lines: usize) -> f32 {39    match lines {40        0 => HEADING + POSTER,41        lines => HEADING + POSTER + FOOT + card::height(lines),42    }43}4445// The space the heading's box holds past the width the average advance46// measures, so the mark never cuts the last letter of a name the47// shaper set wider than the estimate.48const SLACK: f32 = 24.0;4950/// A heading in two runs: the name, and the dot and the words after it. A51/// heading with no dot is the name alone, and the second run is empty. The two52/// runs draw in two colors, so a person reads the name first and the scope53/// second.54pub fn split(heading: &str) -> (&str, &str) {55    match heading.find(DOT) {56        Some(at) => heading.split_at(at),57        None => (heading, ""),58    }59}6061// The words that stand between the name of a strip and the scope after62// it. The library bands join their facts with the same three63// characters.64const DOT: &str = " · ";6566// The heading over a strip, in two colors: the name bright, and the dot and67// the words after it muted. The whole heading draws muted, and the name draws68// over it in the bright ink, so the shaper places both runs and no estimate of69// the name's width stands between them. An estimate is what the page has, and70// it is short by a few pixels on a long name, which would close the space71// before the dot.72fn headed(frame: &mut canvas::Frame<Renderer>, region: Rectangle, heading: &str) {73    let (name, _) = split(heading);74    for (content, color) in [(heading, look::muted()), (name, look::text())] {75        if content.is_empty() {76            continue;77        }78        frame.fill_text(label(79            content,80            Point::new(region.x, region.y),81            look::HEADING,82            color,83            Alignment::Left,84            Vertical::Top,85            region.width,86        ));87    }88}8990/// The box the heading over a strip draws in, which the mark follows91/// where the heading holds focus. It is as wide as the words, so the92/// mark frames the name and not the row.93pub fn heading_box(region: Rectangle, heading: &str) -> Rectangle {94    area(95        region.x,96        region.y,97        (text::width(heading, look::HEADING) + SLACK).min(region.width),98        text::height(1, look::HEADING),99    )100}101102/// The width of one poster: the height at the wall's own ratio.103pub fn poster_width() -> f32 {104    width_at(wall::POSTER)105}106107/// The width of a slot at this ratio, so a still is wider than a poster108/// at the same height.109pub fn width_at(ratio: f32) -> f32 {110    POSTER / ratio111}112113/// The width of the caption band under a slot at this ratio. It is a114/// constant of the strip and not of the frame, so a caption is cut to it115/// once, at the read.116pub fn caption_width(ratio: f32) -> f32 {117    width_at(ratio) + GAP118}119120/// The slot that ends a strip and opens what the strip is about: its121/// words, and the art it draws as with the library that art resolves122/// against, both empty where it draws its words alone.123#[derive(Debug, Clone, Copy, PartialEq, Eq)]124pub struct Last<'a> {125    pub words: &'a str,126    pub library: &'a str,127    pub art: &'a str,128}129130/// One strip to draw. `current` is the member the page is about, and131/// nothing on a strip that is about no member. `last` is the slot that132/// ends the row and opens what the strip is about, or nothing where the133/// row ends with its members, and `lines` is the caption lines under134/// each slot.135pub struct Strip<'a, T> {136    /// The members in the order the catalog answered them.137    pub members: &'a [T],138    /// The index of the member the page is about. It draws at full139    /// brightness.140    pub current: Option<usize>,141    /// The member that holds focus, or nothing while another row of the142    /// page holds it.143    pub focus: Option<usize>,144    /// The set's own title, drawn over the posters.145    pub heading: &'a str,146    /// The library the art paths resolve against.147    pub library: &'a str,148    pub last: Option<Last<'a>>,149    pub lines: usize,150    /// Whether the heading over the strip holds focus. A strip whose151    /// heading opens a page of its own takes focus there as well as on152    /// its members, and the mark says which.153    pub headed: bool,154    /// The part of the frame the strip draws in.155    pub region: Rectangle,156}157158/// Where every slot of the strip is before the scroll, as its left edge159/// and its width: each member at its own ratio, then the "see all" slot160/// at the poster's, each one gap after the last.161pub fn placed<T: Card>(members: &[T], see_all: bool) -> Vec<(f32, f32)> {162    let mut slots = Vec::with_capacity(members.len() + 1);163    let mut x = 0.0;164    let widths = members165        .iter()166        .map(|member| width_at(member.ratio()))167        .chain(see_all.then(poster_width));168    for width in widths {169        slots.push((x, width));170        x += width + GAP;171    }172    slots173}174175/// How far the row has scrolled: the focused slot centered, clamped so176/// the row never leaves a gap at either end, and none while nothing holds177/// focus or the row fits.178pub fn offset(slots: &[(f32, f32)], focus: Option<usize>, viewport: f32) -> f32 {179    let Some((last, width)) = slots.last() else {180        return 0.0;181    };182    let content = last + width;183    if content <= viewport {184        return 0.0;185    }186    let Some((x, width)) = focus.and_then(|index| slots.get(index)) else {187        return 0.0;188    };189    (x + width / 2.0 - viewport / 2.0).clamp(0.0, content - viewport)190}191192/// The slot of one index in frame space, after the scroll.193pub fn slot(strip_region: Rectangle, slots: &[(f32, f32)], offset: f32, index: usize) -> Rectangle {194    let (x, width) = slots[index];195    area(196        strip_region.x + x - offset,197        strip_region.y + HEADING,198        width,199        POSTER,200    )201}202203/// Draw the strip. Only the posters inside the region become geometry,204/// so a set of any length costs one row of slots.205pub fn draw<T: Card, P: Posters>(206    frame: &mut canvas::Frame<Renderer>,207    posters: &mut P,208    strip: &Strip<'_, T>,209) {210    headed(frame, strip.region, strip.heading);211    if strip.headed {212        mark(frame, heading_box(strip.region, strip.heading));213    }214215    let slots = placed(strip.members, strip.last.is_some());216    let offset = offset(&slots, strip.focus.or(strip.current), strip.region.width);217    let right = strip.region.x + strip.region.width;218219    for index in 0..slots.len() {220        let slot = slot(strip.region, &slots, offset, index);221        if slot.x + slot.width < strip.region.x || slot.x > right {222            continue;223        }224        let focused = strip.focus == Some(index);225        match strip.members.get(index) {226            // A shelf draws the mosaic of its own posters, and every other227            // slot draws the one art the member names.228            Some(member) => {229                match member.tiles().is_empty() {230                    true => artwork(231                        frame,232                        posters,233                        library_of(member, strip.library),234                        member.art(),235                        slot,236                        member.name(),237                        tone(strip, index),238                    ),239                    false => mosaic(frame, posters, member.tiles(), slot, tone(strip, index)),240                }241                pilled(frame, member, slot);242                if strip.lines > 0 {243                    card::draw(frame, member, caption_band(slot));244                }245            }246            // The last slot draws its art with its words as the caption,247            // or its words alone in the slot where it has no art.248            None => {249                let Some(last) = strip.last else {250                    continue;251                };252                artwork(253                    frame,254                    posters,255                    last.library,256                    last.art,257                    slot,258                    last.words,259                    Tone::Full,260                );261                if !last.art.is_empty() && strip.lines > 0 {262                    worded(frame, caption_band(slot), last.words);263                }264            }265        }266        if strip.current == Some(index) {267            underline(frame, slot);268        }269        if focused {270            mark(frame, slot);271        }272    }273}274275// The pill's inset from the top and left edges of the still.276const PILL_INSET: f32 = 10.0;277278// The words on the pill over a show's still, and nothing where the show279// holds one new episode or none, because one new thing is what every280// slot of the strip is.281fn pill_words(new: usize) -> Option<String> {282    (new > 1).then(|| format!("{new} new"))283}284285// The band the pill's words draw in: as wide as the shaper sets them, in286// the top-left corner of the art.287fn pill(slot: Rectangle, words: &str) -> Rectangle {288    area(289        slot.x + PILL_INSET,290        slot.y + PILL_INSET,291        text::measured(words, look::CAPTION),292        text::height(1, look::CAPTION),293    )294}295296// The pill over the still of a show that holds more than one new297// episode, and nothing over any other slot. The words draw over a halo298// of dark copies, as the clock does, because a layer draws every fill299// under every image, so a plate under the words would never show over300// the still.301fn pilled<T: Card>(frame: &mut canvas::Frame<Renderer>, member: &T, slot: Rectangle) {302    let Some(words) = pill_words(member.new_episodes()) else {303        return;304    };305    let band = pill(slot, &words);306    let at = Point::new(band.x, band.center_y());307    let ink = |point: Point, color: Color| {308        label(309            &words,310            point,311            look::CAPTION,312            color,313            Alignment::Left,314            Vertical::Center,315            band.width + SLACK,316        )317    };318    for point in clock::halo(at) {319        frame.fill_text(ink(point, look::BACKGROUND));320    }321    frame.fill_text(ink(at, look::text()));322}323324// The band the first caption line of a slot draws in, a gap wider than325// the slot so a caption may run a little past its edges.326fn caption_band(slot: Rectangle) -> Rectangle {327    area(328        slot.center_x() - (slot.width + GAP) / 2.0,329        slot.y + slot.height + FOOT,330        slot.width + GAP,331        text::height(1, look::CAPTION),332    )333}334335// The one muted line under the slot that ends a strip. The shaper cuts it336// to the band, as it cuts the cards beside it, because a word the337// estimate calls short enough can set wider than the band and clip in338// the middle of a letter.339fn worded(frame: &mut canvas::Frame<Renderer>, band: Rectangle, words: &str) {340    let cut = card::cut(words, band.width);341    text::shown(frame, &cut, band, look::CAPTION, look::muted());342}343344// The library one slot's art resolves against: the slot's own where it345// names one, and the strip's otherwise, because a home strip spans346// libraries and a set strip does not.347fn library_of<'a, T: Card>(member: &'a T, strip: &'a str) -> &'a str {348    match member.library().is_empty() {349        true => strip,350        false => member.library(),351    }352}353354/// How bright one member of the strip draws: the film the page is about355/// and the one that holds focus at full, and every sibling under it.356/// The tone one member draws at. Every member draws at full where the357/// strip is about no member.358pub fn tone<T>(strip: &Strip<'_, T>, index: usize) -> Tone {359    match strip.current {360        Some(current) if current != index && strip.focus != Some(index) => Tone::Dimmed,361        _ => Tone::Full,362    }363}364365#[cfg(test)]366mod tests {367    use super::*;368369    struct Member(f32);370371    impl Card for Member {372        fn name(&self) -> &str {373            "Film one"374        }375376        fn ratio(&self) -> f32 {377            self.0378        }379    }380381    fn mixed() -> [Member; 3] {382        [383            Member(wall::POSTER),384            Member(wall::STILL),385            Member(wall::POSTER),386        ]387    }388389    fn strip<'a>(390        members: &'a [Member],391        current: Option<usize>,392        focus: Option<usize>,393    ) -> Strip<'a, Member> {394        Strip {395            members,396            current,397            focus,398            heading: "The Set",399            library: "screening/films",400            last: None,401            lines: 1,402            headed: false,403            region: area(0.0, 0.0, 1000.0, height(1)),404        }405    }406407    #[test]408    fn a_heading_splits_into_its_name_and_the_scope_after_the_dot() {409        assert_eq!(410            split("Wizarding World · a 4-film set"),411            ("Wizarding World", " · a 4-film set")412        );413        assert_eq!(414            split("Marvel Cinematic Universe · a franchise of 124 films and series"),415            (416                "Marvel Cinematic Universe",417                " · a franchise of 124 films and series"418            )419        );420        assert_eq!(split("Franchises · 32"), ("Franchises", " · 32"));421        assert_eq!(split("Genres"), ("Genres", ""));422        assert_eq!(split(""), ("", ""));423    }424425    #[test]426    fn a_heading_splits_on_its_first_dot_alone() {427        assert_eq!(split("One · Two · Three"), ("One", " · Two · Three"));428    }429430    #[test]431    fn the_heading_takes_a_box_as_wide_as_its_words() {432        let region = area(10.0, 20.0, 1000.0, height(1));433        let box_of = heading_box(region, "The Order");434        assert_eq!(box_of.x, 10.0);435        assert_eq!(box_of.y, 20.0);436        assert!(box_of.width < region.width);437        assert!(box_of.width > text::width("The Order", look::HEADING));438        assert_eq!(439            heading_box(area(0.0, 0.0, 40.0, 10.0), "A Long Name").width,440            40.0441        );442        assert_eq!(box_of.height, text::height(1, look::HEADING));443    }444445    #[test]446    fn a_poster_keeps_the_walls_ratio() {447        assert_eq!(POSTER / poster_width(), wall::POSTER);448        assert_eq!(width_at(wall::STILL), POSTER * 16.0 / 9.0);449    }450451    #[test]452    fn a_strip_with_captions_is_taller_by_its_lines() {453        assert_eq!(height(0), HEADING + POSTER);454        assert_eq!(455            height(1),456            HEADING + POSTER + FOOT + text::height(1, look::CAPTION)457        );458        assert!(height(2) > height(1));459    }460461    #[test]462    fn a_strips_two_lines_are_the_cards_own() {463        assert_eq!(height(2), HEADING + POSTER + FOOT + card::height(2));464        assert!(height(2) - height(1) < text::height(1, look::CAPTION));465        let members = mixed();466        let slots = placed(&members, false);467        let region = area(0.0, 0.0, 1000.0, height(2));468        let band = caption_band(slot(region, &slots, 0.0, 0));469        assert_eq!(470            band.y + band.height + card::under(band).height,471            region.y + HEADING + POSTER + FOOT + card::height(2)472        );473    }474475    #[test]476    fn posters_and_stills_sit_side_by_side_at_one_height() {477        let members = mixed();478        let slots = placed(&members, false);479        assert_eq!(slots.len(), 3);480        assert_eq!(slots[0], (0.0, poster_width()));481        assert_eq!(slots[1].0, poster_width() + GAP);482        assert_eq!(slots[1].1, width_at(wall::STILL));483        assert_eq!(slots[2].0, slots[1].0 + slots[1].1 + GAP);484        let region = area(10.0, 20.0, 1000.0, height(1));485        let poster = slot(region, &slots, 0.0, 0);486        let still = slot(region, &slots, 0.0, 1);487        assert_eq!(poster.height, POSTER);488        assert_eq!(still.height, POSTER);489        assert_eq!(poster.y, 20.0 + HEADING);490        assert_eq!(still.y, poster.y);491        assert_eq!(poster.x, 10.0);492        assert!(still.width > poster.width);493    }494495    #[test]496    fn see_all_is_a_last_slot_at_the_posters_ratio() {497        let members = mixed();498        let slots = placed(&members, true);499        assert_eq!(slots.len(), 4);500        assert_eq!(slots[3].1, poster_width());501        assert_eq!(slots[3].0, slots[2].0 + slots[2].1 + GAP);502        assert!(placed::<Member>(&[], true).len() == 1);503        assert!(placed::<Member>(&[], false).is_empty());504    }505506    #[test]507    fn a_strip_that_fits_never_scrolls() {508        let members = mixed();509        assert_eq!(offset(&placed(&members, true), Some(3), 2000.0), 0.0);510        assert_eq!(offset(&[], Some(0), 2000.0), 0.0);511    }512513    #[test]514    fn a_long_strip_keeps_the_focused_slot_in_view() {515        let members: Vec<Member> = (0..30).map(|_| Member(wall::POSTER)).collect();516        let slots = placed(&members, true);517        let viewport = 1000.0;518        assert_eq!(offset(&slots, None, viewport), 0.0);519        assert_eq!(offset(&slots, Some(0), viewport), 0.0);520        let middle = offset(&slots, Some(15), viewport);521        let (x, width) = slots[15];522        assert!(x - middle >= 0.0);523        assert!(x + width - middle <= viewport);524        let (last, width) = slots[30];525        let end = offset(&slots, Some(30), viewport);526        assert_eq!(end, last + width - viewport);527    }528529    #[test]530    fn the_caption_band_is_one_slot_and_one_gap_wide() {531        assert_eq!(caption_width(wall::POSTER), poster_width() + GAP);532        assert_eq!(caption_width(wall::STILL), width_at(wall::STILL) + GAP);533        let members = mixed();534        let slots = placed(&members, false);535        let region = area(0.0, 0.0, 1000.0, height(2));536        assert_eq!(537            caption_band(slot(region, &slots, 0.0, 0)).width,538            caption_width(wall::POSTER)539        );540        assert_eq!(541            caption_band(slot(region, &slots, 0.0, 1)).width,542            caption_width(wall::STILL)543        );544    }545546    #[test]547    fn a_show_of_more_than_one_new_episode_carries_a_pill_and_no_other_slot_does() {548        assert_eq!(pill_words(2).as_deref(), Some("2 new"));549        assert_eq!(pill_words(12).as_deref(), Some("12 new"));550        assert_eq!(pill_words(1), None);551        assert_eq!(pill_words(0), None);552    }553554    #[test]555    fn the_pill_sits_in_the_top_left_corner_of_the_still_it_marks() {556        let slot = area(100.0, 200.0, width_at(wall::STILL), POSTER);557        let pill = pill(slot, "2 new");558        assert_eq!(pill.x, slot.x + PILL_INSET);559        assert_eq!(pill.y, slot.y + PILL_INSET);560        assert_eq!(pill.width, text::measured("2 new", look::CAPTION));561        assert_eq!(pill.height, text::height(1, look::CAPTION));562        assert!(pill.x + pill.width < slot.x + slot.width);563        assert!(pill.y + pill.height < slot.y + slot.height);564    }565566    #[test]567    fn a_poster_has_room_beside_it_for_the_mark() {568        const { assert!(GAP / 2.0 > super::super::REACH) };569        const { assert!(FOOT > super::super::REACH) };570    }571572    #[test]573    fn the_current_film_and_the_focused_one_draw_over_their_siblings() {574        let members = mixed();575        let strip = strip(&members, Some(1), Some(2));576        assert_eq!(tone(&strip, 0), Tone::Dimmed);577        assert_eq!(tone(&strip, 1), Tone::Full);578        assert_eq!(tone(&strip, 2), Tone::Full);579    }580581    #[test]582    fn a_strip_about_no_member_draws_every_member_at_full() {583        let members = mixed();584        let strip = strip(&members, None, Some(2));585        assert_eq!(tone(&strip, 0), Tone::Full);586        assert_eq!(tone(&strip, 2), Tone::Full);587    }588589    #[test]590    fn a_member_that_names_a_library_resolves_its_art_there() {591        struct Elsewhere;592        impl Card for Elsewhere {593            fn name(&self) -> &str {594                "A Title"595            }596            fn library(&self) -> &str {597                "screening/serials"598            }599        }600        assert_eq!(601            library_of(&Elsewhere, "screening/films"),602            "screening/serials"603        );604        assert_eq!(605            library_of(&Member(1.5), "screening/films"),606            "screening/films"607        );608    }609}
src/views/text.rs 99.7%
1// The text primitive: one line, or a block cut to a number of lines. Both2// answer the height they took, so a page stacks its blocks.34use std::f32::consts::FRAC_PI_2;56use iced_wgpu::Renderer;7use iced_widget::canvas;8use iced_winit::core::alignment::Vertical;9use iced_winit::core::text::{Alignment, LineHeight, Shaping};10use iced_winit::core::{Color, Font, Pixels, Point, Rectangle, Vector};1112use super::{area, label};13use crate::look;1415/// The height of one line as a share of its size. Every block of text on16/// a page is measured in it.17pub const LEADING: f32 = 1.32;1819// The width of an average glyph as a share of its size. It decides20// whether a block is longer than its cap.21const ADVANCE: f32 = 0.5;2223/// How many characters of this size a width holds. It is an estimate from24/// the average advance, because the shaper runs on the draw path and a25/// caller decides its geometry before it draws.26pub fn fits(size: f32, width: f32) -> usize {27    (width / (size * ADVANCE)).max(0.0) as usize28}2930/// The width this content takes at this size, from the same average advance31/// the line count uses.32pub fn width(content: &str, size: f32) -> f32 {33    content.chars().count() as f32 * size * ADVANCE34}3536/// The width this content draws at, from the shaper's own paragraph over37/// the display's font, so a line placed after it never drifts with the38/// glyphs the estimate cannot see. The shaper runs on the draw path39/// already, and one paragraph of a short line costs less than a frame.40/// The measure shapes with the brand's faces, loaded once on the first41/// call, so a test and the screen measure the same face.42pub fn measured(content: &str, size: f32) -> f32 {43    use iced_winit::core::text::{Paragraph, Text, Wrapping};44    static FACES: std::sync::Once = std::sync::Once::new();45    FACES.call_once(liken_iced::font::load);46    let paragraph = iced_wgpu::graphics::text::Paragraph::with_text(Text {47        content,48        bounds: iced_winit::core::Size::INFINITE,49        size: Pixels(size),50        line_height: LineHeight::Absolute(Pixels(size * LEADING)),51        font: Font::with_name(look::FONT),52        align_x: Alignment::Left,53        align_y: Vertical::Top,54        shaping: Shaping::Advanced,55        wrapping: Wrapping::None,56    });57    paragraph.min_width()58}5960/// The content cut to what one line of this width holds at this size,61/// with an ellipsis where it was cut. A caption band is one line tall,62/// and a line the shaper wrapped would show the tops of its second line63/// inside the band's clip.64pub fn cut(content: &str, size: f32, width: f32) -> String {65    let room = fits(size, width);66    if content.chars().count() <= room {67        return content.to_string();68    }69    let kept: String = content.chars().take(room.saturating_sub(1)).collect();70    format!("{}\u{2026}", kept.trim_end())71}7273/// The content cut to the widest prefix the shaper sets inside this74/// width, with the same ellipsis `cut` leaves. The estimate under `cut`75/// can call a caption short enough that the shaper sets wider than its76/// band, so a caption that has to fit exactly is cut here.77pub fn measured_cut(content: &str, size: f32, width: f32) -> String {78    if measured(content, size) <= width {79        return content.to_string();80    }81    let letters: Vec<char> = content.chars().collect();82    let (mut kept, mut over) = (0, letters.len());83    while kept < over {84        let middle = (kept + over).div_ceil(2);85        match measured(&ellipsed(&letters[..middle]), size) <= width {86            true => kept = middle,87            false => over = middle - 1,88        }89    }90    ellipsed(&letters[..kept])91}9293// One ellipsis after the letters that were kept, the convention `cut`94// writes.95fn ellipsed(letters: &[char]) -> String {96    let kept: String = letters.iter().collect();97    format!("{}\u{2026}", kept.trim_end())98}99100/// The content broken into the lines this width holds at this size, on the101/// spaces between its words, from the shaper's own measure. A word wider than102/// the line takes a line of its own and runs past the width. A caller that103/// stacks the lines itself wraps here, because the shaper's own wrap draws104/// the lines but answers no count of them, and the caller needs the count to105/// place what follows.106pub fn wrapped(content: &str, size: f32, width: f32) -> Vec<String> {107    let mut lines: Vec<String> = Vec::new();108    for word in content.split_whitespace() {109        match lines.last_mut() {110            Some(line) if measured(&format!("{line} {word}"), size) <= width => {111                line.push(' ');112                line.push_str(word);113            }114            _ => lines.push(word.to_string()),115        }116    }117    lines118}119120/// How many lines this content takes at this size and width. The count121/// is an estimate from the number of characters, because the shaper runs122/// on the draw path and the page decides its geometry before it draws.123pub fn lines(content: &str, size: f32, width: f32) -> usize {124    if content.is_empty() {125        return 0;126    }127    content.chars().count().div_ceil(fits(size, width).max(1))128}129130/// The height a number of lines takes at this size.131pub fn height(lines: usize, size: f32) -> f32 {132    lines as f32 * size * LEADING133}134135/// One line of text with its left edge at `at`, wrapped where the136/// content is longer than the width. The answer is the height it took,137/// and zero for a line the item does not carry, so the caller stacks the138/// next line under it.139pub fn line(140    frame: &mut canvas::Frame<Renderer>,141    content: &str,142    at: Point,143    size: f32,144    color: Color,145    width: f32,146) -> f32 {147    line_in(148        frame,149        content,150        at,151        (size, Font::with_name(look::FONT)),152        color,153        width,154    )155}156157/// One line at a size, in a named face, so a tagline draws in the158/// family's italic while every other line keeps the roman one.159pub fn line_in(160    frame: &mut canvas::Frame<Renderer>,161    content: &str,162    at: Point,163    (size, font): (f32, Font),164    color: Color,165    width: f32,166) -> f32 {167    if content.is_empty() {168        return 0.0;169    }170    let mut text = label(171        content,172        at,173        size,174        color,175        Alignment::Left,176        Vertical::Top,177        width,178    );179    text.font = font;180    frame.fill_text(text);181    height(lines(content, size, width), size)182}183184/// One line centered in its band and clipped to it, so a long line never185/// runs off the screen or over what is beside it. The line is cut to the186/// band's width with an ellipsis, because a band is one line tall and a187/// wrapped line would show the tops of a second. A band with nothing in188/// it draws nothing.189pub fn centered(190    frame: &mut canvas::Frame<Renderer>,191    content: &str,192    band: Rectangle,193    size: f32,194    color: Color,195) {196    shown(frame, &cut(content, size, band.width), band, size, color);197}198199/// One line centered in its band and clipped to it, drawn as it stands.200/// The caller cut it to the band at the read, and a second cut by the201/// estimate would take letters the shaper set inside the band.202pub fn shown(203    frame: &mut canvas::Frame<Renderer>,204    content: &str,205    band: Rectangle,206    size: f32,207    color: Color,208) {209    faced(210        frame,211        content,212        band,213        size,214        color,215        Font::with_name(look::FONT),216    );217}218219/// One line centered in its band and clipped to it, in a named face, so220/// a caption's second line draws in the family's italic while every221/// other line keeps the roman one.222pub fn faced(223    frame: &mut canvas::Frame<Renderer>,224    content: &str,225    band: Rectangle,226    size: f32,227    color: Color,228    font: Font,229) {230    if content.is_empty() {231        return;232    }233    frame.with_clip(band, |frame| {234        frame.fill_text(canvas::Text {235            font,236            ..label(237                content,238                Point::new(band.center_x(), band.y),239                size,240                color,241                Alignment::Center,242                Vertical::Top,243                f32::INFINITY,244            )245        });246    });247}248249/// One line turned a quarter circle inside its box, so it reads from the250/// foot of the box to its head. A jump rail's bars and a metro strip's251/// lines are both tall and narrow, and words along them are read this252/// way. The turn puts the words on the path renderer, which draws them253/// as a mesh and not as a line of text, so they go into the same buffer254/// as the shapes under them and draw over what the caller drew first. A255/// clip of their own would take them out of that buffer and the shapes256/// would then cover them, so the caller cuts the words to the box257/// instead.258pub fn upward(259    frame: &mut canvas::Frame<Renderer>,260    content: &str,261    at: Rectangle,262    size: f32,263    color: Color,264) {265    turned(frame, content, at, size, color, -FRAC_PI_2);266}267268/// The same quarter turn the other way, so the line reads from the head269/// of the box to its foot, the way a title reads down the spine of a270/// book. `upward` above says why the caller cuts the words to the box.271pub fn downward(272    frame: &mut canvas::Frame<Renderer>,273    content: &str,274    at: Rectangle,275    size: f32,276    color: Color,277) {278    turned(frame, content, at, size, color, FRAC_PI_2);279}280281// One line turned this far about the middle of its box.282fn turned(283    frame: &mut canvas::Frame<Renderer>,284    content: &str,285    at: Rectangle,286    size: f32,287    color: Color,288    angle: f32,289) {290    frame.with_save(|frame| {291        frame.translate(Vector::new(at.center_x(), at.center_y()));292        frame.rotate(angle);293        frame.fill_text(label(294            content,295            Point::ORIGIN,296            size,297            color,298            Alignment::Center,299            Vertical::Center,300            // The words are cut to the box already, and a width the301            // shaper may exceed would wrap them into a second line that302            // draws across the first once the box turns them.303            f32::INFINITY,304        ));305    });306}307308/// A block of text cut to `cap` lines. The answer is the height the block309/// took, so the caller stacks the next block under it.310pub fn block(311    frame: &mut canvas::Frame<Renderer>,312    content: &str,313    at: Point,314    size: f32,315    color: Color,316    width: f32,317    cap: usize,318) -> f32 {319    block_in(320        frame,321        content,322        at,323        (size, Font::with_name(look::FONT)),324        color,325        width,326        cap,327    )328}329330/// A block at a size, in a named face, so a tagline draws in the331/// family's italic while every other block keeps the roman one.332pub fn block_in(333    frame: &mut canvas::Frame<Renderer>,334    content: &str,335    at: Point,336    (size, font): (f32, Font),337    color: Color,338    width: f32,339    cap: usize,340) -> f32 {341    let taken = lines(content, size, width);342    if taken == 0 {343        return 0.0;344    }345    let height = height(taken.min(cap), size);346    let block = area(at.x, at.y, width, height);347348    // The clip cuts the block at its last line, so a plot of any length349    // draws in the space the page gave it and never over the row below.350    frame.with_clip(block, |frame| {351        let mut text = label(352            content,353            at,354            size,355            color,356            Alignment::Left,357            Vertical::Top,358            width,359        );360        text.font = font;361        frame.fill_text(text);362    });363364    height365}366367#[cfg(test)]368mod tests {369    use super::*;370371    #[test]372    fn a_caption_longer_than_its_band_ends_in_an_ellipsis() {373        let room = fits(16.0, 120.0);374        let long: String = "a".repeat(room + 5);375        let shown = cut(&long, 16.0, 120.0);376        assert_eq!(shown.chars().count(), room);377        assert!(shown.ends_with('\u{2026}'));378        assert_eq!(cut("short", 16.0, 120.0), "short");379    }380381    #[test]382    fn a_caption_the_shaper_sets_wider_than_its_band_is_cut_to_fit_it() {383        let long = "W".repeat(40);384        let shown = measured_cut(&long, 18.0, 200.0);385        assert!(shown.ends_with('\u{2026}'));386        assert!(shown.chars().count() < long.chars().count());387        assert!(measured(&shown, 18.0) <= 200.0);388    }389390    #[test]391    fn a_caption_the_shaper_sets_inside_its_band_is_cut_nowhere() {392        assert_eq!(393            measured_cut("Specimen 0001", 18.0, 2_000.0),394            "Specimen 0001"395        );396        assert_eq!(measured_cut("", 18.0, 200.0), "");397    }398399    #[test]400    fn a_band_too_narrow_for_one_letter_holds_the_ellipsis_alone() {401        assert_eq!(measured_cut("Specimen 0001", 18.0, 0.0), "\u{2026}");402    }403404    #[test]405    fn a_wrap_breaks_the_words_into_the_lines_the_width_holds() {406        let lines = wrapped("Years from the Battle of Yavin", 18.0, 80.0);407        assert!(lines.len() > 1, "{lines:?}");408        assert_eq!(lines.concat().replace(' ', ""), "YearsfromtheBattleofYavin");409        for line in &lines {410            assert!(411                measured(line, 18.0) <= 80.0 || !line.contains(' '),412                "{line}"413            );414        }415        assert_eq!(416            wrapped("Years from the Battle of Yavin", 18.0, 400.0).len(),417            1418        );419    }420421    #[test]422    fn a_word_wider_than_the_line_takes_a_line_of_its_own() {423        let lines = wrapped("Days from the Anthropocene", 18.0, 10.0);424        assert_eq!(lines, ["Days", "from", "the", "Anthropocene"]);425        assert!(wrapped("", 18.0, 100.0).is_empty());426    }427428    #[test]429    fn a_short_block_is_one_line() {430        assert_eq!(lines("A short plot.", 28.0, 900.0), 1);431    }432433    #[test]434    fn a_line_the_item_does_not_carry_takes_no_height() {435        assert_eq!(lines("", 28.0, 900.0), 0);436        assert_eq!(height(0, 28.0), 0.0);437    }438439    #[test]440    fn a_long_block_runs_past_four_lines() {441        let plot = "word ".repeat(80);442        assert!(lines(&plot, 28.0, 900.0) > 4);443    }444445    #[test]446    fn a_narrow_block_holds_fewer_characters() {447        let plot = "word ".repeat(20);448        assert!(lines(&plot, 28.0, 120.0) > lines(&plot, 28.0, 1800.0));449    }450451    #[test]452    fn a_band_holds_the_characters_its_width_allows() {453        assert_eq!(fits(26.0, 320.0), 24);454        assert_eq!(fits(26.0, 640.0), 49);455        assert_eq!(fits(26.0, 0.0), 0);456    }457458    #[test]459    fn a_width_and_a_count_of_characters_agree() {460        assert_eq!(width("abcd", 26.0), 4.0 * 26.0 * ADVANCE);461        assert_eq!(width("", 26.0), 0.0);462        assert_eq!(fits(26.0, width("abcd", 26.0)), 4);463    }464465    #[test]466    fn a_line_is_as_tall_as_its_size_and_its_leading() {467        assert_eq!(height(2, 30.0), 2.0 * 30.0 * LEADING);468    }469}
src/views/volume.rs 99.2%
1// The volume row: a speaker glyph, a short bar, and the number, in the top2// right corner of the frame. It is the row the idle screen draws, at this3// client's own sizes.4//5// The row is a layer of its own over every screen, because inside one6// layer the renderer draws every mesh, then every image, then every text,7// so a surface drawn over a page's art would be painted under it.89use iced_wgpu::Renderer;10use iced_widget::canvas;11use iced_winit::core::alignment::Vertical;12use iced_winit::core::text::Alignment;13use iced_winit::core::{Color, Point, Rectangle, Theme, mouse};14use std::convert::Infallible;1516use media_screen::volume::{UNITY_LEVEL, Volume};1718use super::{area, label, rounded, text};19use crate::look;2021// The margins the row hangs off, the same measures a page sets its own22// blocks by.23const MARGIN_X: f32 = 120.0;24const MARGIN_Y: f32 = 56.0;2526// The width the number reserves at the right margin, so the bar and the27// glyph hold their place as the number moves between one and three digits.28const NUMBER_WIDTH: f32 = 84.0;2930// The bar is short because the number beside it carries the reading. The31// bar shows the level at a glance.32const BAR_WIDTH: f32 = 220.0;33const BAR_HEIGHT: f32 = 12.0;34const BAR_RADIUS: f32 = 3.0;3536// The glyph's box, and the gap between the glyph and the bar.37const GLYPH_WIDTH: f32 = 26.0;38const GLYPH_HEIGHT: f32 = 30.0;39const GLYPH_GAP: f32 = 16.0;4041// The number's line box. The bar and the glyph centre on the middle of it42// and the dark surface covers it, so the three parts read as one row.43const NUMBER_BOX: f32 = look::ROW_NAME * text::LEADING;4445// The row draws over whatever the browser has on the screen, and on a46// bright backdrop the glyph and the number would vanish, so the row carries47// a dark surface of its own. These are the padding around the three parts48// and the radius of its corners.49const PAD_X: f32 = 24.0;50const PAD_Y: f32 = 12.0;51const SURFACE_RADIUS: f32 = 14.0;5253// The opacity of that surface, and of the track the bar's fill runs over.54const SURFACE: f32 = 0.8;55const TRACK: f32 = 0.69;5657// The speaker: one closed polygon of the driver box and the cone, in the58// glyph's own box. The image carries no icon font, so the mark is a path.59const SPEAKER: [(f32, f32); 6] = [60    (0.0, 10.0),61    (10.0, 10.0),62    (22.0, 0.0),63    (22.0, 30.0),64    (10.0, 20.0),65    (0.0, 20.0),66];6768// The muted state draws this slash across the speaker, so one element69// carries both the level and the mute.70const SLASH: [(f32, f32); 4] = [(2.0, 24.0), (24.0, 2.0), (24.0, 8.0), (2.0, 30.0)];7172// The slash draws in the speaker's own colour, and the two would read as73// one shape without an outline between them. This is the width of that74// outline outside the slash.75const SLASH_BORDER: f32 = 2.0;7677/// The volume row as one frame draws it.78#[derive(Debug, Clone, Copy, PartialEq)]79pub struct Row {80    /// The level and the muted flag the row reads.81    pub volume: Volume,82    /// The row's own fade, from 0 off screen to 1 full.83    pub fade: f32,84}8586impl canvas::Program<Infallible, Theme, Renderer> for Row {87    type State = ();8889    fn draw(90        &self,91        _state: &Self::State,92        renderer: &Renderer,93        _theme: &Theme,94        bounds: Rectangle,95        _cursor: mouse::Cursor,96    ) -> Vec<canvas::Geometry<Renderer>> {97        let mut frame = canvas::Frame::new(renderer, bounds.size());98        let fade = self.fade.clamp(0.0, 1.0);99        if fade <= 0.0 {100            return vec![frame.into_geometry()];101        }102103        let row = places(bounds);104        let ink = match self.volume.muted {105            true => look::muted(),106            false => look::text(),107        };108        let faded = |color: Color, alpha: f32| Color { a: alpha, ..color };109110        frame.fill(111            &rounded(row.surface, SURFACE_RADIUS),112            faded(look::BACKGROUND, SURFACE * fade),113        );114        frame.fill(115            &rounded(row.bar, BAR_RADIUS),116            faded(look::track(), TRACK * fade),117        );118119        let filled = filled(self.volume.level);120        if filled >= 1.0 {121            frame.fill(122                &rounded(123                    Rectangle {124                        width: filled,125                        ..row.bar126                    },127                    BAR_RADIUS,128                ),129                faded(look::accent(), fade),130            );131        }132133        frame.fill(&polygon(&SPEAKER, row.glyph), faded(ink, fade));134        if self.volume.muted {135            let slash = polygon(&SLASH, row.glyph);136            // The toolkit centres a stroke on the path it follows, and137            // the outline this glyph needs stands outside the slash, so138            // the stroke is twice the border and the fill covers the half139            // that fell inside.140            frame.stroke(141                &slash,142                canvas::Stroke {143                    width: 2.0 * SLASH_BORDER,144                    style: canvas::Style::Solid(faded(look::BACKGROUND, fade)),145                    line_join: canvas::LineJoin::Round,146                    ..canvas::Stroke::default()147                },148            );149            frame.fill(&slash, faded(ink, fade));150        }151152        frame.fill_text(label(153            &self.volume.level.to_string(),154            row.number,155            look::ROW_NAME,156            faded(look::text(), fade),157            Alignment::Right,158            Vertical::Top,159            NUMBER_WIDTH,160        ));161162        vec![frame.into_geometry()]163    }164}165166// How much of the bar the level fills, in logical pixels. A level above167// unity fills no further.168fn filled(level: i64) -> f32 {169    BAR_WIDTH * (level as f32 / UNITY_LEVEL as f32).clamp(0.0, 1.0)170}171172// Where the parts of the row stand. The three parts hang off the right173// margin, so the row measures itself against the frame it draws in and174// holds the margin at any window size.175struct Places {176    surface: Rectangle,177    bar: Rectangle,178    glyph: Point,179    number: Point,180}181182fn places(bounds: Rectangle) -> Places {183    let top = bounds.y + MARGIN_Y;184    let right = bounds.x + bounds.width - MARGIN_X;185    // The bar and the glyph centre on the middle of the number's line, so186    // the three parts read as one row.187    let middle = top + NUMBER_BOX / 2.0;188189    let bar_x = right - NUMBER_WIDTH - BAR_WIDTH;190    let glyph_x = bar_x - GLYPH_GAP - GLYPH_WIDTH;191    let surface_x = glyph_x - PAD_X;192193    Places {194        surface: area(195            surface_x,196            top - PAD_Y,197            right + PAD_X - surface_x,198            // The number's line is the tallest of the three parts, so the199            // surface covers it and the padding.200            NUMBER_BOX + 2.0 * PAD_Y,201        ),202        bar: area(bar_x, middle - BAR_HEIGHT / 2.0, BAR_WIDTH, BAR_HEIGHT),203        glyph: Point::new(glyph_x, middle - GLYPH_HEIGHT / 2.0),204        number: Point::new(right, top),205    }206}207208// One closed polygon in the glyph's own box, placed at a point.209fn polygon(points: &[(f32, f32)], at: Point) -> canvas::Path {210    canvas::Path::new(|path| {211        for (index, (x, y)) in points.iter().enumerate() {212            let point = Point::new(at.x + x, at.y + y);213            match index {214                0 => path.move_to(point),215                _ => path.line_to(point),216            }217        }218        path.close();219    })220}221222#[cfg(test)]223mod tests {224    use super::*;225226    fn bounds() -> Rectangle {227        area(0.0, 0.0, 1920.0, 1080.0)228    }229230    #[test]231    fn the_bar_fills_at_unity_and_no_further() {232        assert_eq!(filled(0), 0.0);233        assert_eq!(filled(50), BAR_WIDTH / 2.0);234        assert_eq!(filled(100), BAR_WIDTH);235        assert_eq!(filled(140), BAR_WIDTH);236    }237238    #[test]239    fn the_row_stands_at_the_top_right_margin() {240        let row = places(bounds());241        assert_eq!(row.number.x, 1920.0 - MARGIN_X);242        assert_eq!(row.number.y, MARGIN_Y);243        assert_eq!(row.bar.x, row.number.x - NUMBER_WIDTH - BAR_WIDTH);244        assert_eq!(row.glyph.x, row.bar.x - GLYPH_GAP - GLYPH_WIDTH);245    }246247    #[test]248    fn the_surface_covers_the_three_parts_and_the_padding() {249        let row = places(bounds());250        assert_eq!(row.surface.x, row.glyph.x - PAD_X);251        assert_eq!(row.surface.x + row.surface.width, row.number.x + PAD_X);252        assert_eq!(row.surface.height, NUMBER_BOX + 2.0 * PAD_Y);253        assert_eq!(row.surface.y, row.number.y - PAD_Y);254    }255256    #[test]257    fn the_bar_and_the_glyph_centre_on_the_numbers_line() {258        let row = places(bounds());259        let middle = |shape: Rectangle| shape.y + shape.height / 2.0;260        assert_eq!(middle(row.bar), row.number.y + NUMBER_BOX / 2.0);261        assert_eq!(row.glyph.y + GLYPH_HEIGHT / 2.0, middle(row.bar));262    }263264    #[test]265    fn the_row_follows_the_frame_it_draws_in() {266        let row = places(area(0.0, 0.0, 1280.0, 720.0));267        assert_eq!(row.number.x, 1280.0 - MARGIN_X);268        assert_eq!(row.surface.x + row.surface.width, row.number.x + PAD_X);269    }270}
src/views/wall.rs 93.9%
1// The wall: a grid of art slots, each with the card's lines under it, and2// a stroke of the accent outside the one that holds focus. Only the slots3// inside the viewport become geometry, so a wall of five thousand titles4// builds a couple of dozen slots a frame.5//6// The slot ratio, the column count, and the scroll offset are parameters,7// because a wall of posters is 2:3 at six across and a wall of episode8// stills is 16:9 at four across, and the two walls are one primitive. The9// offset lets a page draw one grid for each season of a series inside one10// scrolled region.11//12// Every slot draws at one size, focused or not, so the store decodes each13// slot once and a press redraws from the cache.1415use iced_wgpu::Renderer;16use iced_widget::canvas;17use iced_winit::core::{Color, Rectangle};1819use super::{Card, Tone, area, artwork, card, mark, scroll, text};20use crate::look;21use crate::posters::Posters;2223/// The wall's column count, fixed so focus movement is a function of24/// the index alone and never of the window size.25pub const COLUMNS: usize = 6;2627/// The height of a poster slot as a share of its width: the 2:3 portrait28/// that a movie's and a series' primary art is.29pub const POSTER: f32 = 1.5;3031/// The height of a still slot as a share of its width: the 16:9 that an32/// episode's own art is.33pub const STILL: f32 = 9.0 / 16.0;3435// The poster's share of its cell; the rest is the gutter, which holds the36// mark of a focused slot.37const POSTER_SHARE: f32 = 0.84;3839// The space between a slot and the line under it, and the space under40// that line before the next row. Both are wider than the mark reaches, so41// no mark ever touches a caption or the row above.42const GAP: f32 = 12.0;43const FOOT: f32 = 14.0;4445/// The wall's cell measures, derived from the viewport width, the slot46/// ratio, and the column count.47#[derive(Debug, Clone, Copy, PartialEq)]48pub struct Cells {49    /// One cell's width, a column of the viewport.50    pub width: f32,51    /// One cell's height: the slot, the gap, the caption, and the foot.52    pub height: f32,53    /// The poster slot's width inside the cell.54    pub poster_width: f32,55    /// The poster slot's height: the width at the ratio the caller asked56    /// for.57    pub poster_height: f32,58}5960/// The cell measures for a viewport of this width, at this slot ratio,61/// with this many slots across and one caption line under each.62pub fn cells(width: f32, ratio: f32, columns: usize) -> Cells {63    lined(width, ratio, columns, 1)64}6566/// The cell measures for a viewport of this width, at this slot ratio,67/// with this many slots across and this many lines under each. Every68/// wall that draws cards asks for two.69pub fn lined(width: f32, ratio: f32, columns: usize, lines: usize) -> Cells {70    let width = width / columns as f32;71    let poster_width = width * POSTER_SHARE;72    let poster_height = poster_width * ratio;73    Cells {74        width,75        height: poster_height + GAP + card::height(lines) + FOOT,76        poster_width,77        poster_height,78    }79}8081// The width the wall lays a cell out at when a read cuts a card to it.82// The frame the wall draws in is not known at the read, so the cut runs83// at the size the browser is drawn for.84const SCREEN: f32 = 1920.0;8586/// The band a card's lines are cut to at the read: one cell of a wall of87/// this many columns.88pub fn band(columns: usize) -> f32 {89    cells(SCREEN, POSTER, columns).width90}9192/// The poster slot of one index, in viewport space after the scroll.93pub fn slot(cells: &Cells, index: usize, offset: f32, columns: usize) -> Rectangle {94    let column = (index % columns) as f32;95    let row = (index / columns) as f32;96    Rectangle {97        x: column * cells.width + (cells.width - cells.poster_width) / 2.0,98        y: row * cells.height - offset,99        width: cells.poster_width,100        height: cells.poster_height,101    }102}103104/// The band one slot's caption draws in: one line, under the slot and105/// inside its own cell, so no caption ever runs under a neighbour's.106pub fn caption(cells: &Cells, slot: Rectangle) -> Rectangle {107    area(108        slot.center_x() - cells.width / 2.0,109        slot.y + slot.height + GAP,110        cells.width,111        text::height(1, look::CAPTION),112    )113}114115/// The band one slot's second line draws in, under its caption.116pub fn under(cells: &Cells, slot: Rectangle) -> Rectangle {117    card::under(caption(cells, slot))118}119120/// The words and the color one slot's caption draws in: the slot's own121/// line, muted, and the facts of the slot that holds focus, bright. The122/// focused slot draws the whole facts that fit the band's character123/// estimate, so the caption never cuts inside a fact.124pub fn captioned<T: Card>(item: &T, focused: bool, chars: usize) -> (&str, Color) {125    match focused {126        true => (item.line_fitting(chars), look::text()),127        false => (item.caption(), look::muted()),128    }129}130131/// How many characters one caption band holds.132pub fn caption_fits(cells: &Cells) -> usize {133    text::fits(look::CAPTION, cells.width)134}135136/// The offset that keeps the focused row of a whole wall centered in a137/// viewport this tall.138pub fn scrolled(focus: usize, count: usize, columns: usize, cells: &Cells, height: f32) -> f32 {139    scroll::offset(140        focus / columns,141        scroll::rows(count, columns),142        cells.height,143        height,144    )145}146147/// One wall to draw: the items, the focus, the region it draws in, the148/// shape of a slot, and how far its rows have scrolled.149pub struct Grid<'a, T> {150    /// The items in draw order.151    pub items: &'a [T],152    /// The focused item's index, or nothing where focus is elsewhere on153    /// the screen.154    pub focus: Option<usize>,155    /// Whether the focused slot carries the mark. It does not while the156    /// band above holds focus.157    pub marked: bool,158    /// The library the art paths resolve against.159    pub library: &'a str,160    /// The height of a slot as a share of its width.161    pub ratio: f32,162    /// How many slots a row holds.163    pub columns: usize,164    /// How many caption lines stand under each slot, one or two.165    pub lines: usize,166    /// The part of the frame the grid draws in, under the band.167    pub region: Rectangle,168    /// How far the grid's first row has scrolled above the region's top.169    /// It is negative for a grid whose rows start below the top.170    pub offset: f32,171}172173/// The space over the first row. It keeps the mark of a focused slot in174/// the first row off the band.175pub const HEAD: f32 = 20.0;176177/// Draw one wall. The store is asked for the slots this frame draws and178/// for one row past them, so a scroll's next posters decode before they179/// appear.180pub fn draw<T: Card, P: Posters>(181    frame: &mut canvas::Frame<Renderer>,182    posters: &mut P,183    grid: &Grid<'_, T>,184) {185    let cells = lined(grid.region.width, grid.ratio, grid.columns, grid.lines);186    let chars = caption_fits(&cells);187    let range = scroll::visible(188        grid.offset,189        grid.region.height,190        cells.height,191        grid.items.len(),192        grid.columns,193    );194195    for index in range.clone() {196        let item = &grid.items[index];197        let slot = lowered(198            slot(&cells, index, grid.offset, grid.columns),199            grid.region.y,200        );201        artwork(202            frame,203            posters,204            library_of(item, grid.library),205            item.art(),206            slot,207            item.name(),208            Tone::Full,209        );210        let focused = Some(index) == grid.focus;211        if focused && grid.marked {212            mark(frame, slot);213        }214        // A card's lines clip to their own bands, and a band's clip does215        // not inherit the wall's, so a line whose band has left the region216        // draws nothing at all, instead of over what stands above the217        // wall.218        let band = caption(&cells, slot);219        if band.y < grid.region.y {220            continue;221        }222        match grid.lines {223            1 => {224                let (content, color) = captioned(item, focused, chars);225                written(frame, band, content, color);226            }227            _ => card::draw(frame, item, band),228        }229    }230231    // One row past the viewport is asked for and not drawn, so a232    // scroll's next posters decode before they appear.233    for index in range.end..(range.end + grid.columns).min(grid.items.len()) {234        let item = &grid.items[index];235        let ahead = slot(&cells, index, grid.offset, grid.columns);236        if !item.art().is_empty() {237            let _ = posters.poster(238                library_of(item, grid.library),239                item.art(),240                ahead.width as u32,241                ahead.height as u32,242            );243        }244    }245}246247// One line centered in its band and clipped to it, so a long title never248// runs off the screen or over the row below.249// The strip draws its captions through this too, so the two read as250// one.251pub(crate) fn written(252    frame: &mut canvas::Frame<Renderer>,253    band: Rectangle,254    content: &str,255    color: Color,256) {257    text::centered(frame, content, band, look::CAPTION, color);258}259260// One slot in frame space: its place in the grid, moved down by the top261// of the grid's region.262fn lowered(slot: Rectangle, top: f32) -> Rectangle {263    Rectangle {264        y: slot.y + top,265        ..slot266    }267}268269// The library one slot's art resolves against: the slot's own where it270// names one, and the grid's otherwise.271fn library_of<'a, T: Card>(item: &'a T, grid: &'a str) -> &'a str {272    match item.library().is_empty() {273        true => grid,274        false => item.library(),275    }276}277278#[cfg(test)]279mod tests {280    use super::*;281    use crate::views::{REACH, marked, underlined};282283    // The two walls this screen draws: posters six across, and episode284    // stills four across.285    const WALLS: [(f32, usize); 2] = [(POSTER, COLUMNS), (STILL, 4)];286287    // One item of a wall, with a caption of its own and a longer line288    // under focus.289    const NAME: &str = "Specimen 0001";290    const LINE: &str = "Specimen 0001 · 1987 · 1h 37m · PG-13";291292    struct Slot;293294    impl Card for Slot {295        fn name(&self) -> &str {296            NAME297        }298299        fn line_fitting(&self, chars: usize) -> &str {300            match LINE.chars().count() <= chars {301                true => LINE,302                false => NAME,303            }304        }305    }306307    struct Elsewhere(&'static str);308309    impl Card for Elsewhere {310        fn name(&self) -> &str {311            "A Title"312        }313314        fn library(&self) -> &str {315            self.0316        }317    }318319    // A person's wall holds titles from more than one library, and a slot320    // that names its own library resolves its poster there.321    #[test]322    fn a_slot_that_names_a_library_resolves_its_art_there() {323        assert_eq!(324            library_of(&Elsewhere("default/series"), "default/movies"),325            "default/series"326        );327        assert_eq!(328            library_of(&Elsewhere(""), "default/movies"),329            "default/movies"330        );331    }332333    #[test]334    fn cells_keep_the_two_three_poster_ratio() {335        let cells = cells(1920.0, POSTER, COLUMNS);336        assert_eq!(cells.width, 320.0);337        assert_eq!(cells.poster_height, cells.poster_width * 1.5);338        assert!(cells.height > cells.poster_height);339    }340341    #[test]342    fn a_second_line_makes_the_cell_taller_by_the_smaller_size_it_draws_at() {343        let one = cells(1920.0, POSTER, COLUMNS);344        let two = lined(1920.0, POSTER, COLUMNS, 2);345        assert!((two.height - one.height - text::height(1, look::FACE)).abs() < 1e-3);346        assert!(two.height - one.height < text::height(1, look::CAPTION));347        assert_eq!(two.poster_height, one.poster_height);348    }349350    #[test]351    fn a_cards_band_is_one_cell_of_the_wall_it_is_cut_for() {352        assert_eq!(band(COLUMNS), cells(1920.0, POSTER, COLUMNS).width);353        assert!(band(4) > band(COLUMNS));354    }355356    #[test]357    fn the_second_line_stands_right_under_the_caption() {358        let cells = lined(1920.0, POSTER, COLUMNS, 2);359        let slot = slot(&cells, 0, 0.0, COLUMNS);360        let caption = caption(&cells, slot);361        let under = under(&cells, slot);362        assert_eq!(under.y, caption.y + caption.height);363        assert_eq!(under.x, caption.x);364        assert_eq!(under.width, caption.width);365    }366367    #[test]368    fn a_wider_ratio_gives_a_shorter_slot() {369        let stills = cells(1920.0, STILL, COLUMNS);370        assert_eq!(stills.width, 320.0);371        assert_eq!(stills.poster_height, stills.poster_width * 9.0 / 16.0);372        assert!(stills.height < cells(1920.0, POSTER, COLUMNS).height);373    }374375    #[test]376    fn fewer_columns_give_a_wider_cell() {377        let four = cells(1920.0, STILL, 4);378        assert_eq!(four.width, 480.0);379        assert!(four.poster_width > cells(1920.0, STILL, COLUMNS).poster_width);380    }381382    #[test]383    fn slots_land_in_their_column_and_row() {384        let cells = cells(1920.0, POSTER, COLUMNS);385        let first = slot(&cells, 0, 0.0, COLUMNS);386        assert_eq!(first.y, 0.0);387        let below = slot(&cells, COLUMNS, 0.0, COLUMNS);388        assert_eq!(below.x, first.x);389        assert_eq!(below.y, cells.height);390        let beside = slot(&cells, 1, 0.0, COLUMNS);391        assert_eq!(beside.x, first.x + cells.width);392    }393394    #[test]395    fn the_scroll_lifts_every_slot() {396        let cells = cells(1920.0, POSTER, COLUMNS);397        assert_eq!(slot(&cells, 0, 200.0, COLUMNS).y, -200.0);398    }399400    #[test]401    fn a_grid_whose_rows_start_below_the_region_takes_a_negative_offset() {402        let cells = cells(1920.0, STILL, 4);403        assert_eq!(slot(&cells, 0, -300.0, 4).y, 300.0);404    }405406    #[test]407    fn the_region_lowers_every_slot_by_its_top() {408        let cells = cells(1920.0, POSTER, COLUMNS);409        assert_eq!(lowered(slot(&cells, 0, 0.0, COLUMNS), 78.0).y, 78.0);410    }411412    #[test]413    fn every_slot_carries_one_caption_under_it() {414        for (ratio, columns) in WALLS {415            let cells = cells(1920.0, ratio, columns);416            let slot = slot(&cells, 0, 0.0, columns);417            let band = caption(&cells, slot);418            assert_eq!(band.height, text::height(1, look::CAPTION));419            assert_eq!(band.width, cells.width);420            assert!(band.y > slot.y + slot.height);421        }422    }423424    #[test]425    fn a_caption_stays_clear_of_the_mark_and_of_the_row_below() {426        for (ratio, columns) in WALLS {427            let cells = cells(1920.0, ratio, columns);428            let slot = slot(&cells, 0, 0.0, columns);429            let band = caption(&cells, slot);430            let below = super::slot(&cells, columns, 0.0, columns);431            assert!(band.y > marked(slot).y + marked(slot).height);432            assert!(band.y + band.height < marked(below).y);433        }434    }435436    #[test]437    fn a_caption_stays_inside_its_own_cell() {438        let cells = cells(1920.0, POSTER, COLUMNS);439        let first = caption(&cells, slot(&cells, 0, 0.0, COLUMNS));440        assert_eq!(first.x, 0.0);441        let last = caption(&cells, slot(&cells, COLUMNS - 1, 0.0, COLUMNS));442        assert_eq!(last.x + last.width, 1920.0);443    }444445    #[test]446    fn the_focused_caption_is_bright_and_carries_the_facts() {447        let (content, color) = captioned(&Slot, true, LINE.chars().count());448        assert_eq!(content, LINE);449        assert_eq!(color, look::text());450    }451452    #[test]453    fn a_focused_caption_wider_than_its_band_gives_facts_up() {454        let (content, _) = captioned(&Slot, true, LINE.chars().count() - 1);455        assert_eq!(content, NAME);456    }457458    #[test]459    fn every_other_caption_is_muted_and_carries_the_name() {460        let (content, color) = captioned(&Slot, false, 0);461        assert_eq!(content, NAME);462        assert_eq!(color, look::muted());463    }464465    #[test]466    fn a_wider_cell_holds_more_of_the_focused_line() {467        let posters = caption_fits(&cells(1920.0, POSTER, COLUMNS));468        assert_eq!(posters, text::fits(look::CAPTION, 320.0));469        assert!(caption_fits(&cells(1920.0, STILL, 4)) > posters);470    }471472    #[test]473    fn the_head_holds_the_mark_of_the_first_row_off_the_band() {474        const { assert!(HEAD > REACH) };475        const { assert!(GAP > REACH) };476        const { assert!(FOOT > REACH) };477    }478479    #[test]480    fn a_whole_wall_scrolls_its_focused_row_to_the_middle() {481        let cells = cells(1920.0, POSTER, COLUMNS);482        assert_eq!(scrolled(0, 60, COLUMNS, &cells, 1080.0), 0.0);483        assert!(scrolled(59, 60, COLUMNS, &cells, 1080.0) > 0.0);484    }485486    #[test]487    fn the_underline_is_the_bottom_edge_of_the_mark_and_no_more() {488        let slot = area(100.0, 100.0, 200.0, 300.0);489        let around = marked(slot);490        let bar = underlined(slot);491        assert_eq!(bar.height, look::MARK);492        assert_eq!(bar.y + bar.height / 2.0, around.y + around.height);493        assert!(bar.x < around.x && bar.x + bar.width > around.x + around.width);494        assert!(around.x < slot.x && around.y + around.height > slot.y + slot.height);495    }496}