clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / admin / restore.go
restore.go 20.8K
1package admin23// Restoring a backup puts channels back as they were in it, folder by4// folder. The rule (the page and README say it too):5//6// - A channel in the backup is the folder of the same name now. Failing7// that, a Jellyfin or YouTube channel is the one folder of its kind8// with the same source (the same YouTube channels, or the same9// Jellyfin picks) that the backup doesn't have: it was renamed since,10// and gets its old folder name, so number and name, back. Failing11// that, its folder is made again; a folder channel's comes back empty,12// as videos are never in a backup.13// - The folder gets the backup's settings, details and logo, and loses14// those the backup doesn't have for it (a logo added since, say).15// Videos, catalogs and anything else in it stay as they are.16// - A YouTube channel's schedule comes back only where it has none, so a17// channel on the air keeps its guide, and one made again picks up18// where the backup left off.19// - A channel is left out when its number belongs, after the restore, to20// a channel the restore leaves alone (or to the weather channel): move21// that one first.22// - The weather channel's record and logo come back like a channel's.23// - The Jellyfin account comes back only when asked.24// - Channels the backup doesn't have are left as they are.25//26// The setup as it is is backed up first, so restoring that undoes it.2728import (29 "bytes"30 "encoding/json"31 "errors"32 "fmt"33 "io/fs"34 "log"35 "maps"36 "net/http"37 "os"38 "path/filepath"39 "slices"40 "strings"41 "time"4243 "airwaves/internal/guide"44 "airwaves/internal/jellyfin"45 "airwaves/internal/vchan"46 "airwaves/internal/youtube"47)4849// weatherKey is the weather channel among a backup's channels.50const weatherKey = ".weather"5152var errNoSuchChannel = errors.New("the backup has no such channel")5354// restoreReq is what to restore.55type restoreReq struct {56 Channels []string `json:"channels"` // by key; all of them when left out57 Account bool `json:"account"` // the Jellyfin account too58 Schedules *bool `json:"schedules"` // YouTube channels' schedules, where they have none; yes when left out59 Preview bool `json:"preview"` // only say what restoring would do60}6162// restoreItem is a channel in a backup, and what restoring it does.63type restoreItem struct {64 Key string `json:"key"` // its folder, or ".weather"65 Kind string `json:"kind"`66 Number string `json:"number,omitempty"` // as in the backup67 Name string `json:"name"`68 Chosen bool `json:"chosen"`69 Now *channelNow `json:"now,omitempty"` // the channel it puts back, as it is now70 Match string `json:"match,omitempty"` // how that was found: "name" or "source"71 Action string `json:"action"` // "add", "change", "same" or "skip"72 // Changes says what changes: "rename", "kind", "settings", "details",73 // "logo" and "schedule".74 Changes []string `json:"changes,omitempty"`75 Empty bool `json:"empty,omitempty"` // a folder channel made again, without its videos76 Reason string `json:"reason,omitempty"` // why it's left out77 Done bool `json:"done,omitempty"`78 Error string `json:"error,omitempty"` // what went wrong putting it back7980 folder string // its folder now, "" when there's none81 files map[string][]byte // the backup's files for it, by name82 source string // what it plays, to know it by when renamed83}8485// channelNow is a channel as it is now.86type channelNow struct {87 Folder string `json:"folder,omitempty"`88 Number string `json:"number"`89 Name string `json:"name"`90 Kind string `json:"kind"`91}9293// accountPlan is what restoring does to the Jellyfin account. The94// password or token never leaves the server.95type accountPlan struct {96 InBackup bool `json:"inBackup"`97 Server string `json:"server,omitempty"` // the backup's98 User string `json:"user,omitempty"`99 Same bool `json:"same"` // as the account now100 Restore bool `json:"restore"`101 Done bool `json:"done,omitempty"`102 Error string `json:"error,omitempty"`103104 data []byte105}106107// restorePlan is what restoring a backup does, or did.108type restorePlan struct {109 Backup backupJSON `json:"backup"`110 Preview bool `json:"preview"`111 Schedules bool `json:"schedules"`112 Items []*restoreItem `json:"items"`113 Account accountPlan `json:"account"`114 Others []channelNow `json:"others"` // channels the backup doesn't have, left as they are115 Skipped []string `json:"skipped,omitempty"` // files in the backup that aren't part of a setup116 Before string `json:"before,omitempty"` // the backup of the setup as it was117 Errors int `json:"errors"`118}119120// folderNumber reads a channel folder's name as the library does: a121// number, then the name, or only a name.122func folderNumber(folder string) (number, name string) {123 if m := folderNumberRe.FindStringSubmatch(folder); m != nil {124 if n, ok := vchan.ParseNumber(m[1]); ok {125 return n, strings.TrimSpace(m[2])126 }127 }128 return "", folder129}130131// weatherName is the weather channel's number and name in its record.132func weatherName(rec []byte) (number, name string) {133 var r struct{ Number, Name string }134 _ = json.Unmarshal(rec, &r)135 number, name = vchan.WeatherNumber, vchan.WeatherName136 if n, ok := vchan.ParseNumber(r.Number); ok {137 number = n138 }139 if n := strings.TrimSpace(r.Name); n != "" {140 name = n141 }142 return number, name143}144145// sourceOf is what a Jellyfin or YouTube channel plays, to know it by146// when it's been renamed: its YouTube channels or playlist, or its147// Jellyfin server and picks. "" for a folder channel, or one that plays148// nothing.149func sourceOf(files map[string][]byte) string {150 switch kindOf(files) {151 case "jellyfin":152 var c struct {153 Server string `json:"server"`154 Series []string `json:"series"`155 Movies []string `json:"movies"`156 Collections []string `json:"collections"`157 Playlists []string `json:"playlists"`158 }159 if json.Unmarshal(files[channelFile], &c) != nil {160 return ""161 }162 var picks []string163 for _, l := range []struct {164 kind string165 names []string166 }{{"s", c.Series}, {"m", c.Movies}, {"c", c.Collections}, {"p", c.Playlists}} {167 for _, n := range l.names {168 picks = append(picks, l.kind+":"+strings.ToLower(strings.TrimSpace(n)))169 }170 }171 if len(picks) == 0 {172 return ""173 }174 slices.Sort(picks)175 return "jellyfin\x00" + strings.ToLower(strings.TrimRight(c.Server, "/")) + "\x00" + strings.Join(slices.Compact(picks), "\x00")176 case "youtube":177 var c struct {178 Channels []string `json:"channels"`179 Playlist string `json:"playlist"`180 }181 if json.Unmarshal(files[youtubeFile], &c) != nil {182 return ""183 }184 if c.Playlist != "" {185 page, err := youtube.Playlist(c.Playlist)186 if err != nil {187 page = strings.TrimSpace(c.Playlist)188 }189 return "youtube\x00" + page190 }191 var pages []string192 for _, ch := range c.Channels {193 page, err := youtubeChannel(ch)194 if err != nil {195 page = strings.TrimSpace(ch)196 }197 pages = append(pages, strings.ToLower(page))198 }199 if len(pages) == 0 {200 return ""201 }202 slices.Sort(pages)203 return "youtube\x00" + strings.Join(slices.Compact(pages), "\x00")204 }205 return ""206}207208// differs reports whether two versions of a file differ, being there or209// not included.210func differs(a, b []byte) bool { return (a == nil) != (b == nil) || !bytes.Equal(a, b) }211212// logosDiffer reports whether two sets of files have different logos.213func logosDiffer(a, b map[string][]byte, base string) bool {214 logos := func(m map[string][]byte) map[string][]byte {215 out := map[string][]byte{}216 for n, d := range m {217 if isLogo(n, base) {218 out[n] = d219 }220 }221 return out222 }223 return !maps.EqualFunc(logos(a), logos(b), bytes.Equal)224}225226// planRestore works out what restoring a backup does: which channel each227// of its channels is now, and what changes.228func (s *Server) planRestore(a archive, b backupJSON, req restoreReq) (*restorePlan, error) {229 root := s.Library.Root230 cur, err := readSetup(root)231 if err != nil {232 return nil, fmt.Errorf("reading the channel setup: %w", err)233 }234 p := &restorePlan{Backup: b, Preview: req.Preview, Schedules: req.Schedules == nil || *req.Schedules,235 Items: []*restoreItem{}, Others: []channelNow{}, Skipped: a.skipped}236237 // The channels as they are now, by folder.238 listed := map[string]channelNow{}239 for _, e := range s.Library.Entries() {240 listed[e.Folder] = channelNow{Folder: e.Folder, Number: e.Channel.Number(), Name: e.Channel.Name(), Kind: vchan.KindOf(e.Channel)}241 }242 now := func(folder string) *channelNow {243 if c, ok := listed[folder]; ok {244 return &c245 }246 number, name := folderNumber(folder)247 return &channelNow{Folder: folder, Number: number, Name: name, Kind: kindOf(cur.in(folder))}248 }249 isFolder := map[string]bool{}250 for _, f := range cur.folders {251 isFolder[f] = true252 }253 inBackup := map[string]bool{}254 for _, f := range a.setup.folders {255 inBackup[f] = true256 }257258 // The backup's channels, each the folder of its name...259 for _, f := range a.setup.folders {260 files := a.setup.in(f)261 number, name := folderNumber(f)262 x := &restoreItem{Key: f, Kind: kindOf(files), Number: number, Name: name, files: files, source: sourceOf(files)}263 if isFolder[f] {264 x.folder, x.Match = f, "name"265 }266 p.Items = append(p.Items, x)267 }268 // ... or else the one renamed from it: the one folder the backup269 // doesn't have that plays the same, for the one channel that does.270 wants := map[string][]*restoreItem{}271 for _, x := range p.Items {272 if x.folder == "" && x.source != "" {273 wants[x.source] = append(wants[x.source], x)274 }275 }276 plays := map[string][]string{}277 for _, f := range cur.folders {278 if !inBackup[f] {279 if src := sourceOf(cur.in(f)); src != "" {280 plays[src] = append(plays[src], f)281 }282 }283 }284 for src, xs := range wants {285 if len(xs) == 1 && len(plays[src]) == 1 {286 xs[0].folder, xs[0].Match = plays[src][0], "source"287 }288 }289 if rec := a.setup.files[vchan.WeatherFile]; rec != nil {290 number, name := weatherName(rec)291 files := map[string][]byte{}292 for n, d := range a.setup.top() {293 if n == vchan.WeatherFile || isLogo(n, vchan.WeatherLogoName) {294 files[n] = d295 }296 }297 p.Items = append(p.Items, &restoreItem{Key: weatherKey, Kind: "weather", Number: number, Name: name, Match: "name", files: files})298 }299300 // Which are chosen.301 chosen := map[string]bool{}302 for _, k := range req.Channels {303 chosen[k] = true304 }305 for _, x := range p.Items {306 x.Chosen = req.Channels == nil || chosen[x.Key]307 delete(chosen, x.Key)308 }309 if len(chosen) > 0 {310 return nil, fmt.Errorf("%w: %s", errNoSuchChannel, strings.Join(slices.Sorted(maps.Keys(chosen)), ", "))311 }312313 // What each changes.314 curTop := cur.top()315 for _, x := range p.Items {316 switch {317 case x.Key == weatherKey:318 number, name := weatherName(curTop[vchan.WeatherFile])319 if s.Weather != nil {320 number, name = s.Weather.Number(), s.Weather.Name()321 }322 x.Now = &channelNow{Number: number, Name: name, Kind: "weather"}323 if differs(curTop[vchan.WeatherFile], x.files[vchan.WeatherFile]) {324 x.Changes = append(x.Changes, "details")325 }326 if logosDiffer(curTop, x.files, vchan.WeatherLogoName) {327 x.Changes = append(x.Changes, "logo")328 }329 case x.folder != "":330 x.Now = now(x.folder)331 x.Changes = folderChanges(x, cur.in(x.folder), p.Schedules)332 default:333 x.Empty = x.Kind == "folder"334 x.Changes = folderChanges(x, map[string][]byte{}, p.Schedules)335 }336 // Something not a folder, or a link, where its folder goes.337 if x.Key != weatherKey && x.folder != x.Key {338 if _, err := os.Lstat(filepath.Join(root, x.Key)); err == nil {339 x.Reason = fmt.Sprintf("something that isn't its folder is in the way: %q in the channels folder", x.Key)340 }341 }342 }343 s.checkNumbers(p, cur)344 for _, x := range p.Items {345 switch {346 case x.Reason != "":347 x.Action = "skip"348 case x.Key != weatherKey && x.folder == "":349 x.Action = "add"350 case len(x.Changes) > 0:351 x.Action = "change"352 default:353 x.Action = "same"354 }355 }356 slices.SortStableFunc(p.Items, func(x, y *restoreItem) int {357 switch {358 case x.Number == "" && y.Number != "":359 return 1360 case x.Number != "" && y.Number == "":361 return -1362 case x.Number != "":363 if c := guide.CompareNumbers(x.Number, y.Number); c != 0 {364 return c365 }366 }367 return strings.Compare(x.Key, y.Key)368 })369370 // Channels the backup doesn't have.371 targets := map[string]bool{}372 for _, x := range p.Items {373 targets[x.folder] = true374 }375 for _, f := range cur.folders {376 if !targets[f] {377 p.Others = append(p.Others, *now(f))378 }379 }380 slices.SortFunc(p.Others, func(x, y channelNow) int { return guide.CompareNumbers(x.Number, y.Number) })381382 if data := a.setup.files[accountFile]; data != nil {383 var acct jellyfin.Account384 _ = json.Unmarshal(data, &acct)385 now, err := jellyfin.LoadAccount(s.accountPath())386 p.Account = accountPlan{InBackup: true, Server: acct.Server, User: acct.User, Same: err == nil && now == acct, data: data}387 p.Account.Restore = req.Account && !p.Account.Same388 }389 return p, nil390}391392// folderChanges says what restoring x into its folder, which holds has393// now, changes.394func folderChanges(x *restoreItem, has map[string][]byte, schedules bool) []string {395 var out []string396 if x.folder != "" && x.folder != x.Key {397 out = append(out, "rename")398 }399 if x.folder != "" && kindOf(has) != x.Kind {400 out = append(out, "kind")401 }402 if settings := map[string]string{"jellyfin": channelFile, "youtube": youtubeFile}[x.Kind]; settings != "" && differs(has[settings], x.files[settings]) {403 out = append(out, "settings")404 }405 if differs(has[vchan.DetailsFile], x.files[vchan.DetailsFile]) {406 out = append(out, "details")407 }408 if logosDiffer(has, x.files, vchan.LogoName) {409 out = append(out, "logo")410 }411 if schedules && x.Kind == "youtube" && x.files[playoutFile] != nil && has[playoutFile] == nil {412 out = append(out, "schedule")413 }414 return out415}416417// checkNumbers leaves out the chosen channels whose number, after the418// restore, a channel the restore leaves alone (or the weather channel)419// has too, and says so of the others, were they chosen.420func (s *Server) checkNumbers(p *restorePlan, cur setup) {421 var touched map[string]bool422 var wx *restoreItem423 in := func(x *restoreItem) bool { return x.Chosen && x.Reason == "" }424 look := func() {425 touched = map[string]bool{}426 for _, x := range p.Items {427 if x.Key == weatherKey {428 wx = x429 } else if in(x) && x.folder != "" {430 touched[x.folder] = true431 }432 }433 }434 taken := func(x *restoreItem) string {435 if x.Number == "" {436 return ""437 }438 key := vchan.NumberKey(x.Number)439 if x.Key != weatherKey {440 wn := ""441 if wx != nil && in(wx) {442 wn = wx.Number443 } else if s.Weather != nil {444 wn = s.Weather.Number()445 }446 if wn != "" && vchan.NumberKey(wn) == key {447 return "the weather channel"448 }449 }450 for _, f := range cur.folders {451 if f == x.folder || touched[f] {452 continue453 }454 if n, name := folderNumber(f); n != "" && vchan.NumberKey(n) == key {455 return n + " " + name456 }457 }458 return ""459 }460 why := func(x *restoreItem, who string) string {461 return fmt.Sprintf("%s is taken by %s now: give that channel another number first, or leave this one out", x.Number, who)462 }463 for again := true; again; {464 again = false465 look()466 for _, x := range p.Items {467 if in(x) {468 if who := taken(x); who != "" {469 x.Reason, again = why(x, who), true470 }471 }472 }473 }474 look()475 for _, x := range p.Items {476 if !x.Chosen && x.Reason == "" {477 if who := taken(x); who != "" {478 x.Reason = why(x, who)479 }480 }481 }482}483484// changes reports whether the plan changes anything.485func (p *restorePlan) changes() bool {486 for _, x := range p.Items {487 if x.Chosen && (x.Action == "add" || x.Action == "change") {488 return true489 }490 }491 return p.Account.Restore492}493494// applyRestore carries a plan out, noting what went wrong with each495// channel.496func (s *Server) applyRestore(p *restorePlan) {497 root := s.Library.Root498 fail := func(err error) {499 for _, x := range p.Items {500 if x.Chosen && (x.Action == "add" || x.Action == "change") {501 x.Error = err.Error()502 p.Errors++503 }504 }505 }506 if err := os.MkdirAll(root, 0o755); err != nil {507 fail(err)508 return509 }510 if p.Account.Restore {511 if err := writeFile(s.accountPath(), p.Account.data, 0o600); err != nil {512 p.Account.Error = err.Error()513 p.Errors++514 } else {515 p.Account.Done = true516 s.mu.Lock()517 s.checked, s.browse = time.Time{}, nil518 s.mu.Unlock()519 }520 }521 for _, x := range p.Items {522 if !x.Chosen || (x.Action != "add" && x.Action != "change") {523 continue524 }525 var err error526 if x.Key == weatherKey {527 err = putFiles(root, x.files, func(n string) bool { return n == vchan.WeatherFile || isLogo(n, vchan.WeatherLogoName) }, false)528 } else {529 err = putFolder(root, x, p.Schedules)530 }531 if err != nil {532 x.Error = err.Error()533 p.Errors++534 } else {535 x.Done = true536 }537 }538 s.Library.Rescan()539}540541// putFolder puts a channel's folder back: made again, or renamed back,542// then its files.543func putFolder(root string, x *restoreItem, schedules bool) error {544 dir := filepath.Join(root, x.Key)545 switch {546 case x.folder == "":547 if err := os.Mkdir(dir, 0o755); err != nil {548 return fmt.Errorf("can't make its folder: %w", err)549 }550 case x.folder != x.Key:551 if _, err := os.Lstat(dir); err == nil {552 return fmt.Errorf("can't rename %q back: %q is in the way", x.folder, x.Key)553 }554 if err := os.Rename(filepath.Join(root, x.folder), dir); err != nil {555 return fmt.Errorf("can't rename its folder: %w", err)556 }557 }558 if info, err := os.Lstat(dir); err != nil || !info.IsDir() {559 return fmt.Errorf("%q isn't a folder", x.Key)560 }561 managed := func(n string) bool {562 return n == channelFile || n == youtubeFile || n == vchan.DetailsFile || isLogo(n, vchan.LogoName)563 }564 return putFiles(dir, x.files, managed, schedules)565}566567// putFiles makes the setup files in dir what want has, each written568// whole or not at all: the schedule first, and only where there's none;569// then the logo and the details; then the files want doesn't have go570// (those managed says are the setup's); and last the settings, which make571// the folder a channel of their kind.572func putFiles(dir string, want map[string][]byte, managed func(string) bool, schedules bool) error {573 put := func(name string) error {574 p := filepath.Join(dir, name)575 if old, err := os.ReadFile(p); err == nil && bytes.Equal(old, want[name]) {576 return nil // as it is, so the channel isn't stirred577 }578 perm := os.FileMode(0o644)579 if private(name) {580 perm = 0o600581 }582 return writeFile(p, want[name], perm)583 }584 settings := func(n string) bool { return n == channelFile || n == youtubeFile }585 if want[playoutFile] != nil && schedules {586 if _, err := os.Lstat(filepath.Join(dir, playoutFile)); errors.Is(err, fs.ErrNotExist) {587 if err := put(playoutFile); err != nil {588 return err589 }590 }591 }592 names := slices.Sorted(maps.Keys(want))593 // Logos before the details that name them.594 slices.SortStableFunc(names, func(a, b string) int {595 la, lb := isLogo(a, vchan.LogoName) || isLogo(a, vchan.WeatherLogoName), isLogo(b, vchan.LogoName) || isLogo(b, vchan.WeatherLogoName)596 switch {597 case la && !lb:598 return -1599 case lb && !la:600 return 1601 }602 return 0603 })604 for _, n := range names {605 if n != playoutFile && !settings(n) {606 if err := put(n); err != nil {607 return err608 }609 }610 }611 entries, err := os.ReadDir(dir)612 if err != nil {613 return err614 }615 for _, e := range entries {616 if n := e.Name(); e.Type().IsRegular() && managed(n) && want[n] == nil {617 if err := os.Remove(filepath.Join(dir, n)); err != nil && !errors.Is(err, fs.ErrNotExist) {618 return err619 }620 }621 }622 for _, n := range names {623 if settings(n) {624 if err := put(n); err != nil {625 return err626 }627 }628 }629 return nil630}631632// restoreBackup says what restoring a backup would do (with preview), or633// does it: the setup as it is is backed up first, and nothing is restored634// when that fails.635func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {636 if !s.backupsOn(w) {637 return638 }639 var req restoreReq640 if !decodeOptional(w, r, &req) {641 return642 }643 file := r.PathValue("file")644 if !req.Preview {645 s.bk.run.Lock()646 defer s.bk.run.Unlock()647 }648 a, b, err := s.openBackup(file)649 switch {650 case errors.Is(err, fs.ErrNotExist):651 writeErr(w, http.StatusNotFound, fmt.Errorf("no backup %s", file))652 return653 case err != nil:654 writeErr(w, http.StatusUnprocessableEntity, err)655 return656 }657 p, err := s.planRestore(a, b, req)658 switch {659 case errors.Is(err, errNoSuchChannel):660 writeErr(w, http.StatusBadRequest, err)661 return662 case err != nil:663 writeErr(w, http.StatusInternalServerError, err)664 return665 }666 if req.Preview || !p.changes() {667 writeJSON(w, p)668 return669 }670 before, _, err := s.backUpLocked(beforeRestore, "")671 if err != nil {672 writeErr(w, http.StatusInternalServerError, fmt.Errorf("nothing was restored, as the setup as it is couldn't be backed up first: %w", err))673 return674 }675 p.Before = before.File676 s.applyRestore(p)677 done := 0678 for _, x := range p.Items {679 if x.Done {680 done++681 }682 }683 log.Printf("admin: restored %d channels from backup %s (the setup before is %s), with %d errors", done, file, p.Before, p.Errors)684 s.backupSoon()685 writeJSON(w, p)686}