Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2ea2e579 | ||
|
|
b66654787c | ||
|
|
882a3467db | ||
|
|
27ac0bf43e | ||
|
|
4485622354 | ||
|
|
91b2b44768 | ||
|
|
8c276fa0a7 | ||
|
|
78b0e22091 | ||
|
|
96abbdf9a8 | ||
|
|
7c61392ff4 | ||
|
|
901abfb3e5 | ||
|
|
2eea836d9f | ||
|
|
9a08a6603a | ||
|
|
887126f5dd | ||
|
|
fad6a04a5f | ||
|
|
e834445b0b | ||
|
|
1aadd12006 | ||
|
|
26a1f30d32 | ||
|
|
e0a17c6a74 | ||
|
|
7ce1b5001c | ||
|
|
72a7759ca5 | ||
|
|
bf46c9f906 | ||
|
|
14bb85f301 | ||
|
|
e68dccbc17 | ||
|
|
ffc62574ec | ||
|
|
ca0889aaab | ||
|
|
772e12d11c | ||
|
|
7d04487b18 | ||
|
|
0b482116bb | ||
|
|
a579bcd463 | ||
|
|
ab7017ff12 | ||
|
|
3d5bea003a | ||
|
|
bc99dc34ee | ||
|
|
965c449f1c | ||
|
|
4679c6f355 | ||
|
|
a3351f4da8 | ||
|
|
422f13202b | ||
|
|
c470e40737 | ||
|
|
0f92ce2166 | ||
|
|
b1becb9ef5 | ||
|
|
3c1599b6b7 | ||
|
|
3e53b742f4 | ||
|
|
5401593279 | ||
|
|
0710e05479 | ||
|
|
1707d011a2 | ||
|
|
5e8d7944bd | ||
|
|
2d2727f7e8 | ||
|
|
c72282613d | ||
|
|
4ac62a107c | ||
|
|
a102199d5a | ||
|
|
3c799b8783 | ||
|
|
3fbbc7f620 | ||
|
|
461efa7f60 | ||
|
|
1321f8df50 | ||
|
|
a081f3a799 | ||
|
|
e532000ad0 | ||
|
|
8d0dc232d7 | ||
|
|
f5602f1e96 | ||
|
|
d9e1e2f58b | ||
|
|
5d56ed5378 | ||
|
|
4aae655180 | ||
|
|
6860933498 | ||
|
|
377c8d3e4e | ||
|
|
74bbfdf5c2 | ||
|
|
0171fb8569 | ||
|
|
fdc97b4e86 | ||
|
|
eb370d64df | ||
|
|
69bf81b658 | ||
|
|
9125273036 | ||
|
|
ee6f81b9e9 | ||
|
|
72eb51e9c0 | ||
|
|
f3833f1433 | ||
|
|
c79f86137e | ||
|
|
8ef27f7fda | ||
|
|
a1e30ff5db | ||
|
|
3c952d21f7 | ||
|
|
9dbf60e3df |
28
.drone.yml
@@ -45,3 +45,31 @@ steps:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
name: jfa-go-pr
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: golang:latest
|
||||
commands:
|
||||
- apt update -y
|
||||
- apt install build-essential python3-pip curl software-properties-common sed upx -y
|
||||
- (curl -sL https://deb.nodesource.com/setup_14.x | bash -)
|
||||
- apt install nodejs
|
||||
- curl -sL https://git.io/goreleaser > goreleaser.sh
|
||||
- chmod +x goreleaser.sh
|
||||
- ./goreleaser.sh --snapshot --skip-publish --rm-dist
|
||||
|
||||
trigger:
|
||||
event:
|
||||
include:
|
||||
- pull_request
|
||||
|
||||
26
.gitignore
vendored
@@ -1,26 +1,14 @@
|
||||
node_modules/
|
||||
passwordreset*.json
|
||||
mail/*.html
|
||||
scss/*.css*
|
||||
scss/bs4/*.css*
|
||||
scss/bs5/*.css*
|
||||
data/static/*.css
|
||||
data/static/*.js
|
||||
data/static/*.js.map
|
||||
data/static/ts/
|
||||
data/static/modules/
|
||||
!data/static/setup.js
|
||||
data/config-base.json
|
||||
data/config-default.ini
|
||||
data/*.html
|
||||
data/*.txt
|
||||
data/docs/
|
||||
dist/*
|
||||
jfa-go
|
||||
dist/
|
||||
build/
|
||||
pkg/
|
||||
old/
|
||||
data/
|
||||
version.go
|
||||
notes
|
||||
docs/*
|
||||
lang/langtostruct.py
|
||||
config-payload.json
|
||||
!docs/go.mod
|
||||
server.key
|
||||
server.pem
|
||||
server.crt
|
||||
|
||||
@@ -7,14 +7,20 @@ release:
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- rm -rf data/web
|
||||
- mkdir -p data
|
||||
- cp -r static data/web
|
||||
- cp -r css data/web/
|
||||
- npm install
|
||||
- cp node_modules/a17t/dist/a17t.css data/web/css/
|
||||
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
|
||||
- cp -r html data/
|
||||
- cp -r lang data/
|
||||
- python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
|
||||
- python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
|
||||
- python3 -m pip install libsass
|
||||
- npm install
|
||||
- python3 scss/compile.py
|
||||
- python3 mail/generate.py
|
||||
- python3 mail/generate.py -o data/
|
||||
- python3 version.py {{.Version}} version.go
|
||||
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify'
|
||||
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=./data/web/js/ --minify'
|
||||
- go get -u github.com/swaggo/swag/cmd/swag
|
||||
- swag init -g main.go
|
||||
builds:
|
||||
@@ -37,9 +43,8 @@ archives:
|
||||
amd64: x86_64
|
||||
files:
|
||||
- data/*
|
||||
- data/templates/*
|
||||
- data/static/*
|
||||
- data/static/modules/*
|
||||
- data/**/*
|
||||
- data/**/**/*
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
|
||||
8
CONTRIBUTING.md
Normal file
@@ -0,0 +1,8 @@
|
||||
#### Code
|
||||
I use 4 spaces for indentation. Go should ideally be formatted with `goimports` and/or `gofmt`. I don't use a formatter on typescript, so don't worry about that.
|
||||
|
||||
If you need to test your changes:
|
||||
* `make debug` will build everything, and include sourcemaps for typescript. This should be the first thing you run.
|
||||
* `make compile` compiles go into `build/jfa-go`.
|
||||
* `make ts-debug` will compile typescript w/ sourcemaps into `build/data/web/js`.
|
||||
* `make copy` will copy css, html, language and static files into `build/data`.
|
||||
@@ -7,7 +7,7 @@ RUN apt update -y \
|
||||
&& (curl -sL https://deb.nodesource.com/setup_14.x | bash -) \
|
||||
&& apt install nodejs \
|
||||
&& (cd /opt/build; make all; make compress) \
|
||||
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/templates/setup.html
|
||||
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
|
||||
|
||||
FROM golang:latest
|
||||
|
||||
|
||||
56
Makefile
@@ -1,33 +1,30 @@
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install
|
||||
|
||||
configuration:
|
||||
$(info Fixing config-base)
|
||||
python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
|
||||
-mkdir -p build/data
|
||||
python3 config/fixconfig.py -i config/config-base.json -o build/data/config-base.json
|
||||
$(info Generating config-default.ini)
|
||||
python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
|
||||
|
||||
sass:
|
||||
$(info Getting libsass)
|
||||
python3 -m pip install libsass
|
||||
$(info Getting node dependencies)
|
||||
npm install
|
||||
$(info Compiling sass)
|
||||
python3 scss/compile.py
|
||||
python3 config/generate_ini.py -i config/config-base.json -o build/data/config-default.ini
|
||||
|
||||
email:
|
||||
$(info Generating email html)
|
||||
python3 mail/generate.py
|
||||
python3 mail/generate.py -o build/data/
|
||||
|
||||
typescript:
|
||||
$(info Compiling typescript)
|
||||
npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify
|
||||
-rm -r data/static/ts
|
||||
-rm -r data/static/typings
|
||||
-rm data/static/*.map
|
||||
$(info compiling typescript)
|
||||
-mkdir -p build/data/web/js
|
||||
-npx esbuild ts/*.ts ts/modules/*.ts --outdir=./build/data/web/js/
|
||||
|
||||
ts-debug:
|
||||
-npx tsc -p ts/ --sourceMap
|
||||
-rm -r data/static/ts
|
||||
-rm -r data/static/typings
|
||||
cp -r ts data/static/
|
||||
$(info compiling typescript w/ sourcemaps)
|
||||
-mkdir -p build/data/web/js
|
||||
-npx esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./build/data/web/js/
|
||||
-rm -r build/data/web/js/ts
|
||||
$(info copying typescript)
|
||||
cp -r ts build/data/web/js
|
||||
|
||||
swagger:
|
||||
go get github.com/swaggo/swag/cmd/swag
|
||||
@@ -47,11 +44,22 @@ compress:
|
||||
upx --lzma build/jfa-go
|
||||
|
||||
copy:
|
||||
$(info Copying data)
|
||||
cp -r data build/
|
||||
$(info copying css)
|
||||
-mkdir -p build/data/web/css
|
||||
cp -r css build/data/web/
|
||||
cp node_modules/a17t/dist/a17t.css build/data/web/css/
|
||||
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 build/data/web/css/
|
||||
$(info copying html)
|
||||
cp -r html build/data/
|
||||
$(info copying static data)
|
||||
-mkdir -p build/data/web
|
||||
cp -r static/* build/data/web/
|
||||
$(info copying language files)
|
||||
cp -r lang build/data/
|
||||
|
||||
|
||||
install:
|
||||
cp -r build $(DESTDIR)/jfa-go
|
||||
|
||||
all: configuration sass email version typescript swagger compile copy
|
||||
debug: configuration sass email version ts-debug swagger compile copy
|
||||
all: configuration npm email version typescript swagger compile copy
|
||||
debug: configuration npm email version ts-debug swagger compile copy
|
||||
|
||||
26
README.md
@@ -1,6 +1,9 @@
|
||||
# 
|
||||
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
|
||||

|
||||
[](https://drone.hrfee.pw/hrfee/jfa-go)
|
||||
[](https://hub.docker.com/r/hrfee/jfa-go)
|
||||
[](https://weblate.hrfee.pw/engage/jfa-go/)
|
||||
---
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
|
||||
|
||||
I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) in Go mainly as a learning experience, but also to slightly improve speeds and efficiency.
|
||||
|
||||
@@ -21,16 +24,15 @@ I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jelly
|
||||
* 🌓 Customizable look
|
||||
* Specify contact and help messages to appear in emails and pages
|
||||
* Light and dark themes available
|
||||
* Optionally provide custom CSS
|
||||
|
||||
## Interface
|
||||
#### Interface
|
||||
<p align="center">
|
||||
<img src="https://github.com/hrfee/jfa-go/blob/main/images/demo.gif" width="100%"></img>
|
||||
<img src="images/demo.gif" width="100%"></img>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/hrfee/jfa-go/blob/main/images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
|
||||
<img src="https://github.com/hrfee/jfa-go/blob/main/images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
|
||||
<img src="images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
|
||||
<img src="images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
|
||||
</p>
|
||||
|
||||
#### Install
|
||||
@@ -48,6 +50,7 @@ For [docker](https://hub.docker.com/repository/docker/hrfee/jfa-go), run:
|
||||
docker create \
|
||||
--name "jfa-go" \ # Whatever you want to name it
|
||||
-p 8056:8056 \
|
||||
# -p 8057:8057 if using tls
|
||||
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
|
||||
-v /path/to/jellyfin:/jf \ # Path to jellyfin config directory
|
||||
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
|
||||
@@ -87,3 +90,10 @@ If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts`
|
||||
* `~/Library/Application Support/jfa-go` on macOS.
|
||||
|
||||
(or specify config/data path with `-config/-data` respectively.)
|
||||
|
||||
#### Contributing
|
||||
See [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
|
||||
##### Translation
|
||||
[](https://weblate.hrfee.pw/engage/jfa-go/)
|
||||
|
||||
For translations, use the weblate instance [here](https://weblate.hrfee.pw/engage/jfa-go/). You can login with github.
|
||||
|
||||
38
README.md.old
Normal file
@@ -0,0 +1,38 @@
|
||||
This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code.
|
||||
|
||||
#### todo
|
||||
**general**
|
||||
* [x] modal implementation
|
||||
* [x] animations
|
||||
* [x] utilities
|
||||
* [x] CSS for light & dark
|
||||
|
||||
**admin**
|
||||
* [x] invites tab
|
||||
* [x] accounts tab
|
||||
* [x] settings tab
|
||||
* [x] modals
|
||||
* [ ] integration with existing code
|
||||
|
||||
**invites**
|
||||
* [x] page design
|
||||
* [ ] integration with existing code
|
||||
|
||||
#### screenshots
|
||||
##### dark
|
||||
<p>
|
||||
<img src="images/dark/invites.png" alt="invites" style="width: 32%; height: auto;">
|
||||
<img src="images/dark/accounts.png" alt="accounts" style="width: 32%; height: auto;">
|
||||
<img src="images/dark/settings.png" alt="settings" style="width: 32%; height: auto;">
|
||||
<img src="images/dark/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
|
||||
<img src="images/dark/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
|
||||
</p>
|
||||
|
||||
##### light
|
||||
<p>
|
||||
<img src="images/light/invites.png" alt="invites" style="width: 32%; height: auto;">
|
||||
<img src="images/light/accounts.png" alt="accounts" style="width: 32%; height: auto;">
|
||||
<img src="images/light/settings.png" alt="settings" style="width: 32%; height: auto;">
|
||||
<img src="images/light/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
|
||||
<img src="images/light/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
|
||||
</p>
|
||||
258
api.go
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -105,12 +103,12 @@ func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) {
|
||||
}
|
||||
|
||||
func (app *appContext) checkInvites() {
|
||||
current_time := time.Now()
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
for code, data := range app.storage.invites {
|
||||
expiry := data.ValidTill
|
||||
if !current_time.After(expiry) {
|
||||
if !currentTime.After(expiry) {
|
||||
continue
|
||||
}
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
||||
@@ -144,7 +142,7 @@ func (app *appContext) checkInvites() {
|
||||
}
|
||||
|
||||
func (app *appContext) checkInvite(code string, used bool, username string) bool {
|
||||
current_time := time.Now()
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
inv, match := app.storage.invites[code]
|
||||
@@ -152,7 +150,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
return false
|
||||
}
|
||||
expiry := inv.ValidTill
|
||||
if current_time.After(expiry) {
|
||||
if currentTime.After(expiry) {
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
||||
notify := inv.Notify
|
||||
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
@@ -188,7 +186,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses -= 1
|
||||
}
|
||||
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(current_time)})
|
||||
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)})
|
||||
if !del {
|
||||
app.storage.invites[code] = newInv
|
||||
}
|
||||
@@ -256,15 +254,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
}
|
||||
if len(app.storage.policy) != 0 {
|
||||
status, err = app.jf.SetPolicy(id, app.storage.policy)
|
||||
if !(status == 200 || status == 204) {
|
||||
if !(status == 200 || status == 204 || err == nil) {
|
||||
app.err.Printf("%s: Failed to set user policy: Code %d", req.Username, status)
|
||||
app.debug.Printf("%s: Error: %s", req.Username, err)
|
||||
}
|
||||
}
|
||||
if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 {
|
||||
status, err = app.jf.SetConfiguration(id, app.storage.configuration)
|
||||
if (status == 200 || status == 204) && err == nil {
|
||||
status, err = app.jf.SetDisplayPreferences(id, app.storage.displayprefs)
|
||||
} else {
|
||||
}
|
||||
if !((status == 200 || status == 204) && err == nil) {
|
||||
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Username, status)
|
||||
}
|
||||
}
|
||||
@@ -364,8 +364,9 @@ func (app *appContext) NewUser(gc *gin.Context) {
|
||||
if len(profile.Policy) != 0 {
|
||||
app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile)
|
||||
status, err = app.jf.SetPolicy(id, profile.Policy)
|
||||
if !(status == 200 || status == 204) {
|
||||
if !((status == 200 || status == 204) && err == nil) {
|
||||
app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
|
||||
app.debug.Printf("%s: Error: %s", req.Code, err)
|
||||
}
|
||||
}
|
||||
if len(profile.Configuration) != 0 && len(profile.Displayprefs) != 0 {
|
||||
@@ -373,8 +374,10 @@ func (app *appContext) NewUser(gc *gin.Context) {
|
||||
status, err = app.jf.SetConfiguration(id, profile.Configuration)
|
||||
if (status == 200 || status == 204) && err == nil {
|
||||
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
|
||||
} else {
|
||||
}
|
||||
if !((status == 200 || status == 204) && err == nil) {
|
||||
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
|
||||
app.debug.Printf("%s: Error: %s", req.Code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,7 +455,7 @@ func (app *appContext) DeleteUser(gc *gin.Context) {
|
||||
app.err.Printf("%s: Failed to send to %s", userID, address)
|
||||
app.debug.Printf("%s: Error: %s", userID, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent invite email to %s", userID, address)
|
||||
app.info.Printf("%s: Sent deletion email to %s", userID, address)
|
||||
}
|
||||
}(userID, req.Reason, addr.(string))
|
||||
}
|
||||
@@ -482,18 +485,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
app.debug.Println("Generating new invite")
|
||||
app.storage.loadInvites()
|
||||
gc.BindJSON(&req)
|
||||
current_time := time.Now()
|
||||
valid_till := current_time.AddDate(0, 0, req.Days)
|
||||
valid_till = valid_till.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
|
||||
currentTime := time.Now()
|
||||
validTill := currentTime.AddDate(0, 0, req.Days)
|
||||
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
|
||||
// make sure code doesn't begin with number
|
||||
invite_code := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(invite_code[0]))
|
||||
inviteCode := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(inviteCode[0]))
|
||||
for err == nil {
|
||||
invite_code = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(invite_code[0]))
|
||||
inviteCode = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(inviteCode[0]))
|
||||
}
|
||||
var invite Invite
|
||||
invite.Created = current_time
|
||||
invite.Created = currentTime
|
||||
if req.MultipleUses {
|
||||
if req.NoLimit {
|
||||
invite.NoLimit = true
|
||||
@@ -503,21 +506,21 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
} else {
|
||||
invite.RemainingUses = 1
|
||||
}
|
||||
invite.ValidTill = valid_till
|
||||
invite.ValidTill = validTill
|
||||
if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
app.debug.Printf("%s: Sending invite email", invite_code)
|
||||
app.debug.Printf("%s: Sending invite email", inviteCode)
|
||||
invite.Email = req.Email
|
||||
msg, err := app.email.constructInvite(invite_code, invite, app)
|
||||
msg, err := app.email.constructInvite(inviteCode, invite, app)
|
||||
if err != nil {
|
||||
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
|
||||
app.err.Printf("%s: Failed to construct invite email", invite_code)
|
||||
app.debug.Printf("%s: Error: %s", invite_code, err)
|
||||
app.err.Printf("%s: Failed to construct invite email", inviteCode)
|
||||
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
||||
} else if err := app.email.send(req.Email, msg); err != nil {
|
||||
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
|
||||
app.err.Printf("%s: %s", invite_code, invite.Email)
|
||||
app.debug.Printf("%s: Error: %s", invite_code, err)
|
||||
app.err.Printf("%s: %s", inviteCode, invite.Email)
|
||||
app.debug.Printf("%s: Error: %s", inviteCode, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
|
||||
app.info.Printf("%s: Sent invite email to %s", inviteCode, req.Email)
|
||||
}
|
||||
}
|
||||
if req.Profile != "" {
|
||||
@@ -527,7 +530,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
invite.Profile = "Default"
|
||||
}
|
||||
}
|
||||
app.storage.invites[invite_code] = invite
|
||||
app.storage.invites[inviteCode] = invite
|
||||
app.storage.storeInvites()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -662,6 +665,9 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
|
||||
gc.BindJSON(&req)
|
||||
name := req.Name
|
||||
if _, ok := app.storage.profiles[name]; ok {
|
||||
if app.storage.defaultProfile == name {
|
||||
app.storage.defaultProfile = ""
|
||||
}
|
||||
delete(app.storage.profiles, name)
|
||||
}
|
||||
app.storage.storeProfiles()
|
||||
@@ -834,10 +840,25 @@ type dateToParse struct {
|
||||
Parsed time.Time `json:"parseme"`
|
||||
}
|
||||
|
||||
// json magically parses datetimes so why not
|
||||
func parseDt(date string) time.Time {
|
||||
func parseDT(date string) time.Time {
|
||||
// decent method
|
||||
dt, err := time.Parse("2006-01-02T15:04:05.000000", date)
|
||||
if err == nil {
|
||||
return dt
|
||||
}
|
||||
// emby method
|
||||
dt, err = time.Parse("2006-01-02T15:04:05.0000000+00:00", date)
|
||||
if err == nil {
|
||||
return dt
|
||||
}
|
||||
// magic method
|
||||
// some stored dates from jellyfin have no timezone at the end, if not we assume UTC
|
||||
if date[len(date)-1] != 'Z' {
|
||||
date += "Z"
|
||||
}
|
||||
timeJSON := []byte("{ \"parseme\": \"" + date + "\" }")
|
||||
var parsed dateToParse
|
||||
// Magically turn it into a time.Time
|
||||
json.Unmarshal(timeJSON, &parsed)
|
||||
return parsed.Parsed
|
||||
}
|
||||
@@ -864,7 +885,7 @@ func (app *appContext) GetUsers(gc *gin.Context) {
|
||||
var user respUser
|
||||
user.LastActive = "n/a"
|
||||
if jfUser["LastActivityDate"] != nil {
|
||||
date := parseDt(jfUser["LastActivityDate"].(string))
|
||||
date := parseDT(jfUser["LastActivityDate"].(string))
|
||||
user.LastActive = app.formatDatetime(date)
|
||||
}
|
||||
user.ID = jfUser["Id"].(string)
|
||||
@@ -972,7 +993,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param userSettingsDTO body userSettingsDTO true "Parameters for applying settings"
|
||||
// @Success 200 {object} errorListDTO
|
||||
// @Failure 500 {object} errorListDTO "Lists of errors that occured while applying settings"
|
||||
// @Failure 500 {object} errorListDTO "Lists of errors that occurred while applying settings"
|
||||
// @Router /users/settings [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
@@ -1056,60 +1077,56 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
|
||||
// @Summary Get jfa-go configuration.
|
||||
// @Produce json
|
||||
// @Success 200 {object} configDTO "Uses the same format as config-base.json"
|
||||
// @Success 200 {object} settings "Uses the same format as config-base.json"
|
||||
// @Router /config [get]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetConfig(gc *gin.Context) {
|
||||
app.info.Println("Config requested")
|
||||
resp := map[string]interface{}{}
|
||||
langPath := filepath.Join(app.local_path, "lang", "form")
|
||||
app.lang.langFiles, _ = ioutil.ReadDir(langPath)
|
||||
app.lang.langOptions = make([]string, len(app.lang.langFiles))
|
||||
chosenLang := app.config.Section("ui").Key("language").MustString("en-us") + ".json"
|
||||
for i, f := range app.lang.langFiles {
|
||||
if f.Name() == chosenLang {
|
||||
app.lang.chosenIndex = i
|
||||
}
|
||||
var langFile map[string]interface{}
|
||||
file, _ := ioutil.ReadFile(filepath.Join(langPath, f.Name()))
|
||||
json.Unmarshal(file, &langFile)
|
||||
|
||||
if meta, ok := langFile["meta"]; ok {
|
||||
app.lang.langOptions[i] = meta.(map[string]interface{})["name"].(string)
|
||||
}
|
||||
}
|
||||
for section, settings := range app.configBase {
|
||||
if section == "order" {
|
||||
resp[section] = settings.([]interface{})
|
||||
} else {
|
||||
resp[section] = make(map[string]interface{})
|
||||
for key, values := range settings.(map[string]interface{}) {
|
||||
if key == "order" {
|
||||
resp[section].(map[string]interface{})[key] = values.([]interface{})
|
||||
} else {
|
||||
resp[section].(map[string]interface{})[key] = values.(map[string]interface{})
|
||||
if key != "meta" {
|
||||
dataType := resp[section].(map[string]interface{})[key].(map[string]interface{})["type"].(string)
|
||||
configKey := app.config.Section(section).Key(key)
|
||||
if dataType == "number" {
|
||||
if val, err := configKey.Int(); err == nil {
|
||||
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = val
|
||||
}
|
||||
} else if dataType == "bool" {
|
||||
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.MustBool(false)
|
||||
} else if dataType == "select" && key == "language" {
|
||||
resp[section].(map[string]interface{})[key].(map[string]interface{})["options"] = app.lang.langOptions
|
||||
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = app.lang.langOptions[app.lang.chosenIndex]
|
||||
} else {
|
||||
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
resp := app.configBase
|
||||
// Load language options
|
||||
formChosen, formOptions := app.storage.lang.Form.getOptions(app.config.Section("ui").Key("language-form").MustString("en-us"))
|
||||
fl := resp.Sections["ui"].Settings["language-form"]
|
||||
fl.Options = formOptions
|
||||
fl.Value = formChosen
|
||||
adminChosen, adminOptions := app.storage.lang.Admin.getOptions(app.config.Section("ui").Key("language-admin").MustString("en-us"))
|
||||
al := resp.Sections["ui"].Settings["language-admin"]
|
||||
al.Options = adminOptions
|
||||
al.Value = adminChosen
|
||||
emailChosen, emailOptions := app.storage.lang.Email.getOptions(app.config.Section("email").Key("language").MustString("en-us"))
|
||||
el := resp.Sections["email"].Settings["language"]
|
||||
el.Options = emailOptions
|
||||
el.Value = emailChosen
|
||||
for sectName, section := range resp.Sections {
|
||||
for settingName, setting := range section.Settings {
|
||||
val := app.config.Section(sectName).Key(settingName)
|
||||
s := resp.Sections[sectName].Settings[settingName]
|
||||
switch setting.Type {
|
||||
case "text", "email", "select", "password":
|
||||
s.Value = val.MustString("")
|
||||
case "number":
|
||||
s.Value = val.MustInt(0)
|
||||
case "bool":
|
||||
s.Value = val.MustBool(false)
|
||||
}
|
||||
resp.Sections[sectName].Settings[settingName] = s
|
||||
}
|
||||
}
|
||||
// resp["jellyfin"].(map[string]interface{})["language"].(map[string]interface{})["options"].([]string)
|
||||
resp.Sections["ui"].Settings["language-form"] = fl
|
||||
resp.Sections["ui"].Settings["language-admin"] = al
|
||||
resp.Sections["email"].Settings["language"] = el
|
||||
|
||||
t := resp.Sections["jellyfin"].Settings["type"]
|
||||
opts := make([]string, len(serverTypes))
|
||||
i := 0
|
||||
for _, v := range serverTypes {
|
||||
opts[i] = v
|
||||
i++
|
||||
}
|
||||
t.Options = opts
|
||||
t.Value = serverTypes[app.config.Section("jellyfin").Key("type").MustString("jellyfin")]
|
||||
resp.Sections["jellyfin"].Settings["type"] = t
|
||||
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
@@ -1124,7 +1141,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
app.info.Println("Config modification requested")
|
||||
var req configDTO
|
||||
gc.BindJSON(&req)
|
||||
tempConfig, _ := ini.Load(app.config_path)
|
||||
tempConfig, _ := ini.Load(app.configPath)
|
||||
for section, settings := range req {
|
||||
if section != "restart-program" {
|
||||
_, err := tempConfig.GetSection(section)
|
||||
@@ -1132,20 +1149,41 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
tempConfig.NewSection(section)
|
||||
}
|
||||
for setting, value := range settings.(map[string]interface{}) {
|
||||
if section == "ui" && setting == "language" {
|
||||
for i, lang := range app.lang.langOptions {
|
||||
if value.(string) == lang {
|
||||
tempConfig.Section(section).Key(setting).SetValue(strings.Replace(app.lang.langFiles[i].Name(), ".json", "", 1))
|
||||
if section == "ui" && setting == "language-form" {
|
||||
for key, lang := range app.storage.lang.Form {
|
||||
if lang.Meta.Name == value.(string) {
|
||||
tempConfig.Section("ui").Key("language-form").SetValue(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if section == "ui" && setting == "language-admin" {
|
||||
for key, lang := range app.storage.lang.Admin {
|
||||
if lang.Meta.Name == value.(string) {
|
||||
tempConfig.Section("ui").Key("language-admin").SetValue(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if section == "email" && setting == "language" {
|
||||
for key, lang := range app.storage.lang.Email {
|
||||
if lang.Meta.Name == value.(string) {
|
||||
tempConfig.Section("email").Key("language").SetValue(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if section == "jellyfin" && setting == "type" {
|
||||
for k, v := range serverTypes {
|
||||
if v == value.(string) {
|
||||
tempConfig.Section("jellyfin").Key("type").SetValue(k)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
|
||||
tempConfig.Section(section).Key(setting).SetValue(value.(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tempConfig.SaveTo(app.config_path)
|
||||
tempConfig.SaveTo(app.configPath)
|
||||
app.debug.Println("Config saved")
|
||||
gc.JSON(200, map[string]bool{"success": true})
|
||||
if req["restart-program"] != nil && req["restart-program"].(bool) {
|
||||
@@ -1160,11 +1198,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
if _, ok := req["password_validation"]; ok {
|
||||
app.debug.Println("Reinitializing validator")
|
||||
validatorConf := ValidatorConf{
|
||||
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
|
||||
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
|
||||
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
|
||||
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
|
||||
"special characters": app.config.Section("password_validation").Key("special").MustInt(0),
|
||||
"length": app.config.Section("password_validation").Key("min_length").MustInt(0),
|
||||
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
|
||||
"lowercase": app.config.Section("password_validation").Key("lower").MustInt(0),
|
||||
"number": app.config.Section("password_validation").Key("number").MustInt(0),
|
||||
"special": app.config.Section("password_validation").Key("special").MustInt(0),
|
||||
}
|
||||
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
|
||||
for key := range validatorConf {
|
||||
@@ -1193,6 +1231,44 @@ func (app *appContext) Logout(gc *gin.Context) {
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns a map of available language codes to their full names, usable in the lang query parameter.
|
||||
// @Produce json
|
||||
// @Success 200 {object} langDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /lang [get]
|
||||
// @tags Other
|
||||
func (app *appContext) GetLanguages(gc *gin.Context) {
|
||||
page := gc.Param("page")
|
||||
resp := langDTO{}
|
||||
if page == "form" {
|
||||
for key, lang := range app.storage.lang.Form {
|
||||
resp[key] = lang.Meta.Name
|
||||
}
|
||||
} else if page == "admin" {
|
||||
for key, lang := range app.storage.lang.Admin {
|
||||
resp[key] = lang.Meta.Name
|
||||
}
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
respond(500, "Couldn't get languages", gc)
|
||||
return
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
func (app *appContext) ServeLang(gc *gin.Context) {
|
||||
page := gc.Param("page")
|
||||
lang := strings.Replace(gc.Param("file"), ".json", "", 1)
|
||||
if page == "admin" {
|
||||
gc.JSON(200, app.storage.lang.Admin[lang])
|
||||
return
|
||||
} else if page == "form" {
|
||||
gc.JSON(200, app.storage.lang.Form[lang])
|
||||
return
|
||||
}
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
|
||||
// func Restart() error {
|
||||
// defer func() {
|
||||
// if r := recover(); r != nil {
|
||||
|
||||
76
config.go
@@ -4,39 +4,14 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
/*var DeCamel ini.NameMapper = func(raw string) string {
|
||||
out := make([]rune, 0, len(raw))
|
||||
upper := 0
|
||||
for _, c := range raw {
|
||||
if unicode.IsUpper(c) {
|
||||
upper++
|
||||
}
|
||||
if upper == 2 {
|
||||
out = append(out, '_')
|
||||
upper = 0
|
||||
}
|
||||
out = append(out, unicode.ToLower(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (app *appContext) loadDefaults() (err error) {
|
||||
var cfb []byte
|
||||
cfb, err = ioutil.ReadFile(app.configBase_path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(cfb, app.defaults)
|
||||
return
|
||||
}*/
|
||||
|
||||
func (app *appContext) loadConfig() error {
|
||||
var err error
|
||||
app.config, err = ini.Load(app.config_path)
|
||||
app.config, err = ini.Load(app.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -44,41 +19,48 @@ func (app *appContext) loadConfig() error {
|
||||
app.config.Section("jellyfin").Key("public_server").SetValue(app.config.Section("jellyfin").Key("public_server").MustString(app.config.Section("jellyfin").Key("server").String()))
|
||||
|
||||
for _, key := range app.config.Section("files").Keys() {
|
||||
// if key.MustString("") == "" && key.Name() != "custom_css" {
|
||||
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
|
||||
// }
|
||||
if key.Name() != "html_templates" {
|
||||
key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json"))))
|
||||
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template"} {
|
||||
// if app.config.Section("files").Key(key).MustString("") == "" {
|
||||
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
|
||||
// }
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.data_path, (key + ".json"))))
|
||||
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
|
||||
}
|
||||
|
||||
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
|
||||
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
|
||||
|
||||
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.local_path, "email.html")))
|
||||
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.local_path, "email.txt")))
|
||||
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.localPath, "email.html")))
|
||||
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.localPath, "email.txt")))
|
||||
|
||||
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.local_path, "invite-email.html")))
|
||||
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.local_path, "invite-email.txt")))
|
||||
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.localPath, "invite-email.html")))
|
||||
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.localPath, "invite-email.txt")))
|
||||
|
||||
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.local_path, "expired.html")))
|
||||
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.local_path, "expired.txt")))
|
||||
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.localPath, "expired.html")))
|
||||
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt")))
|
||||
|
||||
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
|
||||
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
|
||||
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.localPath, "created.html")))
|
||||
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.localPath, "created.txt")))
|
||||
|
||||
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.local_path, "deleted.html")))
|
||||
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.local_path, "deleted.txt")))
|
||||
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.localPath, "deleted.html")))
|
||||
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.localPath, "deleted.txt")))
|
||||
|
||||
app.config.Section("jellyfin").Key("version").SetValue(VERSION)
|
||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", VERSION, COMMIT))
|
||||
|
||||
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
oldFormLang := app.config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenFormLang = oldFormLang
|
||||
}
|
||||
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenFormLang = newFormLang
|
||||
}
|
||||
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
|
||||
|
||||
app.email = NewEmailer(app)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -9,17 +9,17 @@ args = parser.parse_args()
|
||||
with open(args.input, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
newconfig = {"order": []}
|
||||
newconfig = {"sections": {}, "order": []}
|
||||
|
||||
for sect in config:
|
||||
for sect in config["sections"]:
|
||||
newconfig["order"].append(sect)
|
||||
newconfig[sect] = {}
|
||||
newconfig[sect]["order"] = []
|
||||
newconfig[sect]["meta"] = config[sect]["meta"]
|
||||
for setting in config[sect]:
|
||||
if setting != "meta":
|
||||
newconfig[sect]["order"].append(setting)
|
||||
newconfig[sect][setting] = config[sect][setting]
|
||||
newconfig["sections"][sect] = {}
|
||||
newconfig["sections"][sect]["order"] = []
|
||||
newconfig["sections"][sect]["meta"] = config["sections"][sect]["meta"]
|
||||
newconfig["sections"][sect]["settings"] = {}
|
||||
for setting in config["sections"][sect]["settings"]:
|
||||
newconfig["sections"][sect]["order"].append(setting)
|
||||
newconfig["sections"][sect]["settings"][setting] = config["sections"][sect]["settings"][setting]
|
||||
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(json.dumps(newconfig, indent=4))
|
||||
|
||||
@@ -14,18 +14,19 @@ def generate_ini(base_file, ini_file):
|
||||
|
||||
ini = configparser.RawConfigParser(allow_no_value=True)
|
||||
|
||||
for section in config_base:
|
||||
for section in config_base["sections"]:
|
||||
ini.add_section(section)
|
||||
for entry in config_base[section]:
|
||||
if "description" in config_base[section][entry]:
|
||||
ini.set(section, "; " + config_base[section][entry]["description"])
|
||||
if entry != "meta":
|
||||
value = config_base[section][entry]["value"]
|
||||
if isinstance(value, bool):
|
||||
value = str(value).lower()
|
||||
else:
|
||||
value = str(value)
|
||||
ini.set(section, entry, value)
|
||||
if "meta" in config_base["sections"][section]:
|
||||
ini.set(section, "; " + config_base["sections"][section]["meta"]["description"])
|
||||
for entry in config_base["sections"][section]["settings"]:
|
||||
if "description" in config_base["sections"][section]["settings"][entry]:
|
||||
ini.set(section, "; " + config_base["sections"][section]["settings"][entry]["description"])
|
||||
value = config_base["sections"][section]["settings"][entry]["value"]
|
||||
if isinstance(value, bool):
|
||||
value = str(value).lower()
|
||||
else:
|
||||
value = str(value)
|
||||
ini.set(section, entry, value)
|
||||
|
||||
with open(Path(ini_file), "w") as config_file:
|
||||
ini.write(config_file)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import json
|
||||
|
||||
with open("config-formatted.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
indent = 0
|
||||
|
||||
|
||||
def writeln(ln):
|
||||
global indent
|
||||
if "}" in ln and "{" not in ln:
|
||||
indent -= 1
|
||||
s.write(("\t" * indent) + ln + "\n")
|
||||
if "{" in ln and "}" not in ln:
|
||||
indent += 1
|
||||
|
||||
|
||||
with open("configStruct.go", "w") as s:
|
||||
writeln("package main")
|
||||
writeln("")
|
||||
writeln("type Metadata struct{")
|
||||
writeln('Name string `json:"name"`')
|
||||
writeln('Description string `json:"description"`')
|
||||
writeln("}")
|
||||
writeln("")
|
||||
writeln("type Config struct{")
|
||||
if "order" in config:
|
||||
writeln('Order []string `json:"order"`')
|
||||
for section in [x for x in config.keys() if x != "order"]:
|
||||
title = "".join([x.title() for x in section.split("_")])
|
||||
writeln(title + " struct{")
|
||||
if "order" in config[section]:
|
||||
writeln('Order []string `json:"order"`')
|
||||
if "meta" in config[section]:
|
||||
writeln('Meta Metadata `json:"meta"`')
|
||||
for setting in [
|
||||
x for x in config[section].keys() if x != "order" and x != "meta"
|
||||
]:
|
||||
name = "".join([x.title() for x in setting.split("_")])
|
||||
writeln(name + " struct{")
|
||||
writeln('Name string `json:"name"`')
|
||||
writeln('Required bool `json:"required"`')
|
||||
writeln('Restart bool `json:"requires_restart"`')
|
||||
writeln('Description string `json:"description"`')
|
||||
writeln('Type string `json:"type"`')
|
||||
dt = config[section][setting]["type"]
|
||||
if dt == "select":
|
||||
dt = "string"
|
||||
writeln('Options []string `json:"options"`')
|
||||
elif dt == "number":
|
||||
dt = "int"
|
||||
elif dt != "bool":
|
||||
dt = "string"
|
||||
writeln(f'Value {dt} `json:"value" cfg:"{setting}"`')
|
||||
writeln("} " + f'`json:"{setting}" cfg:"{setting}"`')
|
||||
writeln("} " + f'`json:"{section}"`')
|
||||
writeln("}")
|
||||
377
css/base.css
Normal file
@@ -0,0 +1,377 @@
|
||||
@import "a17t.css";
|
||||
@import "remixicon.css";
|
||||
@import "modal.css";
|
||||
@import "dark.css";
|
||||
@import "tooltip.css";
|
||||
@import "loader.css";
|
||||
|
||||
:root {
|
||||
--border-width-default: 2px;
|
||||
--border-width-2: 3px;
|
||||
--border-width-4: 5px;
|
||||
--border-width-8: 8px;
|
||||
}
|
||||
|
||||
.light-theme {
|
||||
--settings-section-button-filter: 90%;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.page-container {
|
||||
margin: 2%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
:root {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.table-responsive table {
|
||||
min-width: 660px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tab-button {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.mb-half {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mt-half {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.ml-half {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.pb-1 {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pl-1 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.al {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
align-items: top;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-expand {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-row-group {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row.baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex: 1;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.col {
|
||||
flex: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.fr {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
background-color: inherit; /* so we can use a17t code blocks */
|
||||
}
|
||||
|
||||
sup.\~critical, .text-critical {
|
||||
color: var(--color-critical-normal-content);
|
||||
}
|
||||
|
||||
.grey {
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.aside.sm {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.support.lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.badge.lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inv-created-users strong,p {
|
||||
padding-left: 0.5rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.inv-created-users.empty strong,p {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.inv {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.inv-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inv-profilearea {
|
||||
min-width: 20%;
|
||||
}
|
||||
|
||||
.inv-profileselect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.inv-codearea {
|
||||
max-width: 40%;
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inv-empty .inv-codearea {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
|
||||
.invite-link {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-pad {
|
||||
padding: 0px 0px 0px 0px;
|
||||
}
|
||||
|
||||
.elem-pad > * {
|
||||
margin: var(--spacing-4, 1rem);
|
||||
}
|
||||
|
||||
.icon.clickable {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box; /* fixes weird length issue with inputs */
|
||||
}
|
||||
|
||||
.button.lg {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.submit {
|
||||
border: none;
|
||||
outline: none; /* remove browser styling on submit buttons */
|
||||
}
|
||||
|
||||
.full-width { /* full width inputs */
|
||||
box-sizing: border-box; /* TODO: maybe remove if we figure out input thing? */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-lp {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focused {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.unfocused {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rotated {
|
||||
transform: rotate(180deg);
|
||||
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
}
|
||||
|
||||
.not-rotated {
|
||||
transform: rotate(0deg);
|
||||
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
|
||||
}
|
||||
|
||||
.stealth-input {
|
||||
font-size: 1rem;
|
||||
padding-top: 0.1rem;
|
||||
padding-bottom: 0.1rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.stealth-input-hidden {
|
||||
border-style: none;
|
||||
--fallback-box-shadow: none;
|
||||
--field-hover-box-shadow: none;
|
||||
--field-focus-box-shadow: none;
|
||||
padding-top: 0.1rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.settings-section-button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
background-color: rgba(0,0,0,0);
|
||||
}
|
||||
|
||||
.settings-section-button:hover, .settings-section-button:focus {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
background-color: var(--color-neutral-normal-fill);
|
||||
filter: brightness(var(--settings-section-button-filter)) !important;
|
||||
}
|
||||
|
||||
.settings-section-button.selected {
|
||||
background-color: var(--color-neutral-normal-fill);
|
||||
--buton-filter-brightness: var(--settings-section-button-filter);
|
||||
filter: brightness(var(--settings-section-button-filter)) !important;
|
||||
}
|
||||
|
||||
.setting {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.overflow-y {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
select, textarea {
|
||||
color: inherit;
|
||||
border: 0 solid var(--color-neutral-300);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
border: 0 solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
p.top {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#notification-box {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 16;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
87
css/dark.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.dark-theme {
|
||||
|
||||
--settings-section-button-filter: 110%;
|
||||
|
||||
--color-neutral-900: rgba(255, 255, 255, 0.87);
|
||||
--color-neutral-800: rgba(255, 255, 255, 0.8);
|
||||
--color-neutral-700: rgba(255, 255, 255, 0.73);
|
||||
--color-neutral-600: rgba(255, 255, 255, 0.66);
|
||||
--color-neutral-500: rgb(153, 153, 153);
|
||||
--color-neutral-400: #383838;
|
||||
--color-neutral-300: #303030;
|
||||
--color-neutral-200: #292929;
|
||||
--color-neutral-100: #242424;
|
||||
--color-neutral-50: #202020;
|
||||
--color-neutral-000: #101010;
|
||||
|
||||
--color-critical-900: #fef2f2;
|
||||
--color-critical-800: #fee2e2;
|
||||
--color-critical-700: #fecaca;
|
||||
--color-critical-600: #fca5a5;
|
||||
--color-critical-500: #f87171;
|
||||
--color-critical-400: #ef4444;
|
||||
--color-critical-300: #dc2626;
|
||||
--color-critical-200: #b91c1c;
|
||||
--color-critical-100: #991b1b;
|
||||
--color-critical-50: #7f1d1d;
|
||||
--color-critical-000: #441313;
|
||||
|
||||
--color-warning-900: #fffbeb;
|
||||
--color-warning-800: #fef3c7;
|
||||
--color-warning-700: #fde68a;
|
||||
--color-warning-600: #fcd34d;
|
||||
--color-warning-500: #fbbf24;
|
||||
--color-warning-400: #f59e0b;
|
||||
--color-warning-300: #d97706;
|
||||
--color-warning-200: #b45309;
|
||||
--color-warning-100: #92400e;
|
||||
--color-warning-50: #783900;
|
||||
--color-warning-000: #411e01;
|
||||
|
||||
--color-positive-900: #f0fdf4;
|
||||
--color-positive-800: #dcfce7;
|
||||
--color-positive-700: #bbf7d0;
|
||||
--color-positive-600: #86efac;
|
||||
--color-positive-500: #4ade80;
|
||||
--color-positive-400: #22c55e;
|
||||
--color-positive-300: #16a34a;
|
||||
--color-positive-200: #15803d;
|
||||
--color-positive-100: #166534;
|
||||
--color-positive-50: #14532d;
|
||||
--color-positive-000: #0f2e1b;
|
||||
|
||||
--color-urge-900: #e0ffff;
|
||||
--color-urge-800: #c0fbff;
|
||||
--color-urge-700: #a0f4ff;
|
||||
--color-urge-600: #80e9ff;
|
||||
--color-urge-500: #60dbfb;
|
||||
--color-urge-400: #40cbf3;
|
||||
--color-urge-300: #20b9e9;
|
||||
--color-urge-200: #00a4dc; /* tab buttons */
|
||||
--color-urge-100: #0054bc;
|
||||
--color-urge-50: #00169a;
|
||||
--color-urge-000: #050076;
|
||||
|
||||
--color-info-900: #f5f3ff;
|
||||
--color-info-800: #ede9fe;
|
||||
--color-info-700: #ddd6fe;
|
||||
--color-info-600: #c4b5fd;
|
||||
--color-info-500: #a78bfa;
|
||||
--color-info-400: #8b5cf6;
|
||||
--color-info-300: #7c3aed;
|
||||
--color-info-200: #6d28d9;
|
||||
--color-info-100: #5b21b6;
|
||||
--color-info-50: #4c1d95;
|
||||
--color-info-000: #240e44;
|
||||
|
||||
|
||||
--color-neutral-normal-content: #ffffff;
|
||||
}
|
||||
|
||||
.light-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark-only {
|
||||
display: initial;
|
||||
}
|
||||
40
css/loader.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.loader {
|
||||
height: auto;
|
||||
color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.loader .dot {
|
||||
--diameter: 0.5rem;
|
||||
--radius: calc(var(--diameter) / 2);
|
||||
--deviation: 20%;
|
||||
height: var(--diameter);
|
||||
width: var(--diameter);
|
||||
background-color: var(--color-content);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
left: calc(50% - var(--radius));
|
||||
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
|
||||
}
|
||||
.loader.loader-sm .dot {
|
||||
--deviation: 10%;
|
||||
}
|
||||
|
||||
@keyframes osc {
|
||||
25% {
|
||||
left: calc(50% + var(--deviation) - var(--radius));
|
||||
}
|
||||
75% {
|
||||
left: calc(50% - var(--deviation));
|
||||
}
|
||||
0%, 50%, 100% {
|
||||
left: calc(50% - var(--radius));
|
||||
}
|
||||
/*
|
||||
0%, 100% {
|
||||
left: calc(50% - var(--deviation))
|
||||
}
|
||||
50% {
|
||||
left: calc(50% + var(--deviation) - var(--radius));
|
||||
}
|
||||
*/
|
||||
}
|
||||
72
css/modal.css
Normal file
@@ -0,0 +1,72 @@
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 12;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,40%);
|
||||
}
|
||||
|
||||
.modal-shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes modal-hide {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.modal-hiding {
|
||||
animation: modal-hide 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@keyframes modal-content-show {
|
||||
from {
|
||||
opacity: 0;
|
||||
top: -6rem;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
margin: 10% auto;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.modal-content.wide {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.modal-shown .modal-content {
|
||||
animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.modal-content.wide {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.modal-content, .modal-content.wide {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
float: right;
|
||||
color: #aaa;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.modal-close:hover,
|
||||
.modal-close:focus {
|
||||
filter: brightness(60%);
|
||||
}
|
||||
36
css/tooltip.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip .content {
|
||||
visibility: hidden;
|
||||
max-width: 10rem;
|
||||
min-width: 6rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1rem;
|
||||
}
|
||||
|
||||
.tooltip.right .content {
|
||||
left: 120%;
|
||||
}
|
||||
|
||||
.tooltip.left .content {
|
||||
right: 120%;
|
||||
}
|
||||
|
||||
.tooltip .content.sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .content {
|
||||
visibility: visible;
|
||||
}
|
||||
10
daemon.go
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type Repeater struct {
|
||||
type repeater struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
Interval time.Duration
|
||||
@@ -12,8 +12,8 @@ type Repeater struct {
|
||||
app *appContext
|
||||
}
|
||||
|
||||
func NewRepeater(interval time.Duration, app *appContext) *Repeater {
|
||||
return &Repeater{
|
||||
func newRepeater(interval time.Duration, app *appContext) *repeater {
|
||||
return &repeater{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
Interval: interval,
|
||||
@@ -22,7 +22,7 @@ func NewRepeater(interval time.Duration, app *appContext) *Repeater {
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *Repeater) Run() {
|
||||
func (rt *repeater) run() {
|
||||
rt.app.info.Println("Invite daemon started")
|
||||
for {
|
||||
select {
|
||||
@@ -42,7 +42,7 @@ func (rt *Repeater) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *Repeater) Shutdown() {
|
||||
func (rt *repeater) shutdown() {
|
||||
rt.Stopped = true
|
||||
rt.ShutdownChannel <- "Down"
|
||||
<-rt.ShutdownChannel
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English (US)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Create Jellyfin Account",
|
||||
"createAccountHeader": "Create Account",
|
||||
"accountDetails": "Details",
|
||||
"emailAddress": "Email",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"reEnterPassword": "Re-enter Password",
|
||||
"reEnterPasswordInvalid": "Passwords are not the same.",
|
||||
"createAccountButton": "Create Account",
|
||||
"passwordRequirementsHeader": "Password Requirements",
|
||||
"successHeader": "Success!",
|
||||
"successContinueButton": "Continue",
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Must have at least {n} character",
|
||||
"plural": "Must have a least {n} characters"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Must have at least {n} uppercase character",
|
||||
"plural": "Must have at least {n} uppercase characters"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Must have at least {n} lowercase character",
|
||||
"plural": "Must have at least {n} lowercase characters"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Must have at least {n} number",
|
||||
"plural": "Must have at least {n} numbers"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Must have at least {n} special character",
|
||||
"plural": "Must have at least {n} special characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Francais (FR)",
|
||||
"author": "https://github.com/Killianbe"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Créer un compte Jellyfin",
|
||||
"createAccountHeader": "Création du compte",
|
||||
"accountDetails": "Détails",
|
||||
"emailAddress": "Email",
|
||||
"username": "Pseudo",
|
||||
"password": "Mot de passe",
|
||||
"reEnterPassword": "Confirmez mot de passe",
|
||||
"reEnterPasswordInvalid": "Les mots de passe ne correspondent pas.",
|
||||
"createAccountButton": "Créer le compte",
|
||||
"passwordRequirementsHeader": "Mot de passe requis",
|
||||
"successHeader": "Succes!",
|
||||
"successContinueButton": "Continuer",
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Doit avoir au moins {n} caractère",
|
||||
"plural": "Doit avoir au moins {n} caractères"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Doit avoir au moins {n} caractère majuscule",
|
||||
"plural": "Must have at least {n} caractères majuscules"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Doit avoir au moins {n} caractère minuscule",
|
||||
"plural": "Doit avoir au moins {n} caractères minuscules"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Doit avoir au moins {n} nombre",
|
||||
"plural": "Doit avoir au moins {n} nombres"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Doit avoir au moins {n} caractère spécial",
|
||||
"plural": "Doit avoir au moins {n} caractères spéciaux"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
document.getElementById('page-1').scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
inline: 'center' });
|
||||
|
||||
function checkAuthRadio() {
|
||||
if (document.getElementById('manualAuthRadio').checked) {
|
||||
document.getElementById('adminOnlyArea').style.display = 'none';
|
||||
document.getElementById('manualAuthArea').style.display = '';
|
||||
} else {
|
||||
document.getElementById('manualAuthArea').style.display = 'none';
|
||||
document.getElementById('adminOnlyArea').style.display = '';
|
||||
};
|
||||
};
|
||||
var authRadios = ['manualAuthRadio', 'jfAuthRadio'];
|
||||
for (var i = 0; i < authRadios.length; i++) {
|
||||
document.getElementById(authRadios[i]).addEventListener('change', function() {
|
||||
checkAuthRadio();
|
||||
});
|
||||
};
|
||||
|
||||
function checkEmailRadio() {
|
||||
document.getElementById('emailNextButton').href = '#page-5';
|
||||
document.getElementById('valBackButton').href = '#page-7';
|
||||
if (document.getElementById('emailSMTPRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = '';
|
||||
document.getElementById('emailSMTPArea').style.display = '';
|
||||
document.getElementById('emailMailgunArea').style.display = 'none';
|
||||
document.getElementById('notificationsEnabled').checked = true;
|
||||
} else if (document.getElementById('emailMailgunRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = '';
|
||||
document.getElementById('emailSMTPArea').style.display = 'none';
|
||||
document.getElementById('emailMailgunArea').style.display = '';
|
||||
document.getElementById('notificationsEnabled').checked = true;
|
||||
} else if (document.getElementById('emailDisabledRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = 'none';
|
||||
document.getElementById('emailSMTPArea').style.display = 'none';
|
||||
document.getElementById('emailMailgunArea').style.display = 'none';
|
||||
document.getElementById('emailNextButton').href = '#page-8';
|
||||
document.getElementById('valBackButton').href = '#page-4';
|
||||
document.getElementById('notificationsEnabled').checked = false;
|
||||
};
|
||||
};
|
||||
var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio'];
|
||||
for (var i = 0; i < emailRadios.length; i++) {
|
||||
document.getElementById(emailRadios[i]).addEventListener('change', function() {
|
||||
checkEmailRadio();
|
||||
});
|
||||
};
|
||||
|
||||
function checkSSL() {
|
||||
var label = document.getElementById('emailSSL_TLSLabel');
|
||||
if (document.getElementById('emailSSL_TLS').checked) {
|
||||
label.textContent = 'Use SSL/TLS';
|
||||
} else {
|
||||
label.textContent = 'Use STARTTLS';
|
||||
};
|
||||
};
|
||||
document.getElementById('emailSSL_TLS').addEventListener('change', function() {
|
||||
checkSSL();
|
||||
});
|
||||
|
||||
function checkPwrEnabled() {
|
||||
if (document.getElementById('pwrEnabled').checked) {
|
||||
document.getElementById('pwrArea').style.display = '';
|
||||
} else {
|
||||
document.getElementById('pwrArea').style.display = 'none';
|
||||
};
|
||||
};
|
||||
var pwrEnabled = document.getElementById('pwrEnabled');
|
||||
pwrEnabled.addEventListener('change', function() {
|
||||
checkPwrEnabled();
|
||||
});
|
||||
|
||||
function checkInvEnabled() {
|
||||
if (document.getElementById('invEnabled').checked) {
|
||||
document.getElementById('invArea').style.display = '';
|
||||
} else {
|
||||
document.getElementById('invArea').style.display = 'none';
|
||||
};
|
||||
};
|
||||
document.getElementById('invEnabled').addEventListener('change', function() {
|
||||
checkInvEnabled();
|
||||
});
|
||||
|
||||
function checkValEnabled() {
|
||||
if (document.getElementById('valEnabled').checked) {
|
||||
document.getElementById('valArea').style.display = '';
|
||||
} else {
|
||||
document.getElementById('valArea').style.display = 'none';
|
||||
};
|
||||
};
|
||||
document.getElementById('valEnabled').addEventListener('change', function() {
|
||||
checkValEnabled();
|
||||
});
|
||||
checkValEnabled();
|
||||
checkInvEnabled();
|
||||
checkSSL();
|
||||
checkAuthRadio();
|
||||
checkEmailRadio();
|
||||
checkPwrEnabled();
|
||||
|
||||
var jfValid = false
|
||||
document.getElementById('jfTestButton').onclick = function() {
|
||||
var testButton = document.getElementById('jfTestButton');
|
||||
var nextButton = document.getElementById('jfNextButton');
|
||||
var jfData = {};
|
||||
jfData['jfHost'] = document.getElementById('jfHost').value;
|
||||
jfData['jfUser'] = document.getElementById('jfUser').value;
|
||||
jfData['jfPassword'] = document.getElementById('jfPassword').value;
|
||||
let valid = true;
|
||||
for (val in jfData) {
|
||||
if (jfData[val] == "") {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (!valid) {
|
||||
if (!testButton.classList.contains('btn-danger')) {
|
||||
testButton.classList.add('btn-danger');
|
||||
testButton.textContent = 'Fill out fields above.';
|
||||
setTimeout(function() {
|
||||
if (testButton.classList.contains('btn-danger')) {
|
||||
testButton.classList.remove('btn-danger');
|
||||
testButton.textContent = 'Test';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
testButton.disabled = true;
|
||||
testButton.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Testing...';
|
||||
nextButton.classList.add('disabled');
|
||||
nextButton.setAttribute('aria-disabled', 'true');
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/jellyfin/test", true);
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.responseType = 'json';
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
testButton.disabled = false;
|
||||
testButton.className = '';
|
||||
if (this.response['success'] == true) {
|
||||
testButton.classList.add('btn', 'btn-success');
|
||||
testButton.textContent = 'Success';
|
||||
nextButton.classList.remove('disabled');
|
||||
nextButton.setAttribute('aria-disabled', 'false');
|
||||
} else {
|
||||
testButton.classList.add('btn', 'btn-danger');
|
||||
testButton.textContent = 'Failed';
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send(JSON.stringify(jfData));
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('submitButton').onclick = function() {
|
||||
var submitButton = document.getElementById('submitButton');
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Submitting...';
|
||||
var config = {};
|
||||
config['jellyfin'] = {};
|
||||
config['ui'] = {};
|
||||
config['password_validation'] = {};
|
||||
config['email'] = {};
|
||||
config['password_resets'] = {};
|
||||
config['invite_emails'] = {};
|
||||
config['mailgun'] = {};
|
||||
config['smtp'] = {};
|
||||
config['notifications'] = {};
|
||||
// Page 2: Auth
|
||||
if (document.getElementById('jfAuthRadio').checked) {
|
||||
config['ui']['jellyfin_login'] = 'true';
|
||||
if (document.getElementById('jfAuthAdminOnly').checked) {
|
||||
config['ui']['admin_only'] = 'true';
|
||||
} else {
|
||||
config['ui']['admin_only'] = 'false'
|
||||
};
|
||||
} else {
|
||||
config['ui']['username'] = document.getElementById('manualAuthUsername').value;
|
||||
config['ui']['password'] = document.getElementById('manualAuthPassword').value;
|
||||
config['ui']['email'] = document.getElementById('manualAuthEmail').value;
|
||||
};
|
||||
// Page 3: Connect to jellyfin
|
||||
config['jellyfin']['server'] = document.getElementById('jfHost').value;
|
||||
let publicAddress = document.getElementById('jfPublicHost').value;
|
||||
if (publicAddress != "") {
|
||||
config['jellyfin']['public_server'] = publicAddress;
|
||||
}
|
||||
config['jellyfin']['username'] = document.getElementById('jfUser').value;
|
||||
config['jellyfin']['password'] = document.getElementById('jfPassword').value;
|
||||
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)
|
||||
if (document.getElementById('emailDisabledRadio').checked) {
|
||||
config['password_resets']['enabled'] = 'false';
|
||||
config['invite_emails']['enabled'] = 'false';
|
||||
config['notifications']['enabled'] = 'false';
|
||||
} else {
|
||||
if (document.getElementById('emailSMTPRadio').checked) {
|
||||
if (document.getElementById('emailSSL_TLS').checked) {
|
||||
config['smtp']['encryption'] = 'ssl_tls';
|
||||
} else {
|
||||
config['smtp']['encryption'] = 'starttls';
|
||||
};
|
||||
config['email']['method'] = 'smtp';
|
||||
config['smtp']['server'] = document.getElementById('emailSMTPServer').value;
|
||||
config['smtp']['port'] = document.getElementById('emailSMTPPort').value;
|
||||
config['smtp']['password'] = document.getElementById('emailSMTPPassword').value;
|
||||
config['email']['address'] = document.getElementById('emailSMTPAddress').value;
|
||||
} else {
|
||||
config['email']['method'] = 'mailgun';
|
||||
config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value;
|
||||
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
|
||||
config['email']['address'] = document.getElementById('emailMailgunAddress').value;
|
||||
};
|
||||
config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString();
|
||||
// Page 5: Email formatting
|
||||
config['email']['from'] = document.getElementById('emailSender').value;
|
||||
config['email']['date_format'] = document.getElementById('emailDateFormat').value;
|
||||
if (document.getElementById('email24hTimeRadio').checked) {
|
||||
config['email']['use_24h'] = 'true';
|
||||
} else {
|
||||
config['email']['use_24h'] = 'false';
|
||||
};
|
||||
config['email']['message'] = document.getElementById('emailMessage').value;
|
||||
// Page 6: Password Resets
|
||||
if (document.getElementById('pwrEnabled').checked) {
|
||||
config['password_resets']['enabled'] = 'true';
|
||||
config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value;
|
||||
config['password_resets']['subject'] = document.getElementById('pwrSubject').value;
|
||||
} else {
|
||||
config['password_resets']['enabled'] = 'false';
|
||||
};
|
||||
// Page 7: Invite Emails
|
||||
if (document.getElementById('invEnabled').checked) {
|
||||
config['invite_emails']['enabled'] = 'true';
|
||||
config['invite_emails']['url_base'] = document.getElementById('invURLBase').value;
|
||||
config['invite_emails']['subject'] = document.getElementById('invSubject').value;
|
||||
} else {
|
||||
config['invite_emails']['enabled'] = 'false';
|
||||
};
|
||||
};
|
||||
// Page 8: Password Validation
|
||||
if (document.getElementById('valEnabled').checked) {
|
||||
config['password_validation']['enabled'] = 'true';
|
||||
config['password_validation']['min_length'] = document.getElementById('valLength').value;
|
||||
config['password_validation']['upper'] = document.getElementById('valUpper').value;
|
||||
config['password_validation']['lower'] = document.getElementById('valLower').value;
|
||||
config['password_validation']['number'] = document.getElementById('valNumber').value;
|
||||
config['password_validation']['special'] = document.getElementById('valSpecial').value;
|
||||
} else {
|
||||
config['password_validation']['enabled'] = 'false';
|
||||
};
|
||||
// Page 9: Messages
|
||||
config['ui']['contact_message'] = document.getElementById('msgContact').value;
|
||||
config['ui']['help_message'] = document.getElementById('msgHelp').value;
|
||||
config['ui']['success_message'] = document.getElementById('msgSuccess').value;
|
||||
// Send it
|
||||
config["restart-program"] = true;
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/config", true);
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.responseType = 'json';
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.className = '';
|
||||
submitButton.classList.add('btn', 'btn-success');
|
||||
submitButton.textContent = 'Success';
|
||||
};
|
||||
};
|
||||
req.send(JSON.stringify(config));
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<title>404</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
|
||||
{{ if not .bs5 }}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{{ if .bs5 }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{{ else }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Page not found.</h1>
|
||||
<p>
|
||||
{{ .contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,494 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<!-- Bootstrap CSS -->
|
||||
|
||||
<script>
|
||||
// To grab theme preference
|
||||
function getCookie(cname) {
|
||||
let name = cname + "=";
|
||||
let decodedCookie = decodeURIComponent(document.cookie);
|
||||
let ca = decodedCookie.split(';');
|
||||
for (let c of ca) {
|
||||
while(c.charAt(0) == ' ') {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
return c.substring(name.length, c.length);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
{{ if .bs5 }}
|
||||
window.bsVersion = 5;
|
||||
{{ else }}
|
||||
window.bsVersion = 4;
|
||||
{{ end }}
|
||||
window.cssFile = "{{ .cssFile }}";
|
||||
var css = document.createElement('link');
|
||||
css.setAttribute('rel', 'stylesheet');
|
||||
css.setAttribute('type', 'text/css');
|
||||
var cssCookie = getCookie("css");
|
||||
if (cssCookie.includes('bs' + bsVersion)) {
|
||||
cssFile = cssCookie;
|
||||
} else if (cssCookie.includes('bs')) {
|
||||
if (cssCookie.includes('jf')) {
|
||||
cssFile = 'bs' + bsVersion + '-jf.css';
|
||||
} else {
|
||||
cssFile = 'bs' + bsVersion + '.css';
|
||||
}
|
||||
document.cookie = 'css=' + cssFile;
|
||||
}
|
||||
css.setAttribute('href', cssFile);
|
||||
document.head.appendChild(css);
|
||||
</script>
|
||||
{{ if not .bs5 }}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{{ if .bs5 }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{{ else }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body class="smooth-transition">
|
||||
<div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="loginTitle">Login</h5>
|
||||
</div>
|
||||
<div class="modal-body" id="formBody">
|
||||
<form action="#" method="POST" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" id="loginSubmit" class="btn btn-primary" form="loginForm">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="users" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="usersTitle">Users</h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="list-group list-group-flush" id="userList">
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer" id="userFooter">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="userDefaults" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="defaultsTitle"></h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="userDefaultsDescription"></p>
|
||||
<div class="mb-3" id="defaultsSourceSection">
|
||||
<label for="defaultsSource" class="form-label">Use settings from:</label>
|
||||
<select class="form-select" id="defaultsSource" aria-label="User settings source">
|
||||
<option value="profile" selected>Profile</option>
|
||||
<option value="fromUser">Source from existing user</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 unfocused" id="profileSelectBox">
|
||||
<label for="profileSelect" class="form-label">Profile</label>
|
||||
<select class="form-select" id="profileSelect" aria-label="Profile to apply">
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 unfocused" id="newProfileBox">
|
||||
<label for="newProfileName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="newProfileName" aria-describedby="Profile Name">
|
||||
</div>
|
||||
<div id="defaultUserRadiosBox">
|
||||
<label for="defaultUserRadios" class="form-label">Get settings from</label>
|
||||
<div id="defaultUserRadios"></div>
|
||||
</div>
|
||||
<div class="form-check" style="margin-top: 1rem;">
|
||||
<input class="form-check-input" type="checkbox" value="" id="storeDefaultHomescreen" checked>
|
||||
<label class="form-check-label" for="storeDefaultHomescreen" id="storeHomescreenLabel"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="defaultsFooter">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="storeDefaults">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .ombiEnabled }}
|
||||
<div class="modal fade" id="ombiDefaults" role="dialog" aria-labelledby="Ombi Users" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="ombiTitle">Ombi user defaults</h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Create an Ombi user and configure it to your liking, then choose it from below to store the settings and permissions as a template for all new users.</p>
|
||||
<div id="ombiUserRadios"></div>
|
||||
</div>
|
||||
<div class="modal-footer" id="ombiFooter">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="storeOmbiDefaults">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="modal fade" id="restartModal" role="dialog" aria-labelledby="Restart Warning" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Warning</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>A restart is needed to apply some settings. Restart now, later, or cancel?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal" id="restartModalCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button>
|
||||
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="refreshModal" role="dialog" aria-labelledby="Refresh page notice" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Settings applied.</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Refresh the page in a few seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="aboutModal" role="dialog" aria-labelledby="About jfa-go" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">About</h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<img src="banner.svg" alt="jfa-go banner">
|
||||
<p><a href="https://github.com/hrfee/jfa-go"><i class="fa fa-github"></i> jfa-go</a></p>
|
||||
<p>Version <i class="text-monospace">{{ .version }}</i></p>
|
||||
<p>Commit <i class="text-monospace">{{ .commit }}</i></p>
|
||||
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="deleteModal" role="dialog" aria-labelledby="Account deletion" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalTitle"></h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="deleteModalNotify">
|
||||
<label class="form-check-label" for="deleteModalNotify" id="deleteModalNotifyLabel">Notify users of account deletion</label>
|
||||
</div>
|
||||
<div class="mb-3 unfocused" id="deleteModalReasonBox">
|
||||
<label for="deleteModalReason" class="form-label">Reason for deletion</label>
|
||||
<textarea class="form-control" id="deleteModalReason" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteModalSend">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="newUserModal" role="dialog" aria-labelledby="Create new user" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create a user</h5>
|
||||
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
|
||||
{{ if not .bs5 }}
|
||||
<span aria-hidden="true">×</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="newUserEmail" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="newUserEmail" aria-describedby="Email address">
|
||||
</div>
|
||||
{{ if .username }}
|
||||
<div class="mb-3">
|
||||
<label for="newUserName" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="newUserName" aria-describedby="Username">
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="mb-3">
|
||||
<label for="newUserPassword" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="newUserPassword" aria-describedby="Password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="newUserCreate">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageContainer">
|
||||
<ul class="nav nav-pills" style="margin-bottom: 2rem;">
|
||||
<li class="nav-item">
|
||||
<h2><a id="invitesTabButton" class="nl nav-link active">Invites</a></h2>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<h2><a id="accountsTabButton" class="nl nav-link">Accounts</a></h2>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<h2><a id="settingsTabButton" class="nl nav-link">Settings</a></h2>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="btn-group" role="group" id="headerButtons">
|
||||
<button type="button" class="btn btn-danger unfocused" id="logoutButton">
|
||||
Logout <i class="fa fa-sign-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="invitesTab">
|
||||
<div class="card mb-3 tabGroup">
|
||||
<div class="card-header">Current Invites</div>
|
||||
<ul class="list-group list-group-flush" id="invites">
|
||||
</ul>
|
||||
</div>
|
||||
<div class="linkForm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Generate Invite</div>
|
||||
<div class="card-body">
|
||||
<form action="#" method="POST" id="inviteForm" class="container">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label for="days">Days</label>
|
||||
<select class="form-control form-select" id="days" name="days">
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="hours">Hours</label>
|
||||
<select class="form-control form-select" id="hours" name="hours">
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="minutes">Minutes</label>
|
||||
<select class="form-control form-select" id="minutes" name="minutes">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="multiUseCount">
|
||||
Multiple uses
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
|
||||
</div>
|
||||
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
|
||||
<label class="form-check-label" for="noUseLimit">
|
||||
No use limit
|
||||
</label>
|
||||
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label for="inviteProfile">Account creation profile</label>
|
||||
<select class="form-control form-select" id="inviteProfile" name="profile">
|
||||
</select>
|
||||
</div>
|
||||
{{ if .email_enabled }}
|
||||
<div class="form-group">
|
||||
<label for="send_to_address">Send invite to address</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
|
||||
</div>
|
||||
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group d-flex float-right">
|
||||
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accountsTab" class="unfocused">
|
||||
<div class="card mb-3 tabGroup">
|
||||
<div class="card-header d-flex" style="align-items: center;">
|
||||
<div>Accounts</div>
|
||||
<div class="ml-auto">
|
||||
<button type="button" class="btn btn-secondary" id="accountsTabAddUser">Add User</button>
|
||||
<button type="button" class="btn btn-primary unfocused" id="accountsTabSetDefaults">Modify Settings</button>
|
||||
<button type="button" class="btn btn-danger unfocused" id="accountsTabDelete"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive">
|
||||
<table class="table table-hover table-striped table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ if .bs5 }}
|
||||
<th scope="col"><input class="form-check-input" type="checkbox" value="" id="selectAll"></th>
|
||||
{{ else }}
|
||||
<th scope="col"><input type="checkbox" value="" id="selectAll"></th>
|
||||
{{ end }}
|
||||
<th scope="col">Username</th>
|
||||
<th scope="col">Email Address</th>
|
||||
<th scope="col">Last Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accountsList">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTab" class="unfocused mb-3 tabGroup card">
|
||||
<div class="card-header d-flex" style="align-items: center;">
|
||||
<div>Settings</div>
|
||||
<div class="ml-auto">
|
||||
<button type="button" class="btn btn-primary" id="settingsSave">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container card-body">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="" id="settingsLeft">
|
||||
<ul class="list-group list-group-flush" style="margin-bottom: 1rem;">
|
||||
<p>Note: <sup class="text-danger">*</sup> Indicates required field, <sup class="text-danger">R</sup> Indicates changes require a restart.</p>
|
||||
<button type="button" class="list-group-item list-group-item-action static" id="openAbout">
|
||||
About <i class="fa fa-info-circle settingIcon"></i>
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action" id="profiles_button">
|
||||
User Profiles <i class="fa fa-user settingIcon"></i>
|
||||
</button>
|
||||
{{ if .ombiEnabled }}
|
||||
<button type="button" class="list-group-item list-group-item-action static" id="openOmbiDefaults" onclick="window.openOmbiDefaults()">
|
||||
Ombi User Defaults <i class="fa fa-chain-broken settingIcon"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<div class="list-group list-group-flush" id="settingsSections">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="" id="settingsContent">
|
||||
<div id="profiles" class="unfocused">
|
||||
<div class="card card-body">
|
||||
<p>Profiles are applied to users when they create an account. They include things like access rights and homescreen layout. You can create them here.</p>
|
||||
<table class="table table-sm table-striped table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Default</th>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Admin?</th>
|
||||
<th scope="col">Libraries</th>
|
||||
<th scope="col"><button class="btn btn-outline-primary" onclick="createProfile()">Create</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="profileList">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contactBox">
|
||||
<p>{{ .contactMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.bs5 = {{ .bs5 }};
|
||||
window.availableProfiles = [];
|
||||
{{ if .notifications }}
|
||||
window.notifications_enabled = true;
|
||||
{{ else }}
|
||||
window.notifications_enabled = false;
|
||||
{{ end }}
|
||||
</script>
|
||||
<script src="admin.js" type="module"></script>
|
||||
<script src="invites.js" type="module"></script>
|
||||
{{ if .ombiEnabled }}
|
||||
<script src="ombi.js" type="module"></script>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
{{ define "form-base" }}
|
||||
<script>
|
||||
window.bs5 = {{ .settings.bs5 }};
|
||||
window.usernameEnabled = {{ .settings.username }};
|
||||
window.validationStrings = JSON.parse({{ .lang.validationStrings }});
|
||||
window.invalidPassword = "{{ .lang.reEnterPasswordInvalid }}";
|
||||
</script>
|
||||
<script src="form.js" type="module"></script>
|
||||
{{ end }}
|
||||
@@ -1,127 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
|
||||
{{ if not .settings.bs5 }}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{{ if .settings.bs5 }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{{ else }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.pageContainer {
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer {
|
||||
margin: 2%;
|
||||
}
|
||||
}
|
||||
.contactBox {
|
||||
color: grey;
|
||||
}
|
||||
#container {
|
||||
margin-top: 5%;
|
||||
margin-bottom: 5%;
|
||||
}
|
||||
</style>
|
||||
<title>{{ .lang.pageTitle }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="modal fade" id="successBox" tabindex="-1" role="dialog" aria-labelledby="successBox" aria-hidden="true" data-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="successHeader">{{ .lang.successHeader }}</h5>
|
||||
</div>
|
||||
<div class="modal-body" id="successBody">
|
||||
<p>{{ .successMessage }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="{{ .jfLink }}" class="btn btn-primary">{{ .lang.successContinueButton }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageContainer">
|
||||
<h1>
|
||||
{{ .lang.createAccountHeader }}
|
||||
</h1>
|
||||
<p>{{ .helpMessage }}</p>
|
||||
<p class="contactBox">{{ .contactMessage }}</p>
|
||||
<div class="container" id="container">
|
||||
<div class="row" id="cardContainer">
|
||||
<div class="col-sm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{ .lang.accountDetails }}</div>
|
||||
<div class="card-body">
|
||||
<form action="#" method="POST" id="accountForm">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail">{{ .lang.emailAddress }}</label>
|
||||
<input type="email" class="form-control" id="{{ if .settings.username }}inputEmail{{ else }}inputUsername{{ end }}" name="{{ if .settings.username }}email{{ else }}username{{ end }}" placeholder="{{ .lang.emailAddress }}" value="{{ .email }}" required>
|
||||
</div>
|
||||
{{ if .settings.username }}
|
||||
<div class="form-group">
|
||||
<label for="inputUsername">{{ .lang.username }}</label>
|
||||
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="{{ .lang.username }}" required>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="form-group">
|
||||
<label for="inputPassword">{{ .lang.password }}</label>
|
||||
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="{{ .lang.password }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inputPassword">{{ .lang.reEnterPassword }}</label>
|
||||
<input type="password" class="form-control" id="reInputPassword" onkeyup="window.checkPassword()" placeholder="{{ .lang.password }}" required>
|
||||
</div>
|
||||
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox" style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-outline-primary" id="submitButton">
|
||||
<span id="createAccount">{{ .lang.createAccountButton }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .validate }}
|
||||
<div class="col-sm" id="requirementBox">
|
||||
<div class="card mb-3 requirementBox">
|
||||
<div class="card-header">{{ .lang.passwordRequirementsHeader }}</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
{{ range $key, $value := .requirements }}
|
||||
<li id="{{ $key }}" min="{{ $value }}" class="list-group-item list-group-item-danger">
|
||||
<div></div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.validationStrings = {{ .lang.validationStrings }};
|
||||
</script>
|
||||
{{ template "form-base" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Invalid Code</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
|
||||
{{ if not .bs5 }}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{{ if .bs5 }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{{ else }}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{{ end }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Invalid Code.</h1>
|
||||
<p>The above code is either incorrect, or has expired.</p>
|
||||
<p>{{ .contactMessage }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
64
email.go
@@ -60,7 +60,6 @@ func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error {
|
||||
ServerName: sm.server,
|
||||
}
|
||||
var err error
|
||||
fmt.Println(server)
|
||||
// err = e.Send(server, sm.auth)
|
||||
if sm.sslTLS {
|
||||
err = e.SendWithTLS(server, sm.auth, tlsConfig)
|
||||
@@ -73,6 +72,7 @@ func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error {
|
||||
// Emailer contains the email sender, email content, and methods to construct message content.
|
||||
type Emailer struct {
|
||||
fromAddr, fromName string
|
||||
lang emailLang
|
||||
sender emailClient
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func NewEmailer(app *appContext) *Emailer {
|
||||
emailer := &Emailer{
|
||||
fromAddr: app.config.Section("email").Key("address").String(),
|
||||
fromName: app.config.Section("email").Key("from").String(),
|
||||
lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
|
||||
}
|
||||
method := app.config.Section("email").Key("method").String()
|
||||
if method == "smtp" {
|
||||
@@ -133,7 +134,7 @@ func (emailer *Emailer) NewMailgun(url, key string) {
|
||||
sender := &Mailgun{
|
||||
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
|
||||
}
|
||||
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages'
|
||||
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages')
|
||||
if strings.Contains(url, "messages") {
|
||||
url = url[0:strings.LastIndex(url, "/")]
|
||||
url = url[0:strings.LastIndex(url, "/")]
|
||||
@@ -154,7 +155,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
|
||||
|
||||
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
|
||||
email := &Email{
|
||||
subject: app.config.Section("invite_emails").Key("subject").String(),
|
||||
subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
|
||||
}
|
||||
expiry := invite.ValidTill
|
||||
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
|
||||
@@ -170,11 +171,13 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"expiry_date": d,
|
||||
"expiry_time": t,
|
||||
"expires_in": expiresIn,
|
||||
"invite_link": inviteLink,
|
||||
"message": message,
|
||||
"hello": emailer.lang.InviteEmail.get("hello"),
|
||||
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
|
||||
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
|
||||
"inviteExpiry": emailer.lang.InviteEmail.format("inviteExpiry", d, t, expiresIn),
|
||||
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
|
||||
"invite_link": inviteLink,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -190,7 +193,7 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
|
||||
|
||||
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext) (*Email, error) {
|
||||
email := &Email{
|
||||
subject: "Notice: Invite expired",
|
||||
subject: emailer.lang.InviteExpiry.get("title"),
|
||||
}
|
||||
expiry := app.formatDatetime(invite.ValidTill)
|
||||
for _, key := range []string{"html", "text"} {
|
||||
@@ -201,8 +204,9 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"code": code,
|
||||
"expiry": expiry,
|
||||
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
|
||||
"expiredAt": emailer.lang.InviteExpiry.format("expiredAt", "\""+code+"\"", expiry),
|
||||
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -218,7 +222,7 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
|
||||
|
||||
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) (*Email, error) {
|
||||
email := &Email{
|
||||
subject: "Notice: User created",
|
||||
subject: emailer.lang.UserCreated.get("title"),
|
||||
}
|
||||
created := app.formatDatetime(invite.Created)
|
||||
var tplAddress string
|
||||
@@ -235,10 +239,14 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"code": code,
|
||||
"username": username,
|
||||
"address": tplAddress,
|
||||
"time": created,
|
||||
"aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""),
|
||||
"name": emailer.lang.UserCreated.get("name"),
|
||||
"address": emailer.lang.UserCreated.get("emailAddress"),
|
||||
"time": emailer.lang.UserCreated.get("time"),
|
||||
"nameVal": username,
|
||||
"addressVal": tplAddress,
|
||||
"timeVal": created,
|
||||
"notificationNotice": emailer.lang.UserCreated.get("notificationNotice"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -252,9 +260,9 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error) {
|
||||
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) {
|
||||
email := &Email{
|
||||
subject: app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin"),
|
||||
subject: emailer.lang.PasswordReset.get("title"),
|
||||
}
|
||||
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
|
||||
message := app.config.Section("email").Key("message").String()
|
||||
@@ -266,12 +274,14 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"username": pwr.Username,
|
||||
"expiry_date": d,
|
||||
"expiry_time": t,
|
||||
"expires_in": expiresIn,
|
||||
"pin": pwr.Pin,
|
||||
"message": message,
|
||||
"helloUser": emailer.lang.PasswordReset.format("helloUser", pwr.Username),
|
||||
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
|
||||
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
|
||||
"codeExpiry": emailer.lang.PasswordReset.format("codeExpiry", d, t, expiresIn),
|
||||
"ifItWasNotYou": emailer.lang.PasswordReset.get("ifItWasNotYou"),
|
||||
"pin": emailer.lang.PasswordReset.get("pin"),
|
||||
"pinVal": pwr.Pin,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -287,7 +297,7 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
|
||||
|
||||
func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
|
||||
email := &Email{
|
||||
subject: app.config.Section("deletion").Key("subject").MustString("Your account was deleted - Jellyfin"),
|
||||
subject: emailer.lang.UserDeleted.get("title"),
|
||||
}
|
||||
for _, key := range []string{"html", "text"} {
|
||||
fpath := app.config.Section("deletion").Key("email_" + key).String()
|
||||
@@ -297,7 +307,9 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"reason": reason,
|
||||
"yourAccountWasDeleted": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
|
||||
"reason": emailer.lang.UserDeleted.get("reason"),
|
||||
"reasonVal": reason,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/bash
|
||||
# set +e
|
||||
# npx tsc -p ts/
|
||||
# set -e
|
||||
npx esbuild ts/* --outdir=data/static --minify
|
||||
17
go.mod
@@ -4,7 +4,7 @@ go 1.14
|
||||
|
||||
replace github.com/hrfee/jfa-go/docs => ./docs
|
||||
|
||||
replace github.com/hrfee/jfa-go/jfapi => ./jfapi
|
||||
replace github.com/hrfee/jfa-go/mediabrowser => ./mediabrowser
|
||||
|
||||
replace github.com/hrfee/jfa-go/common => ./common
|
||||
|
||||
@@ -19,13 +19,14 @@ require (
|
||||
github.com/gin-gonic/gin v1.6.3
|
||||
github.com/go-chi/chi v4.1.2+incompatible // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/spec v0.19.13 // indirect
|
||||
github.com/go-openapi/spec v0.20.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.4.1 // indirect
|
||||
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
||||
github.com/golang/protobuf v1.4.3
|
||||
github.com/google/uuid v1.1.2 // indirect
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20201112212552-b6f3cd7c1f71
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
|
||||
github.com/hrfee/jfa-go/jfapi v0.0.0-20201112212552-b6f3cd7c1f71
|
||||
github.com/hrfee/jfa-go/mediabrowser v0.0.0-20201112212552-b6f3cd7c1f71
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71
|
||||
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible
|
||||
github.com/json-iterator/go v1.1.10 // indirect
|
||||
@@ -41,15 +42,13 @@ require (
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
|
||||
github.com/swaggo/gin-swagger v1.3.0
|
||||
github.com/swaggo/swag v1.6.9 // indirect
|
||||
github.com/swaggo/swag v1.7.0 // indirect
|
||||
github.com/ugorji/go v1.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect
|
||||
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba // indirect
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
|
||||
golang.org/x/text v0.3.4 // indirect
|
||||
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b // indirect
|
||||
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee // indirect
|
||||
google.golang.org/protobuf v1.25.0 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0
|
||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||
)
|
||||
|
||||
28
go.sum
@@ -15,6 +15,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
|
||||
@@ -66,6 +67,8 @@ github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+j
|
||||
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
|
||||
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
|
||||
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
|
||||
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
|
||||
github.com/go-openapi/spec v0.19.4 h1:ixzUSnHTd6hCemgtAJgluaTSGYpLNpJY4mA2DIkdOAo=
|
||||
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
|
||||
@@ -77,6 +80,10 @@ github.com/go-openapi/spec v0.19.12 h1:OO9WrvhDwtiMY/Opr1j1iFZzirI3JW4/bxNFRcntA
|
||||
github.com/go-openapi/spec v0.19.12/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.19.13 h1:AcZVcWsrfW7LqyHKVbTZYpFF7jQcMxmAsWrw2p/b9ew=
|
||||
github.com/go-openapi/spec v0.19.13/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
|
||||
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.20.0 h1:HGLc8AJ7ynOxwv0Lq4TsnwLsWMawHAYiJIFzbcML86I=
|
||||
github.com/go-openapi/spec v0.20.0/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU=
|
||||
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
|
||||
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
|
||||
@@ -87,6 +94,8 @@ github.com/go-openapi/swag v0.19.10 h1:A1SWXruroGP15P1sOiegIPbaKio+G9N5TwWTFaVPm
|
||||
github.com/go-openapi/swag v0.19.10/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
|
||||
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
|
||||
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
|
||||
github.com/go-openapi/swag v0.19.12 h1:Bc0bnY2c3AoF7Gc+IMIAQQsD8fLHjHpc19wXvYuayQI=
|
||||
github.com/go-openapi/swag v0.19.12/go.mod h1:eFdyEBkTdoAf/9RXBvj4cr1nH7GD8Kzo5HTt47gr72M=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||
@@ -130,6 +139,8 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hrfee/jfa-go/jfapi v0.0.0-20210109010027-4aae65518089 h1:WRk+JAywI8V4u+PBQpdvXBX73yCZxgnLwyIiX7xL+Xc=
|
||||
github.com/hrfee/jfa-go/jfapi v0.0.0-20210109010027-4aae65518089/go.mod h1:Al1Rd1JGtpS+3KnK8t7+J0CZVDbT86QJrXHR6kZijds=
|
||||
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e h1:OGunVjqY7y4U4laftpEHv+mvZBlr7UGimJXKEGQtg48=
|
||||
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e/go.mod h1:Fy2gCFfZhay8jplf/Csj6cyH/oshQTkLQYZbKkcV+SY=
|
||||
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI=
|
||||
@@ -151,6 +162,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
|
||||
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
|
||||
github.com/labstack/gommon v0.2.9 h1:heVeuAYtevIQVYkGj6A41dtfT91LrvFG220lavpWhrU=
|
||||
@@ -200,6 +212,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLD
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858 h1:lgbJiJQx8bXo+eM88AFdd0VxUvaTLzCBXpK+H9poJ+Y=
|
||||
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858/go.mod h1:y02HeaN0visd95W6cEX2NXDv5sCwyqfzucWTdDGEwYY=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
@@ -234,6 +247,8 @@ github.com/swaggo/swag v1.6.8 h1:z3ZNcpJs/NLMpZcKqXUsBELmmY2Ocy09JXKx5gu3L4M=
|
||||
github.com/swaggo/swag v1.6.8/go.mod h1:a0IpNeMfGidNOcm2TsqODUh9JHdHu3kxDA0UlGbBKjI=
|
||||
github.com/swaggo/swag v1.6.9 h1:BukKRwZjnEcUxQt7Xgfrt9fpav0hiWw9YimdNO9wssw=
|
||||
github.com/swaggo/swag v1.6.9/go.mod h1:a0IpNeMfGidNOcm2TsqODUh9JHdHu3kxDA0UlGbBKjI=
|
||||
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
|
||||
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
|
||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||
@@ -310,6 +325,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTi
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -340,6 +357,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba h1:xmhUJGQGbxlod18iJGqVEp9cHIPLl7QiX2aA3to708s=
|
||||
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
@@ -376,6 +396,10 @@ golang.org/x/tools v0.0.0-20201113202037-1643af1435f3 h1:7R7+wzd5VuLvCNyHZ/MG511
|
||||
golang.org/x/tools v0.0.0-20201113202037-1643af1435f3/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b h1:Ych5r0Z6MLML1fgf5hTg9p5bV56Xqx9xv9hLgMBATWs=
|
||||
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee h1:5xKxdl/RhlelmSPaxyVeq5PYSmJ4H14yeQT58qP1F6o=
|
||||
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
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=
|
||||
@@ -401,6 +425,7 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
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/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
@@ -420,6 +445,9 @@ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
16
html/404.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/base.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>404 - jfa-go</title>
|
||||
</head>
|
||||
<body class="section">
|
||||
<div class="page-container">
|
||||
<h1 class="heading">Page not found.</h1>
|
||||
<p class="content">
|
||||
{{ .contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
294
html/admin.html
Normal file
@@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/base.css">
|
||||
<script>
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.notificationsEnabled = {{ .notifications }};
|
||||
window.emailEnabled = {{ .email_enabled }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.usernameEnabled = {{ .username }};
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
</script>
|
||||
{{ template "header.html" . }}
|
||||
<title>Admin - jfa-go</title>
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-login" class="modal">
|
||||
<form class="modal-content card" id="form-login" href="">
|
||||
<span class="heading">{{ .strings.login }}</span>
|
||||
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="login-user">
|
||||
<input type="password" class="field input ~neutral !high mb-1" placeholder="{{ .strings.password }}" id="login-password">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.login }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-add-user" class="modal">
|
||||
<form class="modal-content card" id="form-add-user" href="">
|
||||
<span class="heading">{{ .strings.newUser }} <span class="modal-close">×</span></span>
|
||||
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="add-user-user">
|
||||
<input type="email" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}">
|
||||
<input type="password" class="field input ~neutral !high mb-1" placeholder="{{ .strings.password }}" id="add-user-password">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.create }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-about" class="modal">
|
||||
<div class="modal-content content card">
|
||||
<span class="heading">{{ .strings.aboutProgram }} <span class="modal-close">×</span></span>
|
||||
<img src="/banner.svg" class="mt-1" alt="jfa-go banner">
|
||||
<p><i class="icon ri-github-fill"></i><a href="https://github.com/hrfee/jfa-go">jfa-go</a></p>
|
||||
<p>{{ .strings.version }} <span class="code monospace">{{ .version }}</span></p>
|
||||
<p>{{ .strings.commitNoun }} <span class="code monospace">{{ .commit }}</span></p>
|
||||
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-modify-user" class="modal">
|
||||
<form class="modal-content card" id="form-modify-user" href="">
|
||||
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.modifySettingsDescription }}</p>
|
||||
<div class="flex-row mb-1">
|
||||
<label class="flex-row-group mr-1">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
|
||||
<span class="button ~neutral !high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="flex-row-group ml-1">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
|
||||
<span class="button ~neutral !normal supra full-width center">{{ .strings.user }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral !normal mb-1">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral !normal mb-1 unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
</div>
|
||||
<label class="switch mb-1">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-delete-user" class="modal">
|
||||
<form class="modal-content card" id="form-delete-user" href="">
|
||||
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">×</span></span>
|
||||
<div class="content mt-half">
|
||||
<label class="switch mb-1">
|
||||
<input type="checkbox" id="delete-user-notify" checked>
|
||||
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
|
||||
</label>
|
||||
<textarea id="textarea-delete-user" class="textarea full-width ~neutral !normal mb-1" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~critical !normal full-width center supra submit">{{ .strings.delete }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-restart" class="modal">
|
||||
<div class="modal-content card ~critical !low">
|
||||
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">×</span></span>
|
||||
<p class="content pb-1">{{ .strings.settingsRestartRequiredDescription }}</p>
|
||||
<div class="fr">
|
||||
<span class="button ~info !normal mb-half" id="settings-apply-no-restart">{{ .strings.settingsApplyRestartLater }}</span>
|
||||
<span class="button ~critical !normal" id="settings-apply-restart">{{ .strings.settingsApplyRestartNow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-refresh" class="modal">
|
||||
<div class="modal-content card ~neutral !normal">
|
||||
<span class="heading">{{ .strings.settingsApplied }}</span>
|
||||
<p class="content">{{ .strings.settingsRefreshPage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-ombi-defaults" class="modal">
|
||||
<form class="modal-content card" id="form-ombi-defaults" href="">
|
||||
<span class="heading">{{ .strings.ombiUserDefaults }} <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.ombiUserDefaultsDescription }}</p>
|
||||
<div class="select ~neutral !normal mb-1">
|
||||
<select></select>
|
||||
</div>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-user-profiles" class="modal">
|
||||
<div class="modal-content wide card">
|
||||
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">×</span></span>
|
||||
<p class="support lg">{{ .strings.userProfilesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ .strings.name }}</th>
|
||||
<th>{{ .strings.userProfilesIsDefault }}</th>
|
||||
<th>{{ .strings.from }}</th>
|
||||
<th>{{ .strings.userProfilesLibraries }}</th>
|
||||
<th><span class="button ~neutral !high" id="button-profile-create">{{ .strings.create }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-profiles"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-add-profile" class="modal">
|
||||
<form class="modal-content card" id="form-add-profile" href="">
|
||||
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.addProfileDescription }}</p>
|
||||
<label>
|
||||
<span class="supra">{{ .strings.addProfileNameOf }} </span>
|
||||
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="{{ .strings.name }}" id="add-profile-name">
|
||||
<label>
|
||||
<span class="supra">{{ .strings.user }}</span>
|
||||
<div class="select ~neutral !normal mt-half mb-1">
|
||||
<select id="add-profile-user"></select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="switch mb-1">
|
||||
<input type="checkbox" id="add-profile-homescreen" checked>
|
||||
<span>{{ .strings.addProfileStoreHomescreenLayout }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.create }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-1 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral !low" id="lang-list">
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div class="page-container">
|
||||
<div class="mb-1">
|
||||
<header class="flex flex-wrap items-center justify-between">
|
||||
<div class="text-neutral-700">
|
||||
<span id="button-tab-invites" class="tab-button portal">{{ .strings.invites }}</span>
|
||||
<span id="button-tab-accounts" class="tab-button portal">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-settings" class="tab-button portal">{{ .strings.settings }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<div class="text-neutral-700">
|
||||
<span class="button ~critical !normal mb-1 unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
<span id="button-theme" class="button ~neutral !normal mb-1">{{ .strings.theme }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-invites">
|
||||
<div class="card ~neutral !low invites mb-1">
|
||||
<span class="heading">{{ .strings.invites }}</span>
|
||||
<div id="invites"></div>
|
||||
</div>
|
||||
<div class="card ~neutral !low">
|
||||
<span class="heading">{{ .strings.create }}</span>
|
||||
<div class="row" id="create-inv">
|
||||
<div class="card ~neutral !normal col">
|
||||
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral !normal mb-1 mt-half">
|
||||
<select id="create-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral !normal mb-1 mt-half">
|
||||
<select id="create-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral !normal mb-1 mt-half">
|
||||
<select id="create-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ~neutral !normal col">
|
||||
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
|
||||
<div class="flex-expand mb-1 mt-half">
|
||||
<input type="number" min="0" id="create-uses" class="input ~neutral !normal mr-1" value=1>
|
||||
<label for="create-inf-uses" class="button ~neutral !normal">
|
||||
<span>∞</span>
|
||||
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
|
||||
</label>
|
||||
</div>
|
||||
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
|
||||
<label class="label supra">{{ .strings.profile }}</label>
|
||||
<div class="select ~neutral !normal mb-1 mt-half">
|
||||
<select id="create-profile">
|
||||
</select>
|
||||
</div>
|
||||
<div id="create-send-to-container">
|
||||
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
|
||||
<div class="flex-expand mb-1 mt-half">
|
||||
<input type="email" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com">
|
||||
<label for="create-send-to-enabled" class="button ~neutral !normal">
|
||||
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<span class="button ~urge !normal supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-accounts" class="unfocused">
|
||||
<div class="card ~neutral !low accounts mb-1">
|
||||
<span class="heading">{{ .strings.accounts }}</span>
|
||||
<div class="fr">
|
||||
<span class="button ~neutral !normal" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<span class="button ~urge !normal" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||
<span class="button ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
</div>
|
||||
<div class="card ~neutral !normal accounts-header table-responsive mt-half">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" value="" id="accounts-select-all"></th>
|
||||
<th>{{ .strings.username }}</th>
|
||||
<th>{{ .strings.emailAddress }}</th>
|
||||
<th>{{ .strings.lastActiveTime }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-settings" class="unfocused">
|
||||
<div class="card ~neutral !low settings overflow">
|
||||
<span class="heading">{{ .strings.settings }}</span>
|
||||
<div class="fr">
|
||||
<span class="button ~neutral !normal unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="card ~neutral !normal col" id="settings-sidebar">
|
||||
<aside class="aside sm ~info mb-half" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info">R</span> indicates changes require a restart.</aside>
|
||||
<span class="button ~neutral !low settings-section-button mb-half" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-half"></i></span></span>
|
||||
<span class="button ~neutral !low settings-section-button mb-half" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-half"></i></span></span>
|
||||
</div>
|
||||
<div class="card ~neutral !normal col overflow" id="settings-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
html/form-base.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{{ define "form-base" }}
|
||||
<script>
|
||||
window.usernameEnabled = {{ .username }};
|
||||
window.validationStrings = JSON.parse({{ .validationStrings }});
|
||||
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.code = "{{ .code }}";
|
||||
</script>
|
||||
<script src="js/form.js" type="module"></script>
|
||||
{{ end }}
|
||||
76
html/form.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/base.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>{{ .strings.pageTitle }}</title>
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-success" class="modal">
|
||||
<div class="modal-content card">
|
||||
<span class="heading mb-1">{{ .strings.successHeader }}</span>
|
||||
<p class="content mb-1">{{ .successMessage }}</p>
|
||||
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.successContinueButton }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-1 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral !low" id="lang-list">
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div class="page-container">
|
||||
<div class="card ~neutral !low">
|
||||
<div class="row baseline">
|
||||
<span class="col heading">{{ .strings.createAccountHeader }}</span>
|
||||
<span class="col subheading"> {{ .helpMessage }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form class="card ~neutral !normal" id="form-create" href="">
|
||||
<label class="label supra">
|
||||
{{ .strings.username }}
|
||||
<input type="text" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
|
||||
</label>
|
||||
|
||||
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
|
||||
<input type="email" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
|
||||
|
||||
<label class="label supra" for="create-password">{{ .strings.password }}</label>
|
||||
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}">
|
||||
|
||||
<label class="label supra" for="create-reenter-password">{{ .strings.reEnterPassword }}</label>
|
||||
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-reenter-password" aria-label="{{ .strings.reEnterPassword }}">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge !normal full-width center supra submit">{{ .strings.createAccountButton }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card ~neutral !normal">
|
||||
<span class="label supra" for="inv-uses">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<ul>
|
||||
{{ range $key, $value := .requirements }}
|
||||
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
|
||||
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ if .contactMessage }}
|
||||
<aside class="col aside sm ~info">{{ .contactMessage }}</aside>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ template "form-base" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
11
html/header.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
|
||||
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
17
html/invalidCode.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/base.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>Invalid Code - jfa-go</title>
|
||||
</head>
|
||||
<body class="section">
|
||||
<div class="page-container">
|
||||
<h1 class="heading">Invalid invite code.</h1>
|
||||
<p class="content">The code above was either incorrect, or has expired.</p>
|
||||
<p class="content">
|
||||
{{ .contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="bs5-jf.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.min.js" integrity="sha384-t6I8D5dJmMXjCsRLhSzCltuhNZg6P10kE0m0nAncLUjH6GeYLhRU1zfLoW3QNQDF" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.card-body {
|
||||
width: 100%;
|
||||
@@ -31,7 +31,7 @@
|
||||
margin: 10%;
|
||||
}
|
||||
</style>
|
||||
<title>Setup - Jellyfin Accounts</title>
|
||||
<title>Setup - jfa-go</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pageContainer">
|
||||
@@ -369,6 +369,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="setup.js"></script>
|
||||
<script src="js/setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
BIN
images/create.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
images/demo.gif
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 2.4 MiB |
3
images/gengif.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Commands for making GIF:
|
||||
ffmpeg -i demo.mkv -vf "palettegen" videoPalette.png
|
||||
ffmpeg -i demo.mkv -i videoPalette.png -lavfi "fps=25 [x]; [x][1:v] paletteuse" -y demo.gif
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 71 KiB |
@@ -1 +0,0 @@
|
||||
../data/static/banner.svg
|
||||
@@ -1,11 +0,0 @@
|
||||
# Systemd service file for jfa-go. Install to ~/.config/systemd/user.
|
||||
|
||||
[Unit]
|
||||
Description=A web app for managing users on Jellyfin
|
||||
|
||||
[Service]
|
||||
# Modify this to the path to your executable, if necessary.
|
||||
ExecStart=/opt/jfa-go/jfa-go
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,7 +0,0 @@
|
||||
module github.com/hrfee/jfa-go/jfapi
|
||||
|
||||
go 1.15
|
||||
|
||||
replace github.com/hrfee/jfa-go/common => ../common
|
||||
|
||||
require github.com/hrfee/jfa-go/common v0.0.0-00010101000000-000000000000
|
||||
364
jfapi/jfapi.go
@@ -1,364 +0,0 @@
|
||||
package jfapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
type serverInfo struct {
|
||||
LocalAddress string `json:"LocalAddress"`
|
||||
Name string `json:"ServerName"`
|
||||
Version string `json:"Version"`
|
||||
OS string `json:"OperatingSystem"`
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
|
||||
// Jellyfin represents a running Jellyfin instance.
|
||||
type Jellyfin struct {
|
||||
Server string
|
||||
client string
|
||||
version string
|
||||
device string
|
||||
deviceID string
|
||||
useragent string
|
||||
auth string
|
||||
header map[string]string
|
||||
ServerInfo serverInfo
|
||||
Username string
|
||||
password string
|
||||
Authenticated bool
|
||||
AccessToken string
|
||||
userID string
|
||||
httpClient *http.Client
|
||||
loginParams map[string]string
|
||||
userCache []map[string]interface{}
|
||||
CacheExpiry time.Time
|
||||
cacheLength int
|
||||
noFail bool
|
||||
timeoutHandler common.TimeoutHandler
|
||||
}
|
||||
|
||||
// NewJellyfin returns a new Jellyfin object.
|
||||
func NewJellyfin(server, client, version, device, deviceID string, timeoutHandler common.TimeoutHandler, cacheTimeout int) (*Jellyfin, error) {
|
||||
jf := &Jellyfin{}
|
||||
jf.Server = server
|
||||
jf.client = client
|
||||
jf.version = version
|
||||
jf.device = device
|
||||
jf.deviceID = deviceID
|
||||
jf.useragent = fmt.Sprintf("%s/%s", client, version)
|
||||
jf.timeoutHandler = timeoutHandler
|
||||
jf.auth = fmt.Sprintf("MediaBrowser Client=%s, Device=%s, DeviceId=%s, Version=%s", client, device, deviceID, version)
|
||||
jf.header = map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/json; charset=UTF-8",
|
||||
"X-Application": jf.useragent,
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": jf.useragent,
|
||||
"X-Emby-Authorization": jf.auth,
|
||||
}
|
||||
jf.httpClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
infoURL := fmt.Sprintf("%s/System/Info/Public", server)
|
||||
req, _ := http.NewRequest("GET", infoURL, nil)
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
defer jf.timeoutHandler()
|
||||
if err == nil {
|
||||
data, _ := ioutil.ReadAll(resp.Body)
|
||||
json.Unmarshal(data, &jf.ServerInfo)
|
||||
}
|
||||
jf.cacheLength = cacheTimeout
|
||||
jf.CacheExpiry = time.Now()
|
||||
return jf, nil
|
||||
}
|
||||
|
||||
// Authenticate attempts to authenticate using a username & password
|
||||
func (jf *Jellyfin) Authenticate(username, password string) (map[string]interface{}, int, error) {
|
||||
jf.Username = username
|
||||
jf.password = password
|
||||
jf.loginParams = map[string]string{
|
||||
"Username": username,
|
||||
"Pw": password,
|
||||
"Password": password,
|
||||
}
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
err := encoder.Encode(jf.loginParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// loginParams, _ := json.Marshal(jf.loginParams)
|
||||
url := fmt.Sprintf("%s/Users/authenticatebyname", jf.Server)
|
||||
req, err := http.NewRequest("POST", url, buffer)
|
||||
defer jf.timeoutHandler()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for name, value := range jf.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
data, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
data = resp.Body
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(data).Decode(&respData)
|
||||
jf.AccessToken = respData["AccessToken"].(string)
|
||||
user := respData["User"].(map[string]interface{})
|
||||
jf.userID = respData["User"].(map[string]interface{})["Id"].(string)
|
||||
jf.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\", Token=\"%s\"", jf.client, jf.device, jf.deviceID, jf.version, jf.AccessToken)
|
||||
jf.header["X-Emby-Authorization"] = jf.auth
|
||||
jf.Authenticated = true
|
||||
return user, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (jf *Jellyfin) get(url string, params map[string]string) (string, int, error) {
|
||||
var req *http.Request
|
||||
if params != nil {
|
||||
jsonParams, _ := json.Marshal(params)
|
||||
req, _ = http.NewRequest("GET", url, bytes.NewBuffer(jsonParams))
|
||||
} else {
|
||||
req, _ = http.NewRequest("GET", url, nil)
|
||||
}
|
||||
for name, value := range jf.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
defer jf.timeoutHandler()
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
if resp.StatusCode == 401 && jf.Authenticated {
|
||||
jf.Authenticated = false
|
||||
_, _, authErr := jf.Authenticate(jf.Username, jf.password)
|
||||
if authErr == nil {
|
||||
v1, v2, v3 := jf.get(url, params)
|
||||
return v1, v2, v3
|
||||
}
|
||||
}
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data io.Reader
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
data, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
data = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
io.Copy(buf, data)
|
||||
//var respData map[string]interface{}
|
||||
//json.NewDecoder(data).Decode(&respData)
|
||||
return buf.String(), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (jf *Jellyfin) post(url string, data map[string]interface{}, response bool) (string, int, error) {
|
||||
params, _ := json.Marshal(data)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(params))
|
||||
for name, value := range jf.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
defer jf.timeoutHandler()
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
if resp.StatusCode == 401 && jf.Authenticated {
|
||||
jf.Authenticated = false
|
||||
_, _, authErr := jf.Authenticate(jf.Username, jf.password)
|
||||
if authErr == nil {
|
||||
v1, v2, v3 := jf.post(url, data, response)
|
||||
return v1, v2, v3
|
||||
}
|
||||
}
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
if response {
|
||||
defer resp.Body.Close()
|
||||
var outData io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
outData, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
outData = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
io.Copy(buf, outData)
|
||||
return buf.String(), resp.StatusCode, nil
|
||||
}
|
||||
return "", resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) DeleteUser(id string) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s", jf.Server, id)
|
||||
req, _ := http.NewRequest("DELETE", url, nil)
|
||||
for name, value := range jf.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
defer jf.timeoutHandler()
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
|
||||
// GetUsers returns all (visible) users on the Jellyfin instance.
|
||||
func (jf *Jellyfin) GetUsers(public bool) ([]map[string]interface{}, int, error) {
|
||||
var result []map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
if time.Now().After(jf.CacheExpiry) {
|
||||
if public {
|
||||
url := fmt.Sprintf("%s/users/public", jf.Server)
|
||||
data, status, err = jf.get(url, nil)
|
||||
} else {
|
||||
url := fmt.Sprintf("%s/users", jf.Server)
|
||||
data, status, err = jf.get(url, jf.loginParams)
|
||||
}
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
jf.userCache = result
|
||||
jf.CacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
|
||||
return result, status, nil
|
||||
}
|
||||
return jf.userCache, 200, nil
|
||||
}
|
||||
|
||||
// UserByName returns the user corresponding to the provided username.
|
||||
func (jf *Jellyfin) UserByName(username string, public bool) (map[string]interface{}, int, error) {
|
||||
var match map[string]interface{}
|
||||
find := func() (map[string]interface{}, int, error) {
|
||||
users, status, err := jf.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Name"].(string) == username {
|
||||
return user, status, err
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
match, status, err := find()
|
||||
if match == nil {
|
||||
jf.CacheExpiry = time.Now()
|
||||
match, status, err = find()
|
||||
}
|
||||
return match, status, err
|
||||
}
|
||||
|
||||
// UserByID returns the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) UserByID(userID string, public bool) (map[string]interface{}, int, error) {
|
||||
if jf.CacheExpiry.After(time.Now()) {
|
||||
for _, user := range jf.userCache {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if public {
|
||||
users, status, err := jf.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, status, nil
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
url := fmt.Sprintf("%s/users/%s", jf.Server, userID)
|
||||
data, status, err = jf.get(url, jf.loginParams)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
return result, status, nil
|
||||
}
|
||||
|
||||
// NewUser creates a new user with the provided username and password.
|
||||
func (jf *Jellyfin) NewUser(username, password string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/Users/New", jf.Server)
|
||||
stringData := map[string]string{
|
||||
"Name": username,
|
||||
"Password": password,
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
for key, value := range stringData {
|
||||
data[key] = value
|
||||
}
|
||||
response, status, err := jf.post(url, data, true)
|
||||
var recv map[string]interface{}
|
||||
json.Unmarshal([]byte(response), &recv)
|
||||
if err != nil || !(status == 200 || status == 204) {
|
||||
return nil, status, err
|
||||
}
|
||||
return recv, status, nil
|
||||
}
|
||||
|
||||
// SetPolicy sets the access policy for the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) SetPolicy(userID string, policy map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Policy", jf.Server, userID)
|
||||
_, status, err := jf.post(url, policy, false)
|
||||
if err != nil || status != 200 {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SetConfiguration sets the configuration (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) SetConfiguration(userID string, configuration map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Configuration", jf.Server, userID)
|
||||
_, status, err := jf.post(url, configuration, false)
|
||||
return status, err
|
||||
}
|
||||
|
||||
// GetDisplayPreferences gets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) GetDisplayPreferences(userID string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
|
||||
data, status, err := jf.get(url, nil)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return nil, status, err
|
||||
}
|
||||
var displayprefs map[string]interface{}
|
||||
err = json.Unmarshal([]byte(data), &displayprefs)
|
||||
if err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
return displayprefs, status, nil
|
||||
}
|
||||
|
||||
// SetDisplayPreferences sets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (jf *Jellyfin) SetDisplayPreferences(userID string, displayprefs map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
|
||||
_, status, err := jf.post(url, displayprefs, false)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
95
lang.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type langMeta struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type quantityString struct {
|
||||
Singular string `json:"singular"`
|
||||
Plural string `json:"plural"`
|
||||
}
|
||||
|
||||
type adminLangs map[string]adminLang
|
||||
|
||||
func (ls *adminLangs) getOptions(chosen string) (string, []string) {
|
||||
opts := make([]string, len(*ls))
|
||||
chosenLang := (*ls)[chosen].Meta.Name
|
||||
i := 0
|
||||
for _, lang := range *ls {
|
||||
opts[i] = lang.Meta.Name
|
||||
i++
|
||||
}
|
||||
return chosenLang, opts
|
||||
}
|
||||
|
||||
type adminLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
Notifications langSection `json:"notifications"`
|
||||
QuantityStrings map[string]quantityString `json:"quantityStrings"`
|
||||
JSON string
|
||||
}
|
||||
|
||||
type formLangs map[string]formLang
|
||||
|
||||
func (ls *formLangs) getOptions(chosen string) (string, []string) {
|
||||
opts := make([]string, len(*ls))
|
||||
chosenLang := (*ls)[chosen].Meta.Name
|
||||
i := 0
|
||||
for _, lang := range *ls {
|
||||
opts[i] = lang.Meta.Name
|
||||
i++
|
||||
}
|
||||
return chosenLang, opts
|
||||
}
|
||||
|
||||
type formLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
ValidationStrings map[string]quantityString `json:"validationStrings"`
|
||||
validationStringsJSON string
|
||||
}
|
||||
|
||||
type emailLangs map[string]emailLang
|
||||
|
||||
func (ls *emailLangs) getOptions(chosen string) (string, []string) {
|
||||
opts := make([]string, len(*ls))
|
||||
chosenLang := (*ls)[chosen].Meta.Name
|
||||
i := 0
|
||||
for _, lang := range *ls {
|
||||
opts[i] = lang.Meta.Name
|
||||
i++
|
||||
}
|
||||
return chosenLang, opts
|
||||
}
|
||||
|
||||
type emailLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
}
|
||||
|
||||
type langSection map[string]string
|
||||
|
||||
func (el langSection) format(field string, vals ...string) string {
|
||||
text := el.get(field)
|
||||
for _, val := range vals {
|
||||
text = strings.Replace(text, "{n}", val, 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (el langSection) get(field string) string {
|
||||
t, ok := el[field]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return t
|
||||
}
|
||||
127
lang/admin/de-de.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"accounts": "Konten",
|
||||
"settings": "Einstellungen",
|
||||
"theme": "Thema",
|
||||
"inviteDays": "Tage",
|
||||
"inviteHours": "Stunden",
|
||||
"inviteMinutes": "Minuten",
|
||||
"inviteNumberOfUses": "Anzahl Verwendungen",
|
||||
"warning": "Warnung",
|
||||
"inviteInfiniteUsesWarning": "Invites mit unendlich vielen Verwendungen können missbräuchlich verwendet werden",
|
||||
"inviteSendToEmail": "Senden an",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"create": "Erstellen",
|
||||
"apply": "Anwenden",
|
||||
"delete": "Löschen",
|
||||
"submit": "Absenden",
|
||||
"name": "Name",
|
||||
"date": "Datum",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"emailAddress": "E-Mail-Adresse",
|
||||
"lastActiveTime": "Zuletzt aktiv",
|
||||
"from": "Von",
|
||||
"user": "Benutzer",
|
||||
"aboutProgram": "Über",
|
||||
"version": "Version",
|
||||
"commitNoun": "Commit",
|
||||
"newUser": "Neuer Benutzer",
|
||||
"profile": "Profil",
|
||||
"success": "Erfolg",
|
||||
"error": "Fehler",
|
||||
"unknown": "Unbekannt",
|
||||
"modifySettings": "Einstellungen ändern",
|
||||
"modifySettingsDescription": "Wende Einstellungen von einem bestehenden Profil an, oder beziehe sie direkt von einem Benutzer.",
|
||||
"applyHomescreenLayout": "Startbildschirmlayout anwenden",
|
||||
"sendDeleteNotificationEmail": "Benachrichtigungs-E-Mail senden",
|
||||
"sendDeleteNotifiationExample": "Dein Konto wurde gelöscht.",
|
||||
"settingsRestartRequired": "Neustart erforderlich",
|
||||
"settingsRestartRequiredDescription": "Ein Neustart ist notwendig, um einige Einstellungen anzuwenden, die du geändert hast. Jetzt oder später neu starten?",
|
||||
"settingsApplyRestartLater": "Anwenden, später neu starten",
|
||||
"settingsApplyRestartNow": "Anwenden & neu starten",
|
||||
"settingsApplied": "Einstellungen angewendet.",
|
||||
"settingsRefreshPage": "Aktualisiere die Seite in ein paar Sekunden",
|
||||
"settingsRequiredOrRestartMessage": "Hinweis: {n} zeigt ein erforderliches Feld an, {n} zeigt an, dass Änderungen einen Neustart erfordern.",
|
||||
"settingsSave": "Speichern",
|
||||
"ombiUserDefaults": "Ombi-Benutzerstandardeinstellungen",
|
||||
"ombiUserDefaultsDescription": "Erstelle einen Ombi-Benutzer und konfiguriere ihn, dann wähle ihn unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Ombi-Benutzer, erstellt von jfa-go, angewendet",
|
||||
"userProfiles": "Benutzerprofile",
|
||||
"userProfilesDescription": "Profile werden auf Benutzer angewendet, wenn sie ein Konto erstellen. Ein Profil beinhaltet Bibliothekszugriffsrechte und das Startbildschirmlayout.",
|
||||
"userProfilesIsDefault": "Standard",
|
||||
"userProfilesLibraries": "Bibliotheken",
|
||||
"addProfile": "Profil hinzufügen",
|
||||
"addProfileDescription": "Erstelle einen Jellyfin-Benutzer und konfiguriere ihn, dann wähle ihn unten aus. Wenn dieses Profil auf einen Invite angewendet ist, werden neue Benutzer mit den Einstellungen erstellt.",
|
||||
"addProfileNameOf": "Profilname",
|
||||
"addProfileStoreHomescreenLayout": "Startbildschirmlayout speichern",
|
||||
"inviteNoUsersCreated": "Noch keine!",
|
||||
"inviteUsersCreated": "Erstellte Benutzer",
|
||||
"inviteNoProfile": "Kein Profil",
|
||||
"copy": "Kopieren",
|
||||
"inviteDateCreated": "Erstellt",
|
||||
"inviteRemainingUses": "Verbleibende Verwendungen",
|
||||
"inviteNoInvites": "Keine",
|
||||
"inviteExpiresInTime": "Läuft in {n} ab",
|
||||
"notifyEvent": "Benachrichtigen bei:",
|
||||
"notifyInviteExpiry": "Bei Ablauf",
|
||||
"notifyUserCreation": "Bei Benutzererstellung"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
|
||||
"userCreated": "Benutzer {n} erstellt.",
|
||||
"createProfile": "Profil {n} erstellt.",
|
||||
"saveSettings": "Einstellungen wurden gespeichert",
|
||||
"setOmbiDefaults": "Ombi-Standardeinstellungen gespeichert.",
|
||||
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
|
||||
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Einstellungen wurden angewendet, aber die Anwendung des Startbildschirmlayouts ist möglicherweise fehlgeschlagen.",
|
||||
"errorHomescreenAppliedNoSettings": "Startbildschirmlayout wurde angewendet, aber die Anwendung der Einstellungen ist möglicherweise fehlgeschlagen.",
|
||||
"errorSettingsFailed": "Anwendung ist fehlgeschlagen.",
|
||||
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
|
||||
"errorUnknown": "Unbekannter Fehler.",
|
||||
"errorBlankFields": "Felder wurden nicht ausgefüllt",
|
||||
"errorDeleteProfile": "Fehler beim Löschen des Profils {n}",
|
||||
"errorLoadProfiles": "Fehler beim Laden der Profile.",
|
||||
"errorCreateProfile": "Fehler beim Erstellen des Profils {n}",
|
||||
"errorSetDefaultProfile": "Fehler beim Setzen des Standardprofils.",
|
||||
"errorLoadUsers": "Fehler beim Laden der Benutzer.",
|
||||
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"errorLoadSettings": "Fehler beim Laden der Einstellungen.",
|
||||
"errorSetOmbiDefaults": "Fehler beim Speichern der Ombi-Standardeinstellungen.",
|
||||
"errorLoadOmbiUsers": "Fehler beim Laden der Ombi-Benutzer.",
|
||||
"errorChangedEmailAddress": "E-Mail-Adresse von {n} konnte nicht geändert werden.",
|
||||
"errorFailureCheckLogs": "Fehlgeschlagen (überprüfe die Konsole/Logs)",
|
||||
"errorPartialFailureCheckLogs": "Teilweiser Fehlschlag (überprüfe die Konsole/Logs)"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "Einstellungen für {n} Benutzer ändern",
|
||||
"plural": "Einstellungen für {n} Benutzer ändern"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "{n} Benutzer löschen",
|
||||
"plural": "{n} Benutzer löschen"
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Benutzer hinzufügen",
|
||||
"plural": "Benutzer hinzufügen"
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "Benutzer löschen",
|
||||
"plural": "Benutzer löschen"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "{n} Benutzer gelöscht.",
|
||||
"plural": "{n} Benutzer gelöscht."
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Einstellungen auf {n} Benutzer angewendet.",
|
||||
"plural": "Einstellungen auf {n} Benutzer angewendet."
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lang/admin/en-us.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English (US)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"accounts": "Accounts",
|
||||
"settings": "Settings",
|
||||
"theme": "Theme",
|
||||
"inviteDays": "Days",
|
||||
"inviteHours": "Hours",
|
||||
"inviteMinutes": "Minutes",
|
||||
"inviteNumberOfUses": "Number of uses",
|
||||
"warning": "Warning",
|
||||
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
|
||||
"inviteSendToEmail": "Send to",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"create": "Create",
|
||||
"apply": "Apply",
|
||||
"delete": "Delete",
|
||||
"submit": "Submit",
|
||||
"name": "Name",
|
||||
"date": "Date",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"emailAddress": "Email Address",
|
||||
"lastActiveTime": "Last Active",
|
||||
"from": "From",
|
||||
"user": "User",
|
||||
"aboutProgram": "About",
|
||||
"version": "Version",
|
||||
"commitNoun": "Commit",
|
||||
"newUser": "New User",
|
||||
"profile": "Profile",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown",
|
||||
"modifySettings": "Modify Settings",
|
||||
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
||||
"applyHomescreenLayout": "Apply homescreen layout",
|
||||
"sendDeleteNotificationEmail": "Send notification email",
|
||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||
"settingsRestartRequired": "Restart needed",
|
||||
"settingsRestartRequiredDescription": "A restart is necessary to apply some settings you changed. Restart now or later?",
|
||||
"settingsApplyRestartLater": "Apply, restart later",
|
||||
"settingsApplyRestartNow": "Apply & restart",
|
||||
"settingsApplied": "Settings applied.",
|
||||
"settingsRefreshPage": "Refresh the page in a few seconds",
|
||||
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
|
||||
"settingsSave": "Save",
|
||||
"ombiUserDefaults": "Ombi user defaults",
|
||||
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go",
|
||||
"userProfiles": "User Profiles",
|
||||
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
|
||||
"userProfilesIsDefault": "Default",
|
||||
"userProfilesLibraries": "Libraries",
|
||||
"addProfile": "Add Profile",
|
||||
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
|
||||
"addProfileNameOf": "Profile Name",
|
||||
"addProfileStoreHomescreenLayout": "Store homescreen layout",
|
||||
|
||||
"inviteNoUsersCreated": "None yet!",
|
||||
"inviteUsersCreated": "Created users",
|
||||
"inviteNoProfile": "No Profile",
|
||||
"copy": "Copy",
|
||||
"inviteDateCreated": "Created",
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"inviteNoInvites": "None",
|
||||
"inviteExpiresInTime": "Expires in {n}",
|
||||
|
||||
"notifyEvent": "Notify on:",
|
||||
"notifyInviteExpiry": "On expiry",
|
||||
"notifyUserCreation": "On user creation"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
"userCreated": "User {n} created.",
|
||||
"createProfile": "Created profile {n}.",
|
||||
"saveSettings": "Settings were saved",
|
||||
"setOmbiDefaults": "Stored ombi defaults.",
|
||||
"errorConnection": "Couldn't connect to jfa-go.",
|
||||
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
|
||||
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"errorBlankFields": "Fields were left blank",
|
||||
"errorDeleteProfile": "Failed to delete profile {n}",
|
||||
"errorLoadProfiles": "Failed to load profiles.",
|
||||
"errorCreateProfile": "Failed to create profile {n}",
|
||||
"errorSetDefaultProfile": "Failed to set default profile.",
|
||||
"errorLoadUsers": "Failed to load users.",
|
||||
"errorSaveSettings": "Couldn't save settings.",
|
||||
"errorLoadSettings": "Failed to load settings.",
|
||||
"errorSetOmbiDefaults": "Failed to store ombi defaults.",
|
||||
"errorLoadOmbiUsers": "Failed to load ombi users.",
|
||||
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
|
||||
"errorFailureCheckLogs": "Failed (check console/logs)",
|
||||
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)"
|
||||
},
|
||||
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "Modify Settings for {n} user",
|
||||
"plural": "Modify Settings for {n} users"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "Delete {n} user",
|
||||
"plural": "Delete {n} users"
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Add user",
|
||||
"plural": "Add users"
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "Delete User",
|
||||
"plural": "Delete Users"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "Deleted {n} user.",
|
||||
"plural": "Deleted {n} users."
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Applied settings to {n} user.",
|
||||
"plural": "Applied settings to {n} users."
|
||||
}
|
||||
}
|
||||
}
|
||||
128
lang/admin/fr-fr.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Francais (FR)",
|
||||
"author": "https://github.com/Killianbe"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invite",
|
||||
"accounts": "Comptes",
|
||||
"settings": "Reglages",
|
||||
"theme": "Thème",
|
||||
"inviteDays": "Jours",
|
||||
"inviteHours": "Heures",
|
||||
"inviteMinutes": "Minutes",
|
||||
"inviteNumberOfUses": "Nombre d'utilisateur",
|
||||
"warning": "Attention",
|
||||
"inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement",
|
||||
"inviteSendToEmail": "Envoyer à",
|
||||
"login": "S'identifier",
|
||||
"logout": "Se déconecter",
|
||||
"create": "Créer",
|
||||
"apply": "Appliquer",
|
||||
"delete": "Effacer",
|
||||
"submit": "Soumettre",
|
||||
"name": "Nom",
|
||||
"date": "Date",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"emailAddress": "Addresse Email",
|
||||
"lastActiveTime": "Dernière activité",
|
||||
"from": "De",
|
||||
"user": "Utilisateur",
|
||||
"aboutProgram": "A propos",
|
||||
"version": "Version",
|
||||
"commitNoun": "Commettre",
|
||||
"newUser": "Nouvel utilisateur",
|
||||
"profile": "Profil",
|
||||
"success": "Succès",
|
||||
"error": "Erreur",
|
||||
"unknown": "Inconnu",
|
||||
"modifySettings": "Modifier les paramètres",
|
||||
"modifySettingsDescription": "Appliquez les paramètres à partir d'un profil existant ou obtenez-les directement auprès d'un utilisateur.",
|
||||
"applyHomescreenLayout": "Appliquer la disposition de l'écran d'accueil",
|
||||
"sendDeleteNotificationEmail": "Envoyer un e-mail de notification",
|
||||
"sendDeleteNotifiationExample": "Votre compte a été supprimé. ",
|
||||
"settingsRestartRequired": "Redémarrage nécessaire ",
|
||||
"settingsRestartRequiredDescription": "Un redémarrage est nécessaire pour appliquer certains paramètres que vous avez modifiés. Redémarrer maintenant ou plus tard?",
|
||||
"settingsApplyRestartLater": "Appliquer, redémarrer plus tard ",
|
||||
"settingsApplyRestartNow": "Appliquer et redémarrer ",
|
||||
"settingsApplied": "Paramètres appliqués.",
|
||||
"settingsRefreshPage": "Actualisez la page dans quelques secondes ",
|
||||
"settingsRequiredOrRestartMessage": "Remarque: {n} indique un champ obligatoire, {n} indique que les modifications nécessitent un redémarrage. ",
|
||||
"settingsSave": "Sauver",
|
||||
"ombiUserDefaults": "Paramètres par défaut de l'utilisateur Ombi",
|
||||
"ombiUserDefaultsDescription": "Créez un utilisateur Ombi et configurez-le, puis sélectionnez-le ci-dessous. Ses paramètres / autorisations seront stockés et appliqués aux nouveaux utilisateurs Ombi créés par jfa-go ",
|
||||
"userProfiles": "Profils d'utilisateurs",
|
||||
"userProfilesDescription": "Les profils sont appliqués aux utilisateurs lorsqu'ils créent un compte. Un profil inclut les droits d'accès à la bibliothèque et la disposition de l'écran d'accueil. ",
|
||||
"userProfilesIsDefault": "Défaut",
|
||||
"userProfilesLibraries": "Bibliothèques",
|
||||
"addProfile": "Ajouter un profil",
|
||||
"addProfileDescription": "Créez un utilisateur Jellyfin et configurez-le, puis sélectionnez-le ci-dessous. Lorsque ce profil est appliqué à une invitation, de nouveaux utilisateurs seront créés avec les paramètres. ",
|
||||
"addProfileNameOf": "Nom de profil",
|
||||
"addProfileStoreHomescreenLayout": "Enregistrer la disposition de l'écran d'accueil",
|
||||
"inviteNoUsersCreated": "Aucun pour l'instant!",
|
||||
"inviteUsersCreated": "Utilisateurs créer",
|
||||
"inviteNoProfile": "Aucun profil",
|
||||
"copy": "Copier",
|
||||
"inviteDateCreated": "Créer",
|
||||
"inviteRemainingUses": "Utilisations restantes",
|
||||
"inviteNoInvites": "Aucune",
|
||||
"inviteExpiresInTime": "Expires dans {n}",
|
||||
"notifyEvent": "Notifier sur:",
|
||||
"notifyInviteExpiry": "À l'expiration",
|
||||
"notifyUserCreation": "à la création de l'utilisateur"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
|
||||
"userCreated": "L'utilisateur {n} a été créé.",
|
||||
"createProfile": "Profil créé {n}.",
|
||||
"saveSettings": "Les paramètres ont été enregistrés",
|
||||
"setOmbiDefaults": "Valeurs par défaut de Ombi.",
|
||||
"errorConnection": "Impossible de se connecter à jfa-go.",
|
||||
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.",
|
||||
"errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.",
|
||||
"errorSettingsFailed": "L'application a échoué.",
|
||||
"errorLoginBlank": "Le nom d'utilisateur et / ou le mot de passe sont vides",
|
||||
"errorUnknown": "Erreur inconnue.",
|
||||
"errorBlankFields": "Les champs sont vides",
|
||||
"errorDeleteProfile": "Échec de la suppression du profil {n}",
|
||||
"errorLoadProfiles": "Échec du chargement des profils.",
|
||||
"errorCreateProfile": "Échec de la création du profil {n}",
|
||||
"errorSetDefaultProfile": "Échec de la définition du profil par défaut",
|
||||
"errorLoadUsers": "Échec du chargement des utilisateurs.",
|
||||
"errorSaveSettings": "Impossible d'enregistrer les paramètres.",
|
||||
"errorLoadSettings": "Échec du chargement des paramètres.",
|
||||
"errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.",
|
||||
"errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.",
|
||||
"errorChangedEmailAddress": "Impossible de modifier l'adresse e-mail de {n}.",
|
||||
"errorFailureCheckLogs": "Échec (vérifier la console / les journaux)",
|
||||
"errorPartialFailureCheckLogs": "Panne partielle (vérifier la console / les journaux)"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "Modifier les paramètres pour {n} utilisateur",
|
||||
"plural": "Modifier les paramètres pour {n} utilisateurs"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "Supprimer {n} utilisateur",
|
||||
"plural": "Supprimer {n} utilisateurs"
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Ajouter un utilisateur",
|
||||
"plural": "Ajouter des utilisateurs"
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "Supprimer l'utilisateur",
|
||||
"plural": "Supprimer les utilisateurs"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "Supprimer {n} utilisateur.",
|
||||
"plural": "Supprimer {n} utilisateurs."
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Appliquer le paramètre {n} utilisteur.",
|
||||
"plural": "Appliquer les paramètres {n} utilisteurs."
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lang/admin/nl-nl.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Nederlands (NL)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Uitnodigingen",
|
||||
"accounts": "Accounts",
|
||||
"settings": "Instellingen",
|
||||
"theme": "Thema",
|
||||
"inviteDays": "Dagen",
|
||||
"inviteHours": "Uren",
|
||||
"inviteMinutes": "Minuten",
|
||||
"inviteNumberOfUses": "Aantal keer gebruikt",
|
||||
"warning": "Waarschuwing",
|
||||
"inviteInfiniteUsesWarning": "ongelimiteerde uitnodigingen kunnen misbruikt worden",
|
||||
"inviteSendToEmail": "Stuur naar",
|
||||
"login": "Inloggen",
|
||||
"logout": "Uitloggen",
|
||||
"create": "Aanmaken",
|
||||
"apply": "Toepassen",
|
||||
"delete": "Verwijderen",
|
||||
"submit": "Verstuur",
|
||||
"name": "Naam",
|
||||
"date": "Datum",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord",
|
||||
"emailAddress": "Email adres",
|
||||
"lastActiveTime": "Laatst actief",
|
||||
"from": "Van",
|
||||
"user": "Gebruiker",
|
||||
"aboutProgram": "Over",
|
||||
"version": "Versie",
|
||||
"commitNoun": "Commit",
|
||||
"newUser": "Nieuwe gebruiker",
|
||||
"profile": "Profiel",
|
||||
"success": "Success",
|
||||
"error": "Fout",
|
||||
"unknown": "Onbekend",
|
||||
"modifySettings": "Instellingen aanpassen",
|
||||
"modifySettingsDescription": "Pas instellingen van een bestaand profiel toe, of neem ze direct over van een gebruiker.",
|
||||
"applyHomescreenLayout": "Sla startpagina indeling op",
|
||||
"sendDeleteNotificationEmail": "Stuur meldingsemail",
|
||||
"sendDeleteNotifiationExample": "Je account is verwijderd.",
|
||||
"settingsRestartRequired": "Herstart nodig",
|
||||
"settingsRestartRequiredDescription": "Er is een herstart nodig om de wijzigingen door te voeren. Herstart nu of later?",
|
||||
"settingsApplyRestartLater": "Sla op, herstart later",
|
||||
"settingsApplyRestartNow": "Sla op & herstart",
|
||||
"settingsApplied": "Wijzigingen doorgevoerd.",
|
||||
"settingsRefreshPage": "Ververs de pagina over enkele seconden",
|
||||
"settingsRequiredOrRestartMessage": "Opmerking: {n} is een verplicht veld, {n} geeft aan dat na wijzigen een herstart nodig is.",
|
||||
"settingsSave": "Opslaan",
|
||||
"ombiUserDefaults": "Ombi gebruiker standaardinstellingen",
|
||||
"ombiUserDefaultsDescription": "Maak een Ombi gebruiker aan met de gewenste instellingen, en selecteer deze hieronder. Deze instellingen/rechten worden opgeslagen en toegepast voor nieuwe Ombi gebruikers die jfa-go aanmaakt",
|
||||
"userProfiles": "Gebruikersprofielen",
|
||||
"userProfilesDescription": "Profielen worden toegepast op gebruikers wanneer ze een account aanmaken. Een profiel bevat rechten voor bibliotheken en indeling van de startpagina.",
|
||||
"userProfilesIsDefault": "Standaard",
|
||||
"userProfilesLibraries": "Bibliotheken",
|
||||
"addProfile": "Voer profiel toe",
|
||||
"addProfileDescription": "Maak een Jellyfin gebruiker aan met de gewenste instellingen en selecteer deze hieronder. Wanneer dit profiel wordt toegepast op een uitnodiging, worden nieuwe gebruikers aangemaakt met deze instellingen.",
|
||||
"addProfileNameOf": "Profielnaam",
|
||||
"addProfileStoreHomescreenLayout": "Sla startpaginaindeling op",
|
||||
|
||||
"inviteNoUsersCreated": "Nog geen!",
|
||||
"inviteUsersCreated": "Aangemaakte gebruikers",
|
||||
"inviteNoProfile": "Geen profiel",
|
||||
"copy": "Kopiëer",
|
||||
"inviteDateCreated": "Aangemaakt",
|
||||
"inviteRemainingUses": "Resterend aantal gebruiken",
|
||||
"inviteNoInvites": "Geen",
|
||||
"inviteExpiresInTime": "Verloopt over {n}",
|
||||
|
||||
"notifyEvent": "Meldingen:",
|
||||
"notifyInviteExpiry": "Bij verloop",
|
||||
"notifyUserCreation": "Bij aanmaken gebruiker"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Email adres van {n} gewijzigd.",
|
||||
"userCreated": "Gebruiker {n} aangemaakt.",
|
||||
"createProfile": "Profiel {n} aangemaakt.",
|
||||
"saveSettings": "De instellingen zijn opgeslagen",
|
||||
"setOmbiDefaults": "De ombi standaardinstellingen zijn opgeslagen.",
|
||||
"errorConnection": "Kon geen verbinding maken met jfa-go.",
|
||||
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "De instellingen zijn toegepast, maar wijzigen van de startpaginaindeling is misschien mislukt.",
|
||||
"errorHomescreenAppliedNoSettings": "Startpaginaindeling toegepast, maar opslaan van instellingen is misschien mislukt.",
|
||||
"errorSettingsFailed": "Opslaan mislukt.",
|
||||
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
|
||||
"errorUnknown": "Onbekende fout.",
|
||||
"errorBlankFields": "Velden leeggelaten",
|
||||
"errorDeleteProfile": "Verwijderen van profiel {n} mislukt",
|
||||
"errorLoadProfiles": "Fout bij het laden van profielen.",
|
||||
"errorCreateProfile": "Aanmaken van profile {n} mislukt",
|
||||
"errorSetDefaultProfile": "Fout bij instellen van standaardprofiel.",
|
||||
"errorLoadUsers": "Laden van gebruikers mislukt.",
|
||||
"errorSaveSettings": "Opslaan van instellingen mislukt.",
|
||||
"errorLoadSettings": "Laden van instellingen mislukt.",
|
||||
"errorSetOmbiDefaults": "Opslaan van ombi standaardinstellingen mislukt.",
|
||||
"errorLoadOmbiUsers": "Laden van ombi gebruikers mislukt.",
|
||||
"errorChangedEmailAddress": "Wijzigen van emailadres van {n} mislukt.",
|
||||
"errorFailureCheckLogs": "Mislukt (controleer console/logbestanden)",
|
||||
"errorPartialFailureCheckLogs": "Gedeeltelijke fout (controleer console/logbestanden)"
|
||||
},
|
||||
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "Wijzig instellingen voor {n} gebruiker",
|
||||
"plural": "Wijzig instellingen voor {n} gebruikers"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "Verwijder {n} gebruiker",
|
||||
"plural": "Verwijder {n} gebruikers"
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Voeg gebruiker toe",
|
||||
"plural": "Voer gebruikers toe"
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "Verwijder gebruiker",
|
||||
"plural": "Verwijder gebruikers"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "{n} gebruiker verwijderd.",
|
||||
"plural": "{n} gebruikers verwijderd."
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Instellingen toegepast op {n} gebruiker.",
|
||||
"plural": "Instellingen toegepast op {n} gebruikers."
|
||||
}
|
||||
}
|
||||
}
|
||||
41
lang/email/de-de.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Mitteilung: Benutzer erstellt",
|
||||
"aUserWasCreated": "Ein Benutzer wurde unter Verwendung des Codes {n] erstellt.",
|
||||
"name": "Name",
|
||||
"emailAddress": "Adresse",
|
||||
"time": "Zeit",
|
||||
"notificationNotice": "Hinweis: Benachrichtigungs-E-Mails können auf dem Administrator-Dashboard umgeschalten werden."
|
||||
},
|
||||
"inviteExpiry": {
|
||||
"title": "Mitteilung: Invite abgelaufen",
|
||||
"inviteExpired": "Invite abgelaufen.",
|
||||
"expiredAt": "Code {n} lief um {n} ab.",
|
||||
"notificationNotice": "Hinweis: Benachrichtigungs-E-Mails können auf dem Administrator-Dashboard umgeschalten werden."
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Passwortzurücksetzung angefordert - Jellyfin",
|
||||
"helloUser": "Hallo {n},",
|
||||
"someoneHasRequestedReset": "Jemand hat vor kurzem eine Passwortzurücksetzung auf Jellyfin angefordert.",
|
||||
"ifItWasYou": "Wenn du das warst, gib den PIN unten in die Eingabeaufforderung ein.",
|
||||
"codeExpiry": "Der Code wird am {n}, um [n} UTC ablaufen, was in {n} ist.",
|
||||
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
"title": "Dein Konto wurde gelöscht - Jellyfin",
|
||||
"yourAccountWasDeleted": "Dein Jellyfin-Konto wurde gelöscht.",
|
||||
"reason": "Grund"
|
||||
},
|
||||
"inviteEmail": {
|
||||
"title": "Invite - Jellyfin",
|
||||
"hello": "Hallo",
|
||||
"youHaveBeenInvited": "Du wurdest zu Jellyfin eingeladen.",
|
||||
"toJoin": "Um beizutreten, folge dem untenstehenden Link.",
|
||||
"inviteExpiry": "Dieser Invite wird am {n}, um {n} ablaufen, was in {n} ist, also handle schnell.",
|
||||
"linkButton": "Richte dein Konto ein"
|
||||
}
|
||||
}
|
||||
41
lang/email/en-us.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English (US)"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Notice: User created",
|
||||
"aUserWasCreated": "A user was created using code {n}.",
|
||||
"name": "Name",
|
||||
"emailAddress": "Address",
|
||||
"time": "Time",
|
||||
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
|
||||
},
|
||||
"inviteExpiry": {
|
||||
"title": "Notice: Invite expired",
|
||||
"inviteExpired": "Invite expired.",
|
||||
"expiredAt": "Code {n} expired at {n}.",
|
||||
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Password reset requested - Jellyfin",
|
||||
"helloUser": "Hi {n},",
|
||||
"someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.",
|
||||
"ifItWasYou": "If this was you, enter the pin below into the prompt.",
|
||||
"codeExpiry": "The code will expire on {n}, at {n} UTC, which is in {n}.",
|
||||
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
"title": "Your account was deleted - Jellyfin",
|
||||
"yourAccountWasDeleted": "Your Jellyfin account was deleted.",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"inviteEmail": {
|
||||
"title": "Invite - Jellyfin",
|
||||
"hello": "Hi",
|
||||
"youHaveBeenInvited": "You've been invited to Jellyfin.",
|
||||
"toJoin": "To join, follow the below link.",
|
||||
"inviteExpiry": "This invite will expire on {n}, at {n}, which is in {n}, so act quick.",
|
||||
"linkButton": "Setup your account"
|
||||
}
|
||||
}
|
||||
42
lang/email/fr-fr.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Francais (FR)",
|
||||
"author": "https://github.com/Cornichon420"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Notification : Utilisateur créé",
|
||||
"aUserWasCreated": "Un utilisateur a été créé avec ce code {n}",
|
||||
"name": "Nom",
|
||||
"emailAddress": "Adresse",
|
||||
"time": "Date",
|
||||
"notificationNotice": ""
|
||||
},
|
||||
"inviteExpiry": {
|
||||
"title": "Notification : Invitation expirée",
|
||||
"inviteExpired": "Invitation expirée.",
|
||||
"expiredAt": "Le code {n} a expiré à {n}.",
|
||||
"notificationNotice": ""
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Réinitialisation de mot du passe demandée - Jellyfin",
|
||||
"helloUser": "Salut {n},",
|
||||
"someoneHasRequestedReset": "Quelqu'un vient de demander une réinitialisation du mot de passe via Jellyfin.",
|
||||
"ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.",
|
||||
"codeExpiry": "Ce code expirera le {n}, à {n} UTC, soit dans {n}.",
|
||||
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
"title": "Ton compte a été désactivé - Jellyfin",
|
||||
"yourAccountWasDeleted": "Ton compte Jellyfin a été supprimé.",
|
||||
"reason": "Motif"
|
||||
},
|
||||
"inviteEmail": {
|
||||
"title": "Invitation - Jellyfin",
|
||||
"hello": "Salut",
|
||||
"youHaveBeenInvited": "Tu as été invité à rejoindre Jellyfin.",
|
||||
"toJoin": "Pour continuer, suis le lien en dessous.",
|
||||
"inviteExpiry": "L'invitation expirera le {n}, à {n}, soit dans {n}, alors fais vite !",
|
||||
"linkButton": "Lien"
|
||||
}
|
||||
}
|
||||
41
lang/email/nl-nl.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Nederlands (NL)"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Melding: Gebruiker aangemaakt",
|
||||
"aUserWasCreated": "Er is een gebruiker aangemaakt door gebruik te maken van code {n}.",
|
||||
"name": "Naam",
|
||||
"emailAddress": "Adres",
|
||||
"time": "Tijdstip",
|
||||
"notificationNotice": "Opmerking: Meldingsemails kunnen worden aan- of uitgezet via het admin dashboard."
|
||||
},
|
||||
"inviteExpiry": {
|
||||
"title": "Melding: Uitnodiging verlopen",
|
||||
"inviteExpired": "Uitnodiging verlopen.",
|
||||
"expiredAt": "Code {n} is verlopen op {n}.",
|
||||
"notificationNotice": "Opmerking: Meldingsemails kunnen worden aan- of uitgezet via het admin dashboard."
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Wachtwoordreset aangevraagd - Jellyfin",
|
||||
"helloUser": "Hoi {n},",
|
||||
"someoneHasRequestedReset": "Iemand heeft recentelijk een wachtwoordreset aangevraagd in Jellyfin.",
|
||||
"ifItWasYou": "Als jij dit was, voor dan onderstaande PIN in.",
|
||||
"codeExpiry": "De code verloopt op {n}, op {n} UTC, dat is over {n}.",
|
||||
"ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
"title": "Je account is verwijderd - Jellyfin",
|
||||
"yourAccountWasDeleted": "Je Jellyfin account is verwijderd.",
|
||||
"reason": "Reden"
|
||||
},
|
||||
"inviteEmail": {
|
||||
"title": "Uitnodiging - Jellyfin",
|
||||
"hello": "Hoi",
|
||||
"youHaveBeenInvited": "Je bent uitgenodigd voor Jellyfin.",
|
||||
"toJoin": "Volg onderstaande link om door te gaan.",
|
||||
"inviteExpiry": "Deze uitnodiging verloopt op {n}, om {n}, dat is over {n}, dus wees er snel bij.",
|
||||
"linkButton": "Maak account aan"
|
||||
}
|
||||
}
|
||||
41
lang/form/de-de.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Jellyfin-Konto erstellen",
|
||||
"createAccountHeader": "Konto erstellen",
|
||||
"accountDetails": "Details",
|
||||
"emailAddress": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"reEnterPassword": "Passwort wiederholen",
|
||||
"reEnterPasswordInvalid": "Passwörter stimmen nicht überein.",
|
||||
"createAccountButton": "Konto erstellen",
|
||||
"passwordRequirementsHeader": "Passwortanforderungen",
|
||||
"successHeader": "Erfolg!",
|
||||
"successContinueButton": "Weiter"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Muss mindestens {n} Zeichen haben",
|
||||
"plural": "Muss mindestens {n} Zeichen haben"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Muss mindestens {n} Großbuchstaben haben",
|
||||
"plural": "Muss mindestens {n} Großbuchstaben haben"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Muss mindestens {n} Kleinbuchstaben haben",
|
||||
"plural": "Muss mindestens {n} Kleinbuchstaben haben"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Muss mindestens {n} Zahl haben",
|
||||
"plural": "Muss mindestens {n} Zahlen haben"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Muss mindestens {n} Sonderzeichen haben",
|
||||
"plural": "Muss mindestens {n} Sonderzeichen haben"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
lang/form/en-us.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English (US)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Create Jellyfin Account",
|
||||
"createAccountHeader": "Create Account",
|
||||
"accountDetails": "Details",
|
||||
"emailAddress": "Email",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"reEnterPassword": "Re-enter Password",
|
||||
"reEnterPasswordInvalid": "Passwords are not the same.",
|
||||
"createAccountButton": "Create Account",
|
||||
"passwordRequirementsHeader": "Password Requirements",
|
||||
"successHeader": "Success!",
|
||||
"successContinueButton": "Continue"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Must have at least {n} character",
|
||||
"plural": "Must have at least {n} characters"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Must have at least {n} uppercase character",
|
||||
"plural": "Must have at least {n} uppercase characters"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Must have at least {n} lowercase character",
|
||||
"plural": "Must have at least {n} lowercase characters"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Must have at least {n} number",
|
||||
"plural": "Must have at least {n} numbers"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Must have at least {n} special character",
|
||||
"plural": "Must have at least {n} special characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lang/form/fr-fr.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Francais (FR)",
|
||||
"author": "https://github.com/Killianbe"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Créer un compte Jellyfin",
|
||||
"createAccountHeader": "Création du compte",
|
||||
"accountDetails": "Détails",
|
||||
"emailAddress": "Email",
|
||||
"username": "Pseudo",
|
||||
"password": "Mot de passe",
|
||||
"reEnterPassword": "Confirmez mot de passe",
|
||||
"reEnterPasswordInvalid": "Les mots de passe ne correspondent pas.",
|
||||
"createAccountButton": "Créer le compte",
|
||||
"passwordRequirementsHeader": "Mot de passe requis",
|
||||
"successHeader": "Succes!",
|
||||
"successContinueButton": "Continuer"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Doit avoir au moins {n} caractère",
|
||||
"plural": "Doit avoir au moins {n} caractères"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Doit avoir au moins {n} caractère majuscule",
|
||||
"plural": "Must have at least {n} caractères majuscules"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Doit avoir au moins {n} caractère minuscule",
|
||||
"plural": "Doit avoir au moins {n} caractères minuscules"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Doit avoir au moins {n} nombre",
|
||||
"plural": "Doit avoir au moins {n} nombres"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Doit avoir au moins {n} caractère spécial",
|
||||
"plural": "Doit avoir au moins {n} caractères spéciaux"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
lang/form/nl-nl.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Nederlands (NL)"
|
||||
},
|
||||
"strings": {
|
||||
"pageTitle": "Maak Jellyfin account aan",
|
||||
"createAccountHeader": "Account aanmaken",
|
||||
"accountDetails": "Details",
|
||||
"emailAddress": "Email",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord",
|
||||
"reEnterPassword": "Bevestig wachtwoord",
|
||||
"reEnterPasswordInvalid": "Wachtwoorden komen niet overeen.",
|
||||
"createAccountButton": "Maak account aan",
|
||||
"passwordRequirementsHeader": "Wachtwoordvereisten",
|
||||
"successHeader": "Succes!",
|
||||
"successContinueButton": "Doorgaan"
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
"singular": "Moet ten minste {n} teken bevatten",
|
||||
"plural": "Moet ten minste {n} tekens bevatten"
|
||||
},
|
||||
"uppercase": {
|
||||
"singular": "Moet ten minste {n} hoofdletter bevatten",
|
||||
"plural": "Moet ten minste {n} hoofdletters bevatten"
|
||||
},
|
||||
"lowercase": {
|
||||
"singular": "Moet ten minste {n} kleine letter bevatten",
|
||||
"plural": "Moet ten minste {n} kleine letters bevatten"
|
||||
},
|
||||
"number": {
|
||||
"singular": "Moet ten minste {n} cijfer bevatten",
|
||||
"plural": "Moet ten minste {n} cijfers bevatten"
|
||||
},
|
||||
"special": {
|
||||
"singular": "Moet ten minste {n} bijzonder teken bevatten",
|
||||
"plural": "Moet ten minste {n} bijzondere tekens bevatten"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,26 +20,25 @@
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>User Created</h3>
|
||||
<p>A user was created using code {{ .code }}.</p>
|
||||
<p>{{ .aUserWasCreated }}</p>
|
||||
</mj-text>
|
||||
<mj-table mj-class="text" container-background-color="#242424">
|
||||
<tr style="text-align: left;">
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Time</th>
|
||||
<th>{{ .name }}</th>
|
||||
<th>{{ .address }}</th>
|
||||
<th>{{ .time }}</th>
|
||||
</tr>
|
||||
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
|
||||
<th>{{ .username }}</th>
|
||||
<th>{{ .address }}</th>
|
||||
<th>{{ .time }}</th>
|
||||
<th>{{ .nameVal }}</th>
|
||||
<th>{{ .addressVal }}</th>
|
||||
<th>{{ .timeVal }}</th>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
Notification emails can be toggled on the admin dashboard.
|
||||
{{ .notificationNotice }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
A user was created using code {{ .code }}.
|
||||
{{ .aUserWasCreated }}
|
||||
|
||||
Name: {{ .username }}
|
||||
Address: {{ .address }}
|
||||
Time: {{ .time }}
|
||||
{{ .name }}: {{ .nameVal }}
|
||||
{{ .address }}: {{ .addressVal }}
|
||||
{{ .time }}: {{ .timeVal }}
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
{{ .notificationNotice }}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>Your account was deleted.</h3>
|
||||
<p>Reason: <i>{{ .reason }}</i></p>
|
||||
<h3>{{ .yourAccountWasDeleted }}</h3>
|
||||
<p>{{ .reason }}: <i>{{ .reasonVal }}</i></p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Your Jellyfin account was deleted.
|
||||
Reason: {{ .reason }}
|
||||
{{ .yourAccountWasDeleted }}
|
||||
{{ .reason }}: {{ .reasonVal }}
|
||||
|
||||
{{ .message }}
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi {{ .username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ .expiry_date }}, at {{ .expiry_time }} UTC, which is in {{ .expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
<p>{{ .helloUser }}</p>
|
||||
<p>{{ .someoneHasRequestedReset }}</p>
|
||||
<p>{{ .ifItWasYou }}</p>
|
||||
<p>{{ .codeExpiry }}</p>
|
||||
<p>{{ .ifItWasNotYou }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold">{{ .pin }}</mj-button>
|
||||
<mj-button mj-class="blue bold">{{ .pinVal }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
Hi {{ .username }},
|
||||
{{ .helloUser }}
|
||||
|
||||
Someone has recently requests a password reset on Jellyfin.
|
||||
If this was you, enter the below pin into the prompt.
|
||||
This code will expire on {{ .expiry_date }}, at {{ .expiry_time }} UTC, which is in {{ .expires_in }}.
|
||||
If this wasn't you, please ignore this email.
|
||||
{{ .someoneHasRequestedReset }}
|
||||
{{ .ifItWasYou }}
|
||||
{{ .codeExpiry }}
|
||||
{{ .ifItWasNotYou }}
|
||||
|
||||
PIN: {{ .pin }}
|
||||
{{ .pin }}: {{ .pinVal }}
|
||||
|
||||
{{ .message }}
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>Invite Expired.</h3>
|
||||
<p>Code {{ .code }} expired at {{ .expiry }}.</p>
|
||||
<h3>{{ .inviteExpired }}</h3>
|
||||
<p>{{ .expiredAt }}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
Notification emails can be toggled on the admin dashboard.
|
||||
{{ .notificationNotice }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Invite expired.
|
||||
{{ .inviteExpired }}
|
||||
|
||||
Code {{ .code }} expired at {{ .expiry }}.
|
||||
{{ .expiredAt }}
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
{{ .notificationNotice }}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-o", "--output", help="output directory for .html and .txt files")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
def runcmd(cmd):
|
||||
if os.name == "nt":
|
||||
@@ -22,7 +27,8 @@ for mjml in [f for f in local_path.iterdir() if f.is_file() and "mjml" in f.suff
|
||||
|
||||
html = [f for f in local_path.iterdir() if f.is_file() and "html" in f.suffix]
|
||||
|
||||
output = local_path.parent / "data"
|
||||
output = Path(args.output) # local_path.parent / "build" / "data"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for f in html:
|
||||
shutil.copy(str(f), str(output / f.name))
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi,</p>
|
||||
<h3>You've been invited to Jellyfin.</h3>
|
||||
<p>To join, click the button below.</p>
|
||||
<p>This invite will expire on {{ .expiry_date }}, at {{ .expiry_time }}, which is in {{ .expires_in }}, so act quick.</p>
|
||||
<p>{{ .hello }},</p>
|
||||
<h3>{{ .youHaveBeenInvited }}</h3>
|
||||
<p>{{ .toJoin }}</p>
|
||||
<p>{{ .inviteExpiry }}</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold" href="{{ .invite_link }}">Setup your account</mj-button>
|
||||
<mj-button mj-class="blue bold" href="{{ .invite_link }}">{{ .linkButton }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Hi,
|
||||
You've been invited to Jellyfin.
|
||||
To join, follow the below link.
|
||||
This invite will expire on {{ .expiry_date }}, at {{ .expiry_time }}, which is in {{ .expires_in }}, so act quick.
|
||||
{{ .hello }},
|
||||
{{ .youHaveBeenInvited }}
|
||||
{{ .toJoin }}
|
||||
{{ .inviteExpiry }}
|
||||
|
||||
{{ .invite_link }}
|
||||
|
||||
|
||||
255
main.go
@@ -17,6 +17,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,7 +26,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
_ "github.com/hrfee/jfa-go/docs"
|
||||
"github.com/hrfee/jfa-go/jfapi"
|
||||
"github.com/hrfee/jfa-go/mediabrowser"
|
||||
"github.com/hrfee/jfa-go/ombi"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/logrusorgru/aurora/v3"
|
||||
@@ -34,28 +35,36 @@ import (
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// Username is JWT!
|
||||
var serverTypes = map[string]string{
|
||||
"jellyfin": "Jellyfin",
|
||||
"emby": "Emby (experimental)",
|
||||
}
|
||||
var serverType = mediabrowser.JellyfinServer
|
||||
var substituteStrings = ""
|
||||
|
||||
// User is used for auth purposes.
|
||||
type User struct {
|
||||
UserID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// contains everything the application needs, essentially. Wouldn't do this in the future.
|
||||
type appContext struct {
|
||||
// defaults *Config
|
||||
config *ini.File
|
||||
config_path string
|
||||
configBase_path string
|
||||
configBase map[string]interface{}
|
||||
data_path string
|
||||
local_path string
|
||||
cssFile string
|
||||
bsVersion int
|
||||
jellyfinLogin bool
|
||||
users []User
|
||||
invalidTokens []string
|
||||
jf *jfapi.Jellyfin
|
||||
authJf *jfapi.Jellyfin
|
||||
config *ini.File
|
||||
configPath string
|
||||
configBasePath string
|
||||
configBase settings
|
||||
dataPath string
|
||||
localPath string
|
||||
cssClass string
|
||||
jellyfinLogin bool
|
||||
users []User
|
||||
invalidTokens []string
|
||||
// Keeping jf name because I can't think of a better one
|
||||
jf *mediabrowser.MediaBrowser
|
||||
authJf *mediabrowser.MediaBrowser
|
||||
ombi *ombi.Ombi
|
||||
datePattern string
|
||||
timePattern string
|
||||
@@ -67,21 +76,15 @@ type appContext struct {
|
||||
port int
|
||||
version string
|
||||
quit chan os.Signal
|
||||
lang Languages
|
||||
}
|
||||
|
||||
type Languages struct {
|
||||
langFiles []os.FileInfo // Language filenames
|
||||
langOptions []string // Language names
|
||||
chosenIndex int
|
||||
URLBase string
|
||||
}
|
||||
|
||||
func (app *appContext) loadHTML(router *gin.Engine) {
|
||||
customPath := app.config.Section("files").Key("html_templates").MustString("")
|
||||
templatePath := filepath.Join(app.local_path, "templates")
|
||||
templatePath := filepath.Join(app.localPath, "html")
|
||||
htmlFiles, err := ioutil.ReadDir(templatePath)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Couldn't access template directory: \"%s\"", filepath.Join(app.local_path, "templates"))
|
||||
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
|
||||
return
|
||||
}
|
||||
loadFiles := make([]string, len(htmlFiles))
|
||||
@@ -97,7 +100,7 @@ func (app *appContext) loadHTML(router *gin.Engine) {
|
||||
router.LoadHTMLFiles(loadFiles...)
|
||||
}
|
||||
|
||||
func GenerateSecret(length int) (string, error) {
|
||||
func generateSecret(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
@@ -173,7 +176,7 @@ func test(app *appContext) {
|
||||
fmt.Scanln(&username)
|
||||
user, status, err := app.jf.UserByName(username, false)
|
||||
fmt.Printf("UserByName (%s): code %d err %s", username, status, err)
|
||||
out, err := json.MarshalIndent(user, "", " ")
|
||||
out, _ := json.MarshalIndent(user, "", " ")
|
||||
fmt.Print(string(out))
|
||||
}
|
||||
|
||||
@@ -187,17 +190,17 @@ func start(asDaemon, firstCall bool) {
|
||||
local_path is the internal 'data' directory.
|
||||
*/
|
||||
userConfigDir, _ := os.UserConfigDir()
|
||||
app.data_path = filepath.Join(userConfigDir, "jfa-go")
|
||||
app.config_path = filepath.Join(app.data_path, "config.ini")
|
||||
app.dataPath = filepath.Join(userConfigDir, "jfa-go")
|
||||
app.configPath = filepath.Join(app.dataPath, "config.ini")
|
||||
executable, _ := os.Executable()
|
||||
app.local_path = filepath.Join(filepath.Dir(executable), "data")
|
||||
app.localPath = filepath.Join(filepath.Dir(executable), "data")
|
||||
|
||||
app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
|
||||
app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
|
||||
|
||||
if firstCall {
|
||||
DATA = flag.String("data", app.data_path, "alternate path to data directory.")
|
||||
CONFIG = flag.String("config", app.config_path, "alternate path to config file.")
|
||||
DATA = flag.String("data", app.dataPath, "alternate path to data directory.")
|
||||
CONFIG = flag.String("config", app.configPath, "alternate path to config file.")
|
||||
HOST = flag.String("host", "", "alternate address to host web ui on.")
|
||||
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
|
||||
DEBUG = flag.Bool("debug", false, "Enables debug logging and exposes pprof.")
|
||||
@@ -219,35 +222,35 @@ func start(asDaemon, firstCall bool) {
|
||||
*DEBUG = true
|
||||
}
|
||||
// attempt to apply command line flags correctly
|
||||
if app.config_path == *CONFIG && app.data_path != *DATA {
|
||||
app.data_path = *DATA
|
||||
app.config_path = filepath.Join(app.data_path, "config.ini")
|
||||
} else if app.config_path != *CONFIG && app.data_path == *DATA {
|
||||
app.config_path = *CONFIG
|
||||
if app.configPath == *CONFIG && app.dataPath != *DATA {
|
||||
app.dataPath = *DATA
|
||||
app.configPath = filepath.Join(app.dataPath, "config.ini")
|
||||
} else if app.configPath != *CONFIG && app.dataPath == *DATA {
|
||||
app.configPath = *CONFIG
|
||||
} else {
|
||||
app.config_path = *CONFIG
|
||||
app.data_path = *DATA
|
||||
app.configPath = *CONFIG
|
||||
app.dataPath = *DATA
|
||||
}
|
||||
|
||||
// env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.
|
||||
|
||||
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
|
||||
app.config_path = v
|
||||
app.configPath = v
|
||||
}
|
||||
if v := os.Getenv("JFA_DATAPATH"); v != "" {
|
||||
app.data_path = v
|
||||
app.dataPath = v
|
||||
}
|
||||
|
||||
os.Setenv("JFA_CONFIGPATH", app.config_path)
|
||||
os.Setenv("JFA_DATAPATH", app.data_path)
|
||||
os.Setenv("JFA_CONFIGPATH", app.configPath)
|
||||
os.Setenv("JFA_DATAPATH", app.dataPath)
|
||||
|
||||
var firstRun bool
|
||||
if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
|
||||
os.Mkdir(app.data_path, 0700)
|
||||
if _, err := os.Stat(app.dataPath); os.IsNotExist(err) {
|
||||
os.Mkdir(app.dataPath, 0700)
|
||||
}
|
||||
if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(app.configPath); os.IsNotExist(err) {
|
||||
firstRun = true
|
||||
dConfigPath := filepath.Join(app.local_path, "config-default.ini")
|
||||
dConfigPath := filepath.Join(app.localPath, "config-default.ini")
|
||||
var dConfig *os.File
|
||||
dConfig, err = os.Open(dConfigPath)
|
||||
if err != nil {
|
||||
@@ -255,30 +258,24 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
defer dConfig.Close()
|
||||
var nConfig *os.File
|
||||
nConfig, err := os.Create(app.config_path)
|
||||
nConfig, err := os.Create(app.configPath)
|
||||
if err != nil {
|
||||
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.config_path)
|
||||
app.err.Printf("Couldn't open config file for writing: \"%s\"", app.configPath)
|
||||
app.err.Fatalf("Error: %s", err)
|
||||
}
|
||||
defer nConfig.Close()
|
||||
_, err = io.Copy(nConfig, dConfig)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
|
||||
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.configPath)
|
||||
}
|
||||
app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
|
||||
app.info.Printf("Copied default configuration to \"%s\"", app.configPath)
|
||||
}
|
||||
|
||||
var debugMode bool
|
||||
var address string
|
||||
if app.loadConfig() != nil {
|
||||
app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
|
||||
app.err.Fatalf("Failed to load config file \"%s\"", app.configPath)
|
||||
}
|
||||
lang := app.config.Section("ui").Key("language").MustString("en-us")
|
||||
app.storage.lang.FormPath = filepath.Join(app.local_path, "lang", "form", lang+".json")
|
||||
if _, err := os.Stat(app.storage.lang.FormPath); os.IsNotExist(err) {
|
||||
app.storage.lang.FormPath = filepath.Join(app.local_path, "lang", "form", "en-us.json")
|
||||
}
|
||||
app.storage.loadLang()
|
||||
app.version = app.config.Section("jellyfin").Key("version").String()
|
||||
// read from config...
|
||||
debugMode = app.config.Section("ui").Key("debug").MustBool(false)
|
||||
@@ -334,7 +331,12 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
if !firstRun {
|
||||
app.host = app.config.Section("ui").Key("host").String()
|
||||
app.port = app.config.Section("ui").Key("port").MustInt(8056)
|
||||
if app.config.Section("advanced").Key("tls").MustBool(false) {
|
||||
app.info.Println("Using TLS/HTTP2")
|
||||
app.port = app.config.Section("advanced").Key("tls_port").MustInt(8057)
|
||||
} else {
|
||||
app.port = app.config.Section("ui").Key("port").MustInt(8056)
|
||||
}
|
||||
|
||||
if *HOST != app.host && *HOST != "" {
|
||||
app.host = *HOST
|
||||
@@ -353,18 +355,9 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
address = fmt.Sprintf("%s:%d", app.host, app.port)
|
||||
|
||||
app.debug.Printf("Loaded config file \"%s\"", app.config_path)
|
||||
|
||||
if app.config.Section("ui").Key("bs5").MustBool(false) {
|
||||
app.cssFile = "bs5-jf.css"
|
||||
app.bsVersion = 5
|
||||
} else {
|
||||
app.cssFile = "bs4-jf.css"
|
||||
app.bsVersion = 4
|
||||
}
|
||||
app.debug.Printf("Loaded config file \"%s\"", app.configPath)
|
||||
|
||||
app.debug.Println("Loading storage")
|
||||
|
||||
@@ -381,7 +374,6 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
|
||||
app.storage.loadProfiles()
|
||||
|
||||
if !(len(app.storage.policy) == 0 && len(app.storage.configuration) == 0 && len(app.storage.displayprefs) == 0) {
|
||||
app.info.Println("Migrating user template files to new profile format")
|
||||
app.storage.migrateToProfile()
|
||||
@@ -411,20 +403,21 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
}
|
||||
|
||||
app.configBase_path = filepath.Join(app.local_path, "config-base.json")
|
||||
configBase, _ := ioutil.ReadFile(app.configBase_path)
|
||||
app.configBasePath = filepath.Join(app.localPath, "config-base.json")
|
||||
configBase, _ := ioutil.ReadFile(app.configBasePath)
|
||||
json.Unmarshal(configBase, &app.configBase)
|
||||
|
||||
themes := map[string]string{
|
||||
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion),
|
||||
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
|
||||
"Custom CSS": "",
|
||||
"Jellyfin (Dark)": "dark-theme",
|
||||
"Default (Light)": "light-theme",
|
||||
}
|
||||
if app.config.Section("ui").Key("theme").String() == "Bootstrap (Light)" {
|
||||
app.config.Section("ui").Key("theme").SetValue("Default (Light)")
|
||||
}
|
||||
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
|
||||
app.cssFile = val
|
||||
app.cssClass = val
|
||||
}
|
||||
app.debug.Printf("Using css file \"%s\"", app.cssFile)
|
||||
secret, err := GenerateSecret(16)
|
||||
secret, err := generateSecret(16)
|
||||
if err != nil {
|
||||
app.err.Fatal(err)
|
||||
}
|
||||
@@ -443,13 +436,25 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
server := app.config.Section("jellyfin").Key("server").String()
|
||||
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
|
||||
app.jf, _ = jfapi.NewJellyfin(
|
||||
stringServerType := app.config.Section("jellyfin").Key("type").String()
|
||||
timeoutHandler := common.NewTimeoutHandler("Jellyfin", server, true)
|
||||
if stringServerType == "emby" {
|
||||
serverType = mediabrowser.EmbyServer
|
||||
timeoutHandler = common.NewTimeoutHandler("Emby", server, true)
|
||||
app.info.Println("Using Emby server type")
|
||||
fmt.Println(aurora.Yellow("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available."))
|
||||
} else {
|
||||
app.info.Println("Using Jellyfin server type")
|
||||
}
|
||||
|
||||
app.jf, _ = mediabrowser.NewServer(
|
||||
serverType,
|
||||
server,
|
||||
app.config.Section("jellyfin").Key("client").String(),
|
||||
app.config.Section("jellyfin").Key("version").String(),
|
||||
app.config.Section("jellyfin").Key("device").String(),
|
||||
app.config.Section("jellyfin").Key("device_id").String(),
|
||||
common.NewTimeoutHandler("Jellyfin", server, true),
|
||||
timeoutHandler,
|
||||
cacheTimeout,
|
||||
)
|
||||
var status int
|
||||
@@ -458,7 +463,71 @@ func start(asDaemon, firstCall bool) {
|
||||
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
|
||||
}
|
||||
app.info.Printf("Authenticated with %s", server)
|
||||
app.authJf, _ = jfapi.NewJellyfin(server, "jfa-go", app.version, "auth", "auth", common.NewTimeoutHandler("Jellyfin", server, true), cacheTimeout)
|
||||
// from 10.7.0, jellyfin may hyphenate user IDs. This checks if the version is equal or higher.
|
||||
checkVersion := func(version string) int {
|
||||
numberStrings := strings.Split(version, ".")
|
||||
n := 0
|
||||
for _, s := range numberStrings {
|
||||
num, err := strconv.Atoi(s)
|
||||
if err == nil {
|
||||
n += num
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") {
|
||||
// Get users to check if server uses hyphenated userIDs
|
||||
app.jf.GetUsers(false)
|
||||
|
||||
noHyphens := true
|
||||
for id := range app.storage.emails {
|
||||
if strings.Contains(id, "-") {
|
||||
noHyphens = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if noHyphens == app.jf.Hyphens {
|
||||
var newEmails map[string]interface{}
|
||||
var status int
|
||||
var err error
|
||||
if app.jf.Hyphens {
|
||||
app.info.Println(aurora.Yellow("Your build of Jellyfin appears to hypenate user IDs. Your emails.json file will be modified to match."))
|
||||
time.Sleep(time.Second * time.Duration(3))
|
||||
newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails)
|
||||
} else {
|
||||
app.info.Println(aurora.Yellow("Your emails.json file uses hyphens, but the Jellyfin server no longer does. It will be modified."))
|
||||
time.Sleep(time.Second * time.Duration(3))
|
||||
newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails)
|
||||
}
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
|
||||
app.debug.Printf("Error: %s", err)
|
||||
app.err.Fatalf("Couldn't upgrade emails.json")
|
||||
}
|
||||
bakFile := app.storage.emails_path + ".bak"
|
||||
err = storeJSON(bakFile, app.storage.emails)
|
||||
if err != nil {
|
||||
app.err.Fatalf("couldn't store emails.json backup: %s", err)
|
||||
}
|
||||
app.storage.emails = newEmails
|
||||
err = app.storage.storeEmails()
|
||||
if err != nil {
|
||||
app.err.Fatalf("couldn't store emails.json: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
app.storage.lang.FormPath = filepath.Join(app.localPath, "lang", "form")
|
||||
app.storage.lang.AdminPath = filepath.Join(app.localPath, "lang", "admin")
|
||||
app.storage.lang.EmailPath = filepath.Join(app.localPath, "lang", "email")
|
||||
err = app.storage.loadLang()
|
||||
if err != nil {
|
||||
app.info.Fatalf("Failed to load language files: %+v\n", err)
|
||||
}
|
||||
|
||||
// Since email depends on language, the email reload in loadConfig won't work first time.
|
||||
app.email = NewEmailer(app)
|
||||
|
||||
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
|
||||
|
||||
app.loadStrftime()
|
||||
|
||||
@@ -481,10 +550,10 @@ func start(asDaemon, firstCall bool) {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
|
||||
go inviteDaemon.Run()
|
||||
inviteDaemon := newRepeater(time.Duration(60*time.Second), app)
|
||||
go inviteDaemon.run()
|
||||
|
||||
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
|
||||
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
|
||||
go app.StartPWR()
|
||||
}
|
||||
} else {
|
||||
@@ -502,7 +571,7 @@ func start(asDaemon, firstCall bool) {
|
||||
setGinLogger(router, debugMode)
|
||||
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
|
||||
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
|
||||
app.loadHTML(router)
|
||||
router.NoRoute(app.NoRouteHandler)
|
||||
if debugMode {
|
||||
@@ -511,10 +580,14 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
if !firstRun {
|
||||
router.GET("/", app.AdminPage)
|
||||
router.GET("/accounts", app.AdminPage)
|
||||
router.GET("/settings", app.AdminPage)
|
||||
router.GET("/lang/:page/:file", app.ServeLang)
|
||||
router.GET("/lang/:page", app.GetLanguages)
|
||||
router.GET("/token/login", app.getTokenLogin)
|
||||
router.GET("/token/refresh", app.getTokenRefresh)
|
||||
router.POST("/newUser", app.NewUser)
|
||||
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
|
||||
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
|
||||
router.GET("/invite/:invCode", app.InviteProxy)
|
||||
if *SWAGGER {
|
||||
app.info.Print(aurora.Magenta("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
|
||||
@@ -558,8 +631,16 @@ func start(asDaemon, firstCall bool) {
|
||||
Handler: router,
|
||||
}
|
||||
go func() {
|
||||
if err := SRV.ListenAndServe(); err != nil {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
if app.config.Section("advanced").Key("tls").MustBool(false) {
|
||||
cert := app.config.Section("advanced").Key("tls_cert").MustString("")
|
||||
key := app.config.Section("advanced").Key("tls_key").MustString("")
|
||||
if err := SRV.ListenAndServeTLS(cert, key); err != nil {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
}
|
||||
} else {
|
||||
if err := SRV.ListenAndServe(); err != nil {
|
||||
app.err.Printf("Failure serving: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
app.quit = make(chan os.Signal)
|
||||
|
||||
175
mediabrowser/emby.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package mediabrowser
|
||||
|
||||
// Almost identical to jfapi, with the most notable change being the password workaround.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func embyDeleteUser(emby *MediaBrowser, userID string) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s", emby.Server, userID)
|
||||
req, _ := http.NewRequest("DELETE", url, nil)
|
||||
for name, value := range emby.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := emby.httpClient.Do(req)
|
||||
defer emby.timeoutHandler()
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
|
||||
func embyGetUsers(emby *MediaBrowser, public bool) ([]map[string]interface{}, int, error) {
|
||||
var result []map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
if time.Now().After(emby.CacheExpiry) {
|
||||
if public {
|
||||
url := fmt.Sprintf("%s/users/public", emby.Server)
|
||||
data, status, err = emby.get(url, nil)
|
||||
} else {
|
||||
url := fmt.Sprintf("%s/users", emby.Server)
|
||||
data, status, err = emby.get(url, emby.loginParams)
|
||||
}
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
emby.userCache = result
|
||||
emby.CacheExpiry = time.Now().Add(time.Minute * time.Duration(emby.cacheLength))
|
||||
if id, ok := result[0]["Id"]; ok {
|
||||
if id.(string)[8] == '-' {
|
||||
emby.Hyphens = true
|
||||
}
|
||||
}
|
||||
return result, status, nil
|
||||
}
|
||||
return emby.userCache, 200, nil
|
||||
}
|
||||
|
||||
func embyUserByName(emby *MediaBrowser, username string, public bool) (map[string]interface{}, int, error) {
|
||||
var match map[string]interface{}
|
||||
find := func() (map[string]interface{}, int, error) {
|
||||
users, status, err := emby.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Name"].(string) == username {
|
||||
return user, status, err
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
match, status, err := find()
|
||||
if match == nil {
|
||||
emby.CacheExpiry = time.Now()
|
||||
match, status, err = find()
|
||||
}
|
||||
return match, status, err
|
||||
}
|
||||
|
||||
func embyUserByID(emby *MediaBrowser, userID string, public bool) (map[string]interface{}, int, error) {
|
||||
if emby.CacheExpiry.After(time.Now()) {
|
||||
for _, user := range emby.userCache {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if public {
|
||||
users, status, err := emby.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, status, nil
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
url := fmt.Sprintf("%s/users/%s", emby.Server, userID)
|
||||
data, status, err = emby.get(url, emby.loginParams)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
return result, status, nil
|
||||
}
|
||||
|
||||
// Since emby doesn't allow one to specify a password on user creation, we:
|
||||
// Create the account
|
||||
// Immediately disable it
|
||||
// Set password
|
||||
// Reeenable it
|
||||
func embyNewUser(emby *MediaBrowser, username, password string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/Users/New", emby.Server)
|
||||
data := map[string]interface{}{
|
||||
"Name": username,
|
||||
}
|
||||
response, status, err := emby.post(url, data, true)
|
||||
var recv map[string]interface{}
|
||||
json.Unmarshal([]byte(response), &recv)
|
||||
if err != nil || !(status == 200 || status == 204) {
|
||||
return nil, status, err
|
||||
}
|
||||
// Step 2: Set password
|
||||
id := recv["Id"].(string)
|
||||
url = fmt.Sprintf("%s/Users/%s/Password", emby.Server, id)
|
||||
data = map[string]interface{}{
|
||||
"Id": id,
|
||||
"CurrentPw": "",
|
||||
"NewPw": password,
|
||||
}
|
||||
_, status, err = emby.post(url, data, false)
|
||||
// Step 3: If setting password errored, try to delete the account
|
||||
if err != nil || !(status == 200 || status == 204) {
|
||||
_, err = emby.DeleteUser(id)
|
||||
}
|
||||
return recv, status, nil
|
||||
}
|
||||
|
||||
func embySetPolicy(emby *MediaBrowser, userID string, policy map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Policy", emby.Server, userID)
|
||||
_, status, err := emby.post(url, policy, false)
|
||||
if err != nil || status != 200 {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func embySetConfiguration(emby *MediaBrowser, userID string, configuration map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Configuration", emby.Server, userID)
|
||||
_, status, err := emby.post(url, configuration, false)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func embyGetDisplayPreferences(emby *MediaBrowser, userID string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
|
||||
data, status, err := emby.get(url, nil)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return nil, status, err
|
||||
}
|
||||
var displayprefs map[string]interface{}
|
||||
err = json.Unmarshal([]byte(data), &displayprefs)
|
||||
if err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
return displayprefs, status, nil
|
||||
}
|
||||
|
||||
func embySetDisplayPreferences(emby *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
|
||||
_, status, err := emby.post(url, displayprefs, false)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
7
mediabrowser/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/hrfee/jfa-go/mediabrowser
|
||||
|
||||
go 1.15
|
||||
|
||||
replace github.com/hrfee/jfa-go/common => ../common
|
||||
|
||||
require github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
|
||||
160
mediabrowser/jfapi.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package mediabrowser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func jfDeleteUser(jf *MediaBrowser, userID string) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s", jf.Server, userID)
|
||||
req, _ := http.NewRequest("DELETE", url, nil)
|
||||
for name, value := range jf.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := jf.httpClient.Do(req)
|
||||
defer jf.timeoutHandler()
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
|
||||
func jfGetUsers(jf *MediaBrowser, public bool) ([]map[string]interface{}, int, error) {
|
||||
var result []map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
if time.Now().After(jf.CacheExpiry) {
|
||||
if public {
|
||||
url := fmt.Sprintf("%s/users/public", jf.Server)
|
||||
data, status, err = jf.get(url, nil)
|
||||
} else {
|
||||
url := fmt.Sprintf("%s/users", jf.Server)
|
||||
data, status, err = jf.get(url, jf.loginParams)
|
||||
}
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
jf.userCache = result
|
||||
jf.CacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
|
||||
if id, ok := result[0]["Id"]; ok {
|
||||
if id.(string)[8] == '-' {
|
||||
jf.Hyphens = true
|
||||
}
|
||||
}
|
||||
return result, status, nil
|
||||
}
|
||||
return jf.userCache, 200, nil
|
||||
}
|
||||
|
||||
func jfUserByName(jf *MediaBrowser, username string, public bool) (map[string]interface{}, int, error) {
|
||||
var match map[string]interface{}
|
||||
find := func() (map[string]interface{}, int, error) {
|
||||
users, status, err := jf.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Name"].(string) == username {
|
||||
return user, status, err
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
match, status, err := find()
|
||||
if match == nil {
|
||||
jf.CacheExpiry = time.Now()
|
||||
match, status, err = find()
|
||||
}
|
||||
return match, status, err
|
||||
}
|
||||
|
||||
func jfUserByID(jf *MediaBrowser, userID string, public bool) (map[string]interface{}, int, error) {
|
||||
if jf.CacheExpiry.After(time.Now()) {
|
||||
for _, user := range jf.userCache {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, 200, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if public {
|
||||
users, status, err := jf.GetUsers(public)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["Id"].(string) == userID {
|
||||
return user, status, nil
|
||||
}
|
||||
}
|
||||
return nil, status, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
var data string
|
||||
var status int
|
||||
var err error
|
||||
url := fmt.Sprintf("%s/users/%s", jf.Server, userID)
|
||||
data, status, err = jf.get(url, jf.loginParams)
|
||||
if err != nil || status != 200 {
|
||||
return nil, status, err
|
||||
}
|
||||
json.Unmarshal([]byte(data), &result)
|
||||
return result, status, nil
|
||||
}
|
||||
|
||||
func jfNewUser(jf *MediaBrowser, username, password string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/Users/New", jf.Server)
|
||||
stringData := map[string]string{
|
||||
"Name": username,
|
||||
"Password": password,
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
for key, value := range stringData {
|
||||
data[key] = value
|
||||
}
|
||||
response, status, err := jf.post(url, data, true)
|
||||
var recv map[string]interface{}
|
||||
json.Unmarshal([]byte(response), &recv)
|
||||
if err != nil || !(status == 200 || status == 204) {
|
||||
return nil, status, err
|
||||
}
|
||||
return recv, status, nil
|
||||
}
|
||||
|
||||
func jfSetPolicy(jf *MediaBrowser, userID string, policy map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Policy", jf.Server, userID)
|
||||
_, status, err := jf.post(url, policy, false)
|
||||
if err != nil || status != 200 {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func jfSetConfiguration(jf *MediaBrowser, userID string, configuration map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/Users/%s/Configuration", jf.Server, userID)
|
||||
_, status, err := jf.post(url, configuration, false)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func jfGetDisplayPreferences(jf *MediaBrowser, userID string) (map[string]interface{}, int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
|
||||
data, status, err := jf.get(url, nil)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return nil, status, err
|
||||
}
|
||||
var displayprefs map[string]interface{}
|
||||
err = json.Unmarshal([]byte(data), &displayprefs)
|
||||
if err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
return displayprefs, status, nil
|
||||
}
|
||||
|
||||
func jfSetDisplayPreferences(jf *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
|
||||
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
|
||||
_, status, err := jf.post(url, displayprefs, false)
|
||||
if err != nil || !(status == 204 || status == 200) {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
288
mediabrowser/mediabrowser.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package mediabrowser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
type serverType bool
|
||||
|
||||
var JellyfinServer serverType = false
|
||||
var EmbyServer serverType = true
|
||||
|
||||
type serverInfo struct {
|
||||
LocalAddress string `json:"LocalAddress"`
|
||||
Name string `json:"ServerName"`
|
||||
Version string `json:"Version"`
|
||||
OS string `json:"OperatingSystem"`
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
|
||||
// MediaBrowser is an api instance of Jellyfin/Emby.
|
||||
type MediaBrowser struct {
|
||||
Server string
|
||||
client string
|
||||
version string
|
||||
device string
|
||||
deviceID string
|
||||
useragent string
|
||||
auth string
|
||||
header map[string]string
|
||||
ServerInfo serverInfo
|
||||
Username string
|
||||
password string
|
||||
Authenticated bool
|
||||
AccessToken string
|
||||
userID string
|
||||
httpClient *http.Client
|
||||
loginParams map[string]string
|
||||
userCache []map[string]interface{}
|
||||
CacheExpiry time.Time
|
||||
cacheLength int
|
||||
noFail bool
|
||||
Hyphens bool
|
||||
serverType serverType
|
||||
timeoutHandler common.TimeoutHandler
|
||||
}
|
||||
|
||||
// NewServer returns a new Jellyfin object.
|
||||
func NewServer(st serverType, server, client, version, device, deviceID string, timeoutHandler common.TimeoutHandler, cacheTimeout int) (*MediaBrowser, error) {
|
||||
mb := &MediaBrowser{}
|
||||
mb.serverType = st
|
||||
mb.Server = server
|
||||
mb.client = client
|
||||
mb.version = version
|
||||
mb.device = device
|
||||
mb.deviceID = deviceID
|
||||
mb.useragent = fmt.Sprintf("%s/%s", client, version)
|
||||
mb.timeoutHandler = timeoutHandler
|
||||
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\"", client, device, deviceID, version)
|
||||
mb.header = map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/json; charset=UTF-8",
|
||||
"X-Application": mb.useragent,
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": mb.useragent,
|
||||
"X-Emby-Authorization": mb.auth,
|
||||
}
|
||||
mb.httpClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
infoURL := fmt.Sprintf("%s/System/Info/Public", server)
|
||||
req, _ := http.NewRequest("GET", infoURL, nil)
|
||||
resp, err := mb.httpClient.Do(req)
|
||||
defer mb.timeoutHandler()
|
||||
if err == nil {
|
||||
data, _ := ioutil.ReadAll(resp.Body)
|
||||
json.Unmarshal(data, &mb.ServerInfo)
|
||||
}
|
||||
mb.cacheLength = cacheTimeout
|
||||
mb.CacheExpiry = time.Now()
|
||||
return mb, nil
|
||||
}
|
||||
|
||||
func (mb *MediaBrowser) get(url string, params map[string]string) (string, int, error) {
|
||||
var req *http.Request
|
||||
if params != nil {
|
||||
jsonParams, _ := json.Marshal(params)
|
||||
req, _ = http.NewRequest("GET", url, bytes.NewBuffer(jsonParams))
|
||||
} else {
|
||||
req, _ = http.NewRequest("GET", url, nil)
|
||||
}
|
||||
for name, value := range mb.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := mb.httpClient.Do(req)
|
||||
defer mb.timeoutHandler()
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
if resp.StatusCode == 401 && mb.Authenticated {
|
||||
mb.Authenticated = false
|
||||
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
|
||||
if authErr == nil {
|
||||
v1, v2, v3 := mb.get(url, params)
|
||||
return v1, v2, v3
|
||||
}
|
||||
}
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data io.Reader
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
data, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
data = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
io.Copy(buf, data)
|
||||
//var respData map[string]interface{}
|
||||
//json.NewDecoder(data).Decode(&respData)
|
||||
return buf.String(), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (mb *MediaBrowser) post(url string, data map[string]interface{}, response bool) (string, int, error) {
|
||||
params, _ := json.Marshal(data)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(params))
|
||||
for name, value := range mb.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := mb.httpClient.Do(req)
|
||||
defer mb.timeoutHandler()
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
if resp.StatusCode == 401 && mb.Authenticated {
|
||||
mb.Authenticated = false
|
||||
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
|
||||
if authErr == nil {
|
||||
v1, v2, v3 := mb.post(url, data, response)
|
||||
return v1, v2, v3
|
||||
}
|
||||
}
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
if response {
|
||||
defer resp.Body.Close()
|
||||
var outData io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
outData, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
outData = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
io.Copy(buf, outData)
|
||||
return buf.String(), resp.StatusCode, nil
|
||||
}
|
||||
return "", resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// Authenticate attempts to authenticate using a username & password
|
||||
func (mb *MediaBrowser) Authenticate(username, password string) (map[string]interface{}, int, error) {
|
||||
mb.Username = username
|
||||
mb.password = password
|
||||
mb.loginParams = map[string]string{
|
||||
"Username": username,
|
||||
"Pw": password,
|
||||
"Password": password,
|
||||
}
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
err := encoder.Encode(mb.loginParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// loginParams, _ := json.Marshal(jf.loginParams)
|
||||
url := fmt.Sprintf("%s/Users/authenticatebyname", mb.Server)
|
||||
req, err := http.NewRequest("POST", url, buffer)
|
||||
defer mb.timeoutHandler()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for name, value := range mb.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := mb.httpClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
data, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
data = resp.Body
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(data).Decode(&respData)
|
||||
mb.AccessToken = respData["AccessToken"].(string)
|
||||
user := respData["User"].(map[string]interface{})
|
||||
mb.userID = respData["User"].(map[string]interface{})["Id"].(string)
|
||||
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\", Token=\"%s\"", mb.client, mb.device, mb.deviceID, mb.version, mb.AccessToken)
|
||||
mb.header["X-Emby-Authorization"] = mb.auth
|
||||
mb.Authenticated = true
|
||||
return user, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) DeleteUser(userID string) (int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfDeleteUser(mb, userID)
|
||||
}
|
||||
return embyDeleteUser(mb, userID)
|
||||
}
|
||||
|
||||
// GetUsers returns all (visible) users on the Emby instance.
|
||||
func (mb *MediaBrowser) GetUsers(public bool) ([]map[string]interface{}, int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfGetUsers(mb, public)
|
||||
}
|
||||
return embyGetUsers(mb, public)
|
||||
}
|
||||
|
||||
// UserByName returns the user corresponding to the provided username.
|
||||
func (mb *MediaBrowser) UserByName(username string, public bool) (map[string]interface{}, int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfUserByName(mb, username, public)
|
||||
}
|
||||
return embyUserByName(mb, username, public)
|
||||
}
|
||||
|
||||
// UserByID returns the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) UserByID(userID string, public bool) (map[string]interface{}, int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfUserByID(mb, userID, public)
|
||||
}
|
||||
return embyUserByID(mb, userID, public)
|
||||
}
|
||||
|
||||
// NewUser creates a new user with the provided username and password.
|
||||
func (mb *MediaBrowser) NewUser(username, password string) (map[string]interface{}, int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfNewUser(mb, username, password)
|
||||
}
|
||||
return embyNewUser(mb, username, password)
|
||||
}
|
||||
|
||||
// SetPolicy sets the access policy for the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) SetPolicy(userID string, policy map[string]interface{}) (int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfSetPolicy(mb, userID, policy)
|
||||
}
|
||||
return embySetPolicy(mb, userID, policy)
|
||||
}
|
||||
|
||||
// SetConfiguration sets the configuration (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) SetConfiguration(userID string, configuration map[string]interface{}) (int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfSetConfiguration(mb, userID, configuration)
|
||||
}
|
||||
return embySetConfiguration(mb, userID, configuration)
|
||||
}
|
||||
|
||||
// GetDisplayPreferences gets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) GetDisplayPreferences(userID string) (map[string]interface{}, int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfGetDisplayPreferences(mb, userID)
|
||||
}
|
||||
return embyGetDisplayPreferences(mb, userID)
|
||||
}
|
||||
|
||||
// SetDisplayPreferences sets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
|
||||
func (mb *MediaBrowser) SetDisplayPreferences(userID string, displayprefs map[string]interface{}) (int, error) {
|
||||
if mb.serverType == JellyfinServer {
|
||||
return jfSetDisplayPreferences(mb, userID, displayprefs)
|
||||
}
|
||||
return embySetDisplayPreferences(mb, userID, displayprefs)
|
||||
}
|
||||
31
models.go
@@ -127,3 +127,34 @@ type errorListDTO map[string]map[string]string
|
||||
|
||||
type configDTO map[string]interface{}
|
||||
|
||||
// Below are for sending config
|
||||
|
||||
type meta struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type setting struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
RequiresRestart bool `json:"requires_restart"`
|
||||
Type string `json:"type"` // Type (string, number, bool, etc.)
|
||||
Value interface{} `json:"value"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
|
||||
DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
|
||||
}
|
||||
|
||||
type section struct {
|
||||
Meta meta `json:"meta"`
|
||||
Order []string `json:"order"`
|
||||
Settings map[string]setting `json:"settings"`
|
||||
}
|
||||
|
||||
type settings struct {
|
||||
Order []string `json:"order"`
|
||||
Sections map[string]section `json:"sections"`
|
||||
}
|
||||
|
||||
type langDTO map[string]string
|
||||
|
||||
2808
package-lock.json
generated
21
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "jellyfin-accounts",
|
||||
"name": "jfa-go",
|
||||
"version": "1.0.0",
|
||||
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
|
||||
"main": "index.js",
|
||||
@@ -8,24 +8,21 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
|
||||
"url": "git+https://github.com/hrfee/jfa-go.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
|
||||
"url": "https://github.com/hrfee/jfa-go/issues"
|
||||
},
|
||||
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
|
||||
"homepage": "https://github.com/hrfee/jfa-go#readme",
|
||||
"dependencies": {
|
||||
"@types/jquery": "^3.5.3",
|
||||
"autoprefixer": "^9.8.5",
|
||||
"bootstrap": "^5.0.0-alpha3",
|
||||
"bootstrap4": "npm:bootstrap@^4.5.0",
|
||||
"clean-css-cli": "^4.3.0",
|
||||
"a17t": "^0.4.0",
|
||||
"esbuild": "^0.7.8",
|
||||
"lodash": "^4.17.19",
|
||||
"mjml": "^4.6.3",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"mjml": "^4.8.0",
|
||||
"remixicon": "^2.5.0",
|
||||
"typescript": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ func (app *appContext) StartPWR() {
|
||||
<-done
|
||||
}
|
||||
|
||||
type Pwr struct {
|
||||
// PasswordReset represents a passwordreset-xyz.json file generated by Jellyfin.
|
||||
type PasswordReset struct {
|
||||
Pin string `json:"Pin"`
|
||||
Username string `json:"UserName"`
|
||||
Expiry time.Time `json:"ExpirationDate"`
|
||||
@@ -48,7 +49,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
||||
return
|
||||
}
|
||||
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
|
||||
var pwr Pwr
|
||||
var pwr PasswordReset
|
||||
data, err := ioutil.ReadFile(event.Name)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
11
pwval.go
@@ -4,16 +4,15 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Validator allows for validation of passwords.
|
||||
type Validator struct {
|
||||
minLength, upper, lower, number, special int
|
||||
criteria ValidatorConf
|
||||
specialChars []rune
|
||||
}
|
||||
|
||||
type ValidatorConf map[string]int
|
||||
|
||||
func (vd *Validator) init(criteria ValidatorConf) {
|
||||
vd.specialChars = []rune{'[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')', '<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']'}
|
||||
vd.criteria = criteria
|
||||
}
|
||||
|
||||
@@ -39,12 +38,8 @@ func (vd *Validator) validate(password string) map[string]bool {
|
||||
count["lowercase"] += 1
|
||||
} else if unicode.IsNumber(c) {
|
||||
count["number"] += 1
|
||||
} else {
|
||||
for _, s := range vd.specialChars {
|
||||
if c == s {
|
||||
count["special"] += 1
|
||||
}
|
||||
}
|
||||
} else if unicode.ToUpper(c) == unicode.ToLower(c) {
|
||||
count["special"] += 1
|
||||
}
|
||||
}
|
||||
results := map[string]bool{}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
## SCSS
|
||||
|
||||
* `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it.
|
||||
|
||||
**Note**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`.
|
||||
|
||||
* Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages.
|
||||
* If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS.
|
||||
* If you are creating custom css, run `poetry run task get-npm-deps` to only install the necessary dependencies. Follow along with the commands `scss/compile.py` runs to build your css and then set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.
|
||||
|
||||
126
scss/base.scss
@@ -1,126 +0,0 @@
|
||||
.pageContainer {
|
||||
margin: 5% 30% 5% 30%;
|
||||
}
|
||||
@media (max-width: 1900px) {
|
||||
.pageContainer {
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer {
|
||||
margin: 2%;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
/*margin: 20%;*/
|
||||
margin-bottom: 5%;
|
||||
}
|
||||
.tabGroup {
|
||||
/*margin: 20%;*/
|
||||
margin-bottom: 5%;
|
||||
margin-top: 5%;
|
||||
}
|
||||
.linkForm {
|
||||
/*margin: 20%;*/
|
||||
margin-top: 5%;
|
||||
margin-bottom: 5%;
|
||||
}
|
||||
.contactBox {
|
||||
/*margin: 20%;*/
|
||||
margin-top: 5%;
|
||||
color: grey;
|
||||
}
|
||||
.circle {
|
||||
/*margin-left: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
z-index: 5000;*/
|
||||
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
|
||||
}
|
||||
.smooth-transition {
|
||||
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
|
||||
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
|
||||
}
|
||||
.rotated {
|
||||
transform: rotate(180deg);
|
||||
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
|
||||
}
|
||||
|
||||
.not-rotated {
|
||||
transform: rotate(0deg);
|
||||
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
|
||||
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
|
||||
}
|
||||
|
||||
.invite-link {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.settingIcon {
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin white-text {
|
||||
&, &:visited, &:hover, &:active {
|
||||
font-style: inherit;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
font-variant: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
%white-text {
|
||||
@include white-text;
|
||||
}
|
||||
|
||||
%link-unstyled {
|
||||
@include white-text;
|
||||
background-color: transparent;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
@extend %link-unstyled;
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
@extend %link-unstyled;
|
||||
}
|
||||
|
||||
.nl {
|
||||
@extend %link-unstyled;
|
||||
}
|
||||
|
||||
.nl:hover {
|
||||
@extend %white-text;
|
||||
}
|
||||
|
||||
.unfocused {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-monospace {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
$jf-blue: rgb(0, 164, 220);
|
||||
$jf-blue-hover: rgba(0, 164, 220, 0.2);
|
||||
$jf-blue-focus: rgb(12, 176, 232);
|
||||
$jf-blue-light: #4bb3dd;
|
||||
|
||||
$jf-red: rgb(204, 0, 0);
|
||||
$jf-red-light: #e12026;
|
||||
$jf-yellower: #ffc107;
|
||||
$jf-yellow: #e1b222;
|
||||
$jf-orange: #ff870f;
|
||||
$jf-green: #6fbd45;
|
||||
$jf-green-dark: #008040;
|
||||
|
||||
|
||||
$jf-black: #101010; // 16 16 16
|
||||
$jf-gray-90: #202020; // 32 32 32
|
||||
$jf-gray-80: #242424; // jf-card 36 36 36
|
||||
$jf-gray-70: #292929; // jf-input 41 41 41
|
||||
$jf-gray-60: #303030; // jf-button 48 48 48
|
||||
$jf-gray-50: #383838; // jf-button-focus 56 56 56
|
||||
$jf-text-bold: rgba(255, 255, 255, 0.87);
|
||||
$jf-text-primary: rgba(255, 255, 255, 0.8);
|
||||
$jf-text-secondary: rgb(153, 153, 153);
|
||||
|
||||
$primary: $jf-blue;
|
||||
$secondary: $jf-gray-50;
|
||||
$success: $jf-green-dark;
|
||||
$danger: $jf-red-light;
|
||||
$light: $jf-text-primary;
|
||||
$dark: $jf-gray-90;
|
||||
$info: $jf-yellow;
|
||||
$warning: $jf-yellower;
|
||||
|
||||
|
||||
|
||||
$enable-gradients: false;
|
||||
$enable-shadows: false;
|
||||
|
||||
$enable-rounded: false;
|
||||
$body-bg: $jf-black;
|
||||
$body-color: $jf-text-primary;
|
||||
$border-color: $jf-gray-60;
|
||||
$component-active-color: $jf-text-bold;
|
||||
$component-active-bg: $jf-blue-focus;
|
||||
$text-muted: $jf-text-secondary;
|
||||
$link-color: $jf-blue-focus;
|
||||
$btn-link-disabled-color: $jf-text-secondary;
|
||||
$input-bg: $jf-gray-90;
|
||||
$input-color: $jf-text-primary;
|
||||
$input-focus-bg: $jf-gray-60;
|
||||
$input-focus-border-color: $jf-blue-focus;
|
||||
$input-disabled-bg: $jf-gray-70;
|
||||
input:disabled {
|
||||
color: $text-muted;
|
||||
}
|
||||
$input-border-color: $jf-gray-60;
|
||||
$input-placeholder-color: $text-muted;
|
||||
|
||||
$form-check-input-bg: $jf-gray-60;
|
||||
$form-check-input-border: $jf-gray-50;
|
||||
$form-check-input-checked-color: $jf-blue-focus;
|
||||
$form-check-input-checked-bg-color: $jf-blue-hover;
|
||||
|
||||
$input-group-addon-bg: $input-bg;
|
||||
|
||||
$form-select-disabled-color: $jf-text-secondary;
|
||||
$form-select-disabled-bg: $input-disabled-bg;
|
||||
$form-select-indicator-color: $jf-gray-50;
|
||||
|
||||
$card-bg: $jf-gray-80;
|
||||
$card-border-color: null;
|
||||
|
||||
$tooltip-color: $jf-text-bold;
|
||||
$tooltip-bg: $jf-gray-50;
|
||||
|
||||
$modal-content-bg: $jf-gray-80;
|
||||
$modal-content-border-color: $jf-gray-50;
|
||||
$modal-header-border-color: null;
|
||||
$modal-footer-border-color: null;
|
||||
|
||||
$list-group-bg: $card-bg;
|
||||
$list-group-border-color: $jf-gray-50;
|
||||
$list-group-hover-bg: $jf-blue-hover;
|
||||
$list-group-active-bg: $jf-blue-focus;
|
||||
$list-group-action-color: $jf-text-primary;
|
||||
$list-group-action-hover-color: $jf-text-bold;
|
||||
$list-group-action-active-color: $jf-text-bold;
|
||||
$list-group-action-active-bg: $jf-blue-focus;
|
||||
|
||||
// idk why but i had to put these above and below the import
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
@import "../../node_modules/bootstrap4/scss/bootstrap";
|
||||
|
||||
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: $jf-text-secondary;
|
||||
}
|
||||
|
||||
.close:hover, .close:active {
|
||||
color: $jf-text-primary;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.text-bright {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: $jf-blue-hover;
|
||||
}
|
||||
|
||||
@import "../base.scss";
|
||||
@@ -1,19 +0,0 @@
|
||||
@import "../../node_modules/bootstrap4/scss/bootstrap";
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: $list-group-hover-bg;
|
||||
}
|
||||
|
||||
@import "../base.scss";
|
||||
@@ -1,149 +0,0 @@
|
||||
$jf-blue: rgb(0, 164, 220);
|
||||
$jf-blue-hover: rgba(0, 164, 220, 0.2);
|
||||
$jf-blue-focus: rgb(12, 176, 232);
|
||||
$jf-blue-light: #4bb3dd;
|
||||
|
||||
$jf-red: rgb(204, 0, 0);
|
||||
$jf-red-light: #e12026;
|
||||
$jf-yellower: #ffc107;
|
||||
$jf-yellow: #e1b222;
|
||||
$jf-orange: #ff870f;
|
||||
$jf-green: #6fbd45;
|
||||
$jf-green-dark: #008040;
|
||||
|
||||
|
||||
$jf-black: #101010; // 16 16 16
|
||||
$jf-gray-90: #202020; // 32 32 32
|
||||
$jf-gray-80: #242424; // jf-card 36 36 36
|
||||
$jf-gray-70: #292929; // jf-input 41 41 41
|
||||
$jf-gray-60: #303030; // jf-button 48 48 48
|
||||
$jf-gray-50: #383838; // jf-button-focus 56 56 56
|
||||
$jf-text-bold: rgba(255, 255, 255, 0.87);
|
||||
$jf-text-primary: rgba(255, 255, 255, 0.8);
|
||||
$jf-text-secondary: rgb(153, 153, 153);
|
||||
|
||||
$primary: $jf-blue;
|
||||
$secondary: $jf-gray-50;
|
||||
$success: $jf-green-dark;
|
||||
$danger: $jf-red-light;
|
||||
$light: $jf-text-primary;
|
||||
$dark: $jf-gray-90;
|
||||
$info: $jf-yellow;
|
||||
$warning: $jf-yellower;
|
||||
|
||||
|
||||
|
||||
$enable-gradients: false;
|
||||
$enable-shadows: false;
|
||||
|
||||
$enable-rounded: false;
|
||||
$body-bg: $jf-black;
|
||||
$body-color: $jf-text-primary;
|
||||
$border-color: $jf-gray-60;
|
||||
$component-active-color: $jf-text-bold;
|
||||
$component-active-bg: $jf-blue-focus;
|
||||
$text-muted: $jf-text-secondary;
|
||||
$link-color: $jf-blue-focus;
|
||||
$btn-link-disabled-color: $jf-text-secondary;
|
||||
$input-bg: $jf-gray-90;
|
||||
$input-color: $jf-text-primary;
|
||||
$input-focus-bg: $jf-gray-60;
|
||||
$input-focus-border-color: $jf-blue-focus;
|
||||
$input-disabled-bg: $jf-gray-70;
|
||||
input:disabled {
|
||||
color: $text-muted;
|
||||
}
|
||||
$input-border-color: $jf-gray-60;
|
||||
$input-placeholder-color: $text-muted;
|
||||
|
||||
$form-check-input-bg: $jf-gray-60;
|
||||
$form-check-input-border: $jf-gray-50;
|
||||
$form-check-input-checked-color: $jf-blue-focus;
|
||||
$form-check-input-checked-bg-color: $jf-blue-hover;
|
||||
|
||||
$input-group-addon-bg: $input-bg;
|
||||
|
||||
$form-select-disabled-color: $jf-text-secondary;
|
||||
$form-select-disabled-bg: $input-disabled-bg;
|
||||
$form-select-indicator-color: $jf-gray-50;
|
||||
|
||||
$card-bg: $jf-gray-80;
|
||||
$card-border-color: null;
|
||||
|
||||
$tooltip-color: $jf-text-bold;
|
||||
$tooltip-bg: $jf-gray-50;
|
||||
|
||||
$modal-content-bg: $jf-gray-80;
|
||||
$modal-content-border-color: $jf-gray-50;
|
||||
$modal-header-border-color: null;
|
||||
$modal-footer-border-color: null;
|
||||
|
||||
$list-group-bg: $card-bg;
|
||||
$list-group-border-color: $jf-gray-50;
|
||||
$list-group-hover-bg: $jf-blue-hover;
|
||||
$list-group-active-bg: $jf-blue-focus;
|
||||
$list-group-action-color: $jf-text-primary;
|
||||
$list-group-action-hover-color: $jf-text-bold;
|
||||
$list-group-action-active-color: $jf-text-bold;
|
||||
$list-group-action-active-bg: $jf-blue-focus;
|
||||
|
||||
// idk why but i had to put these above and below the import
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap";
|
||||
|
||||
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: $jf-text-secondary;
|
||||
}
|
||||
|
||||
.close:hover, .close:active {
|
||||
color: $jf-text-primary;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.text-bright {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: $jf-blue-hover;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(80%);
|
||||
}
|
||||
|
||||
@import "../base.scss";
|
||||
@@ -1,19 +0,0 @@
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap";
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: $list-group-hover-bg;
|
||||
}
|
||||
|
||||
@import "../base.scss";
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import sass
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def runcmd(cmd):
|
||||
if os.name == "nt":
|
||||
return subprocess.check_output(cmd, shell=True)
|
||||
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
|
||||
return proc.communicate()
|
||||
|
||||
|
||||
local_path = Path(__file__).resolve().parent
|
||||
|
||||
for bsv in [d for d in local_path.iterdir() if "bs" in d.name]:
|
||||
scss = [(bsv / f"{bsv.name}-jf.scss"), (bsv / f"{bsv.name}.scss")]
|
||||
css = [(bsv / f"{bsv.name}-jf.css"), (bsv / f"{bsv.name}.css")]
|
||||
min_css = [
|
||||
(bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css"),
|
||||
(bsv.parents[1] / "data" / "static" / f"{bsv.name}.css"),
|
||||
]
|
||||
for i in range(2):
|
||||
with open(css[i], "w") as f:
|
||||
f.write(
|
||||
sass.compile(
|
||||
filename=str(scss[i].resolve()),
|
||||
output_style="expanded",
|
||||
precision=6,
|
||||
omit_source_map_url=True,
|
||||
)
|
||||
)
|
||||
if css[i].exists():
|
||||
print(f"{scss[i].name}: Compiled.")
|
||||
# postcss only excepts forwards slashes? weird.
|
||||
cssPath = str(css[i].resolve())
|
||||
if os.name == "nt":
|
||||
cssPath = cssPath.replace("\\", "/")
|
||||
runcmd(f"npx postcss {cssPath} --replace --use autoprefixer")
|
||||
print(f"{scss[i].name}: Prefixed.")
|
||||
runcmd(
|
||||
f"npx cleancss --level 1 --format breakWith=lf --output {str(min_css[i].resolve())} {str(css[i].resolve())}"
|
||||
)
|
||||
if min_css[i].exists():
|
||||
print(
|
||||
f"{scss[i].name}: Minified and copied to {str(min_css[i].resolve())}."
|
||||
)
|
||||
4
setup.go
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/jfapi"
|
||||
"github.com/hrfee/jfa-go/mediabrowser"
|
||||
)
|
||||
|
||||
type testReq struct {
|
||||
@@ -15,7 +15,7 @@ type testReq struct {
|
||||
func (app *appContext) TestJF(gc *gin.Context) {
|
||||
var req testReq
|
||||
gc.BindJSON(&req)
|
||||
tempjf, _ := jfapi.NewJellyfin(req.Host, "jfa-go-setup", app.version, "auth", "auth", common.NewTimeoutHandler("authJF", req.Host, true), 30)
|
||||
tempjf, _ := mediabrowser.NewServer(mediabrowser.JellyfinServer, req.Host, "jfa-go-setup", app.version, "auth", "auth", common.NewTimeoutHandler("authJF", req.Host, true), 30)
|
||||
_, status, err := tempjf.Authenticate(req.Username, req.Password)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.info.Printf("Auth failed with code %d (%s)", status, err)
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
283
static/banner.svg
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |