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)
<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
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
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.