diff --git a/cmd/autoscan/main.go b/cmd/autoscan/main.go index 98f1de63..b0e23ce5 100644 --- a/cmd/autoscan/main.go +++ b/cmd/autoscan/main.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "github.com/cloudbox/autoscan/targets/emby" "io" "net/http" "os" @@ -44,6 +45,7 @@ type config struct { // autoscan.Target Targets struct { Plex []plex.Config `yaml:"plex"` + Emby []emby.Config `yaml:"emby"` } `yaml:"targets"` } @@ -230,8 +232,22 @@ func main() { targets = append(targets, tp) } + for _, t := range c.Targets.Emby { + tp, err := emby.New(t) + if err != nil { + log.Fatal(). + Err(err). + Str("target", "emby"). + Str("target_url", t.URL). + Msg("Failed initialising target") + } + + targets = append(targets, tp) + } + log.Info(). Int("plex", len(c.Targets.Plex)). + Int("emby", len(c.Targets.Emby)). Msg("Initialised targets") // processor diff --git a/logging.go b/logging.go index ef246deb..3957d015 100644 --- a/logging.go +++ b/logging.go @@ -6,6 +6,10 @@ import ( ) func GetLogger(verbosity string) zerolog.Logger { + if verbosity == "" { + return log.Logger + } + level, err := zerolog.ParseLevel(verbosity) if err != nil { return log.Logger diff --git a/targets/emby/available.go b/targets/emby/available.go new file mode 100644 index 00000000..a65181a7 --- /dev/null +++ b/targets/emby/available.go @@ -0,0 +1,37 @@ +package emby + +import ( + "fmt" + "net/http" + + "github.com/cloudbox/autoscan" +) + +func (t target) Available() error { + // create request + req, err := http.NewRequest("GET", autoscan.JoinURL(t.url, "emby", "System", "Info"), nil) + if err != nil { + return fmt.Errorf("%v: %w", err, autoscan.ErrFatal) + } + + // set headers + req.Header.Set("X-Emby-Token", t.token) + req.Header.Set("Accept", "application/json") + + // send request + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("could not check Emby availability: %v: %w", + err, autoscan.ErrTargetUnavailable) + } + + defer res.Body.Close() + + // validate response + if res.StatusCode != 200 { + return fmt.Errorf("could not check Emby availability: %v: %w", + res.StatusCode, autoscan.ErrTargetUnavailable) + } + + return nil +} diff --git a/targets/emby/datastore.go b/targets/emby/datastore.go new file mode 100644 index 00000000..b3792018 --- /dev/null +++ b/targets/emby/datastore.go @@ -0,0 +1,86 @@ +package emby + +import ( + "database/sql" + "fmt" + + // database driver + _ "github.com/mattn/go-sqlite3" +) + +func NewDatastore(path string) (*datastore, error) { + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("could not open database: %v", err) + } + + return &datastore{db: db}, nil +} + +type datastore struct { + db *sql.DB +} + +type library struct { + Name string + Path string +} + +func (d *datastore) Libraries() ([]library, error) { + rows, err := d.db.Query(sqlSelectLibraries) + if err != nil { + return nil, fmt.Errorf("select libraries: %v", err) + } + + defer rows.Close() + + libraries := make([]library, 0) + for rows.Next() { + l := library{} + if err := rows.Scan(&l.Name, &l.Path); err != nil { + return nil, fmt.Errorf("scan library row: %v", err) + } + + libraries = append(libraries, l) + } + + return libraries, nil +} + +type mediaPart struct { + ID int + File string + Size uint64 +} + +func (d *datastore) MediaPartByFile(path string) (*mediaPart, error) { + mp := new(mediaPart) + + row := d.db.QueryRow(sqlSelectMediaPart, path) + err := row.Scan(&mp.ID, &mp.File, &mp.Size) + return mp, err +} + +const ( + sqlSelectLibraries = ` +SELECT + mi.Name, + mi.Path +FROM + MediaItems mi +WHERE + mi.type = 3 AND mi.ParentId = 1 +` + sqlSelectMediaPart = ` +SELECT + mi.Id, + mi.Path, + mi.Size +FROM + MediaItems mi +WHERE + mi.Path = $1 +LIMIT + 1 +` +) diff --git a/targets/emby/emby.go b/targets/emby/emby.go new file mode 100644 index 00000000..30622a9c --- /dev/null +++ b/targets/emby/emby.go @@ -0,0 +1,60 @@ +package emby + +import ( + "github.com/cloudbox/autoscan" + "github.com/rs/zerolog" +) + +type Config struct { + Database string `yaml:"database"` + URL string `yaml:"url"` + Token string `yaml:"token"` + Rewrite autoscan.Rewrite `yaml:"rewrite"` + Verbosity string `yaml:"verbosity"` +} + +type target struct { + url string + token string + libraries []library + + log zerolog.Logger + rewrite autoscan.Rewriter + store *datastore +} + +func New(c Config) (*target, error) { + rewriter, err := autoscan.NewRewriter(c.Rewrite) + if err != nil { + return nil, err + } + + store, err := NewDatastore(c.Database) + if err != nil { + return nil, err + } + + libraries, err := store.Libraries() + if err != nil { + return nil, err + } + + l := autoscan.GetLogger(c.Verbosity).With(). + Str("target", "emby"). + Str("url", c.URL). + Logger() + + l.Debug(). + Interface("libraries", libraries). + Msg("Retrieved libraries") + + return &target{ + url: c.URL, + token: c.Token, + libraries: libraries, + + log: l, + rewrite: rewriter, + store: store, + }, nil +} diff --git a/targets/emby/scan.go b/targets/emby/scan.go new file mode 100644 index 00000000..45f284d1 --- /dev/null +++ b/targets/emby/scan.go @@ -0,0 +1,141 @@ +package emby + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/cloudbox/autoscan" +) + +type scanRequest struct { + Path string `json:"path"` + UpdateType string `json:"updateType"` +} + +func (t target) Scan(scans []autoscan.Scan) error { + // ensure scan tasks present (should never fail) + if len(scans) == 0 { + return nil + } + + // check for at-least one missing/changed file + process := false + for _, s := range scans { + targetFilePath := t.rewrite(filepath.Join(s.Folder, s.File)) + + targetFile, err := t.store.MediaPartByFile(targetFilePath) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // local file not found in target + t.log.Debug(). + Str("path", targetFilePath). + Msg("At least one local file did not exist in target datastore") + + process = true + break + } + + // unexpected error + return fmt.Errorf("could not check emby datastore: %v: %w", err, autoscan.ErrFatal) + } + + // local file was found in target + if targetFile.Size != s.Size { + // local file did not match in target + t.log.Debug(). + Str("path", targetFilePath). + Uint64("target_size", targetFile.Size). + Uint64("local_size", s.Size). + Msg("Local file size does not match in target datastore") + + process = true + break + } + } + + if !process { + // all local files existed in target + t.log.Debug(). + Interface("scans", scans). + Msg("All local files exist in target") + return nil + } + + scanFolder := t.rewrite(scans[0].Folder) + + // determine library for this scan + lib, err := t.getScanLibrary(scanFolder) + if err != nil { + t.log.Warn(). + Err(err). + Msg("No target library found") + return fmt.Errorf("%v: %w", err, autoscan.ErrRetryScan) + } + + l := t.log.With(). + Str("path", scanFolder). + Str("library", lib.Name). + Logger() + + l.Trace().Msg("Sending scan request") + + // create request payload + payload := new(struct { + Updates []scanRequest `json:"Updates"` + }) + + payload.Updates = append(payload.Updates, scanRequest{ + Path: scanFolder, + UpdateType: "Created", + }) + + b, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed encoding scan request payload: %v, %w", err, autoscan.ErrFatal) + } + + // create request + reqURL := autoscan.JoinURL(t.url, "Library", "Media", "Updated") + req, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(b)) + if err != nil { + // May only occur when the user has provided an invalid URL + return fmt.Errorf("failed creating scan request: %v: %w", err, autoscan.ErrFatal) + } + + // set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Token", t.token) + + // send request + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed sending scan request: %v: %w", err, autoscan.ErrTargetUnavailable) + } + + defer res.Body.Close() + + // validate response + if res.StatusCode != 204 { + // 404 if some kind of proxy is in-front of Emby while it's offline. + return fmt.Errorf("%v: failed validating scan request response: %w", res.Status, autoscan.ErrTargetUnavailable) + } + + l.Info().Msg("Scan queued") + return nil +} + +func (t target) getScanLibrary(folder string) (*library, error) { + for _, l := range t.libraries { + if strings.HasPrefix(folder, l.Path) { + return &l, nil + } + } + + return nil, fmt.Errorf("%v: failed determining library", folder) +} diff --git a/targets/plex/datastore.go b/targets/plex/datastore.go index cc1c1ce8..749a64e0 100644 --- a/targets/plex/datastore.go +++ b/targets/plex/datastore.go @@ -93,5 +93,6 @@ FROM media_parts mp WHERE mp.file = $1 +LIMIT 1 ` ) diff --git a/targets/plex/scan.go b/targets/plex/scan.go index 8497a0ba..070ea86a 100644 --- a/targets/plex/scan.go +++ b/targets/plex/scan.go @@ -47,7 +47,7 @@ func (t target) Scan(scans []autoscan.Scan) error { Str("target_path", fp). Uint64("target_size", pf.Size). Uint64("trigger_size", s.Size). - Msg("Trigger file size does not match targets file") + Msg("Trigger file size does not match target file") process = true break