Compare commits

...

105 Commits

Author SHA1 Message Date
Harvey Tindall
d72a5c91cf jf_activity_test: reduce time to run
values were reasonable when testing with a size limit of 20 or
something, now it's ~6000.
2025-12-20 11:58:56 +00:00
Harvey Tindall
cacd992aad jf_activity: remove debug line
also bump mediabrowser again to do the same.
2025-12-20 11:49:55 +00:00
Harvey Tindall
23ad016476 mediabrowser: bump to v0.3.34
forgot to switch back from my local copy in the previous commit.
2025-12-20 11:41:35 +00:00
Harvey Tindall
cc571409b9 Merge branch 'jf-log'
accidentally did a fix on the wrong branch, so i'm merging it
prematurely.
2025-12-20 11:23:18 +00:00
Harvey Tindall
d7bad69d40 jf-actvitity: functioning route, ombi fixes
forgot to switch branches before doing a fix for #455, so it's in here
too. OmbiUserByJfID/getOmbiUser takes an optional email *string, to
optionally pass an override email address to search with, used when
changing it.
2025-12-20 11:21:13 +00:00
Harvey Tindall
63bb678a72 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-19 13:12:27 +00:00
Harvey Tindall
2a41c5a393 admin: scroll current tab in nav to center
for #456, ensures you can see and press both the next and previous tabs
    even if you can't scroll for some reason.
2025-12-19 13:11:37 +00:00
Harvey Tindall
9b80492b4f userpage: fix display of email confirmation required message
recent adjustments to modals and CSS in general meant a "content" class
was removed that was being querySelector'ed. For #455.
2025-12-16 18:37:54 +00:00
Harvey Tindall
315d74eb2d userpage: fix redirect on contact method change
root userpage paths ("") would mess up the redirect, wrapper function
returns "/" if thats the case. For #455.
2025-12-16 18:32:37 +00:00
Harvey Tindall
c21df253a1 jf-activity: initial changes
functionality mostly there but needs a UI.
2025-12-16 18:15:39 +00:00
Harvey Tindall
e4e9369d54 theme: set theme-color for ios devices
colours the search bar, sometimes.
2025-12-11 14:47:22 +00:00
Harvey Tindall
26d05911fd ui: fix hidden overflow on mobile devices
mostly the accounts table was causing an overflow on mobile browsers,
specifically anything on ios and samsung browser. For #450.
2025-12-10 20:37:29 +00:00
Harvey Tindall
85a6d66228 html: fix headers
go's html/template was thinking header.html and other template snippets
were full documents, so inserting their own <html> and other tags,
breaking the head up. Renaming to txt seems to fix things.
2025-12-10 20:31:45 +00:00
Harvey Tindall
1c755306f9 tray: fix broken dependency. 2025-12-10 17:43:00 +00:00
Harvey Tindall
2e97142d9e accounts: reduce initial load time even further
took the referralCache idea further and did it for all db queries. Now
take ~40ms for ~5000 users on arm64 through QEMU, and ~60ms on a
rockpro64.
2025-12-10 17:39:54 +00:00
Harvey Tindall
a08a0fd3e6 accounts: reduce initial load time further
When generating the cache and calling userSummary per user, previously
the DB was queried for an invite with the ReferrerJellyfinID set to the
user's ID. This was fast enough on my test system (~5000 users in
~1.5s), while testing cross-compilation, I found it ran extremely slow
on an arm64 build (running through QEMU admittedly), doing ~5000 in
~18s. Instead, a map of IDs to invites/referrals is generated once and
queried instead. Initial load now takes ~80ms on my system, and 0.95s
through QEMU, and 0.68s on a rockpro64 SBC.
2025-12-10 16:44:21 +00:00
Harvey Tindall
420a22970d goreleaser: unset GOOS/GOARCH for precompile step
these should be run natively.
2025-12-10 15:05:30 +00:00
Harvey Tindall
d4109c8cf5 matrix: use goolm over libolm, ci: user smaller docker image base
removes one dependency. jfa-go-build-docker has been updated to reflect
this, and in general with a newer debian version and properly included
goreleaser, and a build for amd64. Dockerfiles now use a
"distroless"-style container as their base. Was gonna use
chainguard/glibc-dynamic, but it running as a different user meant it
wouldn't read/write from your /data mount without manual intervention.
2025-12-10 13:25:21 +00:00
Harvey Tindall
945d579f57 config: un-advanced date format, fix dependencies
changed dependency on "method" (from when it was in the email section)
to "enabled". For #453.
2025-12-10 09:51:12 +00:00
Harvey Tindall
6e1f07563d ci: use caches for container build too 2025-12-09 17:53:26 +00:00
Harvey Tindall
ce6f8b41dc ci: use npm cache too 2025-12-09 17:43:04 +00:00
Harvey Tindall
aa75305da4 scripts/version.sh: downgrade version
use go 1.24 and remove usage of wg.Go().
2025-12-09 17:42:45 +00:00
Harvey Tindall
03cf533c9d ci: use podman volumes for go mod/build cache 2025-12-09 17:38:12 +00:00
Harvey Tindall
ccf0584db8 build: use tsgo for type checking
I don't use and fancy typescript features, and apparently this version
is very usable at this point. Removes the typescript dependency, which
should have been in dev-deps anyway.
2025-12-09 17:31:23 +00:00
Harvey Tindall
833fd26091 scripts/variants: add parallel flag
doesn't result in much gain on my machine or my CI server so it's not
the default. On local machine (5800x), went from ~5ms to ~4ms, on my CI
(hetzner ampere altra with 8 vCPUs) went from ~15ms to ~10ms.
2025-12-09 15:44:47 +00:00
Harvey Tindall
fd72c838c3 userpage: don't trigger submit on "reset password" press
Fiex #452.
2025-12-09 15:25:54 +00:00
Harvey Tindall
63c770db73 css: fix lists with RTL
changes text-align:left to start, and margin-left: to
margin-inline-start:. Submitted a PR to a17t to change these values as
well. For #450.
2025-12-09 15:06:49 +00:00
Harvey Tindall
6237620390 scripts/variants: add verbose -v flag
disable output by default, enable with -v
2025-12-09 14:38:48 +00:00
Harvey Tindall
4a9bac1027 scripts: replace dark-variant.sh with go scripts/variants
uses regex mostly, resulting in a significantly faster execution (old:
~2s, new: ~31ms). Only matches classList.add/remove and class="..."
(won't touch string literal variables or colours), but these weren't
really used anyway. Supports multi-line classList.add/remove, which was
the reason I wrote this anyway (formatting the codebase introduced some
of these).
2025-12-09 14:28:05 +00:00
Harvey Tindall
817107622a ts: format finally
formatted with biome, a config file is provided.
2025-12-08 20:38:30 +00:00
Harvey Tindall
ca7c553147 telegram: add "always use default lang", /lang default
For #451. Setting in integrations > chat bots > telegram makes jfa-go
ignore the telegram users clients lanugage setting and always use
whatever is set as the default language in jfa-go. Also added /lang
default, to un-set a preferred language. the start message also now
shows up on typing /help or !help too.
2025-12-08 18:16:27 +00:00
Harvey Tindall
d00507fd20 accounts: fix matrix input
was too small to see what you were typing.
2025-12-08 17:50:50 +00:00
Harvey Tindall
2bbf97be19 accounts/activity: fix clear search button on RTL
use margin-inline-start instead of margin-left. For #450.
2025-12-08 17:46:11 +00:00
Harvey Tindall
4f135220bc lang: display ckb (kurdish) as RTL 2025-12-08 15:36:03 +00:00
Harvey Tindall
a2500add5a lang: add lang tag to name of Farsi and Kurdish 2025-12-08 15:35:12 +00:00
Harvey Tindall
26284e89f6 web: add more RTL fixes
For #450.
2025-12-08 15:30:24 +00:00
KSAm3lm
8030a7c896 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
5010159621 Translated using Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
92090bcf99 translation from Weblate (Arabic)
Currently translated at 100.0% (304 of 304 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
655096ea1e Translated using Weblate (Arabic)
Currently translated at 100.0% (56 of 56 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-08 16:13:58 +01:00
Harvey Tindall
362984a391 web: remove almost every use of ml/mr
replace with flex and gap mostly. For #450.
2025-12-08 15:12:40 +00:00
Harvey Tindall
9c9e55147d lang: don't show lanugages with empty name fields 2025-12-08 14:38:24 +00:00
Harvey Tindall
f7a72133c6 lang: lowercase names 2025-12-08 14:36:01 +00:00
Harvey Tindall
341dd5930e settings: force LTR always
since it's untranslatable for now.
2025-12-07 20:43:14 +00:00
Harvey Tindall
5baa3b98cd tooltip: add .force-ltr class 2025-12-07 20:43:01 +00:00
Harvey Tindall
99c6559a54 ui: set RTL on some languages
checking with a list I found online. need to set some things as forced
ltr, such as settings. For #450.
2025-12-07 20:38:43 +00:00
Harvey Tindall
12827f6c84 admin: remove all non-dynamic ml/mr usage
replaced with flex gaps. For #450.
2025-12-07 20:28:05 +00:00
Harvey Tindall
ad942fd194 tooltip: flip on rtl 2025-12-07 19:50:15 +00:00
Harvey Tindall
b5e348ad2b ui: correct html lang tag
set according to the language picked.
2025-12-07 19:31:52 +00:00
KSAm3lm
0b8376a19e translation from Weblate (Arabic)
Currently translated at 48.6% (139 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
b64e2ac9a5 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
c9e2e72d60 translation from Weblate (Arabic)
Currently translated at 40.5% (116 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
fc990a9ac2 translation from Weblate (Arabic)
Currently translated at 34.9% (100 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
8601fbf8e0 translation from Weblate (Arabic)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
48b547661f translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
423b060857 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
48cf1a7e39 translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
47954db751 Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
1fe868b41e translation from Weblate (Arabic)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
07f683282a Translated using Weblate (Arabic)
Currently translated at 61.0% (83 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
0a98eea1ac translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
40d86214cb Translated using Weblate (Arabic)
Currently translated at 59.5% (81 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
959b10c2a1 Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
f11fc2e3bc Translated using Weblate (Arabic)
Currently translated at 13.2% (18 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
2e2fcb0beb translation from Weblate (Arabic)
Currently translated at 26.2% (75 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
d2d90083be Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
355ffd5975 translation from Weblate (Arabic)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
0010ece89c Translated using Weblate (Arabic)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
c8db639701 Translated using Weblate (Arabic)
Currently translated at 11.7% (16 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
3bb499dc9d Translated using Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
45d83435cb translation from Weblate (Arabic)
Currently translated at 25.8% (74 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
2535aba2fe Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
3dc039135a Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
726f90bfae Translated using Weblate (Arabic)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2025-12-07 20:07:14 +01:00
Harvey Tindall
709c394d2d invites: use string getting func on CopyButton
should fix #449.
2025-12-07 11:40:16 +00:00
Harvey Tindall
fb5ea43ef5 CopyButton: add string getting func option 2025-12-07 11:38:46 +00:00
Harvey Tindall
49f2026c47 npm: bump esbuild 2025-12-06 20:25:06 +00:00
Harvey Tindall
5ad225fa78 Merge pull request #395 from hrfee/dependabot/npm_and_yarn/cross-spawn-7.0.6
build(deps): bump cross-spawn from 7.0.3 to 7.0.6
2025-12-06 20:21:25 +00:00
Harvey Tindall
155edc997a Merge branch 'main' into dependabot/npm_and_yarn/cross-spawn-7.0.6 2025-12-06 20:20:34 +00:00
Harvey Tindall
591547c167 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-06 20:19:13 +00:00
Harvey Tindall
14e8e63f30 easyproxy: update deps
closes #398.
2025-12-06 20:18:50 +00:00
Harvey Tindall
ccba0e5137 Merge pull request #434 from hrfee/dependabot/npm_and_yarn/glob-10.5.0
build(deps): bump glob from 10.4.5 to 10.5.0
2025-12-06 20:16:50 +00:00
Harvey Tindall
a62e6a5dfc Merge pull request #435 from hrfee/dependabot/npm_and_yarn/site/glob-10.5.0
build(deps): bump glob from 10.4.5 to 10.5.0 in /site
2025-12-06 20:16:37 +00:00
Harvey Tindall
975c47a79b settings: fix showing advanced ones
fixes #448.
2025-12-06 20:14:44 +00:00
Harvey Tindall
fcfd5f4981 query unescape more routes
fixes #447. Went through all routes with components in the path to check
if they needed escaping, quite a few did.
2025-12-06 20:04:30 +00:00
Harvey Tindall
e5315095be invites: improve dropdown animation
involves negative margins and removing padding resulting in a messy
expand function, but it looks smooth. Could also be done on the settings
page, but it looks mostly alright there already.
2025-12-06 17:43:54 +00:00
Harvey Tindall
44e4b5fce2 invites: editable label, /invites/edit route
PATCH /invite/edit lets you edit an invite by giving new values for a
subset of inviteDTO (EditableInviteDTO). Replaces /invite/profile and
/invite/notify, and allows changing (user)label and user expiry as well
as the previously customizable values through other routes. An edit
button next to the code/label allows changing on the invites tab.
2025-12-06 15:38:06 +00:00
Harvey Tindall
4bb116417e messages: fix black page background when editing
changed the "body, .body {" to ".body" in the mail CSS so it doesn't
affect the web page. Seems to look fine still in gmail in both light and
dark modes. Fixes #446
2025-12-06 14:05:12 +00:00
Harvey Tindall
51f604d061 ivnites: use actual inviteDTO for DOMInvite
no intermediary parsing step. Also, moved the date -> duration (3mo6d3h
sorta thing) to the web, there's now a ValidTill field with a unix
timestamp. Used the new Temporal api with a polyfill. Bumped api version
(although it still isn't semver).
2025-12-06 14:01:06 +00:00
Harvey Tindall
ab7694b50b messages: improve editor CSS
improved wrapping behaviour.
2025-12-05 20:46:42 +00:00
Harvey Tindall
556e1411f4 invites: add "send to" to dropdown
allows sending to more people after creating the invite. Fixes #444.
2025-12-05 19:51:42 +00:00
Harvey Tindall
5fa528fd2d invites: add /invites/send, store more details in new SentTo field
deprecated SendTo string field for SentTo, which holds successful send
addresses, and failures with a reason that isn't plain text. Will soon
add an interface for sending invites after their creation. For #444
(ha).
2025-12-05 12:03:21 +00:00
Harvey Tindall
3635a13682 fix opening create profile when jellyseerr disabled
fixes #445.
2025-12-04 18:19:33 +00:00
Harvey Tindall
b59cd73e43 messages: switch to new template package
rewrote and put in its own repo. Now supports {if a ==/!= b}, string
literals ({if a == "b"}), else/else if and nested if statements.
2025-12-04 11:50:14 +00:00
Harvey Tindall
6e31a7e2dd form: add pre-signup card
same as the post-signup card, but shown on the sidebar.
2025-12-02 14:56:15 +00:00
Harvey Tindall
a4b94b4f45 tray: switch to fork, use higher res icon
switched to a fork of a fork of getlantern/systray which has no
ayatana-appindicator dependency.
2025-12-02 13:05:06 +00:00
Harvey Tindall
622de21dcf ci: move buildrone upload back to before container build 2025-12-02 11:46:37 +00:00
Harvey Tindall
0ab5bd1705 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-02 00:41:23 +00:00
Harvey Tindall
f74e85662a email: add migration for pwr email file rename
sorry this was broken for a while for old users.
2025-12-02 00:40:48 +00:00
Harvey Tindall
862217a627 ci: use python3, not python 2025-12-01 17:37:28 +00:00
Harvey Tindall
861e72b331 ci: finalize merged ci files
for merging. renamed to unstable and stable, removed the -docker files,
    and modified the stable one too.
2025-12-01 16:31:13 +00:00
Harvey Tindall
50455b828d housekeeping/discord: respect remove role on disable setting
only remove it if it's set to true.
2025-11-30 21:59:46 +00:00
dependabot[bot]
34f5455ba5 build(deps): bump glob from 10.4.5 to 10.5.0 in /site
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 17:42:14 +00:00
dependabot[bot]
1caf9b3c88 build(deps): bump glob from 10.4.5 to 10.5.0
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 02:33:43 +00:00
dependabot[bot]
763d231e64 build(deps): bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-15 14:52:27 +00:00
135 changed files with 7492 additions and 4591 deletions

1
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1 @@
817107622a8fe6f2fdaf198da4b2632854aa9bac

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ scripts/langmover/out
tinyproxy.conf
static/banner.svg
start.sh
ts/*.tsbuildinfo
ts/**/*.tsbuildinfo
js/

View File

@@ -8,8 +8,8 @@ release:
name_template: "v{{.Version}}"
before:
hooks:
- npm i
- make precompile
- npm ci
- env GOOS= GOARCH= make precompile
builds:
- id: notray
dir: ./
@@ -34,7 +34,7 @@ builds:
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
- GOARM={{ if eq .Arch "arm" }}7{{ end }}
flags:
- -tags=e2ee,{{ .Env.JFA_GO_TAG }}
- -tags=e2ee,goolm,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
@@ -65,7 +65,7 @@ builds:
- CXX=x86_64-linux-gnu-gcc
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
flags:
- -tags=tray,e2ee,{{ .Env.JFA_GO_TAG }}
- -tags=tray,e2ee,goolm,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
@@ -177,13 +177,10 @@ nfpms:
replaces:
- jfa-go
dependencies:
- libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- libappindicator-gtk3
- libolm
apk:
dependencies:
- libayatana-appindicator
- olm

View File

@@ -1,97 +0,0 @@
when:
- event: push
branch: ci-streamline
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- npm i
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
# - name: redoc
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# REDOC_SSH_ID:
# from_secret: REDOC_SSH_ID
# commands:
# - sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
# - bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
# - name: deb-repo
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# REPO_SSH_ID:
# from_secret: REPO_SSH_ID
# commands:
# - sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
# - bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
# - name: buildrone
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# BUILDRONE_KEY:
# from_secret: BUILDRONE_KEY
# commands:
# - wget https://builds.hrfee.pw/upload.py
# - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- name: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh ./goreleaser build --snapshot --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: debug-ci-streamline
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY

View File

@@ -1,37 +0,0 @@
when:
- event: push
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: unstable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true

View File

@@ -1,37 +0,0 @@
when:
- event: tag
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: latest
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true

View File

@@ -17,6 +17,10 @@ steps:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- npm i
- make precompile
@@ -27,6 +31,10 @@ steps:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- make test
- name: build
@@ -36,10 +44,12 @@ steps:
from_secret: BUILT_BY
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- curl -sfL https://goreleaser.com/static/run > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
- ./scripts/version.sh goreleaser
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
@@ -51,6 +61,35 @@ steps:
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: stable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
@@ -59,3 +98,4 @@ steps:
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true

119
.woodpecker/unstable.yaml Normal file
View File

@@ -0,0 +1,119 @@
when:
- event: push
branch: main
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- npm ci --cache /npm --prefer-offline
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- ./scripts/version.sh goreleaser --snapshot --skip=publish --clean
- name: buildrone-binary
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- name: redoc
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REDOC_SSH_ID:
from_secret: REDOC_SSH_ID
commands:
- sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
- bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --snapshot --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: unstable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone-container
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true

View File

@@ -1,26 +1,20 @@
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
# FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
# FROM --platform=$BUILDPLATFORM jfa-go-bd AS support
ARG BUILT_BY
ENV JFA_GO_BUILT_BY=$BUILT_BY
COPY . /opt/build
# RUN curl -sfL https://goreleaser.com/static/run > /goreleaser && chmod +x /goreleaser
RUN cd /opt/build; INTERNAL=off UPDATER=docker ./scripts/version.sh /goreleaser build --snapshot --skip=validate --clean --id notray-e2ee
RUN cd /opt/build; INTERNAL=off UPDATER=docker ./scripts/version.sh goreleaser build --snapshot --skip=validate --clean --id notray-e2ee
RUN mv /opt/build/dist/*_linux_arm_6 /opt/build/dist/placeholder_linux_arm
RUN sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM golang:bookworm AS final
FROM gcr.io/distroless/base:latest AS final
ARG TARGETARCH
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /opt/jfa-go
COPY --from=support /opt/build/build/data /opt/jfa-go/data
RUN apt-get update -y && apt-get install libolm-dev -y
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /jfa-go
COPY --from=support /opt/build/build/data /jfa-go/data
EXPOSE 8056
EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]

View File

@@ -1,12 +1,10 @@
FROM golang:bookworm AS final
FROM gcr.io/distroless/base:latest AS final
ARG TARGETARCH
COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /opt/jfa-go
COPY ./build/data /opt/jfa-go/data
RUN apt-get update -y && apt-get install libolm-dev -y
COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /jfa-go
COPY ./build/data /jfa-go/data
EXPOSE 8056
EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]

View File

@@ -1,6 +1,8 @@
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all
TS ?= npx tsgo
GOESBUILD ?= off
ifeq ($(GOESBUILD), on)
ESBUILD := esbuild
@@ -46,7 +48,7 @@ ifeq ($(TRAY), on)
endif
ifeq ($(E2EE), on)
TAGS := $(TAGS) e2ee
TAGS := $(TAGS) e2ee goolm
endif
TAGS := $(TAGS)"
@@ -60,7 +62,7 @@ DEBUG ?= off
ifeq ($(DEBUG), on)
SOURCEMAP := --sourcemap
MINIFY :=
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
TYPECHECK := $(TS) -noEmit --incremental --project ts/tsconfig.json
# jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
UNCSS := cp $(CSS_BUNDLE) $(DATA)/bundle.css
@@ -123,7 +125,7 @@ $(DATA):
$(CONFIG_DEFAULT): $(CONFIG_BASE)
$(info Generating config-default.ini)
go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
CGO_ENABLED=0 go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
configuration: $(CONFIG_DEFAULT)
@@ -142,11 +144,14 @@ TYPESCRIPT_TEMPSRC = $(TYPESCRIPT_SRC:ts/%=tempts/%)
TYPESCRIPT_TARGET = $(DATA)/web/js/admin.js
$(TYPESCRIPT_TARGET): $(TYPESCRIPT_FULLSRC) ts/tsconfig.json
$(TYPECHECK)
# rm -rf tempts
# cp -r ts tempts
rm -rf tempts
cp -r ts tempts
mkdir -p tempts
$(adding dark variants to typescript)
scripts/dark-variant.sh tempts
scripts/dark-variant.sh tempts/modules
# scripts/dark-variant.sh tempts
# scripts/dark-variant.sh tempts/modules
CGO_ENABLED=0 go run scripts/variants/main.go -dir ts -out tempts
$(info compiling typescript)
$(foreach tempsrc,$(TYPESCRIPT_TEMPSRC),$(ESBUILD) --target=es6 --bundle $(tempsrc) $(SOURCEMAP) --outfile=$(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(tempsrc))) $(MINIFY);)
$(COPYTS)
@@ -157,7 +162,7 @@ $(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL)
swag init --parseDependency --parseInternal -g main.go
VARIANTS_SRC = $(wildcard html/*.html)
VARIANTS_SRC = $(wildcard html/*.html) $(wildcard html/*.txt)
VARIANTS_TARGET = $(DATA)/html/admin.html
$(VARIANTS_TARGET): $(VARIANTS_SRC)
$(info copying html)
@@ -179,7 +184,7 @@ CSS_FULLTARGET = $(CSS_BUNDLE)
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC)
ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) $(wildcard html.*.txt)
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET)
@@ -211,7 +216,7 @@ COPY_TARGET = $(DATA)/jfa-go.service
# $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css
$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
$(info copying $(CONFIG_BASE))
go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
CGO_ENABLED=0 go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
$(info copying crash page)
cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data)
@@ -243,7 +248,7 @@ $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) mod download
$(info Building)
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) $(GOBUILDFLAGS)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1

View File

@@ -13,39 +13,28 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatibility in the future, unless any big changes occur.
#### Alternatives
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
If you want a bit more guarantee of support [Wizarr](https://github.com/Wizarrrr/wizarr) is popular and seems very polished. It supports multiple media servers, lots of customization and invitation through Discord.
* [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration.
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr which can manage users and mainly acts as an Ombi alternative.
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make ones instance much easier to manage.
#### Features
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email, discord, telegram or matrix
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**.
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Can also be done through the "My Account" page if enabled.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* **Invites**: Send invite links to new users so they can sign up without relying on you.
* Customize with profiles: Apply Jellyfin settings (library access, transcoding, etc.) on sign-up, with different profiles for each user type.
* Limit invites by time or number of uses, enforce strong passwords, require a CAPTCHA, and more
* **Password Resets**: Let your users do it themselves. Works with the Jellyfin "Forgot Password" feature, or through the "My Account" page. [See the wiki for your options](https://wiki.jfa-go.com/docs/pwr/).
* **Contact your users**: Collect email address, Discord/Telegram/Matrix info when the user signs up or add later, and jfa-go will contact them when needed (e.g. on/before account expiry, disabling/enabling, deletion) or when you wish with Markdown announcements.
* "Confirm email" optional, similar is required for Discord/Telegram/Matrix
* **"My Account"**: Lets your users change their password or email/contact info themselves and show them relevant info on a special page. Also,
* Referrals: Allow users a special, limited invite to give to their friends/family.
* **Advanced user management**: See all of your users at once and manage them in bulk (enable/disable/delete, send markdown announcements, apply profiles/settings, and more)
* User expiry: Set on an invite, and any new users will be valid for a fixed period (e.g. 30 days). After time passes, account is disabled, deleted, or disabled then deleted.
* **Ombi/Jellyseerr integration**: Sync username/passwords & contact details between your services.
* **Customizable**: Edit messages sent to users and shown on invites, "My Account" page and more with full Markdown support.
#### Interface
<p align="center">
@@ -57,7 +46,7 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
#### Install
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer.
`MatrixE2EE` builds (and Linux `TrayIcon` builds) include support for end-to-end encryption for the Matrix bot, but require the `libolm(-dev)` dependency. `.deb/.rpm/.apk` packages list this dependency, and docker images include it.

View File

@@ -99,7 +99,7 @@ func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerho
for _, q := range req.Queries {
nq := q.AsDBQuery(query)
if nq == nil {
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
nq = ActivityDBQueryFromSpecialField(app.jf.MediaBrowser, query, q)
}
query = nq
}
@@ -156,8 +156,8 @@ func (app *appContext) GetActivities(gc *gin.Context) {
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
Username: act.MustGetUsername(app.jf),
SourceUsername: act.MustGetSourceUsername(app.jf),
Username: act.MustGetUsername(app.jf.MediaBrowser),
SourceUsername: act.MustGetSourceUsername(app.jf.MediaBrowser),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
// Username would've been in here, clear it to avoid confusion to the consumer

View File

@@ -1,6 +1,7 @@
package main
import (
"net/url"
"os"
"path/filepath"
"sort"
@@ -29,10 +30,15 @@ func (app *appContext) CreateBackup(gc *gin.Context) {
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
escapedFName := gc.Param("fname")
fname, err := url.QueryUnescape(escapedFName)
if err != nil {
respondBool(400, false, gc)
return
}
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
b := Backup{}
err := b.FromString(fname)
err = b.FromString(fname)
if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)

View File

@@ -157,6 +157,184 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
return &wait
}
// @Summary Send an existing invite to an email address or discord user.
// @Produce json
// @Param SendInviteDTO body SendInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/send [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SendInvite(gc *gin.Context) {
var req SendInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Invite)
if !ok {
app.err.Printf(lm.FailedGetInvite, req.Invite, lm.NotFound)
respond(500, "Invite not found", gc)
return
}
err := app.sendInvite(req.sendInviteDTO, &inv)
// Even if failed, some error info might have been stored in the invite.
app.storage.SetInvitesKey(req.Invite, inv)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, req.Invite, req.SendTo, err)
respond(500, err.Error(), gc)
return
}
app.info.Printf(lm.SentInviteMessage, req.Invite, req.SendTo)
respondBool(200, true, gc)
}
// @Summary Edit an existing invite. Not all fields are modifiable.
// @Produce json
// @Param EditableInviteDTO body EditableInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Failure 400 {object} stringResponse
// @Router /invites/edit [patch]
// @Security Bearer
// @tags Invites
func (app *appContext) EditInvite(gc *gin.Context) {
var req EditableInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Code)
if !ok {
msg := fmt.Sprintf(lm.InvalidInviteCode, req.Code)
app.err.Println(msg)
respond(400, msg, gc)
return
}
changed := false
if req.NotifyCreation != nil || req.NotifyExpiry != nil {
setNotify := map[string]bool{}
if req.NotifyExpiry != nil {
setNotify["notify-expiry"] = *req.NotifyExpiry
}
if req.NotifyCreation != nil {
setNotify["notify-creation"] = *req.NotifyCreation
}
ch, ok := app.SetNotify(&inv, setNotify, gc)
changed = changed || ch
if ch && !ok {
return
}
}
if req.Profile != nil {
ch, ok := app.SetProfile(&inv, *req.Profile, gc)
changed = changed || ch
if ch && !ok {
return
}
}
if req.Label != nil {
*req.Label = strings.TrimSpace(*req.Label)
changed = changed || (*req.Label != inv.Label)
inv.Label = *req.Label
}
if req.UserLabel != nil {
*req.UserLabel = strings.TrimSpace(*req.UserLabel)
changed = changed || (*req.UserLabel != inv.UserLabel)
inv.UserLabel = *req.UserLabel
}
if req.UserExpiry != nil {
changed = changed || (*req.UserExpiry != inv.UserExpiry)
inv.UserExpiry = *req.UserExpiry
if !inv.UserExpiry {
inv.UserMonths = 0
inv.UserDays = 0
inv.UserHours = 0
inv.UserMinutes = 0
}
}
if req.UserMonths != nil || req.UserDays != nil || req.UserHours != nil || req.UserMinutes != nil {
if inv.UserMonths == 0 &&
inv.UserDays == 0 &&
inv.UserHours == 0 &&
inv.UserMinutes == 0 {
changed = changed || (inv.UserExpiry != false)
inv.UserExpiry = false
}
if req.UserMonths != nil {
changed = changed || (*req.UserMonths != inv.UserMonths)
inv.UserMonths = *req.UserMonths
}
if req.UserDays != nil {
changed = changed || (*req.UserDays != inv.UserDays)
inv.UserDays = *req.UserDays
}
if req.UserHours != nil {
changed = changed || (*req.UserHours != inv.UserHours)
inv.UserHours = *req.UserHours
}
if req.UserMinutes != nil {
changed = changed || (*req.UserMinutes != inv.UserMinutes)
inv.UserMinutes = *req.UserMinutes
}
}
if changed {
app.storage.SetInvitesKey(inv.Code, inv)
}
respondBool(200, true, gc)
}
// sendInvite attempts to send an invite to the given email address or discord username.
func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
// app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
err = errors.New(lm.InviteMessagesDisabled)
return err
}
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: NoUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
} else if len(users) > 1 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: MultiUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
}
discord = users[0].User.ID
}
var msg *Message
msg, err = app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
// app.err.Printf(lm.FailedConstructInviteMessage, req.SendTo, err)
return err
}
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
return err
// app.err.Println(invite.SendTo)
}
invite.SentTo.Success = append(invite.SentTo.Success, req.SendTo)
return err
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@@ -198,48 +376,11 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
}
invite.ValidTill = validTill
if req.SendTo != "" {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
err := app.sendInvite(req.sendInviteDTO, &invite)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
} else {
addressValid := false
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
}
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
if req.Profile != "" {
@@ -312,31 +453,34 @@ func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
// @Security Bearer
// @tags Invites,Statistics
func (app *appContext) GetInvites(gc *gin.Context) {
currentTime := time.Now()
// currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
for _, inv := range app.storage.GetInvites() {
if inv.IsReferral {
continue
}
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
// years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
// months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
Days: days,
Hours: hours,
Minutes: minutes,
UserExpiry: inv.UserExpiry,
UserMonths: inv.UserMonths,
UserDays: inv.UserDays,
UserHours: inv.UserHours,
UserMinutes: inv.UserMinutes,
Created: inv.Created.Unix(),
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
UserLabel: inv.UserLabel,
EditableInviteDTO: EditableInviteDTO{
Code: inv.Code,
Label: &inv.Label,
UserLabel: &inv.UserLabel,
Profile: &inv.Profile,
UserExpiry: &inv.UserExpiry,
UserMonths: &inv.UserMonths,
UserDays: &inv.UserDays,
UserHours: &inv.UserHours,
UserMinutes: &inv.UserMinutes,
},
ValidTill: inv.ValidTill.Unix(),
// Months: months,
// Days: days,
// Hours: hours,
// Minutes: minutes,
Created: inv.Created.Unix(),
NoLimit: inv.NoLimit,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = map[string]int64{}
@@ -357,6 +501,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses
}
if len(inv.SentTo.Success) != 0 || len(inv.SentTo.Failed) != 0 {
invite.SentTo = inv.SentTo
}
if inv.SendTo != "" {
invite.SendTo = inv.SendTo
}
@@ -369,10 +516,12 @@ func (app *appContext) GetInvites(gc *gin.Context) {
}
if _, ok := inv.Notify[addressOrID]; ok {
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
notifyExpiry := inv.Notify[addressOrID]["notify-expiry"]
invite.NotifyExpiry = &notifyExpiry
}
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
notifyCreation := inv.Notify[addressOrID]["notify-creation"]
invite.NotifyCreation = &notifyCreation
}
}
}
@@ -384,82 +533,54 @@ func (app *appContext) GetInvites(gc *gin.Context) {
gc.JSON(200, resp)
}
// @Summary Set profile for an invite
// @Produce json
// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/profile [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)
func (app *appContext) SetProfile(inv *Invite, name string, gc *gin.Context) (changed, ok bool) {
changed = false
ok = false
// "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf(lm.FailedGetProfile, req.Profile)
if _, profileExists := app.storage.GetProfileKey(name); !profileExists && name != "" {
app.err.Printf(lm.FailedGetProfile, name)
respond(500, "Profile not found", gc)
return
}
inv, _ := app.storage.GetInvitesKey(req.Invite)
inv.Profile = req.Profile
app.storage.SetInvitesKey(req.Invite, inv)
respondBool(200, true, gc)
changed = name != inv.Profile
inv.Profile = name
ok = true
return
}
// @Summary Set notification preferences for an invite.
// @Produce json
// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects"
// @Success 200
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /invites/notify [post]
// @Security Bearer
// @tags Other
func (app *appContext) SetNotify(gc *gin.Context) {
var req map[string]map[string]bool
gc.BindJSON(&req)
changed := false
for code, settings := range req {
invite, ok := app.storage.GetInvitesKey(code)
if !ok {
msg := fmt.Sprintf(lm.InvalidInviteCode, code)
app.err.Println(msg)
respond(400, msg, gc)
func (app *appContext) SetNotify(inv *Invite, settings map[string]bool, gc *gin.Context) (changed, ok bool) {
changed = false
ok = false
var address string
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable {
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
return
}
var address string
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable {
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
return
}
address = gc.GetString("jfId")
} else {
address = app.config.Section("ui").Key("email").String()
}
if invite.Notify == nil {
invite.Notify = map[string]map[string]bool{}
}
if _, ok := invite.Notify[address]; !ok {
invite.Notify[address] = map[string]bool{}
} /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
invite.Notify[address][notifyType] = settings[notifyType]
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true
}
}
if changed {
app.storage.SetInvitesKey(code, invite)
address = gc.GetString("jfId")
} else {
address = app.config.Section("ui").Key("email").String()
}
if inv.Notify == nil {
inv.Notify = map[string]map[string]bool{}
}
if _, ok := inv.Notify[address]; !ok {
inv.Notify[address] = map[string]bool{}
} /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
if _, ok := settings[notifyType]; ok && inv.Notify[address][notifyType] != settings[notifyType] {
inv.Notify[address][notifyType] = settings[notifyType]
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true
}
}
ok = true
return
}
// @Summary Delete an invite.

View File

@@ -1,6 +1,7 @@
package main
import (
"net/url"
"time"
"github.com/gin-gonic/gin"
@@ -158,7 +159,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
case "ExpiryReminder":
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail":
msg, err = app.email.constructInvite(Invite{Code: ""}, true)
msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation":
@@ -169,6 +170,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
case "UserPage":
case "UserLogin":
case "PostSignupCard":
case "PreSignupCard":
// These don't have any example content
msg = nil
}
@@ -583,8 +585,9 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Security Bearer
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
if name == "" {
escapedName := gc.Param("username")
name, err := url.QueryUnescape(escapedName)
if err != nil || name == "" {
respondBool(400, false, gc)
return
}

View File

@@ -12,17 +12,22 @@ import (
"github.com/hrfee/mediabrowser"
)
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, error) {
// getOmbiUser searches for an ombi user given a Jellyfin user ID. It looks for matching username or matching email address.
// If "email"=nil, an email address will be acquired from the DB instead. Passing it manually is useful when changing email address.
func (app *appContext) getOmbiUser(jfID string, email *string) (map[string]interface{}, error) {
jfUser, err := app.jf.UserByID(jfID, false)
if err != nil {
return nil, err
}
username := jfUser.Name
email := ""
if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr
if email == nil {
addr := ""
if e, ok := app.storage.GetEmailsKey(jfID); ok {
addr = e.Addr
}
email = &addr
}
user, err := app.ombi.getUser(username, email)
user, err := app.ombi.getUser(username, *email)
return user, err
}
@@ -147,7 +152,7 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
}
type OmbiWrapper struct {
OmbiUserByJfID func(jfID string) (map[string]interface{}, error)
OmbiUserByJfID func(jfID string, email *string) (map[string]interface{}, error)
*ombiLib.Ombi
}
@@ -191,7 +196,7 @@ func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile P
}
func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
ombiUser, err := ombi.OmbiUserByJfID(jellyfinID)
ombiUser, err := ombi.OmbiUserByJfID(jellyfinID, email)
if err != nil {
return
}

View File

@@ -247,7 +247,13 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, err := url.QueryUnescape(escapedProfileName)
if err != nil {
respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}
invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode)
@@ -294,7 +300,13 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, err := url.QueryUnescape(escapedProfileName)
if err != nil {
respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(200, true, gc)

View File

@@ -164,7 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var target ConfirmationTarget
var id string
fail := func() {
app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{
app.gcHTML(gc, 404, "404.html", OtherPage, "en-us", gin.H{
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
@@ -199,22 +199,22 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
// Perform an Action
if target == NoOp {
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
return
} else if target == UserEmailChange {
app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
UserID: id,
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Source: id,
Value: "email",
Time: time.Now(),
}, gc, true)
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
app.info.Printf(lm.UserEmailAdjusted, id)
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
return
}
}
@@ -270,7 +270,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else {
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
app.info.Printf(lm.SentConfirmationEmail, id, req.Email)
}
return
}
@@ -716,7 +716,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"))
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"), nil)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return

View File

@@ -294,6 +294,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
}
contactPrefs.Email = &(emailStore.Contact)
if profile != nil {
// FIXME: Why?
profile.ReferralTemplateKey = profile.ReferralTemplateKey
}
/// Ensures at least one contact method is enabled.
@@ -625,7 +626,12 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
gc.BindJSON(&req)
mode := gc.Param("mode")
source := gc.Param("source")
escapedSource := gc.Param("source")
source, err := url.QueryUnescape(escapedSource)
if err != nil {
respondBool(400, false, gc)
return
}
useExpiry := gc.Param("useExpiry") == "with-expiry"
baseInv := Invite{}
if mode == "profile" {
@@ -813,13 +819,19 @@ func (app *appContext) GetAnnounceTemplate(gc *gin.Context) {
// @Summary Delete an announcement template.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param name path string true "name of template"
// @Param name path string true "name of template (url encoded if necessary)"
// @Router /users/announce/template/{name} [delete]
// @Security Bearer
// @tags Users
func (app *appContext) DeleteAnnounceTemplate(gc *gin.Context) {
name := gc.Param("name")
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
app.storage.DeleteAnnouncementsKey(name)
respondBool(200, false, gc)
}
@@ -891,60 +903,90 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
respondBool(204, true, gc)
}
// userSummary generates a respUser for to be displayed to the user, or sorted/filtered.
// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation.
func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// userSummary functions the same as userSummary, but pulls from the given caches rather than the database.
func (app *appContext) userSummary(jfUser mediabrowser.User, email *EmailAddress, expiry *UserExpiry, discord *DiscordUser, telegram *TelegramUser, matrix *MatrixUser, referralActive bool) respUser {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
user := respUser{
ID: jfUser.ID,
Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
ReferralsEnabled: false,
ReferralsEnabled: referralActive || (email != nil && email.ReferralTemplateKey != ""),
}
if !jfUser.LastActivityDate.IsZero() {
user.LastActive = jfUser.LastActivityDate.Unix()
}
if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok {
if email != nil {
user.Email = email.Addr
user.NotifyThroughEmail = email.Contact
user.Label = email.Label
user.AccountsAdmin = (app.jellyfinLogin) && (email.Admin || (adminOnly && jfUser.Policy.IsAdministrator) || allowAll)
}
expiry, ok := app.storage.GetUserExpiryKey(jfUser.ID)
if ok {
if expiry != nil {
user.Expiry = expiry.Expiry.Unix()
}
if tgUser, ok := app.storage.GetTelegramKey(jfUser.ID); ok {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
if telegram != nil {
user.Telegram = telegram.Username
user.NotifyThroughTelegram = telegram.Contact
}
if mxUser, ok := app.storage.GetMatrixKey(jfUser.ID); ok {
user.Matrix = mxUser.UserID
user.NotifyThroughMatrix = mxUser.Contact
if matrix != nil {
user.Matrix = matrix.UserID
user.NotifyThroughMatrix = matrix.Contact
}
if dcUser, ok := app.storage.GetDiscordKey(jfUser.ID); ok {
user.Discord = RenderDiscordUsername(dcUser)
// user.Discord = dcUser.Username + "#" + dcUser.Discriminator
user.DiscordID = dcUser.ID
user.NotifyThroughDiscord = dcUser.Contact
}
// FIXME: Send referral data
referrerInv := Invite{}
if referralsEnabled {
// 1. Directly attached invite.
err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID))
if err == nil {
user.ReferralsEnabled = true
// 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database.
} else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" {
user.ReferralsEnabled = true
}
if discord != nil {
user.Discord = RenderDiscordUsername(*discord)
// user.Discord = discord.Username + "#" + discord.Discriminator
user.DiscordID = discord.ID
user.NotifyThroughDiscord = discord.Contact
}
return user
}
// GetUserSummary generates a respUser for to be displayed to the user, or sorted/filtered.
// It fetches information from the db quite a lot. If calling lots, consider collecting data for all fields and calling app.userSummary().
// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation.
func (app *appContext) GetUserSummary(jfUser mediabrowser.User) respUser {
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
var emailPtr *EmailAddress = nil
if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok {
emailPtr = &email
}
var expiryPtr *UserExpiry = nil
if expiry, ok := app.storage.GetUserExpiryKey(jfUser.ID); ok {
expiryPtr = &expiry
}
var discordPtr *DiscordUser = nil
if discordEnabled {
if discord, ok := app.storage.GetDiscordKey(jfUser.ID); ok {
discordPtr = &discord
}
}
var telegramPtr *TelegramUser = nil
if telegramEnabled {
if telegram, ok := app.storage.GetTelegramKey(jfUser.ID); ok {
telegramPtr = &telegram
}
}
var matrixPtr *MatrixUser = nil
if matrixEnabled {
if matrix, ok := app.storage.GetMatrixKey(jfUser.ID); ok {
matrixPtr = &matrix
}
}
referralsActive := false
// FIXME: Send referral data
referrerInv := Invite{}
// FIXME: This is veeery slow when running an arm64 binary through qemu
if referralsEnabled {
// 1. Directly attached invite.
if err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("IsReferral").Eq(true).And("ReferrerJellyfinID").Eq(jfUser.ID)); err == nil {
referralsActive = true
}
// 2. performed by userSummaryFixme
}
return app.userSummary(jfUser, emailPtr, expiryPtr, discordPtr, telegramPtr, matrixPtr, referralsActive)
}
// @Summary Returns the total number of Jellyfin users.
@@ -1328,7 +1370,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
}
if ombi != nil {
errorString := ""
user, err := app.getOmbiUser(id)
user, err := app.getOmbiUser(id, nil)
if err != nil {
errorString += fmt.Sprintf("Ombi GetUser: %v ", err)
} else {
@@ -1377,3 +1419,34 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
app.InvalidateUserCaches()
gc.JSON(code, errors)
}
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded.
// @Produce json
// @Success 200 {object} ActivityLogEntriesDTO
// @Failure 400 {object} boolResponse
// @Param id path string true "id of user to fetch activities of."
// @Router /users/{id}/activities/jellyfin [get]
// @Security Bearer
// @tags Users
func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
userId := gc.Param("id")
if userId == "" {
respondBool(400, false, gc)
return
}
activities, err := app.jf.activity.ByUserID(userId)
if err != nil {
app.err.Printf(lm.FailedGetJFActivities, err)
respondBool(400, false, gc)
return
}
out := ActivityLogEntriesDTO{
Entries: make([]ActivityLogEntryDTO, len(activities)),
}
for i := range activities {
out.Entries[i].ActivityLogEntry = activities[i]
out.Entries[i].Date = activities[i].Date.Unix()
}
app.debug.Printf(lm.GotNEntries, len(activities))
gc.JSON(200, out)
}

2
api.go
View File

@@ -201,7 +201,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
respondBool(200, true, gc)
return
} */
ombiUser, err := app.getOmbiUser(user.ID)
ombiUser, err := app.getOmbiUser(user.ID, nil)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
respondBool(200, true, gc)

View File

@@ -1,3 +1,4 @@
//go:build tray
// +build tray
package main
@@ -8,7 +9,7 @@ import (
"path/filepath"
"github.com/emersion/go-autostart"
"github.com/getlantern/systray"
"github.com/lutischan-ferenc/systray"
)
type Autostart struct {
@@ -48,8 +49,8 @@ func NewAutostart(name, displayname, trayName, trayTooltip string) *Autostart {
return a
}
func (a *Autostart) HandleCheck() {
for range a.menuitem.ClickedCh {
func (a *Autostart) Register() {
a.menuitem.Click(func() {
if !a.menuitem.Checked() {
if err := a.as.Enable(); err != nil {
log.Printf("Failed to enable autostart on login: %v", err)
@@ -65,5 +66,5 @@ func (a *Autostart) HandleCheck() {
log.Printf("Disabled autostart")
}
}
}
})
}

9
biome.json Normal file
View File

@@ -0,0 +1,9 @@
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"formatWithErrors": false,
"lineWidth": 120
}
}

View File

@@ -66,6 +66,13 @@ func FixFullURL(v string) string {
return v
}
func MustGetNonEmptyURL(path string) string {
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
func FormatSubpath(path string, removeSingleSlash bool) string {
if path == "/" {
if removeSingleSlash {
@@ -202,8 +209,8 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
// FIXME: Remove all these, eventually
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"password-reset.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"password-reset.txt")
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
@@ -291,6 +298,7 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf
config.MustSetValue("jellyfin", "cache_timeout", "30")
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
config.MustSetValue("jellyfin", "activity_cache_sync_timeout_seconds", "20")
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)

View File

@@ -136,6 +136,13 @@ sections:
type: number
value: 10
description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
- setting: activity_cache_sync_timeout
name: "Activity cache timeout (minutes)"
requires_restart: true
advanced: true
type: number
value: 0.1
description: "Synchronise Jellyfin's activity log after cache is this old. It can be pretty low as syncing only pulls new records and so is quick. Note this is unrelated to jfa-go's activity log."
- setting: type
name: Server type
requires_restart: true
@@ -739,19 +746,19 @@ sections:
etc.
- setting: use_24h
name: Use 24h time
depends_true: method
depends_true: enabled
type: bool
value: true
- setting: date_format
name: Date format
advanced: true
depends_true: method
advanced: false
depends_true: enabled
type: text
value: '%d/%m/%y'
description: Date format used in emails. Follows datetime.strftime format.
- setting: message
name: Help message
depends_true: method
depends_true: enabled
type: text
value: Need help? contact me.
description: Message displayed at bottom of emails.
@@ -1056,6 +1063,12 @@ sections:
value: en-us
description: Default telegram message language. Visit weblate if you'd like to
translate.
- setting: ignore_client_language
name: Always use default language
depends_true: enabled
type: bool
value: false
description: When disabled, jfa-go will check the telegram user's language and use it if possible. Enable to ignore it and use your configured default language always.
- section: matrix
meta:
name: Matrix

View File

@@ -66,9 +66,6 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
}
@media screen and (max-width: 1024px) {
:root {
font-size: 0.9rem;
}
.table-responsive table {
min-width: 800px;
}
@@ -207,9 +204,9 @@ sup.\~critical, .text-critical {
font-size: 1rem;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
margin-left: 0.5rem;
margin-right: 1rem;
max-width: 75%;
margin-inline-start: 0.5rem;
margin-inline-end: 1rem;
width: 5rem;;
}
.stealth-input-hidden {
@@ -279,13 +276,12 @@ table.table-p-0 th, table.table-p-0 td {
padding-bottom: 0 !important;
}
p.top {
margin-top: 0px;
td:dir(rtl), th:dir(rtl) {
text-align: right;
}
.table-responsive {
overflow-x: auto;
font-size: 0.9rem;
p.top {
margin-top: 0px;
}
#notification-box {
@@ -465,7 +461,7 @@ section.section:not(.\~neutral) {
:root {
/* seems to be the sweet spot */
--inside-input-base: -2.6rem;
--inside-input-base: -2.1rem;
/* thought --spacing would do the trick but apparently not */
--tailwind-spacing: 0.25rem;
@@ -473,9 +469,26 @@ section.section:not(.\~neutral) {
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
.gap-1 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
margin-inline-start: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
}
.gap-2 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
margin-inline-start: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
}
.force-ltr {
direction: ltr !important;
}
.content ul, .content ol {
margin-left: unset;
margin-inline-start: 2rem;
}
.content li {
text-align: start;
}
.input {
font-size: 16px !important;
}

View File

@@ -29,7 +29,7 @@ html:not(.dark) .wall {
}
.modal-close {
float: right;
float: inline-end;
color: #aaa;
font-weight: normal;
}

View File

@@ -44,10 +44,20 @@
left: 120%;
}
.tooltip.right:dir(rtl):not(.force-ltr) .content {
right: 120%;
left: unset;
}
.tooltip.left .content {
right: 120%;
}
.tooltip.left:dir(rtl):not(.force-ltr) .content {
left: 120%;
right: unset;
}
.tooltip .content.sm {
font-size: 0.8rem;
}

View File

@@ -352,9 +352,33 @@ var customContent = map[string]CustomContentInfo{
"myAccountURL",
),
Placeholders: defaultVals(map[string]any{
"myAccountURL": "https://sub2.test.url/my/account",
"myAccountURL": "https://example.url/my/account",
}),
},
"PreSignupCard": {
Name: "PreSignupCard",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["preSignupCard"]
},
Description: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["preSignupCardDescription"]
},
Variables: []string{
"myAccountURL",
"profile",
},
Placeholders: map[string]any{
"myAccountURL": "https://example.url/my/account",
"profile": "Default User Profile",
},
},
}
var EmptyCustomContent = CustomContentInfo{

View File

@@ -738,7 +738,6 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var invname *dg.Member = nil
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
err = errors.New(lm.InviteMessagesDisabled)
@@ -746,11 +745,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message
if err == nil {
msg, err = d.app.email.constructInvite(invite, false)
msg, err = d.app.email.constructInvite(&invite, false)
if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: invname.User.Username,
Reason: CheckLogs,
})
d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
}
}
@@ -760,12 +762,12 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
if err == nil {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
sendResponse("sentInvite")
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
sendResponse("sentInviteFailure")
}
}

View File

@@ -1,9 +1,7 @@
module github.com/hrfee/jfa-go/easyproxy
go 1.23.0
go 1.24.0
toolchain go1.24.5
require golang.org/x/net v0.42.0
require golang.org/x/net v0.47.0
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b

View File

@@ -1,6 +1,4 @@
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=

View File

@@ -19,6 +19,8 @@ import (
textTemplate "text/template"
"time"
sTemplate "github.com/hrfee/simple-template"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy"
@@ -251,13 +253,13 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten
Subject: contentInfo.Subject(emailer.config, &emailer.lang),
}
// Template the subject for bonus points
if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil {
if subject, err := sTemplate.Template(msg.Subject, data); err == nil {
msg.Subject = subject
}
if cc.Enabled {
// Use template email, rather than the built-in's email file.
contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data)
content, err := sTemplate.Template(cc.Content, data)
if err != nil {
emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
return msg, err
@@ -377,7 +379,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, placeh
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) {
func (emailer *Emailer) constructInvite(invite *Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false)
inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)

View File

@@ -180,7 +180,7 @@ func TestInvite(t *testing.T) {
Created: time.Now(),
ValidTill: time.Now().Add(30 * time.Minute),
}
msg, err := e.constructInvite(inv, false)
msg, err := e.constructInvite(&inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}

19
go.mod
View File

@@ -28,7 +28,6 @@ require (
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.9.0
github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
@@ -43,9 +42,11 @@ require (
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/mediabrowser v0.3.33
github.com/hrfee/mediabrowser v0.3.35
github.com/hrfee/simple-template v1.1.0
github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/lutischan-ferenc/systray v1.2.1
github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/robert-nix/ansihtml v1.0.1
@@ -70,12 +71,6 @@ require (
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -93,9 +88,9 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -110,7 +105,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect
@@ -119,6 +113,7 @@ require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -132,8 +127,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
@@ -142,7 +135,7 @@ require (
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

74
go.sum
View File

@@ -11,7 +11,6 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -65,28 +64,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -105,7 +82,6 @@ github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -157,9 +133,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
@@ -169,6 +142,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
@@ -208,7 +183,6 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -222,6 +196,12 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYgD6Tg=
github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8=
github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
@@ -249,8 +229,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/lutischan-ferenc/systray v1.2.1 h1:gPNrEpmg4hMwXyKNSlrkuuXqvxgqCYPjF5H/pG9I1+c=
github.com/lutischan-ferenc/systray v1.2.1/go.mod h1:YYaJ28AVuhMrlI5JfqrMsYMIl3Aa4Q02bpXXCl9caqo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
@@ -283,8 +263,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
@@ -312,7 +290,6 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@@ -350,6 +327,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw=
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -384,7 +363,6 @@ github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4te
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
@@ -392,26 +370,14 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
@@ -434,10 +400,8 @@ golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
@@ -458,7 +422,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -473,7 +436,6 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
@@ -490,23 +452,19 @@ golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -533,7 +491,6 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
@@ -568,7 +525,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -580,9 +536,7 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -31,6 +31,7 @@ func (app *appContext) clearEmails() {
func (app *appContext) clearDiscord() {
app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord()
removeRoleOnDisable := app.config.Section("discord").Key("disable_enable_role").MustBool(false)
for _, discordUser := range discordUsers {
user, err := app.jf.UserByID(discordUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
@@ -40,7 +41,7 @@ func (app *appContext) clearDiscord() {
app.discord.RemoveRole(discordUser.MethodID().(string))
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
if user.Policy.IsDisabled {
if removeRoleOnDisable && user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue

View File

@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<title>404 - jfa-go</title>
{{ template "header.html" . }}
{{ template "header.txt" . }}
</head>
<body class="section">
<div class="page-container m-2 lg:my-20 lg:mx-64">

View File

@@ -1,16 +1,16 @@
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
<span class="heading">{{ .strings.linkDiscord }}</span>
<p class="content"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl pin"></h1>
<div class="flex flex-row gap-2 justify-center items-center">
<a class="hover:underline flex flex-row gap-4 items-center">
<span>{{ .strings.joinTheServer }}</span>
<span id="discord-invite" class="flex flex-row gap-2 items-center"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
<span class="button ~info @low full-width center" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}

View File

@@ -1,18 +1,18 @@
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<div class="subheading flex flex-row gap-2 justify-center items-center">
<span class="shield ~info">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
<span>{{ .matrixUser }}</span>
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
<span class="button ~info @low full-width center" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}

View File

@@ -1,18 +1,18 @@
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
<span class="heading">{{ .strings.linkTelegram }}</span>
<p class="content">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl pin"></p>
<a class="subheading link flex flex-row gap-2 justify-center items-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span class="username">{{ .telegramUsername }}</span>
<span class="hover:underline">&#64;<span class="username">{{ .telegramUsername }}</span></span>
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
<span class="button ~info @low full-width center" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}

View File

@@ -1,7 +1,9 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
{{ template "syntaxhighlighting.html" . }}
{{ template "syntaxhighlighting.txt" . }}
<title>Admin - jfa-go</title>
{{ template "header.txt" . }}
<script>
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
@@ -12,10 +14,8 @@
window.jfAllowAll = {{ .jfAllowAll }};
window.loginAppearance = "{{ .loginAppearance }}";
</script>
<title>Admin - jfa-go</title>
{{ template "header.html" . }}
</head>
<body class="max-w-full overflow-x-hidden section">
<body class="max-w-full section"><div class="overflow-x-hidden relative"><!-- for whatever reason position:relative stops hidden x overflow on ios and samsung web -->
{{ template "login-modal.html" . }}
<div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-add-user" href="">
@@ -45,33 +45,33 @@
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<p>{{ .strings.buildTags }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTags }}</span></p>
<div class="flex flex-row flex-wrap gap-2 my-2">
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<div class="flex flex-row flex-wrap gap-2">
<a class="button ~neutral lang-link flex flex-row gap-2" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line"></i>github</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i>
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link flex flex-row gap-2">
<i class="ri-hand-heart-line"></i>
donate
<span class="ml-2 chev"></span>
<span class="chev"></span>
</a>
<div class="dropdown-display">
<div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a>
<div class="card ~neutral @low flex flex-col gap-2">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral w-full lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral w-full lang-link">Ko-fi</a>
</div>
</div>
</div>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
<a class="button ~urge @low discord lang-link flex flex-row gap-2" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line"></i>discord</a>
</div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre>
<pre class="font-mono bg-inherit force-ltr">{{ .license }}</pre>
</div>
</div>
<div id="modal-logs" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.logs }}<span class="modal-close">&times;</span></span>
<pre class="monospace" id="log-area"></pre>
<pre class="monospace force-ltr" id="log-area"></pre>
</div>
</div>
<div id="modal-tasks" class="modal">
@@ -82,10 +82,10 @@
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-col gap-4 my-2">
<p class="content">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
@@ -131,29 +131,29 @@
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="grow mr-2">
<p class="content">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="grow ml-2">
<label class="grow">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
</div>
<div class="select ~neutral @low mb-4">
<div class="select ~neutral @low">
<select id="enable-referrals-user-profiles"></select>
</div>
<div class="select ~neutral @low mb-4 unfocused">
<div class="select ~neutral @low unfocused">
<select id="enable-referrals-user-invites"></select>
</div>
<label class="switch mb-4">
<label class="switch">
<input type="checkbox" id="enable-referrals-user-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
<span class="flex flex-row support">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
@@ -162,17 +162,19 @@
</form>
</div>
<div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
<div class="select ~neutral @low mb-4 mt-2">
<select id="enable-referrals-profile-invites"></select>
<p class="content">{{ .strings.enableReferralsProfileDescription }}</p>
<div class="flex flex-col gap-2">
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
<div class="select ~neutral @low">
<select id="enable-referrals-profile-invites"></select>
</div>
</div>
<label class="switch mb-4">
<label class="switch flex flex-row gap-2">
<input type="checkbox" id="enable-referrals-profile-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
<span class="flex flex-row support">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
@@ -182,14 +184,14 @@
</div>
{{ end }}
<div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" 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-8">
<label class="switch mb-4">
<div class="content">
<label class="switch">
<input type="checkbox" id="delete-user-notify" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label>
<textarea id="textarea-delete-user" class="textarea full-width ~neutral @low mb-4" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
<textarea id="textarea-delete-user" class="textarea full-width ~neutral @low" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
<label>
<input type="submit" class="unfocused">
<span class="button ~critical @low full-width center supra submit">{{ .strings.delete }}</span>
@@ -255,7 +257,7 @@
<input type="checkbox" id="expiry-extend-enable" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label>
<textarea id="textarea-extend-enable" class="textarea full-width ~neutral @low mb-4" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
<textarea id="textarea-extend-enable" class="textarea full-width ~neutral @low" placeholder="{{ .strings.sendDeleteNotificationExample }}"></textarea>
<label>
<input type="submit" class="unfocused">
<span class="button ~critical @low full-width center supra submit">{{ .strings.submit }}</span>
@@ -266,21 +268,23 @@
<div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="flex flex-col md:flex-row">
<div class="col card ~neutral @low">
<div id="announce-details">
<div class="flex flex-row flex-wrap gap-4">
<div class="card ~neutral @low flex flex-col gap-2 justify-between basis-[24rem] grow-[4]">
<div id="announce-details" class="flex flex-col gap-2">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="announce-variables">
<span class="button ~urge @low mb-2 mt-4" id="announce-variables-username" style="margin-left: 0.25rem; margin-right: 0.25rem;"><span class="font-mono bg-inherit">{username}</span></span>
<div id="announce-variables" class="flex flex-row flex-wrap gap-2">
<span class="button ~urge @low" id="announce-variables-username"><span class="font-mono bg-inherit">{username}</span></span>
</div>
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="announce-subject" class="input ~neutral @low">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral @low mt-4 font-mono"></textarea>
<p class="support mt-4 mb-2">{{ .strings.markdownSupported }}</p>
<textarea id="textarea-announce" class="textarea full-width ~neutral @low font-mono"></textarea>
<p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div>
<label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p>
<input type="text" class="input ~neutral @low mb-2 mt-4">
<input type="text" class="input ~neutral @low">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="flex flex-row justify-between">
@@ -291,17 +295,17 @@
<span class="button ~info @low center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span>
</div>
</div>
<div class="col card ~neutral @low">
<div class="card ~neutral @low flex flex-col gap-2 basis-[24rem] grow">
<span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-8" id="announce-preview"></div>
<div id="announce-preview"></div>
</div>
</div>
</form>
</div>
<div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<p class="content">{{ .strings.customizeMessagesDescription }}</p>
<div class="">
<table class="table">
<thead>
@@ -319,8 +323,8 @@
<div id="modal-editor" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href="">
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col card ~neutral @low flex flex-col gap-2 justify-between">
<div class="flex flex-row flex-wrap gap-4">
<div class="card ~neutral @low flex flex-col gap-2 justify-between basis-[24rem] grow-[4]">
<div class="flex flex-col gap-2">
<aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside>
<label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label>
@@ -329,16 +333,17 @@
<div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea>
<p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div>
<div class="flex flex-col gap-2">
<p class="support">{{ .strings.markdownSupported }}</p>
<label class="w-full">
<input type="submit" class="unfocused">
<span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span>
</label>
</div>
</div>
<div class="col card ~neutral @low flex flex-col gap-2">
<div class="card ~neutral @low flex flex-col gap-2 basis-[24rem] grow">
<span class="subheading supra">{{ .strings.preview }}</span>
<div id="editor-preview"></div>
</div>
@@ -346,19 +351,19 @@
</form>
</div>
<div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low flex flex-col gap-4">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right">
<span class="button ~info @low mb-2" id="settings-apply-no-restart">{{ .strings.settingsApplyRestartLater }}</span>
<p class="content">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="flex flex-row justify-end gap-2">
<span class="button ~info @low" id="settings-apply-no-restart">{{ .strings.settingsApplyRestartLater }}</span>
<span class="button ~critical @low" id="settings-apply-restart">{{ .strings.settingsApplyRestartNow }}</span>
</div>
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 flex flex-col gap-4">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
<div class="content">
{{ .strings.backupsDescription }}
<ul>
<li>{{ .strings.backupsCopy }}</li>
@@ -366,11 +371,11 @@
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
</ul>
</div>
<div class="flex flex-row flex-wrap my-2">
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<div class="flex flex-row flex-wrap gap-2">
<button class="button ~info @low" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
<button class="button ~neutral @low flex flex-row gap-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
@@ -388,12 +393,14 @@
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low flex flex-col gap-4">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div class="flex flex-col gap-2">
<p class="content" id="settings-backed-up-location"></p>
<p class="content">{{ .strings.backupCanDownload }}</p>
</div>
<div>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
<button class="button flex w-full ~info @low"><span class="flex flex-row gap-2 items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
@@ -404,17 +411,17 @@
</div>
</div>
<div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low flex flex-col gap-4">
<span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
<p class="content" id="send-pwr-note"></p>
<span class="button ~urge @low" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
<p class="content">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low">
<select></select>
</div>
<label>
@@ -424,10 +431,10 @@
</form>
</div>
<div id="modal-jellyseerr-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-jellyseerr-defaults" href="">
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
<p class="content">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
<div class="select ~neutral @low">
<select></select>
</div>
<label>
@@ -437,9 +444,9 @@
</form>
</div>
<div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 card flex flex-col gap-4">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<p class="content">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
<table class="table">
<thead>
@@ -508,27 +515,27 @@
</form>
</div>
<div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 card flex flex-col gap-2">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content">
<h2 class="mt-2">
<div class="content flex flex-col">
<h2>
<a id="update-version"></a> (<span class="font-mono bg-inherit" id="update-commit"></span>)
</h2>
<p class="content mt-2" id="update-description"></p>
<p class="support mt-2" id="update-date"></p>
<div class="content markdown-box mt-2" id="update-changelog"></div>
</p>
<span class="button ~info @low full-width center mt-2" id="update-download">{{ .strings.download }}</span>
<span class="button ~urge @low full-width center mt-2" id="update-update">{{ .strings.update }}</span>
<p class="content" id="update-description"></p>
<div class="content markdown-box" id="update-changelog"></div>
<p class="support" id="update-date"></p>
</div>
<span class="button ~info @low full-width center" id="update-download">{{ .strings.download }}</span>
<span class="button ~urge @low full-width center" id="update-update">{{ .strings.update }}</span>
</div>
</div>
{{ template "account-linking-telegram.html" . }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p>
<div class="row">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2">
<span class="heading"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content" id="discord-description"></p>
<div>
<input type="search" class="col sm field ~neutral @low input" id="discord-search" placeholder="user#1234">
</div>
<table class="table"><tbody id="discord-list"></tbody></table>
@@ -536,20 +543,29 @@
</div>
{{ end }}
<div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="matrix-user">
<input type="password" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.password }}" id="matrix-password">
<p class="content">{{ .strings.linkMatrixDescription }}</p>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.matrixHomeServer }}</span>
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
</label>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.username }}</span>
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.username }}" id="matrix-user">
</label>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.password }}</span>
<input type="password" class="field input ~neutral @high" placeholder="{{ .strings.password }}" id="matrix-password">
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label>
</label>"
</form>
</div>
<div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 overflow-x-hidden">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
@@ -559,17 +575,17 @@
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
{{ if .userPageEnabled }}
<div class="">
<a class="button ~info" href="{{ .pages.Base }}{{ .pages.MyAccount }}/"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<a class="button ~info flex flex-row gap-2" href="{{ .pages.Base }}{{ .pages.MyAccount }}/"><i class="ri-account-circle-fill"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
</div>
</div>
<header>
<div class="flex flex-row overflow-x-auto items-center gap-2">
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</span>
<div class="flex flex-row overflow-x-auto items-center gap-2 scroll-smooth">
<button type="button" id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</button>
<button type="button" id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</button>
<button type="button" id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</button>
<button type="button" id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</button>
</div>
</header>
<div id="tab-invites" class="flex flex-col gap-4">
@@ -633,9 +649,9 @@
<div class="flex flex-row gap-2">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<label for="create-user-expiry-enabled" class="button ~neutral @low flex flex-row gap-2">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
<span>{{ .strings.enabled }} </span>
</label>
</div>
</div>
@@ -709,20 +725,6 @@
</div>
</div>
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
</div>
<div>
@@ -733,15 +735,15 @@
</div>
</div>
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible flex flex-col gap-2">
<div class="card @low dark:~d_neutral accounts overflow-visible flex flex-col gap-2">
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div class="flex flex-row gap-4 align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
</div>
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<input type="search" class="field ~neutral @low input search" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
@@ -754,7 +756,7 @@
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<div class="card ~neutral @low overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
@@ -772,11 +774,11 @@
<button class="button ~neutral @low center accounts-load-all">{{ .strings.loadAll }}</button>
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
<span class="w-full button ~info @low center items-baseline flex flex-row gap-2" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
<div id="accounts-announce-templates"></div>
<div id="accounts-announce-templates" class="flex flex-col gap-2"></div>
</div>
</div>
</div>
@@ -785,11 +787,11 @@
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<span class="w-full button ~positive @low center items-baseline flex flex-row gap-2" id="accounts-expiry-dropdown-button">{{ .strings.expiry }}<i class="ri-arrow-down-s-line"></i></span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<div class="card ~neutral @low flex flex-col gap-2">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
<span class="button ~critical full-width @low center" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
</div>
</div>
</div>
@@ -804,7 +806,7 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive">
<div class="card @low accounts-header overflow-x-scroll">
<table class="table text-base leading-5">
<thead>
<tr>
@@ -845,7 +847,7 @@
</div>
</div>
</div>
<div class="flex flex-row gap-2 justify-center">
<div class="flex flex-row gap-2 my-3 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
@@ -857,18 +859,18 @@
</div>
</div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible flex flex-col gap-2">
<div class="card @low dark:~d_neutral activity overflow-visible flex flex-col gap-2">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<div class="flex flex-row gap-4 align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold">{{ .strings.activity }}</span>
<div class="flex flex-row gap-2 align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<button class="button ~neutral @low" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<input type="search" class="field ~neutral @low input search" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
@@ -881,7 +883,7 @@
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<div class="card ~neutral @low overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
@@ -906,7 +908,7 @@
</div>
</div>
</div>
<div id="activity-card-list"></div>
<div id="activity-card-list" class="flex flex-col gap-2"></div>
<div id="activity-loader"></div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
@@ -921,11 +923,11 @@
<div id="tab-settings" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral settings overflow flex flex-col gap-2">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<div class="flex flex-row align-middle justify-between md:justify-normal gap-2">
<span class="heading">{{ .strings.settings }}</span>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
<label for="settings-advanced-enabled" class="button ~neutral @low flex flex-row gap-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span>
<span>{{ .strings.advancedSettings }} </span>
</label>
</div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
@@ -936,26 +938,26 @@
<span class="button ~urge @low unfocused gap-1" id="settings-save"><i class="icon ri-save-line"></i>{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="flex flex-col md:flex-row gap-3 force-ltr">
<div class="@low dark:~d_neutral flex md:flex flex-col gap-2" id="settings-sidebar">
<div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
<button class="button ~neutral @low center inside-input rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div>
<div id="settings-loader" class="flex flex-row flex-wrap gap-2">
<span class="button ~neutral @low justify-center grow" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a>
<span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
<span class="button ~neutral @low justify-center grow flex flex-row gap-2" id="setting-about"><span class="flex flex-row gap-2">{{ .strings.aboutProgram }} <i class="ri-information-line"></i></span></span>
<a class="button ~urge dark:~d_info @low justify-center grow flex flex-row gap-2" target="_blank" href="https://wiki.jfa-go.com"><span class="flex flex-row gap-2">{{ .strings.wiki }} <i class="ri-book-shelf-line"></i></a>
<span class="button ~neutral @low justify-center grow flex flex-row gap-2" id="setting-profiles"><span class="flex flex-row gap-2">{{ .strings.userProfiles }} <i class="ri-user-line"></i></span></span>
</div>
<div class="flex md:flex flex-col gap-2 overflow-y-scroll" id="settings-sidebar-items"></div>
</div>
<div class="card ~neutral @low overflow flex-1 grow" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
<span class="mb-2 px-12 text-center">{{ .strings.settingsMaybeUnderAdvanced }}</span>
<button class="button ~neutral @low settings-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
<div class="flex flex-col gap-4 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic">{{ .strings.noResultsFound }}</span>
<span class="px-12 text-center">{{ .strings.settingsMaybeUnderAdvanced }}</span>
<button class="button ~neutral @low settings-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
@@ -965,5 +967,5 @@
</div>
</div>
<script src="{{ .pages.Base }}/js/admin.js" type="module"></script>
</body>
</div></body>
</html>

View File

@@ -1,42 +1,38 @@
<!doctype html>
<html lang="en">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}">
<head>
<!--- This CSS is inlined so we should keep this here! -->
<link inline rel="stylesheet" type="text/css" href="web/css/v0.6.0bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>Crash report</title>
</head>
<body>
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card ~critical sectioned">
<section class="section ~critical">
<section class="section ~critical flex flex-col gap-2">
<span class="heading">Crash report for jfa-go</span>
{{ if .Err }}
<div class="font-mono bg-inherit pre-line mt-4 mb-4">
<div class="font-mono bg-inherit pre-line">
Error: {{ .Err }}
</div>
{{ end }}
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
<a class="button ~critical w-full center" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section>
<section class="section ~neutral @low">
<div class="flex flex-row justify-between">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
<section class="section ~neutral @low flex flex-col gap-4">
<div class="flex flex-row justify-between gap-4">
<span class="subheading font-medium">Full Log</span>
<span class="button ~urge" id="copy-log">Copy</span>
</div>
<div class="row mb-4">
<label class="col mr-4">
<span class="button ~neutral @high supra full-width center" id="button-log-normal">Normal</span>
</label>
<label class="col mr-4">
<span class="button ~neutral @low supra full-width center" id="button-log-sanitized">Sanitized</span>
</label>
<div class="flex flex-row gap-2 justify-between">
<button class="button ~neutral @high supra w-full center" id="button-log-normal">Normal</button>
<button class="button ~neutral @low supra w-full center" id="button-log-sanitized">Sanitized</button>
</div>
<div id="log-normal">
<pre class="font-mono bg-inherit pre-line">{{ .Log }}</pre>
<pre class="card font-mono bg-inherit pre-line">{{ .Log }}</pre>
</div>
<div id="log-sanitized" class="unfocused">
<p class="subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
<pre class="font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
<div id="log-sanitized" class="flex flex-col gap-2 unfocused">
<p class="support subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
<pre class="card font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
</div>
</section>
</div>

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .strings.successHeader }} - jfa-go</title>
</head>
<body class="section">

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
{{ template "header.html" . }}
{{ template "header.txt" . }}
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
{{ else }}
@@ -41,7 +41,7 @@
</div>
<div class="card dark:~d_neutral @low">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading mr-5">
<span class="heading">
{{ if .passwordReset }}
{{ .strings.passwordReset }}
{{ else }}
@@ -82,23 +82,23 @@
<span class="button ~info @low full-width center mb-4" id="link-matrix">{{ .strings.linkMatrix }} {{ if .matrixRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}
{{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
<div id="contact-via" class="unfocused">
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
<div id="contact-via" class="unfocused flex flex-col gap-2">
<label class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="email" id="contact-via-email"><span>Contact through Email</span>
</label>
{{ if .telegramEnabled }}
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram" class="mr-2"><span>Contact through Telegram</span>
<label class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
</label>
{{ end }}
{{ if .discordEnabled }}
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord" class="mr-2"><span>Contact through Discord</span>
<label class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
</label>
{{ end }}
{{ if .matrixEnabled }}
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
<label class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
</label>
{{ end }}
</div>
@@ -125,6 +125,11 @@
{{ if .fromUser }}
<aside class="col aside sm ~positive mb-4" id="invite-from-user" data-from="{{ .fromUser }}">{{ .strings.invitedBy }}</aside>
{{ end }}
{{ if .preSignupCard }}
<div class="card @low dark:~d_neutral break-words content">
{{ .preSignupCardContent }}
</div>
{{ end }}
<div class="card ~neutral @low mb-4">
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
@@ -136,11 +141,19 @@
</ul>
</div>
{{ if .captcha }}
<div class="card ~neutral @low mb-4">
<span class="label supra mb-2">CAPTCHA {{ if not .reCAPTCHA }}<span id="captcha-regen" title="{{ .strings.refresh }}" class="badge lg @low ~info ml-2 float-right"><i class="ri-refresh-line"></i></span><span id="captcha-success" class="badge lg @low ~critical ml-2 float-right"><i class="ri-close-line"></i></span>{{ end }}</span>
<div id="captcha-img" class="mt-2 mb-2 {{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
<div class="card ~neutral @low mb-4 flex flex-col gap-2">
<div class="flex flex-row justify-between gap-2">
<span class="label supra">CAPTCHA</span>
{{ if not .reCAPTCHA }}
<div class="flex flex-row gap-2">
<button id="captcha-regen" aria-label="{{ .strings.refresh }}" title="{{ .strings.refresh }}" class="badge lg @low ~info"><i class="ri-refresh-line"></i></button>
<span id="captcha-success" class="badge lg @low ~critical"><i class="ri-close-line"></i></span>
</div>
{{ end }}
</div>
<div id="captcha-img" class="{{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
{{ if not .reCAPTCHA }}
<input class="field ~neutral @low" id="captcha-input" class="mt-2" placeholder="CAPTCHA">
<input class="field ~neutral @low" id="captcha-input" placeholder="CAPTCHA">
{{ end }}
</div>
{{ end }}

View File

@@ -3,6 +3,9 @@
<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.">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#101010" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff">
<meta name="robots" content="noindex">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .pages.Base }}/apple-touch-icon.png">
@@ -11,7 +14,6 @@
<link rel="manifest" href="{{ .pages.Base }}/site.webmanifest">
<link rel="mask-icon" href="{{ .pages.Base }}/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<script>
window.pages = {
"Base": "{{ .pages.Base }}",

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>Invalid Code - jfa-go</title>
</head>
<body class="section">

View File

@@ -1,7 +1,7 @@
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
<span class="button ~urge dropdown-button flex flex-row gap-2 h-full" title="{{ .strings.language }}" aria-label="{{ .strings.language }}">
<i class="icon ri-global-line"></i>
<i class="icon ri-arrow-down-s-line"></i>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low flex flex-col gap-2">
@@ -13,7 +13,7 @@
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
<div id="lang-list" class="flex flex-col gap-2"></div>
</div>
</div>
</span>

View File

@@ -15,23 +15,23 @@
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<a class="button ~info h-12 w-full flex flex-row gap-2" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}
<form class="card mx-2 form-login w-full {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
<form class="card mx-2 form-login w-full flex flex-col gap-2 {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high" placeholder="{{ .strings.password }}" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.login }}</span>
{{ if index . "pwrEnabled" }}
{{ if .pwrEnabled }}
<span class="button ~info @low full-width center supra submit my-2" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
{{ end }}
{{ end }}
</label>
{{ if index . "pwrEnabled" }}
{{ if .pwrEnabled }}
<span class="button ~info @low full-width center supra" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
{{ end }}
{{ end }}
</form>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .strings.passwordReset }} - jfa-go</title>
</head>
<body class="section">

View File

@@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="light">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
<head>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .lang.Strings.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 items-center">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
@@ -322,10 +322,10 @@
</label>
<div class="flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
<div class="switch flex flex-row gap-2"><input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span></div>
<div class="switch flex flex-row gap-2"><input type="radio" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span></div>
</label>
</div>
<div id="email-sect" class="flex flex-row gap-2 justify-between">

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" class="light">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
<head>
<script>
window.langFile = JSON.parse({{ .language }});
@@ -17,52 +17,50 @@
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
</script>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .strings.myAccount }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-email" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="content">
<span class="heading mb-4 my-2"></span>
<label class="label supra row m-1" for="modal-email-input">{{ .strings.emailAddress }}</label>
<div class="row">
<input type="email" class="col sm field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
</div>
<button class="button ~urge @low supra full-width center lg my-2 modal-submit">{{ .strings.submit }}</button>
<div class="flex flex-col gap-2">
<span class="heading"></span>
<label class="label flex flex-col gap-2">
<span class="supra">{{ .strings.emailAddress }}</span>
<input type="email" class="field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
</label>
<button class="button ~urge @low supra full-width center lg modal-submit">{{ .strings.submit }}</button>
</div>
<div class="confirmation-required unfocused">
<span class="heading mb-4">{{ .strings.confirmationRequired }} <span class="modal-close">&times;</span></span>
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
<div class="confirmation-required unfocused flex flex-col gap-2">
<span class="heading">{{ .strings.confirmationRequired }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
</div>
{{ if .pwrEnabled }}
<div id="modal-pwr" class="modal">
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low flex flex-col gap-2">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLinkStart }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}
</p>
<div class="row">
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
<div class="content">
{{ if .linkResetEnabled }}
<p>{{ .strings.resetPasswordThroughLinkStart }}</p>
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
<p>{{ .strings.resetPasswordThroughLinkEnd }}</p>
{{ else }}
<p>{{ .strings.resetPasswordThroughJellyfin }}</p>
{{ end }}
</div>
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
{{ if .linkResetEnabled }}
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
<span class="button ~info @low full-width center" id="pwr-submit">
{{ .strings.submit }}
</span>
{{ else }}
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
<a class="button ~info @low full-width center" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
{{ end }}
</div>
</div>
@@ -75,12 +73,12 @@
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<a class="button ~info unfocused h-min" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
</div>
<div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span>
<div class="card @low dark:~d_neutral" id="card-user">
<span class="heading flex flex-row gap-4"></span>
</div>
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
{{ if index . "PageMessageEnabled" }}
@@ -90,15 +88,15 @@
</div>
{{ end }}
{{ end }}
<div class="card @low dark:~d_neutral flex-col" id="card-contact">
<span class="heading mb-2">{{ .strings.contactMethods }}</span>
<div class="card @low dark:~d_neutral flex flex-col gap-2" id="card-contact">
<span class="heading">{{ .strings.contactMethods }}</span>
<div class="content flex justify-between flex-col h-100"></div>
</div>
<div>
<div class="card @low dark:~d_neutral content" id="card-password">
<span class="heading row mb-2">{{ .strings.changePassword }}</span>
<div class="">
<div class="my-2">
<div class="card @low dark:~d_neutral flex flex-col gap-2" id="card-password">
<span class="heading">{{ .strings.changePassword }}</span>
<div class="flex flex-col gap-2">
<div class="content">
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
@@ -108,15 +106,15 @@
{{ end }}
</ul>
</div>
<div class="my-2">
<div class="flex flex-col gap-2">
<label class="label supra" for="user-old-password">{{ .strings.oldPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
<label class="label supra" for="user-new-password">{{ .strings.newPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
<label class="label supra" for="user-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center mt-4" id="user-password-submit">
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center" id="user-password-submit">
{{ .strings.changePassword }}
</span>
</div>
@@ -124,21 +122,21 @@
</div>
</div>
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-status">
<span class="heading mb-2">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry my-4"></aside>
<div class="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-status">
<span class="heading">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry"></aside>
<div class="user-expiry-countdown"></div>
</div>
</div>
{{ if .referralsEnabled }}
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-referrals">
<span class="heading">{{ .strings.referrals }}</span>
<aside class="aside ~neutral col user-referrals-description"></aside>
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info"></div>
<div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>
<div class="user-referrals-info flex flex-col gap-2"></div>
<div class="grid">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low flex flex-row gap-2" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line"></i></button>
</div>
</div>
</div>

248
jf_activity.go Normal file
View File

@@ -0,0 +1,248 @@
package main
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/hrfee/mediabrowser"
)
const (
// ActivityLimit is the maximum number of ActivityLogEntries to keep in memory.
// The array they are stored in is fixed, so (ActivityLimit*unsafe.Sizeof(mediabrowser.ActivityLogEntry))
// At writing ActivityLogEntries take up ~160 bytes each, so 1M of memory gives us room for ~6250 records
ActivityLimit int = 1e6 / 160
// If ByUserLimitLength is true, ByUserLengthOrBaseLength is the maximum number of records attached
// to a user.
// If false, it is the base amount of entries to allocate for for each user ID, and more will be allocated as needed.
ByUserLengthOrBaseLength = 128
ByUserLimitLength = false
)
type activityLogEntrySource interface {
GetActivityLog(skip, limit int, since time.Time, hasUserID bool) (mediabrowser.ActivityLog, error)
}
// JFActivityCache is a cache for Jellyfin ActivityLogEntries, intended to be refreshed frequently
// and suited to it by only querying for changes since the last refresh.
type JFActivityCache struct {
jf activityLogEntrySource
cache [ActivityLimit]mediabrowser.ActivityLogEntry
// index into Cache of the entry that should be considered the start (i.e. most recent), and end (i.e. oldest).
start, end int
// Map of activity entry IDs to their index.
byEntryID map[int64]int
// Map of user IDs to a slice of entry indexes they are referenced in, chronologically ordered.
byUserID map[string][]int
LastSync, LastYieldingSync time.Time
// Age of cache before it should be refreshed.
WaitForSyncTimeout time.Duration
syncLock sync.Mutex
syncing bool
// Total number of entries.
Total int
dupesInLastSync int
}
func (c *JFActivityCache) debugString() string {
var b strings.Builder
places := len(strconv.Itoa(ActivityLimit - 1))
b.Grow((ActivityLimit * (places + 1) * 2) + 1)
for i := range c.cache {
fmt.Fprintf(&b, "%0"+strconv.Itoa(places)+"d|", i)
}
b.WriteByte('\n')
for i := range c.cache {
fmt.Fprintf(&b, "%0"+strconv.Itoa(places)+"d|", c.cache[i].ID)
}
return b.String()
}
// NewJFActivityCache returns a Jellyfin ActivityLogEntry cache.
// You should set the timeout low, as events are likely to happen frequently,
// and refreshing should be quick anyway
func NewJFActivityCache(jf activityLogEntrySource, waitForSyncTimeout time.Duration) *JFActivityCache {
c := &JFActivityCache{
jf: jf,
WaitForSyncTimeout: waitForSyncTimeout,
start: -1,
end: -1,
byEntryID: map[int64]int{},
byUserID: map[string][]int{},
Total: 0,
dupesInLastSync: 0,
}
for i := range ActivityLimit {
c.cache[i].ID = -1
}
return c
}
// ByUserID returns a slice of ActivitLogEntries with the given jellyfin ID attached.
func (c *JFActivityCache) ByUserID(jellyfinID string) ([]mediabrowser.ActivityLogEntry, error) {
if err := c.MaybeSync(); err != nil {
return nil, err
}
arr, ok := c.byUserID[jellyfinID]
if !ok {
return nil, nil
}
out := make([]mediabrowser.ActivityLogEntry, len(arr))
for i, aleIdx := range arr {
out[i] = c.cache[aleIdx]
}
return out, nil
}
// ByEntryID returns the ActivityLogEntry with the corresponding ID.
func (c *JFActivityCache) ByEntryID(entryID int64) (entry mediabrowser.ActivityLogEntry, ok bool, err error) {
err = c.MaybeSync()
if err != nil {
return
}
var idx int
idx, ok = c.byEntryID[entryID]
if !ok {
return
}
entry = c.cache[idx]
return
}
// MaybeSync returns once the cache is in a suitable state to read:
// return if cache is fresh, sync if not, or wait if another sync is happening already.
func (c *JFActivityCache) MaybeSync() error {
shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout))
if !shouldWaitForSync {
return nil
}
syncStatus := make(chan error)
go func(status chan error, c *JFActivityCache) {
c.syncLock.Lock()
alreadySyncing := c.syncing
// We're either already syncing or will be
c.syncing = true
c.syncLock.Unlock()
if !alreadySyncing {
// If we haven't synced, this'll just get max (ActivityLimit),
// If we have, it'll get anything that's happened since then
thisSync := time.Now()
al, err := c.jf.GetActivityLog(-1, ActivityLimit, c.LastYieldingSync, true)
if err != nil {
c.syncLock.Lock()
c.syncing = false
c.syncLock.Unlock()
status <- err
return
}
// Can't trust the source fully, so we need to check for anything we've already got stored
// -before- we decide where the data should go.
recvLength := len(al.Items)
c.dupesInLastSync = 0
for i, ale := range al.Items {
if _, ok := c.byEntryID[ale.ID]; ok {
c.dupesInLastSync = len(al.Items) - i
// If we got the same as before, everything after it we'll also have.
recvLength = i
break
}
}
if recvLength > 0 {
// Lazy strategy: rebuild user ID maps each time.
// Wipe them, and then append each new refresh element as we process them.
// Then loop through all the old entries and append them too.
for uid := range c.byUserID {
c.byUserID[uid] = c.byUserID[uid][:0]
}
previousStart := c.start
if c.start == -1 {
c.start = 0
c.end = recvLength - 1
} else {
c.start = ((c.start-recvLength)%ActivityLimit + ActivityLimit) % ActivityLimit
}
if c.cache[c.start].ID != -1 {
c.end = ((c.end-1)%ActivityLimit + ActivityLimit) % ActivityLimit
}
for i := range recvLength {
ale := al.Items[i]
ci := (c.start + i) % ActivityLimit
if c.cache[ci].ID != -1 {
// Since we're overwriting it, remove it from index
delete(c.byEntryID, c.cache[ci].ID)
// don't increment total since we're adding and removing
} else {
c.Total++
}
if ale.UserID != "" {
arr, ok := c.byUserID[ale.UserID]
if !ok {
arr = make([]int, 0, ByUserLengthOrBaseLength)
}
if !ByUserLimitLength || len(arr) < ByUserLengthOrBaseLength {
arr = append(arr, ci)
c.byUserID[ale.UserID] = arr
}
}
c.cache[ci] = ale
c.byEntryID[ale.ID] = ci
}
// If this was the first sync, everything has already been processed in the previous loop.
if previousStart != -1 {
i := previousStart
for {
if c.cache[i].UserID != "" {
arr, ok := c.byUserID[c.cache[i].UserID]
if !ok {
arr = make([]int, 0, ByUserLengthOrBaseLength)
}
if !ByUserLimitLength || len(arr) < ByUserLengthOrBaseLength {
arr = append(arr, i)
c.byUserID[c.cache[i].UserID] = arr
}
}
if i == c.end {
break
}
i = (i + 1) % ActivityLimit
}
}
}
// for i := range c.cache {
// fmt.Printf("%04d|", i)
// }
// fmt.Print("\n")
// for i := range c.cache {
// fmt.Printf("%04d|", c.cache[i].ID)
// }
// fmt.Print("\n")
c.syncLock.Lock()
c.LastSync = thisSync
if recvLength > 0 {
c.LastYieldingSync = thisSync
}
c.syncing = false
c.syncLock.Unlock()
} else {
for c.syncing {
continue
}
}
status <- nil
}(syncStatus, c)
err := <-syncStatus
return err
}

136
jf_activity_test.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"sync"
"testing"
"time"
"github.com/hrfee/mediabrowser"
)
type MockActivityLogSource struct {
logs []mediabrowser.ActivityLogEntry
lock sync.Mutex
i int
}
func (m *MockActivityLogSource) run(size int, delay time.Duration, finished *bool) {
m.logs = make([]mediabrowser.ActivityLogEntry, size)
for i := range len(m.logs) {
m.logs[i].ID = -1
}
m.i = 0
for i := range len(m.logs) {
m.lock.Lock()
log := mediabrowser.ActivityLogEntry{
ID: int64(i),
Date: mediabrowser.Time{time.Now()},
}
m.logs[i] = log
m.i = i + 1
m.lock.Unlock()
time.Sleep(delay)
}
*finished = true
time.Sleep(delay)
}
func (m *MockActivityLogSource) GetActivityLog(skip, limit int, since time.Time, hasUserID bool) (mediabrowser.ActivityLog, error) {
// This may introduce duplicates, but those are handled fine.
// If we don't do this, things go wrong in a way that seems
// very specific to this test setup, and (imo) is not necessarily
// applicable to a real scenario.
// since = since.Add(-time.Millisecond)
out := make([]mediabrowser.ActivityLogEntry, 0, limit)
count := 0
loopCount := 0
m.lock.Lock()
for i := m.i - 1; count < limit && i >= 0; i-- {
loopCount++
if m.logs[i].Date.After(since) {
out = append(out, m.logs[i])
count++
}
}
m.lock.Unlock()
return mediabrowser.ActivityLog{Items: out}, nil
}
func TestJFActivityLog(t *testing.T) {
t.Parallel()
// FIXME: This test is failing
t.Run("Completeness", func(t *testing.T) {
mock := MockActivityLogSource{}
waitForSync := time.Microsecond
cache := NewJFActivityCache(&mock, waitForSync)
finished := false
count := len(cache.cache) - 10
go mock.run(count, time.Millisecond, &finished)
for {
if err := cache.MaybeSync(); err != nil {
t.Errorf("sync failed: %v", err)
return
}
if cache.dupesInLastSync > 1 {
t.Logf("got %d dupes in last sync\n", cache.dupesInLastSync)
}
if finished {
// Make sure we got everything
time.Sleep(5 * waitForSync)
if err := cache.MaybeSync(); err != nil {
t.Errorf("sync failed: %v", err)
return
}
break
}
}
t.Log(">-\n" + cache.debugString())
if cache.Total != count {
t.Errorf("not all collected: %d < %d", cache.Total, count)
}
})
t.Run("Ordering", func(t *testing.T) {
mock := MockActivityLogSource{}
waitForSync := 300 * time.Microsecond
cache := NewJFActivityCache(&mock, waitForSync)
finished := false
count := len(cache.cache) * 2
go mock.run(count, time.Millisecond, &finished)
for {
if err := cache.MaybeSync(); err != nil {
t.Errorf("sync failed: %v", err)
return
}
if finished {
// Make sure we got everything
time.Sleep(waitForSync)
if err := cache.MaybeSync(); err != nil {
t.Errorf("sync failed: %v", err)
return
}
break
}
}
t.Log(">-\n" + cache.debugString())
i := cache.start
lastID := int64(-1)
t.Logf("cache start=%d, end=%d, total=%d\n", cache.start, cache.end, cache.Total)
for {
if i != cache.start {
if cache.cache[i].ID != lastID-1 {
t.Errorf("next was not previous ID: %d != %d-1 = %d", cache.cache[i].ID, lastID, lastID-1)
return
}
}
lastID = cache.cache[i].ID
if i == cache.end {
break
}
i = (i + 1) % len(cache.cache)
}
})
}

View File

@@ -11,16 +11,16 @@
"inviteHours": "ساعات",
"inviteMinutes": "دقائق",
"inviteNumberOfUses": "عدد الاستخدامات",
"inviteDuration": "مدة الدعوة",
"inviteDuration": "صلاحية الدعوة",
"warning": "تحذير",
"inviteInfiniteUsesWarning": "الدعوات ذات الاستخدامات اللانهائية يمكن ان تستخدم بشكل مسيئ",
"inviteInfiniteUsesWarning": "الدعوات ذات الاستخدامات اللامحدودة يمكن إساءة استخدامها",
"inviteSendToEmail": "إرسال إلى",
"create": "إنشاء",
"apply": "تطبيق",
"select": "تحديد",
"name": "الاسم",
"date": "التاريخ",
"setExpiry": "تعيين انتهاء الصلاحية",
"setExpiry": "تعيين مدة الصلاحية",
"updates": "التحديثات",
"update": "تحديث",
"download": "تنزيل",
@@ -30,195 +30,316 @@
"from": "من",
"after": "بعد",
"before": "قبل",
"user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
"aboutProgram": "حول",
"user": "المستخدم",
"userExpiry": "صلاحية المستخدم",
"userExpiryDescription": "عند التفعيل، سيقوم jfa-go بحذف/تعطيل الحساب بعد وقت محدد من التسجيل عبر الدعوة. يمكنك اختيار الإجراء في الإعدادات.",
"aboutProgram": "نُبذة",
"version": "إصدار",
"commitNoun": "فرض",
"commitNoun": "تعديل",
"newUser": "مستخدم جديد",
"profile": "حساب تعريفي",
"profile": "ملف التعريف",
"unknown": "غير معروف",
"label": "وسم",
"label": "الوسم",
"logs": "السجلات",
"announce": "إعلان",
"templates": "قوالب",
"templates": "القوالب",
"subject": "الموضوع",
"message": "الرسالة",
"variables": "المتغيرات",
"conditionals": "",
"conditionals": "الاشتراطات",
"preview": "معاينة",
"reset": "إعادة ضبط",
"donate": "تبرع",
"reset": "إعادة التعيين",
"donate": "تبرّع",
"unlink": "إلغاء ربط الحساب",
"sendPWR": "إرسال إعادة تعيين كلمة المرور",
"contactThrough": "تواصل عن طريق:",
"extendExpiry": "تمديد إنتهاء الصلاحية",
"sendPWRManual": "",
"contactThrough": "تواصل عبر:",
"extendExpiry": "تمديد مدة الصلاحية",
"sendPWRManual": "المستخدم {n} ليس لديه أي وسيلة اتصال، اضغط \"نسخ\" لتحصل على رابط لإرساله إليه.",
"sendPWRSuccess": "تم إرسال رابط إعادة تعيين كلمة المرور.",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
"sendPWRSuccessManual": "إذا لم يستلمه المستخدم، فاضغط \"نسخ\" للحصول على رابط لإرساله إليه يدوياً.",
"sendPWRValidFor": "الرابط صالح لمدة 30 دقيقة.",
"customizeMessages": "تخصيص الرسائل",
"customizeMessagesDescription": "إن لم ترغب في استخدام قوالب رسائل jfa-go، يمكنك إنشاء قوالب مخصصة باستخدام ترميز Markdown.",
"markdownSupported": "ترميز Markdown مدعوم.",
"modifySettings": "تغيير الإعدادات",
"modifySettingsDescription": "طبّق الإعدادات من ملف تعريف موجود، أو انسخها مباشرة من مستخدم.",
"applyHomescreenLayout": "تطبيق مخطط الصفحة الرئيسية",
"sendDeleteNotificationEmail": "إرسال رسالة إشعار",
"sendDeleteNotifiationExample": "تم حذف حسابك.",
"settingsRestart": "اعاده تشغيل",
"settingsRestarting": "اعاده التشغيل…",
"settingsRestartRequired": جب اعاده التشغيل",
"settingsRestartRequiredDescription": جب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
"settingsApplied": "تم تطبيق الاعدادات.",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
"settingsRestart": "إعادة التشغيل",
"settingsRestarting": "يتم إعادة التشغيل…",
"settingsRestartRequired": لزم إعادة التشغيل",
"settingsRestartRequiredDescription": لزم إعادة التشغيل لتطبيق بعض الإعدادات التي تم تغييرها. هل ترغب بإعادة التشغيل الآن أم لاحقاً؟",
"settingsApplyRestartLater": "تطبيق، إعادة التشغيل لاحقاً",
"settingsApplyRestartNow": "تطبيق وإعادة التشغيل",
"settingsApplied": "تم تطبيق الإعدادات.",
"settingsRefreshPage": "حدّث الصفحة بعد عدة ثوانٍ.",
"settingsRequiredOrRestartMessage": "ملاحظة: {n} تشير إلى حقل مطلوب، و{n} تشير إلى أن التغييرات تتطلب إعادة التشغيل.",
"settingsSave": "حفظ",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"ombiProfile": "ملف تعريف مستخدم Ombi",
"ombiUserDefaultsDescription": "أنشئ مستخدم Ombi وقم بإعداده، ثم اختره أدناه. سيتم تخزين إعداداته/أذوناته وتطبيقها على مستخدمي Ombi الجدد الذين أُنشئوا بواسطة jfa-go عند اختيار ملف التعريف هذا.",
"userProfiles": "ملفات التعريف",
"userProfilesDescription": "تُطبّق ملفات التعريف على المستخدمين عند إنشاء حساباتهم. يشمل ملف التعريف صلاحيات الوصول للمكتبات ومخطط الصفحة الرئيسية.",
"userProfilesIsDefault": "الملف الافتراضي",
"userProfilesLibraries": "المكتبات",
"addProfile": "إضافة ملف تعريف",
"addProfileDescription": "أنشئ مستخدم Jellyfin وقم بإعداده، ثم اختره أدناه. عند تطبيق ملف التعريف هذا على دعوة، ستُطبّق إعداداته على المستخدمين المُنشئين من خلال تلك الدعوة.",
"addProfileNameOf": "اسم ملف التعريف",
"addProfileStoreHomescreenLayout": "تخزين مخطط الصفحة الرئيسية",
"inviteNoUsersCreated": "لا أحد حتى الآن!",
"inviteUsersCreated": "المستخدمون المنشئون",
"inviteNoProfile": "بدون ملف تعريف",
"inviteDateCreated": "أُنشئَت في",
"inviteRemainingUses": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": "",
"activity": "الانشطه",
"inviteNoInvites": "لا شيء",
"inviteExpiresInTime": "تنتهي بعد {n}",
"notifyEvent": "الإبلاغ عند:",
"notifyInviteExpiry": "عند انتهاء الصلاحية",
"notifyUserCreation": "عند إنشاء مستخدم",
"sendPIN": "اطلب من المستخدم إرسال الرمز أدناه إلى البوت.",
"searchDiscordUser": "أدخل اسم مستخدم Discord للعثور عليه.",
"findDiscordUser": "ابحث عن مستخدم Discord",
"linkMatrixDescription": "أدخل اسم المستخدم وكلمة المرور للمستخدم المراد استخدامه كبوت. بعد إدخالهما، سيُعاد تشغيل التطبيق.",
"matrixHomeServer": "عنوان الخادم الرئيسي",
"saveAsTemplate": "حفظ كقالب",
"deleteTemplate": "حذف القالب",
"templateEnterName": "أدخل اسماً لحفظ هذا القالب.",
"accessJFA": "مسؤول في jfa-go",
"accessJFASettings": "لا يمكن تغيير ذلك حيث تم تفعيل \"المسؤول فقط\" أو \"السماح للجميع\" في الإعدادات > عام.",
"sortingBy": "الفرز حسب",
"filters": "المُرشِّحات",
"clickToRemoveFilter": "اضغط لإزالة المُرشِّح.",
"clearSearch": "إلغاء البحث",
"actions": "الإجراءات",
"searchOptions": "خيارات البحث",
"matchText": "مطابقة النص",
"jellyfinID": "مُعرّف Jellyfin",
"userPageLogin": "صفحة المستخدم: تسجيل الدخول",
"userPagePage": "صفحة المستخدم: الصفحة",
"buildTime": "وقت بناء النُسخة",
"builtBy": "بُنيَت بواسطة",
"activity": "الأنشطة",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
"userLabelDescription": "وسم يتم تطبيقه على المستخدمين المُنشئين بهذه الدعوة.",
"enableReferrals": "تفعيل الإحالات",
"disableReferrals": "تعطيل الإحالات",
"invite": "دعوة",
"enableReferralsProfileDescription": "امنح المستخدمين المنشئين بملف التعريف هذا رابط إحالة شخصي شبيه بالدعوة، لإرساله إلى الأصدقاء/العائلة. أنشئ دعوة بالإعدادات المطلوبة، ثم اخترها هنا. ستستند كل إحالة بعد ذلك إلى هذه الدعوة. يمكنك حذف الدعوة بمجرد الانتهاء.",
"enableReferralsDescription": "امنح المستخدمين رابط إحالة شخصي شبيه بالدعوة، لإرساله إلى الأصدقاء/العائلة. تستند الإحالة إلى قالب الإحالة في ملف التعريف، أو من دعوة موجودة.",
"disabled": "معطّل",
"wikiPage": "صفحة الويكي",
"wiki": "الويكي",
"enterExpiry": "أدخل تاريخ انتهاء الصلاحية",
"removeExpiry": "إزالة مدة الصلاحية",
"useInviteExpiry": "عيّن مدة الصلاحية من ملف التعريف/الدعوة",
"extendFromPreviousExpiryDescription": "إذا عُثر على تاريخ انتهاء الصلاحية لمستخدم منتهي الصلاحية بالفعل، فسيتم تمديد المدة من ذلك التاريخ، بدلاً من التاريخ الحالي، إلا إذا كان التاريخ الجديد لانتهاء الصلاحية سيكون قد انقضى بالفعل.",
"deleted": "محذوف",
"keepSearching": "واصل البحث",
"keepSearchingDescription": "تم البحث فقط في الأنشطة الحالية المُحمّلة. اضغط أدناه للبحث في جميع الأنشطة.",
"noResultsFound": "لا توجد نتائج",
"extendFromPreviousExpiry": "تمديد من تاريخ انتهاء الصلاحية السابق (إن أمكن)",
"useInviteExpiryNote": "تنتهي صلاحية الدعوات بشكل افتراضي بعد 90 يوم، ولكن يمكن للمستخدم تجديدها. فعّل هذه الخيار لتعطيل الإحالة بعد الوقت المحدد.",
"noResultsFoundLocally": "تم البحث في السجلات المُحمّلة فقط. يمكنك تحميل المزيد، أو البحث في جميع سجلات الخادم.",
"applyConfigurationAndPolicy": "تطبيق إعدادات/سياسة Jellyfin",
"applyOmbi": "تطبيق ملف تعريف Ombi (إن وُجد)",
"applyJellyseerr": "تطبيق ملف تعريف Jellyseerr (إن وُجد)",
"settingsHiddenDependency": "الإعدادات المطابقة لبحثك مخفية لأنها تعتمد على قيمة إعداد آخر:",
"settingsDependsOn": "{setting}: يعتمد على {dependency}",
"settingsAdvancedMode": "{setting}: يجب تفعيل الإعدادات المتقدمة",
"settingsMaybeUnderAdvanced": "تلميح: قد تجد ما تبحث عنه عند تفعيل الإعدادات المتقدمة.",
"jellyseerrProfile": "ملف تعريف مستخدم Jellyseerr",
"jellyseerrUserDefaultsDescription": "أنشئ مستخدم Jellyseerr وقم بإعداده، ثم اختره أدناه. سيتم تخزين إعداداته/أذوناته وتطبيقها على مستخدمي Jellyseerr الجدد الذين أُنشئوا بواسطة jfa-go عند اختيار ملف التعريف هذا.",
"sortDirection": "اتجاه الفرز",
"searchAllRecords": "بحث/فرز جميع السجلات (على الخادم)",
"backups": "النسخ الاحتياطية",
"backupsCopy": "عند استعادة نسخة احتياطية، سيتم إنشاء نسخة من مجلد \"db\" الأصلي بجواره، تحسبًا لحدوث أي خطأ.",
"backupCanDownload": "يمكنك أيضاً الضغط أدناه لتنزيل النسخة الاحتياطية.",
"sentTo": "مرسلة إلى",
"tasks": "المهام",
"editProfile": "تعديل ملف التعريف",
"editProfileDescription": "لإجراء تغييرات كبيرة، يُنصح بتعديل الإعدادات في Jellyfin/Jellyseerr/Ombi وإعادة إنشاء ملف التعريف، كما يمكنك أيضاً إجراء تغييرات مباشرة هنا. يُرجى توخي الحذر عند التعديل.",
"tasksDescription": "المهام هي إجراءات كبيرة تُنفَّذ دورياً في الخلفية. يمكنك تشغيلها يدوياً هنا إذا أردت.",
"run": "تشغيل",
"addProfileStoreJellyseerr": "إنشاء ملف تعريف Jellyseerr",
"preSignupCard": "بطاقة التعليمات قبل التسجيل",
"preSignupCardDescription": "بطاقة اختيارية تظهر في صفحة التسجيل.",
"byAdmin": "بواسطة المسؤول",
"byUser": "بواسطة المستخدم",
"byJfaGo": "بواسطة jfa-go",
"accountExpired": "انتهت صلاحية حساب: {user}",
"inviteDeleted": "تم حذف دعوة: {invite}",
"inviteExpired": "انتهت صلاحية دعوة: {invite}",
"fromInvite": "عبر دعوة",
"activityID": "مُعرّف النشاط",
"title": "العنوان",
"usersMentioned": "المستخدم المذكور",
"actor": "المسبب",
"actorDescription": "الشيء الذي تسبب في هذا الإجراء. \"user\"/\"admin\"/\"daemon\" أو اسم مستخدم.",
"accountDisabledFilter": "حساب تم تعطيله",
"accountEnabledFilter": "حساب تم تفعيله",
"accountCreationFilter": "حساب أُنشئ",
"accountDeletionFilter": "حساب حُذِف",
"totalRecords": "{n} سجل بالمجمل",
"noMoreResults": "لا توجد نتائج أخرى.",
"loadMore": "تحميل المزيد",
"loadAll": "تحميل الكل",
"contactLinkedFilter": "وسيلة اتصال رُبِطت",
"contactUnlinkedFilter": "وسيلة اتصال أُزيلت",
"passwordChangeFilter": "كلمة مرور تغيّرت",
"passwordResetFilter": "كلمة مرور أُعيد تعيينها",
"inviteCreatedFilter": "دعوة أُنشئت",
"inviteDeletedFilter": "دعوة حُذِفت/انتهت",
"loadedRecords": "{n} محمّل",
"shownRecords": "{n} معروض",
"selectedRecords": "{n} محدد",
"allMatchingSelected": "جميع النتائج المطابقة محددة.",
"allLoadedSelected": "جميع النتائج المطابقة المحمّلة محددة. اضغط مرة أخرى لتحميل الكل.",
"restartRequired": "يلزم إعادة التشغيل",
"syntax": "الصياغة",
"syntaxDescription": "المتغيرات يشار إليها بـ {variable}. يمكن للعبارات الشرطية (if) تقييم مدى الصحة (مثل {ifTruth}) أو إجراء مقارنات بسيطة (مثل {ifCompare})",
"postSignupCard": "بطاقة التعليمات بعد التسجيل",
"postSignupCardDescription": "البطاقة التي تظهر للمستخدم بعد التسجيل. تَستبدل \"رسالة النجاح\". يستبدلها إعداد \"Auto redirect on success\".",
"buildTags": "وسوم النُسخة",
"loginNotAdmin": "لست المسؤول؟",
"referrer": "المُحيل (إحالة)",
"accountResetPassword": "{user} أعاد تعيين كلمة مروره",
"accountChangedPassword": "{user} غير كلمة مروره",
"accountCreated": "تم إنشاء حساب: {user}",
"accountReEnabled": "تم إعادة تفعيل حساب: {user}",
"accountDeleted": "تم حذف حساب: {user}",
"accountDisabled": "تم تعطيل حساب: {user}",
"accountUnlinked": "{user}: أزال {contactMethod}",
"accountLinked": "{user}: ربط {contactMethod}",
"backupsDescription": "يمكن إجراء نسخ احتياطية لقاعدة البيانات أو استعادتها أو تنزيلها من هنا.",
"backupsFormatNote": "لن يتم عرض سوى النسخ الاحتياطية المسمّاة بالصيغة الأساسية. لاستخدام أي صيغة أخرى، قم برفع النسخة الاحتياطية يدوياً.",
"backupDownloadRestore": "تنزيل / استعادة",
"backupUpload": "رفع واستعادة نسخة احتياطية",
"backupDownload": "تنزيل النسخة الاحتياطية",
"backupRestore": "استعادة النسخة الاحتياطية",
"backupNow": "إنشاء نسخة احتياطية",
"backupCreated": "تم إنشاء النسخة الاحتياطية",
"backupCanBeFound": "يمكن العثور على النسخة الاحتياطية على الخادم في {filepath}.",
"required": "مطلوب",
"searchAll": "بحث/فرز الكل",
"accountWillExpire": "ستنتهي صلاحية الحساب في {date}.",
"expirationBasedOn": "التاريخ المحدد مبني على أول مستخدم.",
"inviteCreated": "تم إنشاء دعوة: {invite}",
"userDeleted": "تم حذف المستخدم.",
"userDisabled": "تم تعطيل المستخدم"
},
"notifications": {
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
"changedEmailAddress": "تم تغيير عنوان البريد الإلكتروني لـ {n}.",
"userCreated": "تم إنشاء المستخدم {n}.",
"createProfile": "تم إنشاء ملف التعريف {n}.",
"saveSettings": "تم حفظ الإعدادات",
"saveEmail": "تم حفظ البريد الإلكتروني.",
"sentAnnouncement": "تم إرسال الإعلان.",
"savedAnnouncement": "تم حفظ الإعلان.",
"setOmbiProfile": "تم تخزين ملف تعريف Ombi.",
"updateApplied": "تم تطبيق التحديث، يرجى إعادة التشغيل.",
"updateAppliedRefresh": "تم تطبيق التحديث، يرجى تحديث الصفحة.",
"telegramVerified": "تم تأكيد حساب Telegram.",
"accountConnected": "تم ربط الحساب.",
"errorSettingsAppliedNoHomescreenLayout": "تم تطبيق الإعدادات، ولكن ربما فشل تطبيق مخطط الصفحة الرئيسية.",
"errorHomescreenAppliedNoSettings": "تم تطبيق مخطط الصفحة الرئيسية، ولكن ربما فشل تطبيق الإعدادات.",
"errorSettingsFailed": "فشل التطبيق.",
"errorSaveEmail": "فشل حفظ البريد الإلكتروني.",
"errorBlankFields": "تُركت الحقول فارغة",
"errorDeleteProfile": "فشل حذف ملف التعريف {n}",
"errorLoadProfiles": "فشل تحميل ملفات التعريف.",
"errorCreateProfile": "فشل إنشاء ملف التعريف {n}",
"errorSetDefaultProfile": "فشل تعيين ملف التعريف الافتراضي.",
"errorLoadUsers": "فشل تحميل المستخدمين.",
"errorLoadSettings": "فشل تحميل الإعدادات.",
"errorSetOmbiProfile": "فشل تخزين ملف تعريف Ombi.",
"errorLoadOmbiUsers": "فشل تحميل مستخدمي Ombi.",
"errorChangedEmailAddress": "تعذر تغيير عنوان البريد الإلكتروني لـ {n}.",
"errorFailureCheckLogs": "فشل (تحقق من لوحة التحكم/السجلات)",
"errorPartialFailureCheckLogs": "فشل جزئي (تحقق من لوحة التحكم/السجلات)",
"errorUserCreated": "فشل إنشاء المستخدم {n}.",
"errorSendWelcomeEmail": "فشل إرسال رسالة الترحيب (تحقق من لوحة التحكم/السجلات)",
"errorApplyUpdate": "فشل تطبيق التحديث، حاول يدوياً.",
"errorCheckUpdate": "فشل التحقق من التحديثات.",
"updateAvailable": "يتوفر تحديث جديد، تحقق من الإعدادات.",
"noUpdatesAvailable": "لا توجد تحديثات جديدة متاحة.",
"errorInviteNoLongerExists": "الدعوة لم تعد موجودة.",
"pathCopied": "تم نسخ المسار الكامل إلى الحافظة.",
"errorInvalidAddress": "عنوان/اسم غير صالح",
"referralsEnabled": "تم تفعيل الإحالات.",
"activityDeleted": "تم حذف النشاط.",
"errorLoadProfile": "فشل تحميل ملف التعريف.",
"errorCheckLogs": "تحقق من لوحة التحكم/السجلات",
"errorInvalidJSON": "JSON غير صالح.",
"runTask": "تم تشغيل المهمة.",
"errorMultiUser": "تم العثور على عدة مستخدمين متطابقين",
"errorNoUser": "لم يتم العثور على مستخدم مطابق",
"errorInviteNotFound": "الدعوة غير موجودة.",
"errorNoReferralTemplate": "لا يحتوي ملف التعريف على قالب إحالة، أضف واحداً في الإعدادات.",
"errorLoadActivities": "فشل تحميل الأنشطة.",
"errorInvalidDate": "التاريخ غير صالح.",
"savedProfile": "تم تخزين تغييرات ملف التعريف.",
"errorSavedProfile": "فشل حفظ ملف التعريف {n}"
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
"singular": "تغيير الإعدادات لمستخدم واحد",
"plural": "تغيير الإعدادات لـ {n} من المستخدمين"
},
"deleteNUsers": {
"singular": "",
"plural": ""
"singular": "حذف مستخدم واحد",
"plural": "حذف {n} من المستخدمين"
},
"disableUsers": {
"singular": "",
"plural": ""
"singular": "تعطيل مستخدم واحد",
"plural": "تعطيل {n} من المستخدمين"
},
"reEnableUsers": {
"singular": "",
"plural": ""
"singular": "إعادة تفعيل مستخدم واحد",
"plural": "إعادة تفعيل {n} من المستخدمين"
},
"addUser": {
"singular": "",
"plural": ""
"singular": "إضافة مستخدم",
"plural": "إضافة مستخدمين"
},
"deleteUser": {
"singular": "",
"plural": ""
"singular": "حذف المستخدم",
"plural": "حذف المستخدمين"
},
"deletedUser": {
"singular": "",
"plural": ""
"singular": "تم حذف مستخدم واحد.",
"plural": "تم حذف {n} من المستخدمين."
},
"disabledUser": {
"singular": "",
"plural": ""
"singular": "تم تعطيل مستخدم واحد.",
"plural": "تم تعطيل {n} من المستخدمين."
},
"enabledUser": {
"singular": "",
"plural": ""
"singular": "تم تفعيل مستخدم واحد.",
"plural": "تم تفعيل {n} من المستخدمين."
},
"announceTo": {
"singular": "",
"plural": ""
"singular": "الإعلان إلى مستخدم واحد",
"plural": "الإعلان إلى {n} من المستخدمين"
},
"appliedSettings": {
"singular": "",
"plural": ""
"singular": "تم تطبيق الإعدادات على مستخدم واحد.",
"plural": "تم تطبيق الإعدادات على {n} من المستخدمين."
},
"extendExpiry": {
"singular": "",
"plural": ""
"singular": "تمديد مدة الصلاحية لمستخدم واحد",
"plural": "تمديد مدة الصلاحية لـ {n} من المستخدمين"
},
"setExpiry": {
"singular": "",
"plural": ""
"singular": "تعيين مدة الصلاحية لمستخدم واحد",
"plural": "تعيين مدة الصلاحية لـ {n} من المستخدمين"
},
"extendedExpiry": {
"singular": "",
"plural": ""
"singular": "تم تمديد مدة الصلاحية لمستخدم واحد.",
"plural": "تم تمديد مدة الصلاحية لـ {n} من المستخدمين."
},
"enableReferralsFor": {
"singular": "تفعيل الإحالات لمستخدم واحد",
"plural": "تفعيل الإحالات لـ {n} من المستخدمين"
}
}
}

View File

@@ -17,6 +17,7 @@
"warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to",
"sentTo": "Sent to",
"create": "Create",
"apply": "Apply",
"select": "Select",
@@ -155,6 +156,8 @@
"userPagePage": "User Page: Page",
"postSignupCard": "Post-signup help card",
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"preSignupCard": "Pre-signup help card",
"preSignupCardDescription": "Optional card shown on the sign-up page.",
"buildTime": "Build Time",
"builtBy": "Built By",
"buildTags": "Build Tags",
@@ -219,7 +222,9 @@
"wikiPage": "Wiki Page",
"wiki": "Wiki",
"restartRequired": "Restart required",
"required": "Required"
"required": "Required",
"syntax": "Syntax",
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
@@ -257,6 +262,7 @@
"errorLoadOmbiUsers": "Failed to load ombi users.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
"errorFailureCheckLogs": "Failed (check console/logs)",
"errorCheckLogs": "Check console/logs",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
"errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
@@ -268,7 +274,10 @@
"errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task."
"runTask": "Triggered task.",
"errorMultiUser": "Multiple matching users found",
"errorNoUser": "No matching user found",
"errorInvalidAddress": "Invalid address/name"
},
"quantityStrings": {
"modifySettingsFor": {

View File

@@ -5,60 +5,67 @@
"strings": {
"username": "اسم المستخدم",
"password": "كلمة المرور",
"emailAddress": "البريد الالكتروني",
"emailAddress": "عنوان البريد الإلكتروني",
"name": "الاسم",
"submit": "ادخال",
"success": "نجاح",
"continue": "اكمل",
"submit": "إرسال",
"success": "تم",
"continue": "متابعة",
"error": "خطأ",
"copy": "نسخ",
"time24h": "توقيت 24 ساعة",
"time12h": "توقيت 12 ساعة",
"linkTelegram": ابط تلغرام",
"contactTelegram": "التواصل عبر التلغرام",
"linkDiscord": ابط الدسكورد",
"linkTelegram": "ربط Telegram",
"contactTelegram": "التواصل عبر Telegram",
"linkDiscord": "ربط Discord",
"linkMatrix": "ربط Matrix",
"contactDiscord": "التواصل عبر الدسكورد",
"theme": "القالب",
"contactDiscord": "التواصل عبر Discord",
"theme": "السمة",
"refresh": "تحديث",
"required": "مطلوب",
"login": "تسجيل الدخول",
"admin": "المسؤول",
"reEnable": "اعادة تفعيل",
"disable": جميد",
"reEnable": "إعادة تفعيل",
"disable": عطيل",
"accountStatus": "حالة الحساب",
"notSet": "لم تحدد",
"expiry": "انتهاء الصلاحية",
"add": "اضافة",
"add": "إضافة",
"edit": "تعديل",
"delete": "حذف",
"myAccount": "حسابي",
"disabled": "معطل",
"enabled": "مفعل",
"send": "ارسال",
"disabled": "معطّل",
"enabled": "مفعّل",
"send": "إرسال",
"copied": "تم النسخ",
"contactEmail": "التواصل عبر البريد الالكتروني",
"contactEmail": "التواصل عبر البريد",
"logout": "تسجيل الخروج",
"contactMethods": "وسيلة التواصل"
"contactMethods": "وسائل الاتصال",
"referrals": "الإحالات",
"inviteRemainingUses": "الاستخدامات المتبقية",
"internal": "داخلي",
"external": "خارجي",
"sent": "تم إرساُلها",
"failed": "فشل"
},
"notifications": {
"errorUnknown": "خطأ غير معروف.",
"error401Unauthorized": "غير مخول. حاول تحديث الصفحة.",
"errorSaveSettings": "لا يمكن حفظ الاعدادات.",
"errorLoginBlank": "اسم المستخدم و/أو كلمة المرور لم يتم ادخالها.",
"errorConnection": "لا يمكن الاتصال بـالبرنامج."
"error401Unauthorized": "غير مخوّل. حاول تحديث الصفحة.",
"errorSaveSettings": "تعذر حفظ الإعدادات.",
"errorLoginBlank": "اسم المستخدم و/أو كلمة المرور تُرِكا فارغَين.",
"errorConnection": "تعذر الاتصال بـ jfa-go.",
"errorSpecialSymbols": "لا يمكن أن يحتوي الحقل على رموز خاصة."
},
"quantityStrings": {
"year": {
"singular": "{n} سنة",
"singular": "سنة",
"plural": "{n} سنوات"
},
"month": {
"singular": "{n} شهر",
"singular": "شهر",
"plural": "{n} أشهر"
},
"day": {
"singular": "{n} يوم",
"singular": "يوم",
"plural": "{n} أيام"
}
}

View File

@@ -3,12 +3,14 @@
"name": "English (US)"
},
"strings": {
"language": "Language",
"username": "Username",
"password": "Password",
"emailAddress": "Email Address",
"name": "Name",
"submit": "Submit",
"send": "Send",
"sent": "Sent",
"success": "Success",
"continue": "Continue",
"error": "Error",
@@ -43,7 +45,8 @@
"referrals": "Referrals",
"inviteRemainingUses": "Remaining uses",
"internal": "Internal",
"external": "External"
"external": "External",
"failed": "Failed"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "انگلیسی"
"name": "انگلیسی (FA)"
},
"strings": {
"username": "نام کاربری",

View File

@@ -3,64 +3,64 @@
"name": "العربية (AR)"
},
"strings": {
"ifItWasNotYou": "اذا لم يكن هذا انت، الرجاء تجاهل هذا.",
"helloUser": "مرحباً {username}،",
"ifItWasNotYou": "اذا لم يكن هذا أنت، الرجاء تجاهل هذه الرسالة.",
"helloUser": "أهلاً {username}،",
"reason": "السبب"
},
"userCreated": {
"name": "إنشاء حساب",
"title": "ملاحظة: تم إنشاء الحساب",
"aUserWasCreated": "تم إنشاء الحساب بواسطة الرمز {code}.",
"name": "إنشاء مستخدم",
"title": "إشعار: تم إنشاء المستخدم",
"aUserWasCreated": "تم إنشاء مستخدم باستخدام الرمز {code}.",
"time": "الوقت",
"notificationNotice": "ملاحظة: الرسائل التذكيرية يمكن تعديلها في لوحة التحكم."
},
"inviteExpiry": {
"name": "انتهاء صلاحية الدعوة",
"title": "ملاحظة: انتهت صلاحية الدعوة",
"title": "إشعار: انتهت صلاحية الدعوة",
"inviteExpired": "انتهت صلاحية الدعوة.",
"expiredAt": "انتهت صلاحية الرمز {code} في {time} .",
"notificationNotice": "ملاحظة: الرسائل التذكيرية يمكن تعديلها في لوحة التحكم."
},
"passwordReset": {
"name": "إعادة تعيين كلمة المرور",
"title": "تم طلب إعادة تعيين كلمة المرور - Jellyfin",
"title": "طلب إعادة تعيين كلمة المرور - Jellyfin",
"someoneHasRequestedReset": "قام شخص ما بطلب إعادة تعيين كلمة المرور مؤخرا.",
"ifItWasYou": "إذا كان هذا انت، أدخل رمز التعريف الشخصي أدناه في الخانة.",
"ifItWasYouLink": "إذا كان هذا انت، اضغط على الرابط أدناه.",
"ifItWasYou": "إذا كان هذا انت، أدخل الرمز أدناه في الخانة.",
"ifItWasYouLink": "إذا كان هذا انت، اضغط الرابط أدناه.",
"codeExpiry": "ستنتهي صلاحية الرمز في {date}، {time} UTC، خلال {expiresInMinutes}.",
"pin": "رمز التعريف الشخصي"
"pin": "الرمز"
},
"userDeleted": {
"name": "حذف المستخدم",
"title": "لقد تم حذف حسابك - Jellyfin",
"name": "حذف مستخدم",
"title": "تم حذف حسابك - Jellyfin",
"yourAccountWasDeleted": "لقد تم حذف حسابك في Jellyfin."
},
"userDisabled": {
"name": "تعطيل المستخدم",
"title": "لقد تم تعطيل حسابك - Jellyfin",
"name": "تعطيل مستخدم",
"title": "تم تعطيل حسابك - Jellyfin",
"yourAccountWasDisabled": "لقد تم تعطيل حسابك."
},
"userEnabled": {
"name": "تفعيل المستخدم",
"title": "لقد تم تفعيل حسابك - Jellyfin",
"name": "تفعيل مستخدم",
"title": "تم تفعيل حسابك - Jellyfin",
"yourAccountWasEnabled": "لقد تم تفعيل حسابك."
},
"inviteEmail": {
"name": "دعوة البريد الإلكتروني",
"name": "بريد الدعوة",
"title": "دعوة - Jellyfin",
"hello": "مرحباً",
"hello": "أهلاً",
"youHaveBeenInvited": "تمت دعوتك إلى Jellyfin.",
"toJoin": "للإنضمام، اتبع الرابط أدناه.",
"inviteExpiry": "ستنتهي صلاحية الدعوة في {date} {time}، خلال {expiresInMinutes}، اتخذ اجراءاً.",
"linkButton": "قم بإعداد حسابك"
},
"welcomeEmail": {
"name": "مرحباً",
"name": "الترحيب",
"title": "مرحباً في Jellyfin",
"welcome": "مرحباً في Jellyfin!",
"youCanLoginWith": "يمكنك تسجيل الدخول بإستخدام المعلومات أدناه",
"yourAccountWillExpire": "ستنتهي صلاحية حسابك في {date}.",
"jellyfinURL": "رابط"
"jellyfinURL": "الرابط"
},
"emailConfirmation": {
"name": "بريد التحقق",
@@ -69,9 +69,21 @@
"confirmEmail": "تأكيد البريد الإلكتروني"
},
"userExpired": {
"name": "انتهاء صلاحية المستخدم",
"name": "انتهاء صلاحية مستخدم",
"title": "انتهت صلاحية حسابك - Jellyfin",
"yourAccountHasExpired": "انتهت صلاحية حسابك.",
"contactTheAdmin": "تواصل مع المشرفين للمزيد من المعلومات."
},
"expiryReminder": {
"name": "تذكير انتهاء الصلاحية",
"title": "تذكير: ستنتهي صلاحية حسابك قريباً - Jellyfin",
"yourAccountIsDueToExpire": "ستنتهي صلاحية حسابك خلال {expiresIn}، في{date} {time}."
},
"userExpiryAdjusted": {
"yourExpiryWasAdjusted": "تم تغيير تاريخ انتهاء صلاحية حسابك.",
"name": "تغيير مدة انتهاء الصلاحية",
"title": "تغيرت مدة صلاحية حسابك - Jellyfin",
"ifPreviouslyDisabled": "إذا تم تعطيل حسابك مسبقاً، فمن الممكن أنه قد تم تفعيله.",
"newExpiry": "ستنتهي صلاحية حسابك في: {date}."
}
}

View File

@@ -10,70 +10,79 @@
"username": "اسم المستخدم",
"password": "كلمة المرور",
"reEnterPassword": "تأكيد كلمة المرور",
"reEnterPasswordInvalid": "كلمة المرور غير مطابقة.",
"reEnterPasswordInvalid": "كلمات المرور غير متطابقة.",
"createAccountButton": "إنشاء الحساب",
"passwordRequirementsHeader": "متطلبات كلمة المرور",
"successHeader": "تم!",
"confirmationRequired": "مطلوب تأكيد البريد الإلكتروني",
"confirmationRequiredMessage": "يرجى التحقق من صندوق البريد الإلكتروني الخاص بك للتحقق من عنوانك.",
"yourAccountIsValidUntil": "سيكون حسابك ساري المفعول حتى {date}.",
"sendPIN": "أرسل رقم التعريف الشخصي أدناه إلى البوت ، ثم عُد إلى هنا لربط حسابك.",
"sendPINDiscord": "اكتب {command} في {server_channel} على Discord ، ثم أرسل رقم التعريف الشخصي أدناه.",
"matrixEnterUser": "أدخل معرف المستخدم الخاص بك، واضغط على إرسال، وسيتم إرسال رمز PIN إليك. أدخله هنا للمتابعة.",
"oldPassword": "كلمة المرور السابقة",
"confirmationRequiredMessage": "يرجى التحقق من بريدك الإلكتروني لتأكيد العنوان.",
"yourAccountIsValidUntil": "سيكون حسابك صالحاً حتى {date}.",
"sendPIN": "أرسل الرمز أدناه إلى البوت، ثم عُد إلى هنا لربط حسابك.",
"sendPINDiscord": "اكتب {command} في {server_channel} في Discord، ثم أرسل الرمز أدناه.",
"matrixEnterUser": "أدخل مُعرّف المستخدم الخاص بك، واضغط على إرسال، وسيتم إرسال رمز إليك. أدخله هنا للمتابعة.",
"oldPassword": "كلمة المرور القديمة",
"newPassword": "كلمة المرور الجديدة",
"joinTheServer": "انضم إلى السيرفر:",
"editContactMethod": حرير طريقة الاتصال",
"customMessagePlaceholderHeader": حرير هذه البطاقة",
"joinTheServer": "انضم إلى الخادم:",
"editContactMethod": غيير وسيلة الاتصال",
"customMessagePlaceholderHeader": خصيص هذه البطاقة",
"resetPassword": "إعادة تعيين كلمة المرور",
"resetSent": "تم إرسال إعادة التعيين.",
"resetSentDescription": "في حال وجود حساب يملك اسم المستخدم/طريقة الاتصال المحددة، فسيتم إرسال رابط إعادة تعيين كلمة المرور عبر جميع طرق الاتصال المتاحة.ستنتهي صلاحية الرمز خلال 30 دقيقة.",
"resetSentDescription": "في حال وجود حساب يملك اسم المستخدم/وسيلة الاتصال المحددة، فسيتم إرسال رابط إعادة تعيين كلمة المرور عبر جميع وسائل الاتصال المتاحة. ستنتهي صلاحية الرمز خلال 30 دقيقة.",
"changePassword": "تغيير كلمة المرور",
"welcomeUser": "مرحباً، {user}!",
"addContactMethod": "إضافة طريقة اتصال",
"customMessagePlaceholderContent": "اضغط على زر تحرير صفحة المستخدم في الإعدادات لتخصيص هذه البطاقة، أو أظهر واحدة على صفحة تسجيل الدخول، ولا تقلق، لا يستطيع المستخدم رؤيتها.",
"userPageSuccessMessage": "تستطيع رؤية وتحرير التفاصيل حول حسابك لاحقاً في صفحة {myAccount}.",
"addContactMethod": "أضف وسيلة اتصال",
"customMessagePlaceholderContent": "اضغط على زر التعديل في صفحة المستخدم في الإعدادات لتخصيص هذه البطاقة، أو أظهر واحدة على صفحة تسجيل الدخول، ولا تقلق، لا يستطيع المستخدم رؤيتها.",
"userPageSuccessMessage": "يمكنك الاطلاع على تفاصيل حسابك وتغييرها لاحقاً على صفحة {myAccount}.",
"resetPasswordThroughJellyfin": "لإعادة تعيين كلمة المرور، قم بزيارة {jfLink} واضغط على زر \"نسيت كلمة المرور\".",
"resetPasswordThroughLink": "لإعادة تعيين كلمة المرور، أدخل اسم المستخدم، البريد الإلكتروني، أو اسم مستخدم لطريقة اتصال مرتبطة، ثم أرسل. سيتم إرسال رابط لإعادة تعيين كلمة المرور."
"resetPasswordThroughLink": "لإعادة تعيين كلمة المرور، أدخل اسم المستخدم، عنوان البريد الإلكتروني، أو اسم مستخدم لوسيلة اتصال مربوطة، ثم أرسل. سيتم إرسال رابط لإعادة تعيين كلمة المرور.",
"resetPasswordThroughLinkEnd": "ثم اضغط على إرسال. سيتم إرسال رابط لإعادة تعيين كلمة مرورك.",
"referralsDescription": "ادعُ أصدقاءك وعائلتك إلى Jellyfin باستخدام هذا الرابط. عُد إلى هنا للحصول على رابط جديد إذا انتهت صلاحيته.",
"resetPasswordThroughLinkStart": "لإعادة تعيين كلمة مرورك، أدخل أحد الخيارات التالية:",
"resetPasswordContactMethod": "اسم المستخدم لأي وسيلة اتصال مرتبطة بحسابك",
"copyReferral": "نسخ الرابط",
"invitedBy": "تمت دعوتك من قبل المستخدم {user}.",
"resetPasswordUsername": "اسم مستخدم Jellyfin الخاص بك",
"resetPasswordEmail": "عنوان بريدك الإلكتروني",
"referralsWithExpiryDescription": "ادعُ أصدقاءك وعائلتك إلى Jellyfin باستخدام هذا الرابط. سيتم تعطيل الرابط بعد انتهاء صلاحيته."
},
"notifications": {
"errorUserExists": "المستخدم موجود مسبقا.",
"errorInvalidCode": "رمز دعوة غير صالح.",
"errorTelegramVerification": "مطلوب التحقق من تيليجرام.",
"errorDiscordVerification": "مطلوب التحقق من الدسكورد.",
"errorMatrixVerification": "مطلوب التحقق من Matrix.",
"errorInvalidPIN": "رقم التعريف الشخصي غير صالح.",
"errorTelegramVerification": "مطلوب تأكيد Telegram.",
"errorDiscordVerification": "مطلوب تأكيد Discord.",
"errorMatrixVerification": "مطلوب تأكيد Matrix.",
"errorInvalidPIN": "الرمز غير صالح.",
"errorUnknown": "خطأ غير معروف.",
"errorNoEmail": "البريد الإلكتروني مطلوب.",
"errorCaptcha": "كلمة التحقق غير صحيحة.",
"errorCaptcha": "كلمة التحقق خاطئة.",
"errorPassword": "تحقق من متطلبات كلمة المرور.",
"errorNoMatch": "كلمات المرور غير متطابقة.",
"verified": "تم التحقق من الحساب.",
"errorAccountLinked": "الحساب قيد الاستخدام.",
"errorEmailLinked": "البريد الإلكتروني قيد الاستخدام.",
"errorOldPassword": "كلمة المرور القديمة غير صحيحة.",
"verified": "تم تأكيد الحساب.",
"errorAccountLinked": "الحساب مستخدم بالفعل.",
"errorEmailLinked": "البريد مستخدم بالفعل.",
"errorOldPassword": "كلمة المرور القديمة خاطئة.",
"passwordChanged": "تم تغيير كلمة المرور."
},
"validationStrings": {
"length": {
"singular": "يجب أن يتألف من {n} حرف على الأقل",
"singular": "يجب ألا يقل عدد الأحرف عن {n}",
"plural": "يجب ألا يقل عدد الأحرف عن {n}"
},
"uppercase": {
"singular": "يجب أن تحتوي على {n} حرف كبير على الأقل",
"singular": "يجب ألا يقل عدد الأحرف الكبيرة عن {n}",
"plural": "يجب ألا يقل عدد الأحرف الكبيرة عن {n}"
},
"lowercase": {
"singular": "يجب أن يتألف من {n} حرف صغير على الأقل",
"singular": "يجب ألا يقل عدد الأحرف الصغيرة عن {n}",
"plural": "يجب ألا يقل عدد الأحرف الصغيرة عن {n}"
},
"number": {
"singular": "يجب أن يحتوي على {n} رقم على الأقل",
"plural": "يجب أن يحتوي على {n} رقم على الأقل"
"singular": "يجب ألا يقل عدد الأرقام عن {n}",
"plural": "يجب ألا يقل عدد الأرقام عن {n}"
},
"special": {
"singular": "يجب أن يتألف من {n} حرف خاص على الأقل",
"plural": "يجب أن يتألف من {n} حرف خاص على الأقل"
"singular": "يجب ألا يقل عدد الأحرف الخاصة عن {n}",
"plural": "يجب ألا يقل عدد الأحرف الخاصة عن {n}"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "کوردی سۆرانی"
"name": "کوردی سۆرانی (CKB)"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری Jellyfin",

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "انگلیسی"
"name": "انگلیسی (FA)"
},
"strings": {
"pageTitle": "ساختن حساب کاربری",

View File

@@ -4,12 +4,12 @@
},
"strings": {
"passwordReset": "إعادة تعيين كلمة المرور",
"reset": "إعادة ضبط",
"reset": "إعادة التعيين",
"resetFailed": "فشل إعادة تعيين كلمة المرور",
"tryAgain": "حاول مرة اخرى.",
"youCanLogin": "يمكنك الآن تسجيل الدخول باستخدام الرمز أدناه ككلمة المرور الخاصة بك.",
"youCanLoginOmbi": "يمكنك الآن تسجيل الدخول باستخدام الرمز أدناه ككلمة المرور الخاصة بك.",
"youCanLoginPassword": "يمكنك الآن تسجيل الدخول باستخدام الرمز أدناه ككلمة المرور الخاصة بك.",
"tryAgain": "حاول مرة أُخرى.",
"youCanLogin": "يمكنك الآن الدخول باستخدام الرمز أدناه ككلمة مرورك.",
"youCanLoginOmbi": "يمكنك الآن الدخول إلى Jellyfin وOmbi باستخدام الرمز أدناه ككلمة مرورك.",
"youCanLoginPassword": "يمكنك الآن الدخول باستخدام كلمة مرورك الجديدة. اضغط أدناه للانتقال إلى Jellyfin.",
"changeYourPassword": "تأكد من تغيير كلمة المرور الخاصة بك بعد تسجيل الدخول.",
"enterYourPassword": "أدخل كلمة المرور الجديدة أدناه."
}

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "انگلیسی"
"name": "انگلیسی (FA)"
},
"strings": {
"passwordReset": "تنظیم مجدد رمز عبور",

View File

@@ -8,8 +8,8 @@
"back": "الخلف",
"optional": "اختياري",
"serverType": "نوع الخادم",
"disabled": "معطل",
"enabled": "مفعل",
"disabled": "معطّل",
"enabled": "مفعّل",
"port": "المنفذ",
"message": "الرسالة",
"serverAddress": "عنوان الخادم",
@@ -18,135 +18,164 @@
"apiKey": "مفتاح API",
"error": "خطأ",
"errorInvalidUserPass": "اسم مستخدم/كلمة مرور خاطئة.",
"errorNotAdmin": "",
"errorUserDisabled": "",
"error404": "",
"errorConnectionRefused": "",
"errorUnknown": ""
"errorNotAdmin": "لا يُسمح للمستخدم بإدارة الخادم.",
"errorUserDisabled": "قد يكون المستخدم معطلاً.",
"error404": "404، تحقق من عنوان URL الداخلي.",
"errorConnectionRefused": "رُفض الاتصال.",
"errorUnknown": "خطأ غير معروف، تحقق من سجلات التطبيق.",
"errorProxy": "إعدادات الوكيل غير صالحة."
},
"startPage": {
"welcome": "",
"pressStart": "",
"httpsNotice": "",
"start": ""
"welcome": "مرحباً!",
"pressStart": "ستحتاج إلى القيام ببعض الخطوات لإعداد jfa-go. اضغط على \"ابدأ\" للمتابعة.",
"httpsNotice": "تأكد من الاتصال بهذه الصفحة عبر HTTPS أو على شبكة خاصة.",
"start": "ابدأ"
},
"endPage": {
"finished": "",
"finished": "انتهينا!",
"restartMessage": "",
"refreshPage": ""
"refreshPage": "تحديث",
"moreFeatures": "يمكنك العثور على المزيد من الميزات مثل بوتات Discord/Telegram/Matrix ورسائل ترميز Markdown المخصصة في الإعدادات، لذا تأكد من تصفحها.",
"restartReload": "اضغط أدناه لإعادة التشغيل، ثم قم بالدخول إلى jfa-go من أحد عناوين URL الداخلية/الخارجية المحددة.",
"ifFailedLoad": "إذا لم يتم تحميل الصفحة، فتحقق من سجلات التطبيق بحثاً عن أي أدلة تشير إلى السبب."
},
"language": {
"title": "",
"description": "",
"defaultAdminLang": "",
"defaultFormLang": "",
"defaultEmailLang": ""
"title": "اللغة",
"description": "تتوفر ترجمات تطوعية لمعظم أجزاء jfa-go. يمكنك اختيار اللغات الافتراضية أدناه، ويمكن للمستخدمين تغييرها إذا رغبوا في ذلك. إذا أردت المساعدة في الترجمة، قم بالتسجيل في {n} للمساهمة!",
"defaultAdminLang": "لغة المسؤول الافتراضية",
"defaultFormLang": "لغة إنشاء الحساب الافتراضية",
"defaultEmailLang": "لغة البريد الافتراضية"
},
"general": {
"title": "",
"listenAddress": "",
"urlBase": "",
"urlBaseNotice": "",
"lightTheme": "",
"darkTheme": "",
"useHTTPS": "",
"httpsPort": "",
"useHTTPSNotice": "",
"pathToCertificate": "",
"pathToKeyFile": ""
"title": "عام",
"listenAddress": "عنوان الاستماع",
"urlBase": "عنوان URL الأساسي",
"urlBaseNotice": "مطلوب فقط في حالة استخدام وكيل عكسي على مجلد فرعي (على سبيل المثال 'jellyf.in/accounts').",
"lightTheme": "فاتح",
"darkTheme": "داكن",
"useHTTPS": "استخدم HTTPS",
"httpsPort": "منفذ HTTPS",
"useHTTPSNotice": "موصى به فقط في حال عدم استخدامك لوكيل عكسي.",
"pathToCertificate": "مسار الشهادة",
"pathToKeyFile": "مسار ملف المفتاح (.key)",
"externalURLNotice": "عنوان URL المستخدم للوصول إلى jfa-go. يُستخدم لإنشاء روابط لأمور مثل إعادة تعيين كلمة المرور. تأكد من تضمين عنوان URL الأساسي أعلاه إذا قمت بتعيينه.",
"externalURL": "عنوان URL الخارجي لـ jfa-go"
},
"updates": {
"title": "",
"description": "",
"updateChannel": "",
"stable": "",
"unstable": ""
"title": "التحديثات",
"description": "تلقي إشعارات عند توفر تحديثات جديدة. سيتحقق jfa-go من {n} كل 30 دقيقة. لا يتم جمع أي عناوين IP أو معلومات شخصية.",
"updateChannel": "قناة التحديثات",
"stable": "مستقرة",
"unstable": "غير مستقرة"
},
"login": {
"title": "",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"adminOnly": "",
"allowAll": "",
"allowAllDescription": "",
"emailNotice": ""
"title": "تسجيل الدخول",
"description": "للوصول إلى صفحة المسؤول، سجّل الدخول بإحدى الطرق التالية:",
"authorizeWithJellyfin": "التفويض باستخدام Jellyfin/Emby: يتم مشاركة تفاصيل تسجيل الدخول مع Jellyfin، مما يسمح بتعدد المستخدمين.",
"authorizeManual": "اسم المستخدم وكلمة المرور: قم بتعيين اسم المستخدم وكلمة المرور يدوياً.",
"adminOnly": "المسؤولون فقط (مستحسن)",
"allowAll": "السماح لجميع مستخدمي Jellyfin بتسجيل الدخول",
"allowAllDescription": "لا يُنصح بذلك، ينبغي السماح للمستخدمين الفرديين بتسجيل الدخول بعد الإعداد.",
"emailNotice": "يمكن استخدام عنوان بريدك الإلكتروني لتلقي الإشعارات.",
"authorizeManualUserPageNotice": "سيؤدي استخدام هذا إلى تعطيل ميزة \"صفحة المستخدم\"."
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"internal": "",
"external": "",
"replaceJellyfin": "",
"replaceJellyfinNotice": "",
"addressExternalNotice": "",
"testConnection": ""
"title": "إعداد Jellyfin/Emby",
"description": "يلزم وجود حساب مسؤول لأن واجهة برمجة التطبيقات (API) لا تسمح بإنشاء مستخدمين من خلالها. يجب عليك إنشاء حساب منفصل وتحديد خيار \"اسمح لهذا المستخدم بالتحكم بالخادم\". يمكنك تعطيل كل الخيارات الأخرى. بعد ذلك، أدخل بيانات تسجيل الدخول هنا.",
"embyNotice": "دعم Emby محدود ولا يدعم إعادة تعيين كلمة المرور.",
"internal": "داخلي",
"external": "خارجي",
"replaceJellyfin": "اسم الخادم",
"replaceJellyfinNotice": "عند إدخاله، سيحل هذا الاسم محل أي ذكر لكلمة \"Jellyfin\" في التطبيق.",
"addressExternalNotice": "اتركه فارغاً لاستخدام نفس العنوان.",
"testConnection": "اختبر الاتصال"
},
"ombi": {
"title": "",
"description": "",
"apiKeyNotice": ""
"title": "إعداد Ombi",
"description": "عند الاتصال بـ Ombi، سيتم إنشاء حساب Jellyfin و Ombi عندما ينضم المستخدم عبر jfa-go. بعد الانتهاء من الإعداد، انتقل إلى الإعدادات لتعيين ملف تعريف افتراضي لمستخدمي Ombi الجدد.",
"apiKeyNotice": "تجده في أول قسم لإعدادات Ombi.",
"stabilityWarning": "تحذير: تكامل Ombi غير مستقر، وقد يُسبب مشاكل. يُنصح باستخدام Jellyseerr بدلاً منه. راجع {n} لمزيد من المعلومات."
},
"messages": {
"title": "",
"description": ""
"title": "الرسائل",
"description": "يُمكن لـ jfa-go إرسال عمليات إعادة تعيين كلمة المرور ورسائل مُختلفة عبر البريد الإلكتروني، ،Discord ،Telegram و/أو Matrix. ‏يُمكنك إعداد البريد الإلكتروني أدناه، ويُمكنك ضبط باقي الخيارات لاحقاً في الإعدادات. يُمكنك الاطلاع على التعليمات في {n}. إذا لم تكن بحاجة إلى هذه الميزة، فيُمكنك تعطيلها هنا."
},
"email": {
"title": "",
"description": "",
"method": "",
"useEmailAsUsername": "",
"useEmailAsUsernameNotice": "",
"fromAddress": "",
"senderName": "",
"dateFormat": "",
"dateFormatNotice": "",
"encryption": "",
"mailgunApiURL": ""
"title": "البريد الإلكتروني",
"description": "يُمكن لـ jfa-go إرسال رموز (PIN) لإعادة تعيين كلمة المرور وإرسال إشعارات متنوعة عبر البريد الإلكتروني. يُمكنك الاتصال بخادم SMTP أو استخدام واجهة برمجة التطبيقات (API) لـ{n}.",
"method": "وسيلة الإرسال",
"useEmailAsUsername": "استخدم عناوين البريد الإلكتروني كاسم المستخدم",
"useEmailAsUsernameNotice": "عند التفعيل، فسيقوم المستخدمون الجدد بتسجيل الدخول إلى Jellyfin/Emby باستخدام عنوان بريدهم الإلكتروني بدلاً من اسم المستخدم.",
"fromAddress": "عنوان المرسل",
"senderName": "اسم المرسل",
"dateFormat": "صيغة التاريخ",
"dateFormatNotice": "التاريخ يتبع تنسيق strftime. لمزيد من المعلومات، يرجى زيارة {n}.",
"encryption": "التشفير",
"mailgunApiURL": "رابط API"
},
"notifications": {
"title": "",
"description": ""
"title": "إشعارات المسؤول",
"description": "عند التفعيل، ستتلقى رسالة لكل دعوة عند انتهاء صلاحيتها او عند انشاء مستخدم من خلالها. إذا لم تختر طريقة تسجيل الدخول عبر Jellyfin، فتأكد من تقديم عنوان بريدك الإلكتروني أو أضف وسيلة اتصال أخرى لاحقاً."
},
"welcomeEmails": {
"title": "",
"description": ""
"title": "رسائل الترحيب",
"description": "عند التفعيل، فسيتم إرسال رسالة إلى المستخدمين الجدد تحتوي على عنوان URL لـ Jellyfin/Emby واسم المستخدم الخاص بهم."
},
"inviteEmails": {
"title": "",
"description": ""
"title": "رسائل الدعوة",
"description": "عند التفعيل، يمكنك إرسال الدعوات مباشرةً إلى بريد المستخدم الإلكتروني، أو إلى مستخدم Discord أو Matrix. ولأنك قد تستخدم وكيل عكسي، يجب عليك تحديد عنوان URL الذي تصل إليه الدعوات. اكتب عنوان URL الأساسي، وأضف '/invite'."
},
"passwordResets": {
"title": "",
"description": "",
"pathToJellyfin": "",
"pathToJellyfinNotice": "",
"resetLinks": "",
"resetLinksNotice": "",
"resetLinksLanguage": "",
"setPassword": "",
"setPasswordNotice": ""
"title": "عمليات إعادة تعيين كلمة المرور",
"description": "عندما يحاول مستخدم إعادة تعيين كلمة مروره، يُنشئ Jellyfin ملفاً باسم 'passwordreset-*.json' يحتوي على رمز (PIN). يقرأ jfa-go الملف ويرسل الرمز (PIN) إلى المستخدم. إذا قمت بتفعيل ميزة \"صفحة المستخدم\"، فيمكن أيضاً إجراء إعادة التعيين هناك، باستخدام اسم المستخدم أو البريد الإلكتروني أو وسيلة الاتصال.",
"pathToJellyfin": "مسار مجلد تكوين Jellyfin (config)",
"pathToJellyfinNotice": "إذا كنت لا تعرف مكان المسار، فحاول إعادة تعيين كلمة مرورك في Jellyfin. ستظهر نافذة منبثقة تحتوي على \"<path to jellyfin>/passwordreset-*.json\". هذا ليس ضروريًا إذا كنت ترغب فقط في استخدام خدمة إعادة تعيين كلمة المرور الذاتية من خلال \"صفحة المستخدم\".",
"resetLinks": "أرسل رابطاً بدلاً من رمز (PIN)",
"resetLinksNotice": "إذا تم إعداد Ombi، فاستخدم هذا لمزامنة إعادة تعيين كلمة مرور Jellyfin مع Ombi.",
"resetLinksLanguage": "لغة رابط إعادة التعيين الافتراضية",
"setPassword": "تعيين كلمة المرور من خلال رابط",
"setPasswordNotice": "تفعيل هذه الميزة يعني أن المستخدم لن يضطر إلى تغيير كلمة المرور من الرمز (PIN) بعد إعادة التعيين. سيتم أيضاً فرض متطلبات كلمة المرور.",
"moreInfo": "يمكنك العثور على مزيد من المعلومات حول الطرق المختلفة لإعادة تعيين كلمات المرور على {n}.",
"resetLinksRequiredForUserPage": "مطلوب للسماح بإعادة تعيين كلمة المرور ذاتياً على صفحة المستخدم."
},
"passwordValidation": {
"title": "",
"description": "",
"length": "",
"uppercase": "",
"lowercase": "",
"numbers": "",
"special": ""
"title": "متطلبات كلمة المرور",
"description": "عند التفعيل، ستظهر مجموعة من متطلبات كلمة المرور على صفحة إنشاء الحساب، مثل الحد الأدنى لطول الكلمة، وعدد الأحرف الكبيرة/الصغيرة، وما إلى ذلك.",
"length": "طول الكلمة",
"uppercase": "عدد الأحرف الكبيرة",
"lowercase": "عدد الأحرف الصغيرة",
"numbers": "عدد الأرقام",
"special": "عدد الحروف الخاصة (%, *, إلخ.)"
},
"helpMessages": {
"title": "",
"description": "",
"contactMessage": "",
"contactMessageNotice": "",
"helpMessage": "",
"helpMessageNotice": "",
"successMessage": "",
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
"title": "رسائل المساعدة",
"description": "ستظهر هذه الرسائل في صفحة إنشاء الحساب وفي بعض رسائل البريد الإلكتروني.",
"contactMessage": "رسالة الاتصال",
"contactMessageNotice": "يتم عرضها في أسفل جميع الصفحات باستثناء صفحة المسؤول.",
"helpMessage": "رسالة المساعدة",
"helpMessageNotice": "يتم عرضها على صفحة إنشاء الحساب.",
"successMessage": "رسالة النجاح",
"successMessageNotice": "تظهر عندما يُتم المستخدم إنشاء حسابه.",
"emailMessage": "رسالة البريد الإلكتروني",
"emailMessageNotice": "تظهر في نهاية رسائل البريد الإلكتروني.",
"markdownMessageNotice": "يمكن تخصيص محتويات بعض رسائل البريد الإلكتروني والصفحات والرسائل باستخدام ترميز Markdown في الإعدادات."
},
"jellyseerr": {
"description": "Jellyseerr هو بديل لـ Ombi، ويتكامل مع jfa-go بشكل أفضل. بعد الانتهاء من الإعداد، انتقل إلى الإعدادات لإنشاء ملف تعريف وإضافة قالب لحسابات Jellyseerr الجديدة.",
"title": "إعداد Jellyseerr",
"importExisting": "استيراد المستخدمين الحاليين",
"importExistingDescription": "عند التفعيل، سيتم مزامنة تفاصيل وسائل الاتصال والتفضيلات الخاصة بالمستخدمين الحاليين من jfa-go."
},
"proxy": {
"title": "الوكيل",
"description": "دع jfa-go يقوم بجميع الاتصالات من خلال وكيل HTTP/SOCKS5. سيتم اختبار الاتصال بـ Jellyfin من خلاله.",
"protocol": "البروتوكول",
"address": "العنوان (متضمناً المنفذ)"
},
"userPage": {
"description": "تتيح صفحة المستخدم (التي تظهر باسم \"حسابي\") للمستخدمين الوصول إلى معلومات حسابهم، مثل وسائل الاتصال ومدة صلاحية الحساب. كما يمكنهم تغيير كلمة المرور، وطلب إعادة تعيينها ، وربط/تغيير وسائل الاتصال، دون الحاجة إلى طلب ذلك منك. بالإضافة إلى ذلك، يمكن عرض رسائل بترميز Markdown مخصصة للمستخدمين قبل وبعد تسجيل الدخول.",
"title": "صفحة المستخدم",
"customizeMessages": "اضغط زر التعديل بجوار \"صفحة المستخدم\" في الإعدادات لتعيينها لاحقاً.",
"requiredSettings": "يجب تفعيل تسجيل الدخول إلى jfa-go عبر Jellyfin. تأكد من اختيار \"إعادة تعيين كلمة المرور عبر رابط\" لاحقاً للسماح بإعادة تعيين كلمة المرور ذاتياً."
}
}

View File

@@ -3,14 +3,17 @@
"name": "العربية (AR)"
},
"strings": {
"startMessage": "مرحبا!\nقم بإدخال رمز Jellyfin للتحقق من حسابك.",
"discordStartMessage": "مرحباً!\nضع بإدخال الرمز بإستخدام `/pin <PIN>` للتحقق من حسابك.",
"matrixStartMessage": "مرحباً\nأدخل رقم التعريف الشخصي أدناه في صفحة الاشتراك في Jellyfin للتحقق من حسابك.",
"invalidPIN": "رقم التعريف الشخصي هذا غير صالح ، حاول مرة أخرى.",
"startMessage": "أهلاً!\nأدخل رمز Jellyfin هنا لتأكيد حسابك.",
"discordStartMessage": "أهلاً!\nأدخل رمزك باستخدام .لتأكيد حسابك /pin <الرمز>",
"matrixStartMessage": "أهلاً\nأدخل الرمز أدناه في صفحة التسجيل في Jellyfin لتأكيد حسابك.",
"invalidPIN": "هذا الرمز خاطئ، حاول مرة أُخرى.",
"pinSuccess": "تم! يمكنك الآن العودة إلى صفحة التسجيل.",
"languageMessage": "ملاحظة: اطّلع على اللغات المتاحة باستخدام {command} ، وقم بتعيين اللغة باستخدام {command} <language code>.",
"languageMessageDiscord": "ملاحظة: اضبط لغتك باستخدام / lang <language name>.",
"languageMessage": "ملاحظة: اطّلع على اللغات المتاحة باستخدام {command}، وقم بتعيين اللغة باستخدام {command} <language code>.",
"languageMessageDiscord": "ملاحظة: اختر اللغة باستخدام ./lang <language name>",
"languageSet": "تم تعيين اللغة إلى {language}.",
"discordDMs": "يرجى التحقق من الرسائل المباشرة الخاصة بك للحصول على رد."
"discordDMs": "يرجى التحقق من رسائلك المباشرة للحصول على رد.",
"noPermission": "لا تملك الصلاحية لهذا الإجراء.",
"sentInvite": "تم إرسال الدعوة.",
"sentInviteFailure": "فشل إرسال الدعوة، تحقق من السجلات."
}
}

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "انگلیسی"
"name": "انگلیسی (FA)"
},
"strings": {
"startMessage": "سلام!\nبرای تأیید حساب خود ، کد PIN خود را در اینجا وارد کنید.",

View File

@@ -109,9 +109,11 @@ const (
GenerateInvite = "Generating new invite"
FailedGenerateInvite = "Failed to generate new invite: %v"
InvalidInviteCode = "Invalid invite code \"%s\""
FailedGetInvite = "Failed to get invite \"%s\": %v"
FailedSendToTooltipNoUser = "Failed: \"%s\" not found"
FailedSendToTooltipMultiUser = "Failed: \"%s\" linked to multiple users"
InvalidAddress = "invalid address \"%s\""
FailedParseTime = "Failed to parse time value: %v"
@@ -196,6 +198,8 @@ const (
UserAdminAdjusted = "Admin state for user \"%s\" set to %t"
UserLabelAdjusted = "Label for user \"%s\" set to \"%s\""
FailedGetJFActivities = "Failed to get ActivityLog entries: %v"
// api.go
ApplyUpdate = "Applied update"
FailedApplyUpdate = "Failed to apply update: %v"
@@ -326,6 +330,12 @@ const (
// webhooks.go
WebhookRequest = "Webhook request send to \"%s\" (%d): %v"
// usercache.go
CacheRefreshCompleted = "Usercache refreshed, %d in %.2fs (%f.2u/sec)"
// Other
GotNEntries = "got %d entries"
)
const (

View File

@@ -8,7 +8,7 @@
Color-scheme: light dark;
supported-color-schemes: light dark;
}
body, .body {
.body {
background: #101010 !important;
background-color: #101010 !important;
}

22
main.go
View File

@@ -23,15 +23,16 @@ import (
"github.com/fatih/color"
"github.com/goccy/go-yaml"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
var (
@@ -77,7 +78,9 @@ var serverTypes = map[string]string{
"jellyfin": "Jellyfin",
"emby": "Emby (experimental)",
}
var serverType = mediabrowser.JellyfinServer
var substituteStrings = ""
var externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain()
@@ -112,7 +115,10 @@ type appContext struct {
adminUsers []User
invalidTokens []string
// Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser
jf struct {
*mediabrowser.MediaBrowser
activity *JFActivityCache
}
authJf *mediabrowser.MediaBrowser
ombi *OmbiWrapper
js *JellyseerrWrapper
@@ -426,7 +432,7 @@ func start(asDaemon, firstCall bool) {
app.info.Println(lm.UsingJellyfin)
}
app.jf, err = mediabrowser.NewServer(
app.jf.MediaBrowser, err = mediabrowser.NewServer(
serverType,
server,
app.config.Section("jellyfin").Key("client").String(),
@@ -439,6 +445,10 @@ func start(asDaemon, firstCall bool) {
if err != nil {
app.err.Fatalf(lm.FailedAuthJellyfin, server, -1, err)
}
app.jf.activity = NewJFActivityCache(
app.jf,
time.Duration(app.config.Section("jellyfin").Key("activity_cache_sync_timeout_seconds").MustInt(20))*time.Second,
)
/*if debugMode {
app.jf.Verbose = true
}*/
@@ -715,7 +725,7 @@ func flagPassed(name string) (found bool) {
}
// @title jfa-go internal API
// @version 0.6.0
// @version 0.6.1
// @description API for the jfa-go frontend
// @contact.name Harvey Tindall
// @contact.email hrfee@hrfee.dev

View File

@@ -8,7 +8,9 @@ import (
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
_ "github.com/mattn/go-sqlite3"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -35,7 +37,17 @@ func InitMatrixCrypto(d *MatrixDaemon, logger *logger.Logger) error {
dbPath := d.app.config.Section("files").Key("matrix_sql").String()
var err error
d.crypto = &Crypto{}
// bmss, err := NewBackedMemoryStateStore(d.app.storage.db)
// if err != nil {
// return err
// }
// d.bot.StateStore = bmss
d.crypto.helper, err = cryptohelper.NewCryptoHelper(d.bot, []byte("jfa-go"), dbPath)
// bms, err := NewBackedMemoryStore(d.app.storage.db)
// if err != nil {
// return err
// }
// d.crypto.helper, err = cryptohelper.NewCryptoHelper(d.bot, []byte("jfa-go"), bms)
if err != nil {
return err
}
@@ -63,3 +75,55 @@ func EncryptRoom(d *MatrixDaemon, roomID id.RoomID) error {
})
return err
}
/*type BackedMemoryStore struct {
*crypto.MemoryStore
db *badgerhold.Store
}
func (b *BackedMemoryStore) save() error {
err := b.db.Upsert("MatrixEncryptionStore", b.MemoryStore)
defer func(err error) { log.Printf("MATRIX WRITE: err=%v\n", err) }(err)
return err
}
func NewBackedMemoryStore(db *badgerhold.Store) (*BackedMemoryStore, error) {
b := &BackedMemoryStore{
db: db,
}
b.MemoryStore = crypto.NewMemoryStore(b.save)
err := b.db.Get("MatrixEncryptionStore", b.MemoryStore)
if err != nil && !errors.Is(err, badgerhold.ErrNotFound) {
return nil, err
}
return b, nil
}
type BackedMemoryStateStore struct {
*mautrix.MemoryStateStore
db *badgerhold.Store
}
func (b *BackedMemoryStateStore) save() error {
err := b.db.Upsert("MatrixEncryptionStateStore", b.MemoryStateStore)
defer func(err error) { log.Printf("MATRIX WRITE: err=%v\n", err) }(err)
return err
}
func NewBackedMemoryStateStore(db *badgerhold.Store) (*BackedMemoryStateStore, error) {
b := &BackedMemoryStateStore{
db: db,
}
store := mautrix.NewMemoryStateStore()
memStore, ok := store.(*mautrix.MemoryStateStore)
if !ok {
return nil, errors.New("didn't get a MemoryStateStore")
}
b.MemoryStateStore = memStore
err := b.db.Get("MatrixEncryptionStateStore", b.MemoryStateStore)
if err != nil && !errors.Is(err, badgerhold.ErrNotFound) {
return nil, err
}
return b, nil
}*/

View File

@@ -23,6 +23,7 @@ func runMigrations(app *appContext) {
migrateToBadger(app)
intialiseCustomContent(app)
migrateJellyseerrImportDaemon(app)
migratePWREmailPath(app)
}
// Migrate pre-0.2.0 user templates to profiles
@@ -188,7 +189,7 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error {
idList[user.JellyfinID] = vals
}
for jfID, ids := range idList {
ombiUser, err := app.getOmbiUser(jfID)
ombiUser, err := app.getOmbiUser(jfID, nil)
if err != nil {
app.debug.Printf("Failed to get Ombi user with Discord/Telegram \"%s\"/\"%s\": %v", ids[0], ids[1], err)
continue
@@ -514,3 +515,23 @@ func migrateJellyseerrImportDaemon(app *appContext) {
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{false})
}
}
// Migrate previous filepath to the password reset email (email.html/txt) to new one.
func migratePWREmailPath(app *appContext) {
if app.config.Section("password_resets").Key("email_html").String() != "jfa-go:email.html" && app.config.Section("password_resets").Key("email_text").String() != "jfa-go:email.txt" {
return
}
tempConfig, _ := ini.ShadowLoad(app.configPath)
if app.config.Section("password_resets").Key("email_html").String() == "jfa-go:email.html" {
tempConfig.Section("password_resets").Key("email_html").SetValue("")
}
if app.config.Section("password_resets").Key("email_text").String() == "jfa-go:email.txt" {
tempConfig.Section("password_resets").Key("email_text").SetValue("")
}
err := tempConfig.SaveTo(app.configPath)
if err != nil {
app.err.Fatalf("Failed to save config: %v", err)
return
}
app.ReloadConfig()
}

View File

@@ -2,6 +2,8 @@ package main
import (
"time"
"github.com/hrfee/mediabrowser"
)
type stringResponse struct {
@@ -52,16 +54,16 @@ type enableDisableUserDTO struct {
}
type generateInviteDTO struct {
Months int `json:"months" example:"0"` // Number of months
Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
Months int `json:"months" example:"0"` // Number of months
Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
sendInviteDTO
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
@@ -70,9 +72,13 @@ type generateInviteDTO struct {
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
}
type inviteProfileDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
Profile string `json:"profile" example:"DefaultProfile"` // Profile to use
type SendInviteDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
sendInviteDTO
}
type sendInviteDTO struct {
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
}
type profileDTO struct {
@@ -106,26 +112,29 @@ type newProfileDTO struct {
}
type inviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Months int `json:"months" example:"1"` // Number of months till expiry
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
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Created int64 `json:"created" example:"1617737207510"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
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)
SendTo string `json:"send_to,omitempty"` // Email/Discord username 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
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
EditableInviteDTO
ValidTill int64 `json:"valid_till" example:"1617737207510"` // Unix timestamp of expiry
Created int64 `json:"created" example:"1617737207510"` // Date of creation
UsedBy map[string]int64 `json:"used_by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no_limit"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining_uses,omitempty"` // Remaining number of uses (if applicable)
SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable)
SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed.
}
type EditableInviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
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
UserLabel *string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
Profile *string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UserExpiry *bool `json:"user_expiry"` // Whether or not user expiry is enabled
UserMonths *int `json:"user_months,omitempty" example:"1"` // Number of months till user expiry
UserDays *int `json:"user_days,omitempty" example:"1"` // Number of days till user expiry
UserHours *int `json:"user_hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes *int `json:"user_minutes,omitempty" example:"3"` // Number of minutes till user expiry
}
type getInvitesDTO struct {
@@ -506,3 +515,12 @@ type TaskDTO struct {
type LabelsDTO struct {
Labels []string `json:'labels"`
}
type ActivityLogEntriesDTO struct {
Entries []ActivityLogEntryDTO `json:"entries"`
}
type ActivityLogEntryDTO struct {
mediabrowser.ActivityLogEntry
Date int64 `json:"Date"`
}

377
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2",
@@ -29,14 +30,15 @@
"remixicon": "^4.3.0",
"remove-markdown": "^0.5.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.3",
"temporal-polyfill": "^0.3.0",
"uncss": "^0.17.3"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20251209.1",
"live-server": "^1.2.2"
},
"optionalDependencies": {
"esbuild": "^0.25.0"
"esbuild": "^0.25.12"
}
},
"node_modules/@af-utils/scrollend-polyfill": {
@@ -67,9 +69,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -83,9 +85,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -99,9 +101,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -115,9 +117,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -131,9 +133,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -147,9 +149,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -163,9 +165,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -179,9 +181,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -195,9 +197,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -211,9 +213,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -227,9 +229,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -243,9 +245,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -259,9 +261,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -275,9 +277,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -291,9 +293,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -307,9 +309,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -323,9 +325,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -339,9 +341,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
@@ -355,9 +357,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -371,9 +373,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
@@ -387,9 +389,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -403,9 +405,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
@@ -419,9 +421,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -435,9 +437,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -451,9 +453,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -467,9 +469,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -482,6 +484,12 @@
"node": ">=18"
}
},
"node_modules/@hrfee/simpletemplate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@hrfee/simpletemplate/-/simpletemplate-1.1.0.tgz",
"integrity": "sha512-5H4/y7CegE4twstRPip/ms+OGUb+BPyVt+hriEpY88lTWW4I6jxzHFHmz6NDZmVyRa4RVIEVBxvtwk3RXKJVwA==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -631,6 +639,123 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-xnx3A1S1TTx+mx8FfP1UwkNTwPBmhGCbOh4PDNRUV5gDZkVuDDN3y1F7NPGSMg6MXE1KKPSLNM+PQMN33ZAL2Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251209.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20251209.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20251209.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20251209.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20251209.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20251209.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20251209.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-F1cnYi+ZeinYQnaTQKKIsbuoq8vip5iepBkSZXlB8PjbG62LW1edUdktd/nVEc+Q+SEysSQ3jRdk9eU766s5iw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-Ta6XKdAxEMBzd1xS4eQKXmlUkml+kMf23A9qFoegOxmyCdHJPak2gLH9ON5/C6js0ibZm1kdqwbcA0/INrcThg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-4e7WSBLLdmfJUGzm9Id4WA2fDZ2sY3Q6iudyZPNSb5AFsCmqQksM/JGAlNROHpi/tIqo95e3ckbjmrZTmH60EA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-kdiPMvs1hwi76hgvZjz4XQVNYTV+MAbJKnHXz6eL6aVXoTYzNtan5vWywKOHv9rV4jBMyVlZqtKbeG/XVV9WdQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-dH/Z50Xb52N4Csd0BXptmjuMN+87AhUAjM9Y5rNU8VwcUJJDFpKM6aKUhd4Q+XEVJWPFPlKDLx3pVhnO31CBhQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-vW7IGRNIUhhQ0vzFY3sRNxvYavNGum2OWgW1Bwc05yhg9AexBlRjdhsUSTLQ2dUeaDm2nx4i38LhXIVgLzMNeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20251209.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251209.1.tgz",
"integrity": "sha512-jKT6npBrhRX/84LWSy9PbOWx2USTZhq9SOkvH2mcnU/+uqyNxZIMMVnW5exIyzcnWSPly3jK2qpfiHNjdrDaAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@webcoder49/code-input": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@webcoder49/code-input/-/code-input-2.7.2.tgz",
@@ -2078,9 +2203,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2091,32 +2216,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.8",
"@esbuild/android-arm": "0.25.8",
"@esbuild/android-arm64": "0.25.8",
"@esbuild/android-x64": "0.25.8",
"@esbuild/darwin-arm64": "0.25.8",
"@esbuild/darwin-x64": "0.25.8",
"@esbuild/freebsd-arm64": "0.25.8",
"@esbuild/freebsd-x64": "0.25.8",
"@esbuild/linux-arm": "0.25.8",
"@esbuild/linux-arm64": "0.25.8",
"@esbuild/linux-ia32": "0.25.8",
"@esbuild/linux-loong64": "0.25.8",
"@esbuild/linux-mips64el": "0.25.8",
"@esbuild/linux-ppc64": "0.25.8",
"@esbuild/linux-riscv64": "0.25.8",
"@esbuild/linux-s390x": "0.25.8",
"@esbuild/linux-x64": "0.25.8",
"@esbuild/netbsd-arm64": "0.25.8",
"@esbuild/netbsd-x64": "0.25.8",
"@esbuild/openbsd-arm64": "0.25.8",
"@esbuild/openbsd-x64": "0.25.8",
"@esbuild/openharmony-arm64": "0.25.8",
"@esbuild/sunos-x64": "0.25.8",
"@esbuild/win32-arm64": "0.25.8",
"@esbuild/win32-ia32": "0.25.8",
"@esbuild/win32-x64": "0.25.8"
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {
@@ -4608,9 +4733,9 @@
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
"dev": true,
"license": "MIT",
"optional": true
@@ -6748,6 +6873,21 @@
"node": ">=8.0"
}
},
"node_modules/temporal-polyfill": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz",
"integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==",
"license": "MIT",
"dependencies": {
"temporal-spec": "0.3.0"
}
},
"node_modules/temporal-spec": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz",
"integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==",
"license": "ISC"
},
"node_modules/terser": {
"version": "5.43.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz",
@@ -6943,19 +7083,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",

View File

@@ -18,6 +18,7 @@
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2",
@@ -37,13 +38,14 @@
"remixicon": "^4.3.0",
"remove-markdown": "^0.5.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.3",
"temporal-polyfill": "^0.3.0",
"uncss": "^0.17.3"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20251209.1",
"live-server": "^1.2.2"
},
"optionalDependencies": {
"esbuild": "^0.25.0"
"esbuild": "^0.25.12"
}
}

View File

@@ -206,13 +206,15 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/user", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.GET(p+"/users/:id/activities/jellyfin", app.GetJFActivitesForUser)
api.POST(p+"/users/enable", app.EnableDisableUsers)
api.POST(p+"/invites", app.GenerateInvite)
api.GET(p+"/invites", app.GetInvites)
api.GET(p+"/invites/count", app.GetInviteCount)
api.GET(p+"/invites/count/used", app.GetInviteUsedCount)
api.DELETE(p+"/invites", app.DeleteInvite)
api.POST(p+"/invites/profile", app.SetProfile)
api.POST(p+"/invites/send", app.SendInvite)
api.PATCH(p+"/invites/edit", app.EditInvite)
api.GET(p+"/profiles", app.GetProfiles)
api.GET(p+"/profiles/names", app.GetProfileNames)
api.GET(p+"/profiles/raw/:name", app.GetRawProfile)
@@ -220,7 +222,6 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/profiles/default", app.SetDefaultProfile)
api.POST(p+"/profiles", app.CreateProfile)
api.DELETE(p+"/profiles", app.DeleteProfile)
api.POST(p+"/invites/notify", app.SetNotify)
api.POST(p+"/users/emails", app.ModifyEmails)
api.POST(p+"/users/labels", app.ModifyLabels)
api.POST(p+"/users/accounts-admin", app.SetAccountsAdmin)

View File

@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# scan all typescript and automatically add dark variants to color tags if they're not already present.
for f in $1/*.ts; do
# FIXME: inline html
for l in $(grep -n "~neutral\|~positive\|~urge\|~warning\|~info\|~critical" $f | sed -e 's/:.*//g'); do
# for l in $(sed -n '/classList/=' $f); do
line=$(sed -n "${l}p" $f)
echo $line | grep "classList" &> /dev/null
if [ $? -eq 0 ]; then
echo $line | sed 's/.*classList//; s/).*//' | grep "~neutral\|~positive\|~urge\|~warning\|~info\|~critical" &> /dev/null
if [ $? -eq 0 ]; then
# echo "found classList @ " $l
echo $line | grep "dark:" &>/dev/null
if [ $? -ne 0 ]; then
for color in neutral positive urge warning info critical; do
sed -i "${l},${l}s/\"~${color}\"/\"~${color}\", \"dark:~d_${color}\"/g" $f
done
fi
else
echo "FIX: classList found, but color tag wasn't in it"
fi
else
echo $line | grep "querySelector" &> /dev/null
if [ $? -ne 0 ]; then
# echo "found inline in " $f " @ " $l ", " $(sed -n "${l}p" $f)
echo $line | grep "dark:" &>/dev/null
if [ $? -ne 0 ]; then
for color in neutral positive urge warning info critical; do
sed -i "${l},${l}s/~${color}/~${color} dark:~d_${color}/g" $f
done
fi
else
echo $line | sed 's/.*querySelector//; s/).*//' | grep "~neutral\|~positive\|~urge\|~warning\|~info\|~critical" &> /dev/null
if [ $? -ne 0 ]; then
echo $line | grep "dark:" &>/dev/null
if [ $? -ne 0 ]; then
# echo "found inline in " $f " @ " $l ", " $(sed -n "${l}p" $f)
for color in neutral positive urge warning info critical; do
sed -i "${l},${l}s/~${color}/~${color} dark:~d_${color}/g" $f
done
fi
#else
#echo "FIX: querySelector found, but color tag wasn't in it: " $line
fi
fi
fi
done
done

3
scripts/variants/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/hrfee/jfa-go/scripts/variants
go 1.24.0

204
scripts/variants/main.go Normal file
View File

@@ -0,0 +1,204 @@
// Package variants provides a script to comb through typescript/javascript files and
// find (most) instances of a a17t color (~neutral...) being used without
// an accompanying dark version (dark:~d_neutral...) and insert one.
// Not fully feature-matched with the old bash version, only matching classList.add/remove and class="...",
// but doesn't break on multi line function params, and is probably much faster.
package main
import (
"bytes"
"flag"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"sync"
)
var VERBOSE = false
var classList = func() *regexp.Regexp {
classList, err := regexp.Compile(`classList\.(add|remove)(\((?:[^()]*|\((?:[^()]*|\([^()]*\))\))*\))`)
if err != nil {
panic(err)
}
return classList
}()
var color = func() *regexp.Regexp {
color, err := regexp.Compile(`\~(neutral|positive|warning|critical|info|urge|gray)`)
if err != nil {
panic(err)
}
return color
}()
var quotedColor = func() *regexp.Regexp {
quotedColor, err := regexp.Compile(`("|'|\x60)\~(neutral|positive|warning|critical|info|urge|gray)("|'|\x60)`)
if err != nil {
panic(err)
}
return quotedColor
}()
var htmlClassList = func() *regexp.Regexp {
htmlClassList, err := regexp.Compile(`class="[^"]*\~(neutral|positive|warning|critical|info|urge|gray)[^"]*"`)
if err != nil {
panic(err)
}
return htmlClassList
}()
func ParseDir(in, out string) error {
err := filepath.WalkDir(in, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
perm := info.Mode()
rel, err := filepath.Rel(in, path)
outFile := filepath.Join(out, rel)
if d.IsDir() {
return os.MkdirAll(outFile, perm)
}
if VERBOSE {
log.Printf("\"%s\" => \"%s\"\n", path, outFile)
}
if err != nil {
return err
}
return ParseFile(path, outFile, &perm)
})
return err
}
func ParseDirParallel(in, out string) error {
var wg sync.WaitGroup
err := filepath.WalkDir(in, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
perm := info.Mode()
rel, err := filepath.Rel(in, path)
outFile := filepath.Join(out, rel)
if d.IsDir() {
return os.MkdirAll(outFile, perm)
}
if VERBOSE {
log.Printf("\"%s\" => \"%s\"\n", path, outFile)
}
if err != nil {
return err
}
wg.Add(1)
go func() {
if err := ParseFile(path, outFile, &perm); err != nil {
panic(err)
}
wg.Done()
}()
return nil
})
if err != nil {
return err
}
wg.Wait()
return err
}
func ParseFile(in, out string, perm *fs.FileMode) error {
file, err := os.ReadFile(in)
if err != nil {
return err
}
if perm == nil {
f, err := os.Stat(in)
if err != nil {
return err
}
p := f.Mode()
perm = &p
}
outText := classList.ReplaceAllFunc(file, func(match []byte) []byte {
if bytes.Contains(match, []byte("dark:~d_")) {
if VERBOSE {
log.Printf("Skipping pre-set dark colour: `%s`\n", string(match))
}
return match
}
submatches := quotedColor.FindSubmatch(match)
if len(submatches) == 0 {
return match
}
quote := string(submatches[1])
color := string(submatches[2])
if VERBOSE {
log.Printf("Matched quote %s, color %s\n", quote, color)
}
newVal := bytes.Replace(match, []byte(quote+"~"+color+quote), []byte(quote+"~"+color+quote+", "+quote+"dark:~d_"+color+quote), 1)
if VERBOSE {
log.Printf("`%s` => `%s`\n", string(match), string(newVal))
}
return newVal
})
outText = htmlClassList.ReplaceAllFunc(outText, func(match []byte) []byte {
if bytes.Contains(match, []byte("dark:~d_")) {
if VERBOSE {
log.Printf("Skipping pre-set dark colour: `%s`\n", string(match))
}
return match
}
// Sucks we can't get a submatch from ReplaceAllFunc
submatches := color.FindSubmatch(match)
if len(submatches) == 0 {
return match
}
c := submatches[1]
newVal := bytes.Replace(match, []byte("~"+string(c)), []byte("~"+string(c)+" dark:~d_"+string(c)), 1)
if VERBOSE {
log.Printf("`%s` => `%s`\n", string(match), string(newVal))
}
return newVal
})
err = os.WriteFile(out, outText, *perm)
return err
}
func main() {
var inFile, inDir, out string
var parallel bool
flag.StringVar(&inFile, "file", "", "Input of an individual file.")
flag.StringVar(&inDir, "dir", "", "Input of a whole directory.")
flag.StringVar(&out, "out", "", "Output filepath/directory, depending on if -file or -dir passed.")
flag.BoolVar(&VERBOSE, "v", false, "Prints information about files and replacements as they are made")
flag.BoolVar(&parallel, "p", false, "Run a goroutine per file. Probably won't speed things up.")
flag.Parse()
if out == "" {
flag.PrintDefaults()
os.Exit(1)
}
var err error
if inFile != "" {
err = ParseFile(inFile, out, nil)
} else if inDir != "" {
if parallel {
err = ParseDirParallel(inDir, out)
} else {
err = ParseDir(inDir, out)
}
} else {
flag.PrintDefaults()
os.Exit(1)
}
if err != nil {
log.Fatalf("failed: %v\n", err)
os.Exit(1)
}
}

View File

@@ -40,7 +40,7 @@ func (app *appContext) ServeSetup(gc *gin.Context) {
return
}
pages := PagePathsDTO{PagePaths: PAGES}
gc.HTML(200, "setup.html", gin.H{
app.gcHTML(gc, 200, "setup.html", SetupPage, lang, gin.H{
"cssVersion": cssVersion,
"pages": pages,
"lang": app.storage.lang.Setup[lang],

View File

@@ -1986,9 +1986,9 @@
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",

BIN
static/favicon-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -769,20 +769,39 @@ type JellyseerrTemplate struct {
Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
}
type SendFailureReason = string
const (
CheckLogs SendFailureReason = "CheckLogs"
NoUser = "NoUser"
MultiUser = "MultiUser"
InvalidAddress = "InvalidAddress"
)
type SendFailure struct {
Address string `json:"address"`
Reason SendFailureReason `json:"reason"`
}
type SentToList struct {
Success []string `json:"success"`
Failed []SendFailure `json:"failed"`
}
type Invite struct {
Code string `badgerhold:"key"`
Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"`
UserMonths int `json:"user-months,omitempty"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"`
// Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"`
Code string `badgerhold:"key"`
Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"`
UserMonths int `json:"user-months,omitempty"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"` // deprecated: use SentTo now.
SentTo SentToList `json:"sent-to,omitempty"`
UsedBy [][]string `json:"used-by"` // Used to be stored as formatted time, now as Unix.
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
@@ -1697,3 +1716,61 @@ func storeJSON(path string, obj interface{}) error {
}
return err
}
// ActiveReferralsByID returns a map of jellyfin user IDs to their active referral "invite" code, if they have one.
// It does not check if the user still exists, simply finding invites with the ReferrerJellyfinID field set.
func (st *Storage) ActiveReferralsByID() map[string]Invite {
out := map[string]Invite{}
for _, inv := range st.GetInvites() {
if inv.ReferrerJellyfinID == "" {
continue
}
out[inv.ReferrerJellyfinID] = inv
}
return out
}
// EmailsByID returns a map of jellyfin user IDs to EmailAddress entries, if they have one.
func (st *Storage) EmailsByID() map[string]EmailAddress {
out := map[string]EmailAddress{}
for _, email := range st.GetEmails() {
out[email.JellyfinID] = email
}
return out
}
// ExpiriesByID returns a map of jellyfin user IDs to User expiries, if they have one.
func (st *Storage) ExpiriesByID() map[string]UserExpiry {
out := map[string]UserExpiry{}
for _, expiry := range st.GetUserExpiries() {
out[expiry.JellyfinID] = expiry
}
return out
}
// DiscordUsersByID returns a map of jellyfin user IDs to Discord user entries, if they have one.
func (st *Storage) DiscordUsersByID() map[string]DiscordUser {
out := map[string]DiscordUser{}
for _, expiry := range st.GetDiscord() {
out[expiry.JellyfinID] = expiry
}
return out
}
// TelegramUsersByID returns a map of jellyfin user IDs to Telegram user entries, if they have one.
func (st *Storage) TelegramUsersByID() map[string]TelegramUser {
out := map[string]TelegramUser{}
for _, expiry := range st.GetTelegram() {
out[expiry.JellyfinID] = expiry
}
return out
}
// MatrixUsersByID returns a map of jellyfin user IDs to Matrix user entries, if they have one.
func (st *Storage) MatrixUsersByID() map[string]MatrixUser {
out := map[string]MatrixUser{}
for _, expiry := range st.GetMatrix() {
out[expiry.JellyfinID] = expiry
}
return out
}

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