| Input | Unit | Covered | Total | Percent |
|---|---|---|---|---|
| Go | statements | 6938 | 7215 | 96.2% |
| Rust (media-browser) | lines | 15789 | 16089 | 98.1% |
6938 of 7215 statements, 96.2%.
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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)
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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 + `'`
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}
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}
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("&", "&", "<", "<", ">", ">")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}
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)
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}()
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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"
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
15789 of 16089 lines, 98.1%.
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;
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}
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}
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}
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}
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}
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}
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}
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}
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}
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;
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;
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;
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}
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}
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}
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;
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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;
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}
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}
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}
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}
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}
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}
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}
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;
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}
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}
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}
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}
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}
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}
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}
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}
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}
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;
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;
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}
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}
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}
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;
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}
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;
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}
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}
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}
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}
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}
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}
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}
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}
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;
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;
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;
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 ¬e,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(¬e, 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(¬e, 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}
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}
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;
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}
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}
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}
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}
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}
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}
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}
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;
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;
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;
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}
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;
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;
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}
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}
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}
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}
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}
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}
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;
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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}
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;
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}
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}
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}
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}
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}
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}
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}