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}