diff --git a/.gitignore b/.gitignore index 7de6cf544..1840ce7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /*.conf /*.gz /*.zip +/*.upx /unpackerr*.1 /*.deb /*.rpm diff --git a/Makefile b/Makefile index 016125d11..8d105d265 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ dmg: clean macapp # Delete all build assets. clean: - rm -f $(BINARY) $(BINARY).*.{macos,freebsd,linux,exe}{,.gz,.zip} $(BINARY).1{,.gz} $(BINARY).rb + rm -f $(BINARY) $(BINARY).*.{macos,freebsd,linux,exe,upx}{,.gz,.zip} $(BINARY).1{,.gz} $(BINARY).rb rm -f $(BINARY){_,-}*.{deb,rpm,txz} v*.tar.gz.sha256 examples/MANUAL .metadata.make rm -f cmd/$(BINARY)/README{,.html} README{,.html} ./$(BINARY)_manual.html rsrc.syso $(MACAPP).app.zip rm -rf package_build_* release after-install-rendered.sh before-remove-rendered.sh $(MACAPP).app @@ -297,7 +297,7 @@ package_build_linux_armhf: package_build_linux armhf cp $(BINARY).armhf.linux $@/usr/bin/$(BINARY) # Build an environment that can be packaged for freebsd. -package_build_freebsd: readme man freebsd +package_build_freebsd: readme man after-install-rendered.sh before-remove-rendered.sh freebsd mkdir -p $@/usr/local/bin $@/usr/local/etc/$(BINARY) $@/usr/local/share/man/man1 $@/usr/local/share/doc/$(BINARY) cp $(BINARY).amd64.freebsd $@/usr/local/bin/$(BINARY) cp *.1.gz $@/usr/local/share/man/man1 diff --git a/go.mod b/go.mod index 5bd980a27..569ed2033 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,7 @@ module github.com/Go-Lift-TV/discordnotifier-client go 1.15 + require ( github.com/gen2brain/dlgs v0.0.0-20201118155338-03fe7f81ad25 github.com/getlantern/golog v0.0.0-20201105130739-9586b8bde3a9 // indirect @@ -15,6 +16,6 @@ require ( golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect golift.io/cnfg v0.0.7 golift.io/rotatorr v0.0.0-20201213130124-94efc0b9aff1 - golift.io/starr v0.9.5-0.20201225031430-d96b4216b1b8 + golift.io/starr v0.9.5-0.20210104053210-306f1822c914 golift.io/version v0.0.2 ) diff --git a/go.sum b/go.sum index 257a369c6..4acc6a217 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,12 @@ golift.io/rotatorr v0.0.0-20201213130124-94efc0b9aff1 h1:8SDkFT5QpXyN24BCPw5Yux7 golift.io/rotatorr v0.0.0-20201213130124-94efc0b9aff1/go.mod h1:EZevRvIGRh8jDMwuYL0/tlPns0KynquPZzb0SerIC1s= golift.io/starr v0.9.5-0.20201225031430-d96b4216b1b8 h1:3FMaGhdPSsP8G1D3jD4mCMyVnD/DPTAuNkSZVRxNzSw= golift.io/starr v0.9.5-0.20201225031430-d96b4216b1b8/go.mod h1:EE8B7OlqZlE/EGmBP1bLsK1OHEgWwbNpyjDXX0B2f0Y= +golift.io/starr v0.9.5-0.20201230044237-9b54fd5a1c2c h1:/knsNcSIiH4eZftBXRM5uYuA3Wki51ePWOsGxf26wM4= +golift.io/starr v0.9.5-0.20201230044237-9b54fd5a1c2c/go.mod h1:EE8B7OlqZlE/EGmBP1bLsK1OHEgWwbNpyjDXX0B2f0Y= +golift.io/starr v0.9.5-0.20210102172243-614fc1de1548 h1:10xWqUGL/yotNrEaJpquZ1ppvgZN1lkxFqs++f2Xvnk= +golift.io/starr v0.9.5-0.20210102172243-614fc1de1548/go.mod h1:EE8B7OlqZlE/EGmBP1bLsK1OHEgWwbNpyjDXX0B2f0Y= +golift.io/starr v0.9.5-0.20210104053210-306f1822c914 h1:gG2bli4+C6dCkl4Y/z2DUm4V7Ihwmd5pJn9CGA5V5G4= +golift.io/starr v0.9.5-0.20210104053210-306f1822c914/go.mod h1:EE8B7OlqZlE/EGmBP1bLsK1OHEgWwbNpyjDXX0B2f0Y= golift.io/version v0.0.2 h1:i0gXRuSDHKs4O0sVDUg4+vNIuOxYoXhaxspftu2FRTE= golift.io/version v0.0.2/go.mod h1:76aHNz8/Pm7CbuxIsDi97jABL5Zui3f2uZxDm4vB6hU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/init/bsd/freebsd.rc.d b/init/bsd/freebsd.rc.d index acfc69909..38719ce8d 100644 --- a/init/bsd/freebsd.rc.d +++ b/init/bsd/freebsd.rc.d @@ -12,7 +12,7 @@ name="{{BINARYU}}" real_name="{{BINARY}}" rcvar="{{BINARYU}}_enable" {{BINARYU}}_command="/usr/local/bin/${real_name}" -{{BINARYU}}_user="nobody" +{{BINARYU}}_user="{{BINARYU}}" {{BINARYU}}_config="/usr/local/etc/${real_name}/{{CONFIG_FILE}}" pidfile="/var/run/${real_name}/pid" diff --git a/pkg/apps/apps.go b/pkg/apps/apps.go index fe717f37e..651f11483 100644 --- a/pkg/apps/apps.go +++ b/pkg/apps/apps.go @@ -59,6 +59,7 @@ var ( ErrNoLidarr = fmt.Errorf("configured lidarr ID not found") ErrNoReadarr = fmt.Errorf("configured readarr ID not found") ErrExists = fmt.Errorf("the requested item already exists") + ErrNotFound = fmt.Errorf("the request returned an empty payload") ) // APIHandler is our custom handler function for APIs. @@ -94,7 +95,7 @@ func (a *Apps) HandleAPIpath(app App, api string, next APIHandler, method ...str a.Respond(w, http.StatusUnprocessableEntity, fmt.Errorf("%v: %w", id, ErrNoReadarr), start) // These store the application configuration (starr) in a context then pass that into the next method. - // They retrieve the return code and output, then send a response (a.respond). + // They retrieve the return code and output, then send a response (a.Respond). case app == Radarr: i, m := next(r.WithContext(context.WithValue(r.Context(), Radarr, a.Radarr[id-1]))) a.Respond(w, i, m, start) @@ -116,9 +117,10 @@ func (a *Apps) checkAPIKey(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-API-Key") != a.APIKey { w.WriteHeader(http.StatusUnauthorized) - } else { - next.ServeHTTP(w, r) + return } + + next.ServeHTTP(w, r) }) } @@ -133,22 +135,23 @@ func (a *Apps) InitHandlers() { // Setup creates request interfaces and sets the timeout for each server. func (a *Apps) Setup(timeout time.Duration) { for i := range a.Radarr { - a.Radarr[i].fix(timeout) + a.Radarr[i].setup(timeout) } for i := range a.Readarr { - a.Readarr[i].fix(timeout) + a.Readarr[i].setup(timeout) } for i := range a.Sonarr { - a.Sonarr[i].fix(timeout) + a.Sonarr[i].setup(timeout) } for i := range a.Lidarr { - a.Lidarr[i].fix(timeout) + a.Lidarr[i].setup(timeout) } } +// Respond sends a standard response to our caller. JSON encoded blobs. func (a *Apps) Respond(w http.ResponseWriter, stat int, msg interface{}, start time.Time) { w.Header().Set("X-Request-Time", time.Since(start).Round(time.Microsecond).String()) w.Header().Set("Content-Type", "application/json") @@ -157,13 +160,19 @@ func (a *Apps) Respond(w http.ResponseWriter, stat int, msg interface{}, start t statusTxt := strconv.Itoa(stat) + ": " + http.StatusText(stat) if m, ok := msg.(error); ok { - a.ErrorLog.Printf("Status: %s, Message: %v", statusTxt, m) + a.ErrorLog.Printf("Request failed. Status: %s, Message: %v", statusTxt, m) msg = m.Error() } - b, _ := json.Marshal(map[string]interface{}{"status": statusTxt, "message": msg}) - _, _ = w.Write(b) - _, _ = w.Write([]byte("\n")) // curl likes new lines. + b, err := json.Marshal(map[string]interface{}{"status": statusTxt, "message": msg}) + if err != nil { + a.ErrorLog.Printf("JSON marshal failed. Status: %s, Error: %v, Message: %v", statusTxt, err, msg) + } + + size, err := w.Write(append(b, '\n')) // curl likes new lines. + if err != nil { + a.ErrorLog.Printf("Response failed. Written: %d/%d, Status: %s, Error: %v", size, len(b)+1, statusTxt, err) + } } /* Every API call runs one of these methods to find the interface for the respective app. */ diff --git a/pkg/apps/lidarr.go b/pkg/apps/lidarr.go index c60e778e8..ef7126ad2 100644 --- a/pkg/apps/lidarr.go +++ b/pkg/apps/lidarr.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" + "strings" "time" "github.com/gorilla/mux" @@ -18,10 +20,16 @@ mbid - music brainz is the source for lidarr (todo) // lidarrHandlers is called once on startup to register the web API paths. func (a *Apps) lidarrHandlers() { a.HandleAPIpath(Lidarr, "/add", lidarrAddAlbum, "POST") - a.HandleAPIpath(Lidarr, "/check/{albumid:[-a-z0-9]+}", lidarrCheckAlbum, "GET") + a.HandleAPIpath(Lidarr, "/check/{mbid:[-a-z0-9]+}", lidarrCheckAlbum, "GET") + a.HandleAPIpath(Lidarr, "/search/{query}", lidarrSearchAlbum, "GET") + a.HandleAPIpath(Lidarr, "/get/{albumid:[0-9]+}", lidarrGetAlbum, "GET") + a.HandleAPIpath(Lidarr, "/update", lidarrUpdateAlbum, "PUT") a.HandleAPIpath(Lidarr, "/qualityProfiles", lidarrProfiles, "GET") a.HandleAPIpath(Lidarr, "/qualityDefinitions", lidarrQualityDefs, "GET") + a.HandleAPIpath(Lidarr, "/metadataProfiles", lidarrMetadata, "GET") a.HandleAPIpath(Lidarr, "/rootFolder", lidarrRootFolders, "GET") + a.HandleAPIpath(Lidarr, "/artist/{artistid:[0-9]+}", lidarrGetArtist, "GET") + a.HandleAPIpath(Lidarr, "/updateartist", lidarrUpdateArtist, "PUT") } // LidarrConfig represents the input data for a Lidarr server. @@ -30,7 +38,7 @@ type LidarrConfig struct { lidarr *lidarr.Lidarr } -func (r *LidarrConfig) fix(timeout time.Duration) { +func (r *LidarrConfig) setup(timeout time.Duration) { r.lidarr = lidarr.New(r.Config) if r.Timeout.Duration == 0 { r.Timeout.Duration = timeout @@ -69,6 +77,21 @@ func lidarrProfiles(r *http.Request) (int, interface{}) { return http.StatusOK, p } +func lidarrMetadata(r *http.Request) (int, interface{}) { + profiles, err := getLidarr(r).GetMetadataProfiles() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("getting profiles: %w", err) + } + + // Format profile ID=>Name into a nice map. + p := make(map[int64]string) + for i := range profiles { + p[profiles[i].ID] = profiles[i].Name + } + + return http.StatusOK, p +} + func lidarrQualityDefs(r *http.Request) (int, interface{}) { // Get the profiles from lidarr. definitions, err := getLidarr(r).GetQualityDefinition() @@ -86,40 +109,148 @@ func lidarrQualityDefs(r *http.Request) (int, interface{}) { } func lidarrCheckAlbum(r *http.Request) (int, interface{}) { - // Check for existing movie. - if m, err := getLidarr(r).GetAlbum(mux.Vars(r)["albumid"]); err != nil { + id := mux.Vars(r)["mbid"] + + m, err := getLidarr(r).GetAlbum(id) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking album: %w", err) } else if len(m) > 0 { - return http.StatusConflict, fmt.Errorf("%s: %w", mux.Vars(r)["albumid"], ErrExists) + return http.StatusConflict, fmt.Errorf("%s: %w", id, ErrExists) } return http.StatusOK, http.StatusText(http.StatusNotFound) } +func lidarrGetAlbum(r *http.Request) (int, interface{}) { + albumID, _ := strconv.ParseInt(mux.Vars(r)["albumid"], 10, 64) + + album, err := getLidarr(r).GetAlbumByID(albumID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("checking album: %w", err) + } + + return http.StatusOK, album +} + +func lidarrGetArtist(r *http.Request) (int, interface{}) { + artistID, _ := strconv.ParseInt(mux.Vars(r)["artistid"], 10, 64) + + artist, err := getLidarr(r).GetArtistByID(artistID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("checking artist: %w", err) + } + + return http.StatusOK, artist +} + +func lidarrUpdateAlbum(r *http.Request) (int, interface{}) { + var album lidarr.Album + + err := json.NewDecoder(r.Body).Decode(&album) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + _, err = getLidarr(r).UpdateAlbum(album.ID, &album) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating album: %w", err) + } + + return http.StatusOK, "success" +} + +func lidarrUpdateArtist(r *http.Request) (int, interface{}) { + var artist lidarr.Artist + + err := json.NewDecoder(r.Body).Decode(&artist) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + _, err = getLidarr(r).UpdateArtist(&artist) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating artist: %w", err) + } + + return http.StatusOK, "success" +} + func lidarrAddAlbum(r *http.Request) (int, interface{}) { var payload lidarr.AddAlbumInput - // Extract payload and check for TMDB ID. - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) - } else if payload == nil { - return http.StatusUnprocessableEntity, fmt.Errorf("0: %w", ErrNoGRID) + } else if payload.ForeignAlbumID == "" { + return http.StatusUnprocessableEntity, fmt.Errorf("0: %w", ErrNoMBID) } - lidar := getLidarr(r) + app := getLidarr(r) // Check for existing album. - /* broken: - if m, err := lidar.GetAlbum(payload.AlbumID); err != nil { + m, err := app.GetAlbum(payload.ForeignAlbumID) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking album: %w", err) } else if len(m) > 0 { - return http.StatusConflict, fmt.Errorf("%d: %w", payload.AlbumID, ErrExists) + return http.StatusConflict, fmt.Errorf("%s: %w", payload.ForeignAlbumID, ErrExists) } - */ - // Add book using payload. - book, err := lidar.AddAlbum(&payload) + album, err := app.AddAlbum(&payload) if err != nil { return http.StatusInternalServerError, fmt.Errorf("adding album: %w", err) } - return http.StatusCreated, book + return http.StatusCreated, album +} + +func lidarrSearchAlbum(r *http.Request) (int, interface{}) { + albums, err := getLidarr(r).GetAlbum("") + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("getting albums: %w", err) + } + + query := strings.TrimSpace(strings.ToLower(mux.Vars(r)["query"])) // in + output := make([]map[string]interface{}, 0) // out + + for _, album := range albums { + if albumSearch(query, album.Title, album.Releases) { + a := map[string]interface{}{ + "id": album.ID, + "mbid": album.ForeignAlbumID, + "metadataId": album.Artist.MetadataProfileID, + "qualityId": album.Artist.QualityProfileID, + "title": album.Title, + "release": album.ReleaseDate, + "artistId": album.ArtistID, + "artistName": album.Artist.ArtistName, + "profileId": album.ProfileID, + "overview": album.Overview, + "ratings": album.Ratings.Value, + "type": album.AlbumType, + "exists": false, + "files": 0, + } + + if album.Statistics != nil { + a["exists"] = album.Statistics.SizeOnDisk > 0 + } + + output = append(output, a) + } + } + + return http.StatusOK, output +} + +func albumSearch(query, title string, releases []*lidarr.Release) bool { + if strings.Contains(strings.ToLower(title), query) { + return true + } + + for _, t := range releases { + if strings.Contains(strings.ToLower(t.Title), query) { + return true + } + } + + return false } diff --git a/pkg/apps/radarr.go b/pkg/apps/radarr.go index 67a07243c..2b67c1338 100644 --- a/pkg/apps/radarr.go +++ b/pkg/apps/radarr.go @@ -18,6 +18,8 @@ func (a *Apps) radarrHandlers() { a.HandleAPIpath(Radarr, "/add", radarrAddMovie, "POST") a.HandleAPIpath(Radarr, "/search/{query}", radarrSearchMovie, "GET") a.HandleAPIpath(Radarr, "/check/{tmdbid:[0-9]+}", radarrCheckMovie, "GET") + a.HandleAPIpath(Radarr, "/get/{movieid:[0-9]+}", radarrGetMovie, "GET") + a.HandleAPIpath(Radarr, "/update", radarrUpdateMovie, "PUT") a.HandleAPIpath(Radarr, "/qualityProfiles", radarrProfiles, "GET") a.HandleAPIpath(Radarr, "/rootFolder", radarrRootFolders, "GET") } @@ -25,10 +27,10 @@ func (a *Apps) radarrHandlers() { // RadarrConfig represents the input data for a Radarr server. type RadarrConfig struct { *starr.Config - radarr *radarr.Radarr `json:"-" toml:"-" xml:"-" yaml:"-"` + radarr *radarr.Radarr } -func (r *RadarrConfig) fix(timeout time.Duration) { +func (r *RadarrConfig) setup(timeout time.Duration) { r.radarr = radarr.New(r.Config) if r.Timeout.Duration == 0 { r.Timeout.Duration = timeout @@ -70,7 +72,8 @@ func radarrProfiles(r *http.Request) (int, interface{}) { func radarrCheckMovie(r *http.Request) (int, interface{}) { tmdbID, _ := strconv.ParseInt(mux.Vars(r)["tmdbid"], 10, 64) // Check for existing movie. - if m, err := getRadarr(r).GetMovie(tmdbID); err != nil { + m, err := getRadarr(r).GetMovie(tmdbID) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking movie: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", tmdbID, ErrExists) @@ -79,6 +82,17 @@ func radarrCheckMovie(r *http.Request) (int, interface{}) { return http.StatusOK, http.StatusText(http.StatusNotFound) } +func radarrGetMovie(r *http.Request) (int, interface{}) { + movieID, _ := strconv.ParseInt(mux.Vars(r)["movieid"], 10, 64) + + movie, err := getRadarr(r).GetMovieByID(movieID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("checking movie: %w", err) + } + + return http.StatusOK, movie +} + func radarrSearchMovie(r *http.Request) (int, interface{}) { // Get all movies movies, err := getRadarr(r).GetMovie(0) @@ -92,14 +106,18 @@ func radarrSearchMovie(r *http.Request) (int, interface{}) { for _, movie := range movies { if movieSearch(query, []string{movie.Title, movie.OriginalTitle}, movie.AlternateTitles) { returnMovies = append(returnMovies, map[string]interface{}{ - "id": movie.ID, - "title": movie.Title, - "cinemas": movie.InCinemas, - "status": movie.Status, - "exists": movie.HasFile, - "added": movie.Added, - "year": movie.Year, - "path": movie.Path, + "id": movie.ID, + "title": movie.Title, + "cinemas": movie.InCinemas, + "status": movie.Status, + "exists": movie.HasFile, + "added": movie.Added, + "year": movie.Year, + "path": movie.Path, + "tmdbId": movie.TmdbID, + "qualityProfileId": movie.QualityProfileID, + "monitored": movie.Monitored, + "minimumAvailability": movie.MinimumAvailability, }) } } @@ -123,18 +141,37 @@ func movieSearch(query string, titles []string, alts []*radarr.AlternativeTitle) return false } +func radarrUpdateMovie(r *http.Request) (int, interface{}) { + var movie radarr.Movie + // Extract payload and check for TMDB ID. + err := json.NewDecoder(r.Body).Decode(&movie) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + // Check for existing movie. + err = getRadarr(r).UpdateMovie(movie.ID, &movie) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating movie: %w", err) + } + + return http.StatusOK, "radarr seems to have worked" +} + func radarrAddMovie(r *http.Request) (int, interface{}) { - payload := &radarr.AddMovieInput{} + var payload radarr.AddMovieInput // Extract payload and check for TMDB ID. - if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) } else if payload.TmdbID == 0 { return http.StatusUnprocessableEntity, fmt.Errorf("0: %w", ErrNoTMDB) } - radar := getRadarr(r) + app := getRadarr(r) // Check for existing movie. - if m, err := radar.GetMovie(payload.TmdbID); err != nil { + m, err := app.GetMovie(payload.TmdbID) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking movie: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", payload.TmdbID, ErrExists) @@ -150,7 +187,7 @@ func radarrAddMovie(r *http.Request) (int, interface{}) { } // Add movie using fixed payload. - movie, err := radar.AddMovie(payload) + movie, err := app.AddMovie(&payload) if err != nil { return http.StatusInternalServerError, fmt.Errorf("adding movie: %w", err) } diff --git a/pkg/apps/readarr.go b/pkg/apps/readarr.go index bb0d1637f..7d115ec69 100644 --- a/pkg/apps/readarr.go +++ b/pkg/apps/readarr.go @@ -18,9 +18,14 @@ func (a *Apps) readarrHandlers() { a.HandleAPIpath(Readarr, "/add", readarrAddBook, "POST") a.HandleAPIpath(Readarr, "/search/{query}", readarrSearchBook, "GET") a.HandleAPIpath(Readarr, "/check/{grid:[0-9]+}", readarrCheckBook, "GET") + a.HandleAPIpath(Readarr, "/get/{bookid:[0-9]+}", readarrGetBook, "GET") + a.HandleAPIpath(Readarr, "/update", readarrUpdateBook, "PUT") a.HandleAPIpath(Readarr, "/metadataProfiles", readarrMetaProfiles, "GET") a.HandleAPIpath(Readarr, "/qualityProfiles", readarrProfiles, "GET") a.HandleAPIpath(Readarr, "/rootFolder", readarrRootFolders, "GET") + + a.HandleAPIpath(Readarr, "/author/{authorid:[0-9]+}", readarrGetAuthor, "GET") + a.HandleAPIpath(Readarr, "/updateauthor", readarrUpdateAuthor, "PUT") } // ReadarrConfig represents the input data for a Readarr server. @@ -29,15 +34,15 @@ type ReadarrConfig struct { readarr *readarr.Readarr } -func (r *ReadarrConfig) fix(timeout time.Duration) { +func (r *ReadarrConfig) setup(timeout time.Duration) { r.readarr = readarr.New(r.Config) if r.Timeout.Duration == 0 { r.Timeout.Duration = timeout } } +// Get folder list from Readarr. func readarrRootFolders(r *http.Request) (int, interface{}) { - // Get folder list from Readarr. folders, err := getReadarr(r).GetRootFolders() if err != nil { return http.StatusInternalServerError, fmt.Errorf("getting folders: %w", err) @@ -52,8 +57,8 @@ func readarrRootFolders(r *http.Request) (int, interface{}) { return http.StatusOK, p } +// Get the metadata profiles from readarr. func readarrMetaProfiles(r *http.Request) (int, interface{}) { - // Get the metadata profiles from readarr. profiles, err := getReadarr(r).GetMetadataProfiles() if err != nil { return http.StatusInternalServerError, fmt.Errorf("getting profiles: %w", err) @@ -68,8 +73,8 @@ func readarrMetaProfiles(r *http.Request) (int, interface{}) { return http.StatusOK, p } +// Get the profiles from readarr. func readarrProfiles(r *http.Request) (int, interface{}) { - // Get the profiles from readarr. profiles, err := getReadarr(r).GetQualityProfiles() if err != nil { return http.StatusInternalServerError, fmt.Errorf("getting profiles: %w", err) @@ -84,10 +89,12 @@ func readarrProfiles(r *http.Request) (int, interface{}) { return http.StatusOK, p } +// Check for existing book. func readarrCheckBook(r *http.Request) (int, interface{}) { grid, _ := strconv.ParseInt(mux.Vars(r)["grid"], 10, 64) - // Check for existing book. - if m, err := getReadarr(r).GetBook(grid); err != nil { + + m, err := getReadarr(r).GetBook(grid) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking book: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", grid, ErrExists) @@ -112,7 +119,7 @@ func readarrSearchBook(r *http.Request) (int, interface{}) { "title": book.Title, "release": book.ReleaseDate, "author": book.Author.AuthorName, - "authorId": book.Author.Ended, + "authorId": book.Author.ID, "overview": book.Overview, "ratings": book.Ratings.Value, "pages": book.PageCount, @@ -146,28 +153,84 @@ func bookSearch(query, title string, editions []*readarr.Edition) bool { return false } +func readarrGetBook(r *http.Request) (int, interface{}) { + bookID, _ := strconv.ParseInt(mux.Vars(r)["bookid"], 10, 64) + + book, err := getReadarr(r).GetBookByID(bookID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("checking book: %w", err) + } + + return http.StatusOK, book +} + +func readarrUpdateBook(r *http.Request) (int, interface{}) { + var book readarr.Book + + err := json.NewDecoder(r.Body).Decode(&book) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + err = getReadarr(r).UpdateBook(book.ID, &book) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating book: %w", err) + } + + return http.StatusOK, "readarr seems to have worked" +} + func readarrAddBook(r *http.Request) (int, interface{}) { payload := &readarr.AddBookInput{} - // Extract payload and check for TMDB ID. - if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + // Extract payload and check for GRID ID. + err := json.NewDecoder(r.Body).Decode(payload) + if err != nil { return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) } else if payload.ForeignBookID == 0 { return http.StatusUnprocessableEntity, fmt.Errorf("0: %w", ErrNoGRID) } - readar := getReadarr(r) + app := getReadarr(r) // Check for existing book. - if m, err := readar.GetBook(payload.ForeignBookID); err != nil { + m, err := app.GetBook(payload.ForeignBookID) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking book: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", payload.ForeignBookID, ErrExists) } // Add book using payload. - book, err := readar.AddBook(payload) + book, err := app.AddBook(payload) if err != nil { return http.StatusInternalServerError, fmt.Errorf("adding book: %w", err) } return http.StatusCreated, book } + +func readarrGetAuthor(r *http.Request) (int, interface{}) { + authorID, _ := strconv.ParseInt(mux.Vars(r)["authorid"], 10, 64) + + author, err := getReadarr(r).GetAuthorByID(authorID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("getting author: %w", err) + } + + return http.StatusOK, author +} + +func readarrUpdateAuthor(r *http.Request) (int, interface{}) { + var author readarr.Author + + err := json.NewDecoder(r.Body).Decode(&author) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + err = getReadarr(r).UpdateAuthor(author.ID, &author) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating author: %w", err) + } + + return http.StatusOK, "readarr seems to have worked" +} diff --git a/pkg/apps/sonarr.go b/pkg/apps/sonarr.go index 0d4808ad3..58251d2f2 100644 --- a/pkg/apps/sonarr.go +++ b/pkg/apps/sonarr.go @@ -18,6 +18,8 @@ func (a *Apps) sonarrHandlers() { a.HandleAPIpath(Sonarr, "/add", sonarrAddSeries, "POST") a.HandleAPIpath(Sonarr, "/check/{tvdbid:[0-9]+}", sonarrCheckSeries, "GET") a.HandleAPIpath(Sonarr, "/search/{query}", sonarrSearchSeries, "GET") + a.HandleAPIpath(Sonarr, "/get/{seriesid:[0-9]+}", sonarrGetSeries, "GET") + a.HandleAPIpath(Sonarr, "/update", sonarrUpdateSeries, "PUT") a.HandleAPIpath(Sonarr, "/qualityProfiles", sonarrProfiles, "GET") a.HandleAPIpath(Sonarr, "/languageProfiles", sonarrLangProfiles, "GET") a.HandleAPIpath(Sonarr, "/rootFolder", sonarrRootFolders, "GET") @@ -29,7 +31,7 @@ type SonarrConfig struct { sonarr *sonarr.Sonarr } -func (r *SonarrConfig) fix(timeout time.Duration) { +func (r *SonarrConfig) setup(timeout time.Duration) { r.sonarr = sonarr.New(r.Config) if r.Timeout.Duration == 0 { r.Timeout.Duration = timeout @@ -87,7 +89,8 @@ func sonarrLangProfiles(r *http.Request) (int, interface{}) { func sonarrCheckSeries(r *http.Request) (int, interface{}) { tvdbid, _ := strconv.ParseInt(mux.Vars(r)["tvdbid"], 10, 64) // Check for existing series. - if m, err := getSonarr(r).GetSeries(tvdbid); err != nil { + m, err := getSonarr(r).GetSeries(tvdbid) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking series: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", tvdbid, ErrExists) @@ -109,15 +112,22 @@ func sonarrSearchSeries(r *http.Request) (int, interface{}) { for _, s := range series { if seriesSearch(query, s.Title, s.AlternateTitles) { b := map[string]interface{}{ - "id": s.ID, - "title": s.Title, - "first": s.FirstAired, - "next": s.NextAiring, - "prev": s.PreviousAiring, - "added": s.Added, - "status": s.Status, - "exists": false, - "path": s.Path, + "id": s.ID, + "title": s.Title, + "first": s.FirstAired, + "next": s.NextAiring, + "prev": s.PreviousAiring, + "added": s.Added, + "status": s.Status, + "path": s.Path, + "tvdbId": s.TvdbID, + "monitored": s.Monitored, + "qualityProfileId": s.QualityProfileID, + "seasonFolder": s.SeasonFolder, + "seriesType": s.SeriesType, + "languageProfileId": s.LanguageProfileID, + "seasons": s.Seasons, + "exists": false, } if s.Statistics != nil { @@ -145,24 +155,53 @@ func seriesSearch(query, title string, alts []*sonarr.AlternateTitle) bool { return false } +func sonarrGetSeries(r *http.Request) (int, interface{}) { + seriesID, _ := strconv.ParseInt(mux.Vars(r)["seriesid"], 10, 64) + + series, err := getSonarr(r).GetSeriesByID(seriesID) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("checking series: %w", err) + } + + return http.StatusOK, series +} + +func sonarrUpdateSeries(r *http.Request) (int, interface{}) { + var series sonarr.Series + + err := json.NewDecoder(r.Body).Decode(&series) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + err = getSonarr(r).UpdateSeries(series.ID, &series) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("updating series: %w", err) + } + + return http.StatusOK, "sonarr seems to have worked" +} + func sonarrAddSeries(r *http.Request) (int, interface{}) { - payload := &sonarr.AddSeriesInput{} - // Extract payload and check for TMDB ID. - if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + var payload sonarr.AddSeriesInput + // Extract payload and check for TVDB ID. + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) } else if payload.TvdbID == 0 { return http.StatusUnprocessableEntity, fmt.Errorf("0: %w", ErrNoTMDB) } - sonar := getSonarr(r) + app := getSonarr(r) // Check for existing series. - if m, err := sonar.GetSeries(payload.TvdbID); err != nil { + m, err := app.GetSeries(payload.TvdbID) + if err != nil { return http.StatusServiceUnavailable, fmt.Errorf("checking series: %w", err) } else if len(m) > 0 { return http.StatusConflict, fmt.Errorf("%d: %w", payload.TvdbID, ErrExists) } - series, err := sonar.AddSeries(payload) + series, err := app.AddSeries(&payload) if err != nil { return http.StatusInternalServerError, fmt.Errorf("adding series: %w", err) } diff --git a/pkg/client/handlers.go b/pkg/client/handlers.go index 989090422..f9757ab0c 100644 --- a/pkg/client/handlers.go +++ b/pkg/client/handlers.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "net" "net/http" - "path" "strings" "time" @@ -20,15 +19,10 @@ type allowedIPs []*net.IPNet // internalHandlers initializes "special" internal API paths. func (c *Client) internalHandlers() { - // GET /api/status (w/o key) - c.Config.Router.Handle(path.Join("/", c.Config.URLBase, "api", "status"), - http.HandlerFunc(c.statusResponse)).Methods("GET", "HEAD") - // PUT /api/info (w/ key) + c.Config.HandleAPIpath("", "status", c.statusResponse, "GET", "HEAD") + c.Config.HandleAPIpath("", "version", c.versionResponse, "GET", "HEAD") c.Config.HandleAPIpath("", "info", c.updateInfo, "PUT") - // PUT /api/info/alert (w/ key) c.Config.HandleAPIpath("", "info/alert", c.updateInfoAlert, "PUT") - // GET /api/version (w/ key) - c.Config.HandleAPIpath("", "version", c.versionResponse, "GET", "HEAD") // Initialize internal-only paths. c.Config.Router.Handle("/favicon.ico", http.HandlerFunc(c.favIcon)) // built-in icon. @@ -98,6 +92,11 @@ func (c *Client) versionResponse(r *http.Request) (int, interface{}) { "branch": version.Branch, "go_version": version.GoVersion, "revision": version.Revision, + "gui": ui.HasGUI(), + "num_lidarr": len(c.Config.Apps.Lidarr), + "num_sonarr": len(c.Config.Apps.Sonarr), + "num_radarr": len(c.Config.Apps.Radarr), + "num_readarr": len(c.Config.Apps.Readarr), } } @@ -106,9 +105,8 @@ func (c *Client) notFound(w http.ResponseWriter, r *http.Request) { c.Config.Respond(w, http.StatusNotFound, "Check your request parameters and try again.", time.Now()) } -// statusResponse is the handler for /api/status. -func (c *Client) statusResponse(w http.ResponseWriter, r *http.Request) { - c.Config.Respond(w, http.StatusOK, c.Flags.Name()+" alive!", time.Now()) +func (c *Client) statusResponse(r *http.Request) (int, interface{}) { + return http.StatusOK, c.Flags.Name() + " alive!" } // slash is the handler for /. diff --git a/pkg/client/init.go b/pkg/client/init.go index 366c5714d..91d4cc164 100644 --- a/pkg/client/init.go +++ b/pkg/client/init.go @@ -12,7 +12,8 @@ import ( const helpLink = "GoLift Discord: https://golift.io/discord" -// InitStartup prints info about our startup config. This runs once on startup. +// PrintStartupInfo prints info about our startup config. +// This runs once on startup, and again during reloads. func (c *Client) PrintStartupInfo() { c.Printf("==> %s <==", helpLink) c.Print("==> Startup Settings <==") diff --git a/pkg/client/tray.go b/pkg/client/tray.go index 8ee5665d4..84d1a0401 100644 --- a/pkg/client/tray.go +++ b/pkg/client/tray.go @@ -49,6 +49,7 @@ func (c *Client) readyTray() { c.menu["alert"].Hide() // currently unused. c.menu["update"].Hide() + go c.watchKillerChannels() c.StartWebServer() c.watchGuiChannels() } @@ -86,9 +87,25 @@ func (c *Client) makeChannels() { c.menu["exit"] = ui.WrapMenu(systray.AddMenuItem("Quit", "Exit "+c.Flags.Name())) } -func (c *Client) watchGuiChannels() { - defer systray.Quit() // this kills the app. +func (c *Client) watchKillerChannels() { + defer systray.Quit() // this kills the app + + for { + select { + case sigc := <-c.sighup: + c.Printf("Caught Signal: %v (reloading configuration)", sigc) + c.reloadConfiguration() + case sigc := <-c.sigkil: + c.Errorf("Need help? %s\n=====> Exiting! Caught Signal: %v", helpLink, sigc) + return + case <-c.menu["exit"].Clicked(): + c.Errorf("Need help? %s\n=====> Exiting! User Requested", helpLink) + return + } + } +} +func (c *Client) watchGuiChannels() { for { // nolint:errcheck select { @@ -124,17 +141,8 @@ func (c *Client) watchGuiChannels() { case <-c.menu["update"].Clicked(): ui.OpenURL("https://github.com/Go-Lift-TV/discordnotifier-client/releases") case <-c.menu["dninfo"].Clicked(): - ui.Info(Title, "INFO: "+c.info) c.menu["dninfo"].Hide() - case sigc := <-c.sighup: - c.Printf("Caught Signal: %v (reloading configuration)", sigc) - c.reloadConfiguration() - case sigc := <-c.sigkil: - c.Errorf("Need help? %s\n=====> Exiting! Caught Signal: %v", helpLink, sigc) - return - case <-c.menu["exit"].Clicked(): - c.Errorf("Need help? %s\n=====> Exiting! User Requested", helpLink) - return + ui.Info(Title, "INFO: "+c.info) } } } diff --git a/scripts/after-install.sh b/scripts/after-install.sh index 13df0f1b2..34a7ebca6 100644 --- a/scripts/after-install.sh +++ b/scripts/after-install.sh @@ -1,13 +1,25 @@ -#!/bin/bash +#!/bin/sh -# This file is used by deb and rpm packages. +# This file is used by deb, rpm and BSD packages. # FPM adds this as the after-install script. # Edit this file as needed for your application. # This file is only installed if FORMULA is set to service. -# Make a user and group for this app, but only if it does not already exist. -id {{BINARY}} >/dev/null 2>&1 || \ - useradd --system --user-group --no-create-home --home-dir /tmp --shell /bin/false {{BINARY}} +OS="$(uname -s)" + +if [ "${OS}" == "Linux" ]; then + # Make a user and group for this app, but only if it does not already exist. + id {{BINARY}} >/dev/null 2>&1 || \ + useradd --system --user-group --no-create-home --home-dir /tmp --shell /bin/false {{BINARY}} +elif [ "${OS}" == "OpenBSD" ]; then + id {{BINARY}} >/dev/null 2>&1 || \ + useradd -g =uid -d /tmp -s /bin/false {{BINARY}} +elif [ "${OS}" == "FreeBSD" ]; then + id {{BINARY}} >/dev/null 2>&1 || \ + pw useradd {{BINARY}} -d /tmp -w no -s /bin/false +else + echo "Unknown OS: ${OS}, this app may not work." +fi if [ -x "/bin/systemctl" ]; then # Reload and restart - this starts the application as user nobody.