Every video plays YouTube's way: hls.js reads the connection and picks the quality, or one is picked by hand, or Original plays the file as it is. The qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead. 360 videos open in a look-around view (drag, pinch or scroll to zoom from 8 to 300 degrees, phone motion). Settings -> Transcode device picks the machine and device that makes them, for every machine's files: a machine transcoding another's file reads it through the hub by a token for that one file. Replaces the codec/size picker and the single-quality re-encoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
5.8 KiB
Go
174 lines
5.8 KiB
Go
// Package store persists small runtime state (terminal names, login sessions)
|
|
// to a JSON file with atomic writes.
|
|
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Terminal struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Session is one signed-in browser. The map key is the cookie's token; ID is what the
|
|
// UI sees and revokes by, so a session list never shows a usable token.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
User string `json:"user"`
|
|
Expires time.Time `json:"expires"`
|
|
Created time.Time `json:"created,omitempty"`
|
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no
|
|
// user yet; the server gives it to the migrated first account.
|
|
func (s *Session) UnmarshalJSON(b []byte) error {
|
|
if len(b) > 0 && b[0] == '"' {
|
|
return json.Unmarshal(b, &s.Expires)
|
|
}
|
|
type plain Session
|
|
return json.Unmarshal(b, (*plain)(s))
|
|
}
|
|
|
|
// UserState is what follows an account rather than the machine: how the console looks
|
|
// to that person and how their dashboard is laid out.
|
|
type UserState struct {
|
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
|
}
|
|
|
|
// RememberedMount is one bind mount of a container that was running when Ham Lab last
|
|
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
|
|
// when that container is gone the path is the only way back to the file on disk.
|
|
type RememberedMount struct {
|
|
Container string `json:"container"`
|
|
Dest string `json:"dest"` // the path inside the container
|
|
Src string `json:"src"` // the path on the machine
|
|
Seen time.Time `json:"seen"`
|
|
}
|
|
|
|
type State struct {
|
|
Terminals []Terminal `json:"terminals"`
|
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
|
Users map[string]*UserState `json:"users,omitempty"`
|
|
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
|
// account. Kept so an old state file still reads.
|
|
UI json.RawMessage `json:"ui,omitempty"`
|
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
|
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
|
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
|
|
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
|
// orchestrator that has since been removed can still be found on disk. See
|
|
// internal/api/stacks.go.
|
|
Mounts []RememberedMount `json:"mounts,omitempty"`
|
|
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
|
// the next browser — or this one after it has forgotten them — is signed into the device
|
|
// already. See internal/api/kvm.go.
|
|
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
|
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
|
// a machine and a device on it. nil is the machine the file is on, its best device.
|
|
Transcode *Transcode `json:"transcode,omitempty"`
|
|
}
|
|
|
|
// Transcode names a device on a machine: Node "" is the hub itself; Device is a render node
|
|
// or "cpu".
|
|
type Transcode struct {
|
|
Node string `json:"node"`
|
|
Device string `json:"device"`
|
|
}
|
|
|
|
// UserState returns the prefs of an account, making an empty one if needed.
|
|
func (st *State) UserState(name string) *UserState {
|
|
if st.Users == nil {
|
|
st.Users = map[string]*UserState{}
|
|
}
|
|
u := st.Users[name]
|
|
if u == nil {
|
|
u = &UserState{}
|
|
st.Users[name] = u
|
|
}
|
|
return u
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
state State
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
s := &Store{path: path}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
if err == nil {
|
|
// State is conveniences (sessions, terminal names, layout). A file mangled by a power
|
|
// cut must not keep the console down: set it aside and start clean.
|
|
if err := json.Unmarshal(b, &s.state); err != nil {
|
|
s.state = State{}
|
|
_ = os.Rename(path, path+".corrupt")
|
|
log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path)
|
|
}
|
|
}
|
|
if s.state.Sessions == nil {
|
|
s.state.Sessions = map[string]Session{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Update applies fn under the lock and writes the result to disk.
|
|
func (s *Store) Update(fn func(*State)) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
fn(&s.state)
|
|
return s.save()
|
|
}
|
|
|
|
// View reads state under the lock.
|
|
func (s *Store) View(fn func(*State)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
fn(&s.state)
|
|
}
|
|
|
|
func (s *Store) save() error {
|
|
b, err := json.MarshalIndent(&s.state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WriteFileAtomic(s.path, b, 0o600)
|
|
}
|
|
|
|
// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the
|
|
// old version or the new one, never empty or half-written.
|
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = f.Write(data)
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if cerr := f.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|