arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/generate/store.go
jellyfin-year-review/internal/generate/store.goRaw
  1package generate
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"os"
 10	"path/filepath"
 11	"regexp"
 12	"sort"
 13	"strconv"
 14
 15	"reelreview/internal/model"
 16)
 17
 18var datasetName = regexp.MustCompile(`^[a-f0-9]{64}$`)
 19var assetName = regexp.MustCompile(`^[a-f0-9]{64}\.jpg$`)
 20
 21func SafeAsset(s string) bool { return assetName.MatchString(s) }
 22
 23// Current resolves an atomic pointer to a complete immutable dataset.
 24func Current(root string) (string, error) {
 25	b, err := os.ReadFile(filepath.Join(root, "CURRENT"))
 26	if err != nil {
 27		return "", fmt.Errorf("no published dataset")
 28	}
 29	name := string(b)
 30	if !datasetName.MatchString(name) {
 31		return "", fmt.Errorf("invalid dataset pointer")
 32	}
 33	return filepath.Join(root, "datasets", name), nil
 34}
 35func Manifest(root string) (model.Manifest, string, error) {
 36	var m model.Manifest
 37	p, err := Current(root)
 38	if err != nil {
 39		return m, "", err
 40	}
 41	b, err := os.ReadFile(filepath.Join(p, "manifest.json"))
 42	if err != nil {
 43		return m, "", err
 44	}
 45	if json.Unmarshal(b, &m) != nil || m.Version != 1 || m.Users == nil {
 46		return m, "", fmt.Errorf("unsupported manifest")
 47	}
 48	for id, years := range m.Users {
 49		n, e := model.ID(id)
 50		if e != nil || n != id {
 51			return m, "", fmt.Errorf("invalid manifest identity")
 52		}
 53		for _, y := range years {
 54			if y < 1970 || y > 9998 {
 55				return m, "", fmt.Errorf("invalid manifest year")
 56			}
 57		}
 58	}
 59	return m, p, nil
 60}
 61func writeJSON(path string, v any) error {
 62	b, e := json.MarshalIndent(v, "", "  ")
 63	if e != nil {
 64		return e
 65	}
 66	return durableWrite(path, b, 0600)
 67}
 68func durableWrite(path string, b []byte, mode os.FileMode) error {
 69	f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
 70	if e != nil {
 71		return e
 72	}
 73	if _, e = f.Write(b); e == nil {
 74		e = f.Sync()
 75	}
 76	closeErr := f.Close()
 77	if e == nil {
 78		e = closeErr
 79	}
 80	return e
 81}
 82func syncDir(path string) error {
 83	f, e := os.Open(path)
 84	if e != nil {
 85		return e
 86	}
 87	defer f.Close()
 88	return f.Sync()
 89}
 90
 91// Publish clones the last successful dataset, replaces one year, then switches a
 92// synced pointer. A lock serializes writers; a failed write never switches readers.
 93func Publish(root string, rs []model.Review, assets map[string][]byte) error {
 94	if len(rs) == 0 {
 95		return fmt.Errorf("no users to publish")
 96	}
 97	if err := os.MkdirAll(filepath.Join(root, "datasets"), 0700); err != nil {
 98		return err
 99	}
100	lock, e := os.OpenFile(filepath.Join(root, ".generate.lock"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
101	if e != nil {
102		return fmt.Errorf("another generator is active (or stale .generate.lock exists)")
103	}
104	lock.Close()
105	defer os.Remove(filepath.Join(root, ".generate.lock"))
106	stage, e := os.MkdirTemp(filepath.Join(root, "datasets"), ".stage-")
107	if e != nil {
108		return e
109	}
110	defer os.RemoveAll(stage)
111	m := model.Manifest{Version: 1, Users: map[string][]int{}}
112	if _, err := os.Stat(filepath.Join(root, "CURRENT")); err == nil {
113		previous, p, err := Manifest(root)
114		if err != nil {
115			return err
116		}
117		m = previous
118		err = filepath.WalkDir(p, func(path string, d os.DirEntry, err error) error {
119			if err != nil {
120				return err
121			}
122			rel, _ := filepath.Rel(p, path)
123			if rel == "." {
124				return nil
125			}
126			dest := filepath.Join(stage, rel)
127			if d.Type()&os.ModeSymlink != 0 {
128				return fmt.Errorf("symlinks forbidden in dataset")
129			}
130			if d.IsDir() {
131				return os.MkdirAll(dest, 0700)
132			}
133			b, err := os.ReadFile(path)
134			if err != nil {
135				return err
136			}
137			return durableWrite(dest, b, 0600)
138		})
139		if err != nil {
140			return err
141		}
142	}
143	year := rs[0].Year
144	// Remove that year's previous cohort, including users removed from Jellyfin.
145	for id, years := range m.Users {
146		keep := []int{}
147		for _, y := range years {
148			if y != year {
149				keep = append(keep, y)
150			}
151		}
152		m.Users[id] = keep
153		_ = os.Remove(filepath.Join(stage, id, strconv.Itoa(year)+".json"))
154		if len(keep) == 0 {
155			delete(m.Users, id)
156		}
157	}
158	for _, r := range rs {
159		id, e := model.ID(r.User.ID)
160		if e != nil || id != r.User.ID || r.Year != year {
161			return fmt.Errorf("inconsistent review cohort")
162		}
163		if e = r.Validate(id, year); e != nil {
164			return e
165		}
166		dir := filepath.Join(stage, id)
167		if e = os.MkdirAll(dir, 0700); e != nil {
168			return e
169		}
170		if e = writeJSON(filepath.Join(dir, strconv.Itoa(year)+".json"), r); e != nil {
171			return e
172		}
173		m.Users[id] = append(m.Users[id], year)
174		sort.Ints(m.Users[id])
175	}
176	if e = os.MkdirAll(filepath.Join(stage, "art"), 0700); e != nil {
177		return e
178	}
179	for name, b := range assets {
180		if !SafeAsset(name) {
181			return fmt.Errorf("unsafe artwork name")
182		}
183		if e = durableWrite(filepath.Join(stage, "art", name), b, 0600); e != nil {
184			return e
185		}
186	}
187	if e = writeJSON(filepath.Join(stage, "manifest.json"), m); e != nil {
188		return e
189	}
190	h := sha256.New()
191	e = filepath.WalkDir(stage, func(path string, d os.DirEntry, err error) error {
192		if err != nil {
193			return err
194		}
195		if d.IsDir() {
196			return syncDir(path)
197		}
198		rel, _ := filepath.Rel(stage, path)
199		_, _ = io.WriteString(h, rel+"\x00")
200		b, err := os.ReadFile(path)
201		if err != nil {
202			return err
203		}
204		_, _ = h.Write(b)
205		return nil
206	})
207	if e != nil {
208		return e
209	}
210	name := hex.EncodeToString(h.Sum(nil))
211	dest := filepath.Join(root, "datasets", name)
212	if _, err := os.Stat(dest); os.IsNotExist(err) {
213		if e = os.Rename(stage, dest); e != nil {
214			return e
215		}
216	}
217	if e = syncDir(filepath.Join(root, "datasets")); e != nil {
218		return e
219	}
220	temp, e := os.CreateTemp(root, ".current-")
221	if e != nil {
222		return e
223	}
224	path := temp.Name()
225	temp.Close()
226	defer os.Remove(path)
227	if e = durableWrite(path, []byte(name), 0600); e != nil {
228		return e
229	}
230	if e = os.Rename(path, filepath.Join(root, "CURRENT")); e != nil {
231		return e
232	}
233	return syncDir(root)
234}