package main import ( "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/dgraph-io/badger/v3" "github.com/hrfee/mediabrowser" "github.com/timshannon/badgerhold/v4" ) const ( BACKUP_PREFIX = "jfa-go-db-" BACKUP_DATEFMT = "2006-01-02T15-04-05" BACKUP_SUFFIX = ".bak" ) // clearEmails removes stored emails for users which no longer exist. // meant to be called with other such housekeeping functions, so assumes // the user cache is fresh. func (app *appContext) clearEmails() { app.debug.Println("Housekeeping: removing unused email addresses") emails := app.storage.GetEmails() for _, email := range emails { _, _, err := app.jf.UserByID(email.JellyfinID, false) // Make sure the user doesn't exist, and no other error has occured switch err.(type) { case mediabrowser.ErrUserNotFound: app.storage.DeleteEmailsKey(email.JellyfinID) default: continue } } } // clearDiscord does the same as clearEmails, but for Discord Users. func (app *appContext) clearDiscord() { app.debug.Println("Housekeeping: removing unused Discord IDs") discordUsers := app.storage.GetDiscord() for _, discordUser := range discordUsers { _, _, err := app.jf.UserByID(discordUser.JellyfinID, false) // Make sure the user doesn't exist, and no other error has occured switch err.(type) { case mediabrowser.ErrUserNotFound: app.storage.DeleteDiscordKey(discordUser.JellyfinID) default: continue } } } // clearMatrix does the same as clearEmails, but for Matrix Users. func (app *appContext) clearMatrix() { app.debug.Println("Housekeeping: removing unused Matrix IDs") matrixUsers := app.storage.GetMatrix() for _, matrixUser := range matrixUsers { _, _, err := app.jf.UserByID(matrixUser.JellyfinID, false) // Make sure the user doesn't exist, and no other error has occured switch err.(type) { case mediabrowser.ErrUserNotFound: app.storage.DeleteMatrixKey(matrixUser.JellyfinID) default: continue } } } // clearTelegram does the same as clearEmails, but for Telegram Users. func (app *appContext) clearTelegram() { app.debug.Println("Housekeeping: removing unused Telegram IDs") telegramUsers := app.storage.GetTelegram() for _, telegramUser := range telegramUsers { _, _, err := app.jf.UserByID(telegramUser.JellyfinID, false) // Make sure the user doesn't exist, and no other error has occured switch err.(type) { case mediabrowser.ErrUserNotFound: app.storage.DeleteTelegramKey(telegramUser.JellyfinID) default: continue } } } type BackupList struct { files []os.DirEntry dates []time.Time count int } func (bl BackupList) Len() int { return len(bl.files) } func (bl BackupList) Swap(i, j int) { bl.files[i], bl.files[j] = bl.files[j], bl.files[i] bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i] } func (bl BackupList) Less(i, j int) bool { // Push non-backup files to the end of the array, // Since they didn't have a date parsed. if bl.dates[i].IsZero() { return false } if bl.dates[j].IsZero() { return true } // Sort by oldest first return bl.dates[j].After(bl.dates[i]) } // Get human-readable file size from f.Size() result. // https://programming.guide/go/formatting-byte-size-to-human-readable-format.html func fileSize(l int64) string { const unit = 1000 if l < unit { return fmt.Sprintf("%dB", l) } div, exp := int64(unit), 0 for n := l / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp]) } func (app *appContext) getBackups() *BackupList { path := app.config.Section("backups").Key("path").String() err := os.MkdirAll(path, 0755) if err != nil { app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err) return nil } items, err := os.ReadDir(path) if err != nil { app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err) return nil } backups := &BackupList{} backups.files = items backups.dates = make([]time.Time, len(items)) backups.count = 0 for i, item := range items { if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) { continue } t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(item.Name(), BACKUP_PREFIX), BACKUP_SUFFIX)) if err != nil { app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err) continue } backups.dates[i] = t backups.count++ } return backups } func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) { toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20) fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX path := app.config.Section("backups").Key("path").String() backups := app.getBackups() if backups == nil { return } toDelete := backups.count + 1 - toKeep // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files)) if toDelete > 0 && toDelete <= backups.count { sort.Sort(backups) for _, item := range backups.files[:toDelete] { fullpath := filepath.Join(path, item.Name()) app.debug.Printf("Deleting old backup \"%s\"\n", item.Name()) err := os.Remove(fullpath) if err != nil { app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err) return } } } fullpath := filepath.Join(path, fname) f, err := os.Create(fullpath) if err != nil { app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err) return } defer f.Close() _, err = app.storage.db.Badger().Backup(f, 0) if err != nil { app.err.Printf("Failed to create backup: %v\n", err) return } fstat, err := f.Stat() if err != nil { app.err.Printf("Failed to get info on new backup: %v\n", err) return } fileDetails.Size = fileSize(fstat.Size()) fileDetails.Name = fname fileDetails.Path = fullpath // fmt.Printf("Created backup %+v\n", fileDetails) return } func (app *appContext) loadPendingBackup() { if LOADBAK == "" { return } oldPath := filepath.Join(app.dataPath, "db-pre-"+filepath.Base(LOADBAK)) app.info.Printf("Moving existing database to \"%s\"\n", oldPath) err := os.Rename(app.storage.db_path, oldPath) if err != nil { app.err.Fatalf("Failed to move existing database: %v\n", err) } app.ConnectDB() defer app.storage.db.Close() f, err := os.Open(LOADBAK) if err != nil { app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err) } err = app.storage.db.Badger().Load(f, 256) f.Close() if err != nil { app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err) } app.info.Printf("Restored backup \"%s\".", LOADBAK) LOADBAK = "" } func (app *appContext) clearActivities() { app.debug.Println("Housekeeping: Cleaning up Activity log...") keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90) minAge := time.Now().AddDate(0, 0, -maxAgeDays) err := error(nil) errorSource := 0 if maxAgeDays != 0 { err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge)) } if err == nil && keepCount != 0 { // app.debug.Printf("Keeping %d records", keepCount) err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount)) if err != nil { errorSource = 1 } } if err == badger.ErrTxnTooBig { app.debug.Printf("Activities: Delete txn was too big, doing it manually.") list := []Activity{} if errorSource == 0 { app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge)) } else { app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount)) } for _, record := range list { app.storage.DeleteActivityKey(record.ID) } } } // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS type housekeepingDaemon struct { Stopped bool ShutdownChannel chan string Interval time.Duration period time.Duration jobs []func(app *appContext) app *appContext } func newBackupDaemon(app *appContext) *housekeepingDaemon { interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute daemon := housekeepingDaemon{ Stopped: false, ShutdownChannel: make(chan string), Interval: interval, period: interval, app: app, } daemon.jobs = []func(app *appContext){ func(app *appContext) { app.debug.Println("Backups: Creating backup") app.makeBackup() }, } return &daemon } func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon { daemon := housekeepingDaemon{ Stopped: false, ShutdownChannel: make(chan string), Interval: interval, period: interval, app: app, } daemon.jobs = []func(app *appContext){ func(app *appContext) { app.debug.Println("Housekeeping: Checking for expired invites") app.checkInvites() }, func(app *appContext) { app.clearActivities() }, } clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false) clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false) if clearEmail || clearDiscord || clearTelegram || clearMatrix { daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() }) } if clearEmail { daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() }) } if clearDiscord { daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() }) } if clearTelegram { daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() }) } if clearMatrix { daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) } return &daemon } func (rt *housekeepingDaemon) run() { rt.app.info.Println("Invite daemon started") for { select { case <-rt.ShutdownChannel: rt.ShutdownChannel <- "Down" return case <-time.After(rt.period): break } started := time.Now() for _, job := range rt.jobs { job(rt.app) } finished := time.Now() duration := finished.Sub(started) rt.period = rt.Interval - duration } } func (rt *housekeepingDaemon) Shutdown() { rt.Stopped = true rt.ShutdownChannel <- "Down" <-rt.ShutdownChannel close(rt.ShutdownChannel) }