[
  {
    "path": ".dockerignore",
    "content": "frontend/node_modules\nfrontend/.angular\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "on:\r\n  release:\r\n    types: [ created ]\r\n\r\njobs:\r\n  compile-frontend:\r\n    name: Compile Frontend\r\n    runs-on: ubuntu-latest\r\n    steps:\r\n      - uses: actions/checkout@v2\r\n      - uses: actions/setup-node@v2\r\n        with:\r\n          node-version: '16'\r\n          cache: npm\r\n          cache-dependency-path: frontend/package-lock.json\r\n      - run: |\r\n          cd frontend\r\n          npm install\r\n          npm run build:prod\r\n      - uses: actions/upload-artifact@v2\r\n        with:\r\n          name: frontend-dist\r\n          path: frontend/dist/\r\n          if-no-files-found: error\r\n  go-binary-release:\r\n    name: Release Go Binary\r\n    runs-on: ubuntu-latest\r\n    needs: compile-frontend\r\n    strategy:\r\n      matrix:\r\n        goos: [ linux, windows, darwin ]\r\n        goarch: [ amd64, arm, arm64 ]\r\n        exclude:\r\n          - goarch: arm\r\n            goos: darwin\r\n          - goarch: arm64\r\n            goos: windows\r\n          - goarch: arm\r\n            goos: windows\r\n    steps:\r\n      - uses: actions/checkout@v2\r\n      - uses: actions/download-artifact@v2\r\n        with:\r\n          name: frontend-dist\r\n          path: frontend/dist\r\n      - uses: wangyoucao577/go-release-action@v1.22\r\n        with:\r\n          github_token: ${{ secrets.GITHUB_TOKEN }}\r\n          goos: ${{ matrix.goos }}\r\n          goarch: ${{ matrix.goarch }}\r\n          asset_name: storm-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}\r\n          project_path: \"./cmd/storm\"\r\n          binary_name: \"storm\"\r\n          extra_files: LICENSE README.md\r\n          sha256sum: true\r\n          md5sum: false\r\n          overwrite: true\r\n  docker-image:\r\n    name: Build Docker Image\r\n    runs-on: ubuntu-latest\r\n    permissions:\r\n      packages: write\r\n      contents: read\r\n    needs: compile-frontend\r\n    steps:\r\n      - uses: actions/checkout@v2\r\n      - uses: actions/download-artifact@v2\r\n        with:\r\n          name: frontend-dist\r\n          path: frontend/dist\r\n      - uses: docker/setup-qemu-action@v1\r\n      - uses: docker/setup-buildx-action@v1\r\n      - uses: docker/login-action@v1\r\n        with:\r\n          registry: ghcr.io\r\n          username: ${{ github.actor }}\r\n          password: ${{ secrets.GITHUB_TOKEN }}\r\n      - uses: docker/build-push-action@v2\r\n        with:\r\n          context: .\r\n          push: true\r\n          tags: |\r\n            ghcr.io/${{ github.repository }}:latest\r\n            ghcr.io/${{ github.repository }}:${{ github.ref_name }}\r\n          platforms: |\r\n            linux/amd64\r\n            linux/arm64\r\n            linux/arm/v7\r\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n.angular\ncmd/storm/storm\n\nfrontend/node_modules\nfrontend/.idea\nfrontend/dist\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM --platform=${BUILDPLATFORM} golang:alpine as compiler\nARG TARGETOS\nARG TARGETARCH\nENV CGO_ENABLED=0\n\nWORKDIR /go/src/storm\n\nCOPY . .\n\nRUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags=\"-s -w\" github.com/relvacode/storm/cmd/storm\n\n\nFROM --platform=${TARGETPLATFORM} alpine\nCOPY --from=compiler /go/src/storm/storm /usr/bin/storm\n\nENTRYPOINT [\"/usr/bin/storm\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 Jason Kingsbury\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "\n<p align=\"middle\"><img src=\"frontend/src/assets/logo.svg\" height=\"200\"/></p>\n\n\n> You probably have a whole [media stack](https://github.com/relvacode/mediastack) for managing your _legitimate_ media files remotely.\n>\n> 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.\n>\n> Introducing __Storm__\n>\n> A slick remote interface for Deluge that fully supports mobile devices (including as a home-screen app)\n\n&nbsp;\n\n<p float=\"left\" align=\"middle\">\n<img src=\"_docs/example-torrent-view.jpg\" height=\"450\"/>\n<img src=\"_docs/example-add-torrent-menu.jpg\" height=\"450\"/>\n<img src=\"_docs/example-filter-state.jpg\" height=\"450\"/>\n</p>\n\n#### Usage\n\n```\ndocker run --name storm \\\n  --network deluge \\\n  -p 8221:8221 \\\n  -e DELUGE_RPC_HOSTNAME=deluge \\\n  -e DELUGE_RPC_USERNAME=username \\\n  -e DELUGE_RPC_PASSWORD=password \\\n  ghcr.io/relvacode/storm\n```\n\n\nThe recommended way to run Storm is with a Docker image. \n\nYou'll need a Deluge container running with a valid [auth configuration](https://dev.deluge-torrent.org/wiki/UserGuide/Authentication). \nStorm 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.\n\nOnce that's setup you'll need to configure Deluge to allow remote RPC connections:\n\nOpen up `core.conf` in your Deluge configuration folder and set\n\n```\n\"allow_remote\": true\n```\n\nThen you can use the following environment variables to configure Storm\n\n| Environment | Description |\n| ----------- | ----------- |\n| `DELUGE_RPC_HOSTNAME` | The Deluge RPC hostname |\n| `DELUGE_RPC_PORT` | The Deluge RPC port |\n| `DELUGE_RPC_USERNAME` | The username from Deluge auth |\n| `DELUGE_RPC_PASSWORD` | The password from Deluge auth |\n| `DELUGE_RPC_VERSION` | `v1` or `v2` depending on your Deluge version |\n| `STORM_API_KEY` | Enable authentication for the Storm API |\n| `STORM_BASE_PATH` | Set the base URL path. Defaults to `/` |\n\n##### Security\n\nBy 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.\n\nStorm 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`.\n\nSet this to a reasonably secure password. Any requests made to Storm must now provide the API key in the request.\n\nYou 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.\n\n##### Deluge Version\n\nDeluge 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`.\n\nNote 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.\n\n#### Development\n\nThe application is split into two parts, the frontend Angular code and the backend Go API adapter.\n\nThe backend API is presented as an HTTP REST api which talks directly to the Deluge RPC daemon.\nIt also has the frontend code embedded directly into the binary so the application can be distributed as a single binary.\n\nTo start a development environment you must first install the node modules\n\n```\ncd frontend\nnpm install\n```\n\nThen start the Angular build server in watch mode. This will output the frontend distributable into `frontend/dist` and watch for changes.\n\n```\ncd frontend\nnpm run build -- --watch --configuration=development\n```\n\nIn a separate terminal you can now start the main Storm binary in development mode.\nIn development mode, instead of serving the frontend from the binary embedded source it will instead serve directly from the filesystem.\n\n```\ngo run github.com/relvacode/storm/cmd/storm --listen=127.0.0.1:8221 --dev-mode [OPTIONS...]\n```"
  },
  {
    "path": "api.go",
    "content": "package storm\n\nimport (\n\t\"crypto/subtle\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/spf13/afero\"\n\t\"go.uber.org/zap\"\n\t\"html/template\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tApiAuthCookieName = \"storm-api-key\"\n)\n\nfunc appendSuffix(s, suffix string) string {\n\tif strings.HasSuffix(s, suffix) {\n\t\treturn s\n\t}\n\n\treturn fmt.Sprint(s, suffix)\n}\n\nfunc New(log *zap.Logger, pool *ConnectionPool, pathPrefix string, apiKey string, development bool) *Api {\n\tapi := &Api{\n\t\tpool:       pool,\n\t\tpathPrefix: strings.TrimSuffix(pathPrefix, \"/\"),\n\t\tapiKey:     apiKey,\n\t\tlog:        log,\n\t\trouter:     mux.NewRouter(),\n\t}\n\n\tapi.router.NotFoundHandler = api.httpNotFound()\n\tapi.bind(development)\n\n\treturn api\n}\n\ntype Api struct {\n\tpool       *ConnectionPool\n\tpathPrefix string\n\tapiKey     string\n\n\tlog    *zap.Logger\n\trouter *mux.Router\n}\n\nfunc (api *Api) DelugeHandler(f DelugeMethod) http.HandlerFunc {\n\treturn func(rw http.ResponseWriter, r *http.Request) {\n\t\t_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {\n\t\t\tconn, err := api.pool.Get(r.Context())\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tret, err := f(conn, r)\n\t\t\tapi.pool.Put(conn)\n\n\t\t\tswitch t := err.(type) {\n\t\t\tcase deluge.RPCError:\n\t\t\t\terr = RPCError{\n\t\t\t\t\tExceptionType:    t.ExceptionType,\n\t\t\t\t\tExceptionMessage: t.ExceptionMessage,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn ret, err\n\t\t})\n\t}\n}\n\nfunc (api *Api) ServeHTTP(rw http.ResponseWriter, r *http.Request) {\n\tapi.router.ServeHTTP(rw, r)\n}\n\n// httpNotFound implements http.Handler which returns a not found error message.\nfunc (api *Api) httpNotFound() http.Handler {\n\treturn http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n\t\t_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {\n\t\t\treturn nil, &Error{\n\t\t\t\tMessage: \"Not found\",\n\t\t\t\tCode:    http.StatusNotFound,\n\t\t\t}\n\t\t})\n\t})\n}\n\nfunc (api *Api) templateContext() interface{} {\n\tpath := appendSuffix(api.pathPrefix, \"/\")\n\treturn map[string]string{\n\t\t\"BasePath\":    path,\n\t\t\"BaseAPIPath\": fmt.Sprint(path, \"api/\"),\n\t}\n}\n\n// renderTemplate takes the contents of a go html/template found at source `name`\n// and renders it back to the file system using templateContext().\nfunc (api *Api) renderTemplate(fs afero.Fs, name string) {\n\tlog := api.log.With(zap.String(\"template\", name))\n\n\tf, err := fs.Open(name)\n\tif err != nil {\n\t\tlog.Error(\"failed to open template for rendering\", zap.Error(err))\n\t\treturn\n\t}\n\n\ttemplateSource, err := io.ReadAll(f)\n\tif err != nil {\n\t\tlog.Error(\"failed to read template\", zap.Error(err))\n\t\treturn\n\t}\n\n\t_ = f.Close()\n\n\tt, err := template.New(name).Parse(string(templateSource))\n\tif err != nil {\n\t\tlog.Error(\"failed to parse template\", zap.Error(err))\n\t\treturn\n\t}\n\n\tw, err := fs.OpenFile(name, os.O_WRONLY|os.O_TRUNC, os.FileMode(0600))\n\tif err != nil {\n\t\tlog.Error(\"failed to open template file for writing\", zap.Error(err))\n\t\treturn\n\t}\n\n\terr = t.Execute(w, api.templateContext())\n\tif err != nil {\n\t\tlog.Error(\"failed to render template\", zap.Error(err))\n\t\treturn\n\t}\n\n\terr = w.Close()\n\tif err != nil {\n\t\tlog.Error(\"failed to close rendered template\", zap.Error(err))\n\t\treturn\n\t}\n}\n\nfunc (api *Api) bindStatic(router *mux.Router, development bool) {\n\tvar static, _ = fs.Sub(Static, \"frontend/dist\")\n\tif development {\n\t\tstatic = os.DirFS(\"./frontend/dist\")\n\t}\n\n\tvar (\n\t\tmem = afero.NewMemMapFs()\n\t\tufs = afero.NewCopyOnWriteFs(&afero.FromIOFS{\n\t\t\tFS: static,\n\t\t}, mem)\n\t)\n\n\tapi.renderTemplate(ufs, \"index.html\")\n\n\tfileServer := http.FileServer(afero.NewHttpFs(ufs).Dir(\"\"))\n\tif api.pathPrefix != \"\" {\n\t\tfileServer = http.StripPrefix(api.pathPrefix, fileServer)\n\t}\n\n\trouter.Methods(http.MethodGet).Handler(fileServer)\n}\n\n// keyFromRequest attempts to locate the API key from the incoming request.\n// It looks for the key using these methods in the following order:\n//   - The password component of a Basic auth header\n//   - The cookie value ApiAuthCookieName as a base64 encoded value\nfunc (api *Api) keyFromRequest(r *http.Request) (string, bool) {\n\t_, password, ok := r.BasicAuth()\n\tif ok {\n\t\treturn password, false\n\t}\n\n\tfromCookie, err := r.Cookie(ApiAuthCookieName)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\n\tfromCookieDecoded, _ := base64.StdEncoding.DecodeString(fromCookie.Value)\n\n\treturn string(fromCookieDecoded), true\n}\n\n// logForRequest takes a WrappedResponse and an incoming HTTP request and logs it\nfunc (api *Api) logForRequest(rw *WrappedResponse, r *http.Request) {\n\tlogger := api.log.With(\n\t\tzap.String(\"Method\", r.Method),\n\t\tzap.String(\"URL\", r.URL.String()),\n\t\tzap.String(\"RemoteAddr\", r.RemoteAddr),\n\t\tzap.Time(\"Time\", rw.Started()),\n\t\tzap.Int(\"StatusCode\", rw.code),\n\t\tzap.Int(\"ResponseSize\", rw.Len()),\n\t\tzap.Duration(\"Duration\", rw.Duration()),\n\t)\n\n\tvar logLevelFunc = logger.Info\n\n\tif rw.error != nil {\n\t\t// Do not log notification errors\n\t\thttpError, ok := rw.error.(HTTPError)\n\t\tif !ok || httpError.StatusCode() >= 400 {\n\t\t\tlogLevelFunc = logger.With(zap.Error(rw.error)).Error\n\t\t}\n\t}\n\n\tlogLevelFunc(http.StatusText(rw.code))\n}\n\nfunc (api *Api) httpMiddlewareLog(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n\t\twr := WrapResponse(rw)\n\n\t\tnext.ServeHTTP(wr, r)\n\n\t\tapi.logForRequest(wr, r)\n\t})\n}\n\nfunc (api *Api) httpMiddlewareAuthenticate(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n\t\tapiKey, fromCookie := api.keyFromRequest(r)\n\t\tif apiKey == \"\" {\n\t\t\tSendError(rw, &Error{\n\t\t\t\tCode:    http.StatusUnauthorized,\n\t\t\t\tMessage: \"No authentication provided in request\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tok := subtle.ConstantTimeCompare([]byte(api.apiKey), []byte(apiKey)) == 1\n\t\tif !ok {\n\t\t\tSendError(rw, &Error{\n\t\t\t\tCode:    http.StatusUnauthorized,\n\t\t\t\tMessage: \"Incorrect API key\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// If the request didn't originate from a cookie, then set the cookie\n\t\tif !fromCookie {\n\t\t\thttp.SetCookie(rw, &http.Cookie{\n\t\t\t\tName:     ApiAuthCookieName,\n\t\t\t\tValue:    base64.StdEncoding.EncodeToString([]byte(apiKey)),\n\t\t\t\tPath:     fmt.Sprintf(\"%s/api\", api.pathPrefix),\n\t\t\t\tExpires:  time.Now().Add(time.Hour * 24 * 365),\n\t\t\t\tSameSite: http.SameSiteStrictMode,\n\t\t\t\tHttpOnly: true,\n\t\t\t})\n\t\t}\n\n\t\tnext.ServeHTTP(rw, r)\n\t})\n}\n\nfunc (api *Api) bind(development bool) {\n\tprimaryRouter := api.router\n\tif api.pathPrefix != \"\" {\n\t\tprimaryRouter = api.router.PathPrefix(api.pathPrefix).Subrouter()\n\t}\n\n\trouter := primaryRouter.PathPrefix(\"/api\").Subrouter()\n\trouter.NotFoundHandler = api.httpNotFound()\n\n\t// CORS\n\trouter.Methods(http.MethodOptions).HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {\n\t\trw.WriteHeader(http.StatusOK)\n\t})\n\n\tapiRouter := router.NewRoute().Subrouter()\n\tapiRouter.Use(api.httpMiddlewareLog)\n\n\t// Enable API level authentication\n\tif api.apiKey != \"\" {\n\t\tapiRouter.Use(api.httpMiddlewareAuthenticate)\n\t}\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/ping\").\n\t\tHandlerFunc(func(rw http.ResponseWriter, r *http.Request) {\n\t\t\trw.WriteHeader(http.StatusNoContent)\n\t\t})\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/session\").\n\t\tHandlerFunc(api.DelugeHandler(httpGetSessionStatus))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/disk/free\").\n\t\tHandlerFunc(api.DelugeHandler(httpGetFreeSpace))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/view\").\n\t\tHandlerFunc(api.DelugeHandler(httpViewUpdate))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/plugins\").\n\t\tHandlerFunc(api.DelugeHandler(httpGetPlugins))\n\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/plugins/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(httpEnablePlugin))\n\n\tapiRouter.\n\t\tMethods(http.MethodDelete).\n\t\tPath(\"/plugins/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(httpDisablePlugin))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/torrents\").\n\t\tHandlerFunc(api.DelugeHandler(httpTorrentsStatus))\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrents\").\n\t\tHandlerFunc(api.DelugeHandler(httpAddTorrent))\n\tapiRouter.\n\t\tMethods(http.MethodDelete).\n\t\tPath(\"/torrents\").\n\t\tHandlerFunc(api.DelugeHandler(httpDeleteTorrents))\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrents/pause\").\n\t\tHandlerFunc(api.DelugeHandler(httpPauseTorrents))\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrents/resume\").\n\t\tHandlerFunc(api.DelugeHandler(httpResumeTorrents))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/torrent/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpTorrentStatus)))\n\tapiRouter.\n\t\tMethods(http.MethodDelete).\n\t\tPath(\"/torrent/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpDeleteTorrent)))\n\tapiRouter.\n\t\tMethods(http.MethodPut).\n\t\tPath(\"/torrent/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentOptions)))\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrent/{id}/pause\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpPauseTorrent)))\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrent/{id}/resume\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpResumeTorrent)))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/labels\").\n\t\tHandlerFunc(api.DelugeHandler(httpLabels))\n\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/labels/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(httpCreateLabel))\n\n\tapiRouter.\n\t\tMethods(http.MethodDelete).\n\t\tPath(\"/labels/{id}\").\n\t\tHandlerFunc(api.DelugeHandler(httpDeleteLabel))\n\n\tapiRouter.\n\t\tMethods(http.MethodGet).\n\t\tPath(\"/torrents/labels\").\n\t\tHandlerFunc(api.DelugeHandler(httpTorrentsLabels))\n\n\tapiRouter.\n\t\tMethods(http.MethodPost).\n\t\tPath(\"/torrent/{id}/label\").\n\t\tHandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentLabel)))\n\n\t// Static files\n\tapi.bindStatic(primaryRouter, development)\n}\n"
  },
  {
    "path": "cmd/storm/server.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/jessevdk/go-flags\"\n\tstorm \"github.com/relvacode/storm\"\n\t\"go.uber.org/zap\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n)\n\nfunc signalContext(ctx context.Context) context.Context {\n\tsig := make(chan os.Signal, 1)\n\tsignal.Notify(sig, os.Interrupt, syscall.SIGTERM)\n\n\txCtx, cancel := context.WithCancel(ctx)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\tcase <-sig:\n\t\t\tcancel()\n\t\t}\n\t\tsignal.Stop(sig)\n\t}()\n\n\treturn xCtx\n}\n\ntype Duration struct {\n\tDuration time.Duration\n}\n\nfunc (dur *Duration) UnmarshalFlag(value string) (err error) {\n\tdur.Duration, err = time.ParseDuration(value)\n\treturn\n}\n\ntype Path string\n\nfunc (p *Path) UnmarshalFlag(value string) error {\n\tif strings.HasSuffix(value, \"/\") {\n\t\tvalue = strings.TrimSuffix(value, \"/\")\n\t}\n\tif !strings.HasPrefix(value, \"/\") {\n\t\tvalue = fmt.Sprint(\"/\", value)\n\t}\n\n\t*p = Path(value)\n\treturn nil\n}\n\ntype ServerOptions struct {\n\tListen          string `short:\"l\" long:\"listen\" default:\":8221\" env:\"LISTEN_ADDR\" description:\"The address for the HTTP server\"`\n\tLogStyle        string `long:\"log-style\" choice:\"production\" choice:\"console\" default:\"console\" env:\"LOGGING_STYLE\" description:\"The style of log messages\"`\n\tBasePath        *Path  `long:\"base-path\" required:\"true\" default:\"/\" env:\"STORM_BASE_PATH\" description:\"Respond to requests from this base URL path\"`\n\tApiKey          string `long:\"api-key\" env:\"STORM_API_KEY\" description:\"Set the password required to access the API (enables authentication)\"`\n\tDevelopmentMode bool   `long:\"dev-mode\" env:\"DEV_MODE\" description:\"Run in development mode\"`\n}\n\nfunc (options *ServerOptions) Logger() (*zap.Logger, error) {\n\tvar cfg zap.Config\n\tswitch options.LogStyle {\n\tcase \"production\":\n\t\tcfg = zap.NewProductionConfig()\n\tcase \"console\":\n\t\tcfg = zap.NewDevelopmentConfig()\n\tdefault:\n\t\tpanic(\"Invalid LogStyle choice\")\n\t}\n\n\tcfg.DisableStacktrace = true\n\treturn cfg.Build()\n}\n\nfunc (options *ServerOptions) RunHandler(ctx context.Context, log *zap.Logger, handler http.Handler) error {\n\tvar (\n\t\terrors = make(chan error, 1)\n\t\tserver = &http.Server{\n\t\t\tAddr:    options.Listen,\n\t\t\tHandler: handler,\n\t\t}\n\t)\n\n\tgo func() {\n\t\terrors <- server.ListenAndServe()\n\t}()\n\n\tlog.Info(fmt.Sprintf(\"Ready to serve HTTP connections on %s%s\", options.Listen, *options.BasePath))\n\n\tdefer close(errors)\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tlog.Error(\"Interrupt signal received. Gracefully shutting down...\")\n\t\ttimeout, cancel := context.WithTimeout(context.Background(), time.Minute)\n\t\t_ = server.Shutdown(timeout)\n\t\tcancel()\n\tcase err := <-errors:\n\t\treturn err\n\t}\n\n\treturn <-errors\n}\n\ntype DelugeOptions struct {\n\tVersion  string `long:\"deluge-version\" choice:\"v1\" choice:\"v2\" default:\"v1\" env:\"DELUGE_RPC_VERSION\" description:\"The Deluge RPC version\"`\n\tHostname string `short:\"H\" long:\"hostname\" required:\"true\" env:\"DELUGE_RPC_HOSTNAME\" description:\"The Deluge RPC hostname\"`\n\tPort     uint   `short:\"P\" long:\"port\" default:\"58846\" env:\"DELUGE_RPC_PORT\" description:\"The Deluge RPC port\"`\n\tUsername string `short:\"u\" long:\"username\" env:\"DELUGE_RPC_USERNAME\" description:\"The Deluge RPC username\"`\n\tPassword string `short:\"p\" long:\"password\" env:\"DELUGE_RPC_PASSWORD\" description:\"The Deluge RPC password\"`\n\n\tMaxConnections int       `long:\"max-connections\" env:\"POOL_MAX_CONNECTIONS\" required:\"true\" default:\"5\" description:\"Maximum concurrent Deluge RPC connections\"`\n\tIdleTime       *Duration `long:\"idle-time\" env:\"POOL_IDLE_TIME\" required:\"true\" default:\"30s\" description:\"Close idle Deluge RPC connections after this duration\"`\n}\n\nfunc (options *DelugeOptions) Client() storm.DelugeProvider {\n\tvar settings = deluge.Settings{\n\t\tHostname:         options.Hostname,\n\t\tPort:             options.Port,\n\t\tLogin:            options.Username,\n\t\tPassword:         options.Password,\n\t\tReadWriteTimeout: time.Minute * 5,\n\t}\n\n\treturn func() deluge.DelugeClient {\n\n\t\tswitch options.Version {\n\t\tcase \"v1\":\n\t\t\treturn deluge.NewV1(settings)\n\t\tcase \"v2\":\n\t\t\treturn deluge.NewV2(settings)\n\t\tdefault:\n\t\t\tpanic(\"Invalid Version choice\")\n\t\t}\n\t}\n}\n\nfunc (options *DelugeOptions) Pool(log *zap.Logger) *storm.ConnectionPool {\n\treturn storm.NewConnectionPool(log, options.MaxConnections, options.IdleTime.Duration, options.Client())\n}\n\ntype Options struct {\n\tServerOptions\n\tDelugeOptions\n}\n\nfunc Main() error {\n\tvar options Options\n\tvar parser = flags.NewParser(&options, flags.HelpFlag)\n\n\t_, err := parser.Parse()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog, err := (&options.ServerOptions).Logger()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer log.Sync()\n\n\tctx := signalContext(context.Background())\n\n\tpool := (&options.DelugeOptions).Pool(log.Named(\"pool\"))\n\tdefer pool.Close()\n\n\tif options.DevelopmentMode {\n\t\tlog.Info(\"Running in development mode\")\n\t}\n\n\tvar (\n\t\tapiLog = log.Named(\"api\")\n\t\tapi    = storm.New(apiLog, pool, (string)(*options.BasePath), options.ServerOptions.ApiKey, options.DevelopmentMode)\n\t)\n\n\treturn (&options.ServerOptions).RunHandler(ctx, apiLog, api)\n}\n\nfunc main() {\n\terr := Main()\n\tif err != nil {\n\t\t_, _ = fmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "docker-compose/auth",
    "content": "localclient:deluge:10"
  },
  {
    "path": "docker-compose/core.conf",
    "content": "{\n    \"file\": 1,\n    \"format\": 1\n}{\n    \"add_paused\": false,\n    \"allow_remote\": true,\n    \"auto_manage_prefer_seeds\": false,\n    \"auto_managed\": true,\n    \"cache_expiry\": 60,\n    \"cache_size\": 512,\n    \"copy_torrent_file\": false,\n    \"daemon_port\": 58846,\n    \"del_copy_torrent_file\": false,\n    \"dht\": true,\n    \"dont_count_slow_torrents\": false,\n    \"download_location\": \"/root/Downloads\",\n    \"download_location_paths_list\": [],\n    \"enabled_plugins\": [\n        \"Label\"\n    ],\n    \"enc_in_policy\": 1,\n    \"enc_level\": 2,\n    \"enc_out_policy\": 1,\n    \"geoip_db_location\": \"/usr/share/GeoIP/GeoIP.dat\",\n    \"ignore_limits_on_local_network\": true,\n    \"info_sent\": 0.0,\n    \"listen_interface\": \"\",\n    \"listen_ports\": [\n        6881,\n        6891\n    ],\n    \"listen_random_port\": 53703,\n    \"listen_reuse_port\": true,\n    \"listen_use_sys_port\": false,\n    \"lsd\": true,\n    \"max_active_downloading\": 3,\n    \"max_active_limit\": 8,\n    \"max_active_seeding\": 5,\n    \"max_connections_global\": 200,\n    \"max_connections_per_second\": 20,\n    \"max_connections_per_torrent\": -1,\n    \"max_download_speed\": -1.0,\n    \"max_download_speed_per_torrent\": -1,\n    \"max_half_open_connections\": 50,\n    \"max_upload_slots_global\": 4,\n    \"max_upload_slots_per_torrent\": -1,\n    \"max_upload_speed\": -1.0,\n    \"max_upload_speed_per_torrent\": -1,\n    \"move_completed\": false,\n    \"move_completed_path\": \"/root/Downloads\",\n    \"move_completed_paths_list\": [],\n    \"natpmp\": true,\n    \"new_release_check\": false,\n    \"outgoing_interface\": \"\",\n    \"outgoing_ports\": [\n        0,\n        0\n    ],\n    \"path_chooser_accelerator_string\": \"Tab\",\n    \"path_chooser_auto_complete_enabled\": true,\n    \"path_chooser_max_popup_rows\": 20,\n    \"path_chooser_show_chooser_button_on_localhost\": true,\n    \"path_chooser_show_hidden_files\": false,\n    \"peer_tos\": \"0x00\",\n    \"plugins_location\": \"/config/plugins\",\n    \"pre_allocate_storage\": false,\n    \"prioritize_first_last_pieces\": false,\n    \"proxy\": {\n        \"anonymous_mode\": false,\n        \"force_proxy\": false,\n        \"hostname\": \"\",\n        \"password\": \"\",\n        \"port\": 8080,\n        \"proxy_hostnames\": true,\n        \"proxy_peer_connections\": true,\n        \"proxy_tracker_connections\": true,\n        \"type\": 0,\n        \"username\": \"\"\n    },\n    \"queue_new_to_top\": false,\n    \"random_outgoing_ports\": true,\n    \"random_port\": true,\n    \"rate_limit_ip_overhead\": true,\n    \"remove_seed_at_ratio\": false,\n    \"seed_time_limit\": 180,\n    \"seed_time_ratio_limit\": 7.0,\n    \"send_info\": false,\n    \"sequential_download\": false,\n    \"share_ratio_limit\": 2.0,\n    \"shared\": false,\n    \"stop_seed_at_ratio\": false,\n    \"stop_seed_ratio\": 2.0,\n    \"super_seeding\": false,\n    \"torrentfiles_location\": \"/root/Downloads\",\n    \"upnp\": true,\n    \"utpex\": true\n}\n"
  },
  {
    "path": "docker-compose/docker-compose.yml",
    "content": "version: \"2.1\"\n\nservices:\n  deluge:\n    image: ghcr.io/linuxserver/deluge\n    volumes:\n      - deluge-config:/config\n      - ./core.conf:/config/core.conf\n      - ./auth:/config/auth:ro\n  storm:\n    image: ghcr.io/relvacode/storm\n    environment:\n      DELUGE_RPC_VERSION: v2\n      DELUGE_RPC_HOSTNAME: deluge\n      DELUGE_RPC_USERNAME: localclient\n      DELUGE_RPC_PASSWORD: deluge\n    ports:\n      - \"8221:8221\"\n\nvolumes:\n  deluge-config:"
  },
  {
    "path": "error.go",
    "content": "package storm\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n)\n\n// HTTPError is an error that extends the built-in Go error with status code hinting.\ntype HTTPError interface {\n\terror\n\tStatusCode() int\n}\n\ntype RPCError struct {\n\tExceptionType    string\n\tExceptionMessage string\n}\n\nfunc (RPCError) StatusCode() int {\n\treturn http.StatusInternalServerError\n}\n\nfunc (e RPCError) Error() string {\n\treturn fmt.Sprintf(\"%s: %s\", e.ExceptionType, e.ExceptionMessage)\n}\n\nvar _ HTTPError = (*Error)(nil)\n\n// Hint wraps an input error to hint the HTTP status code.\n// If err already implements HTTPError then that error is used directly\nfunc Hint(code int, err error) error {\n\tif httpError, ok := err.(HTTPError); ok {\n\t\treturn httpError\n\t}\n\n\treturn &Error{\n\t\tMessage: err.Error(),\n\t\tCode:    code,\n\t}\n}\n\ntype Error struct {\n\tMessage string\n\tCode    int\n}\n\nfunc (e *Error) Error() string {\n\treturn e.Message\n}\n\nfunc (e *Error) StatusCode() int {\n\treturn e.Code\n}\n\ntype errorResponse struct {\n\tError string\n}\n\n// SendError sends an error back to the client.\n// If err implements HTTPError then that status code is used. Otherwise HTTP InternalServerError is sent.\n// It delivers the error message as the errorResponse payload.\nfunc SendError(rw http.ResponseWriter, err error) {\n\tvar (\n\t\tcode     = http.StatusInternalServerError\n\t\tresponse = errorResponse{\n\t\t\tError: err.Error(),\n\t\t}\n\t)\n\n\tif httpError, ok := err.(HTTPError); ok {\n\t\tcode = httpError.StatusCode()\n\t}\n\n\tif wr, ok := rw.(*WrappedResponse); ok {\n\t\twr.error = err\n\t}\n\n\tSend(rw, code, &response)\n}\n"
  },
  {
    "path": "frontend/.browserslistrc",
    "content": "# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.\n# For additional information regarding the format and rule options, please see:\n# https://github.com/browserslist/browserslist#queries\n\n# For the full list of supported browsers by the Angular framework, please see:\n# https://angular.io/guide/browser-support\n\n# You can see what browsers were selected by your queries by running:\n#   npx browserslist\n\nlast 1 Chrome version\nlast 1 Firefox version\nlast 2 Edge major versions\nlast 2 Safari major versions\nlast 2 iOS major versions\nFirefox ESR\nnot 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.\nnot IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.\n"
  },
  {
    "path": "frontend/.editorconfig",
    "content": "# Editor configuration, see https://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.ts]\nquote_type = single\n\n[*.md]\nmax_line_length = off\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": "frontend/angular.json",
    "content": "{\n  \"$schema\": \"./node_modules/@angular/cli/lib/config/schema.json\",\n  \"version\": 1,\n  \"newProjectRoot\": \"projects\",\n  \"projects\": {\n    \"storm\": {\n      \"projectType\": \"application\",\n      \"schematics\": {\n        \"@schematics/angular:component\": {\n          \"style\": \"scss\"\n        }\n      },\n      \"root\": \"\",\n      \"sourceRoot\": \"src\",\n      \"prefix\": \"t\",\n      \"architect\": {\n        \"build\": {\n          \"builder\": \"@angular-devkit/build-angular:browser\",\n          \"options\": {\n            \"outputPath\": \"dist\",\n            \"index\": \"src/index.html\",\n            \"main\": \"src/main.ts\",\n            \"polyfills\": \"src/polyfills.ts\",\n            \"tsConfig\": \"tsconfig.app.json\",\n            \"aot\": true,\n            \"assets\": [\n              \"src/favicon.ico\",\n              \"src/assets\"\n            ],\n            \"styles\": [\n              \"node_modules/primeng/resources/primeng.min.css\",\n              \"node_modules/primeng/resources/themes/bootstrap4-dark-blue/theme.css\",\n              \"node_modules/primeflex/primeflex.css\",\n              \"node_modules/@fortawesome/fontawesome-free/css/regular.css\",\n              \"node_modules/@fortawesome/fontawesome-free/css/solid.css\",\n              \"node_modules/@fortawesome/fontawesome-free/css/fontawesome.css\",\n              \"src/icons.scss\",\n              \"src/styles.scss\"\n            ],\n            \"scripts\": []\n          },\n          \"configurations\": {\n            \"production\": {\n              \"fileReplacements\": [\n                {\n                  \"replace\": \"src/environments/environment.ts\",\n                  \"with\": \"src/environments/environment.prod.ts\"\n                }\n              ],\n              \"optimization\": true,\n              \"outputHashing\": \"all\",\n              \"sourceMap\": false,\n              \"namedChunks\": false,\n              \"extractLicenses\": true,\n              \"vendorChunk\": false,\n              \"buildOptimizer\": true,\n              \"budgets\": [\n                {\n                  \"type\": \"initial\",\n                  \"maximumWarning\": \"2mb\",\n                  \"maximumError\": \"5mb\"\n                },\n                {\n                  \"type\": \"anyComponentStyle\",\n                  \"maximumWarning\": \"6kb\",\n                  \"maximumError\": \"10kb\"\n                }\n              ]\n            },\n            \"development\": {\n              \"sourceMap\": true,\n              \"buildOptimizer\": false,\n              \"optimization\": false\n            }\n          }\n        },\n        \"serve\": {\n          \"builder\": \"@angular-devkit/build-angular:dev-server\",\n          \"options\": {\n            \"browserTarget\": \"storm:build\"\n          },\n          \"configurations\": {\n            \"production\": {\n              \"browserTarget\": \"storm:build:production\"\n            }\n          }\n        },\n        \"extract-i18n\": {\n          \"builder\": \"@angular-devkit/build-angular:extract-i18n\",\n          \"options\": {\n            \"browserTarget\": \"storm:build\"\n          }\n        },\n        \"test\": {\n          \"builder\": \"@angular-devkit/build-angular:karma\",\n          \"options\": {\n            \"main\": \"src/test.ts\",\n            \"polyfills\": \"src/polyfills.ts\",\n            \"tsConfig\": \"tsconfig.spec.json\",\n            \"karmaConfig\": \"karma.conf.js\",\n            \"assets\": [\n              \"src/favicon.ico\",\n              \"src/assets\"\n            ],\n            \"styles\": [\n              \"src/styles.scss\"\n            ],\n            \"scripts\": []\n          }\n        },\n        \"lint\": {\n          \"builder\": \"@angular-devkit/build-angular:tslint\",\n          \"options\": {\n            \"tsConfig\": [\n              \"tsconfig.app.json\",\n              \"tsconfig.spec.json\",\n              \"e2e/tsconfig.json\"\n            ],\n            \"exclude\": [\n              \"**/node_modules/**\"\n            ]\n          }\n        },\n        \"e2e\": {\n          \"builder\": \"@angular-devkit/build-angular:protractor\",\n          \"options\": {\n            \"protractorConfig\": \"e2e/protractor.conf.js\",\n            \"devServerTarget\": \"storm:serve\"\n          },\n          \"configurations\": {\n            \"production\": {\n              \"devServerTarget\": \"storm:serve:production\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"defaultProject\": \"storm\"\n}\n"
  },
  {
    "path": "frontend/e2e/protractor.conf.js",
    "content": "// @ts-check\n// Protractor configuration file, see link for more information\n// https://github.com/angular/protractor/blob/master/lib/config.ts\n\nconst { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');\n\n/**\n * @type { import(\"protractor\").Config }\n */\nexports.config = {\n  allScriptsTimeout: 11000,\n  specs: [\n    './src/**/*.e2e-spec.ts'\n  ],\n  capabilities: {\n    browserName: 'chrome'\n  },\n  directConnect: true,\n  baseUrl: 'http://localhost:4200/',\n  framework: 'jasmine',\n  jasmineNodeOpts: {\n    showColors: true,\n    defaultTimeoutInterval: 30000,\n    print: function() {}\n  },\n  onPrepare() {\n    require('ts-node').register({\n      project: require('path').join(__dirname, './tsconfig.json')\n    });\n    jasmine.getEnv().addReporter(new SpecReporter({\n      spec: {\n        displayStacktrace: StacktraceOption.PRETTY\n      }\n    }));\n  }\n};"
  },
  {
    "path": "frontend/e2e/src/app.e2e-spec.ts",
    "content": "import { AppPage } from './app.po';\nimport { browser, logging } from 'protractor';\n\ndescribe('workspace-project App', () => {\n  let page: AppPage;\n\n  beforeEach(() => {\n    page = new AppPage();\n  });\n\n  it('should display welcome message', () => {\n    page.navigateTo();\n    expect(page.getTitleText()).toEqual('storm app is running!');\n  });\n\n  afterEach(async () => {\n    // Assert that there are no errors emitted from the browser\n    const logs = await browser.manage().logs().get(logging.Type.BROWSER);\n    expect(logs).not.toContain(jasmine.objectContaining({\n      level: logging.Level.SEVERE,\n    } as logging.Entry));\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/src/app.po.ts",
    "content": "import { browser, by, element } from 'protractor';\n\nexport class AppPage {\n  navigateTo(): Promise<unknown> {\n    return browser.get(browser.baseUrl) as Promise<unknown>;\n  }\n\n  getTitleText(): Promise<string> {\n    return element(by.css('app-root .content span')).getText() as Promise<string>;\n  }\n}\n"
  },
  {
    "path": "frontend/e2e/tsconfig.json",
    "content": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../out-tsc/e2e\",\n    \"module\": \"commonjs\",\n    \"target\": \"es2018\",\n    \"types\": [\n      \"jasmine\",\n      \"jasminewd2\",\n      \"node\"\n    ]\n  }\n}\n"
  },
  {
    "path": "frontend/karma.conf.js",
    "content": "// Karma configuration file, see link for more information\n// https://karma-runner.github.io/1.0/config/configuration-file.html\n\nmodule.exports = function (config) {\n  config.set({\n    basePath: '',\n    frameworks: ['jasmine', '@angular-devkit/build-angular'],\n    plugins: [\n      require('karma-jasmine'),\n      require('karma-chrome-launcher'),\n      require('karma-jasmine-html-reporter'),\n      require('karma-coverage-istanbul-reporter'),\n      require('@angular-devkit/build-angular/plugins/karma')\n    ],\n    client: {\n      clearContext: false // leave Jasmine Spec Runner output visible in browser\n    },\n    coverageIstanbulReporter: {\n      dir: require('path').join(__dirname, './coverage/storm'),\n      reports: ['html', 'lcovonly', 'text-summary'],\n      fixWebpackSourcePaths: true\n    },\n    reporters: ['progress', 'kjhtml'],\n    port: 9876,\n    colors: true,\n    logLevel: config.LOG_INFO,\n    autoWatch: true,\n    browsers: ['Chrome'],\n    singleRun: false,\n    restartOnFileChange: true\n  });\n};\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"storm\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"ng\": \"ng\",\n    \"start\": \"ng serve\",\n    \"build\": \"ng build\",\n    \"build:prod\": \"ng build --configuration=production\",\n    \"test\": \"ng test\",\n    \"lint\": \"ng lint\",\n    \"e2e\": \"ng e2e\"\n  },\n  \"private\": true,\n  \"dependencies\": {\n    \"@angular/animations\": \"^13.1.1\",\n    \"@angular/cdk\": \"^13.1.1\",\n    \"@angular/common\": \"~13.1.1\",\n    \"@angular/compiler\": \"~13.1.1\",\n    \"@angular/core\": \"~13.1.1\",\n    \"@angular/forms\": \"~13.1.1\",\n    \"@angular/platform-browser\": \"~13.1.1\",\n    \"@angular/platform-browser-dynamic\": \"~13.1.1\",\n    \"@angular/router\": \"~13.1.1\",\n    \"@fortawesome/fontawesome-free\": \"^5.15.1\",\n    \"ngx-filesize\": \"^2.0.16\",\n    \"ngx-moment\": \"^5.0.0\",\n    \"primeflex\": \"^2.0.0\",\n    \"primeng\": \"^13.0.3\",\n    \"rxjs\": \"~6.6.0\",\n    \"tslib\": \"^2.0.0\",\n    \"zone.js\": \"~0.11.4\"\n  },\n  \"devDependencies\": {\n    \"@angular-devkit/build-angular\": \"^13.1.2\",\n    \"@angular/cli\": \"~13.1.2\",\n    \"@angular/compiler-cli\": \"~13.1.1\",\n    \"@types/jasmine\": \"~3.5.0\",\n    \"@types/jasminewd2\": \"~2.0.3\",\n    \"@types/node\": \"^12.11.1\",\n    \"codelyzer\": \"^6.0.0\",\n    \"jasmine-core\": \"~3.8.0\",\n    \"jasmine-spec-reporter\": \"~5.0.0\",\n    \"karma\": \"~6.3.9\",\n    \"karma-chrome-launcher\": \"~3.1.0\",\n    \"karma-coverage-istanbul-reporter\": \"~3.0.2\",\n    \"karma-jasmine\": \"~4.0.0\",\n    \"karma-jasmine-html-reporter\": \"^1.5.0\",\n    \"protractor\": \"~7.0.0\",\n    \"ts-node\": \"~8.3.0\",\n    \"tslint\": \"~6.1.0\",\n    \"typescript\": \"~4.5.4\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/api.service.spec.ts",
    "content": "import { TestBed } from '@angular/core/testing';\n\nimport { ApiService } from './api.service';\n\ndescribe('ApiService', () => {\n  let service: ApiService;\n\n  beforeEach(() => {\n    TestBed.configureTestingModule({});\n    service = TestBed.inject(ApiService);\n  });\n\n  it('should be created', () => {\n    expect(service).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/api.service.ts",
    "content": "import {Inject, Injectable} from '@angular/core';\nimport {defer, EMPTY, Observable, ObservableInput, throwError} from 'rxjs';\nimport {\n  HttpClient,\n  HttpErrorResponse,\n  HttpEvent,\n  HttpHandler, HttpHeaders,\n  HttpInterceptor,\n  HttpParams,\n  HttpRequest\n} from '@angular/common/http';\nimport {catchError, retryWhen, switchMap, takeWhile} from 'rxjs/operators';\nimport {Message} from 'primeng/api';\nimport {Environment, ENVIRONMENT} from './environment';\nimport {DialogService} from 'primeng/dynamicdialog';\nimport {ApiKeyDialogComponent} from './components/api-key-dialog/api-key-dialog.component';\n\n/**\n * Raised when the API returns an error\n */\nexport class ApiException {\n  constructor(public status: number, public error: string) {\n  }\n\n  /**\n   * Get a PrimeNG error block for this exception\n   */\n  public get message(): Message {\n    return {\n      severity: 'error',\n      summary: 'Error',\n      detail: this.error,\n    };\n  }\n}\n\nexport type State =\n  'Active'\n  | 'Allocating'\n  | 'Checking'\n  | 'Downloading'\n  | 'Seeding'\n  | 'Paused'\n  | 'Error'\n  | 'Queued'\n  | 'Moving';\n\nexport interface Torrent {\n  ActiveTime: number;\n  CompletedTime: number;\n  TimeAdded: number; // most times an integer\n  DistributedCopies: number;\n  ETA: number; // most times an integer\n  Progress: number; // max is 100\n  Ratio: number;\n  IsFinished: boolean;\n  IsSeed: boolean;\n  Private: boolean;\n  DownloadLocation: string;\n  DownloadPayloadRate: number;\n  Name: string;\n  NextAnnounce: number;\n  NumPeers: number;\n  NumPieces: number;\n  NumSeeds: number;\n  PieceLength: number;\n  SeedingTime: number;\n  State: State;\n  TotalDone: number;\n  TotalPeers: number;\n  TotalSeeds: number;\n  TotalSize: number;\n  TrackerHost: string;\n  TrackerStatus: string;\n  UploadPayloadRate: number;\n\n// Files:         []File\n// Peers:         []Peer\n  FilePriorities: number[];\n  FileProgress: number[];\n}\n\nexport interface Label {\n  Label: string;\n}\n\nexport interface Hash {\n  Hash: string;\n}\n\nexport interface ViewTorrent extends Torrent, Hash, Label {\n}\n\n\nexport interface Torrents {\n  [id: string]: Torrent;\n}\n\nexport interface TorrentOptions {\n  MaxConnections?: number;\n  MaxUploadSlots?: number;\n  MaxUploadSpeed?: number;\n  MaxDownloadSpeed?: number;\n  PrioritizeFirstLastPieces?: boolean;\n  PreAllocateStorage?: boolean; // compact_allocation for v1\n  DownloadLocation?: string;\n  AutoManaged?: boolean;\n  StopAtRatio?: boolean;\n  StopRatio?: number;\n  RemoveAtRatio?: number;\n  MoveCompleted?: boolean;\n  MoveCompletedPath?: string;\n  AddPaused?: boolean;\n}\n\nexport interface AddTorrent {\n  Type: 'url' | 'magnet' | 'file';\n  URI: string;\n  Data?: string;\n}\n\nexport interface AddTorrentRequest extends AddTorrent {\n  Options?: TorrentOptions;\n}\n\nexport interface AddTorrentResponse {\n  ID: string;\n}\n\nexport interface TorrentLabels {\n  [id: string]: string;\n}\n\nexport interface SetTorrentLabelRequest {\n  Label: string;\n}\n\nexport interface SessionStatus {\n  HasIncomingConnections: boolean;\n  UploadRate: number;\n  DownloadRate: number;\n  PayloadUploadRate: number;\n  PayloadDownloadRate: number;\n  TotalDownload: number;\n  TotalUpload: number;\n  NumPeers: number;\n  DhtNodes: number;\n}\n\nexport interface DiskSpace {\n  FreeBytes: number;\n}\n\nexport interface ViewUpdate {\n  Torrents: ViewTorrent[];\n  Session: SessionStatus;\n  DiskFree: number;\n  ETag: string;\n}\n\nexport class ApiInterceptor implements HttpInterceptor {\n  constructor() {\n  }\n\n  private catchError(err: HttpErrorResponse, caught: Observable<HttpEvent<any>>): ObservableInput<any> {\n    return throwError(new ApiException(err.status, err.error.Error));\n  }\n\n  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {\n    return next.handle(req).pipe(\n      catchError(this.catchError)\n    );\n  }\n}\n\n@Injectable()\nexport class AuthInterceptor implements HttpInterceptor {\n  ask$: Observable<void>;\n\n  constructor(dialogService: DialogService) {\n    this.ask$ = defer(() => {\n      const ref = dialogService.open(ApiKeyDialogComponent, {\n        header: 'Authorization Required',\n        showHeader: true,\n        closeOnEscape: false,\n        closable: false,\n      });\n\n      return ref.onClose;\n    });\n  }\n\n  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {\n    return next.handle(req).pipe(\n      // Catch 401 errors and ask for the API key.\n      // Redo the request with the provided API key in basic auth headers\n      catchError((err: ApiException) => {\n        if (err.status !== 401) {\n          return throwError(err);\n        }\n\n        return this.ask$.pipe(\n          switchMap(key => {\n            const withAuthHeaderReq = req.clone({\n              headers: req.headers.set('Authorization', 'Basic ' + btoa(':' + key))\n            });\n\n            return next.handle(withAuthHeaderReq);\n          })\n        );\n      }),\n\n      // Keep retrying 401 errors\n      retryWhen(errors => errors.pipe(\n        takeWhile((err: ApiException) => err.status === 401)\n      ))\n    );\n  }\n}\n\n@Injectable({\n  providedIn: 'root',\n})\nexport class ApiService {\n  constructor(private http: HttpClient, @Inject(ENVIRONMENT) private environment: Environment) {\n  }\n\n  private url(endpoint: string): string {\n    return `${this.environment.baseApiPath}${endpoint}`;\n  }\n\n  /**\n   * Calls the ping endpoint\n   */\n  public ping(): Observable<void> {\n    return this.http.get<void>(this.url('ping'));\n  }\n\n  public sessionStatus(): Observable<SessionStatus> {\n    return this.http.get<SessionStatus>(this.url('session'));\n  }\n\n  public freeDiskSpace(path: string = ''): Observable<DiskSpace> {\n    return this.http.get<DiskSpace>(this.url('disk/free'), {\n      params: {\n        path\n      }\n    });\n  }\n\n  public viewUpdate(etag?: string, state?: State): Observable<ViewUpdate> {\n    let params = new HttpParams();\n    if (!!state) {\n      params = params.set('state', state);\n    }\n\n    let headers = new HttpHeaders();\n    if (!!etag) {\n      headers = headers.set('ETag', etag);\n    }\n\n    return this.http.get<ViewUpdate>(this.url('view'), {\n      params,\n      headers,\n    }).pipe(\n      catchError((err: ApiException) => {\n        if (err.status === 304) {\n          return EMPTY;\n        }\n\n        return throwError(err);\n      })\n    );\n  }\n\n  /**\n   * Get a list of all the currently enabled plugins\n   */\n  public plugins(): Observable<string[]> {\n    return this.http.get<string[]>(this.url('plugins'));\n  }\n\n  /**\n   * Enable a plugin\n   * @param name\n   * The plugin name to enable\n   */\n  public enablePlugin(name: string): Observable<void> {\n    return this.http.post<void>(this.url(`plugins/${name}`), null);\n  }\n\n  /**\n   * Disable a plugin\n   * @param name\n   * The plugin name to disable\n   */\n  public disablePlugin(name: string): Observable<void> {\n    return this.http.delete<void>(this.url(`plugins/${name}`));\n  }\n\n  /**\n   * Pauses one or more torrens\n   * @param torrents\n   * List of torrent IDs\n   */\n  public pause(...torrents: string[]): Observable<void> {\n    const params = new HttpParams({\n      fromObject: {\n        id: torrents,\n      }\n    });\n\n    return this.http.post<void>(this.url('torrents/pause'), null, {\n      params\n    });\n  }\n\n  /**\n   * Resumes one or more torrens\n   * @param torrents\n   * List of torrent IDs\n   */\n  public resume(...torrents: string[]): Observable<void> {\n    const params = new HttpParams({\n      fromObject: {\n        id: torrents,\n      }\n    });\n\n    return this.http.post<void>(this.url('torrents/resume'), null, {\n      params\n    });\n  }\n\n  /**\n   * Get a specific torrent by ID\n   * @param id\n   * The torrent ID\n   */\n  public torrent(id: string): Observable<Torrent> {\n    return this.http.get<Torrent>(this.url(`torrent/${id}`));\n  }\n\n\n  public torrents(state?: State, ...torrents: string[]): Observable<Torrents> {\n    let params = new HttpParams();\n    if (!!state) {\n      params = params.set('state', state);\n    }\n    if (!!torrents && torrents.length) {\n      for (const id of torrents) {\n        params = params.append('id', id);\n      }\n    }\n\n    return this.http.get<Torrents>(this.url('torrents'), {\n      params,\n    });\n  }\n\n  public removeTorrent(withData: boolean, id: string): Observable<void> {\n    return this.http.delete<void>(this.url(`torrent/${id}`), {\n      params: new HttpParams({\n        fromObject: {\n          files: withData ? 'true' : 'false'\n        }\n      })\n    });\n  }\n\n  /**\n   * Add a new torrent\n   * @param req\n   * Add torrent request\n   */\n  public add(req: AddTorrentRequest): Observable<AddTorrentResponse> {\n    return this.http.post<AddTorrentResponse>(this.url('torrents'), req);\n  }\n\n  /**\n   * Gets available labels\n   */\n  public labels(): Observable<string[]> {\n    return this.http.get<string[]>(this.url('labels'));\n  }\n\n  /**\n   * Create a new label\n   * @param name\n   * The label name\n   */\n  public createLabel(name: string): Observable<void> {\n    return this.http.post<void>(this.url(`labels/${name}`), null);\n  }\n\n  /**\n   * Delete an existing label\n   * @param name\n   * The label name\n   */\n  public deleteLabel(name: string): Observable<void> {\n    return this.http.delete<void>(this.url(`labels/${name}`));\n  }\n\n  /**\n   * Gets labels associated with torrents matching filter\n   * @param state\n   * The torrent state\n   * @param torrents\n   * An optional set of torrent IDs\n   */\n  public torrentsLabels(state?: State, ...torrents: string[]): Observable<TorrentLabels> {\n    return this.http.get<TorrentLabels>(this.url('torrents/labels'));\n  }\n\n  /**\n   * Updates the label of a torrent\n   * @param id\n   * The torrent ID\n   * @param req\n   * Request data\n   */\n  public setTorrentLabel(id: string, req: SetTorrentLabelRequest): Observable<void> {\n    return this.http.post<void>(this.url(`torrent/${id}/label`), req);\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/app.component.html",
    "content": "<div class=\"p-menubar p-d-flex p-flex-row p-align-center p-justify-between p-flex-nowrap\">\n\n  <t-breakpoint-overlay icon=\"fas fa-bars\" styleClass=\"t-button-overlay\">\n    <button pButton pRipple (click)=\"menu.toggle($event)\" icon=\"fas fa-plus\" class=\"t-button-alt\"\n            pTooltip=\"Add Torrent\"></button>\n    <t-add-torrent-menu #menu></t-add-torrent-menu>\n\n    <button pButton pRipple type=\"button\" class=\"t-menu-button\"\n            (click)=\"onToggleInView(stateInView === 'Paused' ? 'resume' : 'pause', torrents)\"\n            [disabled]=\"empty\"\n            [pTooltip]=\"stateInView == 'Paused' ? 'Resume' : 'Pause'\"\n            [icon]=\"stateInView === 'Paused' ? 'fas fa-play' : 'fas fa-pause'\"\n    ></button>\n\n    <t-delete-torrent-overlay #remove [torrents]=\"hashesInView\"></t-delete-torrent-overlay>\n    <button pButton pRipple (click)=\"remove.toggle($event)\" type=\"button\" icon=\"far fa-trash-alt\"\n            [disabled]=\"empty\"\n\n            class=\"p-button-danger t-menu-button\"\n            pTooltip=\"Remove Displayed\"></button>\n  </t-breakpoint-overlay>\n\n\n  <t-torrent-search (search)=\"searchText = $event\"></t-torrent-search>\n\n  <t-breakpoint-overlay icon=\"fas fa-filter\" [contentActivated]=\"(get$ | async) !== null\" styleClass=\"t-filter-overlay\">\n    <div class=\"t-sort-option\">\n      <p-dropdown class=\"t-filter-input\" [options]=\"sortOptions\" appendTo=\"body\"\n                  [(ngModel)]=\"sortByField\" placeholder=\"Sort\"></p-dropdown>\n      <button pButton pRipple (click)=\"sortReverse = !sortReverse\" type=\"button\" class=\"t-sort-button\"\n              [icon]=\"sortReverse ? 'fas fa-sort-amount-up' : 'fas fa-sort-amount-down-alt'\"\n              [pTooltip]=\"sortReverse ? 'Sort descending': 'Sort ascending'\"\n      ></button>\n    </div>\n\n    <p-dropdown class=\"t-filter-input\" [options]=\"filterStatesOptions\" appendTo=\"body\"\n                (onChange)=\"get$.next($event.value)\" placeholder=\"State\"></p-dropdown>\n  </t-breakpoint-overlay>\n</div>\n\n\n<ng-template #displayNotLoaded>\n  <div class=\"t-empty p-d-flex p-flex-column p-justify-center p-align-center\">\n    <i class=\"fas fa-circle-notch fa-spin\"></i>\n  </div>\n</ng-template>\n\n<ng-template #displayEmpty>\n  <div class=\"t-empty p-d-flex p-flex-column p-justify-center p-align-center\">\n    <i class=\"fas fa-cloud-moon\"></i>\n    <span>No Torrents</span>\n  </div>\n</ng-template>\n\n<ng-container *ngIf=\"torrents | torrentSearch: searchText as result; else displayNotLoaded\">\n  <div class=\"layout-content\">\n    <div class=\"p-grid\">\n      <div class=\"p-col-12\"\n           *ngFor=\"let item of result | orderBy: sortByField : sortReverse ; trackBy: trackBy\">\n        <t-torrent [torrent]=\"item\" [hash]=\"item.Hash\" [label]=\"item.Label\"></t-torrent>\n      </div>\n    </div>\n  </div>\n\n  <ng-container *ngIf=\"!result.length\" [ngTemplateOutlet]=\"displayEmpty\"></ng-container>\n</ng-container>\n\n\n<div class=\"t-bottom-bar\">\n  <t-session-status [sessionStatus]=\"sessionStatus\" [diskSpace]=\"diskSpace\"></t-session-status>\n</div>\n\n<t-connectivity-status [connected]=\"connected\"></t-connectivity-status>\n\n"
  },
  {
    "path": "frontend/src/app/app.component.scss",
    "content": ".p-menubar {\n  padding: .5rem 1rem;\n  position: sticky;\n  top: 0;\n  z-index: 1;\n\n  & button.t-menu-button:not(:first-child) {\n    margin-left: 6px;\n  }\n\n  & .t-filter-input:not(:first-child) {\n    margin-left: 6px;\n  }\n\n  t-torrent-search {\n    margin: 0 12px;\n    flex-grow: 1;\n  }\n}\n\n.layout-content {\n  padding: 2rem;\n  background-color: var(--surface-b);\n}\n\n::ng-deep .t-button-overlay {\n  & button.p-button:not(:first-child) {\n    margin-left: 12px;\n  }\n}\n\n::ng-deep .t-filter-overlay {\n  min-width: 160px;\n\n  & .t-sort-option {\n    margin-bottom: 12px;\n  }\n\n  & > * {\n    width: 100%;\n\n    & .p-dropdown {\n      width: 100%;\n    }\n  }\n}\n\n\n@media only screen and (max-width: 750px) {\n  .p-menubar {\n    padding: .5rem;\n\n    & p-dropdown:not(:first-child) {\n      margin-left: 6px;\n    }\n  }\n\n  .layout-content {\n    padding: 1rem;\n  }\n}\n\n.t-empty {\n  position: absolute;\n  top: 0;\n  width: 100%;\n  height: 100%;\n  z-index: -1;\n  color: #434f5f;\n\n  & i {\n    font-size: 10rem;\n    opacity: .2;\n  }\n\n  & span {\n    margin-top: 2rem;\n    font-weight: 600;\n  }\n}\n\n\nt-breakpoint-overlay {\n  display: flex;\n  align-items: center;\n}\n\n.t-sort-option {\n  width: 100%;\n  display: flex;\n  flex-direction: row;\n  flex-wrap: nowrap;\n\n\n  ::ng-deep & p-dropdown {\n    flex-grow: 1;\n    & .p-dropdown {\n      border-radius: 4px 0 0 4px;\n    }\n  }\n\n  & .t-sort-button {\n    background: #20262e;\n    border: 1px solid #3f4b5b;\n    border-radius: 0 4px 4px 0;\n    border-left: none;\n    width: 3rem;\n    color: rgba(255, 255, 255, 0.6);\n  }\n}\n\n.t-bottom-bar {\n  position: fixed;\n  bottom: 0;\n  width: 100%;\n}\n"
  },
  {
    "path": "frontend/src/app/app.component.spec.ts",
    "content": "import { TestBed } from '@angular/core/testing';\nimport { RouterTestingModule } from '@angular/router/testing';\nimport { AppComponent } from './app.component';\n\ndescribe('AppComponent', () => {\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      imports: [\n        RouterTestingModule\n      ],\n      declarations: [\n        AppComponent\n      ],\n    }).compileComponents();\n  });\n\n  it('should create the app', () => {\n    const fixture = TestBed.createComponent(AppComponent);\n    const app = fixture.componentInstance;\n    expect(app).toBeTruthy();\n  });\n\n  it(`should have as title 'storm'`, () => {\n    const fixture = TestBed.createComponent(AppComponent);\n    const app = fixture.componentInstance;\n    expect(app.title).toEqual('storm');\n  });\n\n  it('should render title', () => {\n    const fixture = TestBed.createComponent(AppComponent);\n    fixture.detectChanges();\n    const compiled = fixture.nativeElement;\n    expect(compiled.querySelector('.content span').textContent).toContain('storm app is running!');\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/app.component.ts",
    "content": "import {Component} from '@angular/core';\nimport {BehaviorSubject, combineLatest, EMPTY, forkJoin, Observable, of, timer} from 'rxjs';\nimport {catchError, filter, mergeMap, switchMap} from 'rxjs/operators';\nimport {ApiService, DiskSpace, Hash, SessionStatus, State, Torrent, ViewTorrent} from './api.service';\nimport {SelectItem} from 'primeng/api';\nimport {FocusService} from './focus.service';\nimport {DialogService} from 'primeng/dynamicdialog';\nimport {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component';\n\ntype OptionalState = State | null;\n\n\n@Component({\n  selector: 't-root',\n  templateUrl: './app.component.html',\n  styleUrls: ['./app.component.scss'],\n})\nexport class AppComponent {\n  sortByField: keyof Torrent = null;\n  sortReverse = false;\n\n  sortOptions: SelectItem<keyof Torrent>[] = [\n    {\n      label: 'State',\n      value: 'State'\n    },\n    {\n      label: 'Added',\n      value: 'TimeAdded'\n    },\n    {\n      label: 'Progress',\n      value: 'Progress'\n    },\n    {\n      label: 'ETA',\n      value: 'ETA'\n    },\n    {\n      label: 'Name',\n      value: 'Name'\n    },\n    {\n      label: 'Size',\n      value: 'TotalSize'\n    },\n    {\n      label: 'Ratio',\n      value: 'Ratio'\n    },\n    {\n      label: 'Seeding',\n      value: 'SeedingTime'\n    }\n  ];\n\n  filterStatesOptions: SelectItem<OptionalState>[] = [\n    {\n      label: 'All',\n      value: null,\n    },\n    {\n      label: 'Active',\n      value: 'Active'\n    },\n    {\n      label: 'Queued',\n      value: 'Queued',\n    },\n    {\n      label: 'Downloading',\n      value: 'Downloading',\n    },\n    {\n      label: 'Seeding',\n      value: 'Seeding',\n    },\n    {\n      label: 'Paused',\n      value: 'Paused'\n    },\n    {\n      label: 'Error',\n      value: 'Error'\n    }\n  ];\n\n  searchText: string;\n\n  // All torrent hashes within the current view\n  hashesInView: string[];\n  // Current view is empty\n  empty = false;\n  stateInView: OptionalState;\n  sessionStatus: SessionStatus = {\n    HasIncomingConnections: false,\n    UploadRate: 0,\n    DownloadRate: 0,\n    PayloadUploadRate: 0,\n    PayloadDownloadRate: 0,\n    TotalDownload: 0,\n    TotalUpload: 0,\n    NumPeers: 0,\n    DhtNodes: 0,\n  };\n  diskSpace: DiskSpace;\n\n  torrents: ViewTorrent[];\n\n  connected = true;\n  lastEtag: string;\n\n  get$: BehaviorSubject<OptionalState>;\n\n  constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService) {\n    this.get$ = new BehaviorSubject<OptionalState>(null);\n    this.refreshInterval(2000);\n  }\n\n\n  /**\n   * Opens the PluginEnable dialog component\n   * @private\n   */\n  private enableLabelPlugin(): Observable<void> {\n    const ref = this.dialogService.open(PluginEnableComponent, {\n      header: 'Enable Plugin',\n      showHeader: false,\n      closable: false,\n      closeOnEscape: false,\n      dismissableMask: false,\n      styleClass: 't-dialog-responsive',\n      data: {\n        name: 'Label'\n      }\n    });\n\n    return ref.onClose;\n  }\n\n  /**\n   * Updates the list of torrents at every given interval,\n   * or when the selected $get state is updated.\n   * @param interval\n   * Update interval in milliseconds\n   */\n  private refreshInterval(interval: number): void {\n    const timer$ = timer(0, interval);\n\n    // Ensure the label plugin is enabled\n    const labelPluginEnabled$ = this.api.plugins().pipe(\n      switchMap(plugins => {\n        const ok = plugins.findIndex(name => name === 'Label') > -1;\n        if (ok) {\n          return of(true);\n        }\n\n        return this.enableLabelPlugin();\n      })\n    );\n\n    const interval$ = combineLatest([timer$, this.focus.observe, this.get$, labelPluginEnabled$]);\n\n    interval$.pipe(\n      // Continue only when in focus\n      filter(([_, focus]) => focus),\n\n      // Switch to API response of torrents\n      mergeMap(([_, focus, state]) => this.api.viewUpdate(this.lastEtag, state).pipe(\n        catchError(err => {\n          console.error('Connection error', err);\n          this.connected = false;\n          this.lastEtag = null;\n          return EMPTY;\n        }),\n      )),\n    ).subscribe(\n      response => {\n        this.connected = true;\n        this.sessionStatus = response.Session;\n        this.diskSpace = {FreeBytes: response.DiskFree};\n        this.torrents = response.Torrents;\n\n        this.empty = this.torrents.length === 0;\n        this.hashesInView = this.torrents.map(t => t.Hash);\n        this.lastEtag = response.ETag;\n\n        const statesInView = new Set(this.torrents.map(t => t.State));\n        const [onlyStateInView] = statesInView.size === 1 ? statesInView : [];\n        this.stateInView = onlyStateInView || null;\n      }\n    );\n  }\n\n  public trackBy(index: number, torrent: Hash): string {\n    return torrent.Hash;\n  }\n\n\n  onToggleInView(targetState: 'pause' | 'resume', torrents: ViewTorrent[]): void {\n    if (!torrents || torrents.length === 0) {\n      return;\n    }\n\n    let res: Observable<void>;\n    switch (targetState) {\n      case 'pause':\n        res = this.api.pause(...torrents.filter(t => t.State !== 'Paused').map(t => t.Hash));\n        break;\n      case 'resume':\n        res = this.api.resume(...torrents.filter(t => t.State === 'Paused').map(t => t.Hash));\n        break;\n    }\n\n    res.subscribe(\n      _ => console.log(`torrents in view reached target state ${targetState}`)\n    );\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/app.module.ts",
    "content": "import {BrowserModule} from '@angular/platform-browser';\nimport {NgModule} from '@angular/core';\n\nimport {AppComponent} from './app.component';\nimport {BrowserAnimationsModule} from '@angular/platform-browser/animations';\nimport {TorrentStateComponent} from './components/torrent-state/torrent-state.component';\nimport {TorrentComponent} from './components/torrent/torrent.component';\nimport {ProgressBarModule} from 'primeng/progressbar';\nimport {NgxFilesizeModule} from 'ngx-filesize';\nimport {ButtonModule} from 'primeng/button';\nimport {RippleModule} from 'primeng/ripple';\nimport {TooltipModule} from 'primeng/tooltip';\nimport {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';\nimport {ApiInterceptor, AuthInterceptor} from './api.service';\nimport {DropdownModule} from 'primeng/dropdown';\nimport {OrderByPipe} from './order-by.pipe';\nimport {FormsModule} from '@angular/forms';\nimport {OverlayPanelModule} from 'primeng/overlaypanel';\nimport {DeleteTorrentOverlayComponent} from './components/delete-torrent-overlay/delete-torrent-overlay.component';\nimport {ProgressSpinnerModule} from 'primeng/progressspinner';\nimport {MomentModule} from 'ngx-moment';\nimport {MenuModule} from 'primeng/menu';\nimport {AddTorrentMenuComponent} from './components/add-torrent-menu/add-torrent-menu.component';\nimport {DialogService, DynamicDialogModule} from 'primeng/dynamicdialog';\nimport {\n  AddTorrentMagnetInputComponent\n} from './components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component';\nimport {InputTextModule} from 'primeng/inputtext';\nimport {AddTorrentConfigComponent} from './components/add-torrent-menu/add-torrent-config/add-torrent-config.component';\nimport {AccordionModule} from 'primeng/accordion';\nimport {InputNumberModule} from 'primeng/inputnumber';\nimport {CheckboxModule} from 'primeng/checkbox';\nimport {TorrentDetailsDialogComponent} from './components/torrent-details-dialog/torrent-details-dialog.component';\nimport {\n  AddTorrentUrlInputComponent\n} from './components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component';\nimport {MessagesModule} from 'primeng/messages';\nimport {\n  AddTorrentFileInputComponent\n} from './components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component';\nimport {FileUploadModule} from 'primeng/fileupload';\nimport {BreakpointOverlayComponent} from './components/breakpoint-overlay/breakpoint-overlay.component';\nimport {TorrentSearchPipe} from './torrent-search.pipe';\nimport {ConnectivityStatusComponent} from './components/connectivity-status/connectivity-status.component';\nimport {TorrentSearchComponent} from './components/torrent-search/torrent-search.component';\nimport {ENVIRONMENT} from './environment';\nimport {TorrentLabelComponent} from './components/torrent-label/torrent-label.component';\nimport {\n  TorrentEditLabelDialogComponent\n} from './components/torrent-edit-label-dialog/torrent-edit-label-dialog.component';\nimport {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component';\nimport {MultiSelectModule} from 'primeng/multiselect';\nimport { ActivityMarkerComponent } from './components/activity-marker/activity-marker.component';\nimport { ApiKeyDialogComponent } from './components/api-key-dialog/api-key-dialog.component';\nimport { SessionStatusComponent } from './components/session-status/session-status.component';\n\n// @ts-ignore\n@NgModule({\n  declarations: [\n    AppComponent,\n    TorrentStateComponent,\n    TorrentComponent,\n    OrderByPipe,\n    DeleteTorrentOverlayComponent,\n    AddTorrentMenuComponent,\n    AddTorrentMagnetInputComponent,\n    AddTorrentConfigComponent,\n    TorrentDetailsDialogComponent,\n    AddTorrentUrlInputComponent,\n    AddTorrentFileInputComponent,\n    BreakpointOverlayComponent,\n    TorrentSearchPipe,\n    ConnectivityStatusComponent,\n    TorrentSearchComponent,\n    TorrentLabelComponent,\n    TorrentEditLabelDialogComponent,\n    PluginEnableComponent,\n    ActivityMarkerComponent,\n    ApiKeyDialogComponent,\n    SessionStatusComponent,\n  ],\n  imports: [\n    BrowserModule,\n    BrowserAnimationsModule,\n    HttpClientModule,\n\n    NgxFilesizeModule,\n    ProgressBarModule,\n    MenuModule,\n    ButtonModule,\n    RippleModule,\n    TooltipModule,\n    DropdownModule,\n    FormsModule,\n    OverlayPanelModule,\n    ProgressSpinnerModule,\n    DynamicDialogModule,\n    MomentModule,\n    InputTextModule,\n    AccordionModule,\n    InputNumberModule,\n    CheckboxModule,\n    MessagesModule,\n    FileUploadModule,\n    MultiSelectModule,\n  ],\n  entryComponents: [],\n  providers: [\n    {\n      provide: HTTP_INTERCEPTORS,\n      useClass: AuthInterceptor,\n      multi: true,\n    },\n    {\n      provide: HTTP_INTERCEPTORS,\n      useClass: ApiInterceptor,\n      multi: true,\n    },\n    {\n      provide: ENVIRONMENT,\n      // @ts-ignore\n      // Environment injection is handled by the server.\n      // It will replace the contents of the initializer in index.html with the environment\n      // specific variables on-demand.\n      useValue: window.environment,\n    },\n    DialogService,\n  ],\n  bootstrap: [AppComponent]\n})\nexport class AppModule {\n}\n"
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.html",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.scss",
    "content": ":host {\r\n  position: absolute;\r\n  top: 0;\r\n  margin: 0;\r\n\r\n  color: #d45e6a;\r\n  transform: translate(-0.6em, 0.6em);\r\n  font-size: .6rem;\r\n\r\n\r\n  font-family: 'Font Awesome 5 Free';\r\n  font-style: normal;\r\n  font-weight: 900;\r\n\r\n  &::before {\r\n    // fas fa-circle\r\n    content: '\\f111';\r\n  }\r\n}\r\n"
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ActivityMarkerComponent } from './activity-marker.component';\n\ndescribe('ActivityMarkerComponent', () => {\n  let component: ActivityMarkerComponent;\n  let fixture: ComponentFixture<ActivityMarkerComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ ActivityMarkerComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(ActivityMarkerComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.ts",
    "content": "import { Component } from '@angular/core';\n\n@Component({\n  selector: 't-activity-marker',\n  templateUrl: './activity-marker.component.html',\n  styleUrls: ['./activity-marker.component.scss']\n})\nexport class ActivityMarkerComponent {\n\n  constructor() { }\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.html",
    "content": "<!--MaxConnections?: number;-->\n<!--MaxUploadSlots?: number;-->\n<!--MaxUploadSpeed?: number;-->\n<!--MaxDownloadSpeed?: number;-->\n<!--PrioritizeFirstLastPieces?: boolean;-->\n<!--PreAllocateStorage?: boolean; // compact_allocation for v1-->\n<!--DownloadLocation?: string;-->\n<!--AutoManaged?: boolean;-->\n<!--StopAtRatio?: boolean;-->\n<!--StopRatio?: number;-->\n<!--RemoveAtRatio?: number;-->\n<!--MoveCompleted?: boolean;-->\n<!--MoveCompletedPath?: string;-->\n<!--AddPaused?: boolean;-->\n\n<p-accordion>\n  <p-accordionTab header=\"Torrent Options\" [selected]=\"false\">\n    <ng-template pTemplate=\"content\">\n      <div class=\"p-grid p-fluid\">\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-maxDownloadSpeed\">Max Download Speed</label>\n          <p-inputNumber inputId=\"o-maxDownloadSpeed\" [(ngModel)]=\"config.MaxDownloadSpeed\" [min]=\"-1\"\n                         placeholder=\"KB/s\"></p-inputNumber>\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-maxUploadSpeed\">Max Upload Speed</label>\n          <p-inputNumber inputId=\"o-maxUploadSpeed\" [(ngModel)]=\"config.MaxUploadSpeed\" [min]=\"-1\"\n                         placeholder=\"KB/s\"></p-inputNumber>\n        </div>\n      </div>\n\n      <div class=\"p-grid p-fluid\">\n        <div class=\"p-field-checkbox p-col-12 p-md-3\">\n          <p-checkbox inputId=\"o-addPaused\" binary=\"true\" [(ngModel)]=\"config.AddPaused\">\n          </p-checkbox>\n          <label for=\"o-addPaused\">Add Paused</label>\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-maxConnections\">Max Connections</label>\n          <p-inputNumber inputId=\"o-maxConnections\" [(ngModel)]=\"config.MaxConnections\">\n          </p-inputNumber>\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-maxUploadSlots\">Max Upload Slots</label>\n          <p-inputNumber inputId=\"o-maxUploadSlots\" [(ngModel)]=\"config.MaxUploadSlots\">\n          </p-inputNumber>\n        </div>\n\n        <div class=\"p-field-checkbox p-col-12 p-md-3\">\n          <p-checkbox inputId=\"o-firstLastPieces\" binary=\"true\" [(ngModel)]=\"config.PrioritizeFirstLastPieces\">\n          </p-checkbox>\n          <label for=\"o-firstLastPieces\">Prioritise First and Last Pieces</label>\n        </div>\n\n        <div class=\"p-field-checkbox p-col-12 p-md-3\">\n          <p-checkbox inputId=\"o-preAllocate\" binary=\"true\" [(ngModel)]=\"config.PreAllocateStorage\">\n          </p-checkbox>\n          <label for=\"o-preAllocate\">Pre-Allocate Storage</label>\n        </div>\n\n      </div>\n      <div class=\"p-grid p-fluid\">\n        <div class=\"p-field-checkbox p-col-12 p-md-3\">\n          <p-checkbox inputId=\"o-stopAtRatio\" binary=\"true\" [(ngModel)]=\"config.StopAtRatio\"\n                      (ngModelChange)=\"unsetFields($event, 'StopAtRatio', 'StopRatio', 'RemoveAtRatio')\">\n          </p-checkbox>\n          <label for=\"o-stopAtRatio\">Stop At Ratio</label>\n\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-stopRatio\">Stop Ratio</label>\n          <p-inputNumber inputId=\"o-stopRatio\" [(ngModel)]=\"config.StopRatio\" [disabled]=\"!config.StopAtRatio\">\n          </p-inputNumber>\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-3\">\n          <label for=\"o-removeAtRatio\">Remove At Ratio</label>\n          <p-inputNumber inputId=\"o-removeAtRatio\" [(ngModel)]=\"config.RemoveAtRatio\" [min]=\"config.StopRatio\"\n                         [disabled]=\"!config.StopRatio && config.StopRatio != 0\">\n          </p-inputNumber>\n        </div>\n\n      </div>\n      <div class=\"p-grid p-fluid\">\n\n        <div class=\"p-field p-col-12\">\n          <label for=\"o-downloadLocation\">Download Location</label>\n          <input pInputText inputId=\"o-downloadLocation\" [(ngModel)]=\"config.DownloadLocation\">\n        </div>\n\n      </div>\n      <div class=\"p-grid p-fluid\">\n        <div class=\"p-field-checkbox p-col-12 p-md-3\">\n          <p-checkbox inputId=\"o-moveCompleted\" binary=\"true\" [(ngModel)]=\"config.MoveCompleted\"\n                      (ngModelChange)=\"unsetFields($event, 'MoveCompleted', 'MoveCompletedPath')\">\n          </p-checkbox>\n          <label for=\"o-moveCompleted\">Move Completed</label>\n\n        </div>\n\n        <div class=\"p-field p-col-12 p-md-9\">\n          <label for=\"o-moveCompletedPath\">Moved Completed Path</label>\n          <input pInputText inputId=\"o-moveCompletedPath\" [(ngModel)]=\"config.MoveCompletedPath\"\n                 [disabled]=\"!config.MoveCompleted\">\n        </div>\n\n      </div>\n    </ng-template>\n\n  </p-accordionTab>\n</p-accordion>\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.scss",
    "content": ":host {\n  display: block;\n  margin: 1rem 0;\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentConfigComponent } from './add-torrent-config.component';\n\ndescribe('AddTorrentConfigComponent', () => {\n  let component: AddTorrentConfigComponent;\n  let fixture: ComponentFixture<AddTorrentConfigComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ AddTorrentConfigComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(AddTorrentConfigComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.ts",
    "content": "import {Component, Input} from '@angular/core';\nimport {TorrentOptions} from '../../../api.service';\n\n@Component({\n  selector: 't-add-torrent-config',\n  templateUrl: './add-torrent-config.component.html',\n  styleUrls: ['./add-torrent-config.component.scss']\n})\nexport class AddTorrentConfigComponent {\n  @Input('config') config: TorrentOptions;\n\n  constructor() {\n  }\n\n  public unsetFields($event: boolean, ...fields: (keyof TorrentOptions)[]): void {\n    if (!$event) {\n      for (const field of fields) {\n        // @ts-ignore\n        this.config[field] = null;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-dialog-component.ts",
    "content": "import {AddTorrent, AddTorrentRequest, ApiException, ApiService, TorrentOptions} from '../../api.service';\nimport {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';\nimport {Injector} from '@angular/core';\nimport {catchError, finalize} from 'rxjs/operators';\nimport {throwError} from 'rxjs';\nimport {Message} from 'primeng/api';\n\nexport class AddTorrentDialogComponentDirective<T> {\n  public static DefaultIcon = 'far fa-plus-square';\n\n  public config: TorrentOptions;\n  public submitIcon = AddTorrentDialogComponentDirective.DefaultIcon;\n  public submitIsDisabled = false;\n  public errorMessages: Message[] = [];\n\n  api: ApiService;\n  ref: DynamicDialogRef;\n  data: Partial<T>;\n\n  constructor(injector: Injector) {\n    this.api = injector.get(ApiService) as ApiService;\n    this.ref = injector.get(DynamicDialogRef) as DynamicDialogRef;\n    this.data = (injector.get(DynamicDialogConfig) as DynamicDialogConfig).data || {};\n    this.config = {\n      MaxDownloadSpeed: -1,\n      MaxUploadSpeed: -1,\n    };\n  }\n\n  public close(): void {\n    this.ref.close(false);\n  }\n\n  public submit(opt: AddTorrent): void {\n    const req: AddTorrentRequest = Object.assign({Options: this.config}, opt);\n\n    this.submitIcon = 'fa-spin fas fa-spinner';\n    this.submitIsDisabled = true;\n    this.errorMessages.splice(0);\n\n    this.api.add(req).pipe(\n      catchError((err: ApiException) => {\n        // Catch Api exceptions and push them to the messages stack\n        this.errorMessages = [err.message];\n        return throwError(err);\n\n      }),\n      finalize(() => {\n        this.submitIcon = AddTorrentDialogComponentDirective.DefaultIcon;\n        this.submitIsDisabled = false;\n      })\n    ).subscribe(\n      response => this.ref.close(response.ID)\n    );\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.html",
    "content": "<p-fileUpload name=\"torrent\" customUpload=\"true\"\n              [disabled]=\"submitIsDisabled\"\n              (uploadHandler)=\"onSubmit($event)\"\n              (onClear)=\"onResetErrors()\"\n></p-fileUpload>\n\n<t-add-torrent-config [config]=\"config\"></t-add-torrent-config>\n\n<p-messages [(value)]=\"errorMessages\"></p-messages>\n\n<button pButton class=\"p-button-danger\" label=\"Cancel\" icon=\"fas fa-times\" (click)=\"close()\"></button>\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentFileInputComponent } from './add-torrent-file-input.component';\n\ndescribe('AddTorrentFileInputComponent', () => {\n  let component: AddTorrentFileInputComponent;\n  let fixture: ComponentFixture<AddTorrentFileInputComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ AddTorrentFileInputComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(AddTorrentFileInputComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.ts",
    "content": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';\n\n@Component({\n  selector: 't-add-torrent-file-input',\n  templateUrl: './add-torrent-file-input.component.html',\n  styleUrls: ['./add-torrent-file-input.component.scss']\n})\nexport class AddTorrentFileInputComponent extends AddTorrentDialogComponentDirective<null> {\n  constructor(injector: Injector) {\n    super(injector);\n  }\n\n  /**\n   * Converts an array buffer to a Base64 encoded string\n   */\n  private arrayBufferToBase64(buffer: ArrayBuffer): string {\n    let binary = '';\n    const bytes = new Uint8Array(buffer);\n    const len = bytes.byteLength;\n    for (let i = 0; i < len; i++) {\n      binary += String.fromCharCode(bytes[i]);\n    }\n    return btoa(binary);\n  }\n\n  onResetErrors(): void {\n    this.errorMessages = [];\n  }\n\n  onSubmit($event: { files: Array<File> }): void {\n    const file = $event.files[0];\n\n    file.arrayBuffer().then(\n      buffer => {\n        // Encode the file as Base64 and submit to the server\n        const encoded = this.arrayBufferToBase64(buffer);\n        this.submit({\n          Type: 'file',\n          URI: file.name,\n          Data: encoded,\n        });\n      },\n      err => {\n        this.errorMessages = [{\n          severity: 'error',\n          summary: 'Error',\n          detail: err.toString(),\n        }];\n      }\n    );\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.html",
    "content": "<span class=\"p-input-icon-left\">\n    <i class=\"fas fa-magnet\"></i>\n    <input type=\"text\" pInputText placeholder=\"Magnet URL\" [(ngModel)]=\"url\" autofocus/>\n</span>\n\n<t-add-torrent-config [config]=\"config\"></t-add-torrent-config>\n\n<p-messages [(value)]=\"errorMessages\"></p-messages>\n\n<div class=\"p-d-flex p-flex-row p-justify-between\">\n  <button pButton label=\"Add\" [icon]=\"submitIcon\" [disabled]=\"submitIsDisabled\" (click)=\"onSubmit()\"></button>\n  <button pButton class=\"p-button-danger\" label=\"Cancel\" icon=\"fas fa-times\" (click)=\"close()\"></button>\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.scss",
    "content": "span.p-input-icon-left, input {\n  width: 100%;\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentMagnetInputComponent } from './add-torrent-magnet-input.component';\n\ndescribe('AddTorrentMagnetInputComponent', () => {\n  let component: AddTorrentMagnetInputComponent;\n  let fixture: ComponentFixture<AddTorrentMagnetInputComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ AddTorrentMagnetInputComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(AddTorrentMagnetInputComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.ts",
    "content": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';\n\nexport interface MagnetInput {\n  url: string;\n}\n\n@Component({\n  selector: 't-add-torrent-magnet-input',\n  templateUrl: './add-torrent-magnet-input.component.html',\n  styleUrls: ['./add-torrent-magnet-input.component.scss']\n})\nexport class AddTorrentMagnetInputComponent extends AddTorrentDialogComponentDirective<MagnetInput> {\n  url: string;\n\n  constructor(injector: Injector) {\n    super(injector);\n    this.url = this.data.url;\n  }\n\n  onSubmit(): void {\n    this.submit({Type: 'magnet', URI: this.url});\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.html",
    "content": "<p-menu #menu [popup]=\"true\" [model]=\"items\" appendTo=\"body\"></p-menu>\n\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentMenuComponent } from './add-torrent-menu.component';\n\ndescribe('AddTorrentMenuComponent', () => {\n  let component: AddTorrentMenuComponent;\n  let fixture: ComponentFixture<AddTorrentMenuComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ AddTorrentMenuComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(AddTorrentMenuComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.ts",
    "content": "import {Component, ViewChild} from '@angular/core';\nimport {MenuItem} from 'primeng/api';\nimport {Menu} from 'primeng/menu';\nimport {DialogService} from 'primeng/dynamicdialog';\nimport {AddTorrentMagnetInputComponent} from './add-torrent-magnet-input/add-torrent-magnet-input.component';\nimport {AddTorrentUrlInputComponent} from './add-torrent-url-input/add-torrent-url-input.component';\nimport {AddTorrentFileInputComponent} from './add-torrent-file-input/add-torrent-file-input.component';\n\n@Component({\n  selector: 't-add-torrent-menu',\n  templateUrl: './add-torrent-menu.component.html',\n  styleUrls: ['./add-torrent-menu.component.scss'],\n})\nexport class AddTorrentMenuComponent {\n  @ViewChild('menu') menu: Menu;\n\n  items: MenuItem[] = [\n    {\n      label: 'Add Torrent',\n      items: [\n        {\n          label: 'Magnet',\n          icon: 'fas fa-magnet',\n          command: () => this.openDialog(AddTorrentMagnetInputComponent)\n        },\n        {\n          label: 'URL',\n          icon: 'fas fa-link',\n          command: () => this.openDialog(AddTorrentUrlInputComponent)\n        },\n        {\n          label: 'File',\n          icon: 'far fa-file-alt',\n          command: () => this.openDialog(AddTorrentFileInputComponent)\n        }\n      ]\n    }\n  ];\n\n\n  constructor(private dialogService: DialogService) {\n  }\n\n  public toggle($event): void {\n    this.menu.toggle($event);\n  }\n\n  private openDialog(component: any): void {\n    this.dialogService.open(component, {\n      showHeader: false,\n      closable: true,\n      closeOnEscape: true,\n      dismissableMask: true,\n      styleClass: 't-dialog-responsive'\n    });\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.html",
    "content": "<span class=\"p-input-icon-left\">\n    <i class=\"fas fa-link\"></i>\n    <input type=\"text\" pInputText placeholder=\"Torrent URL\" [(ngModel)]=\"url\" autofocus/>\n</span>\n\n<t-add-torrent-config [config]=\"config\"></t-add-torrent-config>\n\n<p-messages [(value)]=\"errorMessages\"></p-messages>\n\n<div class=\"p-d-flex p-flex-row p-justify-between\">\n  <button pButton label=\"Add\" [icon]=\"submitIcon\" [disabled]=\"submitIsDisabled\" (click)=\"onSubmit()\"></button>\n  <button pButton class=\"p-button-danger\" label=\"Cancel\" icon=\"fas fa-times\" (click)=\"close()\"></button>\n</div>\n\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.scss",
    "content": "span.p-input-icon-left, input {\n  width: 100%;\n}\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentUrlInputComponent } from './add-torrent-url-input.component';\n\ndescribe('AddTorrentUrlInputComponent', () => {\n  let component: AddTorrentUrlInputComponent;\n  let fixture: ComponentFixture<AddTorrentUrlInputComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ AddTorrentUrlInputComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(AddTorrentUrlInputComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.ts",
    "content": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';\n\nexport interface URLInput {\n  url: string;\n}\n\n@Component({\n  selector: 't-add-torrent-url-input',\n  templateUrl: './add-torrent-url-input.component.html',\n  styleUrls: ['./add-torrent-url-input.component.scss']\n})\nexport class AddTorrentUrlInputComponent extends AddTorrentDialogComponentDirective<URLInput> {\n  url: string;\n\n  constructor(injector: Injector) {\n    super(injector);\n    this.url = this.data.url;\n  }\n\n  onSubmit(): void {\n    this.submit({Type: 'url', URI: this.url});\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.html",
    "content": "<form (ngSubmit)=\"onSubmit()\">\n  <div class=\"p-grid p-fluid\">\n    <div class=\"p-field p-col-12\">\n      <input #input=\"ngModel\" pInputText type=\"password\" placeholder=\"Enter API Key\" required autofocus [(ngModel)]=\"key\" >\n    </div>\n  </div>\n\n  <div class=\"p-d-flex p-flex-row p-justify-between\">\n    <button pButton class=\"p-button-success\" type=\"submit\" [disabled]=\"!input.valid\">Submit</button>\n  </div>\n</form>\n"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ApiKeyDialogComponent } from './api-key-dialog.component';\n\ndescribe('ApiKeyDialogComponent', () => {\n  let component: ApiKeyDialogComponent;\n  let fixture: ComponentFixture<ApiKeyDialogComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ ApiKeyDialogComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(ApiKeyDialogComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.ts",
    "content": "import { Component } from '@angular/core';\nimport {DynamicDialogRef} from \"primeng/dynamicdialog\";\n\n@Component({\n  selector: 't-api-key-dialog',\n  templateUrl: './api-key-dialog.component.html',\n  styleUrls: ['./api-key-dialog.component.scss']\n})\nexport class ApiKeyDialogComponent{\n  key: string;\n\n  constructor(private ref: DynamicDialogRef) { }\n\n  onSubmit() {\n    this.ref.close(this.key)\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.html",
    "content": "<div *ngIf=\"$breakpoint | async; else content\">\n\n  <p-overlayPanel #panel appendTo=\"body\">\n    <div class=\"p-d-flex p-flex-column\" [classList]=\"styleClass\">\n      <ng-content *ngTemplateOutlet=\"content\">\n      </ng-content>\n    </div>\n  </p-overlayPanel>\n\n\n  <button pButton pRipple (click)=\"panel.toggle($event)\" type=\"button\" [icon]=\"icon\"></button>\n  <t-activity-marker *ngIf=\"contentActivated\"></t-activity-marker>\n</div>\n\n<ng-template #content>\n  <ng-content>\n  </ng-content>\n</ng-template>\n\n\n"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { BreakpointOverlayComponent } from './breakpoint-overlay.component';\n\ndescribe('BreakpointOverlayComponent', () => {\n  let component: BreakpointOverlayComponent;\n  let fixture: ComponentFixture<BreakpointOverlayComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ BreakpointOverlayComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(BreakpointOverlayComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.ts",
    "content": "import {Component, Input} from '@angular/core';\nimport {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';\nimport {Observable} from 'rxjs';\nimport {map} from 'rxjs/operators';\n\n@Component({\n  selector: 't-breakpoint-overlay',\n  templateUrl: './breakpoint-overlay.component.html',\n  styleUrls: ['./breakpoint-overlay.component.scss']\n})\nexport class BreakpointOverlayComponent {\n  @Input('icon') icon: string;\n  @Input('styleClass') styleClass: string;\n  @Input('contentActivated') contentActivated = false;\n\n  $breakpoint: Observable<boolean>;\n\n  constructor(breakpointObserver: BreakpointObserver) {\n    // Breakpoint observable to switch the view to small handset view\n    this.$breakpoint = breakpointObserver.observe(\n      [Breakpoints.Small, Breakpoints.HandsetPortrait]\n    ).pipe(\n      map(state => state.matches)\n    );\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.html",
    "content": "<div class=\"t-connectivity\" [class.connected]=\"connected\" [class.disconnected]=\"!connected\"\n     [class.transition]=\"animate\" [class.transition-closing]=\"closing\" *ngIf=\"$visible | async\">\n  <span>{{ connected ? 'connected' : 'not connected'}}</span>\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.scss",
    "content": "\n.t-connectivity {\n  position: fixed;\n  bottom: 0;\n  width: 100%;\n  z-index: 1000;\n  text-align: center;\n  padding: .2rem;\n  color: #fff;\n  font-weight: 600;\n\n  &.connected {\n    background-color: #5ab132;\n  }\n\n  &.disconnected {\n    background-color: #d45e6a\n  }\n\n\n  &.transition {\n    transition: 1s;\n\n    &.transition-closing {\n      transform: translateY(100%);\n    }\n  }\n\n\n}\n\n\n"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ConnectivityStatusComponent } from './connectivity-status.component';\n\ndescribe('ConnectivityStatusComponent', () => {\n  let component: ConnectivityStatusComponent;\n  let fixture: ComponentFixture<ConnectivityStatusComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ ConnectivityStatusComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(ConnectivityStatusComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.ts",
    "content": "import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';\nimport {concat, Observable, of, Subject, timer} from 'rxjs';\nimport {map, switchMap, tap} from 'rxjs/operators';\n\n@Component({\n  selector: 't-connectivity-status',\n  templateUrl: './connectivity-status.component.html',\n  styleUrls: ['./connectivity-status.component.scss']\n})\nexport class ConnectivityStatusComponent implements OnChanges {\n  @Input('connected') connected: boolean;\n\n  $connected: Subject<boolean>;\n  $visible: Observable<boolean>;\n\n  animate = false;\n  closing = false;\n\n  constructor() {\n    this.$connected = new Subject<boolean>();\n    this.$visible = this.$connected.pipe(\n      tap(_ => {\n        this.animate = false;\n        this.closing = false;\n      }),\n      switchMap(ok => {\n        if (!ok) {\n          // If not connected then make the status visible\n          return of(true);\n        }\n\n        // Otherwise, if connected\n        // Make the status visible, then after 2 seconds disable the visibility\n        return concat(\n          of(true),\n          // After two seconds begin the transition\n          timer(2000, 0).pipe(\n            switchMap(_ => {\n              this.animate = true;\n              this.closing = true;\n              return timer(1000, 0).pipe(\n                map(_ => false)\n              );\n            })\n          )\n        );\n      })\n    );\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    const connectedChange = changes.connected;\n\n    // Connectivity change not made in this cycle\n    if (typeof connectedChange === 'undefined') {\n      return;\n    }\n\n    if (connectedChange.currentValue === true && connectedChange.isFirstChange()) {\n      // Not interested in a successful initial connection\n      return;\n    }\n\n    this.$connected.next(connectedChange.currentValue);\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.html",
    "content": "<p-overlayPanel #overlay appendTo=\"body\">\n  <ng-template pTemplate>\n      <p-progressSpinner *ngIf=\"processing\"></p-progressSpinner>\n      <div *ngIf=\"!processing\" class=\"p-d-flex p-flex-column\">\n        <button (click)=\"onRemove(false)\" type=\"button\" pButton pRipple class=\"p-button t-overlay-button t-button-alt\"\n                label=\"Remove\"></button>\n        <button (click)=\"onRemove(true)\" type=\"button\" pButton pRipple class=\"p-button p-button-danger t-overlay-button\"\n                label=\"Remove With Data\"></button>\n      </div>\n\n\n  </ng-template>\n</p-overlayPanel>\n"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.scss",
    "content": ".t-overlay-button:not(:last-child) {\n  margin-bottom: 1rem;\n}\n"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { DeleteTorrentOverlayComponent } from './delete-torrent-overlay.component';\n\ndescribe('DeleteTorrentOverlayComponent', () => {\n  let component: DeleteTorrentOverlayComponent;\n  let fixture: ComponentFixture<DeleteTorrentOverlayComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ DeleteTorrentOverlayComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(DeleteTorrentOverlayComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.ts",
    "content": "import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';\nimport {ApiService} from '../../api.service';\nimport {OverlayPanel} from 'primeng/overlaypanel';\nimport {finalize, mergeMap} from 'rxjs/operators';\nimport {from} from 'rxjs';\n\n@Component({\n  selector: 't-delete-torrent-overlay',\n  templateUrl: './delete-torrent-overlay.component.html',\n  styleUrls: ['./delete-torrent-overlay.component.scss']\n})\nexport class DeleteTorrentOverlayComponent {\n  @Input('torrents') torrents: string[];\n  @Output('removed') removed = new EventEmitter<boolean>();\n\n\n  @ViewChild('overlay') overlay: OverlayPanel;\n\n\n  processing = false;\n\n  constructor(private api: ApiService) {\n  }\n\n  public toggle($event): void {\n    this.overlay.toggle($event, $event.target);\n  }\n\n  onRemove(withData: boolean): void {\n    this.processing = true;\n\n    from(this.torrents).pipe(\n      mergeMap(hash => this.api.removeTorrent(withData, hash)),\n      finalize(() => {\n        this.processing = false;\n        this.overlay.hide();\n        this.removed.emit(true);\n      })\n    ).subscribe(\n      _ => console.log('torrent deleted'),\n    );\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.html",
    "content": "<p>The Deluge plugin <b>{{name}}</b> must be enabled to continue</p>\n\n<div class=\"p-d-flex p-flex-row p-justify-between\">\n  <button pButton [label]=\"inProgress ? 'Enabling...' : 'Enable'\" type=\"button\" [disabled]=\"inProgress\"\n          (click)=\"onSubmit()\" class=\"p-button-success\"></button>\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { PluginEnableComponent } from './plugin-enable.component';\n\ndescribe('PluginEnableComponent', () => {\n  let component: PluginEnableComponent;\n  let fixture: ComponentFixture<PluginEnableComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ PluginEnableComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(PluginEnableComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.ts",
    "content": "import { Component, OnInit } from '@angular/core';\nimport {DynamicDialogConfig, DynamicDialogRef} from \"primeng/dynamicdialog\";\nimport {ApiService} from \"../../api.service\";\n\n@Component({\n  selector: 't-plugin-enable',\n  templateUrl: './plugin-enable.component.html',\n  styleUrls: ['./plugin-enable.component.scss']\n})\nexport class PluginEnableComponent implements OnInit {\n  name: string;\n\n  inProgress: boolean = false;\n\n  constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig, private api: ApiService) {\n    this.name = config.data.name;\n  }\n\n  ngOnInit(): void {\n  }\n\n  onSubmit(): void {\n    this.inProgress = true;\n    this.api.enablePlugin(this.name).subscribe(\n      _ => {\n        this.inProgress = false;\n        this.ref.close(true);\n      },\n    )\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.html",
    "content": "<div class=\"t-session-status\">\n  <span><i class=\"fas fa-users\"></i>{{sessionStatus.NumPeers}}/{{sessionStatus.DhtNodes}}</span>\n\n  <span><i class=\"fas fa-arrow-down\"></i>{{sessionStatus.DownloadRate | filesize}}s</span>\n  <span><i class=\"fas fa-arrow-up\"></i>{{sessionStatus.UploadRate | filesize}}s</span>\n\n  <span><i class=\"fas fa-download\"></i>{{sessionStatus.TotalDownload | filesize}}</span>\n  <span><i class=\"fas fa-upload\"></i>{{sessionStatus.TotalUpload | filesize}}</span>\n\n  <span><i class=\"fas fa-hdd\"></i>{{diskSpace?.FreeBytes | filesize}}</span>\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.scss",
    "content": ".t-session-status {\n  width: 100%;\n  background: #343e4d;\n  color: rgba(255, 255, 255, 0.5);\n  padding: 4px 1rem;\n  display: flex;\n  flex-direction: row;\n  justify-content: right;\n\n  font-weight: 500;\n  font-size: .9rem;\n\n  & > span {\n    &:not(:last-child) {\n      margin-right: 1rem;\n    }\n  }\n\n  & i {\n    opacity: .6;\n    margin-right: 4px;\n  }\n}\n\n\n@media only screen and (max-width: 750px) {\n  .t-session-status {\n    justify-content: center;\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { SessionStatusComponent } from './session-status.component';\n\ndescribe('SessionStatusComponent', () => {\n  let component: SessionStatusComponent;\n  let fixture: ComponentFixture<SessionStatusComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ SessionStatusComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(SessionStatusComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.ts",
    "content": "import {Component, Input, OnInit} from '@angular/core';\nimport {DiskSpace, SessionStatus} from '../../api.service';\n\n@Component({\n  selector: 't-session-status',\n  templateUrl: './session-status.component.html',\n  styleUrls: ['./session-status.component.scss']\n})\nexport class SessionStatusComponent implements OnInit {\n  @Input() sessionStatus: SessionStatus;\n  @Input() diskSpace: DiskSpace;\n\n  constructor() {\n  }\n\n  ngOnInit(): void {\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.html",
    "content": "<ng-template #torrentInfinite>\n  ∞\n</ng-template>\n\n<div class=\"p-card p-component\">\n  <div class=\"p-card-body\">\n    <div class=\"p-d-flex p-flex-column p-flex-md-row\" style=\"padding: 0\">\n      <div class=\"p-d-flex p-flex-column p-md-8\">\n        <div class=\"p-card-title\">\n          {{torrent.Name}}\n        </div>\n        <div class=\"p-card-subtitle t-torrent-detail\">\n          <t-torrent-state [state]=\"torrent.State\"></t-torrent-state>\n          <t-torrent-label [hash]=\"hash\" [label]=\"label\"></t-torrent-label>\n        </div>\n      </div>\n\n\n    </div>\n\n    <div class=\"p-d-flex p-flex-column t-bar p-col-12\">\n      <div class=\"p-d-flex p-flex-row p-align-center t-torrent-progress\">\n        <span pTooltip=\"ETA\">\n          <i class=\"far fa-clock\"></i>\n          <ng-container *ngIf=\"torrent.ETA else torrentInfinite\">\n                      {{torrent.ETA | amDuration: 'seconds'}}\n          </ng-container>\n        </span>\n        <div class=\"spacer\"></div>\n\n        <span pTooltip=\"Total size\">\n          <i class=\"far fa-hdd\"></i>\n          {{torrent.TotalSize | filesize}}\n        </span>\n      </div>\n\n      <p-progressBar [value]=\"torrent.Progress | number: '1.0-2'\">\n\n      </p-progressBar>\n    </div>\n\n    <div class=\"p-d-flex p-flex-row p-justify-between t-details\">\n      <div class=\"t-info p-d-flex p-flex-row p-flex-wrap\">\n        <span pTooltip=\"Download speed\">\n          <i class=\"fas fa-download\"></i>\n          {{torrent.DownloadPayloadRate |filesize}}s\n        </span>\n\n        <span pTooltip=\"Upload speed\">\n          <i class=\"fas fa-upload\"></i>\n          {{torrent.UploadPayloadRate |filesize}}s\n        </span>\n\n        <span pTooltip=\"Seeding time\">\n          <i class=\"fas fa-leaf\"></i>\n          {{torrent.SeedingTime === 0 ? '0s' : (torrent.SeedingTime | amDuration: 'seconds')}}\n        </span>\n\n        <span pTooltip=\"Seed ratio\">\n          <i class=\"fas fa-percentage\"></i>\n          {{torrent.Ratio < 0 ? 0 : torrent.Ratio | number : '1.0-2'}}\n        </span>\n      </div>\n\n      <div class=\"t-controls\">\n        <button pButton pRipple (click)=\"onChangeState()\" type=\"button\"\n                [icon]=\"torrent.State == 'Paused' ? 'fas fa-play' : 'fas fa-pause'\"\n                class=\"p-button-text\"\n                [pTooltip]=\"torrent.State == 'Paused' ? 'Resume' : 'Pause'\"></button>\n\n        <t-delete-torrent-overlay #remove [torrents]=\"[hash]\"\n                                  (removed)=\"removed.emit($event)\"></t-delete-torrent-overlay>\n        <button pButton pRipple (click)=\"remove.toggle($event)\" type=\"button\" icon=\"far fa-trash-alt\"\n                class=\"p-button-text p-button-danger\"\n                pTooltip=\"Remove\"></button>\n      </div>\n\n    </div>\n\n  </div>\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.scss",
    "content": ".p-card-body {\n  flex-grow: 1;\n  margin-right: .4rem;\n  padding: .4rem;\n\n  & .p-card-title {\n    font-size: 1rem;\n    word-break: break-word;\n    font-weight: 700;\n    margin-bottom: 0.2rem;\n  }\n\n  & .p-card-subtitle {\n    font-weight: 400;\n    color: rgba(255, 255, 255, 0.6);\n    margin-bottom: 0.4rem;\n  }\n}\n\n\n.p-lg-8, .p-lg-4 {\n  padding: 0;\n}\n\n.t-bar {\n  flex-grow: 1;\n  justify-content: center;\n}\n\n.t-controls {\n  text-align: right;\n}\n\n.t-torrent-progress {\n  margin-bottom: 12px;\n  opacity: .6;\n\n  & .spacer {\n    flex-grow: 1;\n  }\n\n  & i {\n    opacity: .4;\n    vertical-align: middle;\n    margin-right: 4px;\n  }\n}\n\n.t-torrent-eta {\n\n}\n\n.t-info {\n  opacity: .4;\n  padding: .4rem;\n\n  & span {\n    word-break: break-all;\n\n    & i {\n      margin-right: 2px;\n    }\n\n    &:not(:last-child) {\n      margin-right: 18px;\n    }\n  }\n}\n\n.t-torrent-detail {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  flex-wrap: wrap;\n\n  & > *:not(:last-child) {\n    margin-right: 18px;\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentComponent } from './torrent.component';\n\ndescribe('TorrentComponent', () => {\n  let component: TorrentComponent;\n  let fixture: ComponentFixture<TorrentComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.ts",
    "content": "import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';\nimport {ApiService, Torrent} from '../../api.service';\nimport {Observable} from 'rxjs';\nimport {switchMap, take} from 'rxjs/operators';\n\n@Component({\n  selector: 't-torrent',\n  templateUrl: './torrent.component.html',\n  styleUrls: ['./torrent.component.scss']\n})\nexport class TorrentComponent implements OnInit {\n  @Input('hash') hash: string;\n  @Input('torrent') torrent: Torrent;\n  @Input('label') label: string;\n  @Output('removed') removed = new EventEmitter<boolean>();\n\n  constructor(private api: ApiService) {\n  }\n\n  ngOnInit(): void {\n  }\n\n  private refreshAfter(action: Observable<any>): void {\n    action.pipe(\n      switchMap(_ => this.api.torrent(this.hash)),\n      take(1),\n    ).subscribe(\n      torrent => this.torrent = torrent\n    );\n  }\n\n  public onPause(): void {\n    this.refreshAfter(this.api.pause(this.hash));\n  }\n\n  public onResume(): void {\n    this.refreshAfter(this.api.resume(this.hash));\n  }\n\n  public onChangeState(): void {\n    if (this.torrent.State === 'Paused') {\n      this.onResume();\n      return;\n    }\n\n    this.onPause();\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.html",
    "content": "<t-torrent [hash]=\"id\" [torrent]=\"torrent | async\" (removed)=\"onRemoved()\"></t-torrent>\n"
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.scss",
    "content": ""
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentDetailsDialogComponent } from './torrent-details-dialog.component';\n\ndescribe('TorrentDetailsDialogComponent', () => {\n  let component: TorrentDetailsDialogComponent;\n  let fixture: ComponentFixture<TorrentDetailsDialogComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentDetailsDialogComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentDetailsDialogComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.ts",
    "content": "import {Component, Injectable} from '@angular/core';\nimport {Torrent} from '../../api.service';\nimport {Observable} from 'rxjs';\nimport {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';\n\n@Component({\n  selector: 't-torrent-details-dialog',\n  templateUrl: './torrent-details-dialog.component.html',\n  styleUrls: ['./torrent-details-dialog.component.scss']\n})\nexport class TorrentDetailsDialogComponent {\n  id: string;\n  torrent: Observable<Torrent>;\n\n  constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig) {\n    this.id = config.data.id;\n    this.torrent = config.data.torrent;\n  }\n\n  public onRemoved(): void {\n    this.ref.close();\n  }\n}\n\n\n@Injectable({\n  providedIn: 'root',\n})\nexport class TorrentDetailsDialogService {\n  constructor(private dialogService: DialogService) {\n  }\n\n  public open(id: string, torrent: Observable<Torrent>): void {\n    this.dialogService.open(TorrentDetailsDialogComponent, {\n      showHeader: false,\n      closable: true,\n      closeOnEscape: true,\n      dismissableMask: true,\n      styleClass: 't-dialog-responsive',\n      data: {\n        id,\n        torrent\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.html",
    "content": "<ng-template #useExistingLabel let-suggestion>\n  <span class=\"t-existing-label\">Use <b>{{suggestion.value}}</b></span>\n  <button pButton type=button (click)=\"onDeleteLabel($event, suggestion.value)\" pTooltip=\"Delete this label\"\n          icon=\"far fa-trash-alt\" class=\"t-delete-label-button p-button-rounded p-button-text p-button-sm\"></button>\n</ng-template>\n\n<ng-template #createNewLabel let-suggestion>\n  <span class=\"t-new-label\">Create and use a new label named <b>{{suggestion.value}}</b></span>\n</ng-template>\n\n<ng-template #clearCurrentLabel>\n  <span class=\"t-new-label\">Clear the current label <b>{{initialLabel}}</b></span>\n</ng-template>\n\n\n<div class=\"p-grid p-fluid\">\n  <div class=\"p-field p-col-12\">\n    <input pInputText autofocus [(ngModel)]=\"label\" (ngModelChange)=\"query$.next($event)\"\n           placeholder=\"Start typing a new label\">\n  </div>\n\n  <div class=\"p-col-12\">\n    <button *ngFor=\"let suggestion of suggestions$ | async\"\n            class=\"p-button p-component p-d-flex p-flex-row p-justify-between t-suggestion-button\" type=\"button\"\n            (click)=\"onApplySuggestion(suggestion)\">\n      <ng-container\n        [ngTemplateOutlet]=\"suggestion.new ? createNewLabel : suggestion.clear ? clearCurrentLabel : useExistingLabel\"\n        [ngTemplateOutletContext]=\"{$implicit: suggestion}\">\n      </ng-container>\n    </button>\n  </div>\n\n</div>\n"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.scss",
    "content": "span.t-new-label {\r\n  font-style: italic;\r\n}\r\n\r\nbutton.t-suggestion-button {\r\n  margin-bottom: 8px;\r\n}\r\n\r\nbutton.p-button.t-delete-label-button {\r\n  height: 1rem;\r\n}\r\n"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentEditLabelDialogComponent } from './torrent-edit-label-dialog.component';\n\ndescribe('TorrentEditLabelDialogComponent', () => {\n  let component: TorrentEditLabelDialogComponent;\n  let fixture: ComponentFixture<TorrentEditLabelDialogComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentEditLabelDialogComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentEditLabelDialogComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.ts",
    "content": "import {Component, Injectable} from '@angular/core';\nimport {DialogService, DynamicDialogConfig, DynamicDialogRef} from \"primeng/dynamicdialog\";\nimport {ApiService} from \"../../api.service\";\nimport {BehaviorSubject, combineLatest, Observable, of} from \"rxjs\";\nimport {delay, map, shareReplay, switchMap} from \"rxjs/operators\";\n\ninterface LabelSuggestion {\n  value: string;\n  new: boolean;\n  clear: boolean;\n}\n\n@Component({\n  selector: 't-torrent-edit-label-dialog',\n  templateUrl: './torrent-edit-label-dialog.component.html',\n  styleUrls: ['./torrent-edit-label-dialog.component.scss']\n})\nexport class TorrentEditLabelDialogComponent {\n  id: string\n  label: string;\n  initialLabel: string;\n\n  query$ = new BehaviorSubject<string>('');\n  refresh$ = new BehaviorSubject<void>(null);\n  labels$: Observable<string[]>;\n  suggestions$: Observable<LabelSuggestion[]>;\n\n  constructor(private ref: DynamicDialogRef, config: DynamicDialogConfig, private api: ApiService) {\n    this.id = config.data.id;\n    this.label = '';\n    this.initialLabel = config.data.currentLabel ? config.data.currentLabel : '';\n\n    this.labels$ = this.refresh$.pipe(\n      switchMap(_ => this.api.labels()),\n      shareReplay(1)\n    );\n\n    this.suggestions$ = combineLatest([this.labels$, this.query$]).pipe(\n      map(([labels, query]) => {\n        const suggestions: LabelSuggestion[] = [];\n        const preparedQuery = query.toLocaleLowerCase();\n\n        let hasExactMatch = false;\n        for (const label of labels) {\n\n          // Don't suggest the initial label\n          if (!!this.initialLabel && preparedQuery === this.initialLabel) {\n            hasExactMatch = true;\n            continue\n          }\n\n          // Suggest an existing label if it partially matches\n          if (!preparedQuery || label.includes(preparedQuery)) {\n            // Only suggest this label if there was no initial label, or if this label does not match the initial\n            if (this.initialLabel === '' || label !== this.initialLabel) {\n              suggestions.push({value: label, new: false, clear: false})\n            }\n          }\n\n          if (label === preparedQuery) {\n            hasExactMatch = true\n          }\n        }\n\n        // If there is a query to suggest, and there's no exact match then suggest creating a new label\n        if (preparedQuery && !hasExactMatch) {\n          suggestions.push({value: preparedQuery, new: true, clear: false})\n        }\n\n        // If there was an initial label, then suggest clearing it\n        if (!!this.initialLabel) {\n          suggestions.push({value: '', new: false, clear: true})\n        }\n\n        return suggestions\n      })\n    )\n  }\n\n  onDeleteLabel($event: { preventDefault: () => void }, name: string): void {\n    $event.preventDefault();\n\n    this.api.deleteLabel(name).subscribe(\n      _ => {\n        if (name === this.label) {\n          this.label = '';\n        }\n\n        this.refresh$.next(null)\n      }\n    )\n  }\n\n  onComplete($event: { query: string }) {\n    this.query$.next($event.query)\n  }\n\n  onApplySuggestion(suggestion: LabelSuggestion): void {\n    // Do nothing if the label is unchanged\n    if (suggestion.value === this.initialLabel && !suggestion.new) {\n      this.ref.close();\n      return;\n    }\n\n    let prep$: Observable<void> = of(null)\n\n    // If the label is not empty then check it is not one of the existing labels\n    if (suggestion.value && suggestion.new) {\n      prep$ = this.api.createLabel(suggestion.value).pipe(\n        // Deluge has issues when a torrent label is set immediately after setting it\n        delay(200)\n      )\n    }\n\n    prep$.pipe(\n      switchMap(_ => this.api.setTorrentLabel(this.id, {Label: suggestion.value}))\n    ).subscribe(\n      _ => this.ref.close(suggestion.value)\n    )\n  }\n}\n\n@Injectable({\n  providedIn: 'root',\n})\nexport class TorrentEditLabelService {\n  constructor(private dialogService: DialogService) {\n  }\n\n  public open(id: string, currentLabel?: string): Observable<string> {\n    const ref = this.dialogService.open(TorrentEditLabelDialogComponent, {\n      header: 'Edit Label',\n      showHeader: true,\n      closable: true,\n      closeOnEscape: true,\n      dismissableMask: true,\n      styleClass: 't-dialog-responsive',\n      data: {\n        id,\n        currentLabel,\n      }\n    });\n\n    return ref.onClose.pipe(\n      map(value => typeof value === 'undefined' ? currentLabel : value)\n    )\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.html",
    "content": "<div (click)=\"onUpdateLabel()\" class=\"t-label-container\">\n  <span class=\"t-label\" pTooltip=\"Torrent label\" *ngIf=\"label else noLabel\">{{label}}</span>\n\n</div>\n\n<ng-template #noLabel>\n  <span class=\"t-label-no-label\">no label</span>\n</ng-template>\n"
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.scss",
    "content": ".t-label-container{\r\n  cursor: pointer;\r\n}\r\n\r\n.t-label-no-label {\r\n  font-style: italic;\r\n  opacity: .4;\r\n}\r\n\r\n.t-label {\r\n  &:before {\r\n    content: \"\\f02b\";\r\n    font-family: 'Font Awesome 5 Free';\r\n    font-weight: 900;\r\n    margin-right: 4px;\r\n  }\r\n}\r\n"
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentLabelComponent } from './torrent-label.component';\n\ndescribe('TorrentLabelComponent', () => {\n  let component: TorrentLabelComponent;\n  let fixture: ComponentFixture<TorrentLabelComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentLabelComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentLabelComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.ts",
    "content": "import {Component, Input} from '@angular/core';\nimport {TorrentEditLabelService} from \"../torrent-edit-label-dialog/torrent-edit-label-dialog.component\";\n\n@Component({\n  selector: 't-torrent-label',\n  templateUrl: './torrent-label.component.html',\n  styleUrls: ['./torrent-label.component.scss']\n})\nexport class TorrentLabelComponent {\n  @Input('hash') hash: string;\n  @Input('label') label: string;\n\n  constructor(private editLabelService: TorrentEditLabelService) { }\n\n  onUpdateLabel(): void {\n    this.editLabelService.open(this.hash, this.label).subscribe(\n      newLabel => this.label = newLabel\n    )\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.html",
    "content": "<div class=\"p-d-flex p-flex-row t-search\">\n  <div class=\"p-inputgroup\">\n    <button type=\"button\" pButton pRipple *ngIf=\"icon\" [icon]=\"icon\" (click)=\"onAdd()\" class=\"p-button-success\"></button>\n\n    <input type=\"text\" pInputText [(ngModel)]=\"searchText\" placeholder=\"Search or Torrent URL\" (ngModelChange)=\"onChange($event)\">\n    <button type=\"button\" *ngIf=\"searchText\" pButton pRipple icon=\"fas fa-times\" (click)=\"onClear()\"></button>\n  </div>\n</div>\n\n"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.scss",
    "content": "\n"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentSearchComponent } from './torrent-search.component';\n\ndescribe('TorrentSearchComponent', () => {\n  let component: TorrentSearchComponent;\n  let fixture: ComponentFixture<TorrentSearchComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentSearchComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentSearchComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.ts",
    "content": "import {Component, EventEmitter, Output} from '@angular/core';\nimport {AddTorrentMagnetInputComponent} from '../add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component';\nimport {AddTorrentUrlInputComponent} from '../add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component';\nimport {DialogService} from 'primeng/dynamicdialog';\n\n\ninterface CT {\n  type: any;\n  icon: string;\n}\n\nconst EntryComponents: { [k: string]: CT | undefined } = {\n  search: undefined,\n  magnet: {\n    type: AddTorrentMagnetInputComponent,\n    icon: 'fas fa-magnet',\n  },\n  url: {\n    type: AddTorrentUrlInputComponent,\n    icon: 'fas fa-link'\n  },\n};\n\n@Component({\n  selector: 't-torrent-search',\n  templateUrl: './torrent-search.component.html',\n  styleUrls: ['./torrent-search.component.scss']\n})\nexport class TorrentSearchComponent {\n  @Output('search') search = new EventEmitter<string>();\n\n  searchText: string;\n  icon: string;\n  mode: keyof typeof EntryComponents = 'search';\n\n\n  constructor(private dialogService: DialogService) {\n  }\n\n\n  private setTarget(target: keyof typeof EntryComponents): void {\n    if (target === this.mode) {\n      return;\n    }\n\n    if (target === 'search') {\n      this.mode = 'search';\n      this.icon = null;\n      return;\n    }\n\n    // Otherwise this is a target url\n\n    if (this.mode === 'search') {\n      this.search.emit('');\n    }\n\n    this.mode = target;\n    this.icon = EntryComponents[target].icon;\n  }\n\n  onClear(): void {\n    this.mode = 'search';\n    this.icon = null;\n    this.searchText = '';\n    this.search.emit('');\n  }\n\n  onChange($event: string): void {\n    switch (true) {\n      case $event.startsWith('magnet:'):\n        this.setTarget('magnet');\n        break;\n      case $event.startsWith('http://') || $event.startsWith('https://'):\n        this.setTarget('url');\n        break;\n      default:\n        this.setTarget('search');\n        this.search.emit(this.searchText);\n    }\n  }\n\n  onAdd(): void {\n    const component = EntryComponents[this.mode];\n    const ref = this.dialogService.open(component.type, {\n      showHeader: false,\n      closable: true,\n      closeOnEscape: true,\n      dismissableMask: true,\n      styleClass: 't-dialog-responsive',\n      data: {\n        url: this.searchText\n      }\n    });\n\n    // When the modal is closed, if the modal\n    // produces a non-nil result (a valid torrent hash)\n    ref.onClose.subscribe(\n      result => {\n        if (!!result) {\n          this.onClear();\n        }\n      }\n    );\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.html",
    "content": "<span class=\"t-state t-state-{{state}}\">{{state}}</span>\n"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.scss",
    "content": ".t-state {\n  &:before {\n    content: \"\\f111\";\n    font-family: 'Font Awesome 5 Free';\n    font-weight: 400;\n    margin-right: 4px;\n  }\n\n  &.t-state-Queued {\n    color: #adaf16;\n  }\n\n  &.t-state-Downloading {\n    color: #5ab132;\n  }\n\n  &.t-state-Seeding {\n    color: #8dd0ff;\n  }\n\n  &.t-state-Error {\n    color: #d45e6a;\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.spec.ts",
    "content": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentStateComponent } from './torrent-state.component';\n\ndescribe('TorrentStateComponent', () => {\n  let component: TorrentStateComponent;\n  let fixture: ComponentFixture<TorrentStateComponent>;\n\n  beforeEach(async () => {\n    await TestBed.configureTestingModule({\n      declarations: [ TorrentStateComponent ]\n    })\n    .compileComponents();\n  });\n\n  beforeEach(() => {\n    fixture = TestBed.createComponent(TorrentStateComponent);\n    component = fixture.componentInstance;\n    fixture.detectChanges();\n  });\n\n  it('should create', () => {\n    expect(component).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.ts",
    "content": "import {Component, Input} from '@angular/core';\nimport {State} from '../../api.service';\n\n@Component({\n  selector: 't-torrent-state',\n  templateUrl: './torrent-state.component.html',\n  styleUrls: ['./torrent-state.component.scss']\n})\nexport class TorrentStateComponent {\n  @Input('state') state: State;\n\n  constructor() {\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/environment.ts",
    "content": "import {InjectionToken} from \"@angular/core\";\n\nexport interface Environment {\n  baseApiPath: string;\n}\n\nexport const ENVIRONMENT = new InjectionToken<Environment>('app.environment');\n"
  },
  {
    "path": "frontend/src/app/focus.service.spec.ts",
    "content": "import { TestBed } from '@angular/core/testing';\n\nimport { FocusService } from './focus.service';\n\ndescribe('FocusService', () => {\n  let service: FocusService;\n\n  beforeEach(() => {\n    TestBed.configureTestingModule({});\n    service = TestBed.inject(FocusService);\n  });\n\n  it('should be created', () => {\n    expect(service).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/focus.service.ts",
    "content": "import {Injectable} from '@angular/core';\nimport {BehaviorSubject, Observable} from 'rxjs';\n\n@Injectable({\n  providedIn: 'root'\n})\nexport class FocusService {\n  private readonly $observer: BehaviorSubject<boolean>;\n\n  constructor() {\n    this.$observer = new BehaviorSubject<boolean>(true);\n\n    document.addEventListener('visibilitychange', () => {\n      this.$observer.next(document.visibilityState === 'visible');\n    });\n\n    // Safari does not emit an event for visibilityState == 'hidden'.\n    // In this case we need to handle the 'pagehide' event.\n    document.addEventListener('pagehide', () => this.$observer.next(false));\n  }\n\n  /**\n   * Returns an observer that signals true when the window is focused\n   * and false when the window is blurred.\n   */\n  public get observe(): Observable<boolean> {\n    return this.$observer;\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/order-by.pipe.spec.ts",
    "content": "import { OrderByPipe } from './order-by.pipe';\n\ndescribe('OrderByPipe', () => {\n  it('create an instance', () => {\n    const pipe = new OrderByPipe();\n    expect(pipe).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/order-by.pipe.ts",
    "content": "import {Pipe, PipeTransform} from '@angular/core';\n\n\n@Pipe({\n  name: 'orderBy'\n})\nexport class OrderByPipe implements PipeTransform {\n\n  transform<T>(values: Array<T>, field: undefined | keyof T, orderType: boolean): Array<T> {\n    if (!field || !Array.isArray(values)) {\n      // If no field is defined then return the values as-is\n      return values;\n    }\n\n    return values.sort((a: T, b: T) => {\n      const v0 = a[field];\n      const v1 = b[field];\n\n      if (v0 < v1) {\n        return orderType ? -1 : 1;\n      }\n\n      if (v0 > v1) {\n        return orderType ? 1 : -1;\n      }\n\n      return 0;\n    });\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/app/torrent-search.pipe.spec.ts",
    "content": "import { TorrentSearchPipe } from './torrent-search.pipe';\n\ndescribe('TorrentSearchPipe', () => {\n  it('create an instance', () => {\n    const pipe = new TorrentSearchPipe();\n    expect(pipe).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "frontend/src/app/torrent-search.pipe.ts",
    "content": "import {Pipe, PipeTransform} from '@angular/core';\nimport {Label, Torrent} from './api.service';\n\ntype LabelledTorrent = Label & Torrent;\n\n@Pipe({\n  name: 'torrentSearch'\n})\nexport class TorrentSearchPipe implements PipeTransform {\n\n  private filter(term: string): (t: LabelledTorrent) => boolean {\n    return (t: LabelledTorrent): boolean => {\n\n      switch (true) {\n        case t.Name.toLowerCase().includes(term):\n          return true;\n        case t.State.toLowerCase().includes(term):\n          return true;\n        case t.DownloadLocation.toLowerCase().includes(term):\n          return true;\n        case t.TrackerHost.toLowerCase().includes(term):\n          return true;\n        case t.Label.toLowerCase().includes(term):\n          return true;\n      }\n\n      return false;\n    };\n  }\n\n  transform<T extends LabelledTorrent>(values: Array<T>, term: string): Array<T> {\n    if (!values || !Array.isArray(values) || !term) {\n      return values;\n    }\n\n    const predicate = this.filter(term.toLowerCase());\n    return values.filter(predicate);\n  }\n\n}\n"
  },
  {
    "path": "frontend/src/assets/.gitkeep",
    "content": ""
  },
  {
    "path": "frontend/src/environments/environment.prod.ts",
    "content": "export const environment = {\n  production: true\n};\n"
  },
  {
    "path": "frontend/src/environments/environment.ts",
    "content": "// This file can be replaced during build by using the `fileReplacements` array.\n// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.\n// The list of file replacements can be found in `angular.json`.\n\nexport const environment = {\n  production: false\n};\n\n/*\n * For easier debugging in development mode, you can import the following file\n * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.\n *\n * This import should be commented out in production mode because it will have a negative impact\n * on performance if an error is thrown.\n */\n// import 'zone.js/dist/zone-error';  // Included with Angular CLI.\n"
  },
  {
    "path": "frontend/src/icons.scss",
    "content": "/**\nPrime Icons Adaptor.\nAdapts Prime Icons into FontAwesome icons\n */\n\n.fas, .far {\n  writing-mode: vertical-lr;\n}\n\n.pi {\n  font-family: 'Font Awesome 5 Free';\n  font-style: normal;\n  font-weight: 900;\n\n  &.pi-chevron-up::before {\n    content: '\\f077'\n  }\n\n  &.pi-chevron-down::before {\n    content: '\\f078'\n  }\n\n  &.pi-chevron-right::before {\n    content: '\\f054';\n  }\n\n  &.pi-check::before {\n    content: '\\f00c';\n  }\n\n  &.pi-plus:before {\n    content: '\\f067';\n  }\n\n  &.pi-upload:before {\n    content: '\\f093';\n  }\n\n  &.pi-times:before {\n    content: '\\f00d';\n  }\n}\n"
  },
  {
    "path": "frontend/src/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>Storm</title>\n  <base href=\"{{ .BasePath }}\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"theme-color\" content=\"#343e4d\">\n  <meta name='mobile-web-app-capable' content='yes'>\n  <meta name='apple-mobile-web-app-capable' content='yes'>\n  <meta name='apple-mobile-web-app-status-bar-style' content='black'>\n\n  <link rel='icon' type='image/png' href='assets/logo@32.png'>\n  <link rel='icon' sizes='192x192' href='assets/logo@192.png'>\n  <link rel='apple-touch-icon' href='assets/logo@152.png'>\n  <meta name='msapplication-square310x310logo' content='assets/logo@310.png'>\n\n  <script type=\"text/javascript\">\n    window.environment = {\n      baseApiPath: '{{ .BaseAPIPath }}'\n    }\n  </script>\n</head>\n<body>\n<t-root></t-root>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/src/main.ts",
    "content": "import { enableProdMode } from '@angular/core';\nimport { platformBrowserDynamic } from '@angular/platform-browser-dynamic';\n\nimport { AppModule } from './app/app.module';\nimport { environment } from './environments/environment';\n\nif (environment.production) {\n  enableProdMode();\n}\n\nplatformBrowserDynamic().bootstrapModule(AppModule)\n  .catch(err => console.error(err));\n"
  },
  {
    "path": "frontend/src/polyfills.ts",
    "content": "/**\n * This file includes polyfills needed by Angular and is loaded before the app.\n * You can add your own extra polyfills to this file.\n *\n * This file is divided into 2 sections:\n *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.\n *   2. Application imports. Files imported after ZoneJS that should be loaded before your main\n *      file.\n *\n * The current setup is for so-called \"evergreen\" browsers; the last versions of browsers that\n * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),\n * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.\n *\n * Learn more in https://angular.io/guide/browser-support\n */\n\n/***************************************************************************************************\n * BROWSER POLYFILLS\n */\n\n/** IE10 and IE11 requires the following for NgClass support on SVG elements */\n// import 'classlist.js';  // Run `npm install --save classlist.js`.\n\n/**\n * Web Animations `@angular/platform-browser/animations`\n * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.\n * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).\n */\n// import 'web-animations-js';  // Run `npm install --save web-animations-js`.\n\n/**\n * By default, zone.js will patch all possible macroTask and DomEvents\n * user can disable parts of macroTask/DomEvents patch by setting following flags\n * because those flags need to be set before `zone.js` being loaded, and webpack\n * will put import in the top of bundle, so user need to create a separate file\n * in this directory (for example: zone-flags.ts), and put the following flags\n * into that file, and then add the following code before importing zone.js.\n * import './zone-flags';\n *\n * The flags allowed in zone-flags.ts are listed here.\n *\n * The following flags will work for all browsers.\n *\n * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame\n * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick\n * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames\n *\n *  in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js\n *  with the following flag, it will bypass `zone.js` patch for IE/Edge\n *\n *  (window as any).__Zone_enable_cross_context_check = true;\n *\n */\n\n/***************************************************************************************************\n * Zone JS is required by default for Angular itself.\n */\nimport 'zone.js/dist/zone';  // Included with Angular CLI.\n\n\n/***************************************************************************************************\n * APPLICATION IMPORTS\n */\n"
  },
  {
    "path": "frontend/src/styles.scss",
    "content": "body {\n  background-color: var(--surface-b);\n  padding: 0;\n  margin: 0;\n  min-height: 100%;\n  font-family: var(--font-family);\n  color: var(--text-color);\n}\n\n.p-component, .p-inputtext {\n  font-size: .9rem;\n}\n\n.p-progressbar .p-progressbar-value {\n  background: #5f6e82;\n  font-size: .8rem;\n}\n\n\nbutton.p-button {\n  &.t-button-alt {\n    background: #445063;\n    color: #a9b5c3;\n\n    &:enabled:hover {\n      color: #3f4b5b;\n      background: #a6b3c3;\n    }\n  }\n\n  &.p-button-text {\n    color: #8495ad;\n\n    &:enabled:hover {\n      background: rgba(132, 149, 173, 0.04);\n      color: #a6b3c3;\n    }\n  }\n\n  &.p-button-danger {\n    background: #d45e6a;\n    border: none;\n  }\n\n  color: #8495ad;\n  background: #3f4b5b;\n  border: none;\n\n  &:enabled:hover {\n    background: rgba(132, 149, 173, 0.04);\n    color: #a6b3c3;\n  }\n\n  &:focus {\n    box-shadow: none;\n  }\n}\n\n.p-overlaypanel .p-overlaypanel-content {\n  padding: 1rem;\n}\n\n\n.t-dialog-responsive {\n  width: 75%;\n}\n\n@media only screen and (max-width: 750px) {\n  .t-dialog-responsive {\n    width: 100%\n  }\n}\n\n.fas, .far {\n  writing-mode: horizontal-tb;\n}\n"
  },
  {
    "path": "frontend/src/test.ts",
    "content": "// This file is required by karma.conf.js and loads recursively all the .spec and framework files\n\nimport 'zone.js/dist/zone-testing';\nimport { getTestBed } from '@angular/core/testing';\nimport {\n  BrowserDynamicTestingModule,\n  platformBrowserDynamicTesting\n} from '@angular/platform-browser-dynamic/testing';\n\ndeclare const require: {\n  context(path: string, deep?: boolean, filter?: RegExp): {\n    keys(): string[];\n    <T>(id: string): T;\n  };\n};\n\n// First, initialize the Angular testing environment.\ngetTestBed().initTestEnvironment(\n  BrowserDynamicTestingModule,\n  platformBrowserDynamicTesting()\n);\n// Then we find all the tests.\nconst context = require.context('./', true, /\\.spec\\.ts$/);\n// And load the modules.\ncontext.keys().map(context);\n"
  },
  {
    "path": "frontend/tsconfig.app.json",
    "content": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"./out-tsc/app\",\n    \"types\": []\n  },\n  \"files\": [\n    \"src/main.ts\",\n    \"src/polyfills.ts\"\n  ],\n  \"include\": [\n    \"src/**/*.d.ts\"\n  ]\n}\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"compileOnSave\": false,\n  \"compilerOptions\": {\n    \"baseUrl\": \"./\",\n    \"outDir\": \"./dist/out-tsc\",\n    \"sourceMap\": true,\n    \"declaration\": false,\n    \"downlevelIteration\": true,\n    \"experimentalDecorators\": true,\n    \"moduleResolution\": \"node\",\n    \"importHelpers\": true,\n    \"target\": \"es2015\",\n    \"module\": \"es2020\",\n    \"lib\": [\n      \"es2018\",\n      \"dom\"\n    ]\n  }\n}\n"
  },
  {
    "path": "frontend/tsconfig.spec.json",
    "content": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"./out-tsc/spec\",\n    \"types\": [\n      \"jasmine\"\n    ]\n  },\n  \"files\": [\n    \"src/test.ts\",\n    \"src/polyfills.ts\"\n  ],\n  \"include\": [\n    \"src/**/*.spec.ts\",\n    \"src/**/*.d.ts\"\n  ]\n}\n"
  },
  {
    "path": "frontend/tslint.json",
    "content": "{\n  \"extends\": \"tslint:recommended\",\n  \"rulesDirectory\": [\n    \"codelyzer\"\n  ],\n  \"rules\": {\n    \"align\": {\n      \"options\": [\n        \"parameters\",\n        \"statements\"\n      ]\n    },\n    \"array-type\": false,\n    \"arrow-return-shorthand\": true,\n    \"curly\": true,\n    \"deprecation\": {\n      \"severity\": \"warning\"\n    },\n    \"eofline\": true,\n    \"import-blacklist\": [\n      true,\n      \"rxjs/Rx\"\n    ],\n    \"import-spacing\": true,\n    \"indent\": {\n      \"options\": [\n        \"spaces\"\n      ]\n    },\n    \"max-classes-per-file\": false,\n    \"max-line-length\": [\n      true,\n      140\n    ],\n    \"member-ordering\": [\n      true,\n      {\n        \"order\": [\n          \"static-field\",\n          \"instance-field\",\n          \"static-method\",\n          \"instance-method\"\n        ]\n      }\n    ],\n    \"no-console\": [\n      true,\n      \"debug\",\n      \"info\",\n      \"time\",\n      \"timeEnd\",\n      \"trace\"\n    ],\n    \"no-empty\": false,\n    \"no-inferrable-types\": [\n      true,\n      \"ignore-params\"\n    ],\n    \"no-non-null-assertion\": true,\n    \"no-redundant-jsdoc\": true,\n    \"no-switch-case-fall-through\": true,\n    \"no-var-requires\": false,\n    \"object-literal-key-quotes\": [\n      true,\n      \"as-needed\"\n    ],\n    \"quotemark\": [\n      true,\n      \"single\"\n    ],\n    \"semicolon\": {\n      \"options\": [\n        \"always\"\n      ]\n    },\n    \"space-before-function-paren\": {\n      \"options\": {\n        \"anonymous\": \"never\",\n        \"asyncArrow\": \"always\",\n        \"constructor\": \"never\",\n        \"method\": \"never\",\n        \"named\": \"never\"\n      }\n    },\n    \"typedef\": [\n      true,\n      \"call-signature\"\n    ],\n    \"typedef-whitespace\": {\n      \"options\": [\n        {\n          \"call-signature\": \"nospace\",\n          \"index-signature\": \"nospace\",\n          \"parameter\": \"nospace\",\n          \"property-declaration\": \"nospace\",\n          \"variable-declaration\": \"nospace\"\n        },\n        {\n          \"call-signature\": \"onespace\",\n          \"index-signature\": \"onespace\",\n          \"parameter\": \"onespace\",\n          \"property-declaration\": \"onespace\",\n          \"variable-declaration\": \"onespace\"\n        }\n      ]\n    },\n    \"variable-name\": {\n      \"options\": [\n        \"ban-keywords\",\n        \"check-format\",\n        \"allow-pascal-case\"\n      ]\n    },\n    \"whitespace\": {\n      \"options\": [\n        \"check-branch\",\n        \"check-decl\",\n        \"check-operator\",\n        \"check-separator\",\n        \"check-type\",\n        \"check-typecast\"\n      ]\n    },\n    \"component-class-suffix\": true,\n    \"contextual-lifecycle\": true,\n    \"directive-class-suffix\": true,\n    \"no-conflicting-lifecycle\": true,\n    \"no-host-metadata-property\": true,\n    \"no-input-rename\": true,\n    \"no-inputs-metadata-property\": true,\n    \"no-output-native\": true,\n    \"no-output-on-prefix\": true,\n    \"no-output-rename\": true,\n    \"no-outputs-metadata-property\": true,\n    \"template-banana-in-box\": true,\n    \"template-no-negated-async\": true,\n    \"use-lifecycle-interface\": true,\n    \"use-pipe-transform-interface\": true,\n    \"directive-selector\": [\n      true,\n      \"attribute\",\n      \"app\",\n      \"camelCase\"\n    ],\n    \"component-selector\": [\n      true,\n      \"element\",\n      \"t\",\n      \"kebab-case\"\n    ]\n  }\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/relvacode/storm\n\ngo 1.17\n\nrequire (\n\tgithub.com/gdm85/go-libdeluge v0.6.0\n\tgithub.com/gorilla/mux v1.8.0\n\tgithub.com/jessevdk/go-flags v1.4.0\n\tgithub.com/spf13/afero v1.6.0\n\tgo.uber.org/zap v1.16.0\n)\n\nrequire (\n\tgithub.com/gdm85/go-rencode v0.1.8 // indirect\n\tgithub.com/stretchr/testify v1.5.1 // indirect\n\tgo.uber.org/atomic v1.6.0 // indirect\n\tgo.uber.org/multierr v1.5.0 // indirect\n\tgolang.org/x/text v0.3.3 // indirect\n\tgolang.org/x/tools v0.0.0-20200308013534-11ec41452d41 // indirect\n\tgopkg.in/yaml.v2 v2.2.8 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/gdm85/go-libdeluge v0.5.6 h1:tSAwrlOAhu9VAMuxGacK/DMSmLN6SjHHhcVtg76fFnY=\ngithub.com/gdm85/go-libdeluge v0.5.6/go.mod h1:PATKp4wpfcubDL/uIWPSLcIFC0ear942OKvD3ZB4Vsk=\ngithub.com/gdm85/go-libdeluge v0.6.0 h1:TCqzmABxup3l1yeWWW6ZIGoxgJZrrKB5aBF6z6BbVPg=\ngithub.com/gdm85/go-libdeluge v0.6.0/go.mod h1:y3CUYGywCSDOB32/IBLomtK+fU4+lfqIFWsnA/jBoeY=\ngithub.com/gdm85/go-rencode v0.1.8 h1:7+qxwoQWU1b1nMGcESOyoUR5dzPtRA6yLQpKn7uXmnI=\ngithub.com/gdm85/go-rencode v0.1.8/go.mod h1:0dr3BuaKzeseY1of6o1KRTGB/Oo7eio+YEyz8KDp5+s=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=\ngithub.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=\ngithub.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=\ngithub.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=\ngithub.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngo.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=\ngo.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=\ngo.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=\ngo.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=\ngo.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE=\ngolang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\nhonnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\n"
  },
  {
    "path": "http.go",
    "content": "package storm\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n)\n\n// HandlerFunc is an adaptor for the http.HandlerFunc that returns JSON data.\ntype HandlerFunc func(r *http.Request) (interface{}, error)\n\n// Send sends JSON data to the client using the supplied HTTP status code.\nfunc Send(rw http.ResponseWriter, code int, data interface{}) {\n\tenc := json.NewEncoder(rw)\n\tenc.SetIndent(\"\", \"  \")\n\n\trw.Header().Set(\"Content-Type\", \"application/json\")\n\trw.WriteHeader(code)\n\n\t_ = enc.Encode(data)\n}\n\n// NoContent sends a 204 No Content response\nfunc NoContent(rw http.ResponseWriter) {\n\trw.WriteHeader(http.StatusNoContent)\n}\n\nfunc Handle(rw http.ResponseWriter, r *http.Request, handler HandlerFunc) error {\n\tresponse, err := handler(r)\n\tif err != nil {\n\t\tSendError(rw, err)\n\t\treturn err\n\t}\n\n\tif response == nil {\n\t\tNoContent(rw)\n\t\treturn nil\n\t}\n\n\tSend(rw, http.StatusOK, response)\n\treturn nil\n}\n"
  },
  {
    "path": "methods.go",
    "content": "package storm\n\nimport (\n\t\"fmt\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/gorilla/mux\"\n\t\"net/http\"\n\t\"net/url\"\n)\n\ntype DelugeMethod func(conn deluge.DelugeClient, r *http.Request) (interface{}, error)\n\nfunc torrentIDs(q url.Values, min int) ([]string, error) {\n\tvar ids = q[\"id\"]\n\tif len(ids) < min {\n\t\treturn nil, &Error{Code: http.StatusBadRequest, Message: fmt.Sprintf(\"At least %d torrent ID(s) are required\", min)}\n\t}\n\n\treturn ids, nil\n}\n\nfunc httpTorrentsStatus(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar (\n\t\tq     = r.URL.Query()\n\t\tids   = q[\"id\"]\n\t\tstate = deluge.TorrentState(q.Get(\"state\"))\n\t)\n\n\treturn conn.TorrentsStatus(state, ids)\n}\n\nfunc httpDeleteTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar (\n\t\tq       = r.URL.Query()\n\t\trmFiles = q.Get(\"files\") == \"true\"\n\t)\n\n\tids, err := torrentIDs(q, 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terrors, err := conn.RemoveTorrents(ids, rmFiles)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(errors) > 0 {\n\t\treturn &errors, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc httpPauseTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tids, err := torrentIDs(r.URL.Query(), 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, conn.PauseTorrents(ids...)\n}\n\nfunc httpResumeTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tids, err := torrentIDs(r.URL.Query(), 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, conn.ResumeTorrents(ids...)\n}\n\ntype AddTorrentRequest struct {\n\tType string\n\tURI  string\n\tData string\n\n\tOptions deluge.Options\n}\n\ntype AddTorrentResponse struct {\n\tID string\n}\n\nfunc httpAddTorrent(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar req AddTorrentRequest\n\n\terr := Read(r, &req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar id string\n\tswitch req.Type {\n\tcase \"url\":\n\t\tid, err = conn.AddTorrentURL(req.URI, &req.Options)\n\tcase \"magnet\":\n\t\tid, err = conn.AddTorrentMagnet(req.URI, &req.Options)\n\tcase \"file\":\n\t\tid, err = conn.AddTorrentFile(req.URI, req.Data, &req.Options)\n\tdefault:\n\t\treturn nil, &Error{Code: http.StatusBadRequest, Message: \"Torrent Type must be one of url, magnet or file\"}\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// The RPC returns an empty ID if the torrent could not be parsed or processed.\n\tif id == \"\" {\n\t\treturn nil, &Error{Code: http.StatusUnprocessableEntity, Message: \"Torrent file could not be read\"}\n\t}\n\n\treturn AddTorrentResponse{ID: id}, nil\n}\n\ntype TorrentMethod func(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error)\n\nfunc TorrentHandler(f TorrentMethod) DelugeMethod {\n\treturn func(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\t\tvars := mux.Vars(r)\n\t\treturn f(vars[\"id\"], conn, r)\n\t}\n}\n\nfunc httpTorrentStatus(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {\n\treturn conn.TorrentStatus(id)\n}\n\nfunc httpDeleteTorrent(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tok, err := conn.RemoveTorrent(id, r.URL.Query().Get(\"files\") == \"true\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !ok {\n\t\treturn nil, &Error{Code: http.StatusNotFound, Message: \"Requested torrent could not be deleted\"}\n\t}\n\n\treturn nil, nil\n}\n\nfunc httpPauseTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {\n\treturn nil, conn.PauseTorrents(id)\n}\n\nfunc httpResumeTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {\n\treturn nil, conn.ResumeTorrents(id)\n}\n\nfunc httpSetTorrentOptions(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar req deluge.Options\n\n\terr := Read(r, &req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, conn.SetTorrentOptions(id, &req)\n}\n\nfunc httpGetSessionStatus(conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {\n\treturn conn.GetSessionStatus()\n}\n\ntype GetFreeSpaceResponse struct {\n\tFreeBytes int64\n}\n\nfunc httpGetFreeSpace(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tpath := r.URL.Query().Get(\"path\")\n\n\tfreeBytes, err := conn.GetFreeSpace(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn GetFreeSpaceResponse{\n\t\tFreeBytes: freeBytes,\n\t}, nil\n}\n"
  },
  {
    "path": "methods_labels.go",
    "content": "package storm\n\nimport (\n\t\"fmt\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/gorilla/mux\"\n\t\"net/http\"\n)\n\n// getClientV1 gets the underlying version 1 client from a DelugeClient interface.\nfunc getClientV1(conn deluge.DelugeClient) (*deluge.Client, error) {\n\tswitch client := conn.(type) {\n\tcase *deluge.Client:\n\t\treturn client, nil\n\tcase *deluge.ClientV2:\n\t\treturn &client.Client, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"failed to obtain version 1 Deluge client\")\n\t}\n}\n\n// labelPluginClient constructs an instance of a deluge.LabelPlugin client from the input DelugeClient connection.\nfunc labelPluginClient(conn deluge.DelugeClient) (*deluge.LabelPlugin, error) {\n\tclient, err := getClientV1(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &deluge.LabelPlugin{\n\t\tClient: client,\n\t}, nil\n}\n\n// httpLabels gets the current labels\nfunc httpLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tplugin, err := labelPluginClient(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn plugin.GetLabels()\n}\n\n// httpCreateLabel creates a new label\nfunc httpCreateLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvars := mux.Vars(r)\n\n\tplugin, err := labelPluginClient(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = plugin.AddLabel(vars[\"id\"])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, nil\n}\n\n// httpCreateLabel deletes an existing label\nfunc httpDeleteLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvars := mux.Vars(r)\n\n\tplugin, err := labelPluginClient(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = plugin.RemoveLabel(vars[\"id\"])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, nil\n}\n\n// httpTorrentsLabels gets labels associated with all torrents matching the filter.\n// \t\t?id[]\tOne or more torrent IDs\n//\t\t?state\tTorrents of this state\n//\n//\t\tReturns a mapping of torrent hash to torrent labels\nfunc httpTorrentsLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tids, err := torrentIDs(r.URL.Query(), 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstate := (deluge.TorrentState)(r.URL.Query().Get(\"state\"))\n\n\tplugin, err := labelPluginClient(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlabels, err := plugin.GetTorrentsLabels(state, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn labels, nil\n}\n\ntype SetTorrentLabelRequest struct {\n\tLabel string\n}\n\n// httpSetTorrentLabel sets the label for a given torrent hash\nfunc httpSetTorrentLabel(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar req SetTorrentLabelRequest\n\n\terr := Read(r, &req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tplugin, err := labelPluginClient(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = plugin.SetTorrentLabel(id, req.Label)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "methods_plugins.go",
    "content": "package storm\n\nimport (\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/gorilla/mux\"\n\t\"net/http\"\n)\n\n// httpGetPlugins gets all the currently enabled plugins\nfunc httpGetPlugins(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\treturn conn.GetEnabledPlugins()\n}\n\nfunc httpEnablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tid := mux.Vars(r)[\"id\"]\n\n\terr := conn.EnablePlugin(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, nil\n}\n\nfunc httpDisablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tid := mux.Vars(r)[\"id\"]\n\n\terr := conn.DisablePlugin(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "methods_view.go",
    "content": "package storm\n\nimport (\n\t\"crypto/sha1\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"net/http\"\n\t\"sort\"\n)\n\ntype ViewTorrent struct {\n\tHash  string\n\tLabel string\n\t*deluge.TorrentStatus\n}\n\ntype ViewUpdate struct {\n\tTorrents []*ViewTorrent\n\tSession  *deluge.SessionStatus\n\tDiskFree int64\n}\n\ntype ViewUpdateResponse struct {\n\tViewUpdate\n\tETag string\n}\n\nfunc httpViewUpdate(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {\n\tvar (\n\t\tq     = r.URL.Query()\n\t\tids   = q[\"id\"]\n\t\tstate = deluge.TorrentState(q.Get(\"state\"))\n\t)\n\n\ttorrents, err := conn.TorrentsStatus(state, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar torrentHashes = make([]string, 0, len(torrents))\n\tfor k := range torrents {\n\t\ttorrentHashes = append(torrentHashes, k)\n\t}\n\tsort.Strings(torrentHashes)\n\n\tvar torrentLabels = make(map[string]string)\n\n\tplugin, err := labelPluginClient(conn)\n\tif err == nil {\n\t\tlabels, err := plugin.GetTorrentsLabels(state, ids)\n\t\tif err == nil {\n\t\t\ttorrentLabels = labels\n\t\t}\n\t}\n\n\tvar responseTorrents = make([]*ViewTorrent, 0, len(torrents))\n\tfor _, k := range torrentHashes {\n\t\tresponseTorrents = append(responseTorrents, &ViewTorrent{\n\t\t\tHash:          k,\n\t\t\tLabel:         torrentLabels[k],\n\t\t\tTorrentStatus: torrents[k],\n\t\t})\n\t}\n\n\tsession, err := conn.GetSessionStatus()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdiskFree, err := conn.GetFreeSpace(q.Get(\"path\"))\n\n\tupdate := ViewUpdate{\n\t\tTorrents: responseTorrents,\n\t\tSession:  session,\n\t\tDiskFree: diskFree,\n\t}\n\n\t// ETag calculation\n\tvar h = sha1.New()\n\t_ = json.NewEncoder(h).Encode(&update)\n\n\tresponseETag := hex.EncodeToString(h.Sum(nil))\n\n\tif requestETag := r.Header.Get(\"ETag\"); requestETag != \"\" && requestETag == responseETag {\n\t\treturn nil, &Error{\n\t\t\tCode:    http.StatusNotModified,\n\t\t\tMessage: \"View not modified since last request\",\n\t\t}\n\t}\n\n\treturn &ViewUpdateResponse{\n\t\tViewUpdate: update,\n\t\tETag:       responseETag,\n\t}, nil\n}\n"
  },
  {
    "path": "pool.go",
    "content": "package storm\n\nimport (\n\t\"context\"\n\t\"errors\"\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"go.uber.org/zap\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype DelugeProvider func() deluge.DelugeClient\n\ntype poolReq struct {\n\t// If the context has been cancelled then no connection is returned\n\tctx context.Context\n\t// The replied client may be nil if the connection could not be established\n\treply chan<- deluge.DelugeClient\n}\n\nfunc (req *poolReq) Send(conn deluge.DelugeClient) bool {\n\tselect {\n\tcase <-req.ctx.Done():\n\t\treturn false\n\tcase req.reply <- conn:\n\t\treturn true\n\t}\n}\n\ntype idleConnection struct {\n\tidle time.Time\n\tconn deluge.DelugeClient\n}\n\ntype Timer interface {\n\tCh() <-chan time.Time\n\tStop() bool\n}\n\ntype timeTimer time.Timer\n\nfunc (t *timeTimer) Ch() <-chan time.Time {\n\treturn (*time.Timer)(t).C\n}\n\nfunc (t *timeTimer) Stop() bool {\n\treturn (*time.Timer)(t).Stop()\n}\n\ntype nullTimer struct{}\n\nfunc (nullTimer) Ch() <-chan time.Time {\n\treturn nil\n}\n\nfunc (nullTimer) Stop() bool {\n\treturn true\n}\n\nfunc NewConnectionPool(log *zap.Logger, maxConnections int, idleConnectionTime time.Duration, provider DelugeProvider) *ConnectionPool {\n\tpool := &ConnectionPool{\n\t\tLog:                log,\n\t\tMaxConnections:     maxConnections,\n\t\tIdleConnectionTime: idleConnectionTime,\n\t\tProvider:           provider,\n\n\t\tget:   make(chan *poolReq),\n\t\tput:   make(chan deluge.DelugeClient),\n\t\tclose: make(chan struct{}),\n\t\talive: new(sync.Mutex),\n\t\tidle:  nullTimer{},\n\t}\n\n\tgo pool.worker()\n\treturn pool\n}\n\ntype ConnectionPool struct {\n\tLog                *zap.Logger\n\tMaxConnections     int\n\tIdleConnectionTime time.Duration\n\tProvider           DelugeProvider\n\n\tget   chan *poolReq\n\tput   chan deluge.DelugeClient\n\tclose chan struct{}\n\talive *sync.Mutex\n\n\twaitConn []*poolReq\n\tinFlight int\n\tpool     []*idleConnection\n\tidle     Timer\n}\n\nfunc (pool *ConnectionPool) nextIdle() {\n\t// Go through the other connections.\n\t// Check that they are not expired, if not set the new idle to the first valid connection\n\tfor {\n\t\tif len(pool.pool) == 0 {\n\t\t\t// Otherwise, there are no more connections to make idle\n\t\t\tpool.idle = nullTimer{}\n\t\t\treturn\n\t\t}\n\n\t\tconn := pool.pool[0]\n\t\texpires := conn.idle.Sub(time.Now())\n\n\t\t// Next connection is now idle. Delete it.\n\t\tif expires < 0 {\n\t\t\terr := conn.conn.Close()\n\t\t\tif err != nil {\n\t\t\t\tpool.Log.Error(\"Failed to closed idle connection\", zap.Error(err))\n\t\t\t}\n\n\t\t\tpool.pool = pool.pool[1:]\n\t\t\tcontinue\n\t\t}\n\n\t\t// Valid connection that is not idle\n\t\tt := time.NewTimer(expires)\n\t\tpool.idle = (*timeTimer)(t)\n\t\treturn\n\t}\n}\n\nfunc (pool *ConnectionPool) idleExpired() {\n\tvar conn *idleConnection\n\tconn, pool.pool = pool.pool[0], pool.pool[1:]\n\n\terr := conn.conn.Close()\n\tif err != nil {\n\t\tpool.Log.Error(\"Failed to closed idle connection\", zap.Error(err))\n\t}\n\n\tpool.idle.Stop()\n\tpool.nextIdle()\n}\n\nfunc (pool *ConnectionPool) putConn(conn deluge.DelugeClient) {\n\t// Check if anyone is waiting for a connection\n\tfor len(pool.waitConn) > 0 {\n\n\t\tw := pool.waitConn[0]\n\t\tpool.waitConn[0] = nil\n\t\tpool.waitConn = pool.waitConn[1:]\n\n\t\tselect {\n\t\tcase <-w.ctx.Done(): // waiter's connection has been cancelled\n\t\tcase w.reply <- conn: // waiter has received connection\n\t\t\treturn\n\t\t}\n\t}\n\n\tidle := &idleConnection{idle: time.Now().Add(pool.IdleConnectionTime), conn: conn}\n\n\t// There are no existing connections in the pool.\n\t// Set the new pool timer\n\tif len(pool.pool) == 0 {\n\t\tpool.idle.Stop()\n\n\t\tt := time.NewTimer(pool.IdleConnectionTime)\n\t\tpool.idle = (*timeTimer)(t)\n\t}\n\n\tpool.pool = append(pool.pool, idle)\n\tpool.inFlight--\n}\n\nfunc (pool *ConnectionPool) closeConns() {\n\tpool.idle.Stop()\n\tpool.idle = nullTimer{}\n\n\t// Close any waiters\n\tfor len(pool.waitConn) > 0 {\n\t\tvar w *poolReq\n\t\tw, pool.waitConn = pool.waitConn[0], pool.waitConn[1:]\n\t\tclose(w.reply)\n\t}\n\n\t// Close all connections within the pool\n\tfor len(pool.pool) > 0 {\n\t\tvar c *idleConnection\n\t\tc, pool.pool = pool.pool[0], pool.pool[1:]\n\t\t_ = c.conn.Close()\n\t}\n}\n\nfunc (pool *ConnectionPool) getConn(req *poolReq) {\n\t// There are connections that can be sent straight away\n\tif len(pool.pool) > 0 {\n\t\tc := pool.pool[0]\n\n\t\tif req.Send(c.conn) {\n\t\t\t// Connection successfully sent\n\t\t\tpool.pool = pool.pool[1:]\n\t\t\tpool.inFlight++\n\n\t\t\tpool.idle.Stop()\n\t\t\tpool.nextIdle()\n\t\t}\n\n\t\treturn\n\t}\n\n\t// There are more connections in-flight.\n\t// Add the request to the list of waiting requests.\n\tif pool.inFlight >= pool.MaxConnections {\n\t\tpool.waitConn = append(pool.waitConn, req)\n\t\treturn\n\t}\n\n\t// A new connection can be established\n\tconn := pool.Provider()\n\n\terr := conn.Connect()\n\tif err != nil {\n\t\tpool.Log.Error(\"Failed to establish Deluge RPC connection\", zap.Error(err))\n\t\tconn = nil\n\t}\n\n\tok := req.Send(conn)\n\n\t// The connection was nil so we don't care what the send response was\n\tif conn == nil {\n\t\treturn\n\t}\n\n\tpool.inFlight++\n\n\t// Connection successfully sent\n\tif ok {\n\t\treturn\n\t}\n\n\t// Connection was established but could not be sent to the caller\n\t// Put the established connection into the pool\n\tpool.putConn(conn)\n}\n\nfunc (pool *ConnectionPool) worker() {\n\tpool.alive.Lock()\n\tdefer pool.alive.Unlock()\n\n\tfor {\n\t\tselect {\n\t\tcase <-pool.idle.Ch(): // The first connection is now idle\n\t\t\tpool.idleExpired()\n\t\tcase req := <-pool.get:\n\t\t\tpool.getConn(req)\n\t\tcase conn := <-pool.put: // A connection has been put back\n\t\t\tpool.putConn(conn)\n\t\tcase <-pool.close:\n\t\t\tpool.closeConns()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// Get gets a connected connection from the pool.\n// If there are no available connections in the pool then one is created and connected to.\n// If there already too many active connections, Get will block until a connection is available\n// or the given context is cancelled.\nfunc (pool *ConnectionPool) Get(ctx context.Context) (deluge.DelugeClient, error) {\n\t// TODO someone needs to close this\n\treplyCh := make(chan deluge.DelugeClient)\n\tpool.get <- &poolReq{ctx: ctx, reply: replyCh}\n\n\tselect {\n\tcase <-pool.close:\n\t\treturn nil, errors.New(\"The Deluge RPC connection pool has been closed\")\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\tcase conn := <-replyCh:\n\t\tif conn == nil {\n\t\t\treturn nil, errors.New(\"A connection to the Deluge RPC daemon could not be established by the connection pool\")\n\t\t}\n\n\t\treturn conn, nil\n\t}\n}\n\n// Put puts a connection back to the pool.\nfunc (pool *ConnectionPool) Put(conn deluge.DelugeClient) {\n\tselect {\n\tcase <-pool.close:\n\t\t// Pool has been closed before the connection can be put back\n\t\t_ = conn.Close()\n\tcase pool.put <- conn:\n\t}\n}\n\nfunc (pool *ConnectionPool) Close() {\n\tclose(pool.close)\n\n\t// Achieve a lock on the pool alive mutex.\n\t// When a lock is achieved the pool worker daemon has been closed.\n\t// Keep the alive mutex locked.\n\tpool.alive.Lock()\n}\n"
  },
  {
    "path": "request.go",
    "content": "package storm\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n)\n\nconst (\n\t// MaxRequestSize is the maximum allowed request size in bytes\n\tMaxRequestSize = 5 << 20\n)\n\n// Read reads JSON data from the request.\nfunc Read(r *http.Request, into interface{}) error {\n\tvar lr = io.LimitReader(r.Body, MaxRequestSize).(*io.LimitedReader)\n\tvar dec = json.NewDecoder(lr)\n\tvar err = dec.Decode(into)\n\n\t_ = r.Body.Close()\n\n\t// Request too large (limited reader fully consumed)\n\tif lr.N < 1 {\n\t\treturn &Error{Code: http.StatusRequestEntityTooLarge, Message: \"Request payload exceeds maximum limit\"}\n\t}\n\n\tif err != nil {\n\t\treturn Hint(http.StatusBadRequest, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "response.go",
    "content": "package storm\n\nimport (\n\t\"net/http\"\n\t\"time\"\n)\n\nvar _ http.ResponseWriter = (*WrappedResponse)(nil)\n\n// WrapResponse wraps a response\nfunc WrapResponse(rw http.ResponseWriter) *WrappedResponse {\n\treturn &WrappedResponse{\n\t\tResponseWriter: rw,\n\t\tstarted:        time.Now().UTC(),\n\t}\n}\n\n// WrappedResponse wraps a http.ResponseWriter to capture information about the response\ntype WrappedResponse struct {\n\thttp.ResponseWriter\n\n\tstarted time.Time\n\tsent    time.Time\n\n\tcode        int\n\terror       error\n\tpayloadSize int\n}\n\nfunc (rw *WrappedResponse) WriteHeader(code int) {\n\trw.code = code\n\trw.sent = time.Now().UTC()\n\trw.ResponseWriter.WriteHeader(code)\n\n\t// Set error based off the status text if an explicit error is not otherwise provided\n\tif code > 399 && rw.error == nil {\n\t\trw.error = &Error{\n\t\t\tCode:    code,\n\t\t\tMessage: http.StatusText(code),\n\t\t}\n\t}\n}\n\nfunc (rw *WrappedResponse) Write(b []byte) (int, error) {\n\twr, err := rw.ResponseWriter.Write(b)\n\trw.payloadSize += wr\n\treturn wr, err\n}\n\nfunc (rw *WrappedResponse) Code() int {\n\treturn rw.code\n}\n\nfunc (rw *WrappedResponse) Started() time.Time {\n\treturn rw.started\n}\n\n// Duration returns the total duration of the request to response.\nfunc (rw *WrappedResponse) Duration() time.Duration {\n\treturn rw.sent.Sub(rw.started)\n}\n\n// Len returns the total payload size in bytes.\nfunc (rw *WrappedResponse) Len() int {\n\treturn rw.payloadSize\n}\n\nfunc (rw *WrappedResponse) Error() error {\n\treturn rw.error\n}\n"
  },
  {
    "path": "static.go",
    "content": "package storm\n\nimport (\n\t\"embed\"\n)\n\n//go:embed frontend/dist/*\nvar Static embed.FS\n"
  }
]