Compare commits

...

140 Commits

Author SHA1 Message Date
Harvey Tindall
e755bc6b61 fix language names 2021-01-29 13:49:58 +00:00
Richard de Boer
7ec9f2435c translation from Weblate (Dutch)
Currently translated at 100.0% (101 of 101 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/nl/
2021-01-29 14:42:20 +01:00
Cornichon420
443d6fee52 Translated using Weblate (French)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/fr/
2021-01-29 14:42:20 +01:00
Cornichon420
b023616033 translation from Weblate (French)
Currently translated at 100.0% (25 of 25 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/fr/
2021-01-29 14:42:20 +01:00
Cornichon420
f182b88c58 Translated using Weblate (French)
Currently translated at 100.0% (95 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/fr/
2021-01-29 14:42:20 +01:00
Richard de Boer
93daadae4b translation from Weblate (Dutch)
Currently translated at 100.0% (25 of 25 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/nl/
2021-01-29 14:42:20 +01:00
virusperfect
fd1ec5d3fb Translated using Weblate (German)
Currently translated at 100.0% (95 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/de/
2021-01-29 14:42:20 +01:00
virusperfect
b9a8a27807 translation from Weblate (German)
Currently translated at 100.0% (101 of 101 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/de/
2021-01-29 14:42:20 +01:00
Cornichon420
27a36898a3 translation from Weblate (French)
Currently translated at 100.0% (101 of 101 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/fr/
2021-01-29 14:42:20 +01:00
Richard de Boer
d8948c037b Translated using Weblate (Dutch)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/nl/
2021-01-29 14:42:20 +01:00
virusperfect
83f2749eab Translated using Weblate (German)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/de/
2021-01-29 14:42:20 +01:00
virusperfect
290435b5ba translation from Weblate (German)
Currently translated at 100.0% (25 of 25 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/de/
2021-01-29 14:42:20 +01:00
Cornichon420
d09125c63c Translated using Weblate (French)
Currently translated at 100.0% (8 of 8 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/common-strings/fr/
2021-01-29 14:42:20 +01:00
Cornichon420
f0aa64373b Added translation using Weblate (French) 2021-01-29 14:42:20 +01:00
DesertCookie
2272883d5a translation from Weblate (German)
Currently translated at 100.0% (25 of 25 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/de/
2021-01-29 14:42:20 +01:00
Richard de Boer
2d0f6d89aa Translated using Weblate (Dutch)
Currently translated at 100.0% (95 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/nl/
2021-01-29 14:42:20 +01:00
hrfee
4cd1571c05 Translated using Weblate (Dutch)
Currently translated at 100.0% (95 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/nl/
2021-01-29 14:42:20 +01:00
ClankJake
55be62bc3e Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.9% (94 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/pt_BR/
2021-01-29 14:42:20 +01:00
ClankJake
da82f1c146 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (25 of 25 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/pt_BR/
2021-01-29 14:42:20 +01:00
DesertCookie
67f53d4112 Translated using Weblate (German)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/de/
2021-01-29 14:42:20 +01:00
ClankJake
88356281fb translation from Weblate (Portuguese (Brazil))
Currently translated at 97.0% (98 of 101 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/pt_BR/
2021-01-29 14:42:20 +01:00
ClankJake
05198ea764 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/pt_BR/
2021-01-29 14:42:20 +01:00
ClankJake
fe33d97d87 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/common-strings/pt_BR/
2021-01-29 14:42:20 +01:00
DesertCookie
2c60dee48a translation from Weblate (German)
Currently translated at 100.0% (101 of 101 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/de/
2021-01-29 14:42:20 +01:00
Richard de Boer
8af9f9944a Translated using Weblate (Dutch)
Currently translated at 72.6% (69 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/nl/
2021-01-29 14:42:20 +01:00
DesertCookie
4e968d2338 Translated using Weblate (German)
Currently translated at 97.8% (93 of 95 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/setup/de/
2021-01-29 14:42:20 +01:00
Richard de Boer
7ba88977a4 Added translation using Weblate (Dutch) 2021-01-29 14:42:20 +01:00
hrfee
0c5f6a68f9 Added translation using Weblate (Portuguese (Brazil)) 2021-01-29 14:42:20 +01:00
ClankJake
bbd539278c add translation from Weblate (Portuguese (Brazil)) 2021-01-29 14:42:20 +01:00
DesertCookie
be9d9ac6ff Added translation using Weblate (German) 2021-01-29 14:42:20 +01:00
ClankJake
e8b37a5df8 Added translation using Weblate (Portuguese (Brazil)) 2021-01-29 14:42:20 +01:00
ClankJake
d10d347e2b Added translation using Weblate (Portuguese (Brazil)) 2021-01-29 14:42:20 +01:00
ClankJake
68689d74a0 add translation from Weblate (Portuguese (Brazil)) 2021-01-29 14:42:20 +01:00
Harvey Tindall
4fc9bdb35b element already existed, oops 2021-01-29 01:32:44 +00:00
Harvey Tindall
c0a05be44e add strftime notice on setup
string was already in translations, just forgot to include it.
2021-01-29 01:29:54 +00:00
Harvey Tindall
482c9d5719 update license date 2021-01-29 01:20:05 +00:00
virusperfect
f063298bf7 Translated using Weblate (German)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/de/
2021-01-27 23:11:35 +01:00
virusperfect
bb1e454850 translation from Weblate (German)
Currently translated at 100.0% (108 of 108 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/de/
2021-01-27 23:11:35 +01:00
Cornichon420
11770d90f1 translation from Weblate (French)
Currently translated at 100.0% (108 of 108 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/fr/
2021-01-27 23:11:35 +01:00
hrfee
8a415140b6 Translated using Weblate (English)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/en/
2021-01-27 23:11:35 +01:00
Harvey Tindall
3dd83bffbf Merge branch 'new-setup'
Merge new setup wizard

This is much more up-to-date than the previous setup page, with a new
design and previously missing/new settings. Currently only available in
english (hopefully that changes soon).

also fixes conflict in _post.
2021-01-27 22:06:44 +00:00
Harvey Tindall
79987ffa22 add refresh button that uses url base if given 2021-01-27 21:51:01 +00:00
Harvey Tindall
764639bbba add header template 2021-01-27 21:38:35 +00:00
Harvey Tindall
eb67116ee6 replace og setup 2021-01-27 21:37:43 +00:00
Harvey Tindall
7baea9101e Add general settings, ombi
host, port, theme, tls are included in general. Page theme changes with
setting. Fixed checkbox support messages. Split some cards into columns.
2021-01-27 21:35:41 +00:00
Harvey Tindall
167fae9892 add jellyfin connection test, submission
fully functional now, but still need to add some sections (ombi mainly).
2021-01-27 12:55:39 +00:00
Harvey Tindall
c7f5aa2e2b split into pages, hide email pages when disabled, add history navigation 2021-01-27 00:51:19 +00:00
Harvey Tindall
8c871bc5fa Add ts to link setting dependance
Also make store each setting as classes in a settings object, to make it
easier to serialize on submitting. Also, added
"substitute_jellyfin_strings", "no_username" and welcome_email.
2021-01-26 22:57:29 +00:00
Harvey Tindall
1f6bbc75ff remove junk files 2021-01-26 00:39:35 +00:00
Harvey Tindall
23ae18d732 compile setup.ts in Makefile/Goreleaser
surprised there hasn't been issues for this, the setup page would've
been broken for a while.
2021-01-26 00:37:32 +00:00
Harvey Tindall
bf1e6230dc split some strings into common file; use lang file to setup page 2021-01-25 21:26:54 +00:00
Harvey Tindall
8af1c13d7e Display error messages on form
two new strings need translating in lang/form.
2021-01-25 18:01:18 +00:00
Harvey Tindall
061945218a fix extra whitespace after pin code
for #39
2021-01-25 17:18:35 +00:00
Harvey Tindall
687edf2b0b Initial setup page content
Rewritten with a17t. Content right now is just a copy of the original
setup.html, but settings for new features will be added later.Currently
all cards are shown, only the current one will show in future.
2021-01-24 23:05:04 +00:00
Cornichon420
1bf1e994fe Translated using Weblate (French)
Currently translated at 100.0% (32 of 32 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/fr/
2021-01-24 18:27:28 +01:00
Cornichon420
7f91a27e4f translation from Weblate (French)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/fr/
2021-01-24 18:27:28 +01:00
Cornichon420
f66510c74b translation from Weblate (French)
Currently translated at 100.0% (108 of 108 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/fr/
2021-01-24 18:27:28 +01:00
Weblate
e5de8b20ff merge branch 'origin/main' into Weblate. 2021-01-24 16:57:46 +01:00
Harvey Tindall
dd96d71280 Add optional label for invites
Requested in #38.
2021-01-24 15:55:45 +00:00
Richard de Boer
a687b2c438 translation from Weblate (Dutch)
Currently translated at 100.0% (105 of 105 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/nl/
2021-01-24 16:22:39 +01:00
Harvey Tindall
ea262ca60b add optional welcome email for new users
When enabled, an email with the server URL and username will be sent to
created users. Requested in #38.
2021-01-24 15:19:58 +00:00
Harvey Tindall
406fef6595 bundle typescript 2021-01-23 19:08:27 +00:00
Harvey Tindall
f7d8feac5d bundle css with esbuild 2021-01-23 18:53:14 +00:00
Harvey Tindall
cd2ea2e579 reload email after lang is loaded
fixes #37.
2021-01-21 18:57:32 +00:00
Harvey Tindall
b66654787c make whole invite clickable to expand 2021-01-21 17:59:16 +00:00
Harvey Tindall
882a3467db fix language settings loading 2021-01-21 14:16:03 +00:00
Harvey Tindall
27ac0bf43e merge german translation 2021-01-21 11:49:20 +00:00
Harvey Tindall
4485622354 remove some comments 2021-01-21 10:56:58 +00:00
virusperfect
91b2b44768 Translated using Weblate (German)
Currently translated at 100.0% (27 of 27 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/emails/de/
2021-01-21 11:52:46 +01:00
virusperfect
8c276fa0a7 translation from Weblate (German)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/form/de/
2021-01-21 11:52:46 +01:00
virusperfect
78b0e22091 translation from Weblate (German)
Currently translated at 100.0% (105 of 105 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/de/
2021-01-21 11:52:46 +01:00
hrfee
96abbdf9a8 Added translation using Weblate (German) 2021-01-20 09:03:49 +01:00
hrfee
7c61392ff4 add translation from Weblate (German) 2021-01-20 09:03:18 +01:00
hrfee
901abfb3e5 add translation from Weblate (German) 2021-01-20 09:02:35 +01:00
hrfee
2eea836d9f translation from Weblate (French)
Currently translated at 100.0% (105 of 105 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/fr/
2021-01-19 01:31:18 +01:00
hrfee
9a08a6603a Deleted translation using Weblate (English (United Kingdom)) 2021-01-19 01:31:18 +01:00
hrfee
887126f5dd Translated using Weblate (English (United Kingdom))
Currently translated at 1.9% (2 of 105 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.pw/projects/jfa-go/admin/en_GB/
2021-01-19 01:31:18 +01:00
hrfee
fad6a04a5f Added translation using Weblate (English (United Kingdom)) 2021-01-19 01:31:18 +01:00
Harvey Tindall
e834445b0b Restructure language loading to support incomplete translations
On startup, files are scanned and any missing values are replaced with
the english version.
2021-01-19 00:29:29 +00:00
Harvey Tindall
1aadd12006 move validationStrings out of strings in lang/form 2021-01-18 22:06:50 +00:00
Harvey Tindall
26a1f30d32 Fix initial language setting value
For some reason it was set as en-US, not en-us.
2021-01-18 00:14:12 +00:00
Harvey Tindall
e0a17c6a74 mention new weblate instance 2021-01-17 20:02:02 +00:00
Harvey Tindall
7ce1b5001c merge nl-nl with invite url fix 2021-01-17 12:38:50 +00:00
Harvey Tindall
72a7759ca5 Fix invite link when non-default language selected 2021-01-17 12:36:16 +00:00
Richard de Boer
bf46c9f906 add dutch translation of emails 2021-01-17 12:32:50 +00:00
Richard de Boer
14bb85f301 add dutch translation of admin pages 2021-01-17 12:32:50 +00:00
Harvey Tindall
e68dccbc17 keep language choice in url when changing tabs 2021-01-15 21:16:11 +00:00
Harvey Tindall
ffc62574ec Fix server push and use Link header to load CSS
Nginx with http2_push_preload on will convert the Link header to server
pushes, so we use it to load css.
2021-01-15 18:57:12 +00:00
Harvey Tindall
ca0889aaab Merge branch 'main' of github.com:hrfee/jfa-go into main
made a mistake, tried to fix it, messed up again.
2021-01-15 14:47:12 +00:00
Harvey Tindall
772e12d11c add optional tls/http2 support
Allows for http2 server push, see the advanced section.
2021-01-15 14:43:32 +00:00
Harvey Tindall
7d04487b18 update CONTRIBUTING.md 2021-01-15 14:43:32 +00:00
Harvey Tindall
0b482116bb fix display of username box on add account modal 2021-01-15 14:43:32 +00:00
Harvey Tindall
a579bcd463 add finished french for admin 2021-01-15 14:43:32 +00:00
Harvey Tindall
ab7017ff12 fix spelling in french email 2021-01-15 14:43:32 +00:00
Harvey Tindall
3d5bea003a Fix email language selection, add finished french emails 2021-01-15 14:43:32 +00:00
Harvey Tindall
bc99dc34ee Add email translation, add part of french translations
Admin translation from @Killianbe, Email translation from
@Cornichon420. French is currently not functional, a few things are
missing which i'm waiting on.
2021-01-15 14:43:32 +00:00
Harvey Tindall
965c449f1c attempt to use http2 server push 2021-01-15 14:43:32 +00:00
Harvey Tindall
4679c6f355 add language selector to admin 2021-01-15 14:43:32 +00:00
Harvey Tindall
a3351f4da8 separate options for form and admin language 2021-01-15 14:43:32 +00:00
Harvey Tindall
422f13202b Use lang file in typescript 2021-01-15 14:43:31 +00:00
Harvey Tindall
c470e40737 Start adding translation support for admin 2021-01-15 14:43:31 +00:00
Harvey Tindall
0f92ce2166 update CONTRIBUTING.md 2021-01-15 13:48:18 +00:00
Harvey Tindall
b1becb9ef5 fix display of username box on add account modal 2021-01-15 13:48:18 +00:00
Harvey Tindall
3c1599b6b7 add finished french for admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
3e53b742f4 fix spelling in french email 2021-01-15 13:48:18 +00:00
Harvey Tindall
5401593279 Fix email language selection, add finished french emails 2021-01-15 13:48:18 +00:00
Harvey Tindall
0710e05479 Add email translation, add part of french translations
Admin translation from @Killianbe, Email translation from
@Cornichon420. French is currently not functional, a few things are
missing which i'm waiting on.
2021-01-15 13:48:18 +00:00
Harvey Tindall
1707d011a2 attempt to use http2 server push 2021-01-15 13:48:18 +00:00
Harvey Tindall
5e8d7944bd add language selector to admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
2d2727f7e8 separate options for form and admin language 2021-01-15 13:48:18 +00:00
Harvey Tindall
c72282613d Use lang file in typescript 2021-01-15 13:48:18 +00:00
Harvey Tindall
4ac62a107c Start adding translation support for admin 2021-01-15 13:48:18 +00:00
Harvey Tindall
a102199d5a include code in invite form instead of getting from url
potentially solves #34?
2021-01-14 14:22:20 +00:00
Harvey Tindall
3c799b8783 remove debug printfs 2021-01-11 19:19:19 +00:00
Harvey Tindall
3fbbc7f620 add language selector to form 2021-01-11 19:17:43 +00:00
Harvey Tindall
461efa7f60 oops 2021-01-11 16:10:04 +00:00
Harvey Tindall
1321f8df50 mention contributing.md 2021-01-11 16:09:30 +00:00
Harvey Tindall
a081f3a799 add contribution notes 2021-01-11 16:07:49 +00:00
Harvey Tindall
e532000ad0 Mention emby in README, add notices about password resets 2021-01-10 16:10:03 +00:00
Harvey Tindall
8d0dc232d7 option to substitute "Jellyfin" in form.html
setting is jellyfin/substitute_jellyfin_strings.
2021-01-10 16:10:03 +00:00
Harvey Tindall
f5602f1e96 change settings description and console warning 2021-01-10 16:10:03 +00:00
Harvey Tindall
d9e1e2f58b compiles, basic issues fixed
Server type is found under the Jellyfin settings tab, where you can
change it to emby. Currently:

* logs in
* creates users
* parses accounts
2021-01-10 16:10:03 +00:00
Harvey Tindall
5d56ed5378 fix most incompatibilites, start separating api clients 2021-01-10 16:10:03 +00:00
Harvey Tindall
4aae655180 live validation on form, change special character definition
The internal array of special characters was lacking, so a character is
now special when not a digit and (uppercase form) == (lowercase form).
2021-01-09 01:00:27 +00:00
Harvey Tindall
6860933498 functional continue button in form, hide empty contactMessage box 2021-01-09 00:07:19 +00:00
alexh-name
377c8d3e4e fix typo in form/en-us 2021-01-08 23:58:48 +00:00
Richard de Boer
74bbfdf5c2 add dutch translation of account creation form 2021-01-08 23:57:18 +00:00
Harvey Tindall
0171fb8569 dont attempt to release PRs on buildrone 2021-01-08 23:52:36 +00:00
Harvey Tindall
fdc97b4e86 rename ts to typescript
unusual name conflict meant this step gets skipped.
2021-01-05 18:40:19 +00:00
Harvey Tindall
eb370d64df Merge a17t-redesign, kinda ts-ify setup.js
the web ui has been redesigned with the a17t toolkit, which imo looks a
lot better than bootstrap. This also brought a complete rework of the
web code, which now makes a lot more sense hopefully. the setup page is
still stuck with bootstrap, its not much of a priority but i'll rewrite
it eventually.
2021-01-05 18:16:23 +00:00
Harvey Tindall
69bf81b658 change comment 2021-01-05 17:34:27 +00:00
Harvey Tindall
9125273036 Merge dependabot PR 2020-12-18 15:46:34 +00:00
Harvey Tindall
ee6f81b9e9 Add ability to revert to non-hyphenated user IDs
The first 10.7.0 build i tried used hyphens, but a later one didn't.
emails.json can now be converted between the two forms depending on what
the server uses.
2020-12-18 15:44:19 +00:00
Harvey Tindall
72eb51e9c0 Merge pull request #20 from hrfee/dependabot/npm_and_yarn/ini-1.3.8
Bump ini from 1.3.5 to 1.3.8
2020-12-13 22:02:38 +00:00
dependabot[bot]
f3833f1433 Bump ini from 1.3.5 to 1.3.8
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-13 04:01:02 +00:00
Harvey Tindall
c79f86137e separate head into template, add description meta tag 2020-12-07 22:46:17 +00:00
Harvey Tindall
8ef27f7fda upgrade bootstrap and popper js
bootstrap css was on alpha 3 while js was on alpha 1 previously.
2020-12-05 22:36:03 +00:00
Harvey Tindall
a1e30ff5db fix/improve parsing of last active dates
parseDT only uses the magic json.Unmarshal method if theres an error
with the better version. Error came from some times being sent without a
"Z" at the end denoting UTC.
2020-12-03 20:49:50 +00:00
Harvey Tindall
3c952d21f7 fix 10.7.0 compatibility, simplify scss
Hyphens are added to user IDs from 10.7.0, so if the server is running
it, emails.json will be modified to include them. The existing file is
backed up. Also, scss files have been simplified since bs4-jf and bs5-jf share
much of the same content.
2020-11-29 18:01:10 +00:00
Harvey Tindall
9dbf60e3df add URL base option for subfolder proxies
also cleaned up the naming of some things.
2020-11-22 16:36:43 +00:00
145 changed files with 10633 additions and 7047 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -7,14 +7,22 @@ release:
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data
- cp -r static data/web
- npm install
- mkdir -p data/web/css
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
- 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'
- npx esbuild --bundle ts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --bundle ts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --bundle ts/setup.ts --outfile=./data/web/js/setup.js --minify
- go get -u github.com/swaggo/swag/cmd/swag
- swag init -g main.go
builds:
@@ -37,9 +45,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
View 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`.

View File

@@ -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

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Harvey Tindall
Copyright (c) 2021 Harvey Tindall
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,33 +1,34 @@
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 --bundle ts/admin.ts --outfile=./build/data/web/js/admin.js --minify
-npx esbuild --bundle ts/form.ts --outfile=./build/data/web/js/form.js --minify
-npx esbuild --bundle ts/setup.ts --outfile=./build/data/web/js/setup.js --minify
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 --bundle ts/admin.ts --sourcemap --outfile=./build/data/web/js/admin.js
-npx esbuild --bundle ts/form.ts --sourcemap --outfile=./build/data/web/js/form.js
-npx esbuild --bundle ts/setup.ts --sourcemap --outfile=./build/data/web/js/setup.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
@@ -46,12 +47,25 @@ compile:
compress:
upx --lzma build/jfa-go
bundle-css:
-mkdir -p build/data/web/css
$(info bundling css)
npx esbuild --bundle css/base.css --outfile=build/data/web/css/bundle.css --external:remixicon.css --minify
copy:
$(info Copying data)
cp -r data build/
$(info copying fonts)
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 bundle-css swagger compile copy
debug: configuration npm email version ts-debug bundle-css swagger compile copy

View File

@@ -1,6 +1,9 @@
# ![jfa-go](data/static/banner.svg)
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.
![jfa-go](images/banner.svg)
[![Build Status](https://drone.hrfee.pw/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.pw/hrfee/jfa-go)
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
[![Translation status](https://weblate.hrfee.pw/widgets/jfa-go/-/svg-badge.svg)](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
[![Translation status](https://weblate.hrfee.pw/widgets/jfa-go/-/multi-auto.svg)](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
View 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>

320
api.go
View File

@@ -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
}
@@ -235,19 +233,28 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
// @Security Bearer
// @tags Users
func (app *appContext) NewUserAdmin(gc *gin.Context) {
respondUser := func(code int, user, email bool, msg string, gc *gin.Context) {
resp := newUserResponse{
User: user,
Email: email,
Error: msg,
}
gc.JSON(code, resp)
gc.Abort()
}
var req newUserDTO
gc.BindJSON(&req)
existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username)
app.info.Printf("%s New user failed: %s", req.Username, msg)
respond(401, msg, gc)
respondUser(401, false, false, msg, gc)
return
}
user, status, err := app.jf.NewUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Username, status)
respond(401, "Unknown error", gc)
respondUser(401, false, false, "Unknown error", gc)
return
}
var id string
@@ -256,18 +263,21 @@ 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)
}
}
app.jf.CacheExpiry = time.Now()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email
app.storage.storeEmails()
@@ -284,7 +294,22 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
}
}
}
app.jf.CacheExpiry = time.Now()
if app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email)
msg, err := app.email.constructWelcome(req.Username, app)
if err != nil {
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
respondUser(500, true, false, err.Error(), gc)
return
} else if err := app.email.send(req.Email, msg); err != nil {
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
respondUser(500, true, false, err.Error(), gc)
return
} else {
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
}
}
respondUser(200, true, true, "", gc)
}
// @Summary Creates a new Jellyfin user via invite code
@@ -300,7 +325,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
app.debug.Printf("%s: New user attempt", req.Code)
if !app.checkInvite(req.Code, false, "") {
app.info.Printf("%s New user failed: invalid code", req.Code)
respondBool(401, false, gc)
respond(401, "errorInvalidCode", gc)
return
}
validation := app.validator.validate(req.Password)
@@ -319,15 +344,15 @@ func (app *appContext) NewUser(gc *gin.Context) {
}
existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username)
msg := fmt.Sprintf("User %s", req.Username)
app.info.Printf("%s New user failed: %s", req.Code, msg)
respond(401, msg, gc)
respond(401, "errorUserExists", gc)
return
}
user, status, err := app.jf.NewUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
respond(401, "Unknown error", gc)
respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc)
return
}
app.storage.loadProfiles()
@@ -364,8 +389,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 +399,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)
}
}
}
@@ -395,6 +423,17 @@ func (app *appContext) NewUser(gc *gin.Context) {
}
}
}
if app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email)
msg, err := app.email.constructWelcome(req.Username, app)
if err != nil {
app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err)
} else if err := app.email.send(req.Email, msg); err != nil {
app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err)
} else {
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
}
}
code := 200
for _, val := range validation {
if !val {
@@ -452,7 +491,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 +521,21 @@ 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
if req.Label != "" {
invite.Label = req.Label
}
invite.Created = currentTime
if req.MultipleUses {
if req.NoLimit {
invite.NoLimit = true
@@ -503,21 +545,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 +569,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 +704,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()
@@ -690,6 +735,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
Created: app.formatDatetime(inv.Created),
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = inv.UsedBy
@@ -834,10 +880,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 +925,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 +1033,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 +1117,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 +1181,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 +1189,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) || value.(string) == key {
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) || value.(string) == key {
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) || value.(string) == key {
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 +1238,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 +1271,52 @@ 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
}
} else if page == "setup" {
for key, lang := range app.storage.lang.Setup {
resp[key] = lang.Meta.Name
}
} else if page == "email" {
for key, lang := range app.storage.lang.Email {
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 {

View File

@@ -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,51 @@ 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("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString(filepath.Join(app.localPath, "welcome.html")))
app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString(filepath.Join(app.localPath, "welcome.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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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("}")

397
css/base.css Normal file
View File

@@ -0,0 +1,397 @@
@import "../node_modules/a17t/dist/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;
}
}
.banner {
margin: calc(-1 * var(--spacing-4,1rem));
}
.banner.header {
margin-bottom: var(--spacing-4,1rem);
}
.banner.footer {
margin-top: var(--spacing-4,1rem);
padding: var(--spacing-4,1rem);
}
div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
.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: start;
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;
}
.middle {
align-items: 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
View 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
View 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
View 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
View 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;
}

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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));
};

View File

@@ -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>

View File

@@ -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">&times;</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">&times;</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">&times;</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 &amp; 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">&times;</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">&times;</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">&times;</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>

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,374 +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="stylesheet" href="bs5-jf.css">
<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>
<style>
.card-body {
width: 100%;
height: 100%;
padding: 10%;
}
.slider {
width: 100%;
height: 100%;
display: flex;
overflow-x: hidden;
-webkit-overflow-scrolling: none;
scroll-snap-type: x mandatory;
}
.slider > div {
scroll-snap-align: start;
}
.slide {
width: 100%;
flex-shrink: 0;
height: 100%;
margin: 10%;
}
</style>
<title>Setup - Jellyfin Accounts</title>
</head>
<body>
<div class="pageContainer">
<div class="container">
<div class="row">
<div class="col-sm"></div>
<div id="setupCarousel" class="col-md-auto slider">
<div class="slide card text-center" id="page-1">
<div class="card-body">
<h5 class="card-title">Welcome!</h5>
<p class="card-text">
You'll need to do a few things to start using jfa-go. Click below to get started, or quit and edit the config file manually.
</p>
<a class="btn btn-primary nextButton" href="#page-2">Get Started</a>
</div>
<div class="card-footer">
<small>Note: Make sure you are accessing this page through HTTPS, or on a private network.</small>
</div>
</div>
<div class="slide card" id="page-2">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p class="card-text">
To access the admin page, you'll need to login. Choose how below.
<ul>
<li><b>Authorize through Jellyfin: </b>Checks credentials with Jellyfin, allowing you to share login details and grant multiple users access.</li>
<li><b>Username & Password: </b>Set your own username and password manually.</li>
</ul>
<div class="form-check" id="jfAuthFormGroup">
<input class="form-check-input" type="radio" name="auth" id="jfAuthRadio" value="jfAuth" checked>
<label class="form-check-label" for="jfAuthRadio">
Authorize through Jellyfin
</label>
</div>
<div id="adminOnlyArea">
<div class="form-check" style="margin-left: 1rem;">
<input type="checkbox" class="form-check-input" id="jfAuthAdminOnly" checked>
<label for="jfAuthAdminOnly" class="form-check-label">Allow admin users only</label>
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="auth" id="manualAuthRadio" value="manualAuth">
<label class="form-check-label" for="manualAuthRadio">
Manual username &amp; password
</label>
</div>
<div id="manualAuthArea">
<div class="form-group">
<label for="manualAuthUsername">Username</label>
<input type="text" class="form-control" id="manualAuthUsername" placeholder="Username">
</div>
<div class="form-group">
<label for="manualAuthPassword">Password</label>
<input type="password" class="form-control" id="manualAuthPassword" placeholder="Password">
</div>
<div class="form-group">
<label for="manualAuthEmail">Email (Optional)</label>
<input type="email" class="form-control" id="manualAuthEmail" placeholder="example@example.com">
<small class="form-text text-muted">Your email address is only required if you want to recieve activity notifications.</small>
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-1">Back</a>
<a class="btn btn-primary nextButton" href="#page-3">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-3">
<div class="card-body">
<h5 class="card-title">Jellyfin</h5>
<p class="card-text">
jfa-go needs admin access so that it can create users, as this is currently not permitted via API tokens.
You should create a separate account for it, checking 'Allow this user to manage the server'. You can disable everything else. Once done, enter the credentials here.
<div class="form-group">
<label for="jfHost">Host (For internal use)</label>
<input type="url" class="form-control" id="jfHost" placeholder="http://jellyf.in:443" required>
</div>
<div class="form-group">
<label for="jfPublicHost">Public Host (For access by users)</label>
<input type="url" class="form-control" id="jfPublicHost" placeholder="Leave blank to use the above address.">
</div>
<div class="form-group">
<label for="jfUser">Username</label>
<input type="text" class="form-control" id="jfUser" placeholder="Username" required>
</div>
<div class="form-group">
<label for="jfPassword">Password</label>
<input type="password" class="form-control" id="jfPassword" placeholder="Password" required>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-secondary" id="jfTestButton">Test</button>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-2">Back</a>
<a class="btn btn-primary nextButton disabled" id="jfNextButton" aria-disabled="true" href="#page-4">Next</a>
</div>
</div>
</div>
</div>
<div class="slide card" id="page-4">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">jfa-go is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through <a href="https://www.mailgun.com/">Mailgun's</a> API.
<div class="form-group">
<div class="form-check" id="emailDisabled">
<input class="form-check-input" type="radio" name="email" id="emailDisabledRadio" value="emailDisabled">
<label class="form-check-label" for="emailDisabledRadio">
Disabled
</label>
</div>
<div class="form-check" id="emailSMTP">
<input class="form-check-input" type="radio" name="email" id="emailSMTPRadio" value="emailSMTP" checked>
<label class="form-check-label" for="emailSMTPRadio">
SMTP
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="email" id="emailMailgunRadio" value="emailMailgun">
<label class="form-check-label" for="emailMailgunRadio">
Mailgun API
</label>
</div>
</div>
<div id="emailSMTPArea">
<div class="form-group form-check form-switch">
<input type="checkbox" class="form-check-input" id="emailSSL_TLS" checked>
<label for="emailSSL_TLS" class="form-check-label" id="emailSSL_TLSLabel">Use SSL/TLS</label>
<small class="form-text text-muted">Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587.</small>
</div>
<div class="form-group form-row">
<div class="col">
<input type="text" class="form-control" id="emailSMTPServer" placeholder="SMTP Server Address">
</div>
<div class="col">
<input type="number" class="form-control" id="emailSMTPPort" placeholder="Port">
</div>
</div>
<div class="form-group form-row">
<div class="col">
<input type="email" class="form-control" id="emailSMTPAddress" placeholder="jellyfin@jellyf.in">
</div>
<div class="col">
<input type="password" class="form-control" id="emailSMTPPassword" placeholder="Password">
</div>
</div>
</div>
<div id="emailMailgunArea">
<div class="form-group">
<input type="url" class="form-control" id="emailMailgunURL" placeholder="API URL">
</div>
<div class="form-group">
<input type="password" class="form-control" id="emailMailgunKey" placeholder="API Key">
</div>
<div class="form-group">
<input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in">
</div>
</div>
<div id="emailCommonArea">
<h5 class="card-title">Notifications</h5>
<p class="card-text">Enabling notifications will allow you to choose (per-invite) to recieve emails when an invite expires, or when a new user is created. If you chose to use Manual auth instead of Jellyfin auth previously, make sure you provided an email address.</p>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="notificationsEnabled">
<label for="notificationsEnabled" class="form-check-label">Enabled</label>
</div>
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-3">Back</a>
<a class="btn btn-primary nextButton" id="emailNextButton" href="#page-5">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-5">
<div class="card-body">
<h5 class="card-title">Email</h5>
<p class="card-text">Just a few more things to get your emails looking great.
<div class="form-group">
<label for="emailSender">Sender: The name shown when a user receives an email.</label>
<input type="text" class="form-control" id="emailSender" value="Jellyfin">
</div>
<div class="form-group">
<label for="emailDateFormat">Date Format: Follows <a target="_blank" href="https://strftime.org/">strftime</a> format.</label>
<input type="text" class="form-control" id="emailDateFormat" value="%d/%m/%y">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email24hTimeRadio" value="email24hTime" checked>
<label class="form-check-label" for="email24hTimeRadio">24h time</label>
</div>
<div class="form-group form-check">
<input class="form-check-input" type="radio" name="time" id="email12hTimeRadio" value="email12hTime">
<label class="form-check-label" for="email12hTimeRadio">12h time</label>
</div>
<div class="form-group">
<label for="emailMessage">Message: Short message displayed at the bottom of emails.</label>
<input type="text" class="form-control" id="emailMessage" value="Need help? Contact me.">
</div>
</p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-4">Back</a>
<a class="btn btn-primary nextButton" href="#page-6">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-6">
<div class="card-body">
<h5 class="card-title">Password Resets</h5>
<p class="card-text">
When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jfa-go will then read this file, and send the PIN to the user's email. Try it now, and put the folder that it informs you it put the file in below. Also, if enter a custom email subject if you don't like the default one.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="pwrEnabled" value="enabled">
<label class="form-check-label" for="pwrEnabled">Enabled</label>
</div>
<div id="pwrArea">
<div class="form-group">
<label for="pwrJfPath">Path to Jellyfin</label>
<input type="text" class="form-control" id="pwrJfPath" placeholder="Folder">
</div>
<div class="form-group">
<label for="pwrSubject">Email Subject</label>
<input type="text" class="form-control" id="pwrSubject" value="Password Reset - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-5">Back</a>
<a class="btn btn-primary nextButton" href="#page-7">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-7">
<div class="card-body">
<h5 class="card-title">Invite Emails</h5>
<p class="card-text">
Allows you to send an invite code directly to a specified email address.
Since you'll most likely being running this behind a reverse proxy, the program has no way of knowing the address it will be accessed from. This is needed for sending emails with links. Write your URL Base with the protocol and append '/invite', e.g:
<ul>
<li>On the local network, you might use <a href="#">http://localhost:8056/invite</a></li>
<li>Exposed to the internet, you might use <a href="#">https://accounts.jellyf.in/invite</a></li>
</ul>
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="invEnabled" value="enabled" checked>
<label class="form-check-label" for="invEnabled">Enabled</label>
</div>
<div id="invArea">
<div class="form-group">
<label for="invURLBase">URL Base</label>
<input type="url" class="form-control" id="invURLBase" placeholder="https://accounts.jellyf.in/invite">
</div>
<div class="form-group">
<label for="invSubject">Subject</label>
<input type="text" class="form-control" id="invSubject" value="Invite - Jellyfin">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-6">Back</a>
<a class="btn btn-primary nextButton" href="#page-8">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-8">
<div class="card-body">
<h5 class="card-title">Password Validation</h5>
<p class="card-text">
Enabling this will display a set of password requirements on the create account page, such as minimum length, uppercase characters, special characters, etc.
</p>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="valEnabled" value="enabled">
<label class="form-check-label" for="valEnabled">Enabled</label>
</div>
<div id="valArea">
<div class="form-group">
<label for="valLength">Minimum Length</label>
<input type="number" class="form-control" id="valLength" value="8">
</div>
<div class="form-group">
<label for="valUpper">Minimum number of uppercase characters</label>
<input type="number" class="form-control" id="valUpper" value="1">
</div>
<div class="form-group">
<label for="valLower">Minimum number of lowercase characters</label>
<input type="number" class="form-control" id="valLower" value="0">
</div>
<div class="form-group">
<label for="valNumber">Minimum number of numbers</label>
<input type="number" class="form-control" id="valNumber" value="0">
</div>
<div class="form-group">
<label for="valSpecial">Minimum number of special characters</label>
<input type="number" class="form-control" id="valSpecial" value="0">
</div>
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" id="valBackButton" href="#page-7">Back</a>
<a class="btn btn-primary nextButton" href="#page-9">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-9">
<div class="card-body">
<h5 class="card-title">Help Messages</h5>
<p class="card-text">
Just a few little messages that will display in various places. Leave these alone if you want.
</p>
<div class="form-group">
<label for="msgContact">Contact message: Displays at bottom of all pages (except admin).</label>
<input id="msgContact" type="text" class="form-control" value="Need help? Contact me.">
</div>
<div class="form-group">
<label for="msgHelp">Help message: Displays when a user is creating an account.</label>
<input id="msgHelp" type="text" class="form-control" value="Enter your details to create an account.">
</div>
<div class="form-group">
<label for="msgSuccess">Success message: Displays when a user successfully creates an account, just above a button taking the user to Jellyfin.</label>
<input id="msgSuccess" type="text" class="form-control" value="Your account has been created. Click below to continue to Jellyfin.">
</div>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-8">Back</a>
<a class="btn btn-primary nextButton" href="#page-10">Next</a>
</div>
</div>
</div>
<div class="slide card" id="page-10">
<div class="card-body text-center">
<h5 class="card-title">Finished!</h5>
<p class="card-text">
Press the button below to submit your settings. The program will restart. Once it's done, refresh this page.
</p>
<button id="submitButton" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
<div class="col-sm"></div>
</div>
</div>
</div>
<script src="setup.js"></script>
</body>
</html>

View File

@@ -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: app.config.Section("password_resets").Key("subject").MustString(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: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("deletion").Key("email_" + key).String()
@@ -297,7 +307,41 @@ 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
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("welcome_email").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(),
"username": emailer.lang.WelcomeEmail.get("username"),
"usernameVal": username,
"message": app.config.Section("email").Key("message").String(),
})
if err != nil {
return nil, err

View File

@@ -1,5 +0,0 @@
#!/usr/bin/bash
# set +e
# npx tsc -p ts/
# set -e
npx esbuild ts/* --outdir=data/static --minify

21
go.mod
View File

@@ -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,15 @@ 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.1 // indirect
github.com/go-openapi/swag v0.19.13 // 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 +43,14 @@ 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/text v0.3.4 // indirect
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619 // indirect
golang.org/x/text v0.3.5 // indirect
golang.org/x/tools v0.1.0 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0
gopkg.in/yaml.v2 v2.3.0 // indirect
)

42
go.sum
View File

@@ -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,12 @@ 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/spec v0.20.1 h1:5WNKTzPguDN+79wbJw2UE2q+eX+gUmEFsIKSvnSQJlc=
github.com/go-openapi/spec v0.20.1/go.mod h1:93x7oh+d+FQsmsieroS4cmR3u0p/ywH649a3qwC9OsQ=
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 +96,10 @@ 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-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
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 +143,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 +166,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 +216,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 +251,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 +329,10 @@ 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/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/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 +363,13 @@ 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/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619 h1:yLLDsUUPDliIQpKl7BjVb1igwngIMH2GBjo1VpwLTE0=
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/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=
@@ -347,6 +377,8 @@ golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -376,6 +408,12 @@ 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/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
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 +439,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 +459,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
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.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>

296
html/admin.html Normal file
View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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>
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral !normal mb-1 mt-half">
</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" title="Set uses to infinite">
<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>

11
html/form-base.html Normal file
View File

@@ -0,0 +1,11 @@
{{ define "form-base" }}
<script>
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .validationStrings }});
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}";
window.messages = JSON.parse({{ .notifications }});
</script>
<script src="js/form.js" type="module"></script>
{{ end }}

75
html/form.html Normal file
View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.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>
<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
View 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
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.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>

460
html/setup.html Normal file
View File

@@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="en" class="light-theme">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.css">
{{ template "header.html" . }}
<title>{{ .lang.Strings.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<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" id="page-container">
<div class="card ~neutral !low mb-1">
<div class="row">
<img class="banner header" src="banner.svg" alt="jfa-go" />
</div>
<div class="row col flex center">
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
</div>
<div class="row col flex center">
<p class="content">{{ .lang.StartPage.pressStart }}</p>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge !normal next">{{ .lang.StartPage.start }}</span>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.Language.title }}</span>
<p class="content" id="language-description"></p>
<label class="label">
<span class="mt-half">{{ .lang.Language.defaultAdminLang }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="ui-language-admin">
</select>
</div>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Language.defaultFormLang }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="ui-language-form">
</select>
</div>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Language.defaultEmailLang }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="email-language">
</select>
</div>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.General.title }}</span>
<div class="row">
<div class="col">
<label class="label">
<span class="mt-half">{{ .lang.General.listenAddress }}</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="ui-host" value="0.0.0.0">
</label>
<label class="row switch">
<input type="checkbox" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span>
</label>
<p class="support mb-1">{{ .lang.General.useHTTPSNotice }}</p>
<label class="label">
<span class="mt-half">{{ .lang.General.pathToCertificate }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="advanced-tls_cert">
</label>
<label class="label">
<span class="mt-half">{{ .lang.General.pathToKeyFile }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="advanced-tls_key">
</label>
</div>
<div class="col">
<label class="label">
<span class="mt-half">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="ui-port" value="8056">
</label>
<label class="label">
<span class="mt-half">{{ .lang.General.httpsPort }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="advanced-tls_port" value="8057">
</label>
<label class="label">
<span class="mt-half">{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
<input type="url" class="input ~neutral !normal mt-half" id="ui-url_base">
<p class="support mb-1">{{ .lang.General.urlBaseNotice }}</p>
</label>
<label class="label">
<span>{{ .lang.Strings.theme }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="ui-theme">
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
</select>
</div>
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.Login.title }}</span>
<p class="content">{{ .lang.Login.description }}</p>
<div class="pl-1">
<label class="row switch pb-1">
<input type="radio" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span>
</label>
<label class="row switch pl-1 pb-1">
<input type="checkbox" id="ui-admin_only"><span>{{ .lang.Login.adminOnly }}</span>
</label>
<label class="row switch pb-1">
<input type="radio" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
</label>
</div>
<div id="login-manual">
<label class="label">
<span class="mt-half">{{ .lang.Strings.username }}</span>
<input type="text" id="ui-username" class="input ~neutral !normal mt-half mb-1" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label">
<span>{{ .lang.Strings.password }}</span>
<input type="password" id="ui-password" class="input ~neutral !normal mt-half mb-1" placeholder="{{ .lang.Strings.password }}">
</label>
<label class="label">
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
<input type="email" id="ui-email" class="input ~neutral !normal mt-half" placeholder="email@address">
<span class="support mb-1">{{ .lang.Login.emailNotice }}</span>
</label>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.JellyfinEmby.title }}</span>
<p class="content">{{ .lang.JellyfinEmby.description }}</p>
<div class="row">
<div class="col">
<label class="label">
<span>{{ .lang.Strings.serverType }}</span>
<div class="select ~neutral !normal mt-half">
<select id="jellyfin-type">
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
</select>
</div>
<p class="support mb-1">{{ .lang.JellyfinEmby.embyNotice }}</p>
</label>
<label class="label">
<span class="mt-half">{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral !normal mt-half" id="jellyfin-substitute_jellyfin_strings">
<p class="support mb-1">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.username }}</span>
<input type="text" id="jellyfin-username" class="input ~neutral !normal mt-half mb-1" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label">
<span>{{ .lang.Strings.password }}</span>
<input type="password" id="jellyfin-password" class="input ~neutral !normal mt-half mb-1" placeholder="{{ .lang.Strings.password }}">
</label>
</div>
<div class="col">
<label class="label">
<span class="mt-half">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="jellyfin-server" placeholder="http://jellyf.in:80">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
<input type="url" class="input ~neutral !normal mt-half" id="jellyfin-public_server" placeholder="https://jellyf.in">
<p class="support mb-1">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
<span class="button ~urge !normal next" disabled>{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.Ombi.title }}</span>
<p class="content">{{ .lang.Ombi.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="ombi-server" placeholder="ombi.jellyf.in">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="ombi-api_key">
<p class="support mb-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.Email.title }}</span>
<p class="content" id="email-description"></p>
<div class="row">
<div class="col">
<label class="label">
<span>{{ .lang.Email.method }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="email-method">
<option value="">{{ .lang.Strings.disabled }}</option>
<option value="smtp">SMTP</option>
<option value="mailgun">Mailgun</option>
</select>
</div>
</label>
<label class="row switch">
<input type="checkbox" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span>
</label>
<p class="support mb-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
<label class="label">
<span class="mt-half">{{ .lang.Email.fromAddress }}</span>
<input type="email" class="input ~neutral !normal mt-half mb-1" id="email-address" placeholder="mail@jellyf.in">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Email.senderName }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="email-from" value="Jellyfin">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Email.dateFormat }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="email-date_format" value="%d/%m/%y">
<p class="support mb-1" id="email-dateformat-notice"></p>
</label>
<div>
<label class="row switch pb-1">
<input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Email.time24h }}</span>
</label>
<label class="row switch pb-1">
<input type="radio" name="email-24h" value="false"><span>{{ .lang.Email.time12h }}</span>
</label>
</div>
</div>
<div class="col">
<div id="email-smtp">
<p class="subheading">SMTP</p>
<label class="label">
<span>{{ .lang.Email.encryption }}</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="smtp-encryption">
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
</select>
</div>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="smtp-server" placeholder="smtp.jellyf.in">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="smtp-port" placeholder="587">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="smtp-username">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.password }}</span>
<input type="password" class="input ~neutral !normal mt-half mb-1" id="smtp-password">
</label>
</div>
<div id="email-mailgun">
<p class="subheading">Mailgun</p>
<label class="label">
<span class="mt-half">{{ .lang.Email.mailgunApiURL }}</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="mailgun-api_key">
</label>
</div>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused related-to-email">
<span class="heading">{{ .lang.Notifications.title }}</span>
<p class="content">{{ .lang.Notifications.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<span class="heading">{{ .lang.WelcomeEmails.title }}</span>
<p class="content">{{ .lang.WelcomeEmails.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused related-to-email">
<span class="heading">{{ .lang.InviteEmails.title }}</span>
<p class="content">{{ .lang.InviteEmails.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.URL }}</span>
<input type="url" class="input ~neutral !normal mt-half mb-1" id="invite_emails-url_base" placeholder="https://accounts.jellyf.in/invite">
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused related-to-email">
<span class="heading">{{ .lang.PasswordResets.title }}</span>
<p class="content">{{ .lang.PasswordResets.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordResets.pathToJellyfin }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="password_resets-watch_directory" placeholder="/config/jellyfin">
<p class="support mb-1">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
</label>
<label class="label">
<span class="mt-half">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.PasswordValidation.title }}</span>
<p class="content">{{ .lang.PasswordValidation.description }}</p>
<label class="row switch pb-1">
<input type="checkbox" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordValidation.length }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="password_validation-min_length" value="8">
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordValidation.uppercase }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="password_validation-upper" value="1">
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordValidation.lowercase }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="password_validation-lower" value="0">
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordValidation.numbers }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="password_validation-number" value="0">
</label>
<label class="label">
<span class="mt-half">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral !normal mt-half mb-1" id="password_validation-special" value="0">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<span class="heading">{{ .lang.HelpMessages.title }}</span>
<p class="content">{{ .lang.HelpMessages.description }}</p>
<label class="label">
<span class="mt-half">{{ .lang.HelpMessages.contactMessage }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="ui-contact_message">
<p class="support mb-1">{{ .lang.HelpMessages.contactMessageNotice }}</p>
</label>
<label class="label">
<span class="mt-half">{{ .lang.HelpMessages.helpMessage }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="ui-help_message">
<p class="support mb-1">{{ .lang.HelpMessages.helpMessageNotice }}</p>
</label>
<label class="label">
<span class="mt-half">{{ .lang.HelpMessages.successMessage }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="ui-success_message">
<p class="support mb-1">{{ .lang.HelpMessages.successMessageNotice }}</p>
</label>
<label class="label related-to-email">
<span class="mt-half">{{ .lang.HelpMessages.emailMessage }}</span>
<input type="text" class="input ~neutral !normal mt-half" id="email-message">
<p class="support mb-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral !normal back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge !normal next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral !low mb-1 unfocused">
<div class="row col flex center">
<span class="heading">{{ .lang.EndPage.finished }}</span>
</div>
<div class="row col flex center">
<p class="content">{{ .lang.EndPage.restartMessage }}</p>
</div>
<div class="row col flex center">
<span class="button ~neutral !normal back mr-1">{{ .lang.Strings.back }}</span>
<span class="button ~urge !normal" id="restart">{{ .lang.Strings.submit }}</span>
<span class="button ~urge !normal unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
</div>
</div>
</div>
<script>
window.langFile = JSON.parse({{ .language }});
window.messages = JSON.parse({{ .messages }});
</script>
<script src="js/setup.js" type="module"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

BIN
images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

3
images/gengif.txt Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1 +0,0 @@
../data/static/banner.svg

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

138
lang.go Normal file
View File

@@ -0,0 +1,138 @@
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 commonLangs map[string]commonLang
type commonLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
}
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"`
Notifications langSection `json:"notifications"`
notificationsJSON string
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"`
WelcomeEmail langSection `json:"welcomeEmail"`
}
type setupLangs map[string]setupLang
type setupLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
StartPage langSection `json:"startPage"`
EndPage langSection `json:"endPage"`
General langSection `json:"general"`
Language langSection `json:"language"`
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"`
Email langSection `json:"email"`
Notifications langSection `json:"notifications"`
WelcomeEmails langSection `json:"welcomeEmails"`
PasswordResets langSection `json:"passwordResets"`
InviteEmails langSection `json:"inviteEmails"`
PasswordValidation langSection `json:"passwordValidation"`
HelpMessages langSection `json:"helpMessages"`
JSON string
}
func (ls *setupLangs) 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 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
}

123
lang/admin/de-de.json Normal file
View File

@@ -0,0 +1,123 @@
{
"meta": {
"name": "Deutsch (DE)"
},
"strings": {
"invites": "Invites",
"accounts": "Konten",
"settings": "Einstellungen",
"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",
"name": "Name",
"date": "Datum",
"lastActiveTime": "Zuletzt aktiv",
"from": "Von",
"user": "Benutzer",
"aboutProgram": "Über",
"version": "Version",
"commitNoun": "Commit",
"newUser": "Neuer Benutzer",
"profile": "Profil",
"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",
"label": "Label"
},
"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)",
"errorUserCreated": "Fehler beim Erstellen des Benutzers {n}.",
"errorSendWelcomeEmail": "Fehler beim Senden der Willkommens-E-Mail (ü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."
}
}
}

126
lang/admin/en-us.json Normal file
View File

@@ -0,0 +1,126 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"invites": "Invites",
"accounts": "Accounts",
"settings": "Settings",
"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",
"name": "Name",
"date": "Date",
"lastActiveTime": "Last Active",
"from": "From",
"user": "User",
"aboutProgram": "About",
"version": "Version",
"commitNoun": "Commit",
"newUser": "New User",
"profile": "Profile",
"unknown": "Unknown",
"label": "Label",
"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)",
"errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome email (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."
}
}
}

124
lang/admin/fr-fr.json Normal file
View File

@@ -0,0 +1,124 @@
{
"meta": {
"name": "Français (FR)",
"author": "https://github.com/Killianbe"
},
"strings": {
"invites": "Invite",
"accounts": "Comptes",
"settings": "Reglages",
"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",
"name": "Nom",
"date": "Date",
"lastActiveTime": "Dernière activité",
"from": "De",
"user": "Utilisateur",
"aboutProgram": "A propos",
"version": "Version",
"commitNoun": "Commettre",
"newUser": "Nouvel utilisateur",
"profile": "Profil",
"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, les nouveaux utilisateurs seront créés avec ces 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",
"label": "Etiquette"
},
"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)",
"errorUserCreated": "Echec lors de la création de l'utilisateur {n}.",
"errorSendWelcomeEmail": "Echec lors de l'envoi du mail de bienvenue (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."
}
}
}

123
lang/admin/nl-nl.json Normal file
View File

@@ -0,0 +1,123 @@
{
"meta": {
"name": "Nederlands (NL)"
},
"strings": {
"invites": "Uitnodigingen",
"accounts": "Accounts",
"settings": "Instellingen",
"inviteDays": "Dagen",
"inviteHours": "Uren",
"inviteMinutes": "Minuten",
"inviteNumberOfUses": "Aantal keer te gebruiken",
"warning": "Waarschuwing",
"inviteInfiniteUsesWarning": "ongelimiteerde uitnodigingen kunnen misbruikt worden",
"inviteSendToEmail": "Stuur naar",
"login": "Inloggen",
"logout": "Uitloggen",
"create": "Aanmaken",
"apply": "Toepassen",
"delete": "Verwijderen",
"name": "Naam",
"date": "Datum",
"lastActiveTime": "Laatst actief",
"from": "Van",
"user": "Gebruiker",
"aboutProgram": "Over",
"version": "Versie",
"commitNoun": "Commit",
"newUser": "Nieuwe gebruiker",
"profile": "Profiel",
"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 meldingse-mail",
"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 keer te gebruiken",
"inviteNoInvites": "Geen",
"inviteExpiresInTime": "Verloopt over {n}",
"notifyEvent": "Meldingen:",
"notifyInviteExpiry": "Bij verloop",
"notifyUserCreation": "Bij aanmaken gebruiker",
"label": "Label"
},
"notifications": {
"changedEmailAddress": "E-mailadres 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 e-mailadres van {n} mislukt.",
"errorFailureCheckLogs": "Mislukt (controleer console/logbestanden)",
"errorPartialFailureCheckLogs": "Gedeeltelijke fout (controleer console/logbestanden)",
"errorSendWelcomeEmail": "Versturen van welkomste-mail is mislukt (zie console/logs)",
"errorUserCreated": "Aanmaken van gebruiker {n} is mislukt."
},
"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."
}
}
}

123
lang/admin/pt-br.json Normal file
View File

@@ -0,0 +1,123 @@
{
"meta": {
"name": "Português (BR)"
},
"strings": {
"invites": "Convites",
"accounts": "Contas",
"settings": "Configurações",
"inviteDays": "Dias",
"inviteHours": "Horas",
"inviteMinutes": "Minutos",
"inviteNumberOfUses": "Números de usuários",
"warning": "Aviso",
"inviteInfiniteUsesWarning": "convites infinitos podem ser usados de forma abusiva",
"inviteSendToEmail": "Enviar para",
"login": "Login",
"logout": "Sair",
"create": "Criar",
"apply": "Aplicar",
"delete": "Deletar",
"name": "Nome",
"date": "Data",
"lastActiveTime": "Ativo pela última vez",
"from": "De",
"user": "Usuário",
"aboutProgram": "Sobre",
"version": "Versão",
"commitNoun": "Commit",
"newUser": "Novo Usuário",
"profile": "Perfil",
"unknown": "Desconhecido",
"label": "",
"modifySettings": "Modificar configurações",
"modifySettingsDescription": "Aplique as configurações de um perfil existente ou obtenha-as diretamente de um usuário.",
"applyHomescreenLayout": "Aplicar layout na tela inicial",
"sendDeleteNotificationEmail": "Enviar email de notificação",
"sendDeleteNotifiationExample": "Sua conta foi deletada.",
"settingsRestartRequired": "Necessário reiniciar",
"settingsRestartRequiredDescription": "É necessário reiniciar para aplicar algumas configurações alteradas. Deseja reiniciar agora ou mais tarde?",
"settingsApplyRestartLater": "Aplicar, reiniciar mais tarde",
"settingsApplyRestartNow": "Aplicar e reiniciar",
"settingsApplied": "Configurações aplicada.",
"settingsRefreshPage": "Atualize a página em alguns segundos",
"settingsRequiredOrRestartMessage": "Nota: {n} indica campo obrigatório, {n} indica que as alterações requer um reinício.",
"settingsSave": "Salve",
"ombiUserDefaults": "Padrões do usuário Ombi",
"ombiUserDefaultsDescription": "Crie um usuário Ombi, configure-o e selecione-o abaixo. Suas configurações/permissões serão armazenadas e aplicadas aos novos usuários Ombi criados pelo jfa-go",
"userProfiles": "Perfil de usuário",
"userProfilesDescription": "Os perfis são aplicados aos usuários quando eles criam uma conta. Um perfil inclui direitos de acesso à biblioteca e layout da tela inicial.",
"userProfilesIsDefault": "Padrão",
"userProfilesLibraries": "Bibliotecas",
"addProfile": "Adicionar Perfil",
"addProfileDescription": "Crie um usuário no Jellyfin, configure e selecione abaixo. Quando este perfil é aplicado a um convite, novos usuários serão criados com as mesma configurações.",
"addProfileNameOf": "Nome do perfil",
"addProfileStoreHomescreenLayout": "Layout da tela inicial da loja",
"inviteNoUsersCreated": "Nenhum ainda!",
"inviteUsersCreated": "Usuários criado",
"inviteNoProfile": "Sem Perfil",
"copy": "Copiar",
"inviteDateCreated": "Criado",
"inviteRemainingUses": "Uso restantes",
"inviteNoInvites": "Nenhum",
"inviteExpiresInTime": "Expira em {n}",
"notifyEvent": "Notificar em:",
"notifyInviteExpiry": "No vencimento",
"notifyUserCreation": "Na criação do usuário"
},
"notifications": {
"changedEmailAddress": "Endereço de e-mail alterado de {n}.",
"userCreated": "Usuário {n} criado.",
"createProfile": "Perfil {n} criado.",
"saveSettings": "As configurações foram salvas",
"setOmbiDefaults": "Padrões do ombi armazenados.",
"errorConnection": "Não foi possível conectar ao jfa-go.",
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
"errorSettingsAppliedNoHomescreenLayout": "As configurações foram aplicadas, mas a aplicação do layout da tela inicial pode ter falhado.",
"errorHomescreenAppliedNoSettings": "O layout da tela inicial foi aplicado, mas a aplicação das configurações pode ter falhado.",
"errorSettingsFailed": "Falha na aplicação.",
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
"errorUnknown": "Erro desconhecido.",
"errorBlankFields": "Os campos foram deixados em branco",
"errorDeleteProfile": "Falha ao excluir perfil {n}",
"errorLoadProfiles": "Falha ao carregar perfis.",
"errorCreateProfile": "Falha ao criar perfil {n}",
"errorSetDefaultProfile": "Falha ao definir o perfil padrão.",
"errorLoadUsers": "Falha ao carregar usuários.",
"errorSaveSettings": "Não foi possível salvar as configurações.",
"errorLoadSettings": "Falha ao carregar as configurações.",
"errorSetOmbiDefaults": "Falha ao armazenar padrões de ombi.",
"errorLoadOmbiUsers": "Falha ao carregar usuários ombi.",
"errorChangedEmailAddress": "Não foi possível alterar o endereço de e-mail de {n}.",
"errorFailureCheckLogs": "Falha (verificar console/logs)",
"errorPartialFailureCheckLogs": "Falha parcial (verificar console/logs)",
"errorUserCreated": "Falha ao criar o usuário {n}.",
"errorSendWelcomeEmail": "Falha ao enviar e-mail de boas-vindas (verifique console/logs)"
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Modificar configurações para usuário {n}",
"plural": "Modificar configurações para usuários {n}"
},
"deleteNUsers": {
"singular": "Excluir usuário {n}",
"plural": "Excluir usuários {n}"
},
"addUser": {
"singular": "Adicionar usuário",
"plural": "Adicionar usuários"
},
"deleteUser": {
"singular": "Deletar Usuário",
"plural": "Deletar Usuários"
},
"deletedUser": {
"singular": "Excluiu usuário {n}.",
"plural": "Excluiu usuários {n}."
},
"appliedSettings": {
"singular": "Configurações aplicada ao usuário {n}.",
"plural": "Configurações aplicada ao usuários {n}."
}
}
}

14
lang/common/de-de.json Normal file
View File

@@ -0,0 +1,14 @@
{
"meta": {
"name": "Deutsch (DE)"
},
"strings": {
"username": "Benutzername",
"password": "Passwort",
"emailAddress": "E-Mail-Adresse",
"submit": "Absenden",
"success": "Erfolg",
"error": "Fehler",
"theme": "Thema"
}
}

14
lang/common/en-us.json Normal file
View File

@@ -0,0 +1,14 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"username": "Username",
"password": "Password",
"emailAddress": "Email Address",
"submit": "Submit",
"success": "Success",
"error": "Error",
"theme": "Theme"
}
}

15
lang/common/fr-fr.json Normal file
View File

@@ -0,0 +1,15 @@
{
"meta": {
"name": "Français (FR)",
"author": "https://github.com/Killianbe"
},
"strings": {
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"emailAddress": "Addresse Email",
"submit": "Soumettre",
"success": "Succès",
"error": "Erreur",
"theme": "Thème"
}
}

14
lang/common/nl-nl.json Normal file
View File

@@ -0,0 +1,14 @@
{
"meta": {
"name": "Nederlands (NL)"
},
"strings": {
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"emailAddress": "E-mailadres",
"submit": "Verstuur",
"success": "Success",
"error": "Fout",
"theme": "Thema"
}
}

14
lang/common/pt-br.json Normal file
View File

@@ -0,0 +1,14 @@
{
"meta": {
"name": "Português (BR)"
},
"strings": {
"username": "Nome do Usuário",
"password": "Senha",
"emailAddress": "Endereço de Email",
"submit": "Enviar",
"success": "Sucesso",
"error": "Erro",
"theme": "Tema"
}
}

48
lang/email/de-de.json Normal file
View File

@@ -0,0 +1,48 @@
{
"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 die 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"
},
"welcomeEmail": {
"title": "Wilkommen bei Jellyfin",
"welcome": "Willkommen bei Jellyfin!",
"youCanLoginWith": "Du kannst dich mit den mit den untenstehenden Zugangsdaten anmelden",
"jellyfinURL": "URL",
"username": "Benutzername"
}
}

48
lang/email/en-us.json Normal file
View File

@@ -0,0 +1,48 @@
{
"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"
},
"welcomeEmail": {
"title": "Welcome to Jellyfin",
"welcome": "Welcome to Jellyfin!",
"youCanLoginWith": "You can login with the details below",
"jellyfinURL": "URL",
"username": "Username"
}
}

49
lang/email/fr-fr.json Normal file
View File

@@ -0,0 +1,49 @@
{
"meta": {
"name": "Français (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": "Note : Les emails de notification peuvent être activés sur le tableau de bord administrateur."
},
"inviteExpiry": {
"title": "Notification : Invitation expirée",
"inviteExpired": "Invitation expirée.",
"expiredAt": "Le code {n} a expiré à {n}.",
"notificationNotice": "Note : Les emails de notification peuvent être activés sur le tableau de bord administrateur."
},
"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"
},
"welcomeEmail": {
"youCanLoginWith": "Tu peux te connecter avec les informations ci-dessous",
"title": "Bienvenue sur Jellyfin",
"welcome": "Bienvenue sur Jellyfin !",
"jellyfinURL": "URL",
"username": "Nom d'utilisateur"
}
}

48
lang/email/nl-nl.json Normal file
View File

@@ -0,0 +1,48 @@
{
"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"
},
"welcomeEmail": {
"title": "Welkom bij Jellyfin",
"welcome": "Welkom bij Jellyfin!",
"youCanLoginWith": "Je kunt inloggen met onderstaande gegevens",
"jellyfinURL": "URL",
"username": "Gebruikersnaam"
}
}

48
lang/email/pt-br.json Normal file
View File

@@ -0,0 +1,48 @@
{
"meta": {
"name": "Português (BR)"
},
"userCreated": {
"title": "Aviso: Usuário criado",
"aUserWasCreated": "Um usuário foi criado usando o código {n}.",
"name": "Nome",
"emailAddress": "Endereço",
"time": "Tempo",
"notificationNotice": "Nota: Os emails de notificação podem ser alternados no painel do administrador."
},
"inviteExpiry": {
"title": "Aviso: Convite expirado",
"inviteExpired": "Convite expirado.",
"expiredAt": "O código {n} expirou em {n}.",
"notificationNotice": "Nota: Os emails de notificação podem ser alternados no painel do administrador."
},
"passwordReset": {
"title": "Redefinir senha foi solicitada - Jellyfin",
"helloUser": "Ola {n},",
"someoneHasRequestedReset": "Alguém recentemente solicitou uma redefinição de senha no Jellyfin.",
"ifItWasYou": "Se foi você, insira o PIN abaixo.",
"codeExpiry": "O código irá expirar em {n}, ás {n}, que está em {n}.",
"ifItWasNotYou": "Se não foi você, ignore este e-mail.",
"pin": "PIN"
},
"userDeleted": {
"title": "Sua conta foi excluída - Jellyfin",
"yourAccountWasDeleted": "Sua conta Jellyfin foi excluída.",
"reason": "Razão"
},
"inviteEmail": {
"title": "Convite - Jellyfin",
"hello": "Ola",
"youHaveBeenInvited": "Você um convite para o Jellyfin.",
"toJoin": "Para participar, clique no link abaixo.",
"inviteExpiry": "Este convite expira em {n} às {n}, que é em {n}, então seja rápido.",
"linkButton": "Crie sua conta"
},
"welcomeEmail": {
"title": "Bem vindo ao Jellyfin",
"welcome": "Bem vindo ao Jellyfin!",
"youCanLoginWith": "Você pode fazer o login com os detalhes abaixo",
"jellyfinURL": "URL",
"username": "Nome do Usuário"
}
}

45
lang/form/de-de.json Normal file
View File

@@ -0,0 +1,45 @@
{
"meta": {
"name": "Deutsch (DE)"
},
"strings": {
"pageTitle": "Jellyfin-Konto erstellen",
"createAccountHeader": "Konto erstellen",
"accountDetails": "Kontodaten",
"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"
}
},
"notifications": {
"errorUserExists": "Benutzer existiert bereits.",
"errorInvalidCode": "Ungültiger Invite-Code."
}
}

45
lang/form/en-us.json Normal file
View File

@@ -0,0 +1,45 @@
{
"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"
},
"notifications": {
"errorUserExists": "User already exists.",
"errorInvalidCode": "Invalid invite code."
},
"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"
}
}
}

46
lang/form/fr-fr.json Normal file
View File

@@ -0,0 +1,46 @@
{
"meta": {
"name": "Français (FR)",
"author": "https://github.com/Killianbe"
},
"strings": {
"pageTitle": "Créer un compte Jellyfin",
"createAccountHeader": "Création du compte",
"accountDetails": "Détails",
"emailAddress": "Email",
"username": "Nom d'utilisateur",
"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"
}
},
"notifications": {
"errorUserExists": "Utilisateur déjà existant.",
"errorInvalidCode": "Code dinvitation non valide."
}
}

45
lang/form/nl-nl.json Normal file
View File

@@ -0,0 +1,45 @@
{
"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"
}
},
"notifications": {
"errorUserExists": "Gebruiker bestaat al.",
"errorInvalidCode": "Ongeldige uitnodigingscode."
}
}

45
lang/form/pt-br.json Normal file
View File

@@ -0,0 +1,45 @@
{
"meta": {
"name": "Português (BR)"
},
"strings": {
"pageTitle": "Criar Conta Jellyfin",
"createAccountHeader": "Criar Conta",
"accountDetails": "Detalhes",
"emailAddress": "Email",
"username": "Nome do Usuário",
"password": "Senha",
"reEnterPassword": "Digite a Senha Novamente",
"reEnterPasswordInvalid": "Senha não Coincidem. Tentar novamente.",
"createAccountButton": "Criar Conta",
"passwordRequirementsHeader": "Requisitos da Senha",
"successHeader": "Sucesso!",
"successContinueButton": "Continuar"
},
"notifications": {
"errorUserExists": "Esse usuário já existe.",
"errorInvalidCode": "Código do convite invalido."
},
"validationStrings": {
"length": {
"singular": "Deve ter pelo menos {n} caractere",
"plural": "Deve ter pelo menos {n} caracteres"
},
"uppercase": {
"singular": "Deve ter pelo menos {n} caractere maiúsculo",
"plural": "Deve ter pelo menos {n} caracteres maiúsculos"
},
"lowercase": {
"singular": "Deve ter pelo menos {n} caractere minúsculo",
"plural": "Deve ter pelo menos {n} caracteres minúsculos"
},
"number": {
"singular": "Deve ter pelo menos {n} número",
"plural": "Deve ter pelo menos {n} números"
},
"special": {
"singular": "Deve ter pelo menos {n} caractere especial",
"plural": "Deve ter pelo menos {n} caracteres especiais"
}
}
}

129
lang/setup/de-de.json Normal file
View File

@@ -0,0 +1,129 @@
{
"meta": {
"name": "Deutsch (DE)"
},
"strings": {
"pageTitle": "Setup - jfa-go",
"next": "Weiter",
"back": "Zurück",
"optional": "Optional",
"serverType": "Servertyp",
"disabled": "Deaktiviert",
"enabled": "Aktiviert",
"port": "Port",
"message": "Nachricht",
"serverAddress": "Serveradresse",
"emailSubject": "E-Mail-Betreff",
"URL": "URL",
"apiKey": "API-Schlüssel"
},
"startPage": {
"welcome": "Willkommen!",
"pressStart": "Du musst einige Dinge tun, um jfa-go einzurichten. Drücke Start, um fortzufahren.",
"httpsNotice": "Stelle sicher, dass du über HTTPS oder in einem privaten Netzwerk auf diese Seite zugreifst.",
"start": "Start"
},
"endPage": {
"finished": "Fertig!",
"restartMessage": "Es gibt weitere Einstellungen, die du auf der Admin-Seite konfigurieren kannst. Drücke unten, um neu zu starten, dann aktualisiere die Seite.",
"refreshPage": "Aktualisieren"
},
"language": {
"title": "Sprache",
"description": "Gemeinschaftsübersetzungen sind für die meisten Teile von jfa-go verfügbar. Du kannst unten die Standardsprachen auswählen, aber Benutzer können dies immer noch ändern, wenn sie wollen. Wenn du helfen willst zu übersetzen, melde dich bei {n} an, um anzufangen, etwas beizutragen!",
"defaultAdminLang": "Standardsprache Admin",
"defaultFormLang": "Standardsprache Kontoerstellung",
"defaultEmailLang": "Standardsprache E-Mail"
},
"general": {
"title": "Allgemein",
"listenAddress": "Listening-Adresse",
"urlBase": "URL-Basis",
"urlBaseNotice": "Nur erforderlich, wenn ein Reverse-Proxy auf einer Subdomain verwendet wird (z. B. 'jellyf.in/accounts').",
"lightTheme": "Hell",
"darkTheme": "Dunkel",
"useHTTPS": "Verwende HTTPS",
"httpsPort": "HTTPS Port",
"useHTTPSNotice": "Nur empfohlen, wenn du keinen Reverse-Proxy verwendest.",
"pathToCertificate": "Pfad zum Zertifikat",
"pathToKeyFile": "Pfad zur Schlüsseldatei"
},
"login": {
"title": "Anmelden",
"description": "Um auf die Admin-Seite zuzugreifen, musst du dich mit einer untenstehenden Methode anmelden:",
"authorizeWithJellyfin": "Autorisiere mit Jellyfin/Emby: Anmeldedaten werden mit Jellyfin geteilt, was mehrere Benutzer ermöglicht.",
"authorizeManual": "Benutzername und Passwort: Lege Benutzername und Passwort manuell fest.",
"adminOnly": "Nur Admin-Benutzer (empfohlen)",
"emailNotice": "Eine E-Mail-Adresse kann verwendet werden, um Benachrichtigungen zu erhalten."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Ein Admin-Konto ist erforderlich, weil die API keine Benutzererstellung unter Verwendung eines API-Schlüssels erlaubt. Du solltest ein separates Konto erstellen und 'Dieser Benutzer kann den Server managen' aktivieren. Du kannst alles andere deaktivieren. Ist das erledigt, gib die Anmeldedaten hier ein.",
"embyNotice": "Emby-Unterstützung ist begrenzt und unterstützt keine Passwortzurücksetzung.",
"internal": "Intern",
"external": "Extern",
"replaceJellyfin": "Servername",
"replaceJellyfinNotice": "Wenn angegeben, ersetzt dies alle Vorkommen von 'Jellyfin' in der App.",
"addressExternalNotice": "Leer lassen, um die selbe Adresse zu verwenden.",
"testConnection": "Verbindung testen"
},
"ombi": {
"title": "Ombi",
"description": "Durch den Anschluss an Ombi wird sowohl ein Jellyfin-Konto als auch ein Ombi-Konto erstellt, wenn ein Benutzer durch jfa-go beitritt. Nachdem das Setup abgeschlossen ist, gehe zu den Einstellungen um ein Standardprofil für neue Ombi-Benutzer festzulegen.",
"apiKeyNotice": "Finde das in der ersten Registerkarte der Ombi-Einstellungen."
},
"email": {
"title": "E-Mail",
"description": "jfa-go kann Passwortzurücksetzung-PINs und verschiedene Benachrichtigungen per E-Mail senden. Du kannst eine Verbindung zu einem SMTP-Server herstellen, oder die {n} API verwenden.",
"method": "Versandmethode",
"useEmailAsUsername": "E-Mail-Adresse als Benutzernamen verwenden",
"useEmailAsUsernameNotice": "Wenn aktiviert, werden sich neue Benutzer bei Jellyfin/Emby mit ihrer E-Mail-Adresse anstatt eines Benutzernamens anmelden.",
"fromAddress": "Absenderadresse",
"senderName": "Absendername",
"dateFormat": "Datumsformat",
"dateFormatNotice": "Datum folgt dem strftime-Format. Für weitere Informationen, besuche {n}.",
"time24h": "24h-Format",
"time12h": "12h-Format",
"encryption": "Verschlüsselung",
"mailgunApiURL": "API-URL"
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Wenn aktiviert, kannst du (per Invite) wählen, eine E-Mail zu erhalten, wenn ein Invite abläuft oder ein Benutzer erstellt wird. Wenn du nicht die Jellyfin-Login-Methode gewählt hast, stelle sicher, dass du deine E-Mail-Adresse angegeben hast."
},
"welcomeEmails": {
"title": "Willkommens-E-Mails",
"description": "Wenn aktiviert, wird eine E-Mail an neue Benutzer gesendet mit der Jellyfin-/Emby-URL und ihrem Benutzername."
},
"inviteEmails": {
"title": "Einladungs-E-Mails",
"description": "Wenn aktiviert, kannst du Invites direkt an die E-Mail-Adresse eines Benutzers schicken. Weil du möglicherweise einen Reverse-Proxy verwendest, musst du die URL angeben, von welcher auf Invites zugegriffen wird. Schreibe deine URL-Basis, und füge '/invite' hinzu."
},
"passwordResets": {
"title": "Passwortzurücksetzungen",
"description": "Wenn ein Benutzer versucht sein Passwort zurückzusetzen, erstellt Jellyfin eine Datei namens 'passwordreset-*.json', welche eine PIN enthält. jfa-go liest die Datei und sendet die PIN an den Benutzer.",
"pathToJellyfin": "Pfad zum Jellyfin-Konfigurationsverzeichnis",
"pathToJellyfinNotice": "Wenn du nicht weißt, wo das ist, versuche dein Passwort in Jellyfin zurückzusetzen. Ein Popup mit '<Pfad zu Jellyfin>/passwordreset-*.json' wird angezeigt werden."
},
"passwordValidation": {
"title": "Passwortüberprüfung",
"description": "Wenn aktiviert, wird eine Reihe von Passwortanforderungen, wie z. B. Mindestlänge, Groß-/Kleinbuchstaben, usw., auf der Kontoerstellungsseite angezeigt.",
"length": "Länge",
"uppercase": "Großbuchstaben",
"lowercase": "Kleinbuchstaben",
"numbers": "Zahlen",
"special": "Sonderzeichen (%, *, usw.)"
},
"helpMessages": {
"title": "Hilfenachrichten",
"description": "Diese Nachrichten werden auf der Kontoerstellungsseite und in einigen E-Mails angezeigt.",
"contactMessage": "Kontaktnachricht",
"contactMessageNotice": "Wird am Ende jeder außer der Admin-Seite angezeigt.",
"helpMessage": "Hilfenachricht",
"helpMessageNotice": "Wird auf der Kontoerstellungsseite angezeigt.",
"successMessage": "Erfolgsnachricht",
"successMessageNotice": "Wird angezeigt, wenn ein Benutzer sein Konto erstellt.",
"emailMessage": "E-Mailnachricht",
"emailMessageNotice": "Wird am Ende von E-Mails angezeigt."
}
}

129
lang/setup/en-us.json Normal file
View File

@@ -0,0 +1,129 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"pageTitle": "Setup - jfa-go",
"next": "Next",
"back": "Back",
"optional": "Optional",
"serverType": "Server Type",
"disabled": "Disabled",
"enabled": "Enabled",
"port": "Port",
"message": "Message",
"serverAddress": "Server Address",
"emailSubject": "Email Subject",
"URL": "URL",
"apiKey": "API Key"
},
"startPage": {
"welcome": "Welcome!",
"pressStart": "You'll need to do a few things to set up jfa-go. Press start to get continue.",
"httpsNotice": "Make sure you're accessing this page via HTTPS or on a private network.",
"start": "Start"
},
"endPage": {
"finished": "Finished!",
"restartMessage": "There are more settings you can configure on the admin page. Click below to restart, then refresh the page.",
"refreshPage": "Refresh"
},
"language": {
"title": "Language",
"description": "Community translations are available for most parts of jfa-go. You can choose the default languages below, but users can still change it if they wish. If you want to help translate, sign up to {n} to start contributing!",
"defaultAdminLang": "Default admin language",
"defaultFormLang": "Default account creation language",
"defaultEmailLang": "Default email language"
},
"general": {
"title": "General",
"listenAddress": "Listen Address",
"urlBase": "URL Base",
"urlBaseNotice": "Only needed if using a reverse proxy on a subdomain (e.g 'jellyf.in/accounts').",
"lightTheme": "Light",
"darkTheme": "Dark",
"useHTTPS": "Use HTTPS",
"httpsPort": "HTTPS Port",
"useHTTPSNotice": "Only recommended if you aren't using a reverse proxy.",
"pathToCertificate": "Path to certificate",
"pathToKeyFile": "Path to key file"
},
"login": {
"title": "Login",
"description": "To access the admin page, you need to login with a method below:",
"authorizeWithJellyfin": "Authorize with Jellyfin/Emby: Login details are shared with Jellyfin, which allows for multiple users.",
"authorizeManual": "Username and Password: Manually set the username and password.",
"adminOnly": "Admin users only (recommended)",
"emailNotice": "Your email address can be used to receive notifications."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "An admin account is needed because the API does not allow user creation using an API key. You should create a separate account and check 'Allow this user to manage the server'. You can disable everything else. Once done, enter the login details here.",
"embyNotice": "Emby support is limited and does not support password resets.",
"internal": "Internal",
"external": "External",
"replaceJellyfin": "Server name",
"replaceJellyfinNotice": "If given, this will replace any occurrence of 'Jellyfin' in the app.",
"addressExternalNotice": "Leave blank to use the same address.",
"testConnection": "Test Connection"
},
"ombi": {
"title": "Ombi",
"description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup if finished, go to Settings to set a default profile for new ombi users.",
"apiKeyNotice": "Find this in the first tab of Ombi settings."
},
"email": {
"title": "Email",
"description": "jfa-go can send password reset PINs and various notifications through email. You can connect to an SMTP server, or use the {n} API.",
"method": "Sending method",
"useEmailAsUsername": "Use email addresses as username",
"useEmailAsUsernameNotice": "If enabled, new users will login to Jellyfin/Emby with their email address instead of a username.",
"fromAddress": "From Address",
"senderName": "Sender Name",
"dateFormat": "Date Format",
"dateFormatNotice": "Date follows the strftime format. For more info, visit {n}.",
"time24h": "24h Time",
"time12h": "12h Time",
"encryption": "Encryption",
"mailgunApiURL": "API URL"
},
"notifications": {
"title": "Notifications",
"description": "If enabled, you can choose (per invite) to receive an email when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address."
},
"welcomeEmails": {
"title": "Welcome emails",
"description": "If enabled, an email will be sent to new users with the Jellyfin/Emby URL and their username."
},
"inviteEmails": {
"title": "Invite Emails",
"description": "If enabled, you can send invites directly to a user's email address. Because you might be using a reverse proxy, you need to provide the URL invites are accessed from. Write your URL Base, and append '/invite'."
},
"passwordResets": {
"title": "Password Resets",
"description": "When a user tries to reset their password, Jellyfin creates a file named 'passwordreset-*.json' which contains a PIN. jfa-go reads the file and sends the PIN to the user.",
"pathToJellyfin": "Path to Jellyfin configuration directory",
"pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear."
},
"passwordValidation": {
"title": "Password Validation",
"description": "If enabled, a set of password requirements will show on the account creation page, such as minimum length, uppercase/lowercase characters, etc.",
"length": "Length",
"uppercase": "Uppercase characters",
"lowercase": "Lowercase characters",
"numbers": "Numbers",
"special": "Special characters (%, *, etc.)"
},
"helpMessages": {
"title": "Help Messages",
"description": "These messages will display in the account creation page and in some emails.",
"contactMessage": "Contact Message",
"contactMessageNotice": "Displays at the bottom of all pages except admin.",
"helpMessage": "Help Message",
"helpMessageNotice": "Displays on the account creation page.",
"successMessage": "Success Message",
"successMessageNotice": "Displays when a user creates their account.",
"emailMessage": "Email Message",
"emailMessageNotice": "Displays at the bottom of emails."
}
}

129
lang/setup/fr-fr.json Normal file
View File

@@ -0,0 +1,129 @@
{
"meta": {
"name": "Français (FR)"
},
"strings": {
"pageTitle": "Installation - jfa-go",
"next": "Suivant",
"back": "Retour",
"optional": "Optionnel",
"serverType": "Type de serveur",
"disabled": "Désactivé",
"enabled": "Activé",
"port": "Port",
"message": "Message",
"serverAddress": "Adresse du serveur",
"emailSubject": "Objet de l'e-mail",
"URL": "URL",
"apiKey": "Clé API"
},
"startPage": {
"welcome": "Bienvenue !",
"pressStart": "Vous devez effectuer quelques opérations pour installer jfa-go. Appuyez sur commencer pour continuer.",
"httpsNotice": "Assurez-vous que vous soyez connecté en HTTPS à cette page ou via réseau privé.",
"start": "Commencer"
},
"endPage": {
"finished": "Terminé !",
"restartMessage": "Il y a d'autres paramètres que vous pouvez configurer sur la page administrateur. Cliquez en dessous pour redémarrer, rafraichissez ensuite la page.",
"refreshPage": "Rafraichir"
},
"language": {
"title": "Langue",
"description": "Des traductions communautaires sont disponibles pour la plupart des parties de jfa-go. Vous pouvez choisir les langues par défaut ci-dessous, mais les utilisateurs peuvent toujours les modifier s'ils le souhaitent. Si vous souhaitez aider à traduire, inscrivez-vous sur {n} pour commencer à contribuer !",
"defaultAdminLang": "Langue administrateur par défaut",
"defaultFormLang": "Langue par défaut lors d'une création de compte",
"defaultEmailLang": "Langue par défaut des e-mails"
},
"general": {
"title": "Général",
"listenAddress": "Adresse d'écoute",
"urlBase": "Base URL",
"urlBaseNotice": "Nécessaire seulement si vous utilisez un reverse proxy sur un sous-domaine (ex : 'jellyf.in/accounts').",
"lightTheme": "Clair",
"darkTheme": "Sombre",
"useHTTPS": "Utiliser HTTPS",
"httpsPort": "Port HTTPS",
"useHTTPSNotice": "Recommandé seulement si vous n'utilisez pas de reverse proxy.",
"pathToCertificate": "Chemin du certificat",
"pathToKeyFile": "Chemin du fichier clé"
},
"login": {
"title": "S'identifier",
"description": "Pour accéder à la page d'administrateur, vous devez vous connecter avec la méthode ci-dessous :",
"authorizeWithJellyfin": "Autoriser avec Jellyfin/Emby : Les Informations de connexion sont partagées avec Jellyfin, ce qui permet plusieurs utilisateurs.",
"authorizeManual": "Utilisateur et Mot de passe : Renseignez manuellement le nom d'utilisateur et le mot de passe.",
"adminOnly": "Administrateurs seulement (recommandé)",
"emailNotice": "Votre adresse e-mail peut être utilisée pour recevoir des notifications."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Un compte administrateur est requis car l'API ne permet pas la création d'utilisateur. Vous devez créer un compte séparément et cocher 'Autoriser cet utilisateur à gérer le serveur'. Vous pouvez désactiver tout le reste. Une fois créé, renseignez les informations d'identification ici.",
"embyNotice": "Le support de Emby est limité et ne permet pas la réinitialisation de mot de passe.",
"internal": "Interne",
"external": "Externe",
"replaceJellyfin": "Nom du serveur",
"replaceJellyfinNotice": "Si renseigné, remplacera toutes les occurrences de 'Jellyfin' dans l'application.",
"addressExternalNotice": "Laissez vide pour utiliser la même adresse.",
"testConnection": "Test de connexion"
},
"ombi": {
"title": "Ombi",
"description": "En connectant Ombi, un compte Jellyfin et un compte Ombi seront créés lorsque l'utilisateur rejoindra via jfa-go. Après la fin de l'installation, allez dans les Paramètres pour définir un profil par défaut pour les nouveaux utilisateurs d'Ombi.",
"apiKeyNotice": "Trouvez ceci dans le premier onglet des paramètres de Ombi."
},
"email": {
"title": "E-mail",
"description": "jfa-go peut envoyer un code PIN de réinitialisation du mot de passe et plusieurs notifications par e-mail. Vous pouvez vous connecter à un serveur SMTP, ou utiliser l'API de {n}.",
"method": "Méthode d'envoi",
"useEmailAsUsername": "Utilisez l'adresse e-mail comme nom d'utilisateur",
"useEmailAsUsernameNotice": "Si activé, les nouveaux utilisateurs se connecteront à Jellyfin/Emby avec cette adresse e-mail au lieu du nom d'utilisateur.",
"fromAddress": "Depuis l'adresse",
"senderName": "Nom de l'envoyeur",
"dateFormat": "Format de la date",
"dateFormatNotice": "La date suis le format srtftime. Pour plus d'informations, consultez {n}.",
"time24h": "Temps 24h",
"time12h": "Temps 12h",
"encryption": "Chiffrement",
"mailgunApiURL": "URL de l'API"
},
"notifications": {
"title": "Notifications",
"description": "Si activé, vous pouvez choisir (par invitation) de recevoir un e-mail lorsque l'invitation expire, ou lorsque l'utilisateur est créé. Si vous ne choisissez pas la méthode de connexion par Jellyfin, assurez-vous d'avoir fourni votre adresse e-mail."
},
"welcomeEmails": {
"title": "E-mails de bienvenue",
"description": "Si activé, un e-mail sera envoyé aux nouveaux utilisateurs avec l'URL de Jellyfin/Emby et leur nom d'utilisateur."
},
"inviteEmails": {
"title": "E-mails d'invitation",
"description": "Si activé, vous pouvez envoyer une invitation directement à l'adresse e-mail de l'utilisateur. Parce que vous pourriez utiliser un reverse proxy, vous devez renseigner l'URL d'accès aux invitations. Renseignez votre Base URL et ajoutez '/invite'."
},
"passwordResets": {
"title": "Réinitialisation de mot de passe",
"description": "Lorsqu'un utilisateur essaie de réinitialiser son mot de passe, Jellyfin créé un fichier nommé 'passwordreset-*.json' qui contient le code PIN. jfa-go lit le fichier et envoie le code PIN à l'utilisateur.",
"pathToJellyfin": "Chemin du dossier de configuration de Jellyfin",
"pathToJellyfinNotice": "Si vous ne savez pas où c'est, essayez de réinitialiser votre mot de passe dans Jellyfin. Une popup avec '<path to jellyfin>/passwordreset-*.json' apparaitra."
},
"passwordValidation": {
"title": "Validation du mot de passe",
"description": "Si cette option est activée, un ensemble d'exigences de mot de passe s'affichera sur la page de création de compte, comme la longueur minimale, les majuscules/minuscules, etc.",
"length": "Longueur",
"uppercase": "Majuscules",
"lowercase": "Minuscules",
"numbers": "Nombres",
"special": "Caractères spéciaux (%, *, etc.)"
},
"helpMessages": {
"title": "Messages d'aide",
"description": "Ces messages s'afficheront dans la page de création de compte et dans certains e-mails.",
"contactMessage": "Message de contact",
"contactMessageNotice": "S'affiche au bas de toutes les pages sauf administrateur.",
"helpMessage": "Message d'aide",
"helpMessageNotice": "S'affiche sur la page de création de compte.",
"successMessage": "Message de réussite",
"successMessageNotice": "S'affiche lorsqu'un utilisateur crée son compte.",
"emailMessage": "Message de l'e-mail",
"emailMessageNotice": "S'affiche au bas des e-mails."
}
}

129
lang/setup/nl-nl.json Normal file
View File

@@ -0,0 +1,129 @@
{
"meta": {
"name": "Nederlands (NL)"
},
"strings": {
"pageTitle": "Insstallatie - jfa-go",
"next": "Volgende",
"back": "Terug",
"optional": "Optioneel",
"serverType": "Servertype",
"disabled": "Uitgeschakeld",
"enabled": "Ingeschakeld",
"port": "Poort",
"message": "Bericht",
"serverAddress": "Serveradres",
"emailSubject": "Onderwerp e-mail",
"URL": "URL",
"apiKey": "API-sleutel"
},
"startPage": {
"welcome": "Welkom!",
"pressStart": "Je moet een paar dingen doen om jfa-go te installeren. Druk op start om verder te gaan.",
"httpsNotice": "Zorg ervoor dat je deze pagina bezoekt via HTTPS of een privénetwerk.",
"start": "Start"
},
"endPage": {
"finished": "Klaar!",
"restartMessage": "Er staan meer instellingen op de adminpagina. Druk op de knop hieronder om opnieuw op te starten, en ververs daarna de pagina.",
"refreshPage": "Verversen"
},
"language": {
"title": "Taal",
"description": "Er zijn gemeenschapsvertalingen beschikbaar voor de meeste onderdelen van jfa-go. Je kunt hieronder de standaardtaal instellen, maar gebruikers kunnen die aanpassen indien gewenst. Als je wilt helpen met vertalen, meld je dan aan bij {n} en begin met vertalen!",
"defaultAdminLang": "Standaardtaal (admin)",
"defaultFormLang": "Standaardtaal aanmaken nieuwe account",
"defaultEmailLang": "Standaardtaal (e-mails)"
},
"general": {
"title": "Algemeen",
"listenAddress": "Adres",
"urlBase": "URL-basis",
"urlBaseNotice": "Alleen nodig bij gebruik van reverse proxy op een subdomein (bijv. 'jellyf.in/accounts').",
"lightTheme": "Licht",
"darkTheme": "Donker",
"useHTTPS": "Gebruik HTTPS",
"httpsPort": "HTTPS poort",
"useHTTPSNotice": "Alleen aanbevolen als je geen reverse proxy gebruikt.",
"pathToCertificate": "Pad naar certificaat",
"pathToKeyFile": "Pad naar sleutelbestand"
},
"login": {
"title": "Inloggen",
"description": "Om de admin-pagina te openen moet je inloggen met één van onderstaande methodes:",
"authorizeWithJellyfin": "Jellyfin/Emby authorisatie: Inloggegevens worden gedeeld met Jellyfin, meerdere gebruikers mogelijk.",
"authorizeManual": "Gebruikersnaam en wachtwoord: Stel handmatig een gebruikersnaam en wachtwoord in.",
"adminOnly": "Alleen admin-gebruikers (aanbevolen)",
"emailNotice": "Je e-mailadres kan gebruikt worden om meldingen te ontvangen."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Er is een admin-account nodig, omdat via de API geen gebruikers aangemaakt kunnen worden. Maak een aparte account aan en vink 'Deze gebruiker kan de server beheren' aan. Alle andere rechten kunnen uitgeschakeld worden. Vul daarna hier de inloggegevens in.",
"embyNotice": "Ondersteuning voor Emby is beperkt en ondersteunt geen wachtwoordresets.",
"internal": "Intern",
"external": "Extern",
"replaceJellyfin": "Servernaam",
"replaceJellyfinNotice": "Indien ingevuld wordt overal in de app het woord 'Jellyfin' hierdoor vervangen.",
"addressExternalNotice": "Laat leeg om hetzelfde adres te gebruiken.",
"testConnection": "Test verbinding"
},
"ombi": {
"title": "Ombi",
"description": "Bij verbinding met Ombi, worden voor nieuwe gebruikers via jfa-go zowel een Jellyfin als een Ombi account aangemaakt. Ga nadat de setup voltooid is naar instellingen om een standaardprofiel voor nieuwe Ombi-gebruikers te kiezen.",
"apiKeyNotice": "Te vinden op het eerste tabblad van de Ombi-instellingen."
},
"email": {
"title": "E-mail",
"description": "jfa-go kan wachtwoord reset PINs en meldingen via e-mail versturen. Je kunt verbinding maken met een SMTP server, of de {n} API gebruiken.",
"method": "Verstuurmethode",
"useEmailAsUsername": "Gebruik e-mailadres als gebruikersnaam",
"useEmailAsUsernameNotice": "Indien ingeschakeld wordt voor nieuwe Jellyfin/Emby-gebruikers hun e-mailadres in plaats van gebruikersnaam gebruikt.",
"fromAddress": "Afzenderadres",
"senderName": "Naam afzender",
"dateFormat": "Datumformaat",
"dateFormatNotice": "Datum volgend het strftime formaat. Meer info op {n}.",
"time24h": "24u-formaat",
"time12h": "12u-formaat",
"encryption": "Versleuteling",
"mailgunApiURL": "API-URL"
},
"notifications": {
"title": "Meldingen",
"description": "Indien ingeschakeld kun je er (per uitnodiging) voor kiezen om een e-mail te ontvangen wanneer een uitnodiging verloopt of er een gebruiker wordt aangemaakt. Als je niet inlogt via Jellyfin, controleer dan dat je je e-mailadres hebt ingevuld."
},
"welcomeEmails": {
"title": "Welkomste-mails",
"description": "Indien ingeschakeld wordt er een e-mail gestuurd aan nieuwe gebruikers met de Jellyfin/Emby-URL en hun gebruikersnaam."
},
"inviteEmails": {
"title": "Uitnodigingse-mails",
"description": "Indien ingeschakeld kun je uitnodigingen direct naar het e-mailadres van gebruikers sturen. Omdat je misschien een reverse proxy gebruikt moet je het URL opgeven waarlangs uitnodigingen geopend worden. Geef de URL-basis op, gevolgd door '/invite'."
},
"passwordResets": {
"title": "Wachtwoordresets",
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een PIN bevat. jfa-go leest dit bestand uit en stuurt de PIN naar de gebruiker.",
"pathToJellyfin": "Pad naar Jellyfin configuratiemap",
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'."
},
"passwordValidation": {
"title": "Wachtwoordvalidatie",
"description": "Indien ingeschakeld worden er op de account-aanmaakpagina een aantal wachtwoordvereisten getoond, zoals minimumlengte, hoofd-/kleine letters, enz.",
"length": "Lengte",
"uppercase": "Hoofdletters",
"lowercase": "Kleine letters",
"numbers": "Cijfers",
"special": "Bijzondere tekens (%, *, etc.)"
},
"helpMessages": {
"title": "Uitleg",
"description": "Deze teksten worden getoond bij de account-aanmaakpagina en in sommige e-mails.",
"contactMessage": "Contacttekst",
"contactMessageNotice": "Getoond onderaan elke pagina behalve admin.",
"helpMessage": "Helpbericht",
"helpMessageNotice": "Getoond op de account-aanmaakpagina.",
"successMessage": "Succesbericht",
"successMessageNotice": "Getoond wanneer een gebruiker een account aanmaakt.",
"emailMessage": "E-mailtext",
"emailMessageNotice": "Getoond onderaan e-mails."
}
}

129
lang/setup/pt-br.json Normal file
View File

@@ -0,0 +1,129 @@
{
"meta": {
"name": "Português (BR)"
},
"strings": {
"pageTitle": "Configuração - jfa-go",
"next": "Próximo",
"back": "Voltar",
"optional": "Opcional",
"serverType": "Tipo de Servidor",
"disabled": "Desativar",
"enabled": "Ativado",
"port": "Porta",
"message": "Mensagem",
"serverAddress": "Endereço do Servidor",
"emailSubject": "Assunto do Email",
"URL": "URL",
"apiKey": "Chave API"
},
"startPage": {
"welcome": "Bem Vindo!",
"pressStart": "Você precisará fazer algumas coisas para configurar o jfa-go. Clique em iniciar para progredir.",
"httpsNotice": "Certifique-se de que está acessando esta página via HTTPS ou em uma rede privada.",
"start": "Iniciar"
},
"endPage": {
"finished": "Finalizado!",
"restartMessage": "Existem mais opções que você pode definir na página do administrador. Clique abaixo para reiniciar e atualize a página.",
"refreshPage": "Atualizar"
},
"language": {
"title": "Idioma",
"description": "As traduções da comunidade estão disponíveis para a maior parte do jfa-go. Você pode escolher os idiomas padrão abaixo, mas os usuários ainda podem alterá-los caso desejem. Se você quiser ajudar na tradução, inscreva-se no {n} para contribuir!",
"defaultAdminLang": "Idioma padrão do administrador",
"defaultFormLang": "Idioma padrão para criação de conta",
"defaultEmailLang": "Idioma padrão para o email"
},
"general": {
"title": "Geral",
"listenAddress": "",
"urlBase": "URL Base",
"urlBaseNotice": "Necessário apenas se estiver usando um proxy reverso em um subdomínio (por exemplo, 'jellyf.in/accounts').",
"lightTheme": "Claro",
"darkTheme": "Escuro",
"useHTTPS": "Usar HTTPS",
"httpsPort": "Porta HTTPS",
"useHTTPSNotice": "Recomendado apenas se você não estiver usando um proxy reverso.",
"pathToCertificate": "Local do certificado",
"pathToKeyFile": "Local da chave do arquivo"
},
"login": {
"title": "Login",
"description": "Para acessar a página do administrador, você precisa fazer o login com o método abaixo:",
"authorizeWithJellyfin": "Autorizar com Jellyfin/Emby: Os detalhes de login são compartilhados com Jellyfin, o que permite vários usuários.",
"authorizeManual": "Nome de usuário e senha: Defina manualmente o nome de usuário e a senha.",
"adminOnly": "Apenas usuários administradores (recomendado)",
"emailNotice": "Seu endereço de email pode ser usado para receber notificações."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Uma conta de administrador é necessária porque a API não permite a criação de usuários usando uma chave de API. Você deve criar uma outra conta marcando a opção 'Permitir este usuário administrar o servidor'. Você pode desativar todas outras opções. Uma vez feito isso, insira os detalhes de login aqui.",
"embyNotice": "O suporte ao Emby é limitado e não tem suporte em redefinir senha.",
"internal": "Interno",
"external": "Externo",
"replaceJellyfin": "Nome do servidor",
"replaceJellyfinNotice": "Se fornecido, ele substituirá qualquer ocorrência de 'Jellyfin' no aplicativo.",
"addressExternalNotice": "Deixe em branco para usar o mesmo endereço.",
"testConnection": "Teste de Conexão"
},
"ombi": {
"title": "Ombi",
"description": "Ao se conectar ao Ombi, as contas no Jellyfin e Ombi serão criadas quando um usuário se cadastrar através do jfa-go. Depois de concluída a configuração, vá para Configurações para definir um perfil padrão para novos usuários de ombi.",
"apiKeyNotice": "Encontre isso na primeira guia nas configurações do Ombi."
},
"email": {
"title": "Email",
"description": "jfa-go pode enviar PINs de redefinição de senha e várias notificações por email. Você pode se conectar a um servidor SMTP ou usar a {n} API.",
"method": "Método de envio",
"useEmailAsUsername": "Usar endereços de email como nome de usuário",
"useEmailAsUsernameNotice": "Se habilitado, novos usuários farão login no Jellyfin/Emby com seu endereço de email em vez de um nome de usuário.",
"fromAddress": "A partir do endereço",
"senderName": "Nome do remetente",
"dateFormat": "Formato da Data",
"dateFormatNotice": "A data segue o formato strftime. Para obter mais informações, visite {n}.",
"time24h": "Horário 24h",
"time12h": "Horário 12h",
"encryption": "Encriptação",
"mailgunApiURL": "API URL"
},
"notifications": {
"title": "Notificações",
"description": "Se ativado, você pode escolher (por convite) receber um email quando um convite expirar ou um usuário for criado. Se você não escolheu o método de login Jellyfin, certifique-se de fornecer seu endereço de e-mail."
},
"welcomeEmails": {
"title": "Email de boas-vindas",
"description": "Se habilitado, um email será enviado para os novos usuários Jellyfin/Emby."
},
"inviteEmails": {
"title": "Convidar por Emails",
"description": "Se ativado, você pode enviar convites diretamente para o endereço de email do usuário. Como você pode estar usando um proxy reverso, é necessário fornecer a URL de onde os convites são acessados. Escreva a sua URL Base e acrescente '/invite'."
},
"passwordResets": {
"title": "Redefinir Senha",
"description": "Quando um usuário tenta redefinir sua senha, o Jellyfin cria um arquivo chamado 'passwordreset-*.json' que contém um PIN. jfa-go lê o arquivo e envia o PIN ao usuário.",
"pathToJellyfin": "Local do diretório de configuração do Jellyfin",
"pathToJellyfinNotice": "Se você não sabe o local onde fica, tente redefinir sua senha no Jellyfin. Um pop-up com '<path to jellyfin>/passwordreset-*.json' aparecerá."
},
"passwordValidation": {
"title": "Validar Senha",
"description": "Se ativado, o requisitos de senha será mostrado na página de criação da conta, como tamanho mínimo, caracteres maiúsculos/minúsculos, etc.",
"length": "Tamanho",
"uppercase": "Caracteres maiúsculos",
"lowercase": "Caracteres minúsculos",
"numbers": "Números",
"special": "Caracteres Especial (%, *, etc.)"
},
"helpMessages": {
"title": "Mensagem de Ajuda",
"description": "Essas mensagens serão exibidas na página de criação de conta e em alguns emails.",
"contactMessage": "Mensagem de Contato",
"contactMessageNotice": "Exibido na parte inferior de todas as páginas, exceto na administradora.",
"helpMessage": "Mensagem de Ajuda",
"helpMessageNotice": "Exibido na página de criação de conta.",
"successMessage": "Mensagem de Sucesso",
"successMessageNotice": "Exibido quando um usuário cria sua conta.",
"emailMessage": "Mensagem de Email",
"emailMessageNotice": "Exibido na parte inferior dos emails."
}
}

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
Your Jellyfin account was deleted.
Reason: {{ .reason }}
{{ .yourAccountWasDeleted }}
{{ .reason }}: {{ .reasonVal }}
{{ .message }}

View File

@@ -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"><mj-raw>{{ .pinVal }}</mj-raw></mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
Invite expired.
{{ .inviteExpired }}
Code {{ .code }} expired at {{ .expiry }}.
{{ .expiredAt }}
Note: Notification emails can be toggled on the admin dashboard.
{{ .notificationNotice }}

View File

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

View File

@@ -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">

View File

@@ -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 }}

38
mail/welcome.mjml Normal file
View File

@@ -0,0 +1,38 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .welcome }}</h3>
<p>{{ .youCanLoginWith }}:</p>
{{ .jellyfinURL }}: <a href="{{ .jellyfinURLVal }}">{{ .jellyfinURLVal }}</a>
<p>{{ .username }}: <i>{{ .usernameVal }}</i></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">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

9
mail/welcome.txt Normal file
View File

@@ -0,0 +1,9 @@
{{ .welcome }}
{{ .youCanLoginWith }}:
{{ .jellyfinURL }}: {{ .jellyfinURLVal }}
{{ .username }}: {{ .usernameVal }}
{{ .message }}

266
main.go
View File

@@ -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)
@@ -332,9 +329,23 @@ func start(asDaemon, firstCall bool) {
}()
}
app.storage.lang.CommonPath = filepath.Join(app.localPath, "lang", "common")
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)
}
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 +364,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 +383,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 +412,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 +445,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 +472,64 @@ 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)
}
}
}
// 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,15 +552,20 @@ 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 {
debugMode = false
address = "0.0.0.0:8056"
app.storage.lang.SetupPath = filepath.Join(app.localPath, "lang", "setup")
err := app.storage.loadLangSetup()
if err != nil {
app.info.Fatalf("Failed to load language files: %+v\n", err)
}
}
app.info.Println("Loading routes")
if debugMode {
@@ -502,19 +578,23 @@ 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 {
app.debug.Println("Loading pprof")
pprof.Register(router)
}
router.GET("/lang/:page", app.GetLanguages)
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("/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"))
@@ -545,9 +625,7 @@ func start(asDaemon, firstCall bool) {
}
app.info.Printf("Starting router @ %s", address)
} else {
router.GET("/", func(gc *gin.Context) {
gc.HTML(200, "setup.html", gin.H{})
})
router.GET("/", app.ServeSetup)
router.POST("/jellyfin/test", app.TestJF)
router.POST("/config", app.ModifyConfig)
app.info.Printf("Loading setup @ %s", address)
@@ -558,8 +636,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
View 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
View 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
View 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
}

View 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)
}

View File

@@ -17,6 +17,12 @@ type newUserDTO struct {
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
}
type newUserResponse struct {
User bool `json:"user" binding:"required"` // Whether user was created successfully
Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled
Error string `json:"error"` // Optional error message.
}
type deleteUserDTO struct {
Users []string `json:"users" binding:"required"` // List of usernames to delete
Notify bool `json:"notify"` // Whether to notify users of deletion
@@ -32,6 +38,7 @@ type generateInviteDTO struct {
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
Profile string `json:"profile" example:"DefaultProfile"` // Name of profile to apply on this invite
Label string `json:"label" example:"For Friends"` // Optional label for the invite
}
type inviteProfileDTO struct {
@@ -61,18 +68,19 @@ type newProfileDTO struct {
}
type inviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
Created string `json:"created" example:"01/01/20 12:00"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
Created string `json:"created" example:"01/01/20 12:00"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite
}
type getInvitesDTO struct {
@@ -127,3 +135,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

File diff suppressed because it is too large Load Diff

View File

@@ -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": {}
}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More