Full Code of relvacode/storm for AI

master 7bfaa6172c02 cached
133 files
154.7 KB
46.6k tokens
258 symbols
1 requests
Download .txt
Repository: relvacode/storm
Branch: master
Commit: 7bfaa6172c02
Files: 133
Total size: 154.7 KB

Directory structure:
gitextract_e9godol_/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── release.yaml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── api.go
├── cmd/
│   └── storm/
│       └── server.go
├── docker-compose/
│   ├── auth
│   ├── core.conf
│   └── docker-compose.yml
├── error.go
├── frontend/
│   ├── .browserslistrc
│   ├── .editorconfig
│   ├── angular.json
│   ├── e2e/
│   │   ├── protractor.conf.js
│   │   ├── src/
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── app.po.ts
│   │   └── tsconfig.json
│   ├── karma.conf.js
│   ├── package.json
│   ├── src/
│   │   ├── app/
│   │   │   ├── api.service.spec.ts
│   │   │   ├── api.service.ts
│   │   │   ├── app.component.html
│   │   │   ├── app.component.scss
│   │   │   ├── app.component.spec.ts
│   │   │   ├── app.component.ts
│   │   │   ├── app.module.ts
│   │   │   ├── components/
│   │   │   │   ├── activity-marker/
│   │   │   │   │   ├── activity-marker.component.html
│   │   │   │   │   ├── activity-marker.component.scss
│   │   │   │   │   ├── activity-marker.component.spec.ts
│   │   │   │   │   └── activity-marker.component.ts
│   │   │   │   ├── add-torrent-menu/
│   │   │   │   │   ├── add-torrent-config/
│   │   │   │   │   │   ├── add-torrent-config.component.html
│   │   │   │   │   │   ├── add-torrent-config.component.scss
│   │   │   │   │   │   ├── add-torrent-config.component.spec.ts
│   │   │   │   │   │   └── add-torrent-config.component.ts
│   │   │   │   │   ├── add-torrent-dialog-component.ts
│   │   │   │   │   ├── add-torrent-file-input/
│   │   │   │   │   │   ├── add-torrent-file-input.component.html
│   │   │   │   │   │   ├── add-torrent-file-input.component.scss
│   │   │   │   │   │   ├── add-torrent-file-input.component.spec.ts
│   │   │   │   │   │   └── add-torrent-file-input.component.ts
│   │   │   │   │   ├── add-torrent-magnet-input/
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.html
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.scss
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.spec.ts
│   │   │   │   │   │   └── add-torrent-magnet-input.component.ts
│   │   │   │   │   ├── add-torrent-menu.component.html
│   │   │   │   │   ├── add-torrent-menu.component.scss
│   │   │   │   │   ├── add-torrent-menu.component.spec.ts
│   │   │   │   │   ├── add-torrent-menu.component.ts
│   │   │   │   │   └── add-torrent-url-input/
│   │   │   │   │       ├── add-torrent-url-input.component.html
│   │   │   │   │       ├── add-torrent-url-input.component.scss
│   │   │   │   │       ├── add-torrent-url-input.component.spec.ts
│   │   │   │   │       └── add-torrent-url-input.component.ts
│   │   │   │   ├── api-key-dialog/
│   │   │   │   │   ├── api-key-dialog.component.html
│   │   │   │   │   ├── api-key-dialog.component.scss
│   │   │   │   │   ├── api-key-dialog.component.spec.ts
│   │   │   │   │   └── api-key-dialog.component.ts
│   │   │   │   ├── breakpoint-overlay/
│   │   │   │   │   ├── breakpoint-overlay.component.html
│   │   │   │   │   ├── breakpoint-overlay.component.scss
│   │   │   │   │   ├── breakpoint-overlay.component.spec.ts
│   │   │   │   │   └── breakpoint-overlay.component.ts
│   │   │   │   ├── connectivity-status/
│   │   │   │   │   ├── connectivity-status.component.html
│   │   │   │   │   ├── connectivity-status.component.scss
│   │   │   │   │   ├── connectivity-status.component.spec.ts
│   │   │   │   │   └── connectivity-status.component.ts
│   │   │   │   ├── delete-torrent-overlay/
│   │   │   │   │   ├── delete-torrent-overlay.component.html
│   │   │   │   │   ├── delete-torrent-overlay.component.scss
│   │   │   │   │   ├── delete-torrent-overlay.component.spec.ts
│   │   │   │   │   └── delete-torrent-overlay.component.ts
│   │   │   │   ├── plugin-enable/
│   │   │   │   │   ├── plugin-enable.component.html
│   │   │   │   │   ├── plugin-enable.component.scss
│   │   │   │   │   ├── plugin-enable.component.spec.ts
│   │   │   │   │   └── plugin-enable.component.ts
│   │   │   │   ├── session-status/
│   │   │   │   │   ├── session-status.component.html
│   │   │   │   │   ├── session-status.component.scss
│   │   │   │   │   ├── session-status.component.spec.ts
│   │   │   │   │   └── session-status.component.ts
│   │   │   │   ├── torrent/
│   │   │   │   │   ├── torrent.component.html
│   │   │   │   │   ├── torrent.component.scss
│   │   │   │   │   ├── torrent.component.spec.ts
│   │   │   │   │   └── torrent.component.ts
│   │   │   │   ├── torrent-details-dialog/
│   │   │   │   │   ├── torrent-details-dialog.component.html
│   │   │   │   │   ├── torrent-details-dialog.component.scss
│   │   │   │   │   ├── torrent-details-dialog.component.spec.ts
│   │   │   │   │   └── torrent-details-dialog.component.ts
│   │   │   │   ├── torrent-edit-label-dialog/
│   │   │   │   │   ├── torrent-edit-label-dialog.component.html
│   │   │   │   │   ├── torrent-edit-label-dialog.component.scss
│   │   │   │   │   ├── torrent-edit-label-dialog.component.spec.ts
│   │   │   │   │   └── torrent-edit-label-dialog.component.ts
│   │   │   │   ├── torrent-label/
│   │   │   │   │   ├── torrent-label.component.html
│   │   │   │   │   ├── torrent-label.component.scss
│   │   │   │   │   ├── torrent-label.component.spec.ts
│   │   │   │   │   └── torrent-label.component.ts
│   │   │   │   ├── torrent-search/
│   │   │   │   │   ├── torrent-search.component.html
│   │   │   │   │   ├── torrent-search.component.scss
│   │   │   │   │   ├── torrent-search.component.spec.ts
│   │   │   │   │   └── torrent-search.component.ts
│   │   │   │   └── torrent-state/
│   │   │   │       ├── torrent-state.component.html
│   │   │   │       ├── torrent-state.component.scss
│   │   │   │       ├── torrent-state.component.spec.ts
│   │   │   │       └── torrent-state.component.ts
│   │   │   ├── environment.ts
│   │   │   ├── focus.service.spec.ts
│   │   │   ├── focus.service.ts
│   │   │   ├── order-by.pipe.spec.ts
│   │   │   ├── order-by.pipe.ts
│   │   │   ├── torrent-search.pipe.spec.ts
│   │   │   └── torrent-search.pipe.ts
│   │   ├── assets/
│   │   │   └── .gitkeep
│   │   ├── environments/
│   │   │   ├── environment.prod.ts
│   │   │   └── environment.ts
│   │   ├── icons.scss
│   │   ├── index.html
│   │   ├── main.ts
│   │   ├── polyfills.ts
│   │   ├── styles.scss
│   │   └── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.spec.json
│   └── tslint.json
├── go.mod
├── go.sum
├── http.go
├── logo.svgz
├── methods.go
├── methods_labels.go
├── methods_plugins.go
├── methods_view.go
├── pool.go
├── request.go
├── response.go
└── static.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
frontend/node_modules
frontend/.angular


================================================
FILE: .github/workflows/release.yaml
================================================
on:
  release:
    types: [ created ]

jobs:
  compile-frontend:
    name: Compile Frontend
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
          cache: npm
          cache-dependency-path: frontend/package-lock.json
      - run: |
          cd frontend
          npm install
          npm run build:prod
      - uses: actions/upload-artifact@v2
        with:
          name: frontend-dist
          path: frontend/dist/
          if-no-files-found: error
  go-binary-release:
    name: Release Go Binary
    runs-on: ubuntu-latest
    needs: compile-frontend
    strategy:
      matrix:
        goos: [ linux, windows, darwin ]
        goarch: [ amd64, arm, arm64 ]
        exclude:
          - goarch: arm
            goos: darwin
          - goarch: arm64
            goos: windows
          - goarch: arm
            goos: windows
    steps:
      - uses: actions/checkout@v2
      - uses: actions/download-artifact@v2
        with:
          name: frontend-dist
          path: frontend/dist
      - uses: wangyoucao577/go-release-action@v1.22
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          goos: ${{ matrix.goos }}
          goarch: ${{ matrix.goarch }}
          asset_name: storm-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
          project_path: "./cmd/storm"
          binary_name: "storm"
          extra_files: LICENSE README.md
          sha256sum: true
          md5sum: false
          overwrite: true
  docker-image:
    name: Build Docker Image
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
    needs: compile-frontend
    steps:
      - uses: actions/checkout@v2
      - uses: actions/download-artifact@v2
        with:
          name: frontend-dist
          path: frontend/dist
      - uses: docker/setup-qemu-action@v1
      - uses: docker/setup-buildx-action@v1
      - uses: docker/login-action@v1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.ref_name }}
          platforms: |
            linux/amd64
            linux/arm64
            linux/arm/v7


================================================
FILE: .gitignore
================================================
.idea
.angular
cmd/storm/storm

frontend/node_modules
frontend/.idea
frontend/dist


================================================
FILE: Dockerfile
================================================
FROM --platform=${BUILDPLATFORM} golang:alpine as compiler
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0

WORKDIR /go/src/storm

COPY . .

RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" github.com/relvacode/storm/cmd/storm


FROM --platform=${TARGETPLATFORM} alpine
COPY --from=compiler /go/src/storm/storm /usr/bin/storm

ENTRYPOINT ["/usr/bin/storm"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2020 Jason Kingsbury

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================

<p align="middle"><img src="frontend/src/assets/logo.svg" height="200"/></p>


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

&nbsp;

<p float="left" align="middle">
<img src="_docs/example-torrent-view.jpg" height="450"/>
<img src="_docs/example-add-torrent-menu.jpg" height="450"/>
<img src="_docs/example-filter-state.jpg" height="450"/>
</p>

#### Usage

```
docker run --name storm \
  --network deluge \
  -p 8221:8221 \
  -e DELUGE_RPC_HOSTNAME=deluge \
  -e DELUGE_RPC_USERNAME=username \
  -e DELUGE_RPC_PASSWORD=password \
  ghcr.io/relvacode/storm
```


The recommended way to run Storm is with a Docker image. 

You'll need a Deluge container running with a valid [auth configuration](https://dev.deluge-torrent.org/wiki/UserGuide/Authentication). 
Storm needs a way to contact the Deluge RPC daemon so it's best that you create a [Docker network](https://docs.docker.com/engine/tutorials/networkingcontainers/) and attach the Storm container to that network.

Once that's setup you'll need to configure Deluge to allow remote RPC connections:

Open up `core.conf` in your Deluge configuration folder and set

```
"allow_remote": true
```

Then you can use the following environment variables to configure Storm

| Environment | Description |
| ----------- | ----------- |
| `DELUGE_RPC_HOSTNAME` | The Deluge RPC hostname |
| `DELUGE_RPC_PORT` | The Deluge RPC port |
| `DELUGE_RPC_USERNAME` | The username from Deluge auth |
| `DELUGE_RPC_PASSWORD` | The password from Deluge auth |
| `DELUGE_RPC_VERSION` | `v1` or `v2` depending on your Deluge version |
| `STORM_API_KEY` | Enable authentication for the Storm API |
| `STORM_BASE_PATH` | Set the base URL path. Defaults to `/` |

##### Security

By default, Storm does not authenticate requests made to the API. When serving Storm over the public internet you should ensure access to your Deluge daemon is properly secured.

Storm comes with a simple built-in authentication mechanism which can be enabled with the environment variable `STORM_API_KEY` or the command-line option `--api-key`.

Set this to a reasonably secure password. Any requests made to Storm must now provide the API key in the request.

You should also seriously consider the use of HTTPS over the internet, with services like LetsEncrypt it's relatively easy to get a valid SSL certificate for free.

##### Deluge Version

Deluge has a different interface between versions 1 and 2. You must set `DELUGE_RPC_VERSION` to either `v1` or `v2` based on the version you have installed. Storm defaults to `v1`.

Note that in version 2, different RPC users are not able to see torrents created by another user [(#38)](https://github.com/relvacode/storm/issues/38). If you're using multiple Deluge clients (such as the vanilla Web UI, or Sonarr, etc) you should make sure they're all using the same Deluge RPC account to connect to Deluge.

#### Development

The application is split into two parts, the frontend Angular code and the backend Go API adapter.

The backend API is presented as an HTTP REST api which talks directly to the Deluge RPC daemon.
It also has the frontend code embedded directly into the binary so the application can be distributed as a single binary.

To start a development environment you must first install the node modules

```
cd frontend
npm install
```

Then start the Angular build server in watch mode. This will output the frontend distributable into `frontend/dist` and watch for changes.

```
cd frontend
npm run build -- --watch --configuration=development
```

In a separate terminal you can now start the main Storm binary in development mode.
In development mode, instead of serving the frontend from the binary embedded source it will instead serve directly from the filesystem.

```
go run github.com/relvacode/storm/cmd/storm --listen=127.0.0.1:8221 --dev-mode [OPTIONS...]
```

================================================
FILE: api.go
================================================
package storm

import (
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	deluge "github.com/gdm85/go-libdeluge"
	"github.com/gorilla/mux"
	"github.com/spf13/afero"
	"go.uber.org/zap"
	"html/template"
	"io"
	"io/fs"
	"net/http"
	"os"
	"strings"
	"time"
)

const (
	ApiAuthCookieName = "storm-api-key"
)

func appendSuffix(s, suffix string) string {
	if strings.HasSuffix(s, suffix) {
		return s
	}

	return fmt.Sprint(s, suffix)
}

func New(log *zap.Logger, pool *ConnectionPool, pathPrefix string, apiKey string, development bool) *Api {
	api := &Api{
		pool:       pool,
		pathPrefix: strings.TrimSuffix(pathPrefix, "/"),
		apiKey:     apiKey,
		log:        log,
		router:     mux.NewRouter(),
	}

	api.router.NotFoundHandler = api.httpNotFound()
	api.bind(development)

	return api
}

type Api struct {
	pool       *ConnectionPool
	pathPrefix string
	apiKey     string

	log    *zap.Logger
	router *mux.Router
}

func (api *Api) DelugeHandler(f DelugeMethod) http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {
			conn, err := api.pool.Get(r.Context())
			if err != nil {
				return nil, err
			}

			ret, err := f(conn, r)
			api.pool.Put(conn)

			switch t := err.(type) {
			case deluge.RPCError:
				err = RPCError{
					ExceptionType:    t.ExceptionType,
					ExceptionMessage: t.ExceptionMessage,
				}
			}

			return ret, err
		})
	}
}

func (api *Api) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
	api.router.ServeHTTP(rw, r)
}

// httpNotFound implements http.Handler which returns a not found error message.
func (api *Api) httpNotFound() http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {
			return nil, &Error{
				Message: "Not found",
				Code:    http.StatusNotFound,
			}
		})
	})
}

func (api *Api) templateContext() interface{} {
	path := appendSuffix(api.pathPrefix, "/")
	return map[string]string{
		"BasePath":    path,
		"BaseAPIPath": fmt.Sprint(path, "api/"),
	}
}

// renderTemplate takes the contents of a go html/template found at source `name`
// and renders it back to the file system using templateContext().
func (api *Api) renderTemplate(fs afero.Fs, name string) {
	log := api.log.With(zap.String("template", name))

	f, err := fs.Open(name)
	if err != nil {
		log.Error("failed to open template for rendering", zap.Error(err))
		return
	}

	templateSource, err := io.ReadAll(f)
	if err != nil {
		log.Error("failed to read template", zap.Error(err))
		return
	}

	_ = f.Close()

	t, err := template.New(name).Parse(string(templateSource))
	if err != nil {
		log.Error("failed to parse template", zap.Error(err))
		return
	}

	w, err := fs.OpenFile(name, os.O_WRONLY|os.O_TRUNC, os.FileMode(0600))
	if err != nil {
		log.Error("failed to open template file for writing", zap.Error(err))
		return
	}

	err = t.Execute(w, api.templateContext())
	if err != nil {
		log.Error("failed to render template", zap.Error(err))
		return
	}

	err = w.Close()
	if err != nil {
		log.Error("failed to close rendered template", zap.Error(err))
		return
	}
}

func (api *Api) bindStatic(router *mux.Router, development bool) {
	var static, _ = fs.Sub(Static, "frontend/dist")
	if development {
		static = os.DirFS("./frontend/dist")
	}

	var (
		mem = afero.NewMemMapFs()
		ufs = afero.NewCopyOnWriteFs(&afero.FromIOFS{
			FS: static,
		}, mem)
	)

	api.renderTemplate(ufs, "index.html")

	fileServer := http.FileServer(afero.NewHttpFs(ufs).Dir(""))
	if api.pathPrefix != "" {
		fileServer = http.StripPrefix(api.pathPrefix, fileServer)
	}

	router.Methods(http.MethodGet).Handler(fileServer)
}

// keyFromRequest attempts to locate the API key from the incoming request.
// It looks for the key using these methods in the following order:
//   - The password component of a Basic auth header
//   - The cookie value ApiAuthCookieName as a base64 encoded value
func (api *Api) keyFromRequest(r *http.Request) (string, bool) {
	_, password, ok := r.BasicAuth()
	if ok {
		return password, false
	}

	fromCookie, err := r.Cookie(ApiAuthCookieName)
	if err != nil {
		return "", false
	}

	fromCookieDecoded, _ := base64.StdEncoding.DecodeString(fromCookie.Value)

	return string(fromCookieDecoded), true
}

// logForRequest takes a WrappedResponse and an incoming HTTP request and logs it
func (api *Api) logForRequest(rw *WrappedResponse, r *http.Request) {
	logger := api.log.With(
		zap.String("Method", r.Method),
		zap.String("URL", r.URL.String()),
		zap.String("RemoteAddr", r.RemoteAddr),
		zap.Time("Time", rw.Started()),
		zap.Int("StatusCode", rw.code),
		zap.Int("ResponseSize", rw.Len()),
		zap.Duration("Duration", rw.Duration()),
	)

	var logLevelFunc = logger.Info

	if rw.error != nil {
		// Do not log notification errors
		httpError, ok := rw.error.(HTTPError)
		if !ok || httpError.StatusCode() >= 400 {
			logLevelFunc = logger.With(zap.Error(rw.error)).Error
		}
	}

	logLevelFunc(http.StatusText(rw.code))
}

func (api *Api) httpMiddlewareLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		wr := WrapResponse(rw)

		next.ServeHTTP(wr, r)

		api.logForRequest(wr, r)
	})
}

func (api *Api) httpMiddlewareAuthenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		apiKey, fromCookie := api.keyFromRequest(r)
		if apiKey == "" {
			SendError(rw, &Error{
				Code:    http.StatusUnauthorized,
				Message: "No authentication provided in request",
			})
			return
		}

		ok := subtle.ConstantTimeCompare([]byte(api.apiKey), []byte(apiKey)) == 1
		if !ok {
			SendError(rw, &Error{
				Code:    http.StatusUnauthorized,
				Message: "Incorrect API key",
			})
			return
		}

		// If the request didn't originate from a cookie, then set the cookie
		if !fromCookie {
			http.SetCookie(rw, &http.Cookie{
				Name:     ApiAuthCookieName,
				Value:    base64.StdEncoding.EncodeToString([]byte(apiKey)),
				Path:     fmt.Sprintf("%s/api", api.pathPrefix),
				Expires:  time.Now().Add(time.Hour * 24 * 365),
				SameSite: http.SameSiteStrictMode,
				HttpOnly: true,
			})
		}

		next.ServeHTTP(rw, r)
	})
}

func (api *Api) bind(development bool) {
	primaryRouter := api.router
	if api.pathPrefix != "" {
		primaryRouter = api.router.PathPrefix(api.pathPrefix).Subrouter()
	}

	router := primaryRouter.PathPrefix("/api").Subrouter()
	router.NotFoundHandler = api.httpNotFound()

	// CORS
	router.Methods(http.MethodOptions).HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
		rw.WriteHeader(http.StatusOK)
	})

	apiRouter := router.NewRoute().Subrouter()
	apiRouter.Use(api.httpMiddlewareLog)

	// Enable API level authentication
	if api.apiKey != "" {
		apiRouter.Use(api.httpMiddlewareAuthenticate)
	}

	apiRouter.
		Methods(http.MethodGet).
		Path("/ping").
		HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			rw.WriteHeader(http.StatusNoContent)
		})

	apiRouter.
		Methods(http.MethodGet).
		Path("/session").
		HandlerFunc(api.DelugeHandler(httpGetSessionStatus))

	apiRouter.
		Methods(http.MethodGet).
		Path("/disk/free").
		HandlerFunc(api.DelugeHandler(httpGetFreeSpace))

	apiRouter.
		Methods(http.MethodGet).
		Path("/view").
		HandlerFunc(api.DelugeHandler(httpViewUpdate))

	apiRouter.
		Methods(http.MethodGet).
		Path("/plugins").
		HandlerFunc(api.DelugeHandler(httpGetPlugins))

	apiRouter.
		Methods(http.MethodPost).
		Path("/plugins/{id}").
		HandlerFunc(api.DelugeHandler(httpEnablePlugin))

	apiRouter.
		Methods(http.MethodDelete).
		Path("/plugins/{id}").
		HandlerFunc(api.DelugeHandler(httpDisablePlugin))

	apiRouter.
		Methods(http.MethodGet).
		Path("/torrents").
		HandlerFunc(api.DelugeHandler(httpTorrentsStatus))
	apiRouter.
		Methods(http.MethodPost).
		Path("/torrents").
		HandlerFunc(api.DelugeHandler(httpAddTorrent))
	apiRouter.
		Methods(http.MethodDelete).
		Path("/torrents").
		HandlerFunc(api.DelugeHandler(httpDeleteTorrents))
	apiRouter.
		Methods(http.MethodPost).
		Path("/torrents/pause").
		HandlerFunc(api.DelugeHandler(httpPauseTorrents))
	apiRouter.
		Methods(http.MethodPost).
		Path("/torrents/resume").
		HandlerFunc(api.DelugeHandler(httpResumeTorrents))

	apiRouter.
		Methods(http.MethodGet).
		Path("/torrent/{id}").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpTorrentStatus)))
	apiRouter.
		Methods(http.MethodDelete).
		Path("/torrent/{id}").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpDeleteTorrent)))
	apiRouter.
		Methods(http.MethodPut).
		Path("/torrent/{id}").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentOptions)))
	apiRouter.
		Methods(http.MethodPost).
		Path("/torrent/{id}/pause").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpPauseTorrent)))
	apiRouter.
		Methods(http.MethodPost).
		Path("/torrent/{id}/resume").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpResumeTorrent)))

	apiRouter.
		Methods(http.MethodGet).
		Path("/labels").
		HandlerFunc(api.DelugeHandler(httpLabels))

	apiRouter.
		Methods(http.MethodPost).
		Path("/labels/{id}").
		HandlerFunc(api.DelugeHandler(httpCreateLabel))

	apiRouter.
		Methods(http.MethodDelete).
		Path("/labels/{id}").
		HandlerFunc(api.DelugeHandler(httpDeleteLabel))

	apiRouter.
		Methods(http.MethodGet).
		Path("/torrents/labels").
		HandlerFunc(api.DelugeHandler(httpTorrentsLabels))

	apiRouter.
		Methods(http.MethodPost).
		Path("/torrent/{id}/label").
		HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentLabel)))

	// Static files
	api.bindStatic(primaryRouter, development)
}


================================================
FILE: cmd/storm/server.go
================================================
package main

import (
	"context"
	"fmt"
	deluge "github.com/gdm85/go-libdeluge"
	"github.com/jessevdk/go-flags"
	storm "github.com/relvacode/storm"
	"go.uber.org/zap"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"
)

func signalContext(ctx context.Context) context.Context {
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)

	xCtx, cancel := context.WithCancel(ctx)
	go func() {
		select {
		case <-ctx.Done():
		case <-sig:
			cancel()
		}
		signal.Stop(sig)
	}()

	return xCtx
}

type Duration struct {
	Duration time.Duration
}

func (dur *Duration) UnmarshalFlag(value string) (err error) {
	dur.Duration, err = time.ParseDuration(value)
	return
}

type Path string

func (p *Path) UnmarshalFlag(value string) error {
	if strings.HasSuffix(value, "/") {
		value = strings.TrimSuffix(value, "/")
	}
	if !strings.HasPrefix(value, "/") {
		value = fmt.Sprint("/", value)
	}

	*p = Path(value)
	return nil
}

type ServerOptions struct {
	Listen          string `short:"l" long:"listen" default:":8221" env:"LISTEN_ADDR" description:"The address for the HTTP server"`
	LogStyle        string `long:"log-style" choice:"production" choice:"console" default:"console" env:"LOGGING_STYLE" description:"The style of log messages"`
	BasePath        *Path  `long:"base-path" required:"true" default:"/" env:"STORM_BASE_PATH" description:"Respond to requests from this base URL path"`
	ApiKey          string `long:"api-key" env:"STORM_API_KEY" description:"Set the password required to access the API (enables authentication)"`
	DevelopmentMode bool   `long:"dev-mode" env:"DEV_MODE" description:"Run in development mode"`
}

func (options *ServerOptions) Logger() (*zap.Logger, error) {
	var cfg zap.Config
	switch options.LogStyle {
	case "production":
		cfg = zap.NewProductionConfig()
	case "console":
		cfg = zap.NewDevelopmentConfig()
	default:
		panic("Invalid LogStyle choice")
	}

	cfg.DisableStacktrace = true
	return cfg.Build()
}

func (options *ServerOptions) RunHandler(ctx context.Context, log *zap.Logger, handler http.Handler) error {
	var (
		errors = make(chan error, 1)
		server = &http.Server{
			Addr:    options.Listen,
			Handler: handler,
		}
	)

	go func() {
		errors <- server.ListenAndServe()
	}()

	log.Info(fmt.Sprintf("Ready to serve HTTP connections on %s%s", options.Listen, *options.BasePath))

	defer close(errors)

	select {
	case <-ctx.Done():
		log.Error("Interrupt signal received. Gracefully shutting down...")
		timeout, cancel := context.WithTimeout(context.Background(), time.Minute)
		_ = server.Shutdown(timeout)
		cancel()
	case err := <-errors:
		return err
	}

	return <-errors
}

type DelugeOptions struct {
	Version  string `long:"deluge-version" choice:"v1" choice:"v2" default:"v1" env:"DELUGE_RPC_VERSION" description:"The Deluge RPC version"`
	Hostname string `short:"H" long:"hostname" required:"true" env:"DELUGE_RPC_HOSTNAME" description:"The Deluge RPC hostname"`
	Port     uint   `short:"P" long:"port" default:"58846" env:"DELUGE_RPC_PORT" description:"The Deluge RPC port"`
	Username string `short:"u" long:"username" env:"DELUGE_RPC_USERNAME" description:"The Deluge RPC username"`
	Password string `short:"p" long:"password" env:"DELUGE_RPC_PASSWORD" description:"The Deluge RPC password"`

	MaxConnections int       `long:"max-connections" env:"POOL_MAX_CONNECTIONS" required:"true" default:"5" description:"Maximum concurrent Deluge RPC connections"`
	IdleTime       *Duration `long:"idle-time" env:"POOL_IDLE_TIME" required:"true" default:"30s" description:"Close idle Deluge RPC connections after this duration"`
}

func (options *DelugeOptions) Client() storm.DelugeProvider {
	var settings = deluge.Settings{
		Hostname:         options.Hostname,
		Port:             options.Port,
		Login:            options.Username,
		Password:         options.Password,
		ReadWriteTimeout: time.Minute * 5,
	}

	return func() deluge.DelugeClient {

		switch options.Version {
		case "v1":
			return deluge.NewV1(settings)
		case "v2":
			return deluge.NewV2(settings)
		default:
			panic("Invalid Version choice")
		}
	}
}

func (options *DelugeOptions) Pool(log *zap.Logger) *storm.ConnectionPool {
	return storm.NewConnectionPool(log, options.MaxConnections, options.IdleTime.Duration, options.Client())
}

type Options struct {
	ServerOptions
	DelugeOptions
}

func Main() error {
	var options Options
	var parser = flags.NewParser(&options, flags.HelpFlag)

	_, err := parser.Parse()
	if err != nil {
		return err
	}

	log, err := (&options.ServerOptions).Logger()
	if err != nil {
		return err
	}

	defer log.Sync()

	ctx := signalContext(context.Background())

	pool := (&options.DelugeOptions).Pool(log.Named("pool"))
	defer pool.Close()

	if options.DevelopmentMode {
		log.Info("Running in development mode")
	}

	var (
		apiLog = log.Named("api")
		api    = storm.New(apiLog, pool, (string)(*options.BasePath), options.ServerOptions.ApiKey, options.DevelopmentMode)
	)

	return (&options.ServerOptions).RunHandler(ctx, apiLog, api)
}

func main() {
	err := Main()
	if err != nil {
		_, _ = fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}


================================================
FILE: docker-compose/auth
================================================
localclient:deluge:10

================================================
FILE: docker-compose/core.conf
================================================
{
    "file": 1,
    "format": 1
}{
    "add_paused": false,
    "allow_remote": true,
    "auto_manage_prefer_seeds": false,
    "auto_managed": true,
    "cache_expiry": 60,
    "cache_size": 512,
    "copy_torrent_file": false,
    "daemon_port": 58846,
    "del_copy_torrent_file": false,
    "dht": true,
    "dont_count_slow_torrents": false,
    "download_location": "/root/Downloads",
    "download_location_paths_list": [],
    "enabled_plugins": [
        "Label"
    ],
    "enc_in_policy": 1,
    "enc_level": 2,
    "enc_out_policy": 1,
    "geoip_db_location": "/usr/share/GeoIP/GeoIP.dat",
    "ignore_limits_on_local_network": true,
    "info_sent": 0.0,
    "listen_interface": "",
    "listen_ports": [
        6881,
        6891
    ],
    "listen_random_port": 53703,
    "listen_reuse_port": true,
    "listen_use_sys_port": false,
    "lsd": true,
    "max_active_downloading": 3,
    "max_active_limit": 8,
    "max_active_seeding": 5,
    "max_connections_global": 200,
    "max_connections_per_second": 20,
    "max_connections_per_torrent": -1,
    "max_download_speed": -1.0,
    "max_download_speed_per_torrent": -1,
    "max_half_open_connections": 50,
    "max_upload_slots_global": 4,
    "max_upload_slots_per_torrent": -1,
    "max_upload_speed": -1.0,
    "max_upload_speed_per_torrent": -1,
    "move_completed": false,
    "move_completed_path": "/root/Downloads",
    "move_completed_paths_list": [],
    "natpmp": true,
    "new_release_check": false,
    "outgoing_interface": "",
    "outgoing_ports": [
        0,
        0
    ],
    "path_chooser_accelerator_string": "Tab",
    "path_chooser_auto_complete_enabled": true,
    "path_chooser_max_popup_rows": 20,
    "path_chooser_show_chooser_button_on_localhost": true,
    "path_chooser_show_hidden_files": false,
    "peer_tos": "0x00",
    "plugins_location": "/config/plugins",
    "pre_allocate_storage": false,
    "prioritize_first_last_pieces": false,
    "proxy": {
        "anonymous_mode": false,
        "force_proxy": false,
        "hostname": "",
        "password": "",
        "port": 8080,
        "proxy_hostnames": true,
        "proxy_peer_connections": true,
        "proxy_tracker_connections": true,
        "type": 0,
        "username": ""
    },
    "queue_new_to_top": false,
    "random_outgoing_ports": true,
    "random_port": true,
    "rate_limit_ip_overhead": true,
    "remove_seed_at_ratio": false,
    "seed_time_limit": 180,
    "seed_time_ratio_limit": 7.0,
    "send_info": false,
    "sequential_download": false,
    "share_ratio_limit": 2.0,
    "shared": false,
    "stop_seed_at_ratio": false,
    "stop_seed_ratio": 2.0,
    "super_seeding": false,
    "torrentfiles_location": "/root/Downloads",
    "upnp": true,
    "utpex": true
}


================================================
FILE: docker-compose/docker-compose.yml
================================================
version: "2.1"

services:
  deluge:
    image: ghcr.io/linuxserver/deluge
    volumes:
      - deluge-config:/config
      - ./core.conf:/config/core.conf
      - ./auth:/config/auth:ro
  storm:
    image: ghcr.io/relvacode/storm
    environment:
      DELUGE_RPC_VERSION: v2
      DELUGE_RPC_HOSTNAME: deluge
      DELUGE_RPC_USERNAME: localclient
      DELUGE_RPC_PASSWORD: deluge
    ports:
      - "8221:8221"

volumes:
  deluge-config:

================================================
FILE: error.go
================================================
package storm

import (
	"fmt"
	"net/http"
)

// HTTPError is an error that extends the built-in Go error with status code hinting.
type HTTPError interface {
	error
	StatusCode() int
}

type RPCError struct {
	ExceptionType    string
	ExceptionMessage string
}

func (RPCError) StatusCode() int {
	return http.StatusInternalServerError
}

func (e RPCError) Error() string {
	return fmt.Sprintf("%s: %s", e.ExceptionType, e.ExceptionMessage)
}

var _ HTTPError = (*Error)(nil)

// Hint wraps an input error to hint the HTTP status code.
// If err already implements HTTPError then that error is used directly
func Hint(code int, err error) error {
	if httpError, ok := err.(HTTPError); ok {
		return httpError
	}

	return &Error{
		Message: err.Error(),
		Code:    code,
	}
}

type Error struct {
	Message string
	Code    int
}

func (e *Error) Error() string {
	return e.Message
}

func (e *Error) StatusCode() int {
	return e.Code
}

type errorResponse struct {
	Error string
}

// SendError sends an error back to the client.
// If err implements HTTPError then that status code is used. Otherwise HTTP InternalServerError is sent.
// It delivers the error message as the errorResponse payload.
func SendError(rw http.ResponseWriter, err error) {
	var (
		code     = http.StatusInternalServerError
		response = errorResponse{
			Error: err.Error(),
		}
	)

	if httpError, ok := err.(HTTPError); ok {
		code = httpError.StatusCode()
	}

	if wr, ok := rw.(*WrappedResponse); ok {
		wr.error = err
	}

	Send(rw, code, &response)
}


================================================
FILE: frontend/.browserslistrc
================================================
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries

# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support

# You can see what browsers were selected by your queries by running:
#   npx browserslist

last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.


================================================
FILE: frontend/.editorconfig
================================================
# Editor configuration, see https://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.ts]
quote_type = single

[*.md]
max_line_length = off
trim_trailing_whitespace = false


================================================
FILE: frontend/angular.json
================================================
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "storm": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "style": "scss"
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "t",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "node_modules/primeng/resources/primeng.min.css",
              "node_modules/primeng/resources/themes/bootstrap4-dark-blue/theme.css",
              "node_modules/primeflex/primeflex.css",
              "node_modules/@fortawesome/fontawesome-free/css/regular.css",
              "node_modules/@fortawesome/fontawesome-free/css/solid.css",
              "node_modules/@fortawesome/fontawesome-free/css/fontawesome.css",
              "src/icons.scss",
              "src/styles.scss"
            ],
            "scripts": []
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false,
              "buildOptimizer": true,
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "2mb",
                  "maximumError": "5mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "6kb",
                  "maximumError": "10kb"
                }
              ]
            },
            "development": {
              "sourceMap": true,
              "buildOptimizer": false,
              "optimization": false
            }
          }
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "options": {
            "browserTarget": "storm:build"
          },
          "configurations": {
            "production": {
              "browserTarget": "storm:build:production"
            }
          }
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "browserTarget": "storm:build"
          }
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "main": "src/test.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.spec.json",
            "karmaConfig": "karma.conf.js",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.scss"
            ],
            "scripts": []
          }
        },
        "lint": {
          "builder": "@angular-devkit/build-angular:tslint",
          "options": {
            "tsConfig": [
              "tsconfig.app.json",
              "tsconfig.spec.json",
              "e2e/tsconfig.json"
            ],
            "exclude": [
              "**/node_modules/**"
            ]
          }
        },
        "e2e": {
          "builder": "@angular-devkit/build-angular:protractor",
          "options": {
            "protractorConfig": "e2e/protractor.conf.js",
            "devServerTarget": "storm:serve"
          },
          "configurations": {
            "production": {
              "devServerTarget": "storm:serve:production"
            }
          }
        }
      }
    }
  },
  "defaultProject": "storm"
}


================================================
FILE: frontend/e2e/protractor.conf.js
================================================
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts

const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');

/**
 * @type { import("protractor").Config }
 */
exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './src/**/*.e2e-spec.ts'
  ],
  capabilities: {
    browserName: 'chrome'
  },
  directConnect: true,
  baseUrl: 'http://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function() {}
  },
  onPrepare() {
    require('ts-node').register({
      project: require('path').join(__dirname, './tsconfig.json')
    });
    jasmine.getEnv().addReporter(new SpecReporter({
      spec: {
        displayStacktrace: StacktraceOption.PRETTY
      }
    }));
  }
};

================================================
FILE: frontend/e2e/src/app.e2e-spec.ts
================================================
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';

describe('workspace-project App', () => {
  let page: AppPage;

  beforeEach(() => {
    page = new AppPage();
  });

  it('should display welcome message', () => {
    page.navigateTo();
    expect(page.getTitleText()).toEqual('storm app is running!');
  });

  afterEach(async () => {
    // Assert that there are no errors emitted from the browser
    const logs = await browser.manage().logs().get(logging.Type.BROWSER);
    expect(logs).not.toContain(jasmine.objectContaining({
      level: logging.Level.SEVERE,
    } as logging.Entry));
  });
});


================================================
FILE: frontend/e2e/src/app.po.ts
================================================
import { browser, by, element } from 'protractor';

export class AppPage {
  navigateTo(): Promise<unknown> {
    return browser.get(browser.baseUrl) as Promise<unknown>;
  }

  getTitleText(): Promise<string> {
    return element(by.css('app-root .content span')).getText() as Promise<string>;
  }
}


================================================
FILE: frontend/e2e/tsconfig.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/e2e",
    "module": "commonjs",
    "target": "es2018",
    "types": [
      "jasmine",
      "jasminewd2",
      "node"
    ]
  }
}


================================================
FILE: frontend/karma.conf.js
================================================
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular-devkit/build-angular/plugins/karma')
    ],
    client: {
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, './coverage/storm'),
      reports: ['html', 'lcovonly', 'text-summary'],
      fixWebpackSourcePaths: true
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false,
    restartOnFileChange: true
  });
};


================================================
FILE: frontend/package.json
================================================
{
  "name": "storm",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "build:prod": "ng build --configuration=production",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^13.1.1",
    "@angular/cdk": "^13.1.1",
    "@angular/common": "~13.1.1",
    "@angular/compiler": "~13.1.1",
    "@angular/core": "~13.1.1",
    "@angular/forms": "~13.1.1",
    "@angular/platform-browser": "~13.1.1",
    "@angular/platform-browser-dynamic": "~13.1.1",
    "@angular/router": "~13.1.1",
    "@fortawesome/fontawesome-free": "^5.15.1",
    "ngx-filesize": "^2.0.16",
    "ngx-moment": "^5.0.0",
    "primeflex": "^2.0.0",
    "primeng": "^13.0.3",
    "rxjs": "~6.6.0",
    "tslib": "^2.0.0",
    "zone.js": "~0.11.4"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^13.1.2",
    "@angular/cli": "~13.1.2",
    "@angular/compiler-cli": "~13.1.1",
    "@types/jasmine": "~3.5.0",
    "@types/jasminewd2": "~2.0.3",
    "@types/node": "^12.11.1",
    "codelyzer": "^6.0.0",
    "jasmine-core": "~3.8.0",
    "jasmine-spec-reporter": "~5.0.0",
    "karma": "~6.3.9",
    "karma-chrome-launcher": "~3.1.0",
    "karma-coverage-istanbul-reporter": "~3.0.2",
    "karma-jasmine": "~4.0.0",
    "karma-jasmine-html-reporter": "^1.5.0",
    "protractor": "~7.0.0",
    "ts-node": "~8.3.0",
    "tslint": "~6.1.0",
    "typescript": "~4.5.4"
  }
}


================================================
FILE: frontend/src/app/api.service.spec.ts
================================================
import { TestBed } from '@angular/core/testing';

import { ApiService } from './api.service';

describe('ApiService', () => {
  let service: ApiService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(ApiService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/api.service.ts
================================================
import {Inject, Injectable} from '@angular/core';
import {defer, EMPTY, Observable, ObservableInput, throwError} from 'rxjs';
import {
  HttpClient,
  HttpErrorResponse,
  HttpEvent,
  HttpHandler, HttpHeaders,
  HttpInterceptor,
  HttpParams,
  HttpRequest
} from '@angular/common/http';
import {catchError, retryWhen, switchMap, takeWhile} from 'rxjs/operators';
import {Message} from 'primeng/api';
import {Environment, ENVIRONMENT} from './environment';
import {DialogService} from 'primeng/dynamicdialog';
import {ApiKeyDialogComponent} from './components/api-key-dialog/api-key-dialog.component';

/**
 * Raised when the API returns an error
 */
export class ApiException {
  constructor(public status: number, public error: string) {
  }

  /**
   * Get a PrimeNG error block for this exception
   */
  public get message(): Message {
    return {
      severity: 'error',
      summary: 'Error',
      detail: this.error,
    };
  }
}

export type State =
  'Active'
  | 'Allocating'
  | 'Checking'
  | 'Downloading'
  | 'Seeding'
  | 'Paused'
  | 'Error'
  | 'Queued'
  | 'Moving';

export interface Torrent {
  ActiveTime: number;
  CompletedTime: number;
  TimeAdded: number; // most times an integer
  DistributedCopies: number;
  ETA: number; // most times an integer
  Progress: number; // max is 100
  Ratio: number;
  IsFinished: boolean;
  IsSeed: boolean;
  Private: boolean;
  DownloadLocation: string;
  DownloadPayloadRate: number;
  Name: string;
  NextAnnounce: number;
  NumPeers: number;
  NumPieces: number;
  NumSeeds: number;
  PieceLength: number;
  SeedingTime: number;
  State: State;
  TotalDone: number;
  TotalPeers: number;
  TotalSeeds: number;
  TotalSize: number;
  TrackerHost: string;
  TrackerStatus: string;
  UploadPayloadRate: number;

// Files:         []File
// Peers:         []Peer
  FilePriorities: number[];
  FileProgress: number[];
}

export interface Label {
  Label: string;
}

export interface Hash {
  Hash: string;
}

export interface ViewTorrent extends Torrent, Hash, Label {
}


export interface Torrents {
  [id: string]: Torrent;
}

export interface TorrentOptions {
  MaxConnections?: number;
  MaxUploadSlots?: number;
  MaxUploadSpeed?: number;
  MaxDownloadSpeed?: number;
  PrioritizeFirstLastPieces?: boolean;
  PreAllocateStorage?: boolean; // compact_allocation for v1
  DownloadLocation?: string;
  AutoManaged?: boolean;
  StopAtRatio?: boolean;
  StopRatio?: number;
  RemoveAtRatio?: number;
  MoveCompleted?: boolean;
  MoveCompletedPath?: string;
  AddPaused?: boolean;
}

export interface AddTorrent {
  Type: 'url' | 'magnet' | 'file';
  URI: string;
  Data?: string;
}

export interface AddTorrentRequest extends AddTorrent {
  Options?: TorrentOptions;
}

export interface AddTorrentResponse {
  ID: string;
}

export interface TorrentLabels {
  [id: string]: string;
}

export interface SetTorrentLabelRequest {
  Label: string;
}

export interface SessionStatus {
  HasIncomingConnections: boolean;
  UploadRate: number;
  DownloadRate: number;
  PayloadUploadRate: number;
  PayloadDownloadRate: number;
  TotalDownload: number;
  TotalUpload: number;
  NumPeers: number;
  DhtNodes: number;
}

export interface DiskSpace {
  FreeBytes: number;
}

export interface ViewUpdate {
  Torrents: ViewTorrent[];
  Session: SessionStatus;
  DiskFree: number;
  ETag: string;
}

export class ApiInterceptor implements HttpInterceptor {
  constructor() {
  }

  private catchError(err: HttpErrorResponse, caught: Observable<HttpEvent<any>>): ObservableInput<any> {
    return throwError(new ApiException(err.status, err.error.Error));
  }

  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      catchError(this.catchError)
    );
  }
}

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  ask$: Observable<void>;

  constructor(dialogService: DialogService) {
    this.ask$ = defer(() => {
      const ref = dialogService.open(ApiKeyDialogComponent, {
        header: 'Authorization Required',
        showHeader: true,
        closeOnEscape: false,
        closable: false,
      });

      return ref.onClose;
    });
  }

  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      // Catch 401 errors and ask for the API key.
      // Redo the request with the provided API key in basic auth headers
      catchError((err: ApiException) => {
        if (err.status !== 401) {
          return throwError(err);
        }

        return this.ask$.pipe(
          switchMap(key => {
            const withAuthHeaderReq = req.clone({
              headers: req.headers.set('Authorization', 'Basic ' + btoa(':' + key))
            });

            return next.handle(withAuthHeaderReq);
          })
        );
      }),

      // Keep retrying 401 errors
      retryWhen(errors => errors.pipe(
        takeWhile((err: ApiException) => err.status === 401)
      ))
    );
  }
}

@Injectable({
  providedIn: 'root',
})
export class ApiService {
  constructor(private http: HttpClient, @Inject(ENVIRONMENT) private environment: Environment) {
  }

  private url(endpoint: string): string {
    return `${this.environment.baseApiPath}${endpoint}`;
  }

  /**
   * Calls the ping endpoint
   */
  public ping(): Observable<void> {
    return this.http.get<void>(this.url('ping'));
  }

  public sessionStatus(): Observable<SessionStatus> {
    return this.http.get<SessionStatus>(this.url('session'));
  }

  public freeDiskSpace(path: string = ''): Observable<DiskSpace> {
    return this.http.get<DiskSpace>(this.url('disk/free'), {
      params: {
        path
      }
    });
  }

  public viewUpdate(etag?: string, state?: State): Observable<ViewUpdate> {
    let params = new HttpParams();
    if (!!state) {
      params = params.set('state', state);
    }

    let headers = new HttpHeaders();
    if (!!etag) {
      headers = headers.set('ETag', etag);
    }

    return this.http.get<ViewUpdate>(this.url('view'), {
      params,
      headers,
    }).pipe(
      catchError((err: ApiException) => {
        if (err.status === 304) {
          return EMPTY;
        }

        return throwError(err);
      })
    );
  }

  /**
   * Get a list of all the currently enabled plugins
   */
  public plugins(): Observable<string[]> {
    return this.http.get<string[]>(this.url('plugins'));
  }

  /**
   * Enable a plugin
   * @param name
   * The plugin name to enable
   */
  public enablePlugin(name: string): Observable<void> {
    return this.http.post<void>(this.url(`plugins/${name}`), null);
  }

  /**
   * Disable a plugin
   * @param name
   * The plugin name to disable
   */
  public disablePlugin(name: string): Observable<void> {
    return this.http.delete<void>(this.url(`plugins/${name}`));
  }

  /**
   * Pauses one or more torrens
   * @param torrents
   * List of torrent IDs
   */
  public pause(...torrents: string[]): Observable<void> {
    const params = new HttpParams({
      fromObject: {
        id: torrents,
      }
    });

    return this.http.post<void>(this.url('torrents/pause'), null, {
      params
    });
  }

  /**
   * Resumes one or more torrens
   * @param torrents
   * List of torrent IDs
   */
  public resume(...torrents: string[]): Observable<void> {
    const params = new HttpParams({
      fromObject: {
        id: torrents,
      }
    });

    return this.http.post<void>(this.url('torrents/resume'), null, {
      params
    });
  }

  /**
   * Get a specific torrent by ID
   * @param id
   * The torrent ID
   */
  public torrent(id: string): Observable<Torrent> {
    return this.http.get<Torrent>(this.url(`torrent/${id}`));
  }


  public torrents(state?: State, ...torrents: string[]): Observable<Torrents> {
    let params = new HttpParams();
    if (!!state) {
      params = params.set('state', state);
    }
    if (!!torrents && torrents.length) {
      for (const id of torrents) {
        params = params.append('id', id);
      }
    }

    return this.http.get<Torrents>(this.url('torrents'), {
      params,
    });
  }

  public removeTorrent(withData: boolean, id: string): Observable<void> {
    return this.http.delete<void>(this.url(`torrent/${id}`), {
      params: new HttpParams({
        fromObject: {
          files: withData ? 'true' : 'false'
        }
      })
    });
  }

  /**
   * Add a new torrent
   * @param req
   * Add torrent request
   */
  public add(req: AddTorrentRequest): Observable<AddTorrentResponse> {
    return this.http.post<AddTorrentResponse>(this.url('torrents'), req);
  }

  /**
   * Gets available labels
   */
  public labels(): Observable<string[]> {
    return this.http.get<string[]>(this.url('labels'));
  }

  /**
   * Create a new label
   * @param name
   * The label name
   */
  public createLabel(name: string): Observable<void> {
    return this.http.post<void>(this.url(`labels/${name}`), null);
  }

  /**
   * Delete an existing label
   * @param name
   * The label name
   */
  public deleteLabel(name: string): Observable<void> {
    return this.http.delete<void>(this.url(`labels/${name}`));
  }

  /**
   * Gets labels associated with torrents matching filter
   * @param state
   * The torrent state
   * @param torrents
   * An optional set of torrent IDs
   */
  public torrentsLabels(state?: State, ...torrents: string[]): Observable<TorrentLabels> {
    return this.http.get<TorrentLabels>(this.url('torrents/labels'));
  }

  /**
   * Updates the label of a torrent
   * @param id
   * The torrent ID
   * @param req
   * Request data
   */
  public setTorrentLabel(id: string, req: SetTorrentLabelRequest): Observable<void> {
    return this.http.post<void>(this.url(`torrent/${id}/label`), req);
  }
}


================================================
FILE: frontend/src/app/app.component.html
================================================
<div class="p-menubar p-d-flex p-flex-row p-align-center p-justify-between p-flex-nowrap">

  <t-breakpoint-overlay icon="fas fa-bars" styleClass="t-button-overlay">
    <button pButton pRipple (click)="menu.toggle($event)" icon="fas fa-plus" class="t-button-alt"
            pTooltip="Add Torrent"></button>
    <t-add-torrent-menu #menu></t-add-torrent-menu>

    <button pButton pRipple type="button" class="t-menu-button"
            (click)="onToggleInView(stateInView === 'Paused' ? 'resume' : 'pause', torrents)"
            [disabled]="empty"
            [pTooltip]="stateInView == 'Paused' ? 'Resume' : 'Pause'"
            [icon]="stateInView === 'Paused' ? 'fas fa-play' : 'fas fa-pause'"
    ></button>

    <t-delete-torrent-overlay #remove [torrents]="hashesInView"></t-delete-torrent-overlay>
    <button pButton pRipple (click)="remove.toggle($event)" type="button" icon="far fa-trash-alt"
            [disabled]="empty"

            class="p-button-danger t-menu-button"
            pTooltip="Remove Displayed"></button>
  </t-breakpoint-overlay>


  <t-torrent-search (search)="searchText = $event"></t-torrent-search>

  <t-breakpoint-overlay icon="fas fa-filter" [contentActivated]="(get$ | async) !== null" styleClass="t-filter-overlay">
    <div class="t-sort-option">
      <p-dropdown class="t-filter-input" [options]="sortOptions" appendTo="body"
                  [(ngModel)]="sortByField" placeholder="Sort"></p-dropdown>
      <button pButton pRipple (click)="sortReverse = !sortReverse" type="button" class="t-sort-button"
              [icon]="sortReverse ? 'fas fa-sort-amount-up' : 'fas fa-sort-amount-down-alt'"
              [pTooltip]="sortReverse ? 'Sort descending': 'Sort ascending'"
      ></button>
    </div>

    <p-dropdown class="t-filter-input" [options]="filterStatesOptions" appendTo="body"
                (onChange)="get$.next($event.value)" placeholder="State"></p-dropdown>
  </t-breakpoint-overlay>
</div>


<ng-template #displayNotLoaded>
  <div class="t-empty p-d-flex p-flex-column p-justify-center p-align-center">
    <i class="fas fa-circle-notch fa-spin"></i>
  </div>
</ng-template>

<ng-template #displayEmpty>
  <div class="t-empty p-d-flex p-flex-column p-justify-center p-align-center">
    <i class="fas fa-cloud-moon"></i>
    <span>No Torrents</span>
  </div>
</ng-template>

<ng-container *ngIf="torrents | torrentSearch: searchText as result; else displayNotLoaded">
  <div class="layout-content">
    <div class="p-grid">
      <div class="p-col-12"
           *ngFor="let item of result | orderBy: sortByField : sortReverse ; trackBy: trackBy">
        <t-torrent [torrent]="item" [hash]="item.Hash" [label]="item.Label"></t-torrent>
      </div>
    </div>
  </div>

  <ng-container *ngIf="!result.length" [ngTemplateOutlet]="displayEmpty"></ng-container>
</ng-container>


<div class="t-bottom-bar">
  <t-session-status [sessionStatus]="sessionStatus" [diskSpace]="diskSpace"></t-session-status>
</div>

<t-connectivity-status [connected]="connected"></t-connectivity-status>



================================================
FILE: frontend/src/app/app.component.scss
================================================
.p-menubar {
  padding: .5rem 1rem;
  position: sticky;
  top: 0;
  z-index: 1;

  & button.t-menu-button:not(:first-child) {
    margin-left: 6px;
  }

  & .t-filter-input:not(:first-child) {
    margin-left: 6px;
  }

  t-torrent-search {
    margin: 0 12px;
    flex-grow: 1;
  }
}

.layout-content {
  padding: 2rem;
  background-color: var(--surface-b);
}

::ng-deep .t-button-overlay {
  & button.p-button:not(:first-child) {
    margin-left: 12px;
  }
}

::ng-deep .t-filter-overlay {
  min-width: 160px;

  & .t-sort-option {
    margin-bottom: 12px;
  }

  & > * {
    width: 100%;

    & .p-dropdown {
      width: 100%;
    }
  }
}


@media only screen and (max-width: 750px) {
  .p-menubar {
    padding: .5rem;

    & p-dropdown:not(:first-child) {
      margin-left: 6px;
    }
  }

  .layout-content {
    padding: 1rem;
  }
}

.t-empty {
  position: absolute;
  top: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  color: #434f5f;

  & i {
    font-size: 10rem;
    opacity: .2;
  }

  & span {
    margin-top: 2rem;
    font-weight: 600;
  }
}


t-breakpoint-overlay {
  display: flex;
  align-items: center;
}

.t-sort-option {
  width: 100%;
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;


  ::ng-deep & p-dropdown {
    flex-grow: 1;
    & .p-dropdown {
      border-radius: 4px 0 0 4px;
    }
  }

  & .t-sort-button {
    background: #20262e;
    border: 1px solid #3f4b5b;
    border-radius: 0 4px 4px 0;
    border-left: none;
    width: 3rem;
    color: rgba(255, 255, 255, 0.6);
  }
}

.t-bottom-bar {
  position: fixed;
  bottom: 0;
  width: 100%;
}


================================================
FILE: frontend/src/app/app.component.spec.ts
================================================
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        RouterTestingModule
      ],
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`should have as title 'storm'`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app.title).toEqual('storm');
  });

  it('should render title', () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('.content span').textContent).toContain('storm app is running!');
  });
});


================================================
FILE: frontend/src/app/app.component.ts
================================================
import {Component} from '@angular/core';
import {BehaviorSubject, combineLatest, EMPTY, forkJoin, Observable, of, timer} from 'rxjs';
import {catchError, filter, mergeMap, switchMap} from 'rxjs/operators';
import {ApiService, DiskSpace, Hash, SessionStatus, State, Torrent, ViewTorrent} from './api.service';
import {SelectItem} from 'primeng/api';
import {FocusService} from './focus.service';
import {DialogService} from 'primeng/dynamicdialog';
import {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component';

type OptionalState = State | null;


@Component({
  selector: 't-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  sortByField: keyof Torrent = null;
  sortReverse = false;

  sortOptions: SelectItem<keyof Torrent>[] = [
    {
      label: 'State',
      value: 'State'
    },
    {
      label: 'Added',
      value: 'TimeAdded'
    },
    {
      label: 'Progress',
      value: 'Progress'
    },
    {
      label: 'ETA',
      value: 'ETA'
    },
    {
      label: 'Name',
      value: 'Name'
    },
    {
      label: 'Size',
      value: 'TotalSize'
    },
    {
      label: 'Ratio',
      value: 'Ratio'
    },
    {
      label: 'Seeding',
      value: 'SeedingTime'
    }
  ];

  filterStatesOptions: SelectItem<OptionalState>[] = [
    {
      label: 'All',
      value: null,
    },
    {
      label: 'Active',
      value: 'Active'
    },
    {
      label: 'Queued',
      value: 'Queued',
    },
    {
      label: 'Downloading',
      value: 'Downloading',
    },
    {
      label: 'Seeding',
      value: 'Seeding',
    },
    {
      label: 'Paused',
      value: 'Paused'
    },
    {
      label: 'Error',
      value: 'Error'
    }
  ];

  searchText: string;

  // All torrent hashes within the current view
  hashesInView: string[];
  // Current view is empty
  empty = false;
  stateInView: OptionalState;
  sessionStatus: SessionStatus = {
    HasIncomingConnections: false,
    UploadRate: 0,
    DownloadRate: 0,
    PayloadUploadRate: 0,
    PayloadDownloadRate: 0,
    TotalDownload: 0,
    TotalUpload: 0,
    NumPeers: 0,
    DhtNodes: 0,
  };
  diskSpace: DiskSpace;

  torrents: ViewTorrent[];

  connected = true;
  lastEtag: string;

  get$: BehaviorSubject<OptionalState>;

  constructor(private api: ApiService, private focus: FocusService, private dialogService: DialogService) {
    this.get$ = new BehaviorSubject<OptionalState>(null);
    this.refreshInterval(2000);
  }


  /**
   * Opens the PluginEnable dialog component
   * @private
   */
  private enableLabelPlugin(): Observable<void> {
    const ref = this.dialogService.open(PluginEnableComponent, {
      header: 'Enable Plugin',
      showHeader: false,
      closable: false,
      closeOnEscape: false,
      dismissableMask: false,
      styleClass: 't-dialog-responsive',
      data: {
        name: 'Label'
      }
    });

    return ref.onClose;
  }

  /**
   * Updates the list of torrents at every given interval,
   * or when the selected $get state is updated.
   * @param interval
   * Update interval in milliseconds
   */
  private refreshInterval(interval: number): void {
    const timer$ = timer(0, interval);

    // Ensure the label plugin is enabled
    const labelPluginEnabled$ = this.api.plugins().pipe(
      switchMap(plugins => {
        const ok = plugins.findIndex(name => name === 'Label') > -1;
        if (ok) {
          return of(true);
        }

        return this.enableLabelPlugin();
      })
    );

    const interval$ = combineLatest([timer$, this.focus.observe, this.get$, labelPluginEnabled$]);

    interval$.pipe(
      // Continue only when in focus
      filter(([_, focus]) => focus),

      // Switch to API response of torrents
      mergeMap(([_, focus, state]) => this.api.viewUpdate(this.lastEtag, state).pipe(
        catchError(err => {
          console.error('Connection error', err);
          this.connected = false;
          this.lastEtag = null;
          return EMPTY;
        }),
      )),
    ).subscribe(
      response => {
        this.connected = true;
        this.sessionStatus = response.Session;
        this.diskSpace = {FreeBytes: response.DiskFree};
        this.torrents = response.Torrents;

        this.empty = this.torrents.length === 0;
        this.hashesInView = this.torrents.map(t => t.Hash);
        this.lastEtag = response.ETag;

        const statesInView = new Set(this.torrents.map(t => t.State));
        const [onlyStateInView] = statesInView.size === 1 ? statesInView : [];
        this.stateInView = onlyStateInView || null;
      }
    );
  }

  public trackBy(index: number, torrent: Hash): string {
    return torrent.Hash;
  }


  onToggleInView(targetState: 'pause' | 'resume', torrents: ViewTorrent[]): void {
    if (!torrents || torrents.length === 0) {
      return;
    }

    let res: Observable<void>;
    switch (targetState) {
      case 'pause':
        res = this.api.pause(...torrents.filter(t => t.State !== 'Paused').map(t => t.Hash));
        break;
      case 'resume':
        res = this.api.resume(...torrents.filter(t => t.State === 'Paused').map(t => t.Hash));
        break;
    }

    res.subscribe(
      _ => console.log(`torrents in view reached target state ${targetState}`)
    );
  }
}


================================================
FILE: frontend/src/app/app.module.ts
================================================
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';

import {AppComponent} from './app.component';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {TorrentStateComponent} from './components/torrent-state/torrent-state.component';
import {TorrentComponent} from './components/torrent/torrent.component';
import {ProgressBarModule} from 'primeng/progressbar';
import {NgxFilesizeModule} from 'ngx-filesize';
import {ButtonModule} from 'primeng/button';
import {RippleModule} from 'primeng/ripple';
import {TooltipModule} from 'primeng/tooltip';
import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';
import {ApiInterceptor, AuthInterceptor} from './api.service';
import {DropdownModule} from 'primeng/dropdown';
import {OrderByPipe} from './order-by.pipe';
import {FormsModule} from '@angular/forms';
import {OverlayPanelModule} from 'primeng/overlaypanel';
import {DeleteTorrentOverlayComponent} from './components/delete-torrent-overlay/delete-torrent-overlay.component';
import {ProgressSpinnerModule} from 'primeng/progressspinner';
import {MomentModule} from 'ngx-moment';
import {MenuModule} from 'primeng/menu';
import {AddTorrentMenuComponent} from './components/add-torrent-menu/add-torrent-menu.component';
import {DialogService, DynamicDialogModule} from 'primeng/dynamicdialog';
import {
  AddTorrentMagnetInputComponent
} from './components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component';
import {InputTextModule} from 'primeng/inputtext';
import {AddTorrentConfigComponent} from './components/add-torrent-menu/add-torrent-config/add-torrent-config.component';
import {AccordionModule} from 'primeng/accordion';
import {InputNumberModule} from 'primeng/inputnumber';
import {CheckboxModule} from 'primeng/checkbox';
import {TorrentDetailsDialogComponent} from './components/torrent-details-dialog/torrent-details-dialog.component';
import {
  AddTorrentUrlInputComponent
} from './components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component';
import {MessagesModule} from 'primeng/messages';
import {
  AddTorrentFileInputComponent
} from './components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component';
import {FileUploadModule} from 'primeng/fileupload';
import {BreakpointOverlayComponent} from './components/breakpoint-overlay/breakpoint-overlay.component';
import {TorrentSearchPipe} from './torrent-search.pipe';
import {ConnectivityStatusComponent} from './components/connectivity-status/connectivity-status.component';
import {TorrentSearchComponent} from './components/torrent-search/torrent-search.component';
import {ENVIRONMENT} from './environment';
import {TorrentLabelComponent} from './components/torrent-label/torrent-label.component';
import {
  TorrentEditLabelDialogComponent
} from './components/torrent-edit-label-dialog/torrent-edit-label-dialog.component';
import {PluginEnableComponent} from './components/plugin-enable/plugin-enable.component';
import {MultiSelectModule} from 'primeng/multiselect';
import { ActivityMarkerComponent } from './components/activity-marker/activity-marker.component';
import { ApiKeyDialogComponent } from './components/api-key-dialog/api-key-dialog.component';
import { SessionStatusComponent } from './components/session-status/session-status.component';

// @ts-ignore
@NgModule({
  declarations: [
    AppComponent,
    TorrentStateComponent,
    TorrentComponent,
    OrderByPipe,
    DeleteTorrentOverlayComponent,
    AddTorrentMenuComponent,
    AddTorrentMagnetInputComponent,
    AddTorrentConfigComponent,
    TorrentDetailsDialogComponent,
    AddTorrentUrlInputComponent,
    AddTorrentFileInputComponent,
    BreakpointOverlayComponent,
    TorrentSearchPipe,
    ConnectivityStatusComponent,
    TorrentSearchComponent,
    TorrentLabelComponent,
    TorrentEditLabelDialogComponent,
    PluginEnableComponent,
    ActivityMarkerComponent,
    ApiKeyDialogComponent,
    SessionStatusComponent,
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    HttpClientModule,

    NgxFilesizeModule,
    ProgressBarModule,
    MenuModule,
    ButtonModule,
    RippleModule,
    TooltipModule,
    DropdownModule,
    FormsModule,
    OverlayPanelModule,
    ProgressSpinnerModule,
    DynamicDialogModule,
    MomentModule,
    InputTextModule,
    AccordionModule,
    InputNumberModule,
    CheckboxModule,
    MessagesModule,
    FileUploadModule,
    MultiSelectModule,
  ],
  entryComponents: [],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true,
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: ApiInterceptor,
      multi: true,
    },
    {
      provide: ENVIRONMENT,
      // @ts-ignore
      // Environment injection is handled by the server.
      // It will replace the contents of the initializer in index.html with the environment
      // specific variables on-demand.
      useValue: window.environment,
    },
    DialogService,
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}


================================================
FILE: frontend/src/app/components/activity-marker/activity-marker.component.html
================================================


================================================
FILE: frontend/src/app/components/activity-marker/activity-marker.component.scss
================================================
:host {
  position: absolute;
  top: 0;
  margin: 0;

  color: #d45e6a;
  transform: translate(-0.6em, 0.6em);
  font-size: .6rem;


  font-family: 'Font Awesome 5 Free';
  font-style: normal;
  font-weight: 900;

  &::before {
    // fas fa-circle
    content: '\f111';
  }
}


================================================
FILE: frontend/src/app/components/activity-marker/activity-marker.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ActivityMarkerComponent } from './activity-marker.component';

describe('ActivityMarkerComponent', () => {
  let component: ActivityMarkerComponent;
  let fixture: ComponentFixture<ActivityMarkerComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ActivityMarkerComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ActivityMarkerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/activity-marker/activity-marker.component.ts
================================================
import { Component } from '@angular/core';

@Component({
  selector: 't-activity-marker',
  templateUrl: './activity-marker.component.html',
  styleUrls: ['./activity-marker.component.scss']
})
export class ActivityMarkerComponent {

  constructor() { }
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.html
================================================
<!--MaxConnections?: number;-->
<!--MaxUploadSlots?: number;-->
<!--MaxUploadSpeed?: number;-->
<!--MaxDownloadSpeed?: number;-->
<!--PrioritizeFirstLastPieces?: boolean;-->
<!--PreAllocateStorage?: boolean; // compact_allocation for v1-->
<!--DownloadLocation?: string;-->
<!--AutoManaged?: boolean;-->
<!--StopAtRatio?: boolean;-->
<!--StopRatio?: number;-->
<!--RemoveAtRatio?: number;-->
<!--MoveCompleted?: boolean;-->
<!--MoveCompletedPath?: string;-->
<!--AddPaused?: boolean;-->

<p-accordion>
  <p-accordionTab header="Torrent Options" [selected]="false">
    <ng-template pTemplate="content">
      <div class="p-grid p-fluid">
        <div class="p-field p-col-12 p-md-3">
          <label for="o-maxDownloadSpeed">Max Download Speed</label>
          <p-inputNumber inputId="o-maxDownloadSpeed" [(ngModel)]="config.MaxDownloadSpeed" [min]="-1"
                         placeholder="KB/s"></p-inputNumber>
        </div>

        <div class="p-field p-col-12 p-md-3">
          <label for="o-maxUploadSpeed">Max Upload Speed</label>
          <p-inputNumber inputId="o-maxUploadSpeed" [(ngModel)]="config.MaxUploadSpeed" [min]="-1"
                         placeholder="KB/s"></p-inputNumber>
        </div>
      </div>

      <div class="p-grid p-fluid">
        <div class="p-field-checkbox p-col-12 p-md-3">
          <p-checkbox inputId="o-addPaused" binary="true" [(ngModel)]="config.AddPaused">
          </p-checkbox>
          <label for="o-addPaused">Add Paused</label>
        </div>

        <div class="p-field p-col-12 p-md-3">
          <label for="o-maxConnections">Max Connections</label>
          <p-inputNumber inputId="o-maxConnections" [(ngModel)]="config.MaxConnections">
          </p-inputNumber>
        </div>

        <div class="p-field p-col-12 p-md-3">
          <label for="o-maxUploadSlots">Max Upload Slots</label>
          <p-inputNumber inputId="o-maxUploadSlots" [(ngModel)]="config.MaxUploadSlots">
          </p-inputNumber>
        </div>

        <div class="p-field-checkbox p-col-12 p-md-3">
          <p-checkbox inputId="o-firstLastPieces" binary="true" [(ngModel)]="config.PrioritizeFirstLastPieces">
          </p-checkbox>
          <label for="o-firstLastPieces">Prioritise First and Last Pieces</label>
        </div>

        <div class="p-field-checkbox p-col-12 p-md-3">
          <p-checkbox inputId="o-preAllocate" binary="true" [(ngModel)]="config.PreAllocateStorage">
          </p-checkbox>
          <label for="o-preAllocate">Pre-Allocate Storage</label>
        </div>

      </div>
      <div class="p-grid p-fluid">
        <div class="p-field-checkbox p-col-12 p-md-3">
          <p-checkbox inputId="o-stopAtRatio" binary="true" [(ngModel)]="config.StopAtRatio"
                      (ngModelChange)="unsetFields($event, 'StopAtRatio', 'StopRatio', 'RemoveAtRatio')">
          </p-checkbox>
          <label for="o-stopAtRatio">Stop At Ratio</label>

        </div>

        <div class="p-field p-col-12 p-md-3">
          <label for="o-stopRatio">Stop Ratio</label>
          <p-inputNumber inputId="o-stopRatio" [(ngModel)]="config.StopRatio" [disabled]="!config.StopAtRatio">
          </p-inputNumber>
        </div>

        <div class="p-field p-col-12 p-md-3">
          <label for="o-removeAtRatio">Remove At Ratio</label>
          <p-inputNumber inputId="o-removeAtRatio" [(ngModel)]="config.RemoveAtRatio" [min]="config.StopRatio"
                         [disabled]="!config.StopRatio && config.StopRatio != 0">
          </p-inputNumber>
        </div>

      </div>
      <div class="p-grid p-fluid">

        <div class="p-field p-col-12">
          <label for="o-downloadLocation">Download Location</label>
          <input pInputText inputId="o-downloadLocation" [(ngModel)]="config.DownloadLocation">
        </div>

      </div>
      <div class="p-grid p-fluid">
        <div class="p-field-checkbox p-col-12 p-md-3">
          <p-checkbox inputId="o-moveCompleted" binary="true" [(ngModel)]="config.MoveCompleted"
                      (ngModelChange)="unsetFields($event, 'MoveCompleted', 'MoveCompletedPath')">
          </p-checkbox>
          <label for="o-moveCompleted">Move Completed</label>

        </div>

        <div class="p-field p-col-12 p-md-9">
          <label for="o-moveCompletedPath">Moved Completed Path</label>
          <input pInputText inputId="o-moveCompletedPath" [(ngModel)]="config.MoveCompletedPath"
                 [disabled]="!config.MoveCompleted">
        </div>

      </div>
    </ng-template>

  </p-accordionTab>
</p-accordion>


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.scss
================================================
:host {
  display: block;
  margin: 1rem 0;
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AddTorrentConfigComponent } from './add-torrent-config.component';

describe('AddTorrentConfigComponent', () => {
  let component: AddTorrentConfigComponent;
  let fixture: ComponentFixture<AddTorrentConfigComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTorrentConfigComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AddTorrentConfigComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.ts
================================================
import {Component, Input} from '@angular/core';
import {TorrentOptions} from '../../../api.service';

@Component({
  selector: 't-add-torrent-config',
  templateUrl: './add-torrent-config.component.html',
  styleUrls: ['./add-torrent-config.component.scss']
})
export class AddTorrentConfigComponent {
  @Input('config') config: TorrentOptions;

  constructor() {
  }

  public unsetFields($event: boolean, ...fields: (keyof TorrentOptions)[]): void {
    if (!$event) {
      for (const field of fields) {
        // @ts-ignore
        this.config[field] = null;
      }
    }
  }
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-dialog-component.ts
================================================
import {AddTorrent, AddTorrentRequest, ApiException, ApiService, TorrentOptions} from '../../api.service';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Injector} from '@angular/core';
import {catchError, finalize} from 'rxjs/operators';
import {throwError} from 'rxjs';
import {Message} from 'primeng/api';

export class AddTorrentDialogComponentDirective<T> {
  public static DefaultIcon = 'far fa-plus-square';

  public config: TorrentOptions;
  public submitIcon = AddTorrentDialogComponentDirective.DefaultIcon;
  public submitIsDisabled = false;
  public errorMessages: Message[] = [];

  api: ApiService;
  ref: DynamicDialogRef;
  data: Partial<T>;

  constructor(injector: Injector) {
    this.api = injector.get(ApiService) as ApiService;
    this.ref = injector.get(DynamicDialogRef) as DynamicDialogRef;
    this.data = (injector.get(DynamicDialogConfig) as DynamicDialogConfig).data || {};
    this.config = {
      MaxDownloadSpeed: -1,
      MaxUploadSpeed: -1,
    };
  }

  public close(): void {
    this.ref.close(false);
  }

  public submit(opt: AddTorrent): void {
    const req: AddTorrentRequest = Object.assign({Options: this.config}, opt);

    this.submitIcon = 'fa-spin fas fa-spinner';
    this.submitIsDisabled = true;
    this.errorMessages.splice(0);

    this.api.add(req).pipe(
      catchError((err: ApiException) => {
        // Catch Api exceptions and push them to the messages stack
        this.errorMessages = [err.message];
        return throwError(err);

      }),
      finalize(() => {
        this.submitIcon = AddTorrentDialogComponentDirective.DefaultIcon;
        this.submitIsDisabled = false;
      })
    ).subscribe(
      response => this.ref.close(response.ID)
    );
  }
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.html
================================================
<p-fileUpload name="torrent" customUpload="true"
              [disabled]="submitIsDisabled"
              (uploadHandler)="onSubmit($event)"
              (onClear)="onResetErrors()"
></p-fileUpload>

<t-add-torrent-config [config]="config"></t-add-torrent-config>

<p-messages [(value)]="errorMessages"></p-messages>

<button pButton class="p-button-danger" label="Cancel" icon="fas fa-times" (click)="close()"></button>


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.scss
================================================


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AddTorrentFileInputComponent } from './add-torrent-file-input.component';

describe('AddTorrentFileInputComponent', () => {
  let component: AddTorrentFileInputComponent;
  let fixture: ComponentFixture<AddTorrentFileInputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTorrentFileInputComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AddTorrentFileInputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.ts
================================================
import {Component, Injector} from '@angular/core';
import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';

@Component({
  selector: 't-add-torrent-file-input',
  templateUrl: './add-torrent-file-input.component.html',
  styleUrls: ['./add-torrent-file-input.component.scss']
})
export class AddTorrentFileInputComponent extends AddTorrentDialogComponentDirective<null> {
  constructor(injector: Injector) {
    super(injector);
  }

  /**
   * Converts an array buffer to a Base64 encoded string
   */
  private arrayBufferToBase64(buffer: ArrayBuffer): string {
    let binary = '';
    const bytes = new Uint8Array(buffer);
    const len = bytes.byteLength;
    for (let i = 0; i < len; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary);
  }

  onResetErrors(): void {
    this.errorMessages = [];
  }

  onSubmit($event: { files: Array<File> }): void {
    const file = $event.files[0];

    file.arrayBuffer().then(
      buffer => {
        // Encode the file as Base64 and submit to the server
        const encoded = this.arrayBufferToBase64(buffer);
        this.submit({
          Type: 'file',
          URI: file.name,
          Data: encoded,
        });
      },
      err => {
        this.errorMessages = [{
          severity: 'error',
          summary: 'Error',
          detail: err.toString(),
        }];
      }
    );
  }
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.html
================================================
<span class="p-input-icon-left">
    <i class="fas fa-magnet"></i>
    <input type="text" pInputText placeholder="Magnet URL" [(ngModel)]="url" autofocus/>
</span>

<t-add-torrent-config [config]="config"></t-add-torrent-config>

<p-messages [(value)]="errorMessages"></p-messages>

<div class="p-d-flex p-flex-row p-justify-between">
  <button pButton label="Add" [icon]="submitIcon" [disabled]="submitIsDisabled" (click)="onSubmit()"></button>
  <button pButton class="p-button-danger" label="Cancel" icon="fas fa-times" (click)="close()"></button>
</div>


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.scss
================================================
span.p-input-icon-left, input {
  width: 100%;
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AddTorrentMagnetInputComponent } from './add-torrent-magnet-input.component';

describe('AddTorrentMagnetInputComponent', () => {
  let component: AddTorrentMagnetInputComponent;
  let fixture: ComponentFixture<AddTorrentMagnetInputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTorrentMagnetInputComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AddTorrentMagnetInputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.ts
================================================
import {Component, Injector} from '@angular/core';
import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';

export interface MagnetInput {
  url: string;
}

@Component({
  selector: 't-add-torrent-magnet-input',
  templateUrl: './add-torrent-magnet-input.component.html',
  styleUrls: ['./add-torrent-magnet-input.component.scss']
})
export class AddTorrentMagnetInputComponent extends AddTorrentDialogComponentDirective<MagnetInput> {
  url: string;

  constructor(injector: Injector) {
    super(injector);
    this.url = this.data.url;
  }

  onSubmit(): void {
    this.submit({Type: 'magnet', URI: this.url});
  }
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.html
================================================
<p-menu #menu [popup]="true" [model]="items" appendTo="body"></p-menu>



================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.scss
================================================


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AddTorrentMenuComponent } from './add-torrent-menu.component';

describe('AddTorrentMenuComponent', () => {
  let component: AddTorrentMenuComponent;
  let fixture: ComponentFixture<AddTorrentMenuComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTorrentMenuComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AddTorrentMenuComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.ts
================================================
import {Component, ViewChild} from '@angular/core';
import {MenuItem} from 'primeng/api';
import {Menu} from 'primeng/menu';
import {DialogService} from 'primeng/dynamicdialog';
import {AddTorrentMagnetInputComponent} from './add-torrent-magnet-input/add-torrent-magnet-input.component';
import {AddTorrentUrlInputComponent} from './add-torrent-url-input/add-torrent-url-input.component';
import {AddTorrentFileInputComponent} from './add-torrent-file-input/add-torrent-file-input.component';

@Component({
  selector: 't-add-torrent-menu',
  templateUrl: './add-torrent-menu.component.html',
  styleUrls: ['./add-torrent-menu.component.scss'],
})
export class AddTorrentMenuComponent {
  @ViewChild('menu') menu: Menu;

  items: MenuItem[] = [
    {
      label: 'Add Torrent',
      items: [
        {
          label: 'Magnet',
          icon: 'fas fa-magnet',
          command: () => this.openDialog(AddTorrentMagnetInputComponent)
        },
        {
          label: 'URL',
          icon: 'fas fa-link',
          command: () => this.openDialog(AddTorrentUrlInputComponent)
        },
        {
          label: 'File',
          icon: 'far fa-file-alt',
          command: () => this.openDialog(AddTorrentFileInputComponent)
        }
      ]
    }
  ];


  constructor(private dialogService: DialogService) {
  }

  public toggle($event): void {
    this.menu.toggle($event);
  }

  private openDialog(component: any): void {
    this.dialogService.open(component, {
      showHeader: false,
      closable: true,
      closeOnEscape: true,
      dismissableMask: true,
      styleClass: 't-dialog-responsive'
    });
  }

}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.html
================================================
<span class="p-input-icon-left">
    <i class="fas fa-link"></i>
    <input type="text" pInputText placeholder="Torrent URL" [(ngModel)]="url" autofocus/>
</span>

<t-add-torrent-config [config]="config"></t-add-torrent-config>

<p-messages [(value)]="errorMessages"></p-messages>

<div class="p-d-flex p-flex-row p-justify-between">
  <button pButton label="Add" [icon]="submitIcon" [disabled]="submitIsDisabled" (click)="onSubmit()"></button>
  <button pButton class="p-button-danger" label="Cancel" icon="fas fa-times" (click)="close()"></button>
</div>



================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.scss
================================================
span.p-input-icon-left, input {
  width: 100%;
}


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AddTorrentUrlInputComponent } from './add-torrent-url-input.component';

describe('AddTorrentUrlInputComponent', () => {
  let component: AddTorrentUrlInputComponent;
  let fixture: ComponentFixture<AddTorrentUrlInputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTorrentUrlInputComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(AddTorrentUrlInputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.ts
================================================
import {Component, Injector} from '@angular/core';
import {AddTorrentDialogComponentDirective} from '../add-torrent-dialog-component';

export interface URLInput {
  url: string;
}

@Component({
  selector: 't-add-torrent-url-input',
  templateUrl: './add-torrent-url-input.component.html',
  styleUrls: ['./add-torrent-url-input.component.scss']
})
export class AddTorrentUrlInputComponent extends AddTorrentDialogComponentDirective<URLInput> {
  url: string;

  constructor(injector: Injector) {
    super(injector);
    this.url = this.data.url;
  }

  onSubmit(): void {
    this.submit({Type: 'url', URI: this.url});
  }
}


================================================
FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.html
================================================
<form (ngSubmit)="onSubmit()">
  <div class="p-grid p-fluid">
    <div class="p-field p-col-12">
      <input #input="ngModel" pInputText type="password" placeholder="Enter API Key" required autofocus [(ngModel)]="key" >
    </div>
  </div>

  <div class="p-d-flex p-flex-row p-justify-between">
    <button pButton class="p-button-success" type="submit" [disabled]="!input.valid">Submit</button>
  </div>
</form>


================================================
FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.scss
================================================


================================================
FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ApiKeyDialogComponent } from './api-key-dialog.component';

describe('ApiKeyDialogComponent', () => {
  let component: ApiKeyDialogComponent;
  let fixture: ComponentFixture<ApiKeyDialogComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ApiKeyDialogComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ApiKeyDialogComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.ts
================================================
import { Component } from '@angular/core';
import {DynamicDialogRef} from "primeng/dynamicdialog";

@Component({
  selector: 't-api-key-dialog',
  templateUrl: './api-key-dialog.component.html',
  styleUrls: ['./api-key-dialog.component.scss']
})
export class ApiKeyDialogComponent{
  key: string;

  constructor(private ref: DynamicDialogRef) { }

  onSubmit() {
    this.ref.close(this.key)
  }

}


================================================
FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.html
================================================
<div *ngIf="$breakpoint | async; else content">

  <p-overlayPanel #panel appendTo="body">
    <div class="p-d-flex p-flex-column" [classList]="styleClass">
      <ng-content *ngTemplateOutlet="content">
      </ng-content>
    </div>
  </p-overlayPanel>


  <button pButton pRipple (click)="panel.toggle($event)" type="button" [icon]="icon"></button>
  <t-activity-marker *ngIf="contentActivated"></t-activity-marker>
</div>

<ng-template #content>
  <ng-content>
  </ng-content>
</ng-template>




================================================
FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.scss
================================================


================================================
FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { BreakpointOverlayComponent } from './breakpoint-overlay.component';

describe('BreakpointOverlayComponent', () => {
  let component: BreakpointOverlayComponent;
  let fixture: ComponentFixture<BreakpointOverlayComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ BreakpointOverlayComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(BreakpointOverlayComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.ts
================================================
import {Component, Input} from '@angular/core';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 't-breakpoint-overlay',
  templateUrl: './breakpoint-overlay.component.html',
  styleUrls: ['./breakpoint-overlay.component.scss']
})
export class BreakpointOverlayComponent {
  @Input('icon') icon: string;
  @Input('styleClass') styleClass: string;
  @Input('contentActivated') contentActivated = false;

  $breakpoint: Observable<boolean>;

  constructor(breakpointObserver: BreakpointObserver) {
    // Breakpoint observable to switch the view to small handset view
    this.$breakpoint = breakpointObserver.observe(
      [Breakpoints.Small, Breakpoints.HandsetPortrait]
    ).pipe(
      map(state => state.matches)
    );
  }

}


================================================
FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.html
================================================
<div class="t-connectivity" [class.connected]="connected" [class.disconnected]="!connected"
     [class.transition]="animate" [class.transition-closing]="closing" *ngIf="$visible | async">
  <span>{{ connected ? 'connected' : 'not connected'}}</span>
</div>


================================================
FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.scss
================================================

.t-connectivity {
  position: fixed;
  bottom: 0;
  width: 100%;
  z-index: 1000;
  text-align: center;
  padding: .2rem;
  color: #fff;
  font-weight: 600;

  &.connected {
    background-color: #5ab132;
  }

  &.disconnected {
    background-color: #d45e6a
  }


  &.transition {
    transition: 1s;

    &.transition-closing {
      transform: translateY(100%);
    }
  }


}




================================================
FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ConnectivityStatusComponent } from './connectivity-status.component';

describe('ConnectivityStatusComponent', () => {
  let component: ConnectivityStatusComponent;
  let fixture: ComponentFixture<ConnectivityStatusComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ConnectivityStatusComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ConnectivityStatusComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.ts
================================================
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
import {concat, Observable, of, Subject, timer} from 'rxjs';
import {map, switchMap, tap} from 'rxjs/operators';

@Component({
  selector: 't-connectivity-status',
  templateUrl: './connectivity-status.component.html',
  styleUrls: ['./connectivity-status.component.scss']
})
export class ConnectivityStatusComponent implements OnChanges {
  @Input('connected') connected: boolean;

  $connected: Subject<boolean>;
  $visible: Observable<boolean>;

  animate = false;
  closing = false;

  constructor() {
    this.$connected = new Subject<boolean>();
    this.$visible = this.$connected.pipe(
      tap(_ => {
        this.animate = false;
        this.closing = false;
      }),
      switchMap(ok => {
        if (!ok) {
          // If not connected then make the status visible
          return of(true);
        }

        // Otherwise, if connected
        // Make the status visible, then after 2 seconds disable the visibility
        return concat(
          of(true),
          // After two seconds begin the transition
          timer(2000, 0).pipe(
            switchMap(_ => {
              this.animate = true;
              this.closing = true;
              return timer(1000, 0).pipe(
                map(_ => false)
              );
            })
          )
        );
      })
    );
  }

  ngOnChanges(changes: SimpleChanges): void {
    const connectedChange = changes.connected;

    // Connectivity change not made in this cycle
    if (typeof connectedChange === 'undefined') {
      return;
    }

    if (connectedChange.currentValue === true && connectedChange.isFirstChange()) {
      // Not interested in a successful initial connection
      return;
    }

    this.$connected.next(connectedChange.currentValue);
  }
}


================================================
FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.html
================================================
<p-overlayPanel #overlay appendTo="body">
  <ng-template pTemplate>
      <p-progressSpinner *ngIf="processing"></p-progressSpinner>
      <div *ngIf="!processing" class="p-d-flex p-flex-column">
        <button (click)="onRemove(false)" type="button" pButton pRipple class="p-button t-overlay-button t-button-alt"
                label="Remove"></button>
        <button (click)="onRemove(true)" type="button" pButton pRipple class="p-button p-button-danger t-overlay-button"
                label="Remove With Data"></button>
      </div>


  </ng-template>
</p-overlayPanel>


================================================
FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.scss
================================================
.t-overlay-button:not(:last-child) {
  margin-bottom: 1rem;
}


================================================
FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { DeleteTorrentOverlayComponent } from './delete-torrent-overlay.component';

describe('DeleteTorrentOverlayComponent', () => {
  let component: DeleteTorrentOverlayComponent;
  let fixture: ComponentFixture<DeleteTorrentOverlayComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ DeleteTorrentOverlayComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(DeleteTorrentOverlayComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.ts
================================================
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {ApiService} from '../../api.service';
import {OverlayPanel} from 'primeng/overlaypanel';
import {finalize, mergeMap} from 'rxjs/operators';
import {from} from 'rxjs';

@Component({
  selector: 't-delete-torrent-overlay',
  templateUrl: './delete-torrent-overlay.component.html',
  styleUrls: ['./delete-torrent-overlay.component.scss']
})
export class DeleteTorrentOverlayComponent {
  @Input('torrents') torrents: string[];
  @Output('removed') removed = new EventEmitter<boolean>();


  @ViewChild('overlay') overlay: OverlayPanel;


  processing = false;

  constructor(private api: ApiService) {
  }

  public toggle($event): void {
    this.overlay.toggle($event, $event.target);
  }

  onRemove(withData: boolean): void {
    this.processing = true;

    from(this.torrents).pipe(
      mergeMap(hash => this.api.removeTorrent(withData, hash)),
      finalize(() => {
        this.processing = false;
        this.overlay.hide();
        this.removed.emit(true);
      })
    ).subscribe(
      _ => console.log('torrent deleted'),
    );
  }
}


================================================
FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.html
================================================
<p>The Deluge plugin <b>{{name}}</b> must be enabled to continue</p>

<div class="p-d-flex p-flex-row p-justify-between">
  <button pButton [label]="inProgress ? 'Enabling...' : 'Enable'" type="button" [disabled]="inProgress"
          (click)="onSubmit()" class="p-button-success"></button>
</div>


================================================
FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.scss
================================================


================================================
FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { PluginEnableComponent } from './plugin-enable.component';

describe('PluginEnableComponent', () => {
  let component: PluginEnableComponent;
  let fixture: ComponentFixture<PluginEnableComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ PluginEnableComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(PluginEnableComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.ts
================================================
import { Component, OnInit } from '@angular/core';
import {DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog";
import {ApiService} from "../../api.service";

@Component({
  selector: 't-plugin-enable',
  templateUrl: './plugin-enable.component.html',
  styleUrls: ['./plugin-enable.component.scss']
})
export class PluginEnableComponent implements OnInit {
  name: string;

  inProgress: boolean = false;

  constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig, private api: ApiService) {
    this.name = config.data.name;
  }

  ngOnInit(): void {
  }

  onSubmit(): void {
    this.inProgress = true;
    this.api.enablePlugin(this.name).subscribe(
      _ => {
        this.inProgress = false;
        this.ref.close(true);
      },
    )
  }

}


================================================
FILE: frontend/src/app/components/session-status/session-status.component.html
================================================
<div class="t-session-status">
  <span><i class="fas fa-users"></i>{{sessionStatus.NumPeers}}/{{sessionStatus.DhtNodes}}</span>

  <span><i class="fas fa-arrow-down"></i>{{sessionStatus.DownloadRate | filesize}}s</span>
  <span><i class="fas fa-arrow-up"></i>{{sessionStatus.UploadRate | filesize}}s</span>

  <span><i class="fas fa-download"></i>{{sessionStatus.TotalDownload | filesize}}</span>
  <span><i class="fas fa-upload"></i>{{sessionStatus.TotalUpload | filesize}}</span>

  <span><i class="fas fa-hdd"></i>{{diskSpace?.FreeBytes | filesize}}</span>
</div>


================================================
FILE: frontend/src/app/components/session-status/session-status.component.scss
================================================
.t-session-status {
  width: 100%;
  background: #343e4d;
  color: rgba(255, 255, 255, 0.5);
  padding: 4px 1rem;
  display: flex;
  flex-direction: row;
  justify-content: right;

  font-weight: 500;
  font-size: .9rem;

  & > span {
    &:not(:last-child) {
      margin-right: 1rem;
    }
  }

  & i {
    opacity: .6;
    margin-right: 4px;
  }
}


@media only screen and (max-width: 750px) {
  .t-session-status {
    justify-content: center;
  }
}


================================================
FILE: frontend/src/app/components/session-status/session-status.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { SessionStatusComponent } from './session-status.component';

describe('SessionStatusComponent', () => {
  let component: SessionStatusComponent;
  let fixture: ComponentFixture<SessionStatusComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ SessionStatusComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SessionStatusComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/session-status/session-status.component.ts
================================================
import {Component, Input, OnInit} from '@angular/core';
import {DiskSpace, SessionStatus} from '../../api.service';

@Component({
  selector: 't-session-status',
  templateUrl: './session-status.component.html',
  styleUrls: ['./session-status.component.scss']
})
export class SessionStatusComponent implements OnInit {
  @Input() sessionStatus: SessionStatus;
  @Input() diskSpace: DiskSpace;

  constructor() {
  }

  ngOnInit(): void {
  }

}


================================================
FILE: frontend/src/app/components/torrent/torrent.component.html
================================================
<ng-template #torrentInfinite>
  ∞
</ng-template>

<div class="p-card p-component">
  <div class="p-card-body">
    <div class="p-d-flex p-flex-column p-flex-md-row" style="padding: 0">
      <div class="p-d-flex p-flex-column p-md-8">
        <div class="p-card-title">
          {{torrent.Name}}
        </div>
        <div class="p-card-subtitle t-torrent-detail">
          <t-torrent-state [state]="torrent.State"></t-torrent-state>
          <t-torrent-label [hash]="hash" [label]="label"></t-torrent-label>
        </div>
      </div>


    </div>

    <div class="p-d-flex p-flex-column t-bar p-col-12">
      <div class="p-d-flex p-flex-row p-align-center t-torrent-progress">
        <span pTooltip="ETA">
          <i class="far fa-clock"></i>
          <ng-container *ngIf="torrent.ETA else torrentInfinite">
                      {{torrent.ETA | amDuration: 'seconds'}}
          </ng-container>
        </span>
        <div class="spacer"></div>

        <span pTooltip="Total size">
          <i class="far fa-hdd"></i>
          {{torrent.TotalSize | filesize}}
        </span>
      </div>

      <p-progressBar [value]="torrent.Progress | number: '1.0-2'">

      </p-progressBar>
    </div>

    <div class="p-d-flex p-flex-row p-justify-between t-details">
      <div class="t-info p-d-flex p-flex-row p-flex-wrap">
        <span pTooltip="Download speed">
          <i class="fas fa-download"></i>
          {{torrent.DownloadPayloadRate |filesize}}s
        </span>

        <span pTooltip="Upload speed">
          <i class="fas fa-upload"></i>
          {{torrent.UploadPayloadRate |filesize}}s
        </span>

        <span pTooltip="Seeding time">
          <i class="fas fa-leaf"></i>
          {{torrent.SeedingTime === 0 ? '0s' : (torrent.SeedingTime | amDuration: 'seconds')}}
        </span>

        <span pTooltip="Seed ratio">
          <i class="fas fa-percentage"></i>
          {{torrent.Ratio < 0 ? 0 : torrent.Ratio | number : '1.0-2'}}
        </span>
      </div>

      <div class="t-controls">
        <button pButton pRipple (click)="onChangeState()" type="button"
                [icon]="torrent.State == 'Paused' ? 'fas fa-play' : 'fas fa-pause'"
                class="p-button-text"
                [pTooltip]="torrent.State == 'Paused' ? 'Resume' : 'Pause'"></button>

        <t-delete-torrent-overlay #remove [torrents]="[hash]"
                                  (removed)="removed.emit($event)"></t-delete-torrent-overlay>
        <button pButton pRipple (click)="remove.toggle($event)" type="button" icon="far fa-trash-alt"
                class="p-button-text p-button-danger"
                pTooltip="Remove"></button>
      </div>

    </div>

  </div>
</div>


================================================
FILE: frontend/src/app/components/torrent/torrent.component.scss
================================================
.p-card-body {
  flex-grow: 1;
  margin-right: .4rem;
  padding: .4rem;

  & .p-card-title {
    font-size: 1rem;
    word-break: break-word;
    font-weight: 700;
    margin-bottom: 0.2rem;
  }

  & .p-card-subtitle {
    font-weight: 400;
    color: rgba(255, 255, 255, 0.6);
    margin-bottom: 0.4rem;
  }
}


.p-lg-8, .p-lg-4 {
  padding: 0;
}

.t-bar {
  flex-grow: 1;
  justify-content: center;
}

.t-controls {
  text-align: right;
}

.t-torrent-progress {
  margin-bottom: 12px;
  opacity: .6;

  & .spacer {
    flex-grow: 1;
  }

  & i {
    opacity: .4;
    vertical-align: middle;
    margin-right: 4px;
  }
}

.t-torrent-eta {

}

.t-info {
  opacity: .4;
  padding: .4rem;

  & span {
    word-break: break-all;

    & i {
      margin-right: 2px;
    }

    &:not(:last-child) {
      margin-right: 18px;
    }
  }
}

.t-torrent-detail {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;

  & > *:not(:last-child) {
    margin-right: 18px;
  }
}


================================================
FILE: frontend/src/app/components/torrent/torrent.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentComponent } from './torrent.component';

describe('TorrentComponent', () => {
  let component: TorrentComponent;
  let fixture: ComponentFixture<TorrentComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent/torrent.component.ts
================================================
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {ApiService, Torrent} from '../../api.service';
import {Observable} from 'rxjs';
import {switchMap, take} from 'rxjs/operators';

@Component({
  selector: 't-torrent',
  templateUrl: './torrent.component.html',
  styleUrls: ['./torrent.component.scss']
})
export class TorrentComponent implements OnInit {
  @Input('hash') hash: string;
  @Input('torrent') torrent: Torrent;
  @Input('label') label: string;
  @Output('removed') removed = new EventEmitter<boolean>();

  constructor(private api: ApiService) {
  }

  ngOnInit(): void {
  }

  private refreshAfter(action: Observable<any>): void {
    action.pipe(
      switchMap(_ => this.api.torrent(this.hash)),
      take(1),
    ).subscribe(
      torrent => this.torrent = torrent
    );
  }

  public onPause(): void {
    this.refreshAfter(this.api.pause(this.hash));
  }

  public onResume(): void {
    this.refreshAfter(this.api.resume(this.hash));
  }

  public onChangeState(): void {
    if (this.torrent.State === 'Paused') {
      this.onResume();
      return;
    }

    this.onPause();
  }
}


================================================
FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.html
================================================
<t-torrent [hash]="id" [torrent]="torrent | async" (removed)="onRemoved()"></t-torrent>


================================================
FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.scss
================================================


================================================
FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentDetailsDialogComponent } from './torrent-details-dialog.component';

describe('TorrentDetailsDialogComponent', () => {
  let component: TorrentDetailsDialogComponent;
  let fixture: ComponentFixture<TorrentDetailsDialogComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentDetailsDialogComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentDetailsDialogComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.ts
================================================
import {Component, Injectable} from '@angular/core';
import {Torrent} from '../../api.service';
import {Observable} from 'rxjs';
import {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';

@Component({
  selector: 't-torrent-details-dialog',
  templateUrl: './torrent-details-dialog.component.html',
  styleUrls: ['./torrent-details-dialog.component.scss']
})
export class TorrentDetailsDialogComponent {
  id: string;
  torrent: Observable<Torrent>;

  constructor(private ref: DynamicDialogRef, private config: DynamicDialogConfig) {
    this.id = config.data.id;
    this.torrent = config.data.torrent;
  }

  public onRemoved(): void {
    this.ref.close();
  }
}


@Injectable({
  providedIn: 'root',
})
export class TorrentDetailsDialogService {
  constructor(private dialogService: DialogService) {
  }

  public open(id: string, torrent: Observable<Torrent>): void {
    this.dialogService.open(TorrentDetailsDialogComponent, {
      showHeader: false,
      closable: true,
      closeOnEscape: true,
      dismissableMask: true,
      styleClass: 't-dialog-responsive',
      data: {
        id,
        torrent
      }
    });
  }
}


================================================
FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.html
================================================
<ng-template #useExistingLabel let-suggestion>
  <span class="t-existing-label">Use <b>{{suggestion.value}}</b></span>
  <button pButton type=button (click)="onDeleteLabel($event, suggestion.value)" pTooltip="Delete this label"
          icon="far fa-trash-alt" class="t-delete-label-button p-button-rounded p-button-text p-button-sm"></button>
</ng-template>

<ng-template #createNewLabel let-suggestion>
  <span class="t-new-label">Create and use a new label named <b>{{suggestion.value}}</b></span>
</ng-template>

<ng-template #clearCurrentLabel>
  <span class="t-new-label">Clear the current label <b>{{initialLabel}}</b></span>
</ng-template>


<div class="p-grid p-fluid">
  <div class="p-field p-col-12">
    <input pInputText autofocus [(ngModel)]="label" (ngModelChange)="query$.next($event)"
           placeholder="Start typing a new label">
  </div>

  <div class="p-col-12">
    <button *ngFor="let suggestion of suggestions$ | async"
            class="p-button p-component p-d-flex p-flex-row p-justify-between t-suggestion-button" type="button"
            (click)="onApplySuggestion(suggestion)">
      <ng-container
        [ngTemplateOutlet]="suggestion.new ? createNewLabel : suggestion.clear ? clearCurrentLabel : useExistingLabel"
        [ngTemplateOutletContext]="{$implicit: suggestion}">
      </ng-container>
    </button>
  </div>

</div>


================================================
FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.scss
================================================
span.t-new-label {
  font-style: italic;
}

button.t-suggestion-button {
  margin-bottom: 8px;
}

button.p-button.t-delete-label-button {
  height: 1rem;
}


================================================
FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentEditLabelDialogComponent } from './torrent-edit-label-dialog.component';

describe('TorrentEditLabelDialogComponent', () => {
  let component: TorrentEditLabelDialogComponent;
  let fixture: ComponentFixture<TorrentEditLabelDialogComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentEditLabelDialogComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentEditLabelDialogComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.ts
================================================
import {Component, Injectable} from '@angular/core';
import {DialogService, DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog";
import {ApiService} from "../../api.service";
import {BehaviorSubject, combineLatest, Observable, of} from "rxjs";
import {delay, map, shareReplay, switchMap} from "rxjs/operators";

interface LabelSuggestion {
  value: string;
  new: boolean;
  clear: boolean;
}

@Component({
  selector: 't-torrent-edit-label-dialog',
  templateUrl: './torrent-edit-label-dialog.component.html',
  styleUrls: ['./torrent-edit-label-dialog.component.scss']
})
export class TorrentEditLabelDialogComponent {
  id: string
  label: string;
  initialLabel: string;

  query$ = new BehaviorSubject<string>('');
  refresh$ = new BehaviorSubject<void>(null);
  labels$: Observable<string[]>;
  suggestions$: Observable<LabelSuggestion[]>;

  constructor(private ref: DynamicDialogRef, config: DynamicDialogConfig, private api: ApiService) {
    this.id = config.data.id;
    this.label = '';
    this.initialLabel = config.data.currentLabel ? config.data.currentLabel : '';

    this.labels$ = this.refresh$.pipe(
      switchMap(_ => this.api.labels()),
      shareReplay(1)
    );

    this.suggestions$ = combineLatest([this.labels$, this.query$]).pipe(
      map(([labels, query]) => {
        const suggestions: LabelSuggestion[] = [];
        const preparedQuery = query.toLocaleLowerCase();

        let hasExactMatch = false;
        for (const label of labels) {

          // Don't suggest the initial label
          if (!!this.initialLabel && preparedQuery === this.initialLabel) {
            hasExactMatch = true;
            continue
          }

          // Suggest an existing label if it partially matches
          if (!preparedQuery || label.includes(preparedQuery)) {
            // Only suggest this label if there was no initial label, or if this label does not match the initial
            if (this.initialLabel === '' || label !== this.initialLabel) {
              suggestions.push({value: label, new: false, clear: false})
            }
          }

          if (label === preparedQuery) {
            hasExactMatch = true
          }
        }

        // If there is a query to suggest, and there's no exact match then suggest creating a new label
        if (preparedQuery && !hasExactMatch) {
          suggestions.push({value: preparedQuery, new: true, clear: false})
        }

        // If there was an initial label, then suggest clearing it
        if (!!this.initialLabel) {
          suggestions.push({value: '', new: false, clear: true})
        }

        return suggestions
      })
    )
  }

  onDeleteLabel($event: { preventDefault: () => void }, name: string): void {
    $event.preventDefault();

    this.api.deleteLabel(name).subscribe(
      _ => {
        if (name === this.label) {
          this.label = '';
        }

        this.refresh$.next(null)
      }
    )
  }

  onComplete($event: { query: string }) {
    this.query$.next($event.query)
  }

  onApplySuggestion(suggestion: LabelSuggestion): void {
    // Do nothing if the label is unchanged
    if (suggestion.value === this.initialLabel && !suggestion.new) {
      this.ref.close();
      return;
    }

    let prep$: Observable<void> = of(null)

    // If the label is not empty then check it is not one of the existing labels
    if (suggestion.value && suggestion.new) {
      prep$ = this.api.createLabel(suggestion.value).pipe(
        // Deluge has issues when a torrent label is set immediately after setting it
        delay(200)
      )
    }

    prep$.pipe(
      switchMap(_ => this.api.setTorrentLabel(this.id, {Label: suggestion.value}))
    ).subscribe(
      _ => this.ref.close(suggestion.value)
    )
  }
}

@Injectable({
  providedIn: 'root',
})
export class TorrentEditLabelService {
  constructor(private dialogService: DialogService) {
  }

  public open(id: string, currentLabel?: string): Observable<string> {
    const ref = this.dialogService.open(TorrentEditLabelDialogComponent, {
      header: 'Edit Label',
      showHeader: true,
      closable: true,
      closeOnEscape: true,
      dismissableMask: true,
      styleClass: 't-dialog-responsive',
      data: {
        id,
        currentLabel,
      }
    });

    return ref.onClose.pipe(
      map(value => typeof value === 'undefined' ? currentLabel : value)
    )
  }
}


================================================
FILE: frontend/src/app/components/torrent-label/torrent-label.component.html
================================================
<div (click)="onUpdateLabel()" class="t-label-container">
  <span class="t-label" pTooltip="Torrent label" *ngIf="label else noLabel">{{label}}</span>

</div>

<ng-template #noLabel>
  <span class="t-label-no-label">no label</span>
</ng-template>


================================================
FILE: frontend/src/app/components/torrent-label/torrent-label.component.scss
================================================
.t-label-container{
  cursor: pointer;
}

.t-label-no-label {
  font-style: italic;
  opacity: .4;
}

.t-label {
  &:before {
    content: "\f02b";
    font-family: 'Font Awesome 5 Free';
    font-weight: 900;
    margin-right: 4px;
  }
}


================================================
FILE: frontend/src/app/components/torrent-label/torrent-label.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentLabelComponent } from './torrent-label.component';

describe('TorrentLabelComponent', () => {
  let component: TorrentLabelComponent;
  let fixture: ComponentFixture<TorrentLabelComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentLabelComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentLabelComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent-label/torrent-label.component.ts
================================================
import {Component, Input} from '@angular/core';
import {TorrentEditLabelService} from "../torrent-edit-label-dialog/torrent-edit-label-dialog.component";

@Component({
  selector: 't-torrent-label',
  templateUrl: './torrent-label.component.html',
  styleUrls: ['./torrent-label.component.scss']
})
export class TorrentLabelComponent {
  @Input('hash') hash: string;
  @Input('label') label: string;

  constructor(private editLabelService: TorrentEditLabelService) { }

  onUpdateLabel(): void {
    this.editLabelService.open(this.hash, this.label).subscribe(
      newLabel => this.label = newLabel
    )
  }
}


================================================
FILE: frontend/src/app/components/torrent-search/torrent-search.component.html
================================================
<div class="p-d-flex p-flex-row t-search">
  <div class="p-inputgroup">
    <button type="button" pButton pRipple *ngIf="icon" [icon]="icon" (click)="onAdd()" class="p-button-success"></button>

    <input type="text" pInputText [(ngModel)]="searchText" placeholder="Search or Torrent URL" (ngModelChange)="onChange($event)">
    <button type="button" *ngIf="searchText" pButton pRipple icon="fas fa-times" (click)="onClear()"></button>
  </div>
</div>



================================================
FILE: frontend/src/app/components/torrent-search/torrent-search.component.scss
================================================



================================================
FILE: frontend/src/app/components/torrent-search/torrent-search.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentSearchComponent } from './torrent-search.component';

describe('TorrentSearchComponent', () => {
  let component: TorrentSearchComponent;
  let fixture: ComponentFixture<TorrentSearchComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentSearchComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentSearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent-search/torrent-search.component.ts
================================================
import {Component, EventEmitter, Output} from '@angular/core';
import {AddTorrentMagnetInputComponent} from '../add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component';
import {AddTorrentUrlInputComponent} from '../add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component';
import {DialogService} from 'primeng/dynamicdialog';


interface CT {
  type: any;
  icon: string;
}

const EntryComponents: { [k: string]: CT | undefined } = {
  search: undefined,
  magnet: {
    type: AddTorrentMagnetInputComponent,
    icon: 'fas fa-magnet',
  },
  url: {
    type: AddTorrentUrlInputComponent,
    icon: 'fas fa-link'
  },
};

@Component({
  selector: 't-torrent-search',
  templateUrl: './torrent-search.component.html',
  styleUrls: ['./torrent-search.component.scss']
})
export class TorrentSearchComponent {
  @Output('search') search = new EventEmitter<string>();

  searchText: string;
  icon: string;
  mode: keyof typeof EntryComponents = 'search';


  constructor(private dialogService: DialogService) {
  }


  private setTarget(target: keyof typeof EntryComponents): void {
    if (target === this.mode) {
      return;
    }

    if (target === 'search') {
      this.mode = 'search';
      this.icon = null;
      return;
    }

    // Otherwise this is a target url

    if (this.mode === 'search') {
      this.search.emit('');
    }

    this.mode = target;
    this.icon = EntryComponents[target].icon;
  }

  onClear(): void {
    this.mode = 'search';
    this.icon = null;
    this.searchText = '';
    this.search.emit('');
  }

  onChange($event: string): void {
    switch (true) {
      case $event.startsWith('magnet:'):
        this.setTarget('magnet');
        break;
      case $event.startsWith('http://') || $event.startsWith('https://'):
        this.setTarget('url');
        break;
      default:
        this.setTarget('search');
        this.search.emit(this.searchText);
    }
  }

  onAdd(): void {
    const component = EntryComponents[this.mode];
    const ref = this.dialogService.open(component.type, {
      showHeader: false,
      closable: true,
      closeOnEscape: true,
      dismissableMask: true,
      styleClass: 't-dialog-responsive',
      data: {
        url: this.searchText
      }
    });

    // When the modal is closed, if the modal
    // produces a non-nil result (a valid torrent hash)
    ref.onClose.subscribe(
      result => {
        if (!!result) {
          this.onClear();
        }
      }
    );
  }

}


================================================
FILE: frontend/src/app/components/torrent-state/torrent-state.component.html
================================================
<span class="t-state t-state-{{state}}">{{state}}</span>


================================================
FILE: frontend/src/app/components/torrent-state/torrent-state.component.scss
================================================
.t-state {
  &:before {
    content: "\f111";
    font-family: 'Font Awesome 5 Free';
    font-weight: 400;
    margin-right: 4px;
  }

  &.t-state-Queued {
    color: #adaf16;
  }

  &.t-state-Downloading {
    color: #5ab132;
  }

  &.t-state-Seeding {
    color: #8dd0ff;
  }

  &.t-state-Error {
    color: #d45e6a;
  }
}


================================================
FILE: frontend/src/app/components/torrent-state/torrent-state.component.spec.ts
================================================
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { TorrentStateComponent } from './torrent-state.component';

describe('TorrentStateComponent', () => {
  let component: TorrentStateComponent;
  let fixture: ComponentFixture<TorrentStateComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TorrentStateComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TorrentStateComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/components/torrent-state/torrent-state.component.ts
================================================
import {Component, Input} from '@angular/core';
import {State} from '../../api.service';

@Component({
  selector: 't-torrent-state',
  templateUrl: './torrent-state.component.html',
  styleUrls: ['./torrent-state.component.scss']
})
export class TorrentStateComponent {
  @Input('state') state: State;

  constructor() {
  }

}


================================================
FILE: frontend/src/app/environment.ts
================================================
import {InjectionToken} from "@angular/core";

export interface Environment {
  baseApiPath: string;
}

export const ENVIRONMENT = new InjectionToken<Environment>('app.environment');


================================================
FILE: frontend/src/app/focus.service.spec.ts
================================================
import { TestBed } from '@angular/core/testing';

import { FocusService } from './focus.service';

describe('FocusService', () => {
  let service: FocusService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(FocusService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/focus.service.ts
================================================
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class FocusService {
  private readonly $observer: BehaviorSubject<boolean>;

  constructor() {
    this.$observer = new BehaviorSubject<boolean>(true);

    document.addEventListener('visibilitychange', () => {
      this.$observer.next(document.visibilityState === 'visible');
    });

    // Safari does not emit an event for visibilityState == 'hidden'.
    // In this case we need to handle the 'pagehide' event.
    document.addEventListener('pagehide', () => this.$observer.next(false));
  }

  /**
   * Returns an observer that signals true when the window is focused
   * and false when the window is blurred.
   */
  public get observe(): Observable<boolean> {
    return this.$observer;
  }
}


================================================
FILE: frontend/src/app/order-by.pipe.spec.ts
================================================
import { OrderByPipe } from './order-by.pipe';

describe('OrderByPipe', () => {
  it('create an instance', () => {
    const pipe = new OrderByPipe();
    expect(pipe).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/order-by.pipe.ts
================================================
import {Pipe, PipeTransform} from '@angular/core';


@Pipe({
  name: 'orderBy'
})
export class OrderByPipe implements PipeTransform {

  transform<T>(values: Array<T>, field: undefined | keyof T, orderType: boolean): Array<T> {
    if (!field || !Array.isArray(values)) {
      // If no field is defined then return the values as-is
      return values;
    }

    return values.sort((a: T, b: T) => {
      const v0 = a[field];
      const v1 = b[field];

      if (v0 < v1) {
        return orderType ? -1 : 1;
      }

      if (v0 > v1) {
        return orderType ? 1 : -1;
      }

      return 0;
    });
  }

}


================================================
FILE: frontend/src/app/torrent-search.pipe.spec.ts
================================================
import { TorrentSearchPipe } from './torrent-search.pipe';

describe('TorrentSearchPipe', () => {
  it('create an instance', () => {
    const pipe = new TorrentSearchPipe();
    expect(pipe).toBeTruthy();
  });
});


================================================
FILE: frontend/src/app/torrent-search.pipe.ts
================================================
import {Pipe, PipeTransform} from '@angular/core';
import {Label, Torrent} from './api.service';

type LabelledTorrent = Label & Torrent;

@Pipe({
  name: 'torrentSearch'
})
export class TorrentSearchPipe implements PipeTransform {

  private filter(term: string): (t: LabelledTorrent) => boolean {
    return (t: LabelledTorrent): boolean => {

      switch (true) {
        case t.Name.toLowerCase().includes(term):
          return true;
        case t.State.toLowerCase().includes(term):
          return true;
        case t.DownloadLocation.toLowerCase().includes(term):
          return true;
        case t.TrackerHost.toLowerCase().includes(term):
          return true;
        case t.Label.toLowerCase().includes(term):
          return true;
      }

      return false;
    };
  }

  transform<T extends LabelledTorrent>(values: Array<T>, term: string): Array<T> {
    if (!values || !Array.isArray(values) || !term) {
      return values;
    }

    const predicate = this.filter(term.toLowerCase());
    return values.filter(predicate);
  }

}


================================================
FILE: frontend/src/assets/.gitkeep
================================================


================================================
FILE: frontend/src/environments/environment.prod.ts
================================================
export const environment = {
  production: true
};


================================================
FILE: frontend/src/environments/environment.ts
================================================
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.

export const environment = {
  production: false
};

/*
 * For easier debugging in development mode, you can import the following file
 * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
 *
 * This import should be commented out in production mode because it will have a negative impact
 * on performance if an error is thrown.
 */
// import 'zone.js/dist/zone-error';  // Included with Angular CLI.


================================================
FILE: frontend/src/icons.scss
================================================
/**
Prime Icons Adaptor.
Adapts Prime Icons into FontAwesome icons
 */

.fas, .far {
  writing-mode: vertical-lr;
}

.pi {
  font-family: 'Font Awesome 5 Free';
  font-style: normal;
  font-weight: 900;

  &.pi-chevron-up::before {
    content: '\f077'
  }

  &.pi-chevron-down::before {
    content: '\f078'
  }

  &.pi-chevron-right::before {
    content: '\f054';
  }

  &.pi-check::before {
    content: '\f00c';
  }

  &.pi-plus:before {
    content: '\f067';
  }

  &.pi-upload:before {
    content: '\f093';
  }

  &.pi-times:before {
    content: '\f00d';
  }
}


================================================
FILE: frontend/src/index.html
================================================
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Storm</title>
  <base href="{{ .BasePath }}">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="theme-color" content="#343e4d">
  <meta name='mobile-web-app-capable' content='yes'>
  <meta name='apple-mobile-web-app-capable' content='yes'>
  <meta name='apple-mobile-web-app-status-bar-style' content='black'>

  <link rel='icon' type='image/png' href='assets/logo@32.png'>
  <link rel='icon' sizes='192x192' href='assets/logo@192.png'>
  <link rel='apple-touch-icon' href='assets/logo@152.png'>
  <meta name='msapplication-square310x310logo' content='assets/logo@310.png'>

  <script type="text/javascript">
    window.environment = {
      baseApiPath: '{{ .BaseAPIPath }}'
    }
  </script>
</head>
<body>
<t-root></t-root>
</body>
</html>


================================================
FILE: frontend/src/main.ts
================================================
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));


================================================
FILE: frontend/src/polyfills.ts
================================================
/**
 * This file includes polyfills needed by Angular and is loaded before the app.
 * You can add your own extra polyfills to this file.
 *
 * This file is divided into 2 sections:
 *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
 *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
 *      file.
 *
 * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
 * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
 * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
 *
 * Learn more in https://angular.io/guide/browser-support
 */

/***************************************************************************************************
 * BROWSER POLYFILLS
 */

/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js';  // Run `npm install --save classlist.js`.

/**
 * Web Animations `@angular/platform-browser/animations`
 * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
 * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
 */
// import 'web-animations-js';  // Run `npm install --save web-animations-js`.

/**
 * By default, zone.js will patch all possible macroTask and DomEvents
 * user can disable parts of macroTask/DomEvents patch by setting following flags
 * because those flags need to be set before `zone.js` being loaded, and webpack
 * will put import in the top of bundle, so user need to create a separate file
 * in this directory (for example: zone-flags.ts), and put the following flags
 * into that file, and then add the following code before importing zone.js.
 * import './zone-flags';
 *
 * The flags allowed in zone-flags.ts are listed here.
 *
 * The following flags will work for all browsers.
 *
 * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
 * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
 * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
 *
 *  in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
 *  with the following flag, it will bypass `zone.js` patch for IE/Edge
 *
 *  (window as any).__Zone_enable_cross_context_check = true;
 *
 */

/***************************************************************************************************
 * Zone JS is required by default for Angular itself.
 */
import 'zone.js/dist/zone';  // Included with Angular CLI.


/***************************************************************************************************
 * APPLICATION IMPORTS
 */


================================================
FILE: frontend/src/styles.scss
================================================
body {
  background-color: var(--surface-b);
  padding: 0;
  margin: 0;
  min-height: 100%;
  font-family: var(--font-family);
  color: var(--text-color);
}

.p-component, .p-inputtext {
  font-size: .9rem;
}

.p-progressbar .p-progressbar-value {
  background: #5f6e82;
  font-size: .8rem;
}


button.p-button {
  &.t-button-alt {
    background: #445063;
    color: #a9b5c3;

    &:enabled:hover {
      color: #3f4b5b;
      background: #a6b3c3;
    }
  }

  &.p-button-text {
    color: #8495ad;

    &:enabled:hover {
      background: rgba(132, 149, 173, 0.04);
      color: #a6b3c3;
    }
  }

  &.p-button-danger {
    background: #d45e6a;
    border: none;
  }

  color: #8495ad;
  background: #3f4b5b;
  border: none;

  &:enabled:hover {
    background: rgba(132, 149, 173, 0.04);
    color: #a6b3c3;
  }

  &:focus {
    box-shadow: none;
  }
}

.p-overlaypanel .p-overlaypanel-content {
  padding: 1rem;
}


.t-dialog-responsive {
  width: 75%;
}

@media only screen and (max-width: 750px) {
  .t-dialog-responsive {
    width: 100%
  }
}

.fas, .far {
  writing-mode: horizontal-tb;
}


================================================
FILE: frontend/src/test.ts
================================================
// This file is required by karma.conf.js and loads recursively all the .spec and framework files

import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

declare const require: {
  context(path: string, deep?: boolean, filter?: RegExp): {
    keys(): string[];
    <T>(id: string): T;
  };
};

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);


================================================
FILE: frontend/tsconfig.app.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}


================================================
FILE: frontend/tsconfig.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2015",
    "module": "es2020",
    "lib": [
      "es2018",
      "dom"
    ]
  }
}


================================================
FILE: frontend/tsconfig.spec.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": [
      "jasmine"
    ]
  },
  "files": [
    "src/test.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}


================================================
FILE: frontend/tslint.json
================================================
{
  "extends": "tslint:recommended",
  "rulesDirectory": [
    "codelyzer"
  ],
  "rules": {
    "align": {
      "options": [
        "parameters",
        "statements"
      ]
    },
    "array-type": false,
    "arrow-return-shorthand": true,
    "curly": true,
    "deprecation": {
      "severity": "warning"
    },
    "eofline": true,
    "import-blacklist": [
      true,
      "rxjs/Rx"
    ],
    "import-spacing": true,
    "indent": {
      "options": [
        "spaces"
      ]
    },
    "max-classes-per-file": false,
    "max-line-length": [
      true,
      140
    ],
    "member-ordering": [
      true,
      {
        "order": [
          "static-field",
          "instance-field",
          "static-method",
          "instance-method"
        ]
      }
    ],
    "no-console": [
      true,
      "debug",
      "info",
      "time",
      "timeEnd",
      "trace"
    ],
    "no-empty": false,
    "no-inferrable-types": [
      true,
      "ignore-params"
    ],
    "no-non-null-assertion": true,
    "no-redundant-jsdoc": true,
    "no-switch-case-fall-through": true,
    "no-var-requires": false,
    "object-literal-key-quotes": [
      true,
      "as-needed"
    ],
    "quotemark": [
      true,
      "single"
    ],
    "semicolon": {
      "options": [
        "always"
      ]
    },
    "space-before-function-paren": {
      "options": {
        "anonymous": "never",
        "asyncArrow": "always",
        "constructor": "never",
        "method": "never",
        "named": "never"
      }
    },
    "typedef": [
      true,
      "call-signature"
    ],
    "typedef-whitespace": {
      "options": [
        {
          "call-signature": "nospace",
          "index-signature": "nospace",
          "parameter": "nospace",
          "property-declaration": "nospace",
          "variable-declaration": "nospace"
        },
        {
          "call-signature": "onespace",
          "index-signature": "onespace",
          "parameter": "onespace",
          "property-declaration": "onespace",
          "variable-declaration": "onespace"
        }
      ]
    },
    "variable-name": {
      "options": [
        "ban-keywords",
        "check-format",
        "allow-pascal-case"
      ]
    },
    "whitespace": {
      "options": [
        "check-branch",
        "check-decl",
        "check-operator",
        "check-separator",
        "check-type",
        "check-typecast"
      ]
    },
    "component-class-suffix": true,
    "contextual-lifecycle": true,
    "directive-class-suffix": true,
    "no-conflicting-lifecycle": true,
    "no-host-metadata-property": true,
    "no-input-rename": true,
    "no-inputs-metadata-property": true,
    "no-output-native": true,
    "no-output-on-prefix": true,
    "no-output-rename": true,
    "no-outputs-metadata-property": true,
    "template-banana-in-box": true,
    "template-no-negated-async": true,
    "use-lifecycle-interface": true,
    "use-pipe-transform-interface": true,
    "directive-selector": [
      true,
      "attribute",
      "app",
      "camelCase"
    ],
    "component-selector": [
      true,
      "element",
      "t",
      "kebab-case"
    ]
  }
}


================================================
FILE: go.mod
================================================
module github.com/relvacode/storm

go 1.17

require (
	github.com/gdm85/go-libdeluge v0.6.0
	github.com/gorilla/mux v1.8.0
	github.com/jessevdk/go-flags v1.4.0
	github.com/spf13/afero v1.6.0
	go.uber.org/zap v1.16.0
)

require (
	github.com/gdm85/go-rencode v0.1.8 // indirect
	github.com/stretchr/testify v1.5.1 // indirect
	go.uber.org/atomic v1.6.0 // indirect
	go.uber.org/multierr v1.5.0 // indirect
	golang.org/x/text v0.3.3 // indirect
	golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 // indirect
	gopkg.in/yaml.v2 v2.2.8 // indirect
)


================================================
FILE: go.sum
================================================
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gdm85/go-libdeluge v0.5.6 h1:tSAwrlOAhu9VAMuxGacK/DMSmLN6SjHHhcVtg76fFnY=
github.com/gdm85/go-libdeluge v0.5.6/go.mod h1:PATKp4wpfcubDL/uIWPSLcIFC0ear942OKvD3ZB4Vsk=
github.com/gdm85/go-libdeluge v0.6.0 h1:TCqzmABxup3l1yeWWW6ZIGoxgJZrrKB5aBF6z6BbVPg=
github.com/gdm85/go-libdeluge v0.6.0/go.mod h1:y3CUYGywCSDOB32/IBLomtK+fU4+lfqIFWsnA/jBoeY=
github.com/gdm85/go-rencode v0.1.8 h1:7+qxwoQWU1b1nMGcESOyoUR5dzPtRA6yLQpKn7uXmnI=
github.com/gdm85/go-rencode v0.1.8/go.mod h1:0dr3BuaKzeseY1of6o1KRTGB/Oo7eio+YEyz8KDp5+s=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE=
golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=


================================================
FILE: http.go
================================================
package storm

import (
	"encoding/json"
	"net/http"
)

// HandlerFunc is an adaptor for the http.HandlerFunc that returns JSON data.
type HandlerFunc func(r *http.Request) (interface{}, error)

// Send sends JSON data to the client using the supplied HTTP status code.
func Send(rw http.ResponseWriter, code int, data interface{}) {
	enc := json.NewEncoder(rw)
	enc.SetIndent("", "  ")

	rw.Header().Set("Content-Type", "application/json")
	rw.WriteHeader(code)

	_ = enc.Encode(data)
}

// NoContent sends a 204 No Content response
func NoContent(rw http.ResponseWriter) {
	rw.WriteHeader(http.StatusNoContent)
}

func Handle(rw http.ResponseWriter, r *http.Request, handler HandlerFunc) error {
	response, err := handler(r)
	if err != nil {
		SendError(rw, err)
		return err
	}

	if response == nil {
		NoContent(rw)
		return nil
	}

	Send(rw, http.StatusOK, response)
	return nil
}


================================================
FILE: methods.go
================================================
package storm

import (
	"fmt"
	deluge "github.com/gdm85/go-libdeluge"
	"github.com/gorilla/mux"
	"net/http"
	"net/url"
)

type DelugeMethod func(conn deluge.DelugeClient, r *http.Request) (interface{}, error)

func torrentIDs(q url.Values, min int) ([]string, error) {
	var ids = q["id"]
	if len(ids) < min {
		return nil, &Error{Code: http.StatusBadRequest, Message: fmt.Sprintf("At least %d torrent ID(s) are required", min)}
	}

	return ids, nil
}

func httpTorrentsStatus(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var (
		q     = r.URL.Query()
		ids   = q["id"]
		state = deluge.TorrentState(q.Get("state"))
	)

	return conn.TorrentsStatus(state, ids)
}

func httpDeleteTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var (
		q       = r.URL.Query()
		rmFiles = q.Get("files") == "true"
	)

	ids, err := torrentIDs(q, 1)
	if err != nil {
		return nil, err
	}

	errors, err := conn.RemoveTorrents(ids, rmFiles)
	if err != nil {
		return nil, err
	}

	if len(errors) > 0 {
		return &errors, nil
	}

	return nil, nil
}

func httpPauseTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	ids, err := torrentIDs(r.URL.Query(), 1)
	if err != nil {
		return nil, err
	}

	return nil, conn.PauseTorrents(ids...)
}

func httpResumeTorrents(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	ids, err := torrentIDs(r.URL.Query(), 1)
	if err != nil {
		return nil, err
	}

	return nil, conn.ResumeTorrents(ids...)
}

type AddTorrentRequest struct {
	Type string
	URI  string
	Data string

	Options deluge.Options
}

type AddTorrentResponse struct {
	ID string
}

func httpAddTorrent(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var req AddTorrentRequest

	err := Read(r, &req)
	if err != nil {
		return nil, err
	}

	var id string
	switch req.Type {
	case "url":
		id, err = conn.AddTorrentURL(req.URI, &req.Options)
	case "magnet":
		id, err = conn.AddTorrentMagnet(req.URI, &req.Options)
	case "file":
		id, err = conn.AddTorrentFile(req.URI, req.Data, &req.Options)
	default:
		return nil, &Error{Code: http.StatusBadRequest, Message: "Torrent Type must be one of url, magnet or file"}
	}

	if err != nil {
		return nil, err
	}

	// The RPC returns an empty ID if the torrent could not be parsed or processed.
	if id == "" {
		return nil, &Error{Code: http.StatusUnprocessableEntity, Message: "Torrent file could not be read"}
	}

	return AddTorrentResponse{ID: id}, nil
}

type TorrentMethod func(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error)

func TorrentHandler(f TorrentMethod) DelugeMethod {
	return func(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
		vars := mux.Vars(r)
		return f(vars["id"], conn, r)
	}
}

func httpTorrentStatus(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {
	return conn.TorrentStatus(id)
}

func httpDeleteTorrent(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	ok, err := conn.RemoveTorrent(id, r.URL.Query().Get("files") == "true")
	if err != nil {
		return nil, err
	}

	if !ok {
		return nil, &Error{Code: http.StatusNotFound, Message: "Requested torrent could not be deleted"}
	}

	return nil, nil
}

func httpPauseTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {
	return nil, conn.PauseTorrents(id)
}

func httpResumeTorrent(id string, conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {
	return nil, conn.ResumeTorrents(id)
}

func httpSetTorrentOptions(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var req deluge.Options

	err := Read(r, &req)
	if err != nil {
		return nil, err
	}

	return nil, conn.SetTorrentOptions(id, &req)
}

func httpGetSessionStatus(conn deluge.DelugeClient, _ *http.Request) (interface{}, error) {
	return conn.GetSessionStatus()
}

type GetFreeSpaceResponse struct {
	FreeBytes int64
}

func httpGetFreeSpace(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	path := r.URL.Query().Get("path")

	freeBytes, err := conn.GetFreeSpace(path)
	if err != nil {
		return nil, err
	}

	return GetFreeSpaceResponse{
		FreeBytes: freeBytes,
	}, nil
}


================================================
FILE: methods_labels.go
================================================
package storm

import (
	"fmt"
	deluge "github.com/gdm85/go-libdeluge"
	"github.com/gorilla/mux"
	"net/http"
)

// getClientV1 gets the underlying version 1 client from a DelugeClient interface.
func getClientV1(conn deluge.DelugeClient) (*deluge.Client, error) {
	switch client := conn.(type) {
	case *deluge.Client:
		return client, nil
	case *deluge.ClientV2:
		return &client.Client, nil
	default:
		return nil, fmt.Errorf("failed to obtain version 1 Deluge client")
	}
}

// labelPluginClient constructs an instance of a deluge.LabelPlugin client from the input DelugeClient connection.
func labelPluginClient(conn deluge.DelugeClient) (*deluge.LabelPlugin, error) {
	client, err := getClientV1(conn)
	if err != nil {
		return nil, err
	}

	return &deluge.LabelPlugin{
		Client: client,
	}, nil
}

// httpLabels gets the current labels
func httpLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	plugin, err := labelPluginClient(conn)
	if err != nil {
		return nil, err
	}

	return plugin.GetLabels()
}

// httpCreateLabel creates a new label
func httpCreateLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	vars := mux.Vars(r)

	plugin, err := labelPluginClient(conn)
	if err != nil {
		return nil, err
	}

	err = plugin.AddLabel(vars["id"])
	if err != nil {
		return nil, err
	}

	return nil, nil
}

// httpCreateLabel deletes an existing label
func httpDeleteLabel(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	vars := mux.Vars(r)

	plugin, err := labelPluginClient(conn)
	if err != nil {
		return nil, err
	}

	err = plugin.RemoveLabel(vars["id"])
	if err != nil {
		return nil, err
	}

	return nil, nil
}

// httpTorrentsLabels gets labels associated with all torrents matching the filter.
// 		?id[]	One or more torrent IDs
//		?state	Torrents of this state
//
//		Returns a mapping of torrent hash to torrent labels
func httpTorrentsLabels(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	ids, err := torrentIDs(r.URL.Query(), 0)
	if err != nil {
		return nil, err
	}

	state := (deluge.TorrentState)(r.URL.Query().Get("state"))

	plugin, err := labelPluginClient(conn)
	if err != nil {
		return nil, err
	}

	labels, err := plugin.GetTorrentsLabels(state, ids)
	if err != nil {
		return nil, err
	}

	return labels, nil
}

type SetTorrentLabelRequest struct {
	Label string
}

// httpSetTorrentLabel sets the label for a given torrent hash
func httpSetTorrentLabel(id string, conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var req SetTorrentLabelRequest

	err := Read(r, &req)
	if err != nil {
		return nil, err
	}

	plugin, err := labelPluginClient(conn)
	if err != nil {
		return nil, err
	}

	err = plugin.SetTorrentLabel(id, req.Label)
	if err != nil {
		return nil, err
	}

	return nil, nil
}


================================================
FILE: methods_plugins.go
================================================
package storm

import (
	deluge "github.com/gdm85/go-libdeluge"
	"github.com/gorilla/mux"
	"net/http"
)

// httpGetPlugins gets all the currently enabled plugins
func httpGetPlugins(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	return conn.GetEnabledPlugins()
}

func httpEnablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	id := mux.Vars(r)["id"]

	err := conn.EnablePlugin(id)
	if err != nil {
		return nil, err
	}

	return nil, nil
}

func httpDisablePlugin(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	id := mux.Vars(r)["id"]

	err := conn.DisablePlugin(id)
	if err != nil {
		return nil, err
	}

	return nil, nil
}


================================================
FILE: methods_view.go
================================================
package storm

import (
	"crypto/sha1"
	"encoding/hex"
	"encoding/json"
	deluge "github.com/gdm85/go-libdeluge"
	"net/http"
	"sort"
)

type ViewTorrent struct {
	Hash  string
	Label string
	*deluge.TorrentStatus
}

type ViewUpdate struct {
	Torrents []*ViewTorrent
	Session  *deluge.SessionStatus
	DiskFree int64
}

type ViewUpdateResponse struct {
	ViewUpdate
	ETag string
}

func httpViewUpdate(conn deluge.DelugeClient, r *http.Request) (interface{}, error) {
	var (
		q     = r.URL.Query()
		ids   = q["id"]
		state = deluge.TorrentState(q.Get("state"))
	)

	torrents, err := conn.TorrentsStatus(state, ids)
	if err != nil {
		return nil, err
	}

	var torrentHashes = make([]string, 0, len(torrents))
	for k := range torrents {
		torrentHashes = append(torrentHashes, k)
	}
	sort.Strings(torrentHashes)

	var torrentLabels = make(map[string]string)

	plugin, err := labelPluginClient(conn)
	if err == nil {
		labels, err := plugin.GetTorrentsLabels(state, ids)
		if err == nil {
			torrentLabels = labels
		}
	}

	var responseTorrents = make([]*ViewTorrent, 0, len(torrents))
	for _, k := range torrentHashes {
		responseTorrents = append(responseTorrents, &ViewTorrent{
			Hash:          k,
			Label:         torrentLabels[k],
			TorrentStatus: torrents[k],
		})
	}

	session, err := conn.GetSessionStatus()
	if err != nil {
		return nil, err
	}

	diskFree, err := conn.GetFreeSpace(q.Get("path"))

	update := ViewUpdate{
		Torrents: responseTorrents,
		Session:  session,
		DiskFree: diskFree,
	}

	// ETag calculation
	var h = sha1.New()
	_ = json.NewEncoder(h).Encode(&update)

	responseETag := hex.EncodeToString(h.Sum(nil))

	if requestETag := r.Header.Get("ETag"); requestETag != "" && requestETag == responseETag {
		return nil, &Error{
			Code:    http.StatusNotModified,
			Message: "View not modified since last request",
		}
	}

	return &ViewUpdateResponse{
		ViewUpdate: update,
		ETag:       responseETag,
	}, nil
}


================================================
FILE: pool.go
================================================
package storm

import (
	"context"
	"errors"
	deluge "github.com/gdm85/go-libdeluge"
	"go.uber.org/zap"
	"sync"
	"time"
)

type DelugeProvider func() deluge.DelugeClient

type poolReq struct {
	// If the context has been cancelled then no connection is returned
	ctx context.Context
	// The replied client may be nil if the connection could not be established
	reply chan<- deluge.DelugeClient
}

func (req *poolReq) Send(conn deluge.DelugeClient) bool {
	select {
	case <-req.ctx.Done():
		return false
	case req.reply <- conn:
		return true
	}
}

type idleConnection struct {
	idle time.Time
	conn deluge.DelugeClient
}

type Timer interface {
	Ch() <-chan time.Time
	Stop() bool
}

type timeTimer time.Timer

func (t *timeTimer) Ch() <-chan time.Time {
	return (*time.Timer)(t).C
}

func (t *timeTimer) Stop() bool {
	return (*time.Timer)(t).Stop()
}

type nullTimer struct{}

func (nullTimer) Ch() <-chan time.Time {
	return nil
}

func (nullTimer) Stop() bool {
	return true
}

func NewConnectionPool(log *zap.Logger, maxConnections int, idleConnectionTime time.Duration, provider DelugeProvider) *ConnectionPool {
	pool := &ConnectionPool{
		Log:                log,
		MaxConnections:     maxConnections,
		IdleConnectionTime: idleConnectionTime,
		Provider:           provider,

		get:   make(chan *poolReq),
		put:   make(chan deluge.DelugeClient),
		close: make(chan struct{}),
		alive: new(sync.Mutex),
		idle:  nullTimer{},
	}

	go pool.worker()
	return pool
}

type ConnectionPool struct {
	Log                *zap.Logger
	MaxConnections     int
	IdleConnectionTime time.Duration
	Provider           DelugeProvider

	get   chan *poolReq
	put   chan deluge.DelugeClient
	close chan struct{}
	alive *sync.Mutex

	waitConn []*poolReq
	inFlight int
	pool     []*idleConnection
	idle     Timer
}

func (pool *ConnectionPool) nextIdle() {
	// Go through the other connections.
	// Check that they are not expired, if not set the new idle to the first valid connection
	for {
		if len(pool.pool) == 0 {
			// Otherwise, there are no more connections to make idle
			pool.idle = nullTimer{}
			return
		}

		conn := pool.pool[0]
		expires := conn.idle.Sub(time.Now())

		// Next connection is now idle. Delete it.
		if expires < 0 {
			err := conn.conn.Close()
			if err != nil {
				pool.Log.Error("Failed to closed idle connection", zap.Error(err))
			}

			pool.pool = pool.pool[1:]
			continue
		}

		// Valid connection that is not idle
		t := time.NewTimer(expires)
		pool.idle = (*timeTimer)(t)
		return
	}
}

func (pool *ConnectionPool) idleExpired() {
	var conn *idleConnection
	conn, pool.pool = pool.pool[0], pool.pool[1:]

	err := conn.conn.Close()
	if err != nil {
		pool.Log.Error("Failed to closed idle connection", zap.Error(err))
	}

	pool.idle.Stop()
	pool.nextIdle()
}

func (pool *ConnectionPool) putConn(conn deluge.DelugeClient) {
	// Check if anyone is waiting for a connection
	for len(pool.waitConn) > 0 {

		w := pool.waitConn[0]
		pool.waitConn[0] = nil
		pool.waitConn = pool.waitConn[1:]

		select {
		case <-w.ctx.Done(): // waiter's connection has been cancelled
		case w.reply <- conn: // waiter has received connection
			return
		}
	}

	idle := &idleConnection{idle: time.Now().Add(pool.IdleConnectionTime), conn: conn}

	// There are no existing connections in the pool.
	// Set the new pool timer
	if len(pool.pool) == 0 {
		pool.idle.Stop()

		t := time.NewTimer(pool.IdleConnectionTime)
		pool.idle = (*timeTimer)(t)
	}

	pool.pool = append(pool.pool, idle)
	pool.inFlight--
}

func (pool *ConnectionPool) closeConns() {
	pool.idle.Stop()
	pool.idle = nullTimer{}

	// Close any waiters
	for len(pool.waitConn) > 0 {
		var w *poolReq
		w, pool.waitConn = pool.waitConn[0], pool.waitConn[1:]
		close(w.reply)
	}

	// Close all connections within the pool
	for len(pool.pool) > 0 {
		var c *idleConnection
		c, pool.pool = pool.pool[0], pool.pool[1:]
		_ = c.conn.Close()
	}
}

func (pool *ConnectionPool) getConn(req *poolReq) {
	// There are connections that can be sent straight away
	if len(pool.pool) > 0 {
		c := pool.pool[0]

		if req.Send(c.conn) {
			// Connection successfully sent
			pool.pool = pool.pool[1:]
			pool.inFlight++

			pool.idle.Stop()
			pool.nextIdle()
		}

		return
	}

	// There are more connections in-flight.
	// Add the request to the list of waiting requests.
	if pool.inFlight >= pool.MaxConnections {
		pool.waitConn = append(pool.waitConn, req)
		return
	}

	// A new connection can be established
	conn := pool.Provider()

	err := conn.Connect()
	if err != nil {
		pool.Log.Error("Failed to establish Deluge RPC connection", zap.Error(err))
		conn = nil
	}

	ok := req.Send(conn)

	// The connection was nil so we don't care what the send response was
	if conn == nil {
		return
	}

	pool.inFlight++

	// Connection successfully sent
	if ok {
		return
	}

	// Connection was established but could not be sent to the caller
	// Put the established connection into the pool
	pool.putConn(conn)
}

func (pool *ConnectionPool) worker() {
	pool.alive.Lock()
	defer pool.alive.Unlock()

	for {
		select {
		case <-pool.idle.Ch(): // The first connection is now idle
			pool.idleExpired()
		case req := <-pool.get:
			pool.getConn(req)
		case conn := <-pool.put: // A connection has been put back
			pool.putConn(conn)
		case <-pool.close:
			pool.closeConns()
			return
		}
	}
}

// Get gets a connected connection from the pool.
// If there are no available connections in the pool then one is created and connected to.
// If there already too many active connections, Get will block until a connection is available
// or the given context is cancelled.
func (pool *ConnectionPool) Get(ctx context.Context) (deluge.DelugeClient, error) {
	// TODO someone needs to close this
	replyCh := make(chan deluge.DelugeClient)
	pool.get <- &poolReq{ctx: ctx, reply: replyCh}

	select {
	case <-pool.close:
		return nil, errors.New("The Deluge RPC connection pool has been closed")
	case <-ctx.Done():
		return nil, ctx.Err()
	case conn := <-replyCh:
		if conn == nil {
			return nil, errors.New("A connection to the Deluge RPC daemon could not be established by the connection pool")
		}

		return conn, nil
	}
}

// Put puts a connection back to the pool.
func (pool *ConnectionPool) Put(conn deluge.DelugeClient) {
	select {
	case <-pool.close:
		// Pool has been closed before the connection can be put back
		_ = conn.Close()
	case pool.put <- conn:
	}
}

func (pool *ConnectionPool) Close() {
	close(pool.close)

	// Achieve a lock on the pool alive mutex.
	// When a lock is achieved the pool worker daemon has been closed.
	// Keep the alive mutex locked.
	pool.alive.Lock()
}


================================================
FILE: request.go
================================================
package storm

import (
	"encoding/json"
	"io"
	"net/http"
)

const (
	// MaxRequestSize is the maximum allowed request size in bytes
	MaxRequestSize = 5 << 20
)

// Read reads JSON data from the request.
func Read(r *http.Request, into interface{}) error {
	var lr = io.LimitReader(r.Body, MaxRequestSize).(*io.LimitedReader)
	var dec = json.NewDecoder(lr)
	var err = dec.Decode(into)

	_ = r.Body.Close()

	// Request too large (limited reader fully consumed)
	if lr.N < 1 {
		return &Error{Code: http.StatusRequestEntityTooLarge, Message: "Request payload exceeds maximum limit"}
	}

	if err != nil {
		return Hint(http.StatusBadRequest, err)
	}

	return nil
}


================================================
FILE: response.go
================================================
package storm

import (
	"net/http"
	"time"
)

var _ http.ResponseWriter = (*WrappedResponse)(nil)

// WrapResponse wraps a response
func WrapResponse(rw http.ResponseWriter) *WrappedResponse {
	return &WrappedResponse{
		ResponseWriter: rw,
		started:        time.Now().UTC(),
	}
}

// WrappedResponse wraps a http.ResponseWriter to capture information about the response
type WrappedResponse struct {
	http.ResponseWriter

	started time.Time
	sent    time.Time

	code        int
	error       error
	payloadSize int
}

func (rw *WrappedResponse) WriteHeader(code int) {
	rw.code = code
	rw.sent = time.Now().UTC()
	rw.ResponseWriter.WriteHeader(code)

	// Set error based off the status text if an explicit error is not otherwise provided
	if code > 399 && rw.error == nil {
		rw.error = &Error{
			Code:    code,
			Message: http.StatusText(code),
		}
	}
}

func (rw *WrappedResponse) Write(b []byte) (int, error) {
	wr, err := rw.ResponseWriter.Write(b)
	rw.payloadSize += wr
	return wr, err
}

func (rw *WrappedResponse) Code() int {
	return rw.code
}

func (rw *WrappedResponse) Started() time.Time {
	return rw.started
}

// Duration returns the total duration of the request to response.
func (rw *WrappedResponse) Duration() time.Duration {
	return rw.sent.Sub(rw.started)
}

// Len returns the total payload size in bytes.
func (rw *WrappedResponse) Len() int {
	return rw.payloadSize
}

func (rw *WrappedResponse) Error() error {
	return rw.error
}


================================================
FILE: static.go
================================================
package storm

import (
	"embed"
)

//go:embed frontend/dist/*
var Static embed.FS
Download .txt
gitextract_e9godol_/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── release.yaml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── api.go
├── cmd/
│   └── storm/
│       └── server.go
├── docker-compose/
│   ├── auth
│   ├── core.conf
│   └── docker-compose.yml
├── error.go
├── frontend/
│   ├── .browserslistrc
│   ├── .editorconfig
│   ├── angular.json
│   ├── e2e/
│   │   ├── protractor.conf.js
│   │   ├── src/
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── app.po.ts
│   │   └── tsconfig.json
│   ├── karma.conf.js
│   ├── package.json
│   ├── src/
│   │   ├── app/
│   │   │   ├── api.service.spec.ts
│   │   │   ├── api.service.ts
│   │   │   ├── app.component.html
│   │   │   ├── app.component.scss
│   │   │   ├── app.component.spec.ts
│   │   │   ├── app.component.ts
│   │   │   ├── app.module.ts
│   │   │   ├── components/
│   │   │   │   ├── activity-marker/
│   │   │   │   │   ├── activity-marker.component.html
│   │   │   │   │   ├── activity-marker.component.scss
│   │   │   │   │   ├── activity-marker.component.spec.ts
│   │   │   │   │   └── activity-marker.component.ts
│   │   │   │   ├── add-torrent-menu/
│   │   │   │   │   ├── add-torrent-config/
│   │   │   │   │   │   ├── add-torrent-config.component.html
│   │   │   │   │   │   ├── add-torrent-config.component.scss
│   │   │   │   │   │   ├── add-torrent-config.component.spec.ts
│   │   │   │   │   │   └── add-torrent-config.component.ts
│   │   │   │   │   ├── add-torrent-dialog-component.ts
│   │   │   │   │   ├── add-torrent-file-input/
│   │   │   │   │   │   ├── add-torrent-file-input.component.html
│   │   │   │   │   │   ├── add-torrent-file-input.component.scss
│   │   │   │   │   │   ├── add-torrent-file-input.component.spec.ts
│   │   │   │   │   │   └── add-torrent-file-input.component.ts
│   │   │   │   │   ├── add-torrent-magnet-input/
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.html
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.scss
│   │   │   │   │   │   ├── add-torrent-magnet-input.component.spec.ts
│   │   │   │   │   │   └── add-torrent-magnet-input.component.ts
│   │   │   │   │   ├── add-torrent-menu.component.html
│   │   │   │   │   ├── add-torrent-menu.component.scss
│   │   │   │   │   ├── add-torrent-menu.component.spec.ts
│   │   │   │   │   ├── add-torrent-menu.component.ts
│   │   │   │   │   └── add-torrent-url-input/
│   │   │   │   │       ├── add-torrent-url-input.component.html
│   │   │   │   │       ├── add-torrent-url-input.component.scss
│   │   │   │   │       ├── add-torrent-url-input.component.spec.ts
│   │   │   │   │       └── add-torrent-url-input.component.ts
│   │   │   │   ├── api-key-dialog/
│   │   │   │   │   ├── api-key-dialog.component.html
│   │   │   │   │   ├── api-key-dialog.component.scss
│   │   │   │   │   ├── api-key-dialog.component.spec.ts
│   │   │   │   │   └── api-key-dialog.component.ts
│   │   │   │   ├── breakpoint-overlay/
│   │   │   │   │   ├── breakpoint-overlay.component.html
│   │   │   │   │   ├── breakpoint-overlay.component.scss
│   │   │   │   │   ├── breakpoint-overlay.component.spec.ts
│   │   │   │   │   └── breakpoint-overlay.component.ts
│   │   │   │   ├── connectivity-status/
│   │   │   │   │   ├── connectivity-status.component.html
│   │   │   │   │   ├── connectivity-status.component.scss
│   │   │   │   │   ├── connectivity-status.component.spec.ts
│   │   │   │   │   └── connectivity-status.component.ts
│   │   │   │   ├── delete-torrent-overlay/
│   │   │   │   │   ├── delete-torrent-overlay.component.html
│   │   │   │   │   ├── delete-torrent-overlay.component.scss
│   │   │   │   │   ├── delete-torrent-overlay.component.spec.ts
│   │   │   │   │   └── delete-torrent-overlay.component.ts
│   │   │   │   ├── plugin-enable/
│   │   │   │   │   ├── plugin-enable.component.html
│   │   │   │   │   ├── plugin-enable.component.scss
│   │   │   │   │   ├── plugin-enable.component.spec.ts
│   │   │   │   │   └── plugin-enable.component.ts
│   │   │   │   ├── session-status/
│   │   │   │   │   ├── session-status.component.html
│   │   │   │   │   ├── session-status.component.scss
│   │   │   │   │   ├── session-status.component.spec.ts
│   │   │   │   │   └── session-status.component.ts
│   │   │   │   ├── torrent/
│   │   │   │   │   ├── torrent.component.html
│   │   │   │   │   ├── torrent.component.scss
│   │   │   │   │   ├── torrent.component.spec.ts
│   │   │   │   │   └── torrent.component.ts
│   │   │   │   ├── torrent-details-dialog/
│   │   │   │   │   ├── torrent-details-dialog.component.html
│   │   │   │   │   ├── torrent-details-dialog.component.scss
│   │   │   │   │   ├── torrent-details-dialog.component.spec.ts
│   │   │   │   │   └── torrent-details-dialog.component.ts
│   │   │   │   ├── torrent-edit-label-dialog/
│   │   │   │   │   ├── torrent-edit-label-dialog.component.html
│   │   │   │   │   ├── torrent-edit-label-dialog.component.scss
│   │   │   │   │   ├── torrent-edit-label-dialog.component.spec.ts
│   │   │   │   │   └── torrent-edit-label-dialog.component.ts
│   │   │   │   ├── torrent-label/
│   │   │   │   │   ├── torrent-label.component.html
│   │   │   │   │   ├── torrent-label.component.scss
│   │   │   │   │   ├── torrent-label.component.spec.ts
│   │   │   │   │   └── torrent-label.component.ts
│   │   │   │   ├── torrent-search/
│   │   │   │   │   ├── torrent-search.component.html
│   │   │   │   │   ├── torrent-search.component.scss
│   │   │   │   │   ├── torrent-search.component.spec.ts
│   │   │   │   │   └── torrent-search.component.ts
│   │   │   │   └── torrent-state/
│   │   │   │       ├── torrent-state.component.html
│   │   │   │       ├── torrent-state.component.scss
│   │   │   │       ├── torrent-state.component.spec.ts
│   │   │   │       └── torrent-state.component.ts
│   │   │   ├── environment.ts
│   │   │   ├── focus.service.spec.ts
│   │   │   ├── focus.service.ts
│   │   │   ├── order-by.pipe.spec.ts
│   │   │   ├── order-by.pipe.ts
│   │   │   ├── torrent-search.pipe.spec.ts
│   │   │   └── torrent-search.pipe.ts
│   │   ├── assets/
│   │   │   └── .gitkeep
│   │   ├── environments/
│   │   │   ├── environment.prod.ts
│   │   │   └── environment.ts
│   │   ├── icons.scss
│   │   ├── index.html
│   │   ├── main.ts
│   │   ├── polyfills.ts
│   │   ├── styles.scss
│   │   └── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.json
│   ├── tsconfig.spec.json
│   └── tslint.json
├── go.mod
├── go.sum
├── http.go
├── logo.svgz
├── methods.go
├── methods_labels.go
├── methods_plugins.go
├── methods_view.go
├── pool.go
├── request.go
├── response.go
└── static.go
Download .txt
SYMBOL INDEX (258 symbols across 39 files)

FILE: api.go
  constant ApiAuthCookieName (line 21) | ApiAuthCookieName = "storm-api-key"
  function appendSuffix (line 24) | func appendSuffix(s, suffix string) string {
  function New (line 32) | func New(log *zap.Logger, pool *ConnectionPool, pathPrefix string, apiKe...
  type Api (line 47) | type Api struct
    method DelugeHandler (line 56) | func (api *Api) DelugeHandler(f DelugeMethod) http.HandlerFunc {
    method ServeHTTP (line 80) | func (api *Api) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
    method httpNotFound (line 85) | func (api *Api) httpNotFound() http.Handler {
    method templateContext (line 96) | func (api *Api) templateContext() interface{} {
    method renderTemplate (line 106) | func (api *Api) renderTemplate(fs afero.Fs, name string) {
    method bindStatic (line 148) | func (api *Api) bindStatic(router *mux.Router, development bool) {
    method keyFromRequest (line 175) | func (api *Api) keyFromRequest(r *http.Request) (string, bool) {
    method logForRequest (line 192) | func (api *Api) logForRequest(rw *WrappedResponse, r *http.Request) {
    method httpMiddlewareLog (line 216) | func (api *Api) httpMiddlewareLog(next http.Handler) http.Handler {
    method httpMiddlewareAuthenticate (line 226) | func (api *Api) httpMiddlewareAuthenticate(next http.Handler) http.Han...
    method bind (line 262) | func (api *Api) bind(development bool) {

FILE: cmd/storm/server.go
  function signalContext (line 18) | func signalContext(ctx context.Context) context.Context {
  type Duration (line 35) | type Duration struct
    method UnmarshalFlag (line 39) | func (dur *Duration) UnmarshalFlag(value string) (err error) {
  type Path (line 44) | type Path
    method UnmarshalFlag (line 46) | func (p *Path) UnmarshalFlag(value string) error {
  type ServerOptions (line 58) | type ServerOptions struct
    method Logger (line 66) | func (options *ServerOptions) Logger() (*zap.Logger, error) {
    method RunHandler (line 81) | func (options *ServerOptions) RunHandler(ctx context.Context, log *zap...
  type DelugeOptions (line 111) | type DelugeOptions struct
    method Client (line 122) | func (options *DelugeOptions) Client() storm.DelugeProvider {
    method Pool (line 144) | func (options *DelugeOptions) Pool(log *zap.Logger) *storm.ConnectionP...
  type Options (line 148) | type Options struct
  function Main (line 153) | func Main() error {
  function main (line 186) | func main() {

FILE: error.go
  type HTTPError (line 9) | type HTTPError interface
  type RPCError (line 14) | type RPCError struct
    method StatusCode (line 19) | func (RPCError) StatusCode() int {
    method Error (line 23) | func (e RPCError) Error() string {
  function Hint (line 31) | func Hint(code int, err error) error {
  type Error (line 42) | type Error struct
    method Error (line 47) | func (e *Error) Error() string {
    method StatusCode (line 51) | func (e *Error) StatusCode() int {
  type errorResponse (line 55) | type errorResponse struct
  function SendError (line 62) | func SendError(rw http.ResponseWriter, err error) {

FILE: frontend/e2e/protractor.conf.js
  method onPrepare (line 26) | onPrepare() {

FILE: frontend/e2e/src/app.po.ts
  class AppPage (line 3) | class AppPage {
    method navigateTo (line 4) | navigateTo(): Promise<unknown> {
    method getTitleText (line 8) | getTitleText(): Promise<string> {

FILE: frontend/src/app/api.service.ts
  class ApiException (line 21) | class ApiException {
    method constructor (line 22) | constructor(public status: number, public error: string) {
    method message (line 28) | public get message(): Message {
  type State (line 37) | type State =
  type Torrent (line 48) | interface Torrent {
  type Label (line 83) | interface Label {
  type Hash (line 87) | interface Hash {
  type ViewTorrent (line 91) | interface ViewTorrent extends Torrent, Hash, Label {
  type Torrents (line 95) | interface Torrents {
  type TorrentOptions (line 99) | interface TorrentOptions {
  type AddTorrent (line 116) | interface AddTorrent {
  type AddTorrentRequest (line 122) | interface AddTorrentRequest extends AddTorrent {
  type AddTorrentResponse (line 126) | interface AddTorrentResponse {
  type TorrentLabels (line 130) | interface TorrentLabels {
  type SetTorrentLabelRequest (line 134) | interface SetTorrentLabelRequest {
  type SessionStatus (line 138) | interface SessionStatus {
  type DiskSpace (line 150) | interface DiskSpace {
  type ViewUpdate (line 154) | interface ViewUpdate {
  class ApiInterceptor (line 161) | class ApiInterceptor implements HttpInterceptor {
    method constructor (line 162) | constructor() {
    method catchError (line 165) | private catchError(err: HttpErrorResponse, caught: Observable<HttpEven...
    method intercept (line 169) | public intercept(req: HttpRequest<any>, next: HttpHandler): Observable...
  class AuthInterceptor (line 177) | class AuthInterceptor implements HttpInterceptor {
    method constructor (line 180) | constructor(dialogService: DialogService) {
    method intercept (line 193) | public intercept(req: HttpRequest<any>, next: HttpHandler): Observable...
  class ApiService (line 224) | class ApiService {
    method constructor (line 225) | constructor(private http: HttpClient, @Inject(ENVIRONMENT) private env...
    method url (line 228) | private url(endpoint: string): string {
    method ping (line 235) | public ping(): Observable<void> {
    method sessionStatus (line 239) | public sessionStatus(): Observable<SessionStatus> {
    method freeDiskSpace (line 243) | public freeDiskSpace(path: string = ''): Observable<DiskSpace> {
    method viewUpdate (line 251) | public viewUpdate(etag?: string, state?: State): Observable<ViewUpdate> {
    method plugins (line 279) | public plugins(): Observable<string[]> {
    method enablePlugin (line 288) | public enablePlugin(name: string): Observable<void> {
    method disablePlugin (line 297) | public disablePlugin(name: string): Observable<void> {
    method pause (line 306) | public pause(...torrents: string[]): Observable<void> {
    method resume (line 323) | public resume(...torrents: string[]): Observable<void> {
    method torrent (line 340) | public torrent(id: string): Observable<Torrent> {
    method torrents (line 345) | public torrents(state?: State, ...torrents: string[]): Observable<Torr...
    method removeTorrent (line 361) | public removeTorrent(withData: boolean, id: string): Observable<void> {
    method add (line 376) | public add(req: AddTorrentRequest): Observable<AddTorrentResponse> {
    method labels (line 383) | public labels(): Observable<string[]> {
    method createLabel (line 392) | public createLabel(name: string): Observable<void> {
    method deleteLabel (line 401) | public deleteLabel(name: string): Observable<void> {
    method torrentsLabels (line 412) | public torrentsLabels(state?: State, ...torrents: string[]): Observabl...
    method setTorrentLabel (line 423) | public setTorrentLabel(id: string, req: SetTorrentLabelRequest): Obser...

FILE: frontend/src/app/app.component.ts
  type OptionalState (line 10) | type OptionalState = State | null;
  class AppComponent (line 18) | class AppComponent {
    method constructor (line 115) | constructor(private api: ApiService, private focus: FocusService, priv...
    method enableLabelPlugin (line 125) | private enableLabelPlugin(): Observable<void> {
    method refreshInterval (line 147) | private refreshInterval(interval: number): void {
    method trackBy (line 195) | public trackBy(index: number, torrent: Hash): string {
    method onToggleInView (line 200) | onToggleInView(targetState: 'pause' | 'resume', torrents: ViewTorrent[...

FILE: frontend/src/app/app.module.ts
  class AppModule (line 131) | class AppModule {

FILE: frontend/src/app/components/activity-marker/activity-marker.component.ts
  class ActivityMarkerComponent (line 8) | class ActivityMarkerComponent {
    method constructor (line 10) | constructor() { }

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.ts
  class AddTorrentConfigComponent (line 9) | class AddTorrentConfigComponent {
    method constructor (line 12) | constructor() {
    method unsetFields (line 15) | public unsetFields($event: boolean, ...fields: (keyof TorrentOptions)[...

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-dialog-component.ts
  class AddTorrentDialogComponentDirective (line 8) | class AddTorrentDialogComponentDirective<T> {
    method constructor (line 20) | constructor(injector: Injector) {
    method close (line 30) | public close(): void {
    method submit (line 34) | public submit(opt: AddTorrent): void {

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.ts
  class AddTorrentFileInputComponent (line 9) | class AddTorrentFileInputComponent extends AddTorrentDialogComponentDire...
    method constructor (line 10) | constructor(injector: Injector) {
    method arrayBufferToBase64 (line 17) | private arrayBufferToBase64(buffer: ArrayBuffer): string {
    method onResetErrors (line 27) | onResetErrors(): void {
    method onSubmit (line 31) | onSubmit($event: { files: Array<File> }): void {

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.ts
  type MagnetInput (line 4) | interface MagnetInput {
  class AddTorrentMagnetInputComponent (line 13) | class AddTorrentMagnetInputComponent extends AddTorrentDialogComponentDi...
    method constructor (line 16) | constructor(injector: Injector) {
    method onSubmit (line 21) | onSubmit(): void {

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.ts
  class AddTorrentMenuComponent (line 14) | class AddTorrentMenuComponent {
    method constructor (line 41) | constructor(private dialogService: DialogService) {
    method toggle (line 44) | public toggle($event): void {
    method openDialog (line 48) | private openDialog(component: any): void {

FILE: frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.ts
  type URLInput (line 4) | interface URLInput {
  class AddTorrentUrlInputComponent (line 13) | class AddTorrentUrlInputComponent extends AddTorrentDialogComponentDirec...
    method constructor (line 16) | constructor(injector: Injector) {
    method onSubmit (line 21) | onSubmit(): void {

FILE: frontend/src/app/components/api-key-dialog/api-key-dialog.component.ts
  class ApiKeyDialogComponent (line 9) | class ApiKeyDialogComponent{
    method constructor (line 12) | constructor(private ref: DynamicDialogRef) { }
    method onSubmit (line 14) | onSubmit() {

FILE: frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.ts
  class BreakpointOverlayComponent (line 11) | class BreakpointOverlayComponent {
    method constructor (line 18) | constructor(breakpointObserver: BreakpointObserver) {

FILE: frontend/src/app/components/connectivity-status/connectivity-status.component.ts
  class ConnectivityStatusComponent (line 10) | class ConnectivityStatusComponent implements OnChanges {
    method constructor (line 19) | constructor() {
    method ngOnChanges (line 51) | ngOnChanges(changes: SimpleChanges): void {

FILE: frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.ts
  class DeleteTorrentOverlayComponent (line 12) | class DeleteTorrentOverlayComponent {
    method constructor (line 22) | constructor(private api: ApiService) {
    method toggle (line 25) | public toggle($event): void {
    method onRemove (line 29) | onRemove(withData: boolean): void {

FILE: frontend/src/app/components/plugin-enable/plugin-enable.component.ts
  class PluginEnableComponent (line 10) | class PluginEnableComponent implements OnInit {
    method constructor (line 15) | constructor(private ref: DynamicDialogRef, private config: DynamicDial...
    method ngOnInit (line 19) | ngOnInit(): void {
    method onSubmit (line 22) | onSubmit(): void {

FILE: frontend/src/app/components/session-status/session-status.component.ts
  class SessionStatusComponent (line 9) | class SessionStatusComponent implements OnInit {
    method constructor (line 13) | constructor() {
    method ngOnInit (line 16) | ngOnInit(): void {

FILE: frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.ts
  class TorrentDetailsDialogComponent (line 11) | class TorrentDetailsDialogComponent {
    method constructor (line 15) | constructor(private ref: DynamicDialogRef, private config: DynamicDial...
    method onRemoved (line 20) | public onRemoved(): void {
  class TorrentDetailsDialogService (line 29) | class TorrentDetailsDialogService {
    method constructor (line 30) | constructor(private dialogService: DialogService) {
    method open (line 33) | public open(id: string, torrent: Observable<Torrent>): void {

FILE: frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.ts
  type LabelSuggestion (line 7) | interface LabelSuggestion {
  class TorrentEditLabelDialogComponent (line 18) | class TorrentEditLabelDialogComponent {
    method constructor (line 28) | constructor(private ref: DynamicDialogRef, config: DynamicDialogConfig...
    method onDeleteLabel (line 80) | onDeleteLabel($event: { preventDefault: () => void }, name: string): v...
    method onComplete (line 94) | onComplete($event: { query: string }) {
    method onApplySuggestion (line 98) | onApplySuggestion(suggestion: LabelSuggestion): void {
  class TorrentEditLabelService (line 126) | class TorrentEditLabelService {
    method constructor (line 127) | constructor(private dialogService: DialogService) {
    method open (line 130) | public open(id: string, currentLabel?: string): Observable<string> {

FILE: frontend/src/app/components/torrent-label/torrent-label.component.ts
  class TorrentLabelComponent (line 9) | class TorrentLabelComponent {
    method constructor (line 13) | constructor(private editLabelService: TorrentEditLabelService) { }
    method onUpdateLabel (line 15) | onUpdateLabel(): void {

FILE: frontend/src/app/components/torrent-search/torrent-search.component.ts
  type CT (line 7) | interface CT {
  class TorrentSearchComponent (line 29) | class TorrentSearchComponent {
    method constructor (line 37) | constructor(private dialogService: DialogService) {
    method setTarget (line 41) | private setTarget(target: keyof typeof EntryComponents): void {
    method onClear (line 62) | onClear(): void {
    method onChange (line 69) | onChange($event: string): void {
    method onAdd (line 83) | onAdd(): void {

FILE: frontend/src/app/components/torrent-state/torrent-state.component.ts
  class TorrentStateComponent (line 9) | class TorrentStateComponent {
    method constructor (line 12) | constructor() {

FILE: frontend/src/app/components/torrent/torrent.component.ts
  class TorrentComponent (line 11) | class TorrentComponent implements OnInit {
    method constructor (line 17) | constructor(private api: ApiService) {
    method ngOnInit (line 20) | ngOnInit(): void {
    method refreshAfter (line 23) | private refreshAfter(action: Observable<any>): void {
    method onPause (line 32) | public onPause(): void {
    method onResume (line 36) | public onResume(): void {
    method onChangeState (line 40) | public onChangeState(): void {

FILE: frontend/src/app/environment.ts
  type Environment (line 3) | interface Environment {
  constant ENVIRONMENT (line 7) | const ENVIRONMENT = new InjectionToken<Environment>('app.environment');

FILE: frontend/src/app/focus.service.ts
  class FocusService (line 7) | class FocusService {
    method constructor (line 10) | constructor() {
    method observe (line 26) | public get observe(): Observable<boolean> {

FILE: frontend/src/app/order-by.pipe.ts
  class OrderByPipe (line 7) | class OrderByPipe implements PipeTransform {
    method transform (line 9) | transform<T>(values: Array<T>, field: undefined | keyof T, orderType: ...

FILE: frontend/src/app/torrent-search.pipe.ts
  type LabelledTorrent (line 4) | type LabelledTorrent = Label & Torrent;
  class TorrentSearchPipe (line 9) | class TorrentSearchPipe implements PipeTransform {
    method filter (line 11) | private filter(term: string): (t: LabelledTorrent) => boolean {
    method transform (line 31) | transform<T extends LabelledTorrent>(values: Array<T>, term: string): ...

FILE: http.go
  type HandlerFunc (line 9) | type HandlerFunc
  function Send (line 12) | func Send(rw http.ResponseWriter, code int, data interface{}) {
  function NoContent (line 23) | func NoContent(rw http.ResponseWriter) {
  function Handle (line 27) | func Handle(rw http.ResponseWriter, r *http.Request, handler HandlerFunc...

FILE: methods.go
  type DelugeMethod (line 11) | type DelugeMethod
  function torrentIDs (line 13) | func torrentIDs(q url.Values, min int) ([]string, error) {
  function httpTorrentsStatus (line 22) | func httpTorrentsStatus(conn deluge.DelugeClient, r *http.Request) (inte...
  function httpDeleteTorrents (line 32) | func httpDeleteTorrents(conn deluge.DelugeClient, r *http.Request) (inte...
  function httpPauseTorrents (line 55) | func httpPauseTorrents(conn deluge.DelugeClient, r *http.Request) (inter...
  function httpResumeTorrents (line 64) | func httpResumeTorrents(conn deluge.DelugeClient, r *http.Request) (inte...
  type AddTorrentRequest (line 73) | type AddTorrentRequest struct
  type AddTorrentResponse (line 81) | type AddTorrentResponse struct
  function httpAddTorrent (line 85) | func httpAddTorrent(conn deluge.DelugeClient, r *http.Request) (interfac...
  type TorrentMethod (line 117) | type TorrentMethod
  function TorrentHandler (line 119) | func TorrentHandler(f TorrentMethod) DelugeMethod {
  function httpTorrentStatus (line 126) | func httpTorrentStatus(id string, conn deluge.DelugeClient, _ *http.Requ...
  function httpDeleteTorrent (line 130) | func httpDeleteTorrent(id string, conn deluge.DelugeClient, r *http.Requ...
  function httpPauseTorrent (line 143) | func httpPauseTorrent(id string, conn deluge.DelugeClient, _ *http.Reque...
  function httpResumeTorrent (line 147) | func httpResumeTorrent(id string, conn deluge.DelugeClient, _ *http.Requ...
  function httpSetTorrentOptions (line 151) | func httpSetTorrentOptions(id string, conn deluge.DelugeClient, r *http....
  function httpGetSessionStatus (line 162) | func httpGetSessionStatus(conn deluge.DelugeClient, _ *http.Request) (in...
  type GetFreeSpaceResponse (line 166) | type GetFreeSpaceResponse struct
  function httpGetFreeSpace (line 170) | func httpGetFreeSpace(conn deluge.DelugeClient, r *http.Request) (interf...

FILE: methods_labels.go
  function getClientV1 (line 11) | func getClientV1(conn deluge.DelugeClient) (*deluge.Client, error) {
  function labelPluginClient (line 23) | func labelPluginClient(conn deluge.DelugeClient) (*deluge.LabelPlugin, e...
  function httpLabels (line 35) | func httpLabels(conn deluge.DelugeClient, r *http.Request) (interface{},...
  function httpCreateLabel (line 45) | func httpCreateLabel(conn deluge.DelugeClient, r *http.Request) (interfa...
  function httpDeleteLabel (line 62) | func httpDeleteLabel(conn deluge.DelugeClient, r *http.Request) (interfa...
  function httpTorrentsLabels (line 83) | func httpTorrentsLabels(conn deluge.DelugeClient, r *http.Request) (inte...
  type SetTorrentLabelRequest (line 104) | type SetTorrentLabelRequest struct
  function httpSetTorrentLabel (line 109) | func httpSetTorrentLabel(id string, conn deluge.DelugeClient, r *http.Re...

FILE: methods_plugins.go
  function httpGetPlugins (line 10) | func httpGetPlugins(conn deluge.DelugeClient, r *http.Request) (interfac...
  function httpEnablePlugin (line 14) | func httpEnablePlugin(conn deluge.DelugeClient, r *http.Request) (interf...
  function httpDisablePlugin (line 25) | func httpDisablePlugin(conn deluge.DelugeClient, r *http.Request) (inter...

FILE: methods_view.go
  type ViewTorrent (line 12) | type ViewTorrent struct
  type ViewUpdate (line 18) | type ViewUpdate struct
  type ViewUpdateResponse (line 24) | type ViewUpdateResponse struct
  function httpViewUpdate (line 29) | func httpViewUpdate(conn deluge.DelugeClient, r *http.Request) (interfac...

FILE: pool.go
  type DelugeProvider (line 12) | type DelugeProvider
  type poolReq (line 14) | type poolReq struct
    method Send (line 21) | func (req *poolReq) Send(conn deluge.DelugeClient) bool {
  type idleConnection (line 30) | type idleConnection struct
  type Timer (line 35) | type Timer interface
  type timeTimer (line 40) | type timeTimer
    method Ch (line 42) | func (t *timeTimer) Ch() <-chan time.Time {
    method Stop (line 46) | func (t *timeTimer) Stop() bool {
  type nullTimer (line 50) | type nullTimer struct
    method Ch (line 52) | func (nullTimer) Ch() <-chan time.Time {
    method Stop (line 56) | func (nullTimer) Stop() bool {
  function NewConnectionPool (line 60) | func NewConnectionPool(log *zap.Logger, maxConnections int, idleConnecti...
  type ConnectionPool (line 78) | type ConnectionPool struct
    method nextIdle (line 95) | func (pool *ConnectionPool) nextIdle() {
    method idleExpired (line 126) | func (pool *ConnectionPool) idleExpired() {
    method putConn (line 139) | func (pool *ConnectionPool) putConn(conn deluge.DelugeClient) {
    method closeConns (line 169) | func (pool *ConnectionPool) closeConns() {
    method getConn (line 188) | func (pool *ConnectionPool) getConn(req *poolReq) {
    method worker (line 240) | func (pool *ConnectionPool) worker() {
    method Get (line 263) | func (pool *ConnectionPool) Get(ctx context.Context) (deluge.DelugeCli...
    method Put (line 283) | func (pool *ConnectionPool) Put(conn deluge.DelugeClient) {
    method Close (line 292) | func (pool *ConnectionPool) Close() {

FILE: request.go
  constant MaxRequestSize (line 11) | MaxRequestSize = 5 << 20
  function Read (line 15) | func Read(r *http.Request, into interface{}) error {

FILE: response.go
  function WrapResponse (line 11) | func WrapResponse(rw http.ResponseWriter) *WrappedResponse {
  type WrappedResponse (line 19) | type WrappedResponse struct
    method WriteHeader (line 30) | func (rw *WrappedResponse) WriteHeader(code int) {
    method Write (line 44) | func (rw *WrappedResponse) Write(b []byte) (int, error) {
    method Code (line 50) | func (rw *WrappedResponse) Code() int {
    method Started (line 54) | func (rw *WrappedResponse) Started() time.Time {
    method Duration (line 59) | func (rw *WrappedResponse) Duration() time.Duration {
    method Len (line 64) | func (rw *WrappedResponse) Len() int {
    method Error (line 68) | func (rw *WrappedResponse) Error() error {
Condensed preview — 133 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (179K chars).
[
  {
    "path": ".dockerignore",
    "chars": 40,
    "preview": "frontend/node_modules\nfrontend/.angular\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "chars": 2568,
    "preview": "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-l"
  },
  {
    "path": ".gitignore",
    "chars": 83,
    "preview": ".idea\n.angular\ncmd/storm/storm\n\nfrontend/node_modules\nfrontend/.idea\nfrontend/dist\n"
  },
  {
    "path": "Dockerfile",
    "chars": 374,
    "preview": "FROM --platform=${BUILDPLATFORM} golang:alpine as compiler\nARG TARGETOS\nARG TARGETARCH\nENV CGO_ENABLED=0\n\nWORKDIR /go/sr"
  },
  {
    "path": "LICENSE",
    "chars": 1072,
    "preview": "MIT License\n\nCopyright (c) 2020 Jason Kingsbury\n\nPermission is hereby granted, free of charge, to any person obtaining a"
  },
  {
    "path": "README.md",
    "chars": 4257,
    "preview": "\n<p align=\"middle\"><img src=\"frontend/src/assets/logo.svg\" height=\"200\"/></p>\n\n\n> You probably have a whole [media stack"
  },
  {
    "path": "api.go",
    "chars": 9640,
    "preview": "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/"
  },
  {
    "path": "cmd/storm/server.go",
    "chars": 5144,
    "preview": "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 "
  },
  {
    "path": "docker-compose/auth",
    "chars": 21,
    "preview": "localclient:deluge:10"
  },
  {
    "path": "docker-compose/core.conf",
    "chars": 2775,
    "preview": "{\n    \"file\": 1,\n    \"format\": 1\n}{\n    \"add_paused\": false,\n    \"allow_remote\": true,\n    \"auto_manage_prefer_seeds\": f"
  },
  {
    "path": "docker-compose/docker-compose.yml",
    "chars": 440,
    "preview": "version: \"2.1\"\n\nservices:\n  deluge:\n    image: ghcr.io/linuxserver/deluge\n    volumes:\n      - deluge-config:/config\n   "
  },
  {
    "path": "error.go",
    "chars": 1531,
    "preview": "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 co"
  },
  {
    "path": "frontend/.browserslistrc",
    "chars": 853,
    "preview": "# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.\n# For addit"
  },
  {
    "path": "frontend/.editorconfig",
    "chars": 274,
    "preview": "# Editor configuration, see https://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size ="
  },
  {
    "path": "frontend/angular.json",
    "chars": 4225,
    "preview": "{\n  \"$schema\": \"./node_modules/@angular/cli/lib/config/schema.json\",\n  \"version\": 1,\n  \"newProjectRoot\": \"projects\",\n  \""
  },
  {
    "path": "frontend/e2e/protractor.conf.js",
    "chars": 869,
    "preview": "// @ts-check\n// Protractor configuration file, see link for more information\n// https://github.com/angular/protractor/bl"
  },
  {
    "path": "frontend/e2e/src/app.e2e-spec.ts",
    "chars": 638,
    "preview": "import { AppPage } from './app.po';\nimport { browser, logging } from 'protractor';\n\ndescribe('workspace-project App', ()"
  },
  {
    "path": "frontend/e2e/src/app.po.ts",
    "chars": 301,
    "preview": "import { browser, by, element } from 'protractor';\n\nexport class AppPage {\n  navigateTo(): Promise<unknown> {\n    return"
  },
  {
    "path": "frontend/e2e/tsconfig.json",
    "chars": 294,
    "preview": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"../tsconfig.json\",\n  \"compi"
  },
  {
    "path": "frontend/karma.conf.js",
    "chars": 1017,
    "preview": "// Karma configuration file, see link for more information\n// https://karma-runner.github.io/1.0/config/configuration-fi"
  },
  {
    "path": "frontend/package.json",
    "chars": 1494,
    "preview": "{\n  \"name\": \"storm\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"ng\": \"ng\",\n    \"start\": \"ng serve\",\n    \"build\": \"ng buil"
  },
  {
    "path": "frontend/src/app/api.service.spec.ts",
    "chars": 342,
    "preview": "import { TestBed } from '@angular/core/testing';\n\nimport { ApiService } from './api.service';\n\ndescribe('ApiService', ()"
  },
  {
    "path": "frontend/src/app/api.service.ts",
    "chars": 9791,
    "preview": "import {Inject, Injectable} from '@angular/core';\nimport {defer, EMPTY, Observable, ObservableInput, throwError} from 'r"
  },
  {
    "path": "frontend/src/app/app.component.html",
    "chars": 3050,
    "preview": "<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"
  },
  {
    "path": "frontend/src/app/app.component.scss",
    "chars": 1595,
    "preview": ".p-menubar {\n  padding: .5rem 1rem;\n  position: sticky;\n  top: 0;\n  z-index: 1;\n\n  & button.t-menu-button:not(:first-chi"
  },
  {
    "path": "frontend/src/app/app.component.spec.ts",
    "chars": 1054,
    "preview": "import { TestBed } from '@angular/core/testing';\nimport { RouterTestingModule } from '@angular/router/testing';\nimport {"
  },
  {
    "path": "frontend/src/app/app.component.ts",
    "chars": 5327,
    "preview": "import {Component} from '@angular/core';\nimport {BehaviorSubject, combineLatest, EMPTY, forkJoin, Observable, of, timer}"
  },
  {
    "path": "frontend/src/app/app.module.ts",
    "chars": 5149,
    "preview": "import {BrowserModule} from '@angular/platform-browser';\nimport {NgModule} from '@angular/core';\n\nimport {AppComponent} "
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.html",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.scss",
    "chars": 296,
    "preview": ":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  "
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.spec.ts",
    "chars": 683,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ActivityMarkerComponent } from './activity-"
  },
  {
    "path": "frontend/src/app/components/activity-marker/activity-marker.component.ts",
    "chars": 256,
    "preview": "import { Component } from '@angular/core';\n\n@Component({\n  selector: 't-activity-marker',\n  templateUrl: './activity-mar"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.html",
    "chars": 4567,
    "preview": "<!--MaxConnections?: number;-->\n<!--MaxUploadSlots?: number;-->\n<!--MaxUploadSpeed?: number;-->\n<!--MaxDownloadSpeed?: n"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.scss",
    "chars": 46,
    "preview": ":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",
    "chars": 698,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentConfigComponent } from './add-tor"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-config/add-torrent-config.component.ts",
    "chars": 584,
    "preview": "import {Component, Input} from '@angular/core';\nimport {TorrentOptions} from '../../../api.service';\n\n@Component({\n  sel"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-dialog-component.ts",
    "chars": 1770,
    "preview": "import {AddTorrent, AddTorrentRequest, ApiException, ApiService, TorrentOptions} from '../../api.service';\nimport {Dynam"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.html",
    "chars": 423,
    "preview": "<p-fileUpload name=\"torrent\" customUpload=\"true\"\n              [disabled]=\"submitIsDisabled\"\n              (uploadHandle"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.scss",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.spec.ts",
    "chars": 720,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentFileInputComponent } from './add-"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-file-input/add-torrent-file-input.component.ts",
    "chars": 1410,
    "preview": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dial"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.html",
    "chars": 558,
    "preview": "<span class=\"p-input-icon-left\">\n    <i class=\"fas fa-magnet\"></i>\n    <input type=\"text\" pInputText placeholder=\"Magnet"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.scss",
    "chars": 49,
    "preview": "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",
    "chars": 734,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentMagnetInputComponent } from './ad"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-magnet-input/add-torrent-magnet-input.component.ts",
    "chars": 649,
    "preview": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dial"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.html",
    "chars": 72,
    "preview": "<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",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.spec.ts",
    "chars": 684,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentMenuComponent } from './add-torre"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-menu.component.ts",
    "chars": 1636,
    "preview": "import {Component, ViewChild} from '@angular/core';\nimport {MenuItem} from 'primeng/api';\nimport {Menu} from 'primeng/me"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.html",
    "chars": 558,
    "preview": "<span class=\"p-input-icon-left\">\n    <i class=\"fas fa-link\"></i>\n    <input type=\"text\" pInputText placeholder=\"Torrent "
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.scss",
    "chars": 49,
    "preview": "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",
    "chars": 713,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { AddTorrentUrlInputComponent } from './add-t"
  },
  {
    "path": "frontend/src/app/components/add-torrent-menu/add-torrent-url-input/add-torrent-url-input.component.ts",
    "chars": 628,
    "preview": "import {Component, Injector} from '@angular/core';\nimport {AddTorrentDialogComponentDirective} from '../add-torrent-dial"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.html",
    "chars": 414,
    "preview": "<form (ngSubmit)=\"onSubmit()\">\n  <div class=\"p-grid p-fluid\">\n    <div class=\"p-field p-col-12\">\n      <input #input=\"ng"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.scss",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.spec.ts",
    "chars": 670,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ApiKeyDialogComponent } from './api-key-dia"
  },
  {
    "path": "frontend/src/app/components/api-key-dialog/api-key-dialog.component.ts",
    "chars": 400,
    "preview": "import { Component } from '@angular/core';\nimport {DynamicDialogRef} from \"primeng/dynamicdialog\";\n\n@Component({\n  selec"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.html",
    "chars": 498,
    "preview": "<div *ngIf=\"$breakpoint | async; else content\">\n\n  <p-overlayPanel #panel appendTo=\"body\">\n    <div class=\"p-d-flex p-fl"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.scss",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.spec.ts",
    "chars": 704,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { BreakpointOverlayComponent } from './breakp"
  },
  {
    "path": "frontend/src/app/components/breakpoint-overlay/breakpoint-overlay.component.ts",
    "chars": 847,
    "preview": "import {Component, Input} from '@angular/core';\nimport {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';\nimp"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.html",
    "chars": 258,
    "preview": "<div class=\"t-connectivity\" [class.connected]=\"connected\" [class.disconnected]=\"!connected\"\n     [class.transition]=\"ani"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.scss",
    "chars": 382,
    "preview": "\n.t-connectivity {\n  position: fixed;\n  bottom: 0;\n  width: 100%;\n  z-index: 1000;\n  text-align: center;\n  padding: .2re"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.spec.ts",
    "chars": 711,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { ConnectivityStatusComponent } from './conne"
  },
  {
    "path": "frontend/src/app/components/connectivity-status/connectivity-status.component.ts",
    "chars": 1826,
    "preview": "import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';\nimport {concat, Observable, of, Subject, timer"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.html",
    "chars": 578,
    "preview": "<p-overlayPanel #overlay appendTo=\"body\">\n  <ng-template pTemplate>\n      <p-progressSpinner *ngIf=\"processing\"></p-prog"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.scss",
    "chars": 62,
    "preview": ".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",
    "chars": 726,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { DeleteTorrentOverlayComponent } from './del"
  },
  {
    "path": "frontend/src/app/components/delete-torrent-overlay/delete-torrent-overlay.component.ts",
    "chars": 1140,
    "preview": "import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';\nimport {ApiService} from '../../api.ser"
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.html",
    "chars": 299,
    "preview": "<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\""
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.scss",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.spec.ts",
    "chars": 669,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { PluginEnableComponent } from './plugin-enab"
  },
  {
    "path": "frontend/src/app/components/plugin-enable/plugin-enable.component.ts",
    "chars": 792,
    "preview": "import { Component, OnInit } from '@angular/core';\nimport {DynamicDialogConfig, DynamicDialogRef} from \"primeng/dynamicd"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.html",
    "chars": 567,
    "preview": "<div class=\"t-session-status\">\n  <span><i class=\"fas fa-users\"></i>{{sessionStatus.NumPeers}}/{{sessionStatus.DhtNodes}}"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.scss",
    "chars": 454,
    "preview": ".t-session-status {\n  width: 100%;\n  background: #343e4d;\n  color: rgba(255, 255, 255, 0.5);\n  padding: 4px 1rem;\n  disp"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.spec.ts",
    "chars": 676,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { SessionStatusComponent } from './session-st"
  },
  {
    "path": "frontend/src/app/components/session-status/session-status.component.ts",
    "chars": 446,
    "preview": "import {Component, Input, OnInit} from '@angular/core';\nimport {DiskSpace, SessionStatus} from '../../api.service';\n\n@Co"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.html",
    "chars": 2718,
    "preview": "<ng-template #torrentInfinite>\n  ∞\n</ng-template>\n\n<div class=\"p-card p-component\">\n  <div class=\"p-card-body\">\n    <div"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.scss",
    "chars": 993,
    "preview": ".p-card-body {\n  flex-grow: 1;\n  margin-right: .4rem;\n  padding: .4rem;\n\n  & .p-card-title {\n    font-size: 1rem;\n    wo"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.spec.ts",
    "chars": 633,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentComponent } from './torrent.componen"
  },
  {
    "path": "frontend/src/app/components/torrent/torrent.component.ts",
    "chars": 1146,
    "preview": "import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';\nimport {ApiService, Torrent} from '../../a"
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.html",
    "chars": 88,
    "preview": "<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",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.spec.ts",
    "chars": 726,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentDetailsDialogComponent } from './tor"
  },
  {
    "path": "frontend/src/app/components/torrent-details-dialog/torrent-details-dialog.component.ts",
    "chars": 1177,
    "preview": "import {Component, Injectable} from '@angular/core';\nimport {Torrent} from '../../api.service';\nimport {Observable} from"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.html",
    "chars": 1368,
    "preview": "<ng-template #useExistingLabel let-suggestion>\n  <span class=\"t-existing-label\">Use <b>{{suggestion.value}}</b></span>\n "
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.scss",
    "chars": 167,
    "preview": "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-butto"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.spec.ts",
    "chars": 741,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentEditLabelDialogComponent } from './t"
  },
  {
    "path": "frontend/src/app/components/torrent-edit-label-dialog/torrent-edit-label-dialog.component.ts",
    "chars": 4402,
    "preview": "import {Component, Injectable} from '@angular/core';\nimport {DialogService, DynamicDialogConfig, DynamicDialogRef} from "
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.html",
    "chars": 247,
    "preview": "<div (click)=\"onUpdateLabel()\" class=\"t-label-container\">\n  <span class=\"t-label\" pTooltip=\"Torrent label\" *ngIf=\"label "
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.scss",
    "chars": 256,
    "preview": ".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 "
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.spec.ts",
    "chars": 669,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentLabelComponent } from './torrent-lab"
  },
  {
    "path": "frontend/src/app/components/torrent-label/torrent-label.component.ts",
    "chars": 614,
    "preview": "import {Component, Input} from '@angular/core';\nimport {TorrentEditLabelService} from \"../torrent-edit-label-dialog/torr"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.html",
    "chars": 454,
    "preview": "<div class=\"p-d-flex p-flex-row t-search\">\n  <div class=\"p-inputgroup\">\n    <button type=\"button\" pButton pRipple *ngIf="
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.scss",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.spec.ts",
    "chars": 676,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentSearchComponent } from './torrent-se"
  },
  {
    "path": "frontend/src/app/components/torrent-search/torrent-search.component.ts",
    "chars": 2509,
    "preview": "import {Component, EventEmitter, Output} from '@angular/core';\nimport {AddTorrentMagnetInputComponent} from '../add-torr"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.html",
    "chars": 57,
    "preview": "<span class=\"t-state t-state-{{state}}\">{{state}}</span>\n"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.scss",
    "chars": 326,
    "preview": ".t-state {\n  &:before {\n    content: \"\\f111\";\n    font-family: 'Font Awesome 5 Free';\n    font-weight: 400;\n    margin-r"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.spec.ts",
    "chars": 669,
    "preview": "import { ComponentFixture, TestBed } from '@angular/core/testing';\n\nimport { TorrentStateComponent } from './torrent-sta"
  },
  {
    "path": "frontend/src/app/components/torrent-state/torrent-state.component.ts",
    "chars": 329,
    "preview": "import {Component, Input} from '@angular/core';\nimport {State} from '../../api.service';\n\n@Component({\n  selector: 't-to"
  },
  {
    "path": "frontend/src/app/environment.ts",
    "chars": 183,
    "preview": "import {InjectionToken} from \"@angular/core\";\n\nexport interface Environment {\n  baseApiPath: string;\n}\n\nexport const ENV"
  },
  {
    "path": "frontend/src/app/focus.service.spec.ts",
    "chars": 352,
    "preview": "import { TestBed } from '@angular/core/testing';\n\nimport { FocusService } from './focus.service';\n\ndescribe('FocusServic"
  },
  {
    "path": "frontend/src/app/focus.service.ts",
    "chars": 842,
    "preview": "import {Injectable} from '@angular/core';\nimport {BehaviorSubject, Observable} from 'rxjs';\n\n@Injectable({\n  providedIn:"
  },
  {
    "path": "frontend/src/app/order-by.pipe.spec.ts",
    "chars": 192,
    "preview": "import { OrderByPipe } from './order-by.pipe';\n\ndescribe('OrderByPipe', () => {\n  it('create an instance', () => {\n    c"
  },
  {
    "path": "frontend/src/app/order-by.pipe.ts",
    "chars": 618,
    "preview": "import {Pipe, PipeTransform} from '@angular/core';\n\n\n@Pipe({\n  name: 'orderBy'\n})\nexport class OrderByPipe implements Pi"
  },
  {
    "path": "frontend/src/app/torrent-search.pipe.spec.ts",
    "chars": 216,
    "preview": "import { TorrentSearchPipe } from './torrent-search.pipe';\n\ndescribe('TorrentSearchPipe', () => {\n  it('create an instan"
  },
  {
    "path": "frontend/src/app/torrent-search.pipe.ts",
    "chars": 1059,
    "preview": "import {Pipe, PipeTransform} from '@angular/core';\nimport {Label, Torrent} from './api.service';\n\ntype LabelledTorrent ="
  },
  {
    "path": "frontend/src/assets/.gitkeep",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "frontend/src/environments/environment.prod.ts",
    "chars": 51,
    "preview": "export const environment = {\n  production: true\n};\n"
  },
  {
    "path": "frontend/src/environments/environment.ts",
    "chars": 662,
    "preview": "// This file can be replaced during build by using the `fileReplacements` array.\n// `ng build --prod` replaces `environm"
  },
  {
    "path": "frontend/src/icons.scss",
    "chars": 570,
    "preview": "/**\nPrime Icons Adaptor.\nAdapts Prime Icons into FontAwesome icons\n */\n\n.fas, .far {\n  writing-mode: vertical-lr;\n}\n\n.pi"
  },
  {
    "path": "frontend/src/index.html",
    "chars": 852,
    "preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>Storm</title>\n  <base href=\"{{ .BasePath }}\">\n"
  },
  {
    "path": "frontend/src/main.ts",
    "chars": 372,
    "preview": "import { enableProdMode } from '@angular/core';\nimport { platformBrowserDynamic } from '@angular/platform-browser-dynami"
  },
  {
    "path": "frontend/src/polyfills.ts",
    "chars": 2835,
    "preview": "/**\n * This file includes polyfills needed by Angular and is loaded before the app.\n * You can add your own extra polyfi"
  },
  {
    "path": "frontend/src/styles.scss",
    "chars": 1099,
    "preview": "body {\n  background-color: var(--surface-b);\n  padding: 0;\n  margin: 0;\n  min-height: 100%;\n  font-family: var(--font-fa"
  },
  {
    "path": "frontend/src/test.ts",
    "chars": 753,
    "preview": "// This file is required by karma.conf.js and loads recursively all the .spec and framework files\n\nimport 'zone.js/dist/"
  },
  {
    "path": "frontend/tsconfig.app.json",
    "chars": 287,
    "preview": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"./tsconfig.json\",\n  \"compil"
  },
  {
    "path": "frontend/tsconfig.json",
    "chars": 458,
    "preview": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"compileOnSave\": false,\n  \"compilerOpti"
  },
  {
    "path": "frontend/tsconfig.spec.json",
    "chars": 333,
    "preview": "/* To learn more about this file see: https://angular.io/config/tsconfig. */\n{\n  \"extends\": \"./tsconfig.json\",\n  \"compil"
  },
  {
    "path": "frontend/tslint.json",
    "chars": 3183,
    "preview": "{\n  \"extends\": \"tslint:recommended\",\n  \"rulesDirectory\": [\n    \"codelyzer\"\n  ],\n  \"rules\": {\n    \"align\": {\n      \"optio"
  },
  {
    "path": "go.mod",
    "chars": 549,
    "preview": "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"
  },
  {
    "path": "go.sum",
    "chars": 7411,
    "preview": "github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=\ngithub.com/BurntSushi/toml v0.3.1/go.m"
  },
  {
    "path": "http.go",
    "chars": 886,
    "preview": "package storm\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n)\n\n// HandlerFunc is an adaptor for the http.HandlerFunc that retur"
  },
  {
    "path": "methods.go",
    "chars": 4224,
    "preview": "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"
  },
  {
    "path": "methods_labels.go",
    "chars": 2816,
    "preview": "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// getCl"
  },
  {
    "path": "methods_plugins.go",
    "chars": 691,
    "preview": "package storm\n\nimport (\n\tdeluge \"github.com/gdm85/go-libdeluge\"\n\t\"github.com/gorilla/mux\"\n\t\"net/http\"\n)\n\n// httpGetPlugi"
  },
  {
    "path": "methods_view.go",
    "chars": 1934,
    "preview": "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/ht"
  },
  {
    "path": "pool.go",
    "chars": 6647,
    "preview": "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"
  },
  {
    "path": "request.go",
    "chars": 664,
    "preview": "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 "
  },
  {
    "path": "response.go",
    "chars": 1459,
    "preview": "package storm\n\nimport (\n\t\"net/http\"\n\t\"time\"\n)\n\nvar _ http.ResponseWriter = (*WrappedResponse)(nil)\n\n// WrapResponse wrap"
  },
  {
    "path": "static.go",
    "chars": 83,
    "preview": "package storm\n\nimport (\n\t\"embed\"\n)\n\n//go:embed frontend/dist/*\nvar Static embed.FS\n"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the relvacode/storm GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 133 files (154.7 KB), approximately 46.6k tokens, and a symbol index with 258 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!