Repository: relvacode/storm Branch: master Commit: 7bfaa6172c02 Files: 133 Total size: 154.7 KB Directory structure: gitextract_e9godol_/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api.go ├── cmd/ │ └── storm/ │ └── server.go ├── docker-compose/ │ ├── auth │ ├── core.conf │ └── docker-compose.yml ├── error.go ├── frontend/ │ ├── .browserslistrc │ ├── .editorconfig │ ├── angular.json │ ├── e2e/ │ │ ├── protractor.conf.js │ │ ├── src/ │ │ │ ├── app.e2e-spec.ts │ │ │ └── app.po.ts │ │ └── tsconfig.json │ ├── karma.conf.js │ ├── package.json │ ├── src/ │ │ ├── app/ │ │ │ ├── api.service.spec.ts │ │ │ ├── api.service.ts │ │ │ ├── app.component.html │ │ │ ├── app.component.scss │ │ │ ├── app.component.spec.ts │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ ├── components/ │ │ │ │ ├── activity-marker/ │ │ │ │ │ ├── activity-marker.component.html │ │ │ │ │ ├── activity-marker.component.scss │ │ │ │ │ ├── activity-marker.component.spec.ts │ │ │ │ │ └── activity-marker.component.ts │ │ │ │ ├── add-torrent-menu/ │ │ │ │ │ ├── add-torrent-config/ │ │ │ │ │ │ ├── add-torrent-config.component.html │ │ │ │ │ │ ├── add-torrent-config.component.scss │ │ │ │ │ │ ├── add-torrent-config.component.spec.ts │ │ │ │ │ │ └── add-torrent-config.component.ts │ │ │ │ │ ├── add-torrent-dialog-component.ts │ │ │ │ │ ├── add-torrent-file-input/ │ │ │ │ │ │ ├── add-torrent-file-input.component.html │ │ │ │ │ │ ├── add-torrent-file-input.component.scss │ │ │ │ │ │ ├── add-torrent-file-input.component.spec.ts │ │ │ │ │ │ └── add-torrent-file-input.component.ts │ │ │ │ │ ├── add-torrent-magnet-input/ │ │ │ │ │ │ ├── add-torrent-magnet-input.component.html │ │ │ │ │ │ ├── add-torrent-magnet-input.component.scss │ │ │ │ │ │ ├── add-torrent-magnet-input.component.spec.ts │ │ │ │ │ │ └── add-torrent-magnet-input.component.ts │ │ │ │ │ ├── add-torrent-menu.component.html │ │ │ │ │ ├── add-torrent-menu.component.scss │ │ │ │ │ ├── add-torrent-menu.component.spec.ts │ │ │ │ │ ├── add-torrent-menu.component.ts │ │ │ │ │ └── add-torrent-url-input/ │ │ │ │ │ ├── add-torrent-url-input.component.html │ │ │ │ │ ├── add-torrent-url-input.component.scss │ │ │ │ │ ├── add-torrent-url-input.component.spec.ts │ │ │ │ │ └── add-torrent-url-input.component.ts │ │ │ │ ├── api-key-dialog/ │ │ │ │ │ ├── api-key-dialog.component.html │ │ │ │ │ ├── api-key-dialog.component.scss │ │ │ │ │ ├── api-key-dialog.component.spec.ts │ │ │ │ │ └── api-key-dialog.component.ts │ │ │ │ ├── breakpoint-overlay/ │ │ │ │ │ ├── breakpoint-overlay.component.html │ │ │ │ │ ├── breakpoint-overlay.component.scss │ │ │ │ │ ├── breakpoint-overlay.component.spec.ts │ │ │ │ │ └── breakpoint-overlay.component.ts │ │ │ │ ├── connectivity-status/ │ │ │ │ │ ├── connectivity-status.component.html │ │ │ │ │ ├── connectivity-status.component.scss │ │ │ │ │ ├── connectivity-status.component.spec.ts │ │ │ │ │ └── connectivity-status.component.ts │ │ │ │ ├── delete-torrent-overlay/ │ │ │ │ │ ├── delete-torrent-overlay.component.html │ │ │ │ │ ├── delete-torrent-overlay.component.scss │ │ │ │ │ ├── delete-torrent-overlay.component.spec.ts │ │ │ │ │ └── delete-torrent-overlay.component.ts │ │ │ │ ├── plugin-enable/ │ │ │ │ │ ├── plugin-enable.component.html │ │ │ │ │ ├── plugin-enable.component.scss │ │ │ │ │ ├── plugin-enable.component.spec.ts │ │ │ │ │ └── plugin-enable.component.ts │ │ │ │ ├── session-status/ │ │ │ │ │ ├── session-status.component.html │ │ │ │ │ ├── session-status.component.scss │ │ │ │ │ ├── session-status.component.spec.ts │ │ │ │ │ └── session-status.component.ts │ │ │ │ ├── torrent/ │ │ │ │ │ ├── torrent.component.html │ │ │ │ │ ├── torrent.component.scss │ │ │ │ │ ├── torrent.component.spec.ts │ │ │ │ │ └── torrent.component.ts │ │ │ │ ├── torrent-details-dialog/ │ │ │ │ │ ├── torrent-details-dialog.component.html │ │ │ │ │ ├── torrent-details-dialog.component.scss │ │ │ │ │ ├── torrent-details-dialog.component.spec.ts │ │ │ │ │ └── torrent-details-dialog.component.ts │ │ │ │ ├── torrent-edit-label-dialog/ │ │ │ │ │ ├── torrent-edit-label-dialog.component.html │ │ │ │ │ ├── torrent-edit-label-dialog.component.scss │ │ │ │ │ ├── torrent-edit-label-dialog.component.spec.ts │ │ │ │ │ └── torrent-edit-label-dialog.component.ts │ │ │ │ ├── torrent-label/ │ │ │ │ │ ├── torrent-label.component.html │ │ │ │ │ ├── torrent-label.component.scss │ │ │ │ │ ├── torrent-label.component.spec.ts │ │ │ │ │ └── torrent-label.component.ts │ │ │ │ ├── torrent-search/ │ │ │ │ │ ├── torrent-search.component.html │ │ │ │ │ ├── torrent-search.component.scss │ │ │ │ │ ├── torrent-search.component.spec.ts │ │ │ │ │ └── torrent-search.component.ts │ │ │ │ └── torrent-state/ │ │ │ │ ├── torrent-state.component.html │ │ │ │ ├── torrent-state.component.scss │ │ │ │ ├── torrent-state.component.spec.ts │ │ │ │ └── torrent-state.component.ts │ │ │ ├── environment.ts │ │ │ ├── focus.service.spec.ts │ │ │ ├── focus.service.ts │ │ │ ├── order-by.pipe.spec.ts │ │ │ ├── order-by.pipe.ts │ │ │ ├── torrent-search.pipe.spec.ts │ │ │ └── torrent-search.pipe.ts │ │ ├── assets/ │ │ │ └── .gitkeep │ │ ├── environments/ │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── icons.scss │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.scss │ │ └── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── go.mod ├── go.sum ├── http.go ├── logo.svgz ├── methods.go ├── methods_labels.go ├── methods_plugins.go ├── methods_view.go ├── pool.go ├── request.go ├── response.go └── static.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ frontend/node_modules frontend/.angular ================================================ FILE: .github/workflows/release.yaml ================================================ on: release: types: [ created ] jobs: compile-frontend: name: Compile Frontend runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: '16' cache: npm cache-dependency-path: frontend/package-lock.json - run: | cd frontend npm install npm run build:prod - uses: actions/upload-artifact@v2 with: name: frontend-dist path: frontend/dist/ if-no-files-found: error go-binary-release: name: Release Go Binary runs-on: ubuntu-latest needs: compile-frontend strategy: matrix: goos: [ linux, windows, darwin ] goarch: [ amd64, arm, arm64 ] exclude: - goarch: arm goos: darwin - goarch: arm64 goos: windows - goarch: arm goos: windows steps: - uses: actions/checkout@v2 - uses: actions/download-artifact@v2 with: name: frontend-dist path: frontend/dist - uses: wangyoucao577/go-release-action@v1.22 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} asset_name: storm-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} project_path: "./cmd/storm" binary_name: "storm" extra_files: LICENSE README.md sha256sum: true md5sum: false overwrite: true docker-image: name: Build Docker Image runs-on: ubuntu-latest permissions: packages: write contents: read needs: compile-frontend steps: - uses: actions/checkout@v2 - uses: actions/download-artifact@v2 with: name: frontend-dist path: frontend/dist - uses: docker/setup-qemu-action@v1 - uses: docker/setup-buildx-action@v1 - uses: docker/login-action@v1 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v2 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ github.ref_name }} platforms: | linux/amd64 linux/arm64 linux/arm/v7 ================================================ FILE: .gitignore ================================================ .idea .angular cmd/storm/storm frontend/node_modules frontend/.idea frontend/dist ================================================ FILE: Dockerfile ================================================ FROM --platform=${BUILDPLATFORM} golang:alpine as compiler ARG TARGETOS ARG TARGETARCH ENV CGO_ENABLED=0 WORKDIR /go/src/storm COPY . . RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" github.com/relvacode/storm/cmd/storm FROM --platform=${TARGETPLATFORM} alpine COPY --from=compiler /go/src/storm/storm /usr/bin/storm ENTRYPOINT ["/usr/bin/storm"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Jason Kingsbury Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

> You probably have a whole [media stack](https://github.com/relvacode/mediastack) for managing your _legitimate_ media files remotely. > > Sometimes though, downloads can get stuck or you want to add something manually to your Torrent client. Deluge's WebUI whilst powerful is pretty much useless on mobile devices. > > Introducing __Storm__ > > A slick remote interface for Deluge that fully supports mobile devices (including as a home-screen app)  

#### Usage ``` docker run --name storm \ --network deluge \ -p 8221:8221 \ -e DELUGE_RPC_HOSTNAME=deluge \ -e DELUGE_RPC_USERNAME=username \ -e DELUGE_RPC_PASSWORD=password \ ghcr.io/relvacode/storm ``` The recommended way to run Storm is with a Docker image. You'll need a Deluge container running with a valid [auth configuration](https://dev.deluge-torrent.org/wiki/UserGuide/Authentication). Storm needs a way to contact the Deluge RPC daemon so it's best that you create a [Docker network](https://docs.docker.com/engine/tutorials/networkingcontainers/) and attach the Storm container to that network. Once that's setup you'll need to configure Deluge to allow remote RPC connections: Open up `core.conf` in your Deluge configuration folder and set ``` "allow_remote": true ``` Then you can use the following environment variables to configure Storm | Environment | Description | | ----------- | ----------- | | `DELUGE_RPC_HOSTNAME` | The Deluge RPC hostname | | `DELUGE_RPC_PORT` | The Deluge RPC port | | `DELUGE_RPC_USERNAME` | The username from Deluge auth | | `DELUGE_RPC_PASSWORD` | The password from Deluge auth | | `DELUGE_RPC_VERSION` | `v1` or `v2` depending on your Deluge version | | `STORM_API_KEY` | Enable authentication for the Storm API | | `STORM_BASE_PATH` | Set the base URL path. Defaults to `/` | ##### Security By default, Storm does not authenticate requests made to the API. When serving Storm over the public internet you should ensure access to your Deluge daemon is properly secured. Storm comes with a simple built-in authentication mechanism which can be enabled with the environment variable `STORM_API_KEY` or the command-line option `--api-key`. Set this to a reasonably secure password. Any requests made to Storm must now provide the API key in the request. You should also seriously consider the use of HTTPS over the internet, with services like LetsEncrypt it's relatively easy to get a valid SSL certificate for free. ##### Deluge Version Deluge has a different interface between versions 1 and 2. You must set `DELUGE_RPC_VERSION` to either `v1` or `v2` based on the version you have installed. Storm defaults to `v1`. Note that in version 2, different RPC users are not able to see torrents created by another user [(#38)](https://github.com/relvacode/storm/issues/38). If you're using multiple Deluge clients (such as the vanilla Web UI, or Sonarr, etc) you should make sure they're all using the same Deluge RPC account to connect to Deluge. #### Development The application is split into two parts, the frontend Angular code and the backend Go API adapter. The backend API is presented as an HTTP REST api which talks directly to the Deluge RPC daemon. It also has the frontend code embedded directly into the binary so the application can be distributed as a single binary. To start a development environment you must first install the node modules ``` cd frontend npm install ``` Then start the Angular build server in watch mode. This will output the frontend distributable into `frontend/dist` and watch for changes. ``` cd frontend npm run build -- --watch --configuration=development ``` In a separate terminal you can now start the main Storm binary in development mode. In development mode, instead of serving the frontend from the binary embedded source it will instead serve directly from the filesystem. ``` go run github.com/relvacode/storm/cmd/storm --listen=127.0.0.1:8221 --dev-mode [OPTIONS...] ``` ================================================ FILE: api.go ================================================ package storm import ( "crypto/subtle" "encoding/base64" "fmt" deluge "github.com/gdm85/go-libdeluge" "github.com/gorilla/mux" "github.com/spf13/afero" "go.uber.org/zap" "html/template" "io" "io/fs" "net/http" "os" "strings" "time" ) const ( ApiAuthCookieName = "storm-api-key" ) func appendSuffix(s, suffix string) string { if strings.HasSuffix(s, suffix) { return s } return fmt.Sprint(s, suffix) } func New(log *zap.Logger, pool *ConnectionPool, pathPrefix string, apiKey string, development bool) *Api { api := &Api{ pool: pool, pathPrefix: strings.TrimSuffix(pathPrefix, "/"), apiKey: apiKey, log: log, router: mux.NewRouter(), } api.router.NotFoundHandler = api.httpNotFound() api.bind(development) return api } type Api struct { pool *ConnectionPool pathPrefix string apiKey string log *zap.Logger router *mux.Router } func (api *Api) DelugeHandler(f DelugeMethod) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { _ = Handle(rw, r, func(r *http.Request) (interface{}, error) { conn, err := api.pool.Get(r.Context()) if err != nil { return nil, err } ret, err := f(conn, r) api.pool.Put(conn) switch t := err.(type) { case deluge.RPCError: err = RPCError{ ExceptionType: t.ExceptionType, ExceptionMessage: t.ExceptionMessage, } } return ret, err }) } } func (api *Api) ServeHTTP(rw http.ResponseWriter, r *http.Request) { api.router.ServeHTTP(rw, r) } // httpNotFound implements http.Handler which returns a not found error message. func (api *Api) httpNotFound() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { _ = Handle(rw, r, func(r *http.Request) (interface{}, error) { return nil, &Error{ Message: "Not found", Code: http.StatusNotFound, } }) }) } func (api *Api) templateContext() interface{} { path := appendSuffix(api.pathPrefix, "/") return map[string]string{ "BasePath": path, "BaseAPIPath": fmt.Sprint(path, "api/"), } } // renderTemplate takes the contents of a go html/template found at source `name` // and renders it back to the file system using templateContext(). func (api *Api) renderTemplate(fs afero.Fs, name string) { log := api.log.With(zap.String("template", name)) f, err := fs.Open(name) if err != nil { log.Error("failed to open template for rendering", zap.Error(err)) return } templateSource, err := io.ReadAll(f) if err != nil { log.Error("failed to read template", zap.Error(err)) return } _ = f.Close() t, err := template.New(name).Parse(string(templateSource)) if err != nil { log.Error("failed to parse template", zap.Error(err)) return } w, err := fs.OpenFile(name, os.O_WRONLY|os.O_TRUNC, os.FileMode(0600)) if err != nil { log.Error("failed to open template file for writing", zap.Error(err)) return } err = t.Execute(w, api.templateContext()) if err != nil { log.Error("failed to render template", zap.Error(err)) return } err = w.Close() if err != nil { log.Error("failed to close rendered template", zap.Error(err)) return } } func (api *Api) bindStatic(router *mux.Router, development bool) { var static, _ = fs.Sub(Static, "frontend/dist") if development { static = os.DirFS("./frontend/dist") } var ( mem = afero.NewMemMapFs() ufs = afero.NewCopyOnWriteFs(&afero.FromIOFS{ FS: static, }, mem) ) api.renderTemplate(ufs, "index.html") fileServer := http.FileServer(afero.NewHttpFs(ufs).Dir("")) if api.pathPrefix != "" { fileServer = http.StripPrefix(api.pathPrefix, fileServer) } router.Methods(http.MethodGet).Handler(fileServer) } // keyFromRequest attempts to locate the API key from the incoming request. // It looks for the key using these methods in the following order: // - The password component of a Basic auth header // - The cookie value ApiAuthCookieName as a base64 encoded value func (api *Api) keyFromRequest(r *http.Request) (string, bool) { _, password, ok := r.BasicAuth() if ok { return password, false } fromCookie, err := r.Cookie(ApiAuthCookieName) if err != nil { return "", false } fromCookieDecoded, _ := base64.StdEncoding.DecodeString(fromCookie.Value) return string(fromCookieDecoded), true } // logForRequest takes a WrappedResponse and an incoming HTTP request and logs it func (api *Api) logForRequest(rw *WrappedResponse, r *http.Request) { logger := api.log.With( zap.String("Method", r.Method), zap.String("URL", r.URL.String()), zap.String("RemoteAddr", r.RemoteAddr), zap.Time("Time", rw.Started()), zap.Int("StatusCode", rw.code), zap.Int("ResponseSize", rw.Len()), zap.Duration("Duration", rw.Duration()), ) var logLevelFunc = logger.Info if rw.error != nil { // Do not log notification errors httpError, ok := rw.error.(HTTPError) if !ok || httpError.StatusCode() >= 400 { logLevelFunc = logger.With(zap.Error(rw.error)).Error } } logLevelFunc(http.StatusText(rw.code)) } func (api *Api) httpMiddlewareLog(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { wr := WrapResponse(rw) next.ServeHTTP(wr, r) api.logForRequest(wr, r) }) } func (api *Api) httpMiddlewareAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { apiKey, fromCookie := api.keyFromRequest(r) if apiKey == "" { SendError(rw, &Error{ Code: http.StatusUnauthorized, Message: "No authentication provided in request", }) return } ok := subtle.ConstantTimeCompare([]byte(api.apiKey), []byte(apiKey)) == 1 if !ok { SendError(rw, &Error{ Code: http.StatusUnauthorized, Message: "Incorrect API key", }) return } // If the request didn't originate from a cookie, then set the cookie if !fromCookie { http.SetCookie(rw, &http.Cookie{ Name: ApiAuthCookieName, Value: base64.StdEncoding.EncodeToString([]byte(apiKey)), Path: fmt.Sprintf("%s/api", api.pathPrefix), Expires: time.Now().Add(time.Hour * 24 * 365), SameSite: http.SameSiteStrictMode, HttpOnly: true, }) } next.ServeHTTP(rw, r) }) } func (api *Api) bind(development bool) { primaryRouter := api.router if api.pathPrefix != "" { primaryRouter = api.router.PathPrefix(api.pathPrefix).Subrouter() } router := primaryRouter.PathPrefix("/api").Subrouter() router.NotFoundHandler = api.httpNotFound() // CORS router.Methods(http.MethodOptions).HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { rw.WriteHeader(http.StatusOK) }) apiRouter := router.NewRoute().Subrouter() apiRouter.Use(api.httpMiddlewareLog) // Enable API level authentication if api.apiKey != "" { apiRouter.Use(api.httpMiddlewareAuthenticate) } apiRouter. Methods(http.MethodGet). Path("/ping"). HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) }) apiRouter. Methods(http.MethodGet). Path("/session"). HandlerFunc(api.DelugeHandler(httpGetSessionStatus)) apiRouter. Methods(http.MethodGet). Path("/disk/free"). HandlerFunc(api.DelugeHandler(httpGetFreeSpace)) apiRouter. Methods(http.MethodGet). Path("/view"). HandlerFunc(api.DelugeHandler(httpViewUpdate)) apiRouter. Methods(http.MethodGet). Path("/plugins"). HandlerFunc(api.DelugeHandler(httpGetPlugins)) apiRouter. Methods(http.MethodPost). Path("/plugins/{id}"). HandlerFunc(api.DelugeHandler(httpEnablePlugin)) apiRouter. Methods(http.MethodDelete). Path("/plugins/{id}"). HandlerFunc(api.DelugeHandler(httpDisablePlugin)) apiRouter. Methods(http.MethodGet). Path("/torrents"). HandlerFunc(api.DelugeHandler(httpTorrentsStatus)) apiRouter. Methods(http.MethodPost). Path("/torrents"). HandlerFunc(api.DelugeHandler(httpAddTorrent)) apiRouter. Methods(http.MethodDelete). Path("/torrents"). HandlerFunc(api.DelugeHandler(httpDeleteTorrents)) apiRouter. Methods(http.MethodPost). Path("/torrents/pause"). HandlerFunc(api.DelugeHandler(httpPauseTorrents)) apiRouter. Methods(http.MethodPost). Path("/torrents/resume"). HandlerFunc(api.DelugeHandler(httpResumeTorrents)) apiRouter. Methods(http.MethodGet). Path("/torrent/{id}"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpTorrentStatus))) apiRouter. Methods(http.MethodDelete). Path("/torrent/{id}"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpDeleteTorrent))) apiRouter. Methods(http.MethodPut). Path("/torrent/{id}"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentOptions))) apiRouter. Methods(http.MethodPost). Path("/torrent/{id}/pause"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpPauseTorrent))) apiRouter. Methods(http.MethodPost). Path("/torrent/{id}/resume"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpResumeTorrent))) apiRouter. Methods(http.MethodGet). Path("/labels"). HandlerFunc(api.DelugeHandler(httpLabels)) apiRouter. Methods(http.MethodPost). Path("/labels/{id}"). HandlerFunc(api.DelugeHandler(httpCreateLabel)) apiRouter. Methods(http.MethodDelete). Path("/labels/{id}"). HandlerFunc(api.DelugeHandler(httpDeleteLabel)) apiRouter. Methods(http.MethodGet). Path("/torrents/labels"). HandlerFunc(api.DelugeHandler(httpTorrentsLabels)) apiRouter. Methods(http.MethodPost). Path("/torrent/{id}/label"). HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentLabel))) // Static files api.bindStatic(primaryRouter, development) } ================================================ FILE: cmd/storm/server.go ================================================ package main import ( "context" "fmt" deluge "github.com/gdm85/go-libdeluge" "github.com/jessevdk/go-flags" storm "github.com/relvacode/storm" "go.uber.org/zap" "net/http" "os" "os/signal" "strings" "syscall" "time" ) func signalContext(ctx context.Context) context.Context { sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) xCtx, cancel := context.WithCancel(ctx) go func() { select { case <-ctx.Done(): case <-sig: cancel() } signal.Stop(sig) }() return xCtx } type Duration struct { Duration time.Duration } func (dur *Duration) UnmarshalFlag(value string) (err error) { dur.Duration, err = time.ParseDuration(value) return } type Path string func (p *Path) UnmarshalFlag(value string) error { if strings.HasSuffix(value, "/") { value = strings.TrimSuffix(value, "/") } if !strings.HasPrefix(value, "/") { value = fmt.Sprint("/", value) } *p = Path(value) return nil } type ServerOptions struct { Listen string `short:"l" long:"listen" default:":8221" env:"LISTEN_ADDR" description:"The address for the HTTP server"` LogStyle string `long:"log-style" choice:"production" choice:"console" default:"console" env:"LOGGING_STYLE" description:"The style of log messages"` BasePath *Path `long:"base-path" required:"true" default:"/" env:"STORM_BASE_PATH" description:"Respond to requests from this base URL path"` ApiKey string `long:"api-key" env:"STORM_API_KEY" description:"Set the password required to access the API (enables authentication)"` DevelopmentMode bool `long:"dev-mode" env:"DEV_MODE" description:"Run in development mode"` } func (options *ServerOptions) Logger() (*zap.Logger, error) { var cfg zap.Config switch options.LogStyle { case "production": cfg = zap.NewProductionConfig() case "console": cfg = zap.NewDevelopmentConfig() default: panic("Invalid LogStyle choice") } cfg.DisableStacktrace = true return cfg.Build() } func (options *ServerOptions) RunHandler(ctx context.Context, log *zap.Logger, handler http.Handler) error { var ( errors = make(chan error, 1) server = &http.Server{ Addr: options.Listen, Handler: handler, } ) go func() { errors <- server.ListenAndServe() }() log.Info(fmt.Sprintf("Ready to serve HTTP connections on %s%s", options.Listen, *options.BasePath)) defer close(errors) select { case <-ctx.Done(): log.Error("Interrupt signal received. Gracefully shutting down...") timeout, cancel := context.WithTimeout(context.Background(), time.Minute) _ = server.Shutdown(timeout) cancel() case err := <-errors: return err } return <-errors } type DelugeOptions struct { Version string `long:"deluge-version" choice:"v1" choice:"v2" default:"v1" env:"DELUGE_RPC_VERSION" description:"The Deluge RPC version"` Hostname string `short:"H" long:"hostname" required:"true" env:"DELUGE_RPC_HOSTNAME" description:"The Deluge RPC hostname"` Port uint `short:"P" long:"port" default:"58846" env:"DELUGE_RPC_PORT" description:"The Deluge RPC port"` Username string `short:"u" long:"username" env:"DELUGE_RPC_USERNAME" description:"The Deluge RPC username"` Password string `short:"p" long:"password" env:"DELUGE_RPC_PASSWORD" description:"The Deluge RPC password"` MaxConnections int `long:"max-connections" env:"POOL_MAX_CONNECTIONS" required:"true" default:"5" description:"Maximum concurrent Deluge RPC connections"` IdleTime *Duration `long:"idle-time" env:"POOL_IDLE_TIME" required:"true" default:"30s" description:"Close idle Deluge RPC connections after this duration"` } func (options *DelugeOptions) Client() storm.DelugeProvider { var settings = deluge.Settings{ Hostname: options.Hostname, Port: options.Port, Login: options.Username, Password: options.Password, ReadWriteTimeout: time.Minute * 5, } return func() deluge.DelugeClient { switch options.Version { case "v1": return deluge.NewV1(settings) case "v2": return deluge.NewV2(settings) default: panic("Invalid Version choice") } } } func (options *DelugeOptions) Pool(log *zap.Logger) *storm.ConnectionPool { return storm.NewConnectionPool(log, options.MaxConnections, options.IdleTime.Duration, options.Client()) } type Options struct { ServerOptions DelugeOptions } func Main() error { var options Options var parser = flags.NewParser(&options, flags.HelpFlag) _, err := parser.Parse() if err != nil { return err } log, err := (&options.ServerOptions).Logger() if err != nil { return err } defer log.Sync() ctx := signalContext(context.Background()) pool := (&options.DelugeOptions).Pool(log.Named("pool")) defer pool.Close() if options.DevelopmentMode { log.Info("Running in development mode") } var ( apiLog = log.Named("api") api = storm.New(apiLog, pool, (string)(*options.BasePath), options.ServerOptions.ApiKey, options.DevelopmentMode) ) return (&options.ServerOptions).RunHandler(ctx, apiLog, api) } func main() { err := Main() if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ================================================ FILE: docker-compose/auth ================================================ localclient:deluge:10 ================================================ FILE: docker-compose/core.conf ================================================ { "file": 1, "format": 1 }{ "add_paused": false, "allow_remote": true, "auto_manage_prefer_seeds": false, "auto_managed": true, "cache_expiry": 60, "cache_size": 512, "copy_torrent_file": false, "daemon_port": 58846, "del_copy_torrent_file": false, "dht": true, "dont_count_slow_torrents": false, "download_location": "/root/Downloads", "download_location_paths_list": [], "enabled_plugins": [ "Label" ], "enc_in_policy": 1, "enc_level": 2, "enc_out_policy": 1, "geoip_db_location": "/usr/share/GeoIP/GeoIP.dat", "ignore_limits_on_local_network": true, "info_sent": 0.0, "listen_interface": "", "listen_ports": [ 6881, 6891 ], "listen_random_port": 53703, "listen_reuse_port": true, "listen_use_sys_port": false, "lsd": true, "max_active_downloading": 3, "max_active_limit": 8, "max_active_seeding": 5, "max_connections_global": 200, "max_connections_per_second": 20, "max_connections_per_torrent": -1, "max_download_speed": -1.0, "max_download_speed_per_torrent": -1, "max_half_open_connections": 50, "max_upload_slots_global": 4, "max_upload_slots_per_torrent": -1, "max_upload_speed": -1.0, "max_upload_speed_per_torrent": -1, "move_completed": false, "move_completed_path": "/root/Downloads", "move_completed_paths_list": [], "natpmp": true, "new_release_check": false, "outgoing_interface": "", "outgoing_ports": [ 0, 0 ], "path_chooser_accelerator_string": "Tab", "path_chooser_auto_complete_enabled": true, "path_chooser_max_popup_rows": 20, "path_chooser_show_chooser_button_on_localhost": true, "path_chooser_show_hidden_files": false, "peer_tos": "0x00", "plugins_location": "/config/plugins", "pre_allocate_storage": false, "prioritize_first_last_pieces": false, "proxy": { "anonymous_mode": false, "force_proxy": false, "hostname": "", "password": "", "port": 8080, "proxy_hostnames": true, "proxy_peer_connections": true, "proxy_tracker_connections": true, "type": 0, "username": "" }, "queue_new_to_top": false, "random_outgoing_ports": true, "random_port": true, "rate_limit_ip_overhead": true, "remove_seed_at_ratio": false, "seed_time_limit": 180, "seed_time_ratio_limit": 7.0, "send_info": false, "sequential_download": false, "share_ratio_limit": 2.0, "shared": false, "stop_seed_at_ratio": false, "stop_seed_ratio": 2.0, "super_seeding": false, "torrentfiles_location": "/root/Downloads", "upnp": true, "utpex": true } ================================================ FILE: docker-compose/docker-compose.yml ================================================ version: "2.1" services: deluge: image: ghcr.io/linuxserver/deluge volumes: - deluge-config:/config - ./core.conf:/config/core.conf - ./auth:/config/auth:ro storm: image: ghcr.io/relvacode/storm environment: DELUGE_RPC_VERSION: v2 DELUGE_RPC_HOSTNAME: deluge DELUGE_RPC_USERNAME: localclient DELUGE_RPC_PASSWORD: deluge ports: - "8221:8221" volumes: deluge-config: ================================================ FILE: error.go ================================================ package storm import ( "fmt" "net/http" ) // HTTPError is an error that extends the built-in Go error with status code hinting. type HTTPError interface { error StatusCode() int } type RPCError struct { ExceptionType string ExceptionMessage string } func (RPCError) StatusCode() int { return http.StatusInternalServerError } func (e RPCError) Error() string { return fmt.Sprintf("%s: %s", e.ExceptionType, e.ExceptionMessage) } var _ HTTPError = (*Error)(nil) // Hint wraps an input error to hint the HTTP status code. // If err already implements HTTPError then that error is used directly func Hint(code int, err error) error { if httpError, ok := err.(HTTPError); ok { return httpError } return &Error{ Message: err.Error(), Code: code, } } type Error struct { Message string Code int } func (e *Error) Error() string { return e.Message } func (e *Error) StatusCode() int { return e.Code } type errorResponse struct { Error string } // SendError sends an error back to the client. // If err implements HTTPError then that status code is used. Otherwise HTTP InternalServerError is sent. // It delivers the error message as the errorResponse payload. func SendError(rw http.ResponseWriter, err error) { var ( code = http.StatusInternalServerError response = errorResponse{ Error: err.Error(), } ) if httpError, ok := err.(HTTPError); ok { code = httpError.StatusCode() } if wr, ok := rw.(*WrappedResponse); ok { wr.error = err } Send(rw, code, &response) } ================================================ FILE: frontend/.browserslistrc ================================================ # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries # For the full list of supported browsers by the Angular framework, please see: # https://angular.io/guide/browser-support # You can see what browsers were selected by your queries by running: # npx browserslist last 1 Chrome version last 1 Firefox version last 2 Edge major versions last 2 Safari major versions last 2 iOS major versions Firefox ESR not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. ================================================ FILE: frontend/.editorconfig ================================================ # Editor configuration, see https://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.ts] quote_type = single [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: frontend/angular.json ================================================ { "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects", "projects": { "storm": { "projectType": "application", "schematics": { "@schematics/angular:component": { "style": "scss" } }, "root": "", "sourceRoot": "src", "prefix": "t", "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { "outputPath": "dist", "index": "src/index.html", "main": "src/main.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.app.json", "aot": true, "assets": [ "src/favicon.ico", "src/assets" ], "styles": [ "node_modules/primeng/resources/primeng.min.css", "node_modules/primeng/resources/themes/bootstrap4-dark-blue/theme.css", "node_modules/primeflex/primeflex.css", "node_modules/@fortawesome/fontawesome-free/css/regular.css", "node_modules/@fortawesome/fontawesome-free/css/solid.css", "node_modules/@fortawesome/fontawesome-free/css/fontawesome.css", "src/icons.scss", "src/styles.scss" ], "scripts": [] }, "configurations": { "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "budgets": [ { "type": "initial", "maximumWarning": "2mb", "maximumError": "5mb" }, { "type": "anyComponentStyle", "maximumWarning": "6kb", "maximumError": "10kb" } ] }, "development": { "sourceMap": true, "buildOptimizer": false, "optimization": false } } }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { "browserTarget": "storm:build" }, "configurations": { "production": { "browserTarget": "storm:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { "browserTarget": "storm:build" } }, "test": { "builder": "@angular-devkit/build-angular:karma", "options": { "main": "src/test.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", "assets": [ "src/favicon.ico", "src/assets" ], "styles": [ "src/styles.scss" ], "scripts": [] } }, "lint": { "builder": "@angular-devkit/build-angular:tslint", "options": { "tsConfig": [ "tsconfig.app.json", "tsconfig.spec.json", "e2e/tsconfig.json" ], "exclude": [ "**/node_modules/**" ] } }, "e2e": { "builder": "@angular-devkit/build-angular:protractor", "options": { "protractorConfig": "e2e/protractor.conf.js", "devServerTarget": "storm:serve" }, "configurations": { "production": { "devServerTarget": "storm:serve:production" } } } } } }, "defaultProject": "storm" } ================================================ FILE: frontend/e2e/protractor.conf.js ================================================ // @ts-check // Protractor configuration file, see link for more information // https://github.com/angular/protractor/blob/master/lib/config.ts const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); /** * @type { import("protractor").Config } */ exports.config = { allScriptsTimeout: 11000, specs: [ './src/**/*.e2e-spec.ts' ], capabilities: { browserName: 'chrome' }, directConnect: true, baseUrl: 'http://localhost:4200/', framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, onPrepare() { require('ts-node').register({ project: require('path').join(__dirname, './tsconfig.json') }); jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: StacktraceOption.PRETTY } })); } }; ================================================ FILE: frontend/e2e/src/app.e2e-spec.ts ================================================ import { AppPage } from './app.po'; import { browser, logging } from 'protractor'; describe('workspace-project App', () => { let page: AppPage; beforeEach(() => { page = new AppPage(); }); it('should display welcome message', () => { page.navigateTo(); expect(page.getTitleText()).toEqual('storm app is running!'); }); afterEach(async () => { // Assert that there are no errors emitted from the browser const logs = await browser.manage().logs().get(logging.Type.BROWSER); expect(logs).not.toContain(jasmine.objectContaining({ level: logging.Level.SEVERE, } as logging.Entry)); }); }); ================================================ FILE: frontend/e2e/src/app.po.ts ================================================ import { browser, by, element } from 'protractor'; export class AppPage { navigateTo(): Promise { return browser.get(browser.baseUrl) as Promise; } getTitleText(): Promise { return element(by.css('app-root .content span')).getText() as Promise; } } ================================================ FILE: frontend/e2e/tsconfig.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/e2e", "module": "commonjs", "target": "es2018", "types": [ "jasmine", "jasminewd2", "node" ] } } ================================================ FILE: frontend/karma.conf.js ================================================ // Karma configuration file, see link for more information // https://karma-runner.github.io/1.0/config/configuration-file.html module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular-devkit/build-angular'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular-devkit/build-angular/plugins/karma') ], client: { clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/storm'), reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, reporters: ['progress', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false, restartOnFileChange: true }); }; ================================================ FILE: frontend/package.json ================================================ { "name": "storm", "version": "0.0.0", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "build:prod": "ng build --configuration=production", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e" }, "private": true, "dependencies": { "@angular/animations": "^13.1.1", "@angular/cdk": "^13.1.1", "@angular/common": "~13.1.1", "@angular/compiler": "~13.1.1", "@angular/core": "~13.1.1", "@angular/forms": "~13.1.1", "@angular/platform-browser": "~13.1.1", "@angular/platform-browser-dynamic": "~13.1.1", "@angular/router": "~13.1.1", "@fortawesome/fontawesome-free": "^5.15.1", "ngx-filesize": "^2.0.16", "ngx-moment": "^5.0.0", "primeflex": "^2.0.0", "primeng": "^13.0.3", "rxjs": "~6.6.0", "tslib": "^2.0.0", "zone.js": "~0.11.4" }, "devDependencies": { "@angular-devkit/build-angular": "^13.1.2", "@angular/cli": "~13.1.2", "@angular/compiler-cli": "~13.1.1", "@types/jasmine": "~3.5.0", "@types/jasminewd2": "~2.0.3", "@types/node": "^12.11.1", "codelyzer": "^6.0.0", "jasmine-core": "~3.8.0", "jasmine-spec-reporter": "~5.0.0", "karma": "~6.3.9", "karma-chrome-launcher": "~3.1.0", "karma-coverage-istanbul-reporter": "~3.0.2", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "protractor": "~7.0.0", "ts-node": "~8.3.0", "tslint": "~6.1.0", "typescript": "~4.5.4" } } ================================================ FILE: frontend/src/app/api.service.spec.ts ================================================ import { TestBed } from '@angular/core/testing'; import { ApiService } from './api.service'; describe('ApiService', () => { let service: ApiService; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(ApiService); }); it('should be created', () => { expect(service).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/api.service.ts ================================================ import {Inject, Injectable} from '@angular/core'; import {defer, EMPTY, Observable, ObservableInput, throwError} from 'rxjs'; import { HttpClient, HttpErrorResponse, HttpEvent, HttpHandler, HttpHeaders, HttpInterceptor, HttpParams, HttpRequest } from '@angular/common/http'; import {catchError, retryWhen, switchMap, takeWhile} from 'rxjs/operators'; import {Message} from 'primeng/api'; import {Environment, ENVIRONMENT} from './environment'; import {DialogService} from 'primeng/dynamicdialog'; import {ApiKeyDialogComponent} from './components/api-key-dialog/api-key-dialog.component'; /** * Raised when the API returns an error */ export class ApiException { constructor(public status: number, public error: string) { } /** * Get a PrimeNG error block for this exception */ public get message(): Message { return { severity: 'error', summary: 'Error', detail: this.error, }; } } export type State = 'Active' | 'Allocating' | 'Checking' | 'Downloading' | 'Seeding' | 'Paused' | 'Error' | 'Queued' | 'Moving'; export interface Torrent { ActiveTime: number; CompletedTime: number; TimeAdded: number; // most times an integer DistributedCopies: number; ETA: number; // most times an integer Progress: number; // max is 100 Ratio: number; IsFinished: boolean; IsSeed: boolean; Private: boolean; DownloadLocation: string; DownloadPayloadRate: number; Name: string; NextAnnounce: number; NumPeers: number; NumPieces: number; NumSeeds: number; PieceLength: number; SeedingTime: number; State: State; TotalDone: number; TotalPeers: number; TotalSeeds: number; TotalSize: number; TrackerHost: string; TrackerStatus: string; UploadPayloadRate: number; // Files: []File // Peers: []Peer FilePriorities: number[]; FileProgress: number[]; } export interface Label { Label: string; } export interface Hash { Hash: string; } export interface ViewTorrent extends Torrent, Hash, Label { } export interface Torrents { [id: string]: Torrent; } export interface TorrentOptions { MaxConnections?: number; MaxUploadSlots?: number; MaxUploadSpeed?: number; MaxDownloadSpeed?: number; PrioritizeFirstLastPieces?: boolean; PreAllocateStorage?: boolean; // compact_allocation for v1 DownloadLocation?: string; AutoManaged?: boolean; StopAtRatio?: boolean; StopRatio?: number; RemoveAtRatio?: number; MoveCompleted?: boolean; MoveCompletedPath?: string; AddPaused?: boolean; } export interface AddTorrent { Type: 'url' | 'magnet' | 'file'; URI: string; Data?: string; } export interface AddTorrentRequest extends AddTorrent { Options?: TorrentOptions; } export interface AddTorrentResponse { ID: string; } export interface TorrentLabels { [id: string]: string; } export interface SetTorrentLabelRequest { Label: string; } export interface SessionStatus { HasIncomingConnections: boolean; UploadRate: number; DownloadRate: number; PayloadUploadRate: number; PayloadDownloadRate: number; TotalDownload: number; TotalUpload: number; NumPeers: number; DhtNodes: number; } export interface DiskSpace { FreeBytes: number; } export interface ViewUpdate { Torrents: ViewTorrent[]; Session: SessionStatus; DiskFree: number; ETag: string; } export class ApiInterceptor implements HttpInterceptor { constructor() { } private catchError(err: HttpErrorResponse, caught: Observable>): ObservableInput { return throwError(new ApiException(err.status, err.error.Error)); } public intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( catchError(this.catchError) ); } } @Injectable() export class AuthInterceptor implements HttpInterceptor { ask$: Observable; constructor(dialogService: DialogService) { this.ask$ = defer(() => { const ref = dialogService.open(ApiKeyDialogComponent, { header: 'Authorization Required', showHeader: true, closeOnEscape: false, closable: false, }); return ref.onClose; }); } public intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( // Catch 401 errors and ask for the API key. // Redo the request with the provided API key in basic auth headers catchError((err: ApiException) => { if (err.status !== 401) { return throwError(err); } return this.ask$.pipe( switchMap(key => { const withAuthHeaderReq = req.clone({ headers: req.headers.set('Authorization', 'Basic ' + btoa(':' + key)) }); return next.handle(withAuthHeaderReq); }) ); }), // Keep retrying 401 errors retryWhen(errors => errors.pipe( takeWhile((err: ApiException) => err.status === 401) )) ); } } @Injectable({ providedIn: 'root', }) export class ApiService { constructor(private http: HttpClient, @Inject(ENVIRONMENT) private environment: Environment) { } private url(endpoint: string): string { return `${this.environment.baseApiPath}${endpoint}`; } /** * Calls the ping endpoint */ public ping(): Observable { return this.http.get(this.url('ping')); } public sessionStatus(): Observable { return this.http.get(this.url('session')); } public freeDiskSpace(path: string = ''): Observable { return this.http.get(this.url('disk/free'), { params: { path } }); } public viewUpdate(etag?: string, state?: State): Observable { let params = new HttpParams(); if (!!state) { params = params.set('state', state); } let headers = new HttpHeaders(); if (!!etag) { headers = headers.set('ETag', etag); } return this.http.get(this.url('view'), { params, headers, }).pipe( catchError((err: ApiException) => { if (err.status === 304) { return EMPTY; } return throwError(err); }) ); } /** * Get a list of all the currently enabled plugins */ public plugins(): Observable { return this.http.get(this.url('plugins')); } /** * Enable a plugin * @param name * The plugin name to enable */ public enablePlugin(name: string): Observable { return this.http.post(this.url(`plugins/${name}`), null); } /** * Disable a plugin * @param name * The plugin name to disable */ public disablePlugin(name: string): Observable { return this.http.delete(this.url(`plugins/${name}`)); } /** * Pauses one or more torrens * @param torrents * List of torrent IDs */ public pause(...torrents: string[]): Observable { const params = new HttpParams({ fromObject: { id: torrents, } }); return this.http.post(this.url('torrents/pause'), null, { params }); } /** * Resumes one or more torrens * @param torrents * List of torrent IDs */ public resume(...torrents: string[]): Observable { const params = new HttpParams({ fromObject: { id: torrents, } }); return this.http.post(this.url('torrents/resume'), null, { params }); } /** * Get a specific torrent by ID * @param id * The torrent ID */ public torrent(id: string): Observable { return this.http.get(this.url(`torrent/${id}`)); } public torrents(state?: State, ...torrents: string[]): Observable { let params = new HttpParams(); if (!!state) { params = params.set('state', state); } if (!!torrents && torrents.length) { for (const id of torrents) { params = params.append('id', id); } } return this.http.get(this.url('torrents'), { params, }); } public removeTorrent(withData: boolean, id: string): Observable { return this.http.delete(this.url(`torrent/${id}`), { params: new HttpParams({ fromObject: { files: withData ? 'true' : 'false' } }) }); } /** * Add a new torrent * @param req * Add torrent request */ public add(req: AddTorrentRequest): Observable { return this.http.post(this.url('torrents'), req); } /** * Gets available labels */ public labels(): Observable { return this.http.get(this.url('labels')); } /** * Create a new label * @param name * The label name */ public createLabel(name: string): Observable { return this.http.post(this.url(`labels/${name}`), null); } /** * Delete an existing label * @param name * The label name */ public deleteLabel(name: string): Observable { return this.http.delete(this.url(`labels/${name}`)); } /** * Gets labels associated with torrents matching filter * @param state * The torrent state * @param torrents * An optional set of torrent IDs */ public torrentsLabels(state?: State, ...torrents: string[]): Observable { return this.http.get(this.url('torrents/labels')); } /** * Updates the label of a torrent * @param id * The torrent ID * @param req * Request data */ public setTorrentLabel(id: string, req: SetTorrentLabelRequest): Observable { return this.http.post(this.url(`torrent/${id}/label`), req); } } ================================================ FILE: frontend/src/app/app.component.html ================================================
No Torrents
================================================ FILE: frontend/src/app/app.component.scss ================================================ .p-menubar { padding: .5rem 1rem; position: sticky; top: 0; z-index: 1; & button.t-menu-button:not(:first-child) { margin-left: 6px; } & .t-filter-input:not(:first-child) { margin-left: 6px; } t-torrent-search { margin: 0 12px; flex-grow: 1; } } .layout-content { padding: 2rem; background-color: var(--surface-b); } ::ng-deep .t-button-overlay { & button.p-button:not(:first-child) { margin-left: 12px; } } ::ng-deep .t-filter-overlay { min-width: 160px; & .t-sort-option { margin-bottom: 12px; } & > * { width: 100%; & .p-dropdown { width: 100%; } } } @media only screen and (max-width: 750px) { .p-menubar { padding: .5rem; & p-dropdown:not(:first-child) { margin-left: 6px; } } .layout-content { padding: 1rem; } } .t-empty { position: absolute; top: 0; width: 100%; height: 100%; z-index: -1; color: #434f5f; & i { font-size: 10rem; opacity: .2; } & span { margin-top: 2rem; font-weight: 600; } } t-breakpoint-overlay { display: flex; align-items: center; } .t-sort-option { width: 100%; display: flex; flex-direction: row; flex-wrap: nowrap; ::ng-deep & p-dropdown { flex-grow: 1; & .p-dropdown { border-radius: 4px 0 0 4px; } } & .t-sort-button { background: #20262e; border: 1px solid #3f4b5b; border-radius: 0 4px 4px 0; border-left: none; width: 3rem; color: rgba(255, 255, 255, 0.6); } } .t-bottom-bar { position: fixed; bottom: 0; width: 100%; } ================================================ FILE: frontend/src/app/app.component.spec.ts ================================================ import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { AppComponent } from './app.component'; describe('AppComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ RouterTestingModule ], declarations: [ AppComponent ], }).compileComponents(); }); it('should create the app', () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; expect(app).toBeTruthy(); }); it(`should have as title 'storm'`, () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; expect(app.title).toEqual('storm'); }); it('should render title', () => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.nativeElement; expect(compiled.querySelector('.content span').textContent).toContain('storm app is running!'); }); }); ================================================ FILE: frontend/src/app/app.component.ts ================================================ import {Component} from '@angular/core'; import {BehaviorSubject, combineLatest, EMPTY, forkJoin, Observable, of, timer} from 'rxjs'; import {catchError, filter, mergeMap, switchMap} from 'rxjs/operators'; import {ApiService, DiskSpace, Hash, SessionStatus, State, Torrent, ViewTorrent} from './api.service'; import {SelectItem} from 'primeng/api'; import {FocusService} from './focus.service'; import {DialogService} from 'primeng/dynamicdialog'; import {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component'; type OptionalState = State | null; @Component({ selector: 't-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], }) export class AppComponent { sortByField: keyof Torrent = null; sortReverse = false; sortOptions: SelectItem[] = [ { label: 'State', value: 'State' }, { label: 'Added', value: 'TimeAdded' }, { label: 'Progress', value: 'Progress' }, { label: 'ETA', value: 'ETA' }, { label: 'Name', value: 'Name' }, { label: 'Size', value: 'TotalSize' }, { label: 'Ratio', value: 'Ratio' }, { label: 'Seeding', value: 'SeedingTime' } ]; filterStatesOptions: SelectItem[] = [ { label: 'All', value: null, }, { label: 'Active', value: 'Active' }, { label: 'Queued', value: 'Queued', }, { label: 'Downloading', value: 'Downloading', }, { label: 'Seeding', value: 'Seeding', }, { label: 'Paused', value: 'Paused' }, { label: 'Error', value: 'Error' } ]; searchText: string; // All torrent hashes within the current view hashesInView: string[]; // Current view is empty empty = false; stateInView: OptionalState; sessionStatus: SessionStatus = { HasIncomingConnections: false, UploadRate: 0, DownloadRate: 0, PayloadUploadRate: 0, PayloadDownloadRate: 0, TotalDownload: 0, TotalUpload: 0, NumPeers: 0, DhtNodes: 0, }; diskSpace: DiskSpace; torrents: ViewTorrent[]; connected = true; lastEtag: string; get$: BehaviorSubject; constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService) { this.get$ = new BehaviorSubject(null); this.refreshInterval(2000); } /** * Opens the PluginEnable dialog component * @private */ private enableLabelPlugin(): Observable { const ref = this.dialogService.open(PluginEnableComponent, { header: 'Enable Plugin', showHeader: false, closable: false, closeOnEscape: false, dismissableMask: false, styleClass: 't-dialog-responsive', data: { name: 'Label' } }); return ref.onClose; } /** * Updates the list of torrents at every given interval, * or when the selected $get state is updated. * @param interval * Update interval in milliseconds */ private refreshInterval(interval: number): void { const timer$ = timer(0, interval); // Ensure the label plugin is enabled const labelPluginEnabled$ = this.api.plugins().pipe( switchMap(plugins => { const ok = plugins.findIndex(name => name === 'Label') > -1; if (ok) { return of(true); } return this.enableLabelPlugin(); }) ); const interval$ = combineLatest([timer$, this.focus.observe, this.get$, labelPluginEnabled$]); interval$.pipe( // Continue only when in focus filter(([_, focus]) => focus), // Switch to API response of torrents mergeMap(([_, focus, state]) => this.api.viewUpdate(this.lastEtag, state).pipe( catchError(err => { console.error('Connection error', err); this.connected = false; this.lastEtag = null; return EMPTY; }), )), ).subscribe( response => { this.connected = true; this.sessionStatus = response.Session; this.diskSpace = {FreeBytes: response.DiskFree}; this.torrents = response.Torrents; this.empty = this.torrents.length === 0; this.hashesInView = this.torrents.map(t => t.Hash); this.lastEtag = response.ETag; const statesInView = new Set(this.torrents.map(t => t.State)); const [onlyStateInView] = statesInView.size === 1 ? statesInView : []; this.stateInView = onlyStateInView || null; } ); } public trackBy(index: number, torrent: Hash): string { return torrent.Hash; } onToggleInView(targetState: 'pause' | 'resume', torrents: ViewTorrent[]): void { if (!torrents || torrents.length === 0) { return; } let res: Observable; switch (targetState) { case 'pause': res = this.api.pause(...torrents.filter(t => t.State !== 'Paused').map(t => t.Hash)); break; case 'resume': res = this.api.resume(...torrents.filter(t => t.State === 'Paused').map(t => t.Hash)); break; } res.subscribe( _ => console.log(`torrents in view reached target state ${targetState}`) ); } } ================================================ FILE: frontend/src/app/app.module.ts ================================================ import {BrowserModule} from '@angular/platform-browser'; import {NgModule} from '@angular/core'; import {AppComponent} from './app.component'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {TorrentStateComponent} from './components/torrent-state/torrent-state.component'; import {TorrentComponent} from './components/torrent/torrent.component'; import {ProgressBarModule} from 'primeng/progressbar'; import {NgxFilesizeModule} from 'ngx-filesize'; import {ButtonModule} from 'primeng/button'; import {RippleModule} from 'primeng/ripple'; import {TooltipModule} from 'primeng/tooltip'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {ApiInterceptor, AuthInterceptor} from './api.service'; import {DropdownModule} from 'primeng/dropdown'; import {OrderByPipe} from './order-by.pipe'; import {FormsModule} from '@angular/forms'; import {OverlayPanelModule} from 'primeng/overlaypanel'; import {DeleteTorrentOverlayComponent} from './components/delete-torrent-overlay/delete-torrent-overlay.component'; import {ProgressSpinnerModule} from 'primeng/progressspinner'; import {MomentModule} from 'ngx-moment'; import {MenuModule} from 'primeng/menu'; import {AddTorrentMenuComponent} from './components/add-torrent-menu/add-torrent-menu.component'; import {DialogService, DynamicDialogModule} from 'primeng/dynamicdialog'; import { AddTorrentMagnetInputComponent } from './components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component'; import {InputTextModule} from 'primeng/inputtext'; import {AddTorrentConfigComponent} from './components/add-torrent-menu/add-torrent-config/add-torrent-config.component'; import {AccordionModule} from 'primeng/accordion'; import {InputNumberModule} from 'primeng/inputnumber'; import {CheckboxModule} from 'primeng/checkbox'; import {TorrentDetailsDialogComponent} from './components/torrent-details-dialog/torrent-details-dialog.component'; import { AddTorrentUrlInputComponent } from './components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component'; import {MessagesModule} from 'primeng/messages'; import { AddTorrentFileInputComponent } from './components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component'; import {FileUploadModule} from 'primeng/fileupload'; import {BreakpointOverlayComponent} from './components/breakpoint-overlay/breakpoint-overlay.component'; import {TorrentSearchPipe} from './torrent-search.pipe'; import {ConnectivityStatusComponent} from './components/connectivity-status/connectivity-status.component'; import {TorrentSearchComponent} from './components/torrent-search/torrent-search.component'; import {ENVIRONMENT} from './environment'; import {TorrentLabelComponent} from './components/torrent-label/torrent-label.component'; import { TorrentEditLabelDialogComponent } from './components/torrent-edit-label-dialog/torrent-edit-label-dialog.component'; import {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component'; import {MultiSelectModule} from 'primeng/multiselect'; import { ActivityMarkerComponent } from './components/activity-marker/activity-marker.component'; import { ApiKeyDialogComponent } from './components/api-key-dialog/api-key-dialog.component'; import { SessionStatusComponent } from './components/session-status/session-status.component'; // @ts-ignore @NgModule({ declarations: [ AppComponent, TorrentStateComponent, TorrentComponent, OrderByPipe, DeleteTorrentOverlayComponent, AddTorrentMenuComponent, AddTorrentMagnetInputComponent, AddTorrentConfigComponent, TorrentDetailsDialogComponent, AddTorrentUrlInputComponent, AddTorrentFileInputComponent, BreakpointOverlayComponent, TorrentSearchPipe, ConnectivityStatusComponent, TorrentSearchComponent, TorrentLabelComponent, TorrentEditLabelDialogComponent, PluginEnableComponent, ActivityMarkerComponent, ApiKeyDialogComponent, SessionStatusComponent, ], imports: [ BrowserModule, BrowserAnimationsModule, HttpClientModule, NgxFilesizeModule, ProgressBarModule, MenuModule, ButtonModule, RippleModule, TooltipModule, DropdownModule, FormsModule, OverlayPanelModule, ProgressSpinnerModule, DynamicDialogModule, MomentModule, InputTextModule, AccordionModule, InputNumberModule, CheckboxModule, MessagesModule, FileUploadModule, MultiSelectModule, ], entryComponents: [], providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true, }, { provide: HTTP_INTERCEPTORS, useClass: ApiInterceptor, multi: true, }, { provide: ENVIRONMENT, // @ts-ignore // Environment injection is handled by the server. // It will replace the contents of the initializer in index.html with the environment // specific variables on-demand. useValue: window.environment, }, DialogService, ], bootstrap: [AppComponent] }) export class AppModule { } ================================================ FILE: frontend/src/app/components/activity-marker/activity-marker.component.html ================================================ ================================================ FILE: frontend/src/app/components/activity-marker/activity-marker.component.scss ================================================ :host { position: absolute; top: 0; margin: 0; color: #d45e6a; transform: translate(-0.6em, 0.6em); font-size: .6rem; font-family: 'Font Awesome 5 Free'; font-style: normal; font-weight: 900; &::before { // fas fa-circle content: '\f111'; } } ================================================ FILE: frontend/src/app/components/activity-marker/activity-marker.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivityMarkerComponent } from './activity-marker.component'; describe('ActivityMarkerComponent', () => { let component: ActivityMarkerComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ ActivityMarkerComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ActivityMarkerComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/activity-marker/activity-marker.component.ts ================================================ import { Component } from '@angular/core'; @Component({ selector: 't-activity-marker', templateUrl: './activity-marker.component.html', styleUrls: ['./activity-marker.component.scss'] }) export class ActivityMarkerComponent { constructor() { } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.html ================================================
================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.scss ================================================ :host { display: block; margin: 1rem 0; } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AddTorrentConfigComponent } from './add-torrent-config.component'; describe('AddTorrentConfigComponent', () => { let component: AddTorrentConfigComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ AddTorrentConfigComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AddTorrentConfigComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.ts ================================================ import {Component, Input} from '@angular/core'; import {TorrentOptions} from '../../../api.service'; @Component({ selector: 't-add-torrent-config', templateUrl: './add-torrent-config.component.html', styleUrls: ['./add-torrent-config.component.scss'] }) export class AddTorrentConfigComponent { @Input('config') config: TorrentOptions; constructor() { } public unsetFields($event: boolean, ...fields: (keyof TorrentOptions)[]): void { if (!$event) { for (const field of fields) { // @ts-ignore this.config[field] = null; } } } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-dialog-component.ts ================================================ import {AddTorrent, AddTorrentRequest, ApiException, ApiService, TorrentOptions} from '../../api.service'; import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; import {Injector} from '@angular/core'; import {catchError, finalize} from 'rxjs/operators'; import {throwError} from 'rxjs'; import {Message} from 'primeng/api'; export class AddTorrentDialogComponentDirective { public static DefaultIcon = 'far fa-plus-square'; public config: TorrentOptions; public submitIcon = AddTorrentDialogComponentDirective.DefaultIcon; public submitIsDisabled = false; public errorMessages: Message[] = []; api: ApiService; ref: DynamicDialogRef; data: Partial; constructor(injector: Injector) { this.api = injector.get(ApiService) as ApiService; this.ref = injector.get(DynamicDialogRef) as DynamicDialogRef; this.data = (injector.get(DynamicDialogConfig) as DynamicDialogConfig).data || {}; this.config = { MaxDownloadSpeed: -1, MaxUploadSpeed: -1, }; } public close(): void { this.ref.close(false); } public submit(opt: AddTorrent): void { const req: AddTorrentRequest = Object.assign({Options: this.config}, opt); this.submitIcon = 'fa-spin fas fa-spinner'; this.submitIsDisabled = true; this.errorMessages.splice(0); this.api.add(req).pipe( catchError((err: ApiException) => { // Catch Api exceptions and push them to the messages stack this.errorMessages = [err.message]; return throwError(err); }), finalize(() => { this.submitIcon = AddTorrentDialogComponentDirective.DefaultIcon; this.submitIsDisabled = false; }) ).subscribe( response => this.ref.close(response.ID) ); } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.html ================================================ ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.scss ================================================ ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AddTorrentFileInputComponent } from './add-torrent-file-input.component'; describe('AddTorrentFileInputComponent', () => { let component: AddTorrentFileInputComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ AddTorrentFileInputComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AddTorrentFileInputComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.ts ================================================ import {Component, Injector} from '@angular/core'; import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component'; @Component({ selector: 't-add-torrent-file-input', templateUrl: './add-torrent-file-input.component.html', styleUrls: ['./add-torrent-file-input.component.scss'] }) export class AddTorrentFileInputComponent extends AddTorrentDialogComponentDirective { constructor(injector: Injector) { super(injector); } /** * Converts an array buffer to a Base64 encoded string */ private arrayBufferToBase64(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } onResetErrors(): void { this.errorMessages = []; } onSubmit($event: { files: Array }): void { const file = $event.files[0]; file.arrayBuffer().then( buffer => { // Encode the file as Base64 and submit to the server const encoded = this.arrayBufferToBase64(buffer); this.submit({ Type: 'file', URI: file.name, Data: encoded, }); }, err => { this.errorMessages = [{ severity: 'error', summary: 'Error', detail: err.toString(), }]; } ); } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.html ================================================
================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.scss ================================================ span.p-input-icon-left, input { width: 100%; } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AddTorrentMagnetInputComponent } from './add-torrent-magnet-input.component'; describe('AddTorrentMagnetInputComponent', () => { let component: AddTorrentMagnetInputComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ AddTorrentMagnetInputComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AddTorrentMagnetInputComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.ts ================================================ import {Component, Injector} from '@angular/core'; import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component'; export interface MagnetInput { url: string; } @Component({ selector: 't-add-torrent-magnet-input', templateUrl: './add-torrent-magnet-input.component.html', styleUrls: ['./add-torrent-magnet-input.component.scss'] }) export class AddTorrentMagnetInputComponent extends AddTorrentDialogComponentDirective { url: string; constructor(injector: Injector) { super(injector); this.url = this.data.url; } onSubmit(): void { this.submit({Type: 'magnet', URI: this.url}); } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.html ================================================ ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.scss ================================================ ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AddTorrentMenuComponent } from './add-torrent-menu.component'; describe('AddTorrentMenuComponent', () => { let component: AddTorrentMenuComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ AddTorrentMenuComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AddTorrentMenuComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.ts ================================================ import {Component, ViewChild} from '@angular/core'; import {MenuItem} from 'primeng/api'; import {Menu} from 'primeng/menu'; import {DialogService} from 'primeng/dynamicdialog'; import {AddTorrentMagnetInputComponent} from './add-torrent-magnet-input/add-torrent-magnet-input.component'; import {AddTorrentUrlInputComponent} from './add-torrent-url-input/add-torrent-url-input.component'; import {AddTorrentFileInputComponent} from './add-torrent-file-input/add-torrent-file-input.component'; @Component({ selector: 't-add-torrent-menu', templateUrl: './add-torrent-menu.component.html', styleUrls: ['./add-torrent-menu.component.scss'], }) export class AddTorrentMenuComponent { @ViewChild('menu') menu: Menu; items: MenuItem[] = [ { label: 'Add Torrent', items: [ { label: 'Magnet', icon: 'fas fa-magnet', command: () => this.openDialog(AddTorrentMagnetInputComponent) }, { label: 'URL', icon: 'fas fa-link', command: () => this.openDialog(AddTorrentUrlInputComponent) }, { label: 'File', icon: 'far fa-file-alt', command: () => this.openDialog(AddTorrentFileInputComponent) } ] } ]; constructor(private dialogService: DialogService) { } public toggle($event): void { this.menu.toggle($event); } private openDialog(component: any): void { this.dialogService.open(component, { showHeader: false, closable: true, closeOnEscape: true, dismissableMask: true, styleClass: 't-dialog-responsive' }); } } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.html ================================================
================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.scss ================================================ span.p-input-icon-left, input { width: 100%; } ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AddTorrentUrlInputComponent } from './add-torrent-url-input.component'; describe('AddTorrentUrlInputComponent', () => { let component: AddTorrentUrlInputComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ AddTorrentUrlInputComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AddTorrentUrlInputComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.ts ================================================ import {Component, Injector} from '@angular/core'; import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component'; export interface URLInput { url: string; } @Component({ selector: 't-add-torrent-url-input', templateUrl: './add-torrent-url-input.component.html', styleUrls: ['./add-torrent-url-input.component.scss'] }) export class AddTorrentUrlInputComponent extends AddTorrentDialogComponentDirective { url: string; constructor(injector: Injector) { super(injector); this.url = this.data.url; } onSubmit(): void { this.submit({Type: 'url', URI: this.url}); } } ================================================ FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.html ================================================
================================================ FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.scss ================================================ ================================================ FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ApiKeyDialogComponent } from './api-key-dialog.component'; describe('ApiKeyDialogComponent', () => { let component: ApiKeyDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ ApiKeyDialogComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ApiKeyDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.ts ================================================ import { Component } from '@angular/core'; import {DynamicDialogRef} from "primeng/dynamicdialog"; @Component({ selector: 't-api-key-dialog', templateUrl: './api-key-dialog.component.html', styleUrls: ['./api-key-dialog.component.scss'] }) export class ApiKeyDialogComponent{ key: string; constructor(private ref: DynamicDialogRef) { } onSubmit() { this.ref.close(this.key) } } ================================================ FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.html ================================================
================================================ FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.scss ================================================ ================================================ FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BreakpointOverlayComponent } from './breakpoint-overlay.component'; describe('BreakpointOverlayComponent', () => { let component: BreakpointOverlayComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ BreakpointOverlayComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(BreakpointOverlayComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.ts ================================================ import {Component, Input} from '@angular/core'; import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout'; import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; @Component({ selector: 't-breakpoint-overlay', templateUrl: './breakpoint-overlay.component.html', styleUrls: ['./breakpoint-overlay.component.scss'] }) export class BreakpointOverlayComponent { @Input('icon') icon: string; @Input('styleClass') styleClass: string; @Input('contentActivated') contentActivated = false; $breakpoint: Observable; constructor(breakpointObserver: BreakpointObserver) { // Breakpoint observable to switch the view to small handset view this.$breakpoint = breakpointObserver.observe( [Breakpoints.Small, Breakpoints.HandsetPortrait] ).pipe( map(state => state.matches) ); } } ================================================ FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.html ================================================
{{ connected ? 'connected' : 'not connected'}}
================================================ FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.scss ================================================ .t-connectivity { position: fixed; bottom: 0; width: 100%; z-index: 1000; text-align: center; padding: .2rem; color: #fff; font-weight: 600; &.connected { background-color: #5ab132; } &.disconnected { background-color: #d45e6a } &.transition { transition: 1s; &.transition-closing { transform: translateY(100%); } } } ================================================ FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ConnectivityStatusComponent } from './connectivity-status.component'; describe('ConnectivityStatusComponent', () => { let component: ConnectivityStatusComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ ConnectivityStatusComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ConnectivityStatusComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.ts ================================================ import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; import {concat, Observable, of, Subject, timer} from 'rxjs'; import {map, switchMap, tap} from 'rxjs/operators'; @Component({ selector: 't-connectivity-status', templateUrl: './connectivity-status.component.html', styleUrls: ['./connectivity-status.component.scss'] }) export class ConnectivityStatusComponent implements OnChanges { @Input('connected') connected: boolean; $connected: Subject; $visible: Observable; animate = false; closing = false; constructor() { this.$connected = new Subject(); this.$visible = this.$connected.pipe( tap(_ => { this.animate = false; this.closing = false; }), switchMap(ok => { if (!ok) { // If not connected then make the status visible return of(true); } // Otherwise, if connected // Make the status visible, then after 2 seconds disable the visibility return concat( of(true), // After two seconds begin the transition timer(2000, 0).pipe( switchMap(_ => { this.animate = true; this.closing = true; return timer(1000, 0).pipe( map(_ => false) ); }) ) ); }) ); } ngOnChanges(changes: SimpleChanges): void { const connectedChange = changes.connected; // Connectivity change not made in this cycle if (typeof connectedChange === 'undefined') { return; } if (connectedChange.currentValue === true && connectedChange.isFirstChange()) { // Not interested in a successful initial connection return; } this.$connected.next(connectedChange.currentValue); } } ================================================ FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.html ================================================
================================================ FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.scss ================================================ .t-overlay-button:not(:last-child) { margin-bottom: 1rem; } ================================================ FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DeleteTorrentOverlayComponent } from './delete-torrent-overlay.component'; describe('DeleteTorrentOverlayComponent', () => { let component: DeleteTorrentOverlayComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ DeleteTorrentOverlayComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DeleteTorrentOverlayComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.ts ================================================ import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; import {ApiService} from '../../api.service'; import {OverlayPanel} from 'primeng/overlaypanel'; import {finalize, mergeMap} from 'rxjs/operators'; import {from} from 'rxjs'; @Component({ selector: 't-delete-torrent-overlay', templateUrl: './delete-torrent-overlay.component.html', styleUrls: ['./delete-torrent-overlay.component.scss'] }) export class DeleteTorrentOverlayComponent { @Input('torrents') torrents: string[]; @Output('removed') removed = new EventEmitter(); @ViewChild('overlay') overlay: OverlayPanel; processing = false; constructor(private api: ApiService) { } public toggle($event): void { this.overlay.toggle($event, $event.target); } onRemove(withData: boolean): void { this.processing = true; from(this.torrents).pipe( mergeMap(hash => this.api.removeTorrent(withData, hash)), finalize(() => { this.processing = false; this.overlay.hide(); this.removed.emit(true); }) ).subscribe( _ => console.log('torrent deleted'), ); } } ================================================ FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.html ================================================

The Deluge plugin {{name}} must be enabled to continue

================================================ FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.scss ================================================ ================================================ FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PluginEnableComponent } from './plugin-enable.component'; describe('PluginEnableComponent', () => { let component: PluginEnableComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ PluginEnableComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(PluginEnableComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.ts ================================================ import { Component, OnInit } from '@angular/core'; import {DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog"; import {ApiService} from "../../api.service"; @Component({ selector: 't-plugin-enable', templateUrl: './plugin-enable.component.html', styleUrls: ['./plugin-enable.component.scss'] }) export class PluginEnableComponent implements OnInit { name: string; inProgress: boolean = false; constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig, private api: ApiService) { this.name = config.data.name; } ngOnInit(): void { } onSubmit(): void { this.inProgress = true; this.api.enablePlugin(this.name).subscribe( _ => { this.inProgress = false; this.ref.close(true); }, ) } } ================================================ FILE: frontend/src/app/components/session-status/session-status.component.html ================================================
{{sessionStatus.NumPeers}}/{{sessionStatus.DhtNodes}} {{sessionStatus.DownloadRate | filesize}}s {{sessionStatus.UploadRate | filesize}}s {{sessionStatus.TotalDownload | filesize}} {{sessionStatus.TotalUpload | filesize}} {{diskSpace?.FreeBytes | filesize}}
================================================ FILE: frontend/src/app/components/session-status/session-status.component.scss ================================================ .t-session-status { width: 100%; background: #343e4d; color: rgba(255, 255, 255, 0.5); padding: 4px 1rem; display: flex; flex-direction: row; justify-content: right; font-weight: 500; font-size: .9rem; & > span { &:not(:last-child) { margin-right: 1rem; } } & i { opacity: .6; margin-right: 4px; } } @media only screen and (max-width: 750px) { .t-session-status { justify-content: center; } } ================================================ FILE: frontend/src/app/components/session-status/session-status.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SessionStatusComponent } from './session-status.component'; describe('SessionStatusComponent', () => { let component: SessionStatusComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ SessionStatusComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SessionStatusComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/session-status/session-status.component.ts ================================================ import {Component, Input, OnInit} from '@angular/core'; import {DiskSpace, SessionStatus} from '../../api.service'; @Component({ selector: 't-session-status', templateUrl: './session-status.component.html', styleUrls: ['./session-status.component.scss'] }) export class SessionStatusComponent implements OnInit { @Input() sessionStatus: SessionStatus; @Input() diskSpace: DiskSpace; constructor() { } ngOnInit(): void { } } ================================================ FILE: frontend/src/app/components/torrent/torrent.component.html ================================================
{{torrent.Name}}
{{torrent.ETA | amDuration: 'seconds'}}
{{torrent.TotalSize | filesize}}
{{torrent.DownloadPayloadRate |filesize}}s {{torrent.UploadPayloadRate |filesize}}s {{torrent.SeedingTime === 0 ? '0s' : (torrent.SeedingTime | amDuration: 'seconds')}} {{torrent.Ratio < 0 ? 0 : torrent.Ratio | number : '1.0-2'}}
================================================ FILE: frontend/src/app/components/torrent/torrent.component.scss ================================================ .p-card-body { flex-grow: 1; margin-right: .4rem; padding: .4rem; & .p-card-title { font-size: 1rem; word-break: break-word; font-weight: 700; margin-bottom: 0.2rem; } & .p-card-subtitle { font-weight: 400; color: rgba(255, 255, 255, 0.6); margin-bottom: 0.4rem; } } .p-lg-8, .p-lg-4 { padding: 0; } .t-bar { flex-grow: 1; justify-content: center; } .t-controls { text-align: right; } .t-torrent-progress { margin-bottom: 12px; opacity: .6; & .spacer { flex-grow: 1; } & i { opacity: .4; vertical-align: middle; margin-right: 4px; } } .t-torrent-eta { } .t-info { opacity: .4; padding: .4rem; & span { word-break: break-all; & i { margin-right: 2px; } &:not(:last-child) { margin-right: 18px; } } } .t-torrent-detail { display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; & > *:not(:last-child) { margin-right: 18px; } } ================================================ FILE: frontend/src/app/components/torrent/torrent.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentComponent } from './torrent.component'; describe('TorrentComponent', () => { let component: TorrentComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent/torrent.component.ts ================================================ import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {ApiService, Torrent} from '../../api.service'; import {Observable} from 'rxjs'; import {switchMap, take} from 'rxjs/operators'; @Component({ selector: 't-torrent', templateUrl: './torrent.component.html', styleUrls: ['./torrent.component.scss'] }) export class TorrentComponent implements OnInit { @Input('hash') hash: string; @Input('torrent') torrent: Torrent; @Input('label') label: string; @Output('removed') removed = new EventEmitter(); constructor(private api: ApiService) { } ngOnInit(): void { } private refreshAfter(action: Observable): void { action.pipe( switchMap(_ => this.api.torrent(this.hash)), take(1), ).subscribe( torrent => this.torrent = torrent ); } public onPause(): void { this.refreshAfter(this.api.pause(this.hash)); } public onResume(): void { this.refreshAfter(this.api.resume(this.hash)); } public onChangeState(): void { if (this.torrent.State === 'Paused') { this.onResume(); return; } this.onPause(); } } ================================================ FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.html ================================================ ================================================ FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.scss ================================================ ================================================ FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentDetailsDialogComponent } from './torrent-details-dialog.component'; describe('TorrentDetailsDialogComponent', () => { let component: TorrentDetailsDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentDetailsDialogComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentDetailsDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.ts ================================================ import {Component, Injectable} from '@angular/core'; import {Torrent} from '../../api.service'; import {Observable} from 'rxjs'; import {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; @Component({ selector: 't-torrent-details-dialog', templateUrl: './torrent-details-dialog.component.html', styleUrls: ['./torrent-details-dialog.component.scss'] }) export class TorrentDetailsDialogComponent { id: string; torrent: Observable; constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig) { this.id = config.data.id; this.torrent = config.data.torrent; } public onRemoved(): void { this.ref.close(); } } @Injectable({ providedIn: 'root', }) export class TorrentDetailsDialogService { constructor(private dialogService: DialogService) { } public open(id: string, torrent: Observable): void { this.dialogService.open(TorrentDetailsDialogComponent, { showHeader: false, closable: true, closeOnEscape: true, dismissableMask: true, styleClass: 't-dialog-responsive', data: { id, torrent } }); } } ================================================ FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.html ================================================ Use {{suggestion.value}} Create and use a new label named {{suggestion.value}} Clear the current label {{initialLabel}}
================================================ FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.scss ================================================ span.t-new-label { font-style: italic; } button.t-suggestion-button { margin-bottom: 8px; } button.p-button.t-delete-label-button { height: 1rem; } ================================================ FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentEditLabelDialogComponent } from './torrent-edit-label-dialog.component'; describe('TorrentEditLabelDialogComponent', () => { let component: TorrentEditLabelDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentEditLabelDialogComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentEditLabelDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.ts ================================================ import {Component, Injectable} from '@angular/core'; import {DialogService, DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog"; import {ApiService} from "../../api.service"; import {BehaviorSubject, combineLatest, Observable, of} from "rxjs"; import {delay, map, shareReplay, switchMap} from "rxjs/operators"; interface LabelSuggestion { value: string; new: boolean; clear: boolean; } @Component({ selector: 't-torrent-edit-label-dialog', templateUrl: './torrent-edit-label-dialog.component.html', styleUrls: ['./torrent-edit-label-dialog.component.scss'] }) export class TorrentEditLabelDialogComponent { id: string label: string; initialLabel: string; query$ = new BehaviorSubject(''); refresh$ = new BehaviorSubject(null); labels$: Observable; suggestions$: Observable; constructor(private ref: DynamicDialogRef, config: DynamicDialogConfig, private api: ApiService) { this.id = config.data.id; this.label = ''; this.initialLabel = config.data.currentLabel ? config.data.currentLabel : ''; this.labels$ = this.refresh$.pipe( switchMap(_ => this.api.labels()), shareReplay(1) ); this.suggestions$ = combineLatest([this.labels$, this.query$]).pipe( map(([labels, query]) => { const suggestions: LabelSuggestion[] = []; const preparedQuery = query.toLocaleLowerCase(); let hasExactMatch = false; for (const label of labels) { // Don't suggest the initial label if (!!this.initialLabel && preparedQuery === this.initialLabel) { hasExactMatch = true; continue } // Suggest an existing label if it partially matches if (!preparedQuery || label.includes(preparedQuery)) { // Only suggest this label if there was no initial label, or if this label does not match the initial if (this.initialLabel === '' || label !== this.initialLabel) { suggestions.push({value: label, new: false, clear: false}) } } if (label === preparedQuery) { hasExactMatch = true } } // If there is a query to suggest, and there's no exact match then suggest creating a new label if (preparedQuery && !hasExactMatch) { suggestions.push({value: preparedQuery, new: true, clear: false}) } // If there was an initial label, then suggest clearing it if (!!this.initialLabel) { suggestions.push({value: '', new: false, clear: true}) } return suggestions }) ) } onDeleteLabel($event: { preventDefault: () => void }, name: string): void { $event.preventDefault(); this.api.deleteLabel(name).subscribe( _ => { if (name === this.label) { this.label = ''; } this.refresh$.next(null) } ) } onComplete($event: { query: string }) { this.query$.next($event.query) } onApplySuggestion(suggestion: LabelSuggestion): void { // Do nothing if the label is unchanged if (suggestion.value === this.initialLabel && !suggestion.new) { this.ref.close(); return; } let prep$: Observable = of(null) // If the label is not empty then check it is not one of the existing labels if (suggestion.value && suggestion.new) { prep$ = this.api.createLabel(suggestion.value).pipe( // Deluge has issues when a torrent label is set immediately after setting it delay(200) ) } prep$.pipe( switchMap(_ => this.api.setTorrentLabel(this.id, {Label: suggestion.value})) ).subscribe( _ => this.ref.close(suggestion.value) ) } } @Injectable({ providedIn: 'root', }) export class TorrentEditLabelService { constructor(private dialogService: DialogService) { } public open(id: string, currentLabel?: string): Observable { const ref = this.dialogService.open(TorrentEditLabelDialogComponent, { header: 'Edit Label', showHeader: true, closable: true, closeOnEscape: true, dismissableMask: true, styleClass: 't-dialog-responsive', data: { id, currentLabel, } }); return ref.onClose.pipe( map(value => typeof value === 'undefined' ? currentLabel : value) ) } } ================================================ FILE: frontend/src/app/components/torrent-label/torrent-label.component.html ================================================
{{label}}
no label ================================================ FILE: frontend/src/app/components/torrent-label/torrent-label.component.scss ================================================ .t-label-container{ cursor: pointer; } .t-label-no-label { font-style: italic; opacity: .4; } .t-label { &:before { content: "\f02b"; font-family: 'Font Awesome 5 Free'; font-weight: 900; margin-right: 4px; } } ================================================ FILE: frontend/src/app/components/torrent-label/torrent-label.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentLabelComponent } from './torrent-label.component'; describe('TorrentLabelComponent', () => { let component: TorrentLabelComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentLabelComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentLabelComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent-label/torrent-label.component.ts ================================================ import {Component, Input} from '@angular/core'; import {TorrentEditLabelService} from "../torrent-edit-label-dialog/torrent-edit-label-dialog.component"; @Component({ selector: 't-torrent-label', templateUrl: './torrent-label.component.html', styleUrls: ['./torrent-label.component.scss'] }) export class TorrentLabelComponent { @Input('hash') hash: string; @Input('label') label: string; constructor(private editLabelService: TorrentEditLabelService) { } onUpdateLabel(): void { this.editLabelService.open(this.hash, this.label).subscribe( newLabel => this.label = newLabel ) } } ================================================ FILE: frontend/src/app/components/torrent-search/torrent-search.component.html ================================================ ================================================ FILE: frontend/src/app/components/torrent-search/torrent-search.component.scss ================================================ ================================================ FILE: frontend/src/app/components/torrent-search/torrent-search.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentSearchComponent } from './torrent-search.component'; describe('TorrentSearchComponent', () => { let component: TorrentSearchComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentSearchComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentSearchComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent-search/torrent-search.component.ts ================================================ import {Component, EventEmitter, Output} from '@angular/core'; import {AddTorrentMagnetInputComponent} from '../add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component'; import {AddTorrentUrlInputComponent} from '../add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component'; import {DialogService} from 'primeng/dynamicdialog'; interface CT { type: any; icon: string; } const EntryComponents: { [k: string]: CT | undefined } = { search: undefined, magnet: { type: AddTorrentMagnetInputComponent, icon: 'fas fa-magnet', }, url: { type: AddTorrentUrlInputComponent, icon: 'fas fa-link' }, }; @Component({ selector: 't-torrent-search', templateUrl: './torrent-search.component.html', styleUrls: ['./torrent-search.component.scss'] }) export class TorrentSearchComponent { @Output('search') search = new EventEmitter(); searchText: string; icon: string; mode: keyof typeof EntryComponents = 'search'; constructor(private dialogService: DialogService) { } private setTarget(target: keyof typeof EntryComponents): void { if (target === this.mode) { return; } if (target === 'search') { this.mode = 'search'; this.icon = null; return; } // Otherwise this is a target url if (this.mode === 'search') { this.search.emit(''); } this.mode = target; this.icon = EntryComponents[target].icon; } onClear(): void { this.mode = 'search'; this.icon = null; this.searchText = ''; this.search.emit(''); } onChange($event: string): void { switch (true) { case $event.startsWith('magnet:'): this.setTarget('magnet'); break; case $event.startsWith('http://') || $event.startsWith('https://'): this.setTarget('url'); break; default: this.setTarget('search'); this.search.emit(this.searchText); } } onAdd(): void { const component = EntryComponents[this.mode]; const ref = this.dialogService.open(component.type, { showHeader: false, closable: true, closeOnEscape: true, dismissableMask: true, styleClass: 't-dialog-responsive', data: { url: this.searchText } }); // When the modal is closed, if the modal // produces a non-nil result (a valid torrent hash) ref.onClose.subscribe( result => { if (!!result) { this.onClear(); } } ); } } ================================================ FILE: frontend/src/app/components/torrent-state/torrent-state.component.html ================================================ {{state}} ================================================ FILE: frontend/src/app/components/torrent-state/torrent-state.component.scss ================================================ .t-state { &:before { content: "\f111"; font-family: 'Font Awesome 5 Free'; font-weight: 400; margin-right: 4px; } &.t-state-Queued { color: #adaf16; } &.t-state-Downloading { color: #5ab132; } &.t-state-Seeding { color: #8dd0ff; } &.t-state-Error { color: #d45e6a; } } ================================================ FILE: frontend/src/app/components/torrent-state/torrent-state.component.spec.ts ================================================ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TorrentStateComponent } from './torrent-state.component'; describe('TorrentStateComponent', () => { let component: TorrentStateComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ TorrentStateComponent ] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TorrentStateComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/components/torrent-state/torrent-state.component.ts ================================================ import {Component, Input} from '@angular/core'; import {State} from '../../api.service'; @Component({ selector: 't-torrent-state', templateUrl: './torrent-state.component.html', styleUrls: ['./torrent-state.component.scss'] }) export class TorrentStateComponent { @Input('state') state: State; constructor() { } } ================================================ FILE: frontend/src/app/environment.ts ================================================ import {InjectionToken} from "@angular/core"; export interface Environment { baseApiPath: string; } export const ENVIRONMENT = new InjectionToken('app.environment'); ================================================ FILE: frontend/src/app/focus.service.spec.ts ================================================ import { TestBed } from '@angular/core/testing'; import { FocusService } from './focus.service'; describe('FocusService', () => { let service: FocusService; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(FocusService); }); it('should be created', () => { expect(service).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/focus.service.ts ================================================ import {Injectable} from '@angular/core'; import {BehaviorSubject, Observable} from 'rxjs'; @Injectable({ providedIn: 'root' }) export class FocusService { private readonly $observer: BehaviorSubject; constructor() { this.$observer = new BehaviorSubject(true); document.addEventListener('visibilitychange', () => { this.$observer.next(document.visibilityState === 'visible'); }); // Safari does not emit an event for visibilityState == 'hidden'. // In this case we need to handle the 'pagehide' event. document.addEventListener('pagehide', () => this.$observer.next(false)); } /** * Returns an observer that signals true when the window is focused * and false when the window is blurred. */ public get observe(): Observable { return this.$observer; } } ================================================ FILE: frontend/src/app/order-by.pipe.spec.ts ================================================ import { OrderByPipe } from './order-by.pipe'; describe('OrderByPipe', () => { it('create an instance', () => { const pipe = new OrderByPipe(); expect(pipe).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/order-by.pipe.ts ================================================ import {Pipe, PipeTransform} from '@angular/core'; @Pipe({ name: 'orderBy' }) export class OrderByPipe implements PipeTransform { transform(values: Array, field: undefined | keyof T, orderType: boolean): Array { if (!field || !Array.isArray(values)) { // If no field is defined then return the values as-is return values; } return values.sort((a: T, b: T) => { const v0 = a[field]; const v1 = b[field]; if (v0 < v1) { return orderType ? -1 : 1; } if (v0 > v1) { return orderType ? 1 : -1; } return 0; }); } } ================================================ FILE: frontend/src/app/torrent-search.pipe.spec.ts ================================================ import { TorrentSearchPipe } from './torrent-search.pipe'; describe('TorrentSearchPipe', () => { it('create an instance', () => { const pipe = new TorrentSearchPipe(); expect(pipe).toBeTruthy(); }); }); ================================================ FILE: frontend/src/app/torrent-search.pipe.ts ================================================ import {Pipe, PipeTransform} from '@angular/core'; import {Label, Torrent} from './api.service'; type LabelledTorrent = Label & Torrent; @Pipe({ name: 'torrentSearch' }) export class TorrentSearchPipe implements PipeTransform { private filter(term: string): (t: LabelledTorrent) => boolean { return (t: LabelledTorrent): boolean => { switch (true) { case t.Name.toLowerCase().includes(term): return true; case t.State.toLowerCase().includes(term): return true; case t.DownloadLocation.toLowerCase().includes(term): return true; case t.TrackerHost.toLowerCase().includes(term): return true; case t.Label.toLowerCase().includes(term): return true; } return false; }; } transform(values: Array, term: string): Array { if (!values || !Array.isArray(values) || !term) { return values; } const predicate = this.filter(term.toLowerCase()); return values.filter(predicate); } } ================================================ FILE: frontend/src/assets/.gitkeep ================================================ ================================================ FILE: frontend/src/environments/environment.prod.ts ================================================ export const environment = { production: true }; ================================================ FILE: frontend/src/environments/environment.ts ================================================ // This file can be replaced during build by using the `fileReplacements` array. // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. // The list of file replacements can be found in `angular.json`. export const environment = { production: false }; /* * For easier debugging in development mode, you can import the following file * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. * * This import should be commented out in production mode because it will have a negative impact * on performance if an error is thrown. */ // import 'zone.js/dist/zone-error'; // Included with Angular CLI. ================================================ FILE: frontend/src/icons.scss ================================================ /** Prime Icons Adaptor. Adapts Prime Icons into FontAwesome icons */ .fas, .far { writing-mode: vertical-lr; } .pi { font-family: 'Font Awesome 5 Free'; font-style: normal; font-weight: 900; &.pi-chevron-up::before { content: '\f077' } &.pi-chevron-down::before { content: '\f078' } &.pi-chevron-right::before { content: '\f054'; } &.pi-check::before { content: '\f00c'; } &.pi-plus:before { content: '\f067'; } &.pi-upload:before { content: '\f093'; } &.pi-times:before { content: '\f00d'; } } ================================================ FILE: frontend/src/index.html ================================================ Storm ================================================ FILE: frontend/src/main.ts ================================================ import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.error(err)); ================================================ FILE: frontend/src/polyfills.ts ================================================ /** * This file includes polyfills needed by Angular and is loaded before the app. * You can add your own extra polyfills to this file. * * This file is divided into 2 sections: * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. * 2. Application imports. Files imported after ZoneJS that should be loaded before your main * file. * * The current setup is for so-called "evergreen" browsers; the last versions of browsers that * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/guide/browser-support */ /*************************************************************************************************** * BROWSER POLYFILLS */ /** IE10 and IE11 requires the following for NgClass support on SVG elements */ // import 'classlist.js'; // Run `npm install --save classlist.js`. /** * Web Animations `@angular/platform-browser/animations` * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). */ // import 'web-animations-js'; // Run `npm install --save web-animations-js`. /** * By default, zone.js will patch all possible macroTask and DomEvents * user can disable parts of macroTask/DomEvents patch by setting following flags * because those flags need to be set before `zone.js` being loaded, and webpack * will put import in the top of bundle, so user need to create a separate file * in this directory (for example: zone-flags.ts), and put the following flags * into that file, and then add the following code before importing zone.js. * import './zone-flags'; * * The flags allowed in zone-flags.ts are listed here. * * The following flags will work for all browsers. * * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames * * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js * with the following flag, it will bypass `zone.js` patch for IE/Edge * * (window as any).__Zone_enable_cross_context_check = true; * */ /*************************************************************************************************** * Zone JS is required by default for Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */ ================================================ FILE: frontend/src/styles.scss ================================================ body { background-color: var(--surface-b); padding: 0; margin: 0; min-height: 100%; font-family: var(--font-family); color: var(--text-color); } .p-component, .p-inputtext { font-size: .9rem; } .p-progressbar .p-progressbar-value { background: #5f6e82; font-size: .8rem; } button.p-button { &.t-button-alt { background: #445063; color: #a9b5c3; &:enabled:hover { color: #3f4b5b; background: #a6b3c3; } } &.p-button-text { color: #8495ad; &:enabled:hover { background: rgba(132, 149, 173, 0.04); color: #a6b3c3; } } &.p-button-danger { background: #d45e6a; border: none; } color: #8495ad; background: #3f4b5b; border: none; &:enabled:hover { background: rgba(132, 149, 173, 0.04); color: #a6b3c3; } &:focus { box-shadow: none; } } .p-overlaypanel .p-overlaypanel-content { padding: 1rem; } .t-dialog-responsive { width: 75%; } @media only screen and (max-width: 750px) { .t-dialog-responsive { width: 100% } } .fas, .far { writing-mode: horizontal-tb; } ================================================ FILE: frontend/src/test.ts ================================================ // This file is required by karma.conf.js and loads recursively all the .spec and framework files import 'zone.js/dist/zone-testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; declare const require: { context(path: string, deep?: boolean, filter?: RegExp): { keys(): string[]; (id: string): T; }; }; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting() ); // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. context.keys().map(context); ================================================ FILE: frontend/tsconfig.app.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", "types": [] }, "files": [ "src/main.ts", "src/polyfills.ts" ], "include": [ "src/**/*.d.ts" ] } ================================================ FILE: frontend/tsconfig.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "compileOnSave": false, "compilerOptions": { "baseUrl": "./", "outDir": "./dist/out-tsc", "sourceMap": true, "declaration": false, "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, "target": "es2015", "module": "es2020", "lib": [ "es2018", "dom" ] } } ================================================ FILE: frontend/tsconfig.spec.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ "jasmine" ] }, "files": [ "src/test.ts", "src/polyfills.ts" ], "include": [ "src/**/*.spec.ts", "src/**/*.d.ts" ] } ================================================ FILE: frontend/tslint.json ================================================ { "extends": "tslint:recommended", "rulesDirectory": [ "codelyzer" ], "rules": { "align": { "options": [ "parameters", "statements" ] }, "array-type": false, "arrow-return-shorthand": true, "curly": true, "deprecation": { "severity": "warning" }, "eofline": true, "import-blacklist": [ true, "rxjs/Rx" ], "import-spacing": true, "indent": { "options": [ "spaces" ] }, "max-classes-per-file": false, "max-line-length": [ true, 140 ], "member-ordering": [ true, { "order": [ "static-field", "instance-field", "static-method", "instance-method" ] } ], "no-console": [ true, "debug", "info", "time", "timeEnd", "trace" ], "no-empty": false, "no-inferrable-types": [ true, "ignore-params" ], "no-non-null-assertion": true, "no-redundant-jsdoc": true, "no-switch-case-fall-through": true, "no-var-requires": false, "object-literal-key-quotes": [ true, "as-needed" ], "quotemark": [ true, "single" ], "semicolon": { "options": [ "always" ] }, "space-before-function-paren": { "options": { "anonymous": "never", "asyncArrow": "always", "constructor": "never", "method": "never", "named": "never" } }, "typedef": [ true, "call-signature" ], "typedef-whitespace": { "options": [ { "call-signature": "nospace", "index-signature": "nospace", "parameter": "nospace", "property-declaration": "nospace", "variable-declaration": "nospace" }, { "call-signature": "onespace", "index-signature": "onespace", "parameter": "onespace", "property-declaration": "onespace", "variable-declaration": "onespace" } ] }, "variable-name": { "options": [ "ban-keywords", "check-format", "allow-pascal-case" ] }, "whitespace": { "options": [ "check-branch", "check-decl", "check-operator", "check-separator", "check-type", "check-typecast" ] }, "component-class-suffix": true, "contextual-lifecycle": true, "directive-class-suffix": true, "no-conflicting-lifecycle": true, "no-host-metadata-property": true, "no-input-rename": true, "no-inputs-metadata-property": true, "no-output-native": true, "no-output-on-prefix": true, "no-output-rename": true, "no-outputs-metadata-property": true, "template-banana-in-box": true, "template-no-negated-async": true, "use-lifecycle-interface": true, "use-pipe-transform-interface": true, "directive-selector": [ true, "attribute", "app", "camelCase" ], "component-selector": [ true, "element", "t", "kebab-case" ] } } ================================================ FILE: go.mod ================================================ module github.com/relvacode/storm go 1.17 require ( github.com/gdm85/go-libdeluge v0.6.0 github.com/gorilla/mux v1.8.0 github.com/jessevdk/go-flags v1.4.0 github.com/spf13/afero v1.6.0 go.uber.org/zap v1.16.0 ) require ( github.com/gdm85/go-rencode v0.1.8 // indirect github.com/stretchr/testify v1.5.1 // indirect go.uber.org/atomic v1.6.0 // indirect go.uber.org/multierr v1.5.0 // indirect golang.org/x/text v0.3.3 // indirect golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect ) ================================================ FILE: go.sum ================================================ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gdm85/go-libdeluge v0.5.6 h1:tSAwrlOAhu9VAMuxGacK/DMSmLN6SjHHhcVtg76fFnY= github.com/gdm85/go-libdeluge v0.5.6/go.mod h1:PATKp4wpfcubDL/uIWPSLcIFC0ear942OKvD3ZB4Vsk= github.com/gdm85/go-libdeluge v0.6.0 h1:TCqzmABxup3l1yeWWW6ZIGoxgJZrrKB5aBF6z6BbVPg= github.com/gdm85/go-libdeluge v0.6.0/go.mod h1:y3CUYGywCSDOB32/IBLomtK+fU4+lfqIFWsnA/jBoeY= github.com/gdm85/go-rencode v0.1.8 h1:7+qxwoQWU1b1nMGcESOyoUR5dzPtRA6yLQpKn7uXmnI= github.com/gdm85/go-rencode v0.1.8/go.mod h1:0dr3BuaKzeseY1of6o1KRTGB/Oo7eio+YEyz8KDp5+s= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE= golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= ================================================ FILE: http.go ================================================ package storm import ( "encoding/json" "net/http" ) // HandlerFunc is an adaptor for the http.HandlerFunc that returns JSON data. type HandlerFunc func(r *http.Request) (interface{}, error) // Send sends JSON data to the client using the supplied HTTP status code. func Send(rw http.ResponseWriter, code int, data interface{}) { enc := json.NewEncoder(rw) enc.SetIndent("", " ") rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(code) _ = enc.Encode(data) } // NoContent sends a 204 No Content response func NoContent(rw http.ResponseWriter) { rw.WriteHeader(http.StatusNoContent) } func Handle(rw http.ResponseWriter, r *http.Request, handler HandlerFunc) error { response, err := handler(r) if err != nil { SendError(rw, err) return err } if response == nil { NoContent(rw) return nil } Send(rw, http.StatusOK, response) return nil } ================================================ FILE: methods.go ================================================ package storm import ( "fmt" deluge "github.com/gdm85/go-libdeluge" "github.com/gorilla/mux" "net/http" "net/url" ) type DelugeMethod func(conn deluge.DelugeClient, r *http.Request) (interface{}, error) func torrentIDs(q url.Values, min int) ([]string, error) { var ids = q["id"] if len(ids) < min { return nil, &Error{Code: http.StatusBadRequest, Message: fmt.Sprintf("At least %d torrent ID(s) are required", min)} } return ids, nil } func httpTorrentsStatus(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var ( q = r.URL.Query() ids = q["id"] state = deluge.TorrentState(q.Get("state")) ) return conn.TorrentsStatus(state, ids) } func httpDeleteTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var ( q = r.URL.Query() rmFiles = q.Get("files") == "true" ) ids, err := torrentIDs(q, 1) if err != nil { return nil, err } errors, err := conn.RemoveTorrents(ids, rmFiles) if err != nil { return nil, err } if len(errors) > 0 { return &errors, nil } return nil, nil } func httpPauseTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { ids, err := torrentIDs(r.URL.Query(), 1) if err != nil { return nil, err } return nil, conn.PauseTorrents(ids...) } func httpResumeTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { ids, err := torrentIDs(r.URL.Query(), 1) if err != nil { return nil, err } return nil, conn.ResumeTorrents(ids...) } type AddTorrentRequest struct { Type string URI string Data string Options deluge.Options } type AddTorrentResponse struct { ID string } func httpAddTorrent(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var req AddTorrentRequest err := Read(r, &req) if err != nil { return nil, err } var id string switch req.Type { case "url": id, err = conn.AddTorrentURL(req.URI, &req.Options) case "magnet": id, err = conn.AddTorrentMagnet(req.URI, &req.Options) case "file": id, err = conn.AddTorrentFile(req.URI, req.Data, &req.Options) default: return nil, &Error{Code: http.StatusBadRequest, Message: "Torrent Type must be one of url, magnet or file"} } if err != nil { return nil, err } // The RPC returns an empty ID if the torrent could not be parsed or processed. if id == "" { return nil, &Error{Code: http.StatusUnprocessableEntity, Message: "Torrent file could not be read"} } return AddTorrentResponse{ID: id}, nil } type TorrentMethod func(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) func TorrentHandler(f TorrentMethod) DelugeMethod { return func(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { vars := mux.Vars(r) return f(vars["id"], conn, r) } } func httpTorrentStatus(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) { return conn.TorrentStatus(id) } func httpDeleteTorrent(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) { ok, err := conn.RemoveTorrent(id, r.URL.Query().Get("files") == "true") if err != nil { return nil, err } if !ok { return nil, &Error{Code: http.StatusNotFound, Message: "Requested torrent could not be deleted"} } return nil, nil } func httpPauseTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) { return nil, conn.PauseTorrents(id) } func httpResumeTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) { return nil, conn.ResumeTorrents(id) } func httpSetTorrentOptions(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var req deluge.Options err := Read(r, &req) if err != nil { return nil, err } return nil, conn.SetTorrentOptions(id, &req) } func httpGetSessionStatus(conn deluge.DelugeClient, _ *http.Request) (interface{}, error) { return conn.GetSessionStatus() } type GetFreeSpaceResponse struct { FreeBytes int64 } func httpGetFreeSpace(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { path := r.URL.Query().Get("path") freeBytes, err := conn.GetFreeSpace(path) if err != nil { return nil, err } return GetFreeSpaceResponse{ FreeBytes: freeBytes, }, nil } ================================================ FILE: methods_labels.go ================================================ package storm import ( "fmt" deluge "github.com/gdm85/go-libdeluge" "github.com/gorilla/mux" "net/http" ) // getClientV1 gets the underlying version 1 client from a DelugeClient interface. func getClientV1(conn deluge.DelugeClient) (*deluge.Client, error) { switch client := conn.(type) { case *deluge.Client: return client, nil case *deluge.ClientV2: return &client.Client, nil default: return nil, fmt.Errorf("failed to obtain version 1 Deluge client") } } // labelPluginClient constructs an instance of a deluge.LabelPlugin client from the input DelugeClient connection. func labelPluginClient(conn deluge.DelugeClient) (*deluge.LabelPlugin, error) { client, err := getClientV1(conn) if err != nil { return nil, err } return &deluge.LabelPlugin{ Client: client, }, nil } // httpLabels gets the current labels func httpLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { plugin, err := labelPluginClient(conn) if err != nil { return nil, err } return plugin.GetLabels() } // httpCreateLabel creates a new label func httpCreateLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { vars := mux.Vars(r) plugin, err := labelPluginClient(conn) if err != nil { return nil, err } err = plugin.AddLabel(vars["id"]) if err != nil { return nil, err } return nil, nil } // httpCreateLabel deletes an existing label func httpDeleteLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { vars := mux.Vars(r) plugin, err := labelPluginClient(conn) if err != nil { return nil, err } err = plugin.RemoveLabel(vars["id"]) if err != nil { return nil, err } return nil, nil } // httpTorrentsLabels gets labels associated with all torrents matching the filter. // ?id[] One or more torrent IDs // ?state Torrents of this state // // Returns a mapping of torrent hash to torrent labels func httpTorrentsLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { ids, err := torrentIDs(r.URL.Query(), 0) if err != nil { return nil, err } state := (deluge.TorrentState)(r.URL.Query().Get("state")) plugin, err := labelPluginClient(conn) if err != nil { return nil, err } labels, err := plugin.GetTorrentsLabels(state, ids) if err != nil { return nil, err } return labels, nil } type SetTorrentLabelRequest struct { Label string } // httpSetTorrentLabel sets the label for a given torrent hash func httpSetTorrentLabel(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var req SetTorrentLabelRequest err := Read(r, &req) if err != nil { return nil, err } plugin, err := labelPluginClient(conn) if err != nil { return nil, err } err = plugin.SetTorrentLabel(id, req.Label) if err != nil { return nil, err } return nil, nil } ================================================ FILE: methods_plugins.go ================================================ package storm import ( deluge "github.com/gdm85/go-libdeluge" "github.com/gorilla/mux" "net/http" ) // httpGetPlugins gets all the currently enabled plugins func httpGetPlugins(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { return conn.GetEnabledPlugins() } func httpEnablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { id := mux.Vars(r)["id"] err := conn.EnablePlugin(id) if err != nil { return nil, err } return nil, nil } func httpDisablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { id := mux.Vars(r)["id"] err := conn.DisablePlugin(id) if err != nil { return nil, err } return nil, nil } ================================================ FILE: methods_view.go ================================================ package storm import ( "crypto/sha1" "encoding/hex" "encoding/json" deluge "github.com/gdm85/go-libdeluge" "net/http" "sort" ) type ViewTorrent struct { Hash string Label string *deluge.TorrentStatus } type ViewUpdate struct { Torrents []*ViewTorrent Session *deluge.SessionStatus DiskFree int64 } type ViewUpdateResponse struct { ViewUpdate ETag string } func httpViewUpdate(conn deluge.DelugeClient, r *http.Request) (interface{}, error) { var ( q = r.URL.Query() ids = q["id"] state = deluge.TorrentState(q.Get("state")) ) torrents, err := conn.TorrentsStatus(state, ids) if err != nil { return nil, err } var torrentHashes = make([]string, 0, len(torrents)) for k := range torrents { torrentHashes = append(torrentHashes, k) } sort.Strings(torrentHashes) var torrentLabels = make(map[string]string) plugin, err := labelPluginClient(conn) if err == nil { labels, err := plugin.GetTorrentsLabels(state, ids) if err == nil { torrentLabels = labels } } var responseTorrents = make([]*ViewTorrent, 0, len(torrents)) for _, k := range torrentHashes { responseTorrents = append(responseTorrents, &ViewTorrent{ Hash: k, Label: torrentLabels[k], TorrentStatus: torrents[k], }) } session, err := conn.GetSessionStatus() if err != nil { return nil, err } diskFree, err := conn.GetFreeSpace(q.Get("path")) update := ViewUpdate{ Torrents: responseTorrents, Session: session, DiskFree: diskFree, } // ETag calculation var h = sha1.New() _ = json.NewEncoder(h).Encode(&update) responseETag := hex.EncodeToString(h.Sum(nil)) if requestETag := r.Header.Get("ETag"); requestETag != "" && requestETag == responseETag { return nil, &Error{ Code: http.StatusNotModified, Message: "View not modified since last request", } } return &ViewUpdateResponse{ ViewUpdate: update, ETag: responseETag, }, nil } ================================================ FILE: pool.go ================================================ package storm import ( "context" "errors" deluge "github.com/gdm85/go-libdeluge" "go.uber.org/zap" "sync" "time" ) type DelugeProvider func() deluge.DelugeClient type poolReq struct { // If the context has been cancelled then no connection is returned ctx context.Context // The replied client may be nil if the connection could not be established reply chan<- deluge.DelugeClient } func (req *poolReq) Send(conn deluge.DelugeClient) bool { select { case <-req.ctx.Done(): return false case req.reply <- conn: return true } } type idleConnection struct { idle time.Time conn deluge.DelugeClient } type Timer interface { Ch() <-chan time.Time Stop() bool } type timeTimer time.Timer func (t *timeTimer) Ch() <-chan time.Time { return (*time.Timer)(t).C } func (t *timeTimer) Stop() bool { return (*time.Timer)(t).Stop() } type nullTimer struct{} func (nullTimer) Ch() <-chan time.Time { return nil } func (nullTimer) Stop() bool { return true } func NewConnectionPool(log *zap.Logger, maxConnections int, idleConnectionTime time.Duration, provider DelugeProvider) *ConnectionPool { pool := &ConnectionPool{ Log: log, MaxConnections: maxConnections, IdleConnectionTime: idleConnectionTime, Provider: provider, get: make(chan *poolReq), put: make(chan deluge.DelugeClient), close: make(chan struct{}), alive: new(sync.Mutex), idle: nullTimer{}, } go pool.worker() return pool } type ConnectionPool struct { Log *zap.Logger MaxConnections int IdleConnectionTime time.Duration Provider DelugeProvider get chan *poolReq put chan deluge.DelugeClient close chan struct{} alive *sync.Mutex waitConn []*poolReq inFlight int pool []*idleConnection idle Timer } func (pool *ConnectionPool) nextIdle() { // Go through the other connections. // Check that they are not expired, if not set the new idle to the first valid connection for { if len(pool.pool) == 0 { // Otherwise, there are no more connections to make idle pool.idle = nullTimer{} return } conn := pool.pool[0] expires := conn.idle.Sub(time.Now()) // Next connection is now idle. Delete it. if expires < 0 { err := conn.conn.Close() if err != nil { pool.Log.Error("Failed to closed idle connection", zap.Error(err)) } pool.pool = pool.pool[1:] continue } // Valid connection that is not idle t := time.NewTimer(expires) pool.idle = (*timeTimer)(t) return } } func (pool *ConnectionPool) idleExpired() { var conn *idleConnection conn, pool.pool = pool.pool[0], pool.pool[1:] err := conn.conn.Close() if err != nil { pool.Log.Error("Failed to closed idle connection", zap.Error(err)) } pool.idle.Stop() pool.nextIdle() } func (pool *ConnectionPool) putConn(conn deluge.DelugeClient) { // Check if anyone is waiting for a connection for len(pool.waitConn) > 0 { w := pool.waitConn[0] pool.waitConn[0] = nil pool.waitConn = pool.waitConn[1:] select { case <-w.ctx.Done(): // waiter's connection has been cancelled case w.reply <- conn: // waiter has received connection return } } idle := &idleConnection{idle: time.Now().Add(pool.IdleConnectionTime), conn: conn} // There are no existing connections in the pool. // Set the new pool timer if len(pool.pool) == 0 { pool.idle.Stop() t := time.NewTimer(pool.IdleConnectionTime) pool.idle = (*timeTimer)(t) } pool.pool = append(pool.pool, idle) pool.inFlight-- } func (pool *ConnectionPool) closeConns() { pool.idle.Stop() pool.idle = nullTimer{} // Close any waiters for len(pool.waitConn) > 0 { var w *poolReq w, pool.waitConn = pool.waitConn[0], pool.waitConn[1:] close(w.reply) } // Close all connections within the pool for len(pool.pool) > 0 { var c *idleConnection c, pool.pool = pool.pool[0], pool.pool[1:] _ = c.conn.Close() } } func (pool *ConnectionPool) getConn(req *poolReq) { // There are connections that can be sent straight away if len(pool.pool) > 0 { c := pool.pool[0] if req.Send(c.conn) { // Connection successfully sent pool.pool = pool.pool[1:] pool.inFlight++ pool.idle.Stop() pool.nextIdle() } return } // There are more connections in-flight. // Add the request to the list of waiting requests. if pool.inFlight >= pool.MaxConnections { pool.waitConn = append(pool.waitConn, req) return } // A new connection can be established conn := pool.Provider() err := conn.Connect() if err != nil { pool.Log.Error("Failed to establish Deluge RPC connection", zap.Error(err)) conn = nil } ok := req.Send(conn) // The connection was nil so we don't care what the send response was if conn == nil { return } pool.inFlight++ // Connection successfully sent if ok { return } // Connection was established but could not be sent to the caller // Put the established connection into the pool pool.putConn(conn) } func (pool *ConnectionPool) worker() { pool.alive.Lock() defer pool.alive.Unlock() for { select { case <-pool.idle.Ch(): // The first connection is now idle pool.idleExpired() case req := <-pool.get: pool.getConn(req) case conn := <-pool.put: // A connection has been put back pool.putConn(conn) case <-pool.close: pool.closeConns() return } } } // Get gets a connected connection from the pool. // If there are no available connections in the pool then one is created and connected to. // If there already too many active connections, Get will block until a connection is available // or the given context is cancelled. func (pool *ConnectionPool) Get(ctx context.Context) (deluge.DelugeClient, error) { // TODO someone needs to close this replyCh := make(chan deluge.DelugeClient) pool.get <- &poolReq{ctx: ctx, reply: replyCh} select { case <-pool.close: return nil, errors.New("The Deluge RPC connection pool has been closed") case <-ctx.Done(): return nil, ctx.Err() case conn := <-replyCh: if conn == nil { return nil, errors.New("A connection to the Deluge RPC daemon could not be established by the connection pool") } return conn, nil } } // Put puts a connection back to the pool. func (pool *ConnectionPool) Put(conn deluge.DelugeClient) { select { case <-pool.close: // Pool has been closed before the connection can be put back _ = conn.Close() case pool.put <- conn: } } func (pool *ConnectionPool) Close() { close(pool.close) // Achieve a lock on the pool alive mutex. // When a lock is achieved the pool worker daemon has been closed. // Keep the alive mutex locked. pool.alive.Lock() } ================================================ FILE: request.go ================================================ package storm import ( "encoding/json" "io" "net/http" ) const ( // MaxRequestSize is the maximum allowed request size in bytes MaxRequestSize = 5 << 20 ) // Read reads JSON data from the request. func Read(r *http.Request, into interface{}) error { var lr = io.LimitReader(r.Body, MaxRequestSize).(*io.LimitedReader) var dec = json.NewDecoder(lr) var err = dec.Decode(into) _ = r.Body.Close() // Request too large (limited reader fully consumed) if lr.N < 1 { return &Error{Code: http.StatusRequestEntityTooLarge, Message: "Request payload exceeds maximum limit"} } if err != nil { return Hint(http.StatusBadRequest, err) } return nil } ================================================ FILE: response.go ================================================ package storm import ( "net/http" "time" ) var _ http.ResponseWriter = (*WrappedResponse)(nil) // WrapResponse wraps a response func WrapResponse(rw http.ResponseWriter) *WrappedResponse { return &WrappedResponse{ ResponseWriter: rw, started: time.Now().UTC(), } } // WrappedResponse wraps a http.ResponseWriter to capture information about the response type WrappedResponse struct { http.ResponseWriter started time.Time sent time.Time code int error error payloadSize int } func (rw *WrappedResponse) WriteHeader(code int) { rw.code = code rw.sent = time.Now().UTC() rw.ResponseWriter.WriteHeader(code) // Set error based off the status text if an explicit error is not otherwise provided if code > 399 && rw.error == nil { rw.error = &Error{ Code: code, Message: http.StatusText(code), } } } func (rw *WrappedResponse) Write(b []byte) (int, error) { wr, err := rw.ResponseWriter.Write(b) rw.payloadSize += wr return wr, err } func (rw *WrappedResponse) Code() int { return rw.code } func (rw *WrappedResponse) Started() time.Time { return rw.started } // Duration returns the total duration of the request to response. func (rw *WrappedResponse) Duration() time.Duration { return rw.sent.Sub(rw.started) } // Len returns the total payload size in bytes. func (rw *WrappedResponse) Len() int { return rw.payloadSize } func (rw *WrappedResponse) Error() error { return rw.error } ================================================ FILE: static.go ================================================ package storm import ( "embed" ) //go:embed frontend/dist/* var Static embed.FS