mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-10-19 09:13:33 +00:00
Initial commit
Assemblrr: Docker-based bootstrap and wiring for a self-hosted media stack.
This commit is contained in:
commit
e9f356cffd
36 changed files with 6664 additions and 0 deletions
36
.env.example
Normal file
36
.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Assemblrr Environment Configuration
|
||||
# This file documents all available environment variables.
|
||||
# Actual values are generated by setup.sh using envsubst.
|
||||
|
||||
# User/Group IDs — must match the user that owns the media files
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# Paths (defaults are home-based; set during setup)
|
||||
MEDIA_DIRECTORY=/home/<user>/assemblrr-media
|
||||
INSTALL_DIRECTORY=/home/<user>/assemblrr
|
||||
|
||||
# Media service: jellyfin, emby, or plex
|
||||
MEDIA_SERVICE=jellyfin
|
||||
|
||||
# Timezone (IANA format)
|
||||
TZ=UTC
|
||||
|
||||
# VPN Configuration
|
||||
VPN_ENABLED=n
|
||||
VPN_SERVICE=protonvpn
|
||||
VPN_TYPE=openvpn
|
||||
|
||||
# OpenVPN credentials are stored in secrets/openvpn_user.txt and secrets/openvpn_password.txt
|
||||
# WireGuard private key is stored in secrets/wireguard_private_key.txt
|
||||
# These are NOT in .env — they are mounted as Docker secrets so docker inspect won't expose them.
|
||||
|
||||
# WireGuard addresses (not secret, just an internal IP)
|
||||
WIREGUARD_ADDRESSES=
|
||||
|
||||
# Port forwarding (only supported by some VPN providers)
|
||||
PORT_FORWARD_ONLY=off
|
||||
VPN_PORT_FORWARDING=off
|
||||
|
||||
# API Host for configuration script (advanced users)
|
||||
API_HOST=127.0.0.1
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
custom: ['https://www.paypal.com/paypalme/soulis1256']
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
*.log
|
||||
tmp/
|
||||
|
||||
# Runtime files (generated by setup.sh)
|
||||
.env
|
||||
.assemblrr-config
|
||||
|
||||
# VPN credential files (generated by setup.sh, mounted as Docker secrets)
|
||||
secrets/
|
||||
|
||||
# Caveman
|
||||
/.agents/
|
||||
skills-lock.json
|
||||
621
LICENSE
Normal file
621
LICENSE
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
121
README.md
Normal file
121
README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# assemblrr
|
||||
|
||||
> [!NOTE]
|
||||
> **Developer’s note — vision & roadmap**
|
||||
> Assemblrr is infrastructure for self-hosted media *automation* (install, wire services, operate the stack). It does **not** provide media, indexers, or content. What you request, download, and host is entirely your responsibility and must comply with the laws that apply to you.
|
||||
>
|
||||
> Future directions and ideas shall be discussed with the community **[here](https://github.com/soulis-1256/assemblrr/discussions/1)**
|
||||
|
||||
A lightweight media server stack via Docker. Cross-platform, VPN-aware, and built with failsafe shell scripting.
|
||||
|
||||
## Features & Philosophy
|
||||
|
||||
- **100% Docker Native:** No messy host installations. Uses native Docker Compose overlays instead of finicky sed replacements.
|
||||
- **Built for Stability:** Bash scripts enforce strict safety (set -euo pipefail), active dependency checking, and shared VPN orchestration loops.
|
||||
- **Fail-Safe Backups:** Built-in CLI backup & restore features snapshot your configuration before merging to prevent data corruption.
|
||||
- **Secure Networking:** First-class Gluetun VPN integration. All traffic from download clients strictly routes through the VPN context.
|
||||
|
||||
## The Stack
|
||||
|
||||
### Core (included, auto-configured)
|
||||
- **Media Server:** Jellyfin (recommended), Emby, or Plex
|
||||
- **Download Client:** qBittorrent
|
||||
- **Managers:** Sonarr, Radarr
|
||||
- **Indexing:** Prowlarr
|
||||
- **Request Management:** Seerr
|
||||
- **Quality Profiles:** Recyclarr
|
||||
- **Management:** Portainer
|
||||
|
||||
### Optional (via `compose/custom.yaml`)
|
||||
- **Music Manager:** Lidarr
|
||||
- **Usenet Downloader:** SABnzbd
|
||||
- **Subtitles:** Bazarr
|
||||
- **Update Notifications:** Watchtower
|
||||
|
||||
## Installation
|
||||
|
||||
### Linux / WSL2
|
||||
|
||||
bash / zsh:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/soulis-1256/assemblrr/main/platform/linux/bootstrap.sh)
|
||||
```
|
||||
|
||||
fish (no `<(...)` process substitution — use `psub` instead):
|
||||
|
||||
```fish
|
||||
bash (curl -fsSL https://raw.githubusercontent.com/soulis-1256/assemblrr/main/platform/linux/bootstrap.sh | psub)
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
Ensure Docker Desktop is running before execution.
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/soulis-1256/assemblrr/main/platform/windows/bootstrap.ps1 | iex
|
||||
```
|
||||
|
||||
## Usage (Assemblrr CLI)
|
||||
|
||||
The `assemblrr` command is injected into your path locally to orchestrate the Docker containers seamlessly.
|
||||
|
||||
```bash
|
||||
assemblrr start # Start all services (with VPN verification)
|
||||
assemblrr stop # Stop all services
|
||||
assemblrr restart # Restart all services
|
||||
assemblrr status # Show container status
|
||||
assemblrr health # Show healthcheck status of all services
|
||||
assemblrr logs [service] # Follow logs (all services, or one)
|
||||
assemblrr config # Show current configuration
|
||||
assemblrr configure # Auto-configure APIs (Radarr, Prowlarr) using jq
|
||||
assemblrr reconfigure # Re-run the setup wizard
|
||||
assemblrr backup /target/dir # Create a safe snapshot of the configuration
|
||||
assemblrr restore /backup.tar.gz # Non-destructive config wipe and restore
|
||||
assemblrr update-containers # Pull latest images and restart (offers a backup first)
|
||||
assemblrr update-cli # Update the CLI to the latest version
|
||||
assemblrr check-vpn # Actively poll VPN health
|
||||
assemblrr uninstall # Remove everything (asks before deleting data)
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
`assemblrr uninstall` stops and removes the containers, network, CLI, and installation directory. Your media is kept unless you explicitly delete it. If the CLI is missing or the installation is broken, you can still run the bundled CLI directly (`bash ~/assemblrr/cli.sh uninstall`) or remove everything by hand.
|
||||
|
||||
See [docs/uninstall.md](docs/uninstall.md) for the complete removal guide, including a full inventory of what an installation creates.
|
||||
|
||||
## Configuration & Architecture
|
||||
|
||||
Configuration lives in your installation directory (default: `~/assemblrr`).
|
||||
|
||||
- `.env`: Environment overrides (ASSEMBLRR_HOST for remote proxying, PUID/PGID, API variables).
|
||||
- `compose/base.yaml`: Immutable core services.
|
||||
- `compose/vpn.yaml`: Gluetun routing overlays.
|
||||
- `config/`: Persistent data safe from container teardowns.
|
||||
|
||||
## Development
|
||||
|
||||
### Windows (powershell)
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/soulis-1256/assemblrr.git
|
||||
cd assemblrr
|
||||
.\platform\windows\bootstrap-dev.ps1 -Full # Full setup (copies files to WSL2 and runs setup.sh)
|
||||
.\platform\windows\bootstrap-dev.ps1 -Full -Clean # Uninstalls the existing installation, then does a full setup
|
||||
.\platform\windows\bootstrap-dev.ps1 -Update # Sync changed files to WSL2 (no setup re-run, useful for cli changes)
|
||||
```
|
||||
|
||||
### Linux/WSL2
|
||||
|
||||
```bash
|
||||
git clone https://github.com/soulis-1256/assemblrr.git
|
||||
cd assemblrr
|
||||
bash bin/setup.sh
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Originally inspired by [YAMS](https://yams.media/) ([rogsme/yams](https://github.com/rogsme/yams)); Assemblrr has since been rewritten and expanded into its own project.
|
||||
|
||||
### License
|
||||
GNU General Public License v3.0
|
||||
670
bin/cli.sh
Normal file
670
bin/cli.sh
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Assemblrr CLI — manages your media server
|
||||
# This file is installed to ~/.local/bin/<APP_CLI_NAME> during setup
|
||||
|
||||
# Source shared library (safe_source, find_install_directory, logging, colors)
|
||||
# CLI is installed to ~/.local/bin, lib/ lives next to it or in the install dir
|
||||
_cli_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Locate lib directory (repo: lib/ sibling of bin/; installed: lib/ next to CLI)
|
||||
if [ -f "$_cli_self/../lib/core.sh" ]; then
|
||||
_lib_dir="$_cli_self/../lib"
|
||||
elif [ -f "$_cli_self/lib/core.sh" ]; then
|
||||
_lib_dir="$_cli_self/lib"
|
||||
else
|
||||
# Fallback: find install dir first, then source from there
|
||||
_tmp_install=$(grep -r "^INSTALL_DIRECTORY=" /opt/assemblrr/.assemblrr-config "$HOME/assemblrr/.assemblrr-config" "$HOME/.assemblrr-config" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"' || true)
|
||||
if [ -n "$_tmp_install" ] && [ -f "$_tmp_install/lib/core.sh" ]; then
|
||||
_lib_dir="$_tmp_install/lib"
|
||||
else
|
||||
echo -e "\033[0;31mError: lib/core.sh not found. Re-run setup.\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
source "$_lib_dir/core.sh"
|
||||
source "$_lib_dir/branding.sh"
|
||||
source "$_lib_dir/compose.sh"
|
||||
source "$_lib_dir/vpn.sh"
|
||||
|
||||
INSTALL_DIR=$(find_install_directory)
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
log_error "Could not find ${APP_NAME:-assemblrr} installation. Run setup first."
|
||||
fi
|
||||
|
||||
# Source runtime config
|
||||
safe_source "$INSTALL_DIR/.assemblrr-config"
|
||||
|
||||
# Source branding from install directory
|
||||
load_branding "$INSTALL_DIR"
|
||||
|
||||
# Build compose command dynamically (uses lib/compose.sh build_compose_args + adds profile)
|
||||
build_compose_args "$INSTALL_DIR" "${VPN_ENABLED:-n}"
|
||||
DC=(run_docker compose "${COMPOSE_ARGS[@]}" "--profile" "${MEDIA_SERVICE:-jellyfin}")
|
||||
readonly TIMEOUT_SECONDS=60
|
||||
readonly IP_ENDPOINTS=(
|
||||
"https://ipinfo.io/ip"
|
||||
"https://api.ipify.org"
|
||||
"https://checkip.amazonaws.com"
|
||||
"https://tnedi.me"
|
||||
"https://api.myip.la"
|
||||
"https://wtfismyip.com/text"
|
||||
)
|
||||
|
||||
# Commands
|
||||
declare -A COMMANDS=(
|
||||
["--help"]="displays this help message"
|
||||
["restart"]="restarts services (can specify service names)"
|
||||
["stop"]="stops all services (can specify service names)"
|
||||
["start"]="starts services (can specify service names)"
|
||||
["status"]="checks services status"
|
||||
["destroy"]="destroys services so you can start from scratch (can specify service names)"
|
||||
["uninstall"]="completely removes the stack (keeps media unless confirmed) — see docs/uninstall.md"
|
||||
["check-vpn"]="checks if the VPN is working as expected"
|
||||
["backup"]="backs up to the destination location"
|
||||
["restore"]="restores from a backup archive"
|
||||
["update-containers"]="updates all containers"
|
||||
["update-cli"]="updates the CLI script to the latest version"
|
||||
["reconfigure"]="re-runs the setup wizard to change configuration"
|
||||
["logs"]="shows container logs (optionally specify service name)"
|
||||
["health"]="checks health status of all services"
|
||||
["config"]="shows current configuration"
|
||||
["configure"]="auto-configures Radarr, Prowlarr, and indexers"
|
||||
)
|
||||
|
||||
# log_error_inline — same as log_error but without exit (used by check_health)
|
||||
log_error_inline() { echo -e "${RED}$1${NC}"; }
|
||||
|
||||
|
||||
|
||||
show_help() {
|
||||
echo "${APP_DISPLAY_NAME} - ${APP_DESCRIPTION:-Your one-click media server}"
|
||||
echo
|
||||
echo "Usage: ${APP_CLI_NAME} [command] [options]"
|
||||
echo
|
||||
echo "Commands:"
|
||||
for cmd in "${!COMMANDS[@]}"; do
|
||||
printf "%-25s %s\n" "$cmd" "${COMMANDS[$cmd]}"
|
||||
done
|
||||
echo
|
||||
echo "Examples:"
|
||||
echo " ${APP_CLI_NAME} start # Start all services"
|
||||
echo " ${APP_CLI_NAME} start jellyfin # Start the 'jellyfin' service"
|
||||
echo " ${APP_CLI_NAME} stop jellyfin # Stop the 'jellyfin' service"
|
||||
echo " ${APP_CLI_NAME} restart jellyfin # Restart the 'jellyfin' service"
|
||||
echo " ${APP_CLI_NAME} destroy jellyfin # Destroy the 'jellyfin' service"
|
||||
echo " ${APP_CLI_NAME} backup /path/to/backup # Backup to specified directory"
|
||||
echo " ${APP_CLI_NAME} restore /path/to/backup.tar.gz # Restore from backup"
|
||||
echo " ${APP_CLI_NAME} uninstall # Remove everything (asks before deleting data)"
|
||||
echo " ${APP_CLI_NAME} uninstall --force # Remove without prompts (keeps media)"
|
||||
echo " ${APP_CLI_NAME} uninstall --force --media # Remove everything including media"
|
||||
echo " ${APP_CLI_NAME} logs # View all container logs"
|
||||
echo " ${APP_CLI_NAME} logs jellyfin # View specific service logs"
|
||||
echo " ${APP_CLI_NAME} health # Check health of all services"
|
||||
echo " ${APP_CLI_NAME} update-cli # Update CLI to latest version"
|
||||
echo " ${APP_CLI_NAME} reconfigure # Re-run setup wizard"
|
||||
}
|
||||
|
||||
wait_for_services() {
|
||||
local wait_time=0
|
||||
echo -n "Waiting for services to start"
|
||||
|
||||
while [ $wait_time -lt $TIMEOUT_SECONDS ]; do
|
||||
local total_services
|
||||
local running_services
|
||||
|
||||
total_services=$("${DC[@]}" ps --format '{{.Name}}' | wc -l)
|
||||
running_services=$("${DC[@]}" ps --format '{{.Status}}' | grep -c "Up")
|
||||
|
||||
if [ "$total_services" -eq "$running_services" ] && [ "$total_services" -gt 0 ]; then
|
||||
echo
|
||||
log_success "All $total_services services are up and running!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
sleep 1
|
||||
((wait_time++))
|
||||
|
||||
if [ $((wait_time % 10)) -eq 0 ]; then
|
||||
echo
|
||||
echo -n "$running_services/$total_services services running"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
log_error "Not all services started within ${TIMEOUT_SECONDS} seconds ($running_services/$total_services running)"
|
||||
}
|
||||
|
||||
get_ip_with_retries() {
|
||||
local context=$1
|
||||
local cmd_prefix=""
|
||||
|
||||
if [ "$context" = "qbittorrent" ]; then
|
||||
cmd_prefix="run_docker exec qbittorrent"
|
||||
fi
|
||||
|
||||
for endpoint in "${IP_ENDPOINTS[@]}"; do
|
||||
local ip
|
||||
if [ "$context" = "local" ]; then
|
||||
ip=$(curl -s --connect-timeout 5 "$endpoint")
|
||||
else
|
||||
ip=$($cmd_prefix curl -s --connect-timeout 5 "$endpoint")
|
||||
fi
|
||||
|
||||
if [ -n "$ip" ] && [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "$ip"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_vpn() {
|
||||
echo "Getting your IP..."
|
||||
local your_ip
|
||||
your_ip=$(get_ip_with_retries "local") || log_error "Failed to get your IP address from any endpoint"
|
||||
echo "Your IP: $your_ip"
|
||||
|
||||
local country
|
||||
country=$(curl -s --connect-timeout 5 "https://am.i.mullvad.net/country") || log_warning "Couldn't determine your country"
|
||||
[ -n "$country" ] && echo "Your local IP country is $country"
|
||||
|
||||
echo -e "\nGetting your qBittorrent IP..."
|
||||
local qbit_ip
|
||||
qbit_ip=$(get_ip_with_retries "qbittorrent") || log_error "Failed to get qBittorrent IP from any endpoint"
|
||||
echo "qBittorrent IP: $qbit_ip"
|
||||
|
||||
local qbit_country
|
||||
qbit_country=$(run_docker exec qbittorrent curl -s --connect-timeout 5 "https://am.i.mullvad.net/country") || log_warning "Couldn't determine qBittorrent country"
|
||||
[ -n "$qbit_country" ] && echo "qBittorrent country is $qbit_country"
|
||||
|
||||
if [ "$qbit_ip" == "$your_ip" ]; then
|
||||
log_error "WARNING: Your IPs are the same! qBittorrent is exposing your IP!"
|
||||
else
|
||||
log_success "Success: Your IPs are different. qBittorrent is masking your IP!"
|
||||
fi
|
||||
}
|
||||
|
||||
backup_app() {
|
||||
local destination=$1
|
||||
local backup_date
|
||||
backup_date=$(date '+%Y-%m-%d-%s')
|
||||
local backup_file="$destination/${APP_BACKUP_PREFIX}-$backup_date.tar.gz"
|
||||
|
||||
# Verify destination exists or create it
|
||||
if [ ! -d "$destination" ]; then
|
||||
log_info "Backup destination \"$destination\" does not exist. Creating it..."
|
||||
mkdir -p "$destination" || log_error "Failed to create backup destination directory"
|
||||
fi
|
||||
|
||||
echo "Stopping ${APP_DISPLAY_NAME} services..."
|
||||
"${DC[@]}" stop > /dev/null 2>&1 || log_error "Failed to stop services"
|
||||
|
||||
echo -e "\nBacking up ${APP_DISPLAY_NAME} to $destination..."
|
||||
echo "This may take a while depending on the size of your installation."
|
||||
|
||||
# Copy current CLI script and lib modules, create backup (skip copy if running from install dir)
|
||||
local running_cli; running_cli="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null || echo "${_cli_self}/cli.sh")"
|
||||
local target_cli; target_cli="$(realpath "$INSTALL_DIR/cli.sh" 2>/dev/null || echo "$INSTALL_DIR/cli.sh")"
|
||||
if [ -f "$running_cli" ] && [ "$running_cli" != "$target_cli" ]; then
|
||||
cp "$running_cli" "$INSTALL_DIR/cli.sh" 2>/dev/null || log_warning "Failed to backup CLI script"
|
||||
fi
|
||||
|
||||
# Copy lib modules if they exist outside target dir
|
||||
if [ -d "$HOME/.local/bin/lib" ] && [ "$(realpath "$HOME/.local/bin/lib" 2>/dev/null)" != "$(realpath "$INSTALL_DIR/lib" 2>/dev/null)" ]; then
|
||||
cp -r "$HOME/.local/bin/lib" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Run tar inside an alpine container bound to the INSTALL_DIR and the destination (excluding temp/cache and sockets)
|
||||
run_docker run --rm \
|
||||
-v "$INSTALL_DIR:/source" \
|
||||
-v "$(dirname "$backup_file"):/backup" \
|
||||
alpine tar --exclude='./transcoding-temp' --exclude='./config/jellyfin/cache' --exclude='ipc-socket' -czf "/backup/$(basename "$backup_file")" -C /source . ||
|
||||
log_error "Failed to create backup archive"
|
||||
|
||||
echo -e "\nStarting ${APP_DISPLAY_NAME} services..."
|
||||
"${DC[@]}" start > /dev/null 2>&1 || log_warning "Failed to restart services"
|
||||
|
||||
log_success "Backup completed successfully!"
|
||||
echo "Backup file: $backup_file"
|
||||
}
|
||||
|
||||
destroy_app() {
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo -e "\nWARNING: This will destroy all your services, containers, volumes, and the custom network!"
|
||||
echo "This is not recoverable!"
|
||||
while true; do
|
||||
read -p "Are you sure you want to continue? [y/N]: " -r
|
||||
local reply=${REPLY:-n}
|
||||
if [[ ${reply,,} =~ ^y$ ]]; then
|
||||
"${DC[@]}" down || log_error "Failed to destroy services"
|
||||
docker network rm "$APP_NETWORK_NAME" 2>/dev/null || log_warning "Failed to remove $APP_NETWORK_NAME. It might not exist or was already removed."
|
||||
echo -e "\n${APP_DISPLAY_NAME} services were destroyed. To restart, run: ${APP_CLI_NAME} start"
|
||||
break
|
||||
elif [[ ${reply,,} =~ ^n$ ]]; then
|
||||
log_info "Destroy cancelled."
|
||||
break
|
||||
else
|
||||
log_warning "Please answer y or n."
|
||||
fi
|
||||
done
|
||||
else
|
||||
local target_label="$*"
|
||||
echo -e "\nWARNING: This will destroy the service(s): \"${target_label}\". This includes their associated containers and volumes!"
|
||||
echo "This is not recoverable!"
|
||||
while true; do
|
||||
read -p "Are you sure you want to continue? [y/N]: " -r
|
||||
local reply=${REPLY:-n}
|
||||
if [[ ${reply,,} =~ ^y$ ]]; then
|
||||
"${DC[@]}" stop "$@" || log_error "Failed to stop services"
|
||||
"${DC[@]}" rm -f -v "$@" || log_error "Failed to destroy services"
|
||||
echo -e "\n${APP_DISPLAY_NAME} service was destroyed. To restart, run: ${APP_CLI_NAME} start"
|
||||
break
|
||||
elif [[ ${reply,,} =~ ^n$ ]]; then
|
||||
log_info "Destroy cancelled."
|
||||
break
|
||||
else
|
||||
log_warning "Please answer y or n."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_app() {
|
||||
local force=false
|
||||
local delete_media=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-f|--force) force=true ;;
|
||||
-m|--media) delete_media=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --force already opts out of all confirmation; skip the scary preamble.
|
||||
if [ "$force" = false ]; then
|
||||
echo -e "\nWARNING: This will COMPLETELY remove ${APP_DISPLAY_NAME}!"
|
||||
echo "This includes:"
|
||||
echo " - All running containers and volumes"
|
||||
echo " - The installation directory: $INSTALL_DIR"
|
||||
echo " - The CLI command: $APP_CLI_NAME"
|
||||
echo " - The Docker network: $APP_NETWORK_NAME"
|
||||
echo
|
||||
echo "Your media directory ($MEDIA_DIRECTORY) is kept unless you explicitly delete it."
|
||||
echo "Tip: run '${APP_CLI_NAME} backup <target-dir>' first if you want to keep your configuration."
|
||||
echo
|
||||
echo "This is not recoverable!"
|
||||
read -p "Are you ABSOLUTELY sure? Type 'yes' to confirm: " -r
|
||||
if [[ "$REPLY" != "yes" ]]; then
|
||||
echo "Uninstall cancelled."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Stopping all services and removing volumes..."
|
||||
"${DC[@]}" down -v 2>/dev/null || log_warning "Failed to stop some services"
|
||||
|
||||
echo "Removing Docker network..."
|
||||
if run_docker network inspect "$APP_NETWORK_NAME" &>/dev/null; then
|
||||
run_docker network rm "$APP_NETWORK_NAME" 2>/dev/null || log_warning "Failed to remove Docker network $APP_NETWORK_NAME"
|
||||
fi
|
||||
|
||||
echo "Removing installation directory..."
|
||||
if [ "$force" = true ]; then
|
||||
safe_rm_rf "$INSTALL_DIR"
|
||||
log_warning "Installation directory deleted"
|
||||
else
|
||||
read -p "Also delete config at $INSTALL_DIR? Type the full path to confirm: " -r
|
||||
if [[ "$REPLY" = "$INSTALL_DIR" ]]; then
|
||||
safe_rm_rf "$INSTALL_DIR"
|
||||
log_warning "Installation directory deleted"
|
||||
else
|
||||
log_info "Installation directory preserved at $INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$delete_media" = true ]; then
|
||||
safe_rm_rf "$MEDIA_DIRECTORY"
|
||||
log_warning "Media directory deleted"
|
||||
elif [ "$force" = false ]; then
|
||||
read -p "Also delete media at $MEDIA_DIRECTORY? Type the full path to confirm: " -r
|
||||
if [[ "$REPLY" = "$MEDIA_DIRECTORY" ]]; then
|
||||
safe_rm_rf "$MEDIA_DIRECTORY"
|
||||
log_warning "Media directory deleted"
|
||||
else
|
||||
log_info "Media directory preserved at $MEDIA_DIRECTORY"
|
||||
fi
|
||||
else
|
||||
log_info "Media directory preserved at $MEDIA_DIRECTORY (pass --media with --force to delete it)"
|
||||
fi
|
||||
|
||||
echo "Removing CLI..."
|
||||
if [ -n "${APP_CLI_NAME:-}" ]; then
|
||||
rm -f "$HOME/.local/bin/$APP_CLI_NAME" "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null || true
|
||||
fi
|
||||
local _lib_module
|
||||
for _lib_module in core branding compose vpn; do
|
||||
rm -f "$HOME/.local/bin/lib/${_lib_module}.sh" 2>/dev/null || true
|
||||
done
|
||||
rmdir "$HOME/.local/bin/lib" 2>/dev/null || true
|
||||
|
||||
log_success "${APP_DISPLAY_NAME} has been uninstalled!"
|
||||
log_info "Docker images were left on disk — see docs/uninstall.md to remove them."
|
||||
}
|
||||
|
||||
start_app() {
|
||||
# Validate VPN config before starting (if VPN is enabled)
|
||||
if [ "${VPN_ENABLED:-n}" = "y" ]; then
|
||||
if ! validate_vpn_secrets "$INSTALL_DIR" "${VPN_ENABLED:-n}" "${VPN_TYPE:-openvpn}"; then
|
||||
log_error "Set VPN credentials in $INSTALL_DIR/secrets before starting services"
|
||||
fi
|
||||
|
||||
# Test VPN connection by starting Gluetun first
|
||||
if ! test_vpn_connection_shared "${DC[@]}"; then
|
||||
log_error "VPN startup failed. Fix credentials in $INSTALL_DIR/secrets (and .env if needed), then retry."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
"${DC[@]}" up -d || log_error "Failed to start services"
|
||||
wait_for_services
|
||||
else
|
||||
"${DC[@]}" up -d "$@" || log_error "Failed to start services"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_app() {
|
||||
if [ "$#" -eq 0 ]; then
|
||||
"${DC[@]}" stop || log_warning "Some services failed to stop"
|
||||
"${DC[@]}" up -d || log_error "Failed to start services"
|
||||
wait_for_services
|
||||
else
|
||||
"${DC[@]}" stop "$@" || log_warning "Some services failed to stop"
|
||||
"${DC[@]}" up -d "$@" || log_error "Failed to start services"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ $# -eq 0 ]; then
|
||||
"${DC[@]}" stop || log_error "Failed to stop services"
|
||||
else
|
||||
"${DC[@]}" stop "$@" || log_error "Failed to stop services: $*"
|
||||
fi
|
||||
log_success "Services stopped successfully"
|
||||
}
|
||||
|
||||
check_status() {
|
||||
"${DC[@]}" ps || log_error "Failed to check services"
|
||||
}
|
||||
|
||||
update_containers() {
|
||||
local skip_backup=false
|
||||
local assume_yes=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-backup) skip_backup=true ;;
|
||||
-y|--yes) assume_yes=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo -e "\nWARNING: Updating containers may cause data loss or compatibility issues if new versions have breaking changes!"
|
||||
|
||||
if [ "$skip_backup" = false ] && [ "$assume_yes" = false ]; then
|
||||
read -p "Would you like to back up your current configuration first? [Y/n]: " -r
|
||||
if [[ ! ${REPLY,,} =~ ^n$ ]]; then
|
||||
local default_backup_dir="$HOME/assemblrr-backups"
|
||||
echo -e "\nStarting automatic backup to $default_backup_dir..."
|
||||
backup_app "$default_backup_dir"
|
||||
echo -e "\nBackup complete. Proceeding with container update..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$assume_yes" = false ]; then
|
||||
read -p "Are you sure you want to update all containers? [Y/n]: " -r
|
||||
if [[ ${REPLY,,} =~ ^n$ ]]; then
|
||||
echo "Update aborted."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Pulling latest container images..."
|
||||
"${DC[@]}" pull || log_error "Failed to pull containers"
|
||||
echo "Restarting ${APP_DISPLAY_NAME} services..."
|
||||
restart_app
|
||||
log_success "Containers updated successfully!"
|
||||
}
|
||||
|
||||
show_config() {
|
||||
echo "${APP_DISPLAY_NAME} Configuration:"
|
||||
echo " Install directory: $INSTALL_DIR"
|
||||
echo " Media directory: $MEDIA_DIRECTORY"
|
||||
echo " Media service: $MEDIA_SERVICE"
|
||||
echo " VPN enabled: $VPN_ENABLED"
|
||||
echo " VPN type: $VPN_TYPE"
|
||||
echo " Timezone: $TZ"
|
||||
if [ "${VPN_ENABLED:-n}" = "y" ]; then
|
||||
echo " VPN secrets: $INSTALL_DIR/secrets/"
|
||||
fi
|
||||
echo
|
||||
echo "Compose files:"
|
||||
local _prev="" _arg
|
||||
for _arg in "${COMPOSE_ARGS[@]}"; do
|
||||
if [ "$_prev" = "-f" ]; then
|
||||
echo " $_arg"
|
||||
fi
|
||||
_prev="$_arg"
|
||||
done
|
||||
}
|
||||
|
||||
restore_app() {
|
||||
local backup_file="$1"
|
||||
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
log_error "Backup file not found: $backup_file"
|
||||
fi
|
||||
|
||||
echo -e "\nWARNING: This will stop all services and overwrite the current installation!"
|
||||
echo "Backup file: $backup_file"
|
||||
read -p "Are you sure you want to restore? [y/N]: " -r
|
||||
if [[ ${REPLY,,} != "y" ]]; then
|
||||
echo "Restore cancelled."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Stopping ${APP_DISPLAY_NAME} services..."
|
||||
"${DC[@]}" down 2>/dev/null || log_warning "Failed to stop services"
|
||||
|
||||
echo "Restoring from backup..."
|
||||
|
||||
# Safely clear the current configuration instead of blindly overwriting
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_dir="${INSTALL_DIR}_backup_${timestamp}"
|
||||
|
||||
echo "Creating safety snapshot of current state at ${backup_dir}..."
|
||||
# Use alpine container to copy the snapshot safely
|
||||
run_docker run --rm \
|
||||
-v "$(dirname "$INSTALL_DIR"):/target_parent" \
|
||||
alpine cp -r "/target_parent/$(basename "$INSTALL_DIR")" "/target_parent/$(basename "$backup_dir")" || log_warning "Failed to create safety snapshot"
|
||||
|
||||
# Remove files using alpine so we don't hit permission denied issues
|
||||
run_docker run --rm \
|
||||
-v "$INSTALL_DIR:/target" \
|
||||
alpine sh -c 'find /target -mindepth 1 -maxdepth 1 ! -name "media" ! -name "downloads" -exec rm -rf {} +'
|
||||
|
||||
# Extract using alpine
|
||||
local abs_backup_file; abs_backup_file=$(realpath "$backup_file")
|
||||
run_docker run --rm \
|
||||
-v "$INSTALL_DIR:/target" \
|
||||
-v "$(dirname "$abs_backup_file"):/backup" \
|
||||
alpine tar -xzf "/backup/$(basename "$abs_backup_file")" -C /target || log_error "Failed to extract backup"
|
||||
|
||||
echo "Starting ${APP_DISPLAY_NAME} services..."
|
||||
"${DC[@]}" up -d || log_warning "Failed to start services"
|
||||
|
||||
echo "Cleaning up old safety snapshots (keeping last 3)..."
|
||||
# Keep only the 3 most recent backups, remove the rest
|
||||
ls -dt "${INSTALL_DIR}_backup_"* 2>/dev/null | tail -n +4 | while read -r old_backup; do
|
||||
rm -rf "$old_backup" 2>/dev/null || true
|
||||
done
|
||||
|
||||
log_success "Restore completed successfully!"
|
||||
echo "Safety snapshot kept at: $backup_dir"
|
||||
}
|
||||
|
||||
show_logs() {
|
||||
if [ "${2:-}" ]; then
|
||||
"${DC[@]}" logs -f "${@:2}"
|
||||
else
|
||||
"${DC[@]}" logs -f
|
||||
fi
|
||||
}
|
||||
|
||||
check_health() {
|
||||
echo "${APP_DISPLAY_NAME} Health Check:"
|
||||
echo
|
||||
local unhealthy=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
local name status health
|
||||
name=$(echo "$line" | awk '{print $1}')
|
||||
status=$(echo "$line" | awk '{print $2}')
|
||||
health=$(echo "$line" | awk '{print $3}')
|
||||
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log_success " $name: $health"
|
||||
elif [ "$status" = "Up" ] && [ -z "$health" ]; then
|
||||
log_warning " $name: running (no healthcheck defined)"
|
||||
elif [ "$health" = "starting" ]; then
|
||||
log_warning " $name: starting..."
|
||||
else
|
||||
log_error_inline " $name: $health"
|
||||
((unhealthy++))
|
||||
fi
|
||||
done < <("${DC[@]}" ps --format '{{.Name}} {{.Status}}' 2>/dev/null | sed 's/(/ /;s/)/ /' | awk '{print $1, $2, $3}')
|
||||
|
||||
echo
|
||||
if [ $unhealthy -gt 0 ]; then
|
||||
log_warning "$unhealthy service(s) are unhealthy"
|
||||
else
|
||||
log_success "All services are healthy"
|
||||
fi
|
||||
}
|
||||
|
||||
update_cli() {
|
||||
echo "Updating ${APP_DISPLAY_NAME} CLI..."
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
|
||||
if ! git clone --depth=1 "${APP_REPO_URL}" "$tmp_dir/assemblrr" 2>/dev/null; then
|
||||
log_error "Failed to clone ${APP_REPO_URL}. Check your internet connection and repository access."
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.local/bin/lib"
|
||||
cp "$tmp_dir/assemblrr/lib/core.sh" "$HOME/.local/bin/lib/core.sh"
|
||||
cp "$tmp_dir/assemblrr/lib/branding.sh" "$HOME/.local/bin/lib/branding.sh"
|
||||
cp "$tmp_dir/assemblrr/lib/compose.sh" "$HOME/.local/bin/lib/compose.sh"
|
||||
cp "$tmp_dir/assemblrr/lib/vpn.sh" "$HOME/.local/bin/lib/vpn.sh"
|
||||
cp "$tmp_dir/assemblrr/bin/cli.sh" "$HOME/.local/bin/$APP_CLI_NAME" && chmod +x "$HOME/.local/bin/$APP_CLI_NAME"
|
||||
# Remove old system-wide install if it exists
|
||||
[ -n "${APP_CLI_NAME:-}" ] && rm -f "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null
|
||||
log_success "CLI updated successfully!"
|
||||
|
||||
[ -n "$tmp_dir" ] && rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
reconfigure_app() {
|
||||
echo "Re-running ${APP_DISPLAY_NAME} setup wizard..."
|
||||
echo "Current configuration will be used as defaults."
|
||||
echo
|
||||
|
||||
# Find the setup script — could be in install dir or need to be cloned
|
||||
local setup_script
|
||||
if [ -f "$INSTALL_DIR/setup.sh" ]; then
|
||||
setup_script="$INSTALL_DIR/setup.sh"
|
||||
else
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
if ! git clone --depth=1 "${APP_REPO_URL}" "$tmp_dir/assemblrr" 2>/dev/null; then
|
||||
log_error "Failed to clone ${APP_REPO_URL}. Check your internet connection and repository access."
|
||||
fi
|
||||
setup_script="$tmp_dir/assemblrr/bin/setup.sh"
|
||||
fi
|
||||
|
||||
bash "$setup_script"
|
||||
}
|
||||
|
||||
main() {
|
||||
local command=${1:-"--help"}
|
||||
local destination=${2:-.}
|
||||
|
||||
if [ "$command" = "backup" ]; then
|
||||
destination=$(realpath "$destination" 2>/dev/null || echo "$destination") || log_error "Invalid backup destination path"
|
||||
fi
|
||||
|
||||
case "$command" in
|
||||
--help)
|
||||
show_help
|
||||
;;
|
||||
restart)
|
||||
restart_app "${@:2}"
|
||||
;;
|
||||
stop)
|
||||
stop_app "${@:2}"
|
||||
;;
|
||||
start)
|
||||
start_app "${@:2}"
|
||||
;;
|
||||
status)
|
||||
check_status
|
||||
;;
|
||||
check-vpn)
|
||||
check_vpn
|
||||
;;
|
||||
destroy)
|
||||
destroy_app "${@:2}"
|
||||
;;
|
||||
uninstall)
|
||||
uninstall_app "${@:2}"
|
||||
;;
|
||||
backup)
|
||||
backup_app "$destination"
|
||||
;;
|
||||
restore)
|
||||
[ -z "${2:-}" ] && log_error "Usage: ${APP_CLI_NAME} restore /path/to/backup.tar.gz"
|
||||
restore_app "${2}"
|
||||
;;
|
||||
update-containers)
|
||||
update_containers "${@:2}"
|
||||
;;
|
||||
update-cli)
|
||||
update_cli
|
||||
;;
|
||||
reconfigure)
|
||||
reconfigure_app
|
||||
;;
|
||||
logs)
|
||||
show_logs "$@"
|
||||
;;
|
||||
health)
|
||||
check_health
|
||||
;;
|
||||
config)
|
||||
show_config
|
||||
;;
|
||||
configure)
|
||||
if [ -f "$INSTALL_DIR/configure.sh" ]; then
|
||||
bash "$INSTALL_DIR/configure.sh"
|
||||
else
|
||||
log_error "configure.sh not found in $INSTALL_DIR"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: $command\nRun '${APP_CLI_NAME} --help' for usage information"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
177
bin/configure.sh
Normal file
177
bin/configure.sh
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Assemblrr Service Auto-Configuration
|
||||
# Configures Radarr, Prowlarr, and related services after initial deployment
|
||||
# Called by setup.sh after containers start, or via: assemblrr configure
|
||||
# Uses jq for JSON parsing
|
||||
|
||||
# --- Config discovery ---
|
||||
|
||||
# Locate lib/ directory (handles both repo layout and installed layout)
|
||||
_cfg_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ -f "$_cfg_self/../lib/core.sh" ]; then
|
||||
_lib_dir="$_cfg_self/../lib" # repo: bin/ and lib/ are siblings
|
||||
elif [ -f "$_cfg_self/lib/core.sh" ]; then
|
||||
_lib_dir="$_cfg_self/lib" # installed: lib/ is subdirectory of install dir
|
||||
elif [ -f "${ASSEMBLRR_DIR:-$HOME/assemblrr}/lib/core.sh" ]; then
|
||||
_lib_dir="${ASSEMBLRR_DIR:-$HOME/assemblrr}/lib"
|
||||
else
|
||||
echo -e "\033[0;31mError: lib/core.sh not found. Re-run setup.\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source core library (logging, colors, safe_source, find_install_directory)
|
||||
source "$_lib_dir/core.sh"
|
||||
# Source branding
|
||||
source "$_lib_dir/branding.sh"
|
||||
|
||||
INSTALL_DIR=$(find_install_directory)
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
log_error "Could not find installation. Run setup first."
|
||||
fi
|
||||
|
||||
# Source runtime config and branding
|
||||
safe_source "$INSTALL_DIR/.assemblrr-config"
|
||||
load_branding "$INSTALL_DIR"
|
||||
|
||||
# Source .env for VPN_ENABLED and other settings
|
||||
set -a; safe_source "$INSTALL_DIR/.env"; set +a
|
||||
API_HOST="${API_HOST:-127.0.0.1}"
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
# Docker network URLs (for inter-service communication)
|
||||
RADARR_DOCKER_URL="http://radarr:7878"
|
||||
SONARR_DOCKER_URL="http://sonarr:8989"
|
||||
PROWLARR_DOCKER_URL="http://prowlarr:9696"
|
||||
|
||||
# Download client host depends on VPN mode
|
||||
if [ "${VPN_ENABLED:-n}" = "y" ]; then
|
||||
QBITTORRENT_HOST="gluetun"
|
||||
else
|
||||
QBITTORRENT_HOST="qbittorrent"
|
||||
fi
|
||||
|
||||
# Auth credentials
|
||||
AUTH_USERNAME=$(cat "$INSTALL_DIR/secrets/auth_username.txt" 2>/dev/null || echo "$USER")
|
||||
AUTH_PASSWORD=$(cat "$INSTALL_DIR/secrets/auth_password.txt" 2>/dev/null || echo "")
|
||||
|
||||
# --- Logging ---
|
||||
|
||||
# Always create a configure log file (same output for both express and manual)
|
||||
CONFIGURE_LOG="/tmp/${APP_NAME}-configure-$(date '+%Y%m%d-%H%M%S').log"
|
||||
|
||||
# Extended logging for configure (step markers on top of lib/core.sh base, tee to log file)
|
||||
_cfg_log_info() { echo " $1" | tee -a "$CONFIGURE_LOG"; }
|
||||
|
||||
# Track step counts for end-of-run summary
|
||||
_configure_total=0
|
||||
_configure_ok=0
|
||||
_configure_fail=0
|
||||
log_step() { echo -e " ${GREEN}✓${NC} $1" | tee -a "$CONFIGURE_LOG"; _configure_ok=$((_configure_ok + 1)); _configure_total=$((_configure_total + 1)); }
|
||||
log_step_fail() { echo -e " ${RED}✗${NC} $1" | tee -a "$CONFIGURE_LOG"; _configure_fail=$((_configure_fail + 1)); _configure_total=$((_configure_total + 1)); }
|
||||
|
||||
# Prompt for custom indexers (fzf TUI)
|
||||
source "$_lib_dir/fzf-tui.sh"
|
||||
|
||||
# --- API helpers ---
|
||||
source "$_lib_dir/api.sh"
|
||||
|
||||
# --- Service configuration modules ---
|
||||
source "$_lib_dir/arr.sh"
|
||||
source "$_lib_dir/jellyfin.sh"
|
||||
source "$_lib_dir/seerr.sh"
|
||||
|
||||
# --- Main ---
|
||||
|
||||
echo
|
||||
_cfg_log_info "Auto-configuring ${APP_DISPLAY_NAME} services..."
|
||||
echo
|
||||
|
||||
# Read API keys from config.xml (created by services on first start)
|
||||
RADARR_API_KEY=$(read_api_key "radarr") || RADARR_API_KEY=""
|
||||
SONARR_API_KEY=$(read_api_key "sonarr") || SONARR_API_KEY=""
|
||||
PROWLARR_API_KEY=$(read_api_key "prowlarr") || PROWLARR_API_KEY=""
|
||||
|
||||
if [ -z "$RADARR_API_KEY" ]; then
|
||||
log_step_fail "Cannot read Radarr API key — skipping Radarr configuration"
|
||||
fi
|
||||
|
||||
if [ -z "$SONARR_API_KEY" ]; then
|
||||
log_step_fail "Cannot read Sonarr API key — skipping Sonarr configuration"
|
||||
fi
|
||||
|
||||
if [ -z "$PROWLARR_API_KEY" ]; then
|
||||
log_step_fail "Cannot read Prowlarr API key — skipping Prowlarr configuration"
|
||||
fi
|
||||
|
||||
# Wait for APIs to be fully ready
|
||||
if [ -n "$RADARR_API_KEY" ]; then
|
||||
wait_for_api "Radarr" "7878" "$RADARR_API_KEY" || true
|
||||
fi
|
||||
|
||||
if [ -n "$SONARR_API_KEY" ]; then
|
||||
wait_for_api "Sonarr" "8989" "$SONARR_API_KEY" || true
|
||||
fi
|
||||
|
||||
if [ -n "$PROWLARR_API_KEY" ]; then
|
||||
wait_for_api "Prowlarr" "9696" "$PROWLARR_API_KEY" "/api/v1/system/status" || true
|
||||
configure_indexers "$PROWLARR_API_KEY"
|
||||
fi
|
||||
|
||||
# Set qBittorrent credentials once (before configuring any *arr service)
|
||||
if [ -n "$RADARR_API_KEY" ] || [ -n "$SONARR_API_KEY" ]; then
|
||||
qbit_set_credentials || true
|
||||
fi
|
||||
|
||||
# Configure services
|
||||
if [ -n "$RADARR_API_KEY" ]; then
|
||||
configure_radarr "$RADARR_API_KEY" || true
|
||||
fi
|
||||
|
||||
if [ -n "$SONARR_API_KEY" ]; then
|
||||
configure_sonarr "$SONARR_API_KEY" || true
|
||||
fi
|
||||
|
||||
if [ -n "$PROWLARR_API_KEY" ]; then
|
||||
configure_prowlarr "$PROWLARR_API_KEY" "$RADARR_API_KEY" "$SONARR_API_KEY" || true
|
||||
fi
|
||||
|
||||
if [ "${MEDIA_SERVICE:-}" = "jellyfin" ]; then
|
||||
configure_jellyfin || true
|
||||
configure_jellyfin_notifications || true
|
||||
fi
|
||||
|
||||
# Configure Recyclarr (must run before Seerr so quality profiles exist in Radarr/Sonarr)
|
||||
configure_recyclarr || true
|
||||
|
||||
# Configure Seerr (must run after media server is configured AND Recyclarr has synced profiles)
|
||||
configure_seerr || true
|
||||
|
||||
# Show compact summary
|
||||
if [ "${_configure_fail:-0}" -gt 0 ]; then
|
||||
echo
|
||||
echo -e " ${YELLOW}${_configure_ok} succeeded, ${_configure_fail} failed${NC}"
|
||||
echo -e " ${YELLOW}Details: $CONFIGURE_LOG${NC}"
|
||||
echo
|
||||
else
|
||||
echo
|
||||
echo -e " ${GREEN}All ${_configure_ok} steps succeeded${NC}"
|
||||
echo
|
||||
fi
|
||||
|
||||
_cfg_log_info "Next steps:"
|
||||
if [ "${MEDIA_SERVICE:-}" = "jellyfin" ]; then
|
||||
_cfg_log_info " 1. Open Jellyfin at http://${API_HOST}:8096"
|
||||
_cfg_log_info " 2. Open Seerr at http://${API_HOST}:5055 and request a movie"
|
||||
_cfg_log_info " 3. It will download automatically via qBittorrent and appear in Jellyfin"
|
||||
else
|
||||
_cfg_log_info " 1. Open Seerr at http://${API_HOST}:5055"
|
||||
_cfg_log_info " 2. Request a movie — it will download automatically via qBittorrent"
|
||||
_cfg_log_info " 3. Your media service will pick it up from your media folder"
|
||||
fi
|
||||
echo
|
||||
if [ -n "$AUTH_USERNAME" ]; then
|
||||
_cfg_log_info "Login credentials: $AUTH_USERNAME / (the password you set during setup)"
|
||||
fi
|
||||
109
bin/docker-install.sh
Normal file
109
bin/docker-install.sh
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Docker installation helper — supports multiple distros
|
||||
# Called by setup.sh when Docker is not found
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_ROOT="$SCRIPT_DIR"
|
||||
|
||||
# Source shared library (safe_source, logging, colors)
|
||||
if [ -f "$SCRIPT_DIR/../lib/core.sh" ]; then
|
||||
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
source "$APP_ROOT/lib/core.sh"
|
||||
source "$APP_ROOT/lib/branding.sh"
|
||||
elif [ -f "$SCRIPT_DIR/lib/core.sh" ]; then
|
||||
source "$SCRIPT_DIR/lib/core.sh"
|
||||
source "$SCRIPT_DIR/lib/branding.sh"
|
||||
else
|
||||
# Minimal fallback if lib modules are not available
|
||||
log_error() { echo -e "\033[0;31m$1\033[0m" >&2; exit 1; }
|
||||
log_info() { echo "$1"; }
|
||||
log_success() { echo -e "\033[0;32m$1\033[0m"; }
|
||||
load_branding() {
|
||||
APP_NAME="assemblrr"
|
||||
APP_DISPLAY_NAME="Assemblrr"
|
||||
APP_CLI_NAME="assemblrr"
|
||||
}
|
||||
fi
|
||||
|
||||
# Source branding — use load_branding for validation + fallback
|
||||
load_branding "$APP_ROOT"
|
||||
|
||||
detect_distro() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
elif [ -f /etc/arch-release ]; then
|
||||
echo "arch"
|
||||
elif [ -f /etc/fedora-release ]; then
|
||||
echo "fedora"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
install_docker_debian() {
|
||||
local distro_id="$1"
|
||||
local codename
|
||||
codename=$(lsb_release -cs 2>/dev/null || echo "unknown")
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/$distro_id/gpg" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$distro_id \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
}
|
||||
|
||||
install_docker_arch() {
|
||||
sudo pacman -Sy --noconfirm --needed docker docker-compose
|
||||
}
|
||||
|
||||
install_docker_fedora() {
|
||||
sudo dnf install -y dnf-plugins-core
|
||||
sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
|
||||
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
}
|
||||
|
||||
main() {
|
||||
local distro
|
||||
distro=$(detect_distro)
|
||||
|
||||
log_info "Detected distribution: $distro"
|
||||
|
||||
case "$distro" in
|
||||
ubuntu|debian|linuxmint|pop|elementary)
|
||||
install_docker_debian "$distro"
|
||||
;;
|
||||
arch|manjaro|endeavouros|garuda)
|
||||
install_docker_arch
|
||||
;;
|
||||
fedora)
|
||||
install_docker_fedora
|
||||
;;
|
||||
*)
|
||||
log_error "Unsupported distribution: $distro. Please install Docker manually from https://docs.docker.com/engine/install/"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker "$USER"
|
||||
|
||||
# Start Docker service
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
log_success "Docker installed successfully!"
|
||||
log_info "NOTE: You may need to log out and back in for group changes to take effect."
|
||||
log_info "Re-run the setup script after Docker is available."
|
||||
exit 0
|
||||
}
|
||||
|
||||
main
|
||||
699
bin/setup.sh
Normal file
699
bin/setup.sh
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_ROOT=""
|
||||
|
||||
# Support both repository layout (`bin/` + `lib/`) and installed layout
|
||||
# (entrypoints copied into the install root with `lib/` underneath).
|
||||
if [ -f "$SCRIPT_DIR/../lib/core.sh" ]; then
|
||||
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
elif [ -f "$SCRIPT_DIR/lib/core.sh" ]; then
|
||||
APP_ROOT="$SCRIPT_DIR"
|
||||
else
|
||||
echo -e "\033[0;31mError: lib/core.sh not found. Re-run setup from a complete Assemblrr tree.\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_project_file() {
|
||||
local relative_path="$1"
|
||||
local candidate="$APP_ROOT/$relative_path"
|
||||
|
||||
if [ -f "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$relative_path" in
|
||||
bin/*)
|
||||
candidate="$APP_ROOT/${relative_path#bin/}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -f "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Source core library (logging, colors, safe_source, path utilities)
|
||||
source "$APP_ROOT/lib/core.sh"
|
||||
|
||||
# Source branding — all names/paths come from here
|
||||
source "$APP_ROOT/lib/branding.sh"
|
||||
load_branding "$APP_ROOT"
|
||||
|
||||
# Source Docker Compose builder
|
||||
source "$APP_ROOT/lib/compose.sh"
|
||||
|
||||
# Source VPN helpers
|
||||
source "$APP_ROOT/lib/vpn.sh"
|
||||
|
||||
echo "===================================================="
|
||||
echo " | | | | "
|
||||
echo " __ _ ___ ___ ___ _ __ ___ | |__ | |_ __ _ __ "
|
||||
echo " / _\` / __/ __|/ _ \ '_ \` _ \\| '_ \\| | '__| '__|"
|
||||
echo "| (_| \\__ \\__ \\ __/ | | | | | |_) | | | | | "
|
||||
echo " \\__,_|___/___/\\___|_| |_| |_|_.__/|_|_| |_| "
|
||||
echo " "
|
||||
echo "===================================================="
|
||||
echo "Welcome to ${APP_DISPLAY_NAME} (${APP_DESCRIPTION})"
|
||||
echo "===================================================="
|
||||
echo ""
|
||||
|
||||
# --- Setup mode ---
|
||||
SETUP_MODE=""
|
||||
echo " 1) Express (recommended — minimal questions, smart defaults)"
|
||||
echo " 2) Manual (full control over every setting)"
|
||||
while true; do
|
||||
read -p "Choose setup mode [1]: " setup_mode_choice
|
||||
setup_mode_choice=${setup_mode_choice:-1}
|
||||
case "$setup_mode_choice" in
|
||||
1) SETUP_MODE="express"; break ;;
|
||||
2) SETUP_MODE="manual"; break ;;
|
||||
*) log_warning "Invalid choice. Please choose 1 or 2." ;;
|
||||
esac
|
||||
done
|
||||
export SETUP_MODE
|
||||
|
||||
if [ "$SETUP_MODE" = "express" ]; then
|
||||
echo ""
|
||||
log_success "Express mode — we'll use smart defaults and only ask the essentials."
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
log_info "Manual mode — you'll configure every setting."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Constants
|
||||
readonly SUPPORTED_MEDIA_SERVICES=("jellyfin" "emby" "plex")
|
||||
readonly DEFAULT_MEDIA_SERVICE="jellyfin"
|
||||
readonly DEFAULT_VPN_SERVICE="protonvpn"
|
||||
readonly MEDIA_SUBDIRS=("torrents/movies" "torrents/tv" "media/movies" "media/tv" "blackhole")
|
||||
|
||||
# Dependencies
|
||||
readonly REQUIRED_COMMANDS=("curl" "jq" "sed" "awk" "envsubst" "fzf")
|
||||
|
||||
# LOG_FILE is set in main() — lib/core.sh log_info/log_debug handle file logging natively
|
||||
# Path utilities (expand_path, read_masked, create_and_verify_directory, etc.) are in lib/core.sh
|
||||
|
||||
detect_package_manager() {
|
||||
if command -v apt-get &>/dev/null; then
|
||||
echo "apt"
|
||||
elif command -v pacman &>/dev/null; then
|
||||
echo "pacman"
|
||||
elif command -v dnf &>/dev/null; then
|
||||
echo "dnf"
|
||||
elif command -v zypper &>/dev/null; then
|
||||
echo "zypper"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
local pkgs=("$@")
|
||||
local pm
|
||||
pm=$(detect_package_manager)
|
||||
|
||||
case "$pm" in
|
||||
apt)
|
||||
sudo apt-get update -qq && sudo apt-get install -y "${pkgs[@]}"
|
||||
;;
|
||||
pacman)
|
||||
sudo pacman -Sy --noconfirm --needed "${pkgs[@]}"
|
||||
;;
|
||||
dnf)
|
||||
sudo dnf install -y "${pkgs[@]}"
|
||||
;;
|
||||
zypper)
|
||||
sudo zypper -n in "${pkgs[@]}"
|
||||
;;
|
||||
*)
|
||||
log_error "Unsupported package manager. Please install manually: ${pkgs[*]}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_dependencies() {
|
||||
local missing_packages=()
|
||||
|
||||
for pkg in "${REQUIRED_COMMANDS[@]}"; do
|
||||
local works=false
|
||||
if command -v "$pkg" &>/dev/null; then
|
||||
case "$pkg" in
|
||||
curl) curl --version &>/dev/null && works=true ;;
|
||||
jq) echo "{}" | jq . &>/dev/null && works=true ;;
|
||||
awk) echo "" | awk '{print}' &>/dev/null && works=true ;;
|
||||
sed) echo "" | sed '' &>/dev/null && works=true ;;
|
||||
envsubst) echo "" | envsubst &>/dev/null && works=true ;;
|
||||
fzf) fzf --version &>/dev/null && works=true ;;
|
||||
*) works=true ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! $works; then
|
||||
missing_packages+=("$pkg")
|
||||
else
|
||||
log_success "$pkg works"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_packages[@]} -gt 0 ]; then
|
||||
log_warning "Missing required packages: ${missing_packages[*]}"
|
||||
read -p "Would you like to install the missing packages? (y/N) [Default = n]: " install_deps
|
||||
install_deps=${install_deps:-"n"}
|
||||
|
||||
if [ "${install_deps,,}" = "y" ]; then
|
||||
echo "Installing missing packages..."
|
||||
if ! install_packages "${missing_packages[@]}"; then
|
||||
log_error "Failed to install missing packages. Please install them manually: ${missing_packages[*]}"
|
||||
fi
|
||||
log_success "Successfully installed missing packages"
|
||||
else
|
||||
log_error "Please install the required packages manually: ${missing_packages[*]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check Docker and Docker Compose
|
||||
if ! verify_docker; then
|
||||
if ! command -v docker &>/dev/null || ! docker compose version &>/dev/null; then
|
||||
log_warning "Docker/Docker Compose not found!"
|
||||
|
||||
# If in WSL2, do not attempt to install Docker inside the distro.
|
||||
# Recommend installing Docker Desktop on Windows.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
log_error "WSL2 environment detected, but Docker Desktop is not running or not installed on Windows.\n\nPlease install Docker Desktop for Windows and enable WSL2 integration:\n https://docs.docker.com/desktop/wsl/"
|
||||
fi
|
||||
|
||||
read -p "Install Docker and Docker Compose? (y/N) [Default = n]: " install_docker
|
||||
install_docker=${install_docker:-"n"}
|
||||
|
||||
if [ "${install_docker,,}" = "y" ]; then
|
||||
bash "$SCRIPT_DIR/docker-install.sh"
|
||||
if ! verify_docker; then
|
||||
log_error "Docker daemon is not running. Please start Docker and try again."
|
||||
fi
|
||||
else
|
||||
log_error "Please install Docker and Docker Compose first"
|
||||
fi
|
||||
else
|
||||
log_error "Docker daemon is not running. Please start Docker and try again."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Perform snap check if docker is installed
|
||||
if command -v docker &>/dev/null; then
|
||||
if [[ $(which docker) == "/snap/bin/docker" ]]; then
|
||||
log_error "Docker is installed via snap. ${APP_DISPLAY_NAME} requires the official Docker installation. Please remove snap Docker and install from https://docs.docker.com/engine/install/"
|
||||
fi
|
||||
log_success "Docker is installed and running"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Setup prompts (sourced from lib/prompts.sh) ---
|
||||
# Requires: lib/core.sh already sourced, constants defined above
|
||||
# fzf-tui helpers (fzf_single_select, fzf_multi_select) must be sourced before prompts
|
||||
source "$APP_ROOT/lib/fzf-tui.sh"
|
||||
source "$APP_ROOT/lib/prompts.sh"
|
||||
|
||||
copy_configuration_files() {
|
||||
local -A files=(
|
||||
["lib/core.sh"]="lib/core.sh"
|
||||
["lib/branding.sh"]="lib/branding.sh"
|
||||
["lib/compose.sh"]="lib/compose.sh"
|
||||
["lib/vpn.sh"]="lib/vpn.sh"
|
||||
["lib/api.sh"]="lib/api.sh"
|
||||
["lib/arr.sh"]="lib/arr.sh"
|
||||
["lib/jellyfin.sh"]="lib/jellyfin.sh"
|
||||
["lib/seerr.sh"]="lib/seerr.sh"
|
||||
["lib/prompts.sh"]="lib/prompts.sh"
|
||||
["lib/fzf-tui.sh"]="lib/fzf-tui.sh"
|
||||
["compose/base.yaml"]="compose/base.yaml"
|
||||
["compose/direct-access.yaml"]="compose/direct-access.yaml"
|
||||
["compose/vpn.yaml"]="compose/vpn.yaml"
|
||||
["compose/examples/custom.yaml.example"]="compose/examples/custom.yaml.example"
|
||||
["templates/envsubst.env"]="templates/envsubst.env"
|
||||
["templates/recyclarr-ultra_hd.yml"]="templates/recyclarr-ultra_hd.yml"
|
||||
["templates/recyclarr-full_hd.yml"]="templates/recyclarr-full_hd.yml"
|
||||
["branding.conf"]="branding.conf"
|
||||
["bin/cli.sh"]="cli.sh"
|
||||
["bin/configure.sh"]="configure.sh"
|
||||
["bin/setup.sh"]="setup.sh"
|
||||
["bin/docker-install.sh"]="docker-install.sh"
|
||||
["scripts/jellyfin-refresh.sh"]="scripts/jellyfin-refresh.sh"
|
||||
["scripts/vpn-watchdog.sh"]="scripts/vpn-watchdog.sh"
|
||||
)
|
||||
|
||||
for src in "${!files[@]}"; do
|
||||
local dest="$install_directory/${files[$src]}"
|
||||
local dest_dir
|
||||
local src_path=""
|
||||
dest_dir=$(dirname "$dest")
|
||||
|
||||
if [ ! -d "$dest_dir" ]; then
|
||||
mkdir -p "$dest_dir"
|
||||
fi
|
||||
|
||||
if ! src_path=$(resolve_project_file "$src"); then
|
||||
if [ "$src" = "compose/examples/custom.yaml.example" ]; then
|
||||
log_warning "Optional example file not found: $src"
|
||||
continue
|
||||
fi
|
||||
log_error "Failed to locate source file: $src"
|
||||
fi
|
||||
|
||||
log_info "Copying $src to $dest..."
|
||||
if cp "$src_path" "$dest"; then
|
||||
# Custom scripts mounted into *arr containers must be executable;
|
||||
# Radarr/Sonarr validate CustomScript paths by exec'ing them on save.
|
||||
case "$src" in
|
||||
scripts/*) chmod +x "$dest" ;;
|
||||
esac
|
||||
log_success "$src copied successfully"
|
||||
else
|
||||
log_error "Failed to copy $src to $dest. Check permissions"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
generate_env_file() {
|
||||
local env_file="$install_directory/.env"
|
||||
|
||||
log_info "Generating environment configuration..."
|
||||
|
||||
# Set VPN-related variables for envsubst (credentials go to secrets/)
|
||||
if [ "${setup_vpn,,}" == "y" ]; then
|
||||
VPN_ENABLED="y"
|
||||
VPN_SERVICE="$vpn_service"
|
||||
VPN_TYPE="$vpn_type"
|
||||
WIREGUARD_ADDRESSES="${wireguard_addresses:-}"
|
||||
if [ "${enable_port_forwarding,,}" = "y" ]; then
|
||||
PORT_FORWARD_ONLY="on"
|
||||
VPN_PORT_FORWARDING="on"
|
||||
else
|
||||
PORT_FORWARD_ONLY="off"
|
||||
VPN_PORT_FORWARDING="off"
|
||||
fi
|
||||
else
|
||||
VPN_ENABLED="n"
|
||||
VPN_SERVICE=""
|
||||
VPN_TYPE="openvpn"
|
||||
WIREGUARD_ADDRESSES=""
|
||||
PORT_FORWARD_ONLY="off"
|
||||
VPN_PORT_FORWARDING="off"
|
||||
fi
|
||||
|
||||
# Export all variables for envsubst
|
||||
API_HOST="${API_HOST:-127.0.0.1}"
|
||||
export PUID="$puid"
|
||||
export PGID="$pgid"
|
||||
export MEDIA_DIRECTORY="$media_directory"
|
||||
export INSTALL_DIRECTORY="$install_directory"
|
||||
export MEDIA_SERVICE="$media_service"
|
||||
export TZ="$tz"
|
||||
export VPN_ENABLED VPN_SERVICE VPN_TYPE
|
||||
export WIREGUARD_ADDRESSES
|
||||
export PORT_FORWARD_ONLY VPN_PORT_FORWARDING
|
||||
export API_HOST
|
||||
|
||||
# Generate .env from template using envsubst
|
||||
envsubst < "$APP_ROOT/templates/envsubst.env" > "$env_file" || \
|
||||
log_error "Failed to generate .env file"
|
||||
|
||||
chmod 600 "$env_file"
|
||||
|
||||
log_success "Environment configuration generated"
|
||||
}
|
||||
|
||||
write_vpn_secrets() {
|
||||
if [ "${setup_vpn,,}" != "y" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local secrets_dir="$install_directory/secrets"
|
||||
mkdir -p "$secrets_dir"
|
||||
|
||||
if [ "$vpn_type" = "openvpn" ]; then
|
||||
echo -n "${vpn_user:-}" > "$secrets_dir/openvpn_user.txt"
|
||||
echo -n "${vpn_password:-}" > "$secrets_dir/openvpn_password.txt"
|
||||
# Create empty WireGuard secret so the secret definition always resolves
|
||||
: > "$secrets_dir/wireguard_private_key.txt"
|
||||
else
|
||||
echo -n "${wireguard_private_key:-}" > "$secrets_dir/wireguard_private_key.txt"
|
||||
# Create empty OpenVPN secrets so the secret definitions always resolve
|
||||
: > "$secrets_dir/openvpn_user.txt"
|
||||
: > "$secrets_dir/openvpn_password.txt"
|
||||
fi
|
||||
|
||||
chmod 600 "$secrets_dir"/*.txt
|
||||
log_success "VPN secrets written to $secrets_dir"
|
||||
}
|
||||
|
||||
write_auth_secrets() {
|
||||
local secrets_dir="$install_directory/secrets"
|
||||
mkdir -p "$secrets_dir"
|
||||
|
||||
echo -n "${auth_username:-admin}" > "$secrets_dir/auth_username.txt"
|
||||
echo -n "${auth_password:-}" > "$secrets_dir/auth_password.txt"
|
||||
chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt"
|
||||
log_success "Auth credentials written to $secrets_dir"
|
||||
}
|
||||
|
||||
write_qbittorrent_config() {
|
||||
local qbit_config_dir="$install_directory/config/qbittorrent/qBittorrent"
|
||||
mkdir -p "$qbit_config_dir"
|
||||
|
||||
local pbkdf2_hash
|
||||
pbkdf2_hash=$(qbit_generate_pbkdf2 "${auth_password:-}")
|
||||
|
||||
if [ -n "$pbkdf2_hash" ]; then
|
||||
cat > "$qbit_config_dir/qBittorrent.conf" << EOF
|
||||
[Preferences]
|
||||
WebUI\Username=${auth_username:-admin}
|
||||
WebUI\Password_PBKDF2=$pbkdf2_hash
|
||||
WebUI\Port=8081
|
||||
EOF
|
||||
log_success "Pre-configured qBittorrent credentials (bypassing WebAPI restrictions)"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_vpn_config() {
|
||||
validate_vpn_secrets "$install_directory" "${setup_vpn,,}" "${VPN_TYPE:-openvpn}"
|
||||
}
|
||||
|
||||
test_vpn_connection() {
|
||||
# Profile is only needed for media services; Gluetun has no profile.
|
||||
local profile="${media_service:-jellyfin}"
|
||||
build_compose_args "$install_directory" "${setup_vpn,,}"
|
||||
test_vpn_connection_shared run_docker compose "${COMPOSE_ARGS[@]}" --profile "$profile"
|
||||
}
|
||||
|
||||
# Provisional host vars so Gluetun can start before the rest of the wizard.
|
||||
# Final paths/user are collected after a successful tunnel test and may change.
|
||||
set_provisional_vpn_test_defaults() {
|
||||
username="${username:-$USER}"
|
||||
puid="${puid:-$(id -u)}"
|
||||
pgid="${pgid:-$(id -g)}"
|
||||
install_directory="${install_directory:-$APP_DEFAULT_INSTALL_DIR}"
|
||||
media_directory="${media_directory:-$APP_DEFAULT_MEDIA_DIR}"
|
||||
if declare -F expand_path >/dev/null 2>&1; then
|
||||
install_directory=$(expand_path "$install_directory")
|
||||
media_directory=$(expand_path "$media_directory")
|
||||
fi
|
||||
media_service="${media_service:-jellyfin}"
|
||||
media_service_port="${media_service_port:-8096}"
|
||||
if [ -z "${tz:-}" ]; then
|
||||
tz=$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone -v 2>/dev/null | tail -1 || echo "UTC")
|
||||
tz=${tz:-UTC}
|
||||
fi
|
||||
export username puid pgid install_directory media_directory media_service media_service_port tz
|
||||
}
|
||||
|
||||
# Gate the rest of setup on a working tunnel. On failure, re-prompt credentials
|
||||
# and retry instead of abandoning a half-finished install.
|
||||
ensure_vpn_connection() {
|
||||
if [ "${setup_vpn,,}" != "y" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if test_vpn_connection; then
|
||||
VPN_ALREADY_VERIFIED=1
|
||||
export VPN_ALREADY_VERIFIED
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${RED}VPN test failed — cannot continue setup without a working VPN.${NC}"
|
||||
echo -e "${YELLOW}Download clients (qBittorrent, SABnzbd) require the VPN to function.${NC}"
|
||||
echo
|
||||
echo "Credentials live under $install_directory/secrets/ (not as passwords in .env)."
|
||||
if [ "${vpn_type:-openvpn}" = "wireguard" ]; then
|
||||
echo " WireGuard key: $install_directory/secrets/wireguard_private_key.txt"
|
||||
echo " WG address: WIREGUARD_ADDRESSES in $install_directory/.env"
|
||||
else
|
||||
echo " OpenVPN user: $install_directory/secrets/openvpn_user.txt"
|
||||
echo " OpenVPN pass: $install_directory/secrets/openvpn_password.txt"
|
||||
fi
|
||||
echo "Gluetun logs above usually show why the tunnel failed."
|
||||
echo
|
||||
|
||||
local retry=""
|
||||
read -p "Re-enter VPN credentials and try again? (Y/n) [Default = y]: " retry
|
||||
retry=${retry:-y}
|
||||
if [ "${retry,,}" != "y" ]; then
|
||||
echo
|
||||
echo "Setup stopped — VPN was not verified, so the rest of the wizard was not run."
|
||||
echo "When you are ready, re-run setup and enter correct VPN details:"
|
||||
local raw_base="${APP_REPO_URL:-https://github.com/soulis-1256/assemblrr}"
|
||||
raw_base="${raw_base/github.com/raw.githubusercontent.com}/main"
|
||||
echo " Linux / WSL2: bash <(curl -fsSL ${raw_base}/platform/linux/bootstrap.sh)"
|
||||
echo " Windows: irm ${raw_base}/platform/windows/bootstrap.ps1 | iex"
|
||||
if [ -f "$install_directory/setup.sh" ]; then
|
||||
echo " On disk: bash $install_directory/setup.sh"
|
||||
fi
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
log_info "Re-enter VPN credentials for ${vpn_service:-your provider} (${vpn_type:-openvpn})."
|
||||
prompt_vpn_credentials
|
||||
write_vpn_secrets
|
||||
# WIREGUARD_ADDRESSES and related values live in .env
|
||||
generate_env_file
|
||||
done
|
||||
}
|
||||
|
||||
# Right after VPN prompts: materialize a minimal install and prove the tunnel
|
||||
# before asking about users, paths, media, etc.
|
||||
early_vpn_gate() {
|
||||
if [ "${setup_vpn,,}" != "y" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
log_info "Testing VPN connection before the rest of setup..."
|
||||
set_provisional_vpn_test_defaults
|
||||
mkdir -p "$install_directory"
|
||||
copy_configuration_files
|
||||
generate_env_file
|
||||
write_vpn_secrets
|
||||
ensure_vpn_connection
|
||||
log_success "VPN is working — continuing with the rest of setup."
|
||||
echo
|
||||
}
|
||||
|
||||
write_runtime_config() {
|
||||
local config_file="$install_directory/.${APP_NAME}-config"
|
||||
cat > "$config_file" << EOF
|
||||
INSTALL_DIRECTORY="$install_directory"
|
||||
MEDIA_DIRECTORY="$media_directory"
|
||||
MEDIA_SERVICE="$media_service"
|
||||
VPN_ENABLED="${setup_vpn,,}"
|
||||
VPN_TYPE="${vpn_type:-openvpn}"
|
||||
TZ="$tz"
|
||||
SEERR_DEFAULT_PROFILE="${seerr_default_profile:-1}"
|
||||
SEERR_IS_4K="${seerr_is_4k:-false}"
|
||||
SUBTITLE_LANGUAGE="${subtitle_language:-}"
|
||||
SETUP_MODE="${SETUP_MODE:-manual}"
|
||||
EOF
|
||||
chmod 600 "$config_file"
|
||||
log_success "Runtime config written to $config_file"
|
||||
}
|
||||
|
||||
# build_compose_args is defined in lib/compose.sh
|
||||
# Usage: build_compose_args "$install_directory" "${setup_vpn,,}"
|
||||
|
||||
install_cli() {
|
||||
echo
|
||||
log_info "Installing ${APP_DISPLAY_NAME} CLI..."
|
||||
|
||||
# Copy from install directory (persistent), not SCRIPT_DIR (may be /tmp)
|
||||
local cli_source="$install_directory/cli.sh"
|
||||
|
||||
mkdir -p "$HOME/.local/bin/lib"
|
||||
# Copy lib modules (CLI sources them from lib/ subdirectory)
|
||||
for _lib_module in core branding compose vpn; do
|
||||
cp "$install_directory/lib/${_lib_module}.sh" "$HOME/.local/bin/lib/${_lib_module}.sh"
|
||||
done
|
||||
cp "$cli_source" "$HOME/.local/bin/$APP_CLI_NAME" && chmod +x "$HOME/.local/bin/$APP_CLI_NAME"
|
||||
if ! grep -q '.local/bin' "$HOME/.profile" 2>/dev/null; then
|
||||
log_info "Adding $HOME/.local/bin to your PATH (in ~/.profile)..."
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.profile"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
# fish ignores ~/.profile, so register the path in its own config too
|
||||
if command -v fish &>/dev/null; then
|
||||
local fish_config="$HOME/.config/fish/config.fish"
|
||||
mkdir -p "$HOME/.config/fish"
|
||||
if ! grep -q '.local/bin' "$fish_config" 2>/dev/null; then
|
||||
log_info "Adding $HOME/.local/bin to your fish PATH (in $fish_config)..."
|
||||
echo 'fish_add_path $HOME/.local/bin' >> "$fish_config"
|
||||
fi
|
||||
fi
|
||||
log_success "${APP_DISPLAY_NAME} CLI installed to $HOME/.local/bin/$APP_CLI_NAME"
|
||||
}
|
||||
|
||||
set_permissions() {
|
||||
local dirs=("$media_directory" "$install_directory" "$install_directory/config")
|
||||
|
||||
# Skip chown entirely if the current user already matches PUID/PGID
|
||||
if [ "$(id -u)" = "$puid" ] && [ "$(id -g)" = "$pgid" ]; then
|
||||
log_success "Permissions already correct (current user matches PUID/PGID)"
|
||||
else
|
||||
for dir in "${dirs[@]}"; do
|
||||
log_info "Setting permissions for $dir..."
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p "$dir" || log_error "Failed to create directory $dir"
|
||||
fi
|
||||
|
||||
if sudo chown -R "$puid:$pgid" "$dir"; then
|
||||
log_success "Permissions set successfully for $dir"
|
||||
else
|
||||
log_error "Failed to set permissions for $dir"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Seerr runs as node user (UID 1000), not PUID — ensure its config dir is owned by 1000
|
||||
local seerr_config_dir="$install_directory/config/seerr"
|
||||
if [ ! -d "$seerr_config_dir" ]; then
|
||||
mkdir -p "$seerr_config_dir" || log_warning "Failed to create Seerr config directory"
|
||||
fi
|
||||
if [ -d "$seerr_config_dir" ] && [ "$(id -u)" -ne 1000 ]; then
|
||||
sudo chown -R 1000:1000 "$seerr_config_dir" 2>/dev/null || log_warning "Failed to set Seerr config ownership to 1000:1000"
|
||||
log_success "Seerr config directory ownership set to 1000:1000"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
# Prevent running as root
|
||||
if [[ "$EUID" = 0 ]]; then
|
||||
log_error "${APP_DISPLAY_NAME} must run without sudo! Please run with regular permissions"
|
||||
fi
|
||||
|
||||
# Setup logging
|
||||
LOG_FILE="/tmp/${APP_NAME}-setup-$(date '+%Y%m%d-%H%M%S').log"
|
||||
log_info "Setup log: $LOG_FILE"
|
||||
log_info "Checking prerequisites..."
|
||||
check_dependencies
|
||||
|
||||
# 1) VPN questions, then immediately test the tunnel (fail fast).
|
||||
configure_vpn
|
||||
early_vpn_gate
|
||||
|
||||
# 2) Only after VPN works (or is disabled): rest of the wizard
|
||||
get_user_info
|
||||
get_installation_paths
|
||||
configure_media_service
|
||||
configure_seerr_profile
|
||||
configure_subtitle_language
|
||||
configure_timezone
|
||||
configure_auth
|
||||
|
||||
# ---- Installation summary / confirmation ----
|
||||
echo
|
||||
echo "========================================================"
|
||||
echo " ${APP_DISPLAY_NAME} — Installation Summary"
|
||||
echo "========================================================"
|
||||
echo " User: $username (PUID=$puid, PGID=$pgid)"
|
||||
echo " Install directory: $install_directory"
|
||||
echo " Media directory: $media_directory"
|
||||
if [ "${setup_vpn,,}" = "y" ]; then
|
||||
echo " VPN: ENABLED ($vpn_service, $vpn_type)"
|
||||
echo " Port forwarding: ${enable_port_forwarding:-n}"
|
||||
else
|
||||
echo " VPN: DISABLED"
|
||||
fi
|
||||
echo " Media service: $media_service (port $media_service_port)"
|
||||
echo " Seerr Profile: $seerr_default_profile (4K=$seerr_is_4k)"
|
||||
echo " Subtitle language: ${subtitle_language:-none}"
|
||||
echo " Timezone: $tz"
|
||||
echo " Service login: $auth_username"
|
||||
echo "========================================================"
|
||||
echo
|
||||
if [ "${SETUP_MODE:-}" != "express" ]; then
|
||||
while true; do
|
||||
read -p "Proceed with installation? (Y/n) [Default = y]: " proceed
|
||||
proceed=${proceed:-"y"}
|
||||
if [ "${proceed,,}" = "y" ]; then
|
||||
break
|
||||
elif [ "${proceed,,}" = "n" ]; then
|
||||
log_info "Installation cancelled by user"
|
||||
exit 0
|
||||
else
|
||||
log_warning "Please answer y or n."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log_info "Configuring ${APP_DISPLAY_NAME} for user \"$username\" in \"$install_directory\"..."
|
||||
|
||||
# Refresh install tree for final paths/settings (may differ from provisional VPN test defaults)
|
||||
copy_configuration_files
|
||||
generate_env_file
|
||||
write_vpn_secrets
|
||||
|
||||
# Re-check only if we never verified (VPN disabled skips; early gate already set the flag)
|
||||
if [ "${setup_vpn,,}" = "y" ] && [ "${VPN_ALREADY_VERIFIED:-}" != "1" ]; then
|
||||
ensure_vpn_connection
|
||||
elif [ "${setup_vpn,,}" = "y" ]; then
|
||||
log_info "VPN already verified earlier — not re-testing."
|
||||
fi
|
||||
|
||||
write_auth_secrets
|
||||
write_qbittorrent_config
|
||||
write_runtime_config
|
||||
|
||||
log_success "Install files ready — starting services..."
|
||||
|
||||
log_info "Starting ${APP_DISPLAY_NAME} services..."
|
||||
log_info "This may take a while..."
|
||||
build_compose_args "$install_directory" "${setup_vpn,,}"
|
||||
if ! run_docker compose "${COMPOSE_ARGS[@]}" --profile "$media_service" up -d; then
|
||||
log_error "Failed to start ${APP_DISPLAY_NAME} services"
|
||||
fi
|
||||
|
||||
# Auto-configure services (Radarr, Prowlarr, etc.)
|
||||
if [ -f "$install_directory/configure.sh" ]; then
|
||||
if ! bash "$install_directory/configure.sh"; then
|
||||
log_warning "Auto-configuration had issues. You can re-run: $APP_CLI_NAME configure"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install CLI and set permissions
|
||||
echo
|
||||
log_info "Installing CLI and configuring permissions..."
|
||||
install_cli
|
||||
set_permissions
|
||||
|
||||
log_success "All done! Enjoy ${APP_DISPLAY_NAME}!"
|
||||
log_info "You can check the installation in $install_directory"
|
||||
log_info "========================================================"
|
||||
log_info "Everything should be running now! To check everything running, go to:"
|
||||
echo
|
||||
|
||||
running_services_location
|
||||
|
||||
echo
|
||||
log_info "All the service locations are also saved in ~/${APP_SERVICE_FILE}"
|
||||
running_services_location > ~/"${APP_SERVICE_FILE}"
|
||||
|
||||
log_info "========================================================"
|
||||
echo
|
||||
log_info "To configure ${APP_DISPLAY_NAME}, check the documentation at"
|
||||
log_info "${APP_REPO_URL}"
|
||||
echo
|
||||
log_info "========================================================"
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
13
branding.conf
Normal file
13
branding.conf
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# branding.conf — Single source of truth for all names, paths, and URLs
|
||||
# Change these values to rebrand the entire project
|
||||
|
||||
APP_NAME="assemblrr"
|
||||
APP_DISPLAY_NAME="Assemblrr"
|
||||
APP_DESCRIPTION="Your one-click media server"
|
||||
APP_CLI_NAME="assemblrr"
|
||||
APP_NETWORK_NAME="assemblrr_network"
|
||||
APP_REPO_URL="https://github.com/soulis-1256/assemblrr"
|
||||
APP_DEFAULT_INSTALL_DIR="$HOME/$APP_NAME"
|
||||
APP_DEFAULT_MEDIA_DIR="$HOME/$APP_NAME-media"
|
||||
APP_BACKUP_PREFIX="assemblrr-backup"
|
||||
APP_SERVICE_FILE="assemblrr_services.txt"
|
||||
243
compose/base.yaml
Normal file
243
compose/base.yaml
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
services:
|
||||
# Jellyfin — open source media server (recommended)
|
||||
jellyfin:
|
||||
profiles: ["jellyfin"]
|
||||
image: lscr.io/linuxserver/jellyfin
|
||||
container_name: jellyfin
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
- VERSION=docker
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/jellyfin:/config
|
||||
ports:
|
||||
- 8096:8096
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8096/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.11
|
||||
|
||||
# Emby — media server with premium features
|
||||
emby:
|
||||
profiles: ["emby"]
|
||||
image: lscr.io/linuxserver/emby
|
||||
container_name: emby
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
- VERSION=docker
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/emby:/config
|
||||
ports:
|
||||
- 8096:8096
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8096/emby/System/Ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.11
|
||||
|
||||
# Plex — popular media server with advanced features (uses host networking)
|
||||
plex:
|
||||
profiles: ["plex"]
|
||||
image: lscr.io/linuxserver/plex
|
||||
container_name: plex
|
||||
network_mode: host
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
- VERSION=docker
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/plex:/config
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:32400/identity"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
# qBittorrent — torrent client (ports added by overlay)
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: qbittorrent
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
- WEBUI_PORT=8081
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/qbittorrent:/config
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8081"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Sonarr — TV show management and automation
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr
|
||||
container_name: sonarr
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/sonarr:/config
|
||||
- ${INSTALL_DIRECTORY}/scripts/jellyfin-refresh.sh:/scripts/jellyfin-refresh.sh:ro
|
||||
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
|
||||
ports:
|
||||
- 8989:8989
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8989/ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.13
|
||||
|
||||
# Radarr — movie management and automation
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr
|
||||
container_name: radarr
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${MEDIA_DIRECTORY}:/data
|
||||
- ${INSTALL_DIRECTORY}/config/radarr:/config
|
||||
- ${INSTALL_DIRECTORY}/scripts/jellyfin-refresh.sh:/scripts/jellyfin-refresh.sh:ro
|
||||
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
|
||||
ports:
|
||||
- 7878:7878
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:7878/ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.14
|
||||
|
||||
# Prowlarr — indexer management for *arr apps
|
||||
prowlarr:
|
||||
image: lscr.io/linuxserver/prowlarr
|
||||
container_name: prowlarr
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${INSTALL_DIRECTORY}/config/prowlarr:/config
|
||||
ports:
|
||||
- 9696:9696
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:9696/ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.15
|
||||
|
||||
# Portainer — container management UI
|
||||
portainer:
|
||||
image: portainer/portainer-ce
|
||||
container_name: portainer
|
||||
ports:
|
||||
- 9000:9000
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:9000/api/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${INSTALL_DIRECTORY}/config/portainer:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.16
|
||||
|
||||
# Seerr — media request management (successor to Jellyseerr/Overseerr)
|
||||
seerr:
|
||||
image: ghcr.io/seerr-team/seerr:latest
|
||||
container_name: seerr
|
||||
init: true
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${INSTALL_DIRECTORY}/config/seerr:/app/config
|
||||
ports:
|
||||
- 5055:5055
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5055/api/v1/settings/public"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.17
|
||||
|
||||
# Recyclarr - automatically syncs TRaSH guides
|
||||
recyclarr:
|
||||
image: ghcr.io/recyclarr/recyclarr:latest
|
||||
container_name: recyclarr
|
||||
user: ${PUID}:${PGID}
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- CRON=@daily
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${INSTALL_DIRECTORY}/config/recyclarr:/config
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.18
|
||||
|
||||
networks:
|
||||
assemblrr_network:
|
||||
name: assemblrr_network
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.60.0.0/24
|
||||
11
compose/direct-access.yaml
Normal file
11
compose/direct-access.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Overlay: direct access ports and networks for qBittorrent (no VPN)
|
||||
# Usage: docker compose -f base.yaml -f direct-access.yaml --profile <media_service> up -d
|
||||
|
||||
services:
|
||||
qbittorrent:
|
||||
ports:
|
||||
- 8081:8081
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.12
|
||||
|
||||
102
compose/examples/custom.yaml.example
Normal file
102
compose/examples/custom.yaml.example
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Custom services overlay
|
||||
# Rename this file to custom.yaml to enable optional services
|
||||
# They will automatically join the assemblrr_network
|
||||
#
|
||||
# Usage: docker compose -f base.yaml -f [direct-access|vpn].yaml -f custom.yaml --profile <media_service> up -d
|
||||
#
|
||||
# These services are NOT auto-configured by assemblrr.
|
||||
# You will need to configure them manually through their web UIs after enabling.
|
||||
|
||||
networks:
|
||||
default:
|
||||
external: true
|
||||
name: assemblrr_network
|
||||
|
||||
services:
|
||||
|
||||
# --- Lidarr — Music management and automation ---
|
||||
# Requires: Prowlarr indexer, qBittorrent download client (configure manually)
|
||||
# Web UI: http://<host>:8686
|
||||
# lidarr:
|
||||
# image: lscr.io/linuxserver/lidarr
|
||||
# container_name: lidarr
|
||||
# environment:
|
||||
# - PUID=${PUID}
|
||||
# - PGID=${PGID}
|
||||
# - TZ=${TZ}
|
||||
# volumes:
|
||||
# - /etc/localtime:/etc/localtime:ro
|
||||
# - ${MEDIA_DIRECTORY}:/data
|
||||
# - ${INSTALL_DIRECTORY}/config/lidarr:/config
|
||||
# ports:
|
||||
# - 8686:8686
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# assemblrr_network:
|
||||
# ipv4_address: 172.60.0.20
|
||||
|
||||
# --- SABnzbd — Usenet downloader ---
|
||||
# Requires: A paid Usenet provider (Newshosting, Eweka, etc.)
|
||||
# Note: Routes through VPN if enabled (same as qBittorrent)
|
||||
# Web UI: http://<host>:8080
|
||||
# sabnzbd:
|
||||
# image: lscr.io/linuxserver/sabnzbd:latest
|
||||
# container_name: sabnzbd
|
||||
# environment:
|
||||
# - PUID=${PUID}
|
||||
# - PGID=${PGID}
|
||||
# - TZ=${TZ}
|
||||
# volumes:
|
||||
# - /etc/localtime:/etc/localtime:ro
|
||||
# - ${MEDIA_DIRECTORY}:/data
|
||||
# - ${INSTALL_DIRECTORY}/config/sabnzbd:/config
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# assemblrr_network:
|
||||
# ipv4_address: 172.60.0.21
|
||||
|
||||
# --- Bazarr — Automatic subtitle management ---
|
||||
# Requires: Radarr and/or Sonarr configured (configure manually)
|
||||
# Web UI: http://<host>:6767
|
||||
# bazarr:
|
||||
# image: lscr.io/linuxserver/bazarr
|
||||
# container_name: bazarr
|
||||
# environment:
|
||||
# - PUID=${PUID}
|
||||
# - PGID=${PGID}
|
||||
# - TZ=${TZ}
|
||||
# volumes:
|
||||
# - /etc/localtime:/etc/localtime:ro
|
||||
# - ${MEDIA_DIRECTORY}:/data
|
||||
# - ${INSTALL_DIRECTORY}/config/bazarr:/config
|
||||
# ports:
|
||||
# - 6767:6767
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# assemblrr_network:
|
||||
# ipv4_address: 172.60.0.22
|
||||
|
||||
# --- Watchtower — Automatic container update notifications ---
|
||||
# MONITOR_ONLY=true: Notifies when updates are available, does NOT auto-update
|
||||
# To enable notifications, set WATCHTOWER_NOTIFICATION_URL (see: https://containrrr.dev/shoutrrr/v0.5/services/)
|
||||
# Examples:
|
||||
# Discord: discord://token@id
|
||||
# Telegram: telegram://bottoken@ChatID
|
||||
# Slack: slack://token@channel
|
||||
# Email: smtp://user:pass@host:port/?from=addr&to=addr
|
||||
# watchtower:
|
||||
# image: nickfedor/watchtower
|
||||
# container_name: watchtower
|
||||
# environment:
|
||||
# - WATCHTOWER_CLEANUP=true
|
||||
# - TZ=${TZ}
|
||||
# - WATCHTOWER_SCHEDULE=0 0 4 * * *
|
||||
# - WATCHTOWER_MONITOR_ONLY=true
|
||||
# - WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||
# - WATCHTOWER_NOTIFICATION_URL=discord://token@id
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# assemblrr_network:
|
||||
# ipv4_address: 172.60.0.23
|
||||
111
compose/vpn.yaml
Normal file
111
compose/vpn.yaml
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Overlay: VPN via Gluetun — routes qBittorrent through VPN
|
||||
# Usage: docker compose -f base.yaml -f vpn.yaml --profile <media_service> up -d
|
||||
|
||||
services:
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8888:8888/tcp # HTTP proxy
|
||||
- 8388:8388/tcp # Shadowsocks
|
||||
- 8388:8388/udp # Shadowsocks
|
||||
- 8003:8000/tcp # Admin
|
||||
- 8081:8081/tcp # qBittorrent via VPN
|
||||
secrets:
|
||||
- openvpn_user
|
||||
- openvpn_password
|
||||
- wireguard_private_key
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=${VPN_SERVICE}
|
||||
- VPN_TYPE=${VPN_TYPE}
|
||||
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
||||
- OPENVPN_CIPHERS=AES-256-GCM
|
||||
- PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY:-off}
|
||||
- VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING:-off}
|
||||
- FIREWALL_OUTBOUND_SUBNETS=172.60.0.0/24
|
||||
- TZ=${TZ}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO /dev/null http://127.0.0.1:9999/v1/healthcheck || exit 1",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
volumes:
|
||||
- ${INSTALL_DIRECTORY}/config/gluetun:/gluetun
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
assemblrr_network:
|
||||
ipv4_address: 172.60.0.19
|
||||
|
||||
qbittorrent:
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
gluetun:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "deunhealth.restart.on.unhealthy=true"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1:9999/v1/healthcheck || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 2
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
deunhealth:
|
||||
image: qmcgaw/deunhealth
|
||||
container_name: deunhealth
|
||||
network_mode: "none"
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- HEALTH_SERVER_ADDRESS=127.0.0.1:9999
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
|
||||
vpn-watchdog:
|
||||
image: alpine:3
|
||||
container_name: vpn-watchdog
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
gluetun:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- AUTH_USERNAME_FILE=/run/secrets/auth_username.txt
|
||||
- AUTH_PASSWORD_FILE=/run/secrets/auth_password.txt
|
||||
- TZ=${TZ}
|
||||
- WATCHDOG_INTERVAL=${WATCHDOG_INTERVAL:-30}
|
||||
volumes:
|
||||
- ${INSTALL_DIRECTORY}/scripts/vpn-watchdog.sh:/scripts/vpn-watchdog.sh:ro
|
||||
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
if ! command -v bash >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1 || ! command -v docker >/dev/null 2>&1; then
|
||||
apk add --no-cache bash curl jq docker-cli
|
||||
fi
|
||||
while true; do
|
||||
bash /scripts/vpn-watchdog.sh || true
|
||||
sleep "$${WATCHDOG_INTERVAL}"
|
||||
done
|
||||
restart: unless-stopped
|
||||
|
||||
secrets:
|
||||
openvpn_user:
|
||||
file: ${INSTALL_DIRECTORY}/secrets/openvpn_user.txt
|
||||
openvpn_password:
|
||||
file: ${INSTALL_DIRECTORY}/secrets/openvpn_password.txt
|
||||
wireguard_private_key:
|
||||
file: ${INSTALL_DIRECTORY}/secrets/wireguard_private_key.txt
|
||||
35
docs/api-reference.md
Normal file
35
docs/api-reference.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Assemblrr API Reference
|
||||
|
||||
This document aggregates the official API documentation for all services used by Assemblrr.
|
||||
|
||||
## Media Services
|
||||
|
||||
| Service | API Documentation | Notes |
|
||||
|---------|-------------------|-------|
|
||||
| **Jellyfin** | https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json | Raw OpenAPI JSON; alternative Mintlify: https://mintlify.com/jellyfin/jellyfin |
|
||||
| **Emby** | https://app.swaggerhub.com/apis-docs/MediaBrowser/Emby.Api | Outdated spec; Emby and Jellyfin share origins but their APIs diverged — not interchangeable |
|
||||
| **Plex** | https://www.plexopedia.com/plex-media-server/api | Unofficial but comprehensive; official docs require login |
|
||||
|
||||
## *arr Stack (Servarr Family)
|
||||
|
||||
| Service | API Documentation | Notes |
|
||||
|---------|-------------------|-------|
|
||||
| **Prowlarr** | https://prowlarr.com/docs/api/ | OpenAPI/Swagger UI |
|
||||
| **Sonarr** | https://sonarr.tv/docs/api/ | v3 API covers v3 and v4 |
|
||||
| **Radarr** | https://radarr.video/docs/api/ | OpenAPI/Swagger UI |
|
||||
|
||||
### Servarr Wiki
|
||||
- **URL:** https://wiki.servarr.com/
|
||||
- Contains setup guides, configuration tutorials, and troubleshooting for all *arr services.
|
||||
|
||||
## Request Management
|
||||
|
||||
| Service | API Documentation | Notes |
|
||||
|---------|-------------------|-------|
|
||||
| **Seerr** | https://api-docs.overseerr.dev/ | Swagger UI |
|
||||
|
||||
## Utility
|
||||
|
||||
| Service | API Documentation | Notes |
|
||||
|---------|-------------------|-------|
|
||||
| **Portainer** | https://documentation.portainer.io/ | REST API for container management |
|
||||
122
docs/uninstall.md
Normal file
122
docs/uninstall.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Uninstall & Complete Removal
|
||||
|
||||
Assemblrr installs entirely into your home directory by default, uses no system
|
||||
packages, and needs no sudo to remove. Everything it creates is listed below, so
|
||||
you can always verify what belongs to it — and remove it completely, by hand,
|
||||
even if the installation itself is broken.
|
||||
|
||||
## What an installation creates
|
||||
|
||||
| Component | Default location |
|
||||
|---|---|
|
||||
| Containers | `jellyfin`/`emby`/`plex` (your choice), `qbittorrent`, `sonarr`, `radarr`, `prowlarr`, `seerr`, `recyclarr`, `portainer` — plus `gluetun`, `deunhealth`, `vpn-watchdog` when VPN is enabled |
|
||||
| Docker network | `assemblrr_network` |
|
||||
| Install directory (config, secrets, compose files) | `~/assemblrr` |
|
||||
| Media directory (your movies/TV/downloads) | `~/assemblrr-media` |
|
||||
| CLI command | `~/.local/bin/assemblrr` |
|
||||
| CLI library modules | `~/.local/bin/lib/{core,branding,compose,vpn}.sh` |
|
||||
| Runtime config | `~/assemblrr/.assemblrr-config` (inside the install directory) |
|
||||
| Service URL cheat-sheet | `~/assemblrr_services.txt` |
|
||||
| PATH entries (added only if missing) | `~/.profile` and/or `~/.config/fish/config.fish` |
|
||||
| Logs | `/tmp/assemblrr-*.log` |
|
||||
|
||||
No named Docker volumes are used — all state lives in the two directories above.
|
||||
|
||||
## Option 1 — Normal uninstall (CLI works)
|
||||
|
||||
```bash
|
||||
assemblrr backup ~/my-backup # optional, but recommended first
|
||||
assemblrr uninstall # interactive: asks before deleting anything
|
||||
```
|
||||
|
||||
The interactive uninstall:
|
||||
|
||||
- stops and removes all containers, volumes, and the Docker network
|
||||
- asks before deleting the install directory (type the full path to confirm)
|
||||
- asks before deleting the media directory (type the full path to confirm)
|
||||
|
||||
Variants:
|
||||
|
||||
```bash
|
||||
assemblrr uninstall --force # no prompts; deletes everything EXCEPT media
|
||||
assemblrr uninstall --force --media # no prompts; deletes everything INCLUDING media
|
||||
```
|
||||
|
||||
`--force` never deletes your media directory unless you also pass `--media` —
|
||||
media is your actual content and is treated as opt-in deletion.
|
||||
|
||||
## Option 2 — CLI missing or installation broken
|
||||
|
||||
Setup copies the CLI into the install directory, so you can run it directly
|
||||
without anything being on your PATH (works from fish too):
|
||||
|
||||
```bash
|
||||
bash ~/assemblrr/cli.sh uninstall
|
||||
```
|
||||
|
||||
If the install directory itself is gone or unreadable, use Option 3.
|
||||
|
||||
## Option 3 — Manual removal (nothing else works)
|
||||
|
||||
Every step is independent; run whichever apply.
|
||||
|
||||
**1. Remove containers and the network:**
|
||||
|
||||
```bash
|
||||
docker rm -f jellyfin emby plex qbittorrent sonarr radarr prowlarr \
|
||||
seerr recyclarr portainer gluetun deunhealth vpn-watchdog 2>/dev/null
|
||||
docker network rm assemblrr_network 2>/dev/null
|
||||
```
|
||||
|
||||
**2. Delete the install directory (configuration, secrets, compose files):**
|
||||
|
||||
```bash
|
||||
rm -rf ~/assemblrr
|
||||
```
|
||||
|
||||
**3. Delete the media directory — only if you really want your content gone:**
|
||||
|
||||
```bash
|
||||
rm -rf ~/assemblrr-media # WARNING: this is your movies/TV/downloads
|
||||
```
|
||||
|
||||
**4. Remove the CLI files:**
|
||||
|
||||
```bash
|
||||
rm -f ~/.local/bin/assemblrr
|
||||
rm -f ~/.local/bin/lib/core.sh ~/.local/bin/lib/branding.sh \
|
||||
~/.local/bin/lib/compose.sh ~/.local/bin/lib/vpn.sh
|
||||
rmdir ~/.local/bin/lib 2>/dev/null # only removed if now empty
|
||||
rm -f ~/assemblrr_services.txt
|
||||
```
|
||||
|
||||
**5. Optional — remove the Docker images:**
|
||||
|
||||
```bash
|
||||
# VPN mode also pulls qmcgaw/gluetun and qmcgaw/deunhealth;
|
||||
# swap jellyfin for emby/plex if you chose a different media server
|
||||
docker rmi lscr.io/linuxserver/jellyfin lscr.io/linuxserver/qbittorrent \
|
||||
lscr.io/linuxserver/sonarr lscr.io/linuxserver/radarr \
|
||||
lscr.io/linuxserver/prowlarr portainer/portainer-ce \
|
||||
ghcr.io/seerr-team/seerr ghcr.io/recyclarr/recyclarr alpine:3
|
||||
```
|
||||
|
||||
**6. PATH entries.** Uninstall keeps the `$HOME/.local/bin` PATH lines in
|
||||
`~/.profile` / `~/.config/fish/config.fish`. Remove them manually if you want.
|
||||
|
||||
## Guarantees
|
||||
|
||||
Removal will never:
|
||||
|
||||
- touch system directories or require sudo (default home-based installation)
|
||||
- delete your media directory without explicit confirmation or `--media`
|
||||
- remove `~/.local/bin/lib` if it contains files that aren't Assemblrr's
|
||||
- delete Docker images or the `~/.local/bin` PATH entry
|
||||
- uninstall packages, because none are installed (dependencies are only
|
||||
suggested via your package manager during setup if missing)
|
||||
|
||||
## Windows / WSL2
|
||||
|
||||
Run any of the options above inside your WSL2 distro. If you used the dev
|
||||
scripts, `.\platform\windows\bootstrap-dev.ps1 -Full -Clean` also uninstalls
|
||||
the existing WSL2 installation before setting up again.
|
||||
141
lib/api.sh
Normal file
141
lib/api.sh
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr API helpers — sourced by configure.sh
|
||||
# Provides: jq helpers, API request wrappers, API key discovery, wait_for_api
|
||||
# Requires: lib/core.sh already sourced (for logging), API_HOST and INSTALL_DIR set
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- JSON helpers using jq ---
|
||||
|
||||
jq_json_get() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
echo "$json" | jq -r --arg k "$key" '.[$k] // empty' 2>/dev/null
|
||||
}
|
||||
|
||||
jq_json_has_key() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
echo "$json" | jq -e --arg k "$key" 'has($k)' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
jq_json_list_values() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
echo "$json" | jq -r --arg k "$key" '.[][$k] // empty' 2>/dev/null
|
||||
}
|
||||
|
||||
# --- API key discovery from config.xml ---
|
||||
|
||||
read_api_key() {
|
||||
local service="$1"
|
||||
local config_file="$INSTALL_DIR/config/$service/config.xml"
|
||||
local max_wait=120
|
||||
local wait_time=0
|
||||
|
||||
echo -n "Waiting for $service to initialize" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
if [ -f "$config_file" ]; then
|
||||
local key
|
||||
key=$(sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' "$config_file" 2>/dev/null | head -1)
|
||||
if [ -n "$key" ]; then
|
||||
echo >&2
|
||||
echo "$key"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
log_step_fail "$service: config.xml not found or ApiKey missing after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- API request helpers ---
|
||||
|
||||
api_get() {
|
||||
local port="$1"
|
||||
local endpoint="$2"
|
||||
local apikey="$3"
|
||||
curl -s --connect-timeout 5 "http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null
|
||||
}
|
||||
|
||||
api_post() {
|
||||
local port="$1"
|
||||
local endpoint="$2"
|
||||
local apikey="$3"
|
||||
local data="$4"
|
||||
curl -s --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null
|
||||
}
|
||||
|
||||
api_put() {
|
||||
local port="$1"
|
||||
local endpoint="$2"
|
||||
local apikey="$3"
|
||||
local data="$4"
|
||||
curl -s --connect-timeout 5 -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null
|
||||
}
|
||||
|
||||
api_delete() {
|
||||
local port="$1"
|
||||
local endpoint="$2"
|
||||
local apikey="$3"
|
||||
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X DELETE \
|
||||
"http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null
|
||||
}
|
||||
|
||||
# POST with forceSave query param (bypasses connectivity validation in Prowlarr/Radarr/Sonarr)
|
||||
api_post_force() {
|
||||
local port="$1"
|
||||
local endpoint="$2"
|
||||
local apikey="$3"
|
||||
local data="$4"
|
||||
curl -s --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"http://${API_HOST}:${port}${endpoint}?apikey=${apikey}&forceSave=true" 2>/dev/null
|
||||
}
|
||||
|
||||
qbit_cookie_sid() {
|
||||
local cookie_jar="$1"
|
||||
awk '$6 ~ /^QBT_SID/ || $6 == "SID" { print $7; exit }' "$cookie_jar" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# --- Wait for service API to be ready ---
|
||||
|
||||
wait_for_api() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local apikey="$3"
|
||||
local api_path="${4:-/api/v3/system/status}"
|
||||
local max_wait="${API_READY_TIMEOUT_SECONDS:-120}"
|
||||
local wait_time=0
|
||||
|
||||
echo -n "Waiting for $name API" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
dot_inline
|
||||
local response
|
||||
response=$(curl -s --connect-timeout 3 "http://${API_HOST}:${port}${api_path}?apikey=${apikey}" 2>/dev/null || echo "")
|
||||
if [ -n "$response" ]; then
|
||||
local version
|
||||
version=$(jq_json_get "$response" "version")
|
||||
if [ -n "$version" ]; then
|
||||
echo >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
wait_time=$((wait_time + 2))
|
||||
done
|
||||
echo >&2
|
||||
log_step_fail "$name API not ready after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
518
lib/arr.sh
Normal file
518
lib/arr.sh
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr *arr service configuration — sourced by configure.sh
|
||||
# Provides: set_arr_auth, qbit helpers, configure_arr_service, profile lookups,
|
||||
# configure_radarr, configure_sonarr, configure_prowlarr
|
||||
# Requires: lib/core.sh (logging), lib/api.sh (api_get/post/put/delete helpers),
|
||||
# configure.sh (_cfg_log_info, log_step, log_step_fail, AUTH_USERNAME, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Shared auth config for *arr services ---
|
||||
|
||||
set_arr_auth() {
|
||||
local service_name="$1"
|
||||
local port="$2"
|
||||
local apikey="$3"
|
||||
local api_version="${4:-v3}"
|
||||
|
||||
if [ -z "$AUTH_USERNAME" ] || [ -z "$AUTH_PASSWORD" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local host_config
|
||||
host_config=$(api_get "$port" "/api/${api_version}/config/host" "$apikey")
|
||||
if [ -z "$host_config" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local host_id
|
||||
host_id=$(jq_json_get "$host_config" "id")
|
||||
host_id=${host_id:-1}
|
||||
|
||||
local updated_config
|
||||
updated_config=$(echo "$host_config" | jq --arg hid "$host_id" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \
|
||||
'.id = ($hid | tonumber) | .authenticationMethod = "forms" | .authenticationRequired = "enabled" | .username = $user | .password = $pass | .passwordConfirmation = $pass' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$updated_config" ]; then
|
||||
local auth_result
|
||||
auth_result=$(api_put "$port" "/api/${api_version}/config/host" "$apikey" "$updated_config")
|
||||
if jq_json_has_key "$auth_result" "id"; then
|
||||
log_step "${service_name}: set authentication (username: $AUTH_USERNAME)"
|
||||
else
|
||||
log_step_fail "${service_name}: failed to set authentication"
|
||||
fi
|
||||
else
|
||||
log_step_fail "${service_name}: failed to build auth config payload"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- qBittorrent helpers ---
|
||||
|
||||
# Set qBittorrent credentials and save path using the temp password from container logs
|
||||
# Called once before configuring any *arr service.
|
||||
# On re-runs, tries the final credentials first to avoid triggering brute-force bans.
|
||||
qbit_set_credentials() {
|
||||
local qbit_port=8081
|
||||
local qbit_cookie_jar="/tmp/qb_cookie_jar_init_$$_${qbit_port}"
|
||||
|
||||
# Try logging in with the final credentials first (already set from a previous run)
|
||||
if [ -n "$AUTH_USERNAME" ] && [ -n "$AUTH_PASSWORD" ]; then
|
||||
curl -s --connect-timeout 5 -c "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
|
||||
local qbit_sid
|
||||
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
|
||||
if [ -n "$qbit_sid" ]; then
|
||||
# Credentials already set — ensure save path is configured
|
||||
curl -s --connect-timeout 5 \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/app/setPreferences" \
|
||||
-b "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "json={\"save_path\":\"/data/torrents\",\"temp_path\":\"/data/torrents/incomplete\",\"temp_path_enabled\":true}" \
|
||||
2>/dev/null >/dev/null || true
|
||||
_cfg_log_info "qBittorrent credentials already set"
|
||||
rm -f "$qbit_cookie_jar"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Credentials not set yet — use temp password from container logs
|
||||
local wait_time=0
|
||||
local max_wait=60
|
||||
|
||||
echo -n "Waiting for qBittorrent to initialize" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
local qbit_temp_pass
|
||||
qbit_temp_pass=$(docker logs qbittorrent 2>&1 | awk -F': ' '/temporary password is provided for this session:/ { print $NF; exit }' || true)
|
||||
|
||||
if [ -n "$qbit_temp_pass" ]; then
|
||||
curl -s --connect-timeout 5 -c "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "username=admin&password=${qbit_temp_pass}" \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
|
||||
local qbit_sid
|
||||
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
|
||||
if [ -n "$qbit_sid" ]; then
|
||||
curl -s --connect-timeout 5 \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/app/setPreferences" \
|
||||
-b "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "json={\"web_ui_username\":\"${AUTH_USERNAME}\",\"web_ui_password\":\"${AUTH_PASSWORD}\",\"save_path\":\"/data/torrents\",\"temp_path\":\"/data/torrents/incomplete\",\"temp_path_enabled\":true}" \
|
||||
2>/dev/null >/dev/null || true
|
||||
echo >&2
|
||||
_cfg_log_info "Set qBittorrent credentials and save path"
|
||||
rm -f "$qbit_cookie_jar"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
log_warning "Could not login to qBittorrent with temp password"
|
||||
rm -f "$qbit_cookie_jar"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Create a qBittorrent category with a save path
|
||||
qbit_create_category() {
|
||||
local service_name="$1"
|
||||
local category="$2"
|
||||
local save_path="$3"
|
||||
|
||||
local qbit_port=8081
|
||||
local qbit_cookie_jar="/tmp/qb_cookie_jar_${service_name}_$$_${qbit_port}"
|
||||
curl -s --connect-timeout 10 -c "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
|
||||
local qbit_sid
|
||||
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
|
||||
if [ -n "$qbit_sid" ]; then
|
||||
curl -s --connect-timeout 10 \
|
||||
"http://${API_HOST}:${qbit_port}/api/v2/torrents/createCategory" \
|
||||
-b "$qbit_cookie_jar" \
|
||||
-H "Referer: http://${API_HOST}:${qbit_port}" \
|
||||
-d "category=${category}&savePath=${save_path}" \
|
||||
2>/dev/null >/dev/null || true
|
||||
log_step "${service_name}: created qBittorrent '${category}' category (${save_path})"
|
||||
else
|
||||
log_step_fail "${service_name}: could not login to qBittorrent to create categories"
|
||||
fi
|
||||
rm -f "$qbit_cookie_jar"
|
||||
}
|
||||
|
||||
# --- Shared *arr service configuration ---
|
||||
|
||||
# Configure a Radarr or Sonarr instance with common settings
|
||||
# Parameters:
|
||||
# $1 service_name (e.g. "Radarr" or "Sonarr")
|
||||
# $2 port (e.g. "7878" or "8989")
|
||||
# $3 apikey
|
||||
# $4 root_path (e.g. "/data/media/movies" or "/data/media/tv")
|
||||
# $5 category_name (e.g. "movies" or "tv")
|
||||
# $6 category_path (e.g. "/data/torrents/movies" or "/data/torrents/tv")
|
||||
# $7 unmonitor_field (e.g. "autoUnmonitorPreviouslyDownloadedMovies" or "autoUnmonitorPreviouslyDownloadedEpisodes")
|
||||
# $8 naming_jq (jq expression for naming convention, or "" to skip)
|
||||
configure_arr_service() {
|
||||
local service_name="$1"
|
||||
local port="$2"
|
||||
local apikey="$3"
|
||||
local root_path="$4"
|
||||
local category_name="$5"
|
||||
local category_path="$6"
|
||||
local unmonitor_field="$7"
|
||||
local naming_jq="$8"
|
||||
|
||||
# 1. Add root folder
|
||||
local root_folders
|
||||
root_folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey")
|
||||
local existing_paths
|
||||
existing_paths=$(jq_json_list_values "$root_folders" "path")
|
||||
|
||||
if ! echo "$existing_paths" | grep -q "^${root_path}$"; then
|
||||
local rf_result
|
||||
rf_result=$(api_post "$port" "/api/v3/rootfolder" "$apikey" "{\"path\":\"${root_path}\"}")
|
||||
if jq_json_has_key "$rf_result" "id"; then
|
||||
log_step "${service_name}: added ${root_path} as root folder"
|
||||
else
|
||||
log_step_fail "${service_name}: failed to add root folder"
|
||||
fi
|
||||
else
|
||||
log_step "${service_name}: root folder ${root_path} already exists"
|
||||
fi
|
||||
|
||||
# 1b. Remove stale auto-detected root folders
|
||||
local all_root_folders
|
||||
all_root_folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey")
|
||||
local stale_ids
|
||||
stale_ids=$(echo "$all_root_folders" | jq -r --arg rp "$root_path" '.[] | select(.path != $rp) | .id' 2>/dev/null || echo "")
|
||||
|
||||
for stale_id in $stale_ids; do
|
||||
if [ -n "$stale_id" ]; then
|
||||
api_delete "$port" "/api/v3/rootfolder/${stale_id}" "$apikey" >/dev/null || true
|
||||
log_step "${service_name}: removed stale root folder (id: ${stale_id})"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Add qBittorrent download client
|
||||
local download_clients
|
||||
download_clients=$(api_get "$port" "/api/v3/downloadclient" "$apikey")
|
||||
local existing_impls
|
||||
existing_impls=$(jq_json_list_values "$download_clients" "implementationName")
|
||||
|
||||
if ! echo "$existing_impls" | grep -q "qBittorrent"; then
|
||||
# Build download client payload — category field names differ between Radarr and Sonarr
|
||||
local qbit_payload
|
||||
qbit_payload=$(jq -n --arg host "$QBITTORRENT_HOST" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \
|
||||
--arg cat_name "$category_name" --arg sn "$service_name" '
|
||||
{
|
||||
"enable": true,
|
||||
"removeCompletedDownloads": true,
|
||||
"removeFailedDownloads": true,
|
||||
"name": "qBittorrent",
|
||||
"implementation": "QBittorrent",
|
||||
"implementationName": "qBittorrent",
|
||||
"configContract": "QBittorrentSettings",
|
||||
"priority": 1,
|
||||
"fields": [
|
||||
{"name": "host", "value": $host},
|
||||
{"name": "port", "value": 8081},
|
||||
{"name": "useSsl", "value": false},
|
||||
{"name": "urlBase", "value": ""},
|
||||
{"name": "username", "value": $user},
|
||||
{"name": "password", "value": $pass},
|
||||
{"name": (if $sn == "Radarr" then "movieCategory" else "tvCategory" end), "value": $cat_name},
|
||||
{"name": (if $sn == "Radarr" then "movieImportedCategory" else "tvImportedCategory" end), "value": ""},
|
||||
{"name": (if $sn == "Radarr" then "recentMoviePriority" else "recentTvPriority" end), "value": 0},
|
||||
{"name": (if $sn == "Radarr" then "olderMoviePriority" else "olderTvPriority" end), "value": 0},
|
||||
{"name": "initialState", "value": 0},
|
||||
{"name": "sequentialOrder", "value": false},
|
||||
{"name": "firstAndLast", "value": false},
|
||||
{"name": "contentLayout", "value": 0}
|
||||
]
|
||||
}
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$qbit_payload" ]; then
|
||||
local dc_result
|
||||
dc_result=$(api_post "$port" "/api/v3/downloadclient" "$apikey" "$qbit_payload")
|
||||
if jq_json_has_key "$dc_result" "id"; then
|
||||
log_step "${service_name}: added qBittorrent as download client (host: ${QBITTORRENT_HOST})"
|
||||
else
|
||||
log_step_fail "${service_name}: failed to add qBittorrent download client"
|
||||
fi
|
||||
else
|
||||
log_step_fail "${service_name}: failed to build qBittorrent payload"
|
||||
fi
|
||||
else
|
||||
log_step "${service_name}: qBittorrent download client already exists"
|
||||
fi
|
||||
|
||||
# 2b. Create qBittorrent category with correct save path
|
||||
qbit_create_category "$service_name" "$category_name" "$category_path"
|
||||
|
||||
# 2c. Enable hardlinks in Media Management
|
||||
local mediamgmt
|
||||
mediamgmt=$(api_get "$port" "/api/v3/config/mediamanagement" "$apikey")
|
||||
if [ -n "$mediamgmt" ]; then
|
||||
local mgmt_payload
|
||||
mgmt_payload=$(echo "$mediamgmt" | jq --arg uf "$unmonitor_field" '.copyUsingHardlinks = true | .enableMediaManagement = true | .[$uf] = true | .recycleBin = ""' 2>/dev/null || echo "")
|
||||
if [ -n "$mgmt_payload" ]; then
|
||||
local mgmt_result
|
||||
mgmt_result=$(api_put "$port" "/api/v3/config/mediamanagement" "$apikey" "$mgmt_payload")
|
||||
if jq_json_has_key "$mgmt_result" "id"; then
|
||||
log_step "${service_name}: enabled hardlinks in Media Management"
|
||||
else
|
||||
log_step_fail "${service_name}: failed to enable hardlinks"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Set naming convention (if jq expression provided)
|
||||
if [ -n "$naming_jq" ]; then
|
||||
local naming
|
||||
naming=$(api_get "$port" "/api/v3/config/naming" "$apikey")
|
||||
if [ -n "$naming" ]; then
|
||||
local naming_payload
|
||||
naming_payload=$(echo "$naming" | jq "$naming_jq" 2>/dev/null || echo "")
|
||||
if [ -n "$naming_payload" ]; then
|
||||
local naming_result
|
||||
naming_result=$(api_put "$port" "/api/v3/config/naming" "$apikey" "$naming_payload")
|
||||
if jq_json_has_key "$naming_result" "id"; then
|
||||
log_step "${service_name}: set naming convention"
|
||||
else
|
||||
log_step_fail "${service_name}: failed to set naming convention"
|
||||
fi
|
||||
else
|
||||
log_step_fail "${service_name}: failed to build naming config payload"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Set authentication
|
||||
set_arr_auth "$service_name" "$port" "$apikey"
|
||||
}
|
||||
|
||||
# --- Quality profile lookup ---
|
||||
|
||||
lookup_radarr_profile() {
|
||||
local apikey="$1"
|
||||
local profiles_json
|
||||
profiles_json=$(api_get "7878" "/api/v3/qualityprofile" "$apikey")
|
||||
|
||||
if [ -z "$profiles_json" ]; then
|
||||
echo "1:Any"
|
||||
return
|
||||
fi
|
||||
|
||||
# Recyclarr creates profiles with names like "Ultra-HD" or "HD Bluray + WEB"
|
||||
# Match by keyword since the exact name depends on TRaSH Guide version
|
||||
# Default Radarr profiles to skip: Any, SD, HD-720p, HD-1080p, HD - 720p/1080p
|
||||
local target_keyword
|
||||
if [ "${SEERR_IS_4K:-false}" = "true" ]; then
|
||||
target_keyword="Ultra"
|
||||
elif [ "${SEERR_DEFAULT_PROFILE:-1}" != "1" ]; then
|
||||
target_keyword="Bluray + WEB"
|
||||
else
|
||||
echo "1:Any"
|
||||
return
|
||||
fi
|
||||
|
||||
local matched
|
||||
matched=$(echo "$profiles_json" | jq -r --arg kw "$target_keyword" '.[] | select(.name | contains($kw)) | select(.name as $n | ["Any","SD","HD-720p","HD-1080p","HD - 720p/1080p"] | index($n) | not) | "\(.id):\(.name)"' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -n "$matched" ]; then
|
||||
echo "$matched"
|
||||
else
|
||||
echo "1:Any"
|
||||
fi
|
||||
}
|
||||
|
||||
lookup_sonarr_profile() {
|
||||
local apikey="$1"
|
||||
local profiles_json
|
||||
profiles_json=$(api_get "8989" "/api/v3/qualityprofile" "$apikey")
|
||||
|
||||
if [ -z "$profiles_json" ]; then
|
||||
echo "1:Default"
|
||||
return
|
||||
fi
|
||||
|
||||
# Recyclarr creates profiles with names like "Ultra-HD" or "WEB-1080p"
|
||||
# Match by keyword since the exact name depends on TRaSH Guide version
|
||||
# Default Sonarr profiles to skip: Any, SD, HD-720p, HD-1080p, HD - 720p/1080p
|
||||
local target_keyword
|
||||
if [ "${SEERR_IS_4K:-false}" = "true" ]; then
|
||||
target_keyword="Ultra"
|
||||
else
|
||||
target_keyword="WEB"
|
||||
fi
|
||||
|
||||
local matched
|
||||
matched=$(echo "$profiles_json" | jq -r --arg kw "$target_keyword" '.[] | select(.name | contains($kw)) | select(.name as $n | ["Any","SD","HD-720p","HD-1080p","HD - 720p/1080p"] | index($n) | not) | "\(.id):\(.name)"' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -n "$matched" ]; then
|
||||
echo "$matched"
|
||||
else
|
||||
echo "1:Default"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Radarr configuration ---
|
||||
|
||||
configure_radarr() {
|
||||
local apikey="$1"
|
||||
configure_arr_service \
|
||||
"Radarr" "7878" "$apikey" \
|
||||
"/data/media/movies" "movies" "/data/torrents/movies" \
|
||||
"autoUnmonitorPreviouslyDownloadedMovies" \
|
||||
'.renameMovies = true | .standardMovieFormat = "{Movie CleanTitle} ({Release Year}) {Quality Full}" | .movieFolderFormat = "{Movie CleanTitle} ({Release Year})"'
|
||||
}
|
||||
|
||||
# --- Prowlarr configuration ---
|
||||
|
||||
configure_prowlarr() {
|
||||
local apikey="$1"
|
||||
local radarr_apikey="$2"
|
||||
|
||||
# 1. Add Radarr as connected application
|
||||
local apps
|
||||
apps=$(api_get "9696" "/api/v1/applications" "$apikey")
|
||||
local existing_impls
|
||||
existing_impls=$(jq_json_list_values "$apps" "implementationName")
|
||||
|
||||
if ! echo "$existing_impls" | grep -q "Radarr"; then
|
||||
# Get the Radarr application schema first to get correct field names
|
||||
local app_schema
|
||||
app_schema=$(api_get "9696" "/api/v1/applications/schema" "$apikey")
|
||||
local app_payload
|
||||
app_payload=$(echo "$app_schema" | jq --arg prowlarr_url "$PROWLARR_DOCKER_URL" --arg radarr_url "$RADARR_DOCKER_URL" --arg radarr_key "$radarr_apikey" '
|
||||
[.[] | select(.implementationName == "Radarr")][0] |
|
||||
.fields = [.fields[] | if .name == "prowlarrUrl" then .value = $prowlarr_url elif .name == "baseUrl" then .value = $radarr_url elif .name == "apiKey" then .value = $radarr_key elif .name == "syncCategories" then .value = [2000] elif .name == "syncRejectBlocklistedTorrentHashesWhileGrabbing" then .value = false else . end] |
|
||||
.enable = true | .syncLevel = "fullSync" | .name = "Radarr" | .priority = (.priority // 25) | .tags = []
|
||||
' 2>/dev/null || echo "")
|
||||
if [ -z "$app_payload" ]; then
|
||||
log_step_fail "Prowlarr: Radarr application schema not found"
|
||||
return 0
|
||||
fi
|
||||
local app_result
|
||||
app_result=$(api_post_force "9696" "/api/v1/applications" "$apikey" "$app_payload")
|
||||
if jq_json_has_key "$app_result" "id"; then
|
||||
log_step "Prowlarr: added Radarr as connected application"
|
||||
else
|
||||
log_step_fail "Prowlarr: failed to add Radarr application"
|
||||
fi
|
||||
else
|
||||
log_step "Prowlarr: Radarr application already connected"
|
||||
fi
|
||||
|
||||
# 1b. Add Sonarr as connected application
|
||||
local sonarr_apikey="$3"
|
||||
|
||||
if [ -n "$sonarr_apikey" ]; then
|
||||
if ! echo "$existing_impls" | grep -q "Sonarr"; then
|
||||
local sonarr_app_payload
|
||||
sonarr_app_payload=$(echo "$app_schema" | jq --arg prowlarr_url "$PROWLARR_DOCKER_URL" --arg sonarr_url "$SONARR_DOCKER_URL" --arg sonarr_key "$sonarr_apikey" '
|
||||
[.[] | select(.implementationName == "Sonarr")][0] |
|
||||
.fields = [.fields[] | if .name == "prowlarrUrl" then .value = $prowlarr_url elif .name == "baseUrl" then .value = $sonarr_url elif .name == "apiKey" then .value = $sonarr_key elif .name == "syncCategories" then .value = [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5090] elif .name == "animeSyncCategories" then .value = [5070] elif .name == "syncRejectBlocklistedTorrentHashesWhileGrabbing" then .value = false else . end] |
|
||||
.enable = true | .syncLevel = "fullSync" | .name = "Sonarr" | .priority = (.priority // 25) | .tags = []
|
||||
' 2>/dev/null || echo "")
|
||||
if [ -z "$sonarr_app_payload" ]; then
|
||||
log_step_fail "Prowlarr: Sonarr application schema not found"
|
||||
else
|
||||
local sonarr_app_result
|
||||
sonarr_app_result=$(api_post_force "9696" "/api/v1/applications" "$apikey" "$sonarr_app_payload")
|
||||
if jq_json_has_key "$sonarr_app_result" "id"; then
|
||||
log_step "Prowlarr: added Sonarr as connected application"
|
||||
else
|
||||
log_step_fail "Prowlarr: failed to add Sonarr application"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_step "Prowlarr: Sonarr application already connected"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Add public indexers
|
||||
echo "Adding ${#PUBLIC_INDEXERS[@]} indexer(s) to Prowlarr" >&2
|
||||
local indexer_schemas
|
||||
indexer_schemas=$(api_get "9696" "/api/v1/indexer/schema" "$apikey")
|
||||
|
||||
if [ -z "$indexer_schemas" ]; then
|
||||
log_step_fail "Prowlarr: failed to fetch indexer schemas"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get existing indexers to avoid duplicates
|
||||
local existing_indexers
|
||||
existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey")
|
||||
local existing_names
|
||||
existing_names=$(jq_json_list_values "$existing_indexers" "name")
|
||||
|
||||
for indexer_name in ${PUBLIC_INDEXERS[@]+"${PUBLIC_INDEXERS[@]}"}; do
|
||||
# Skip if already added
|
||||
if echo "$existing_names" | grep -q -F -x "$indexer_name"; then
|
||||
log_step "Prowlarr: ${indexer_name} indexer already exists"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Build the indexer payload using jq to safely extract from schema
|
||||
# Cardigann indexers use 'name' field to match, not implementationName
|
||||
# Skip empty field values — Prowlarr uses Cardigann definition defaults
|
||||
# Add as disabled for Cloudflare-protected indexers (need FlareSolverr)
|
||||
local indexer_payload
|
||||
indexer_payload=$(echo "$indexer_schemas" | jq --arg idx "$indexer_name" '
|
||||
[.[] | select(.name == $idx or (.name | ascii_downcase | startswith($idx | ascii_downcase)))][0] |
|
||||
.fields = [.fields[] | select(.value != "") | {name: .name, value: .value}] |
|
||||
.enable = true | .enableAutoSearch = true | .appProfileId = 1 | .priority = (.priority // 25)
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$indexer_payload" ]; then
|
||||
log_step_fail "Prowlarr: ${indexer_name} schema not found"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Try adding with forceSave to bypass connectivity validation
|
||||
# If that fails (e.g. Cloudflare), retry with enable=false
|
||||
local idx_result
|
||||
idx_result=$(curl -s --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$indexer_payload" \
|
||||
"http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null)
|
||||
if jq_json_has_key "$idx_result" "id"; then
|
||||
log_step "Prowlarr: added ${indexer_name} indexer"
|
||||
else
|
||||
# Retry with enable=false
|
||||
local disabled_payload
|
||||
disabled_payload=$(echo "$indexer_payload" | jq -c '.enable = false' 2>/dev/null || echo "")
|
||||
idx_result=$(curl -s --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$disabled_payload" \
|
||||
"http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null)
|
||||
if jq_json_has_key "$idx_result" "id"; then
|
||||
log_step "Prowlarr: added ${indexer_name} indexer (disabled — needs FlareSolverr for Cloudflare)"
|
||||
else
|
||||
log_step_fail "Prowlarr: failed to add ${indexer_name} indexer"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo >&2
|
||||
log_success "Indexers added to Prowlarr!" >&2
|
||||
|
||||
# 3. Set authentication
|
||||
set_arr_auth "Prowlarr" "9696" "$apikey" "v1"
|
||||
}
|
||||
|
||||
# --- Sonarr configuration ---
|
||||
|
||||
configure_sonarr() {
|
||||
local apikey="$1"
|
||||
configure_arr_service \
|
||||
"Sonarr" "8989" "$apikey" \
|
||||
"/data/media/tv" "tv" "/data/torrents/tv" \
|
||||
"autoUnmonitorPreviouslyDownloadedEpisodes" \
|
||||
'.renameEpisodes = true | .replaceIllegalCharacters = true | .standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" | .dailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" | .animeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" | .seriesFolderFormat = "{Series Title}" | .seasonFolderFormat = "Season {season}" | .multiEpisodeStyle = 5 | .colonReplacementFormat = 4'
|
||||
}
|
||||
24
lib/branding.sh
Normal file
24
lib/branding.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr branding loader — sourced by entry points that need branding
|
||||
# Requires: lib/core.sh (for safe_source)
|
||||
|
||||
if [ -n "${_ASSEMBLRR_BRANDING_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
readonly _ASSEMBLRR_BRANDING_SOURCED=1
|
||||
|
||||
# Load branding.conf from a given directory, with hardcoded fallback defaults.
|
||||
# Usage: load_branding "$dir_containing_branding_conf"
|
||||
load_branding() {
|
||||
local _branding_dir="$1"
|
||||
if [ -f "$_branding_dir/branding.conf" ]; then
|
||||
safe_source "$_branding_dir/branding.conf"
|
||||
else
|
||||
APP_NAME="assemblrr"
|
||||
APP_DISPLAY_NAME="Assemblrr"
|
||||
APP_CLI_NAME="assemblrr"
|
||||
APP_NETWORK_NAME="assemblrr_network"
|
||||
APP_BACKUP_PREFIX="assemblrr-backup"
|
||||
APP_SERVICE_FILE="assemblrr_services.txt"
|
||||
fi
|
||||
}
|
||||
33
lib/compose.sh
Normal file
33
lib/compose.sh
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr Docker Compose builder — sourced by entry points that manage containers
|
||||
# Requires: nothing beyond bash builtins
|
||||
|
||||
if [ -n "${_ASSEMBLRR_COMPOSE_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
readonly _ASSEMBLRR_COMPOSE_SOURCED=1
|
||||
|
||||
# Builds the docker compose arguments array (COMPOSE_ARGS) based on VPN mode.
|
||||
# Usage: build_compose_args "$install_dir" "$vpn_enabled"
|
||||
# $1 = install directory (e.g. /home/user/assemblrr)
|
||||
# $2 = vpn enabled flag ("y" or "n")
|
||||
# After calling, COMPOSE_ARGS contains -f flags and --project-directory.
|
||||
# Caller should add "--profile" and "docker compose" prefix as needed.
|
||||
build_compose_args() {
|
||||
local _install_dir="$1"
|
||||
local _vpn_enabled="${2:-n}"
|
||||
|
||||
COMPOSE_ARGS=("--project-directory" "$_install_dir")
|
||||
COMPOSE_ARGS+=("-f" "$_install_dir/compose/base.yaml")
|
||||
|
||||
if [ "${_vpn_enabled,,}" = "y" ]; then
|
||||
COMPOSE_ARGS+=("-f" "$_install_dir/compose/vpn.yaml")
|
||||
else
|
||||
COMPOSE_ARGS+=("-f" "$_install_dir/compose/direct-access.yaml")
|
||||
fi
|
||||
|
||||
# Check for custom overlay
|
||||
if [ -f "$_install_dir/compose/custom.yaml" ]; then
|
||||
COMPOSE_ARGS+=("-f" "$_install_dir/compose/custom.yaml")
|
||||
fi
|
||||
}
|
||||
365
lib/core.sh
Normal file
365
lib/core.sh
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr core library — sourced by all other lib modules and entry points
|
||||
# Provides: color codes, logging, safe_source, find_install_directory,
|
||||
# path utilities, directory helpers, dot_inline
|
||||
|
||||
# Guard against double-sourcing (readonly arrays would fail on re-source)
|
||||
if [ -n "${_ASSEMBLRR_CORE_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
readonly _ASSEMBLRR_CORE_SOURCED=1
|
||||
|
||||
# --- Color codes ---
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly NC='\033[0m'
|
||||
|
||||
# --- Logging ---
|
||||
# Optional: set LOG_FILE before sourcing lib/core.sh to enable file logging
|
||||
_log_to_file() {
|
||||
if [ -n "${LOG_FILE:-}" ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
log_success() { echo -e "${GREEN}$1${NC}"; _log_to_file "INFO: $1"; return 0; }
|
||||
log_error() { echo -e "${RED}$1${NC}" >&2; _log_to_file "ERROR: $1"; exit 1; }
|
||||
log_warning() { echo -e "${YELLOW}$1${NC}"; _log_to_file "WARN: $1"; return 0; }
|
||||
log_info() { echo "$1"; _log_to_file "INFO: $1"; return 0; }
|
||||
log_debug() { _log_to_file "DEBUG: $1"; return 0; }
|
||||
|
||||
# --- Progress indicators ---
|
||||
# Inline dot for polling loops (simple, foreground, no process management)
|
||||
dot_inline() { printf "." >&2; }
|
||||
|
||||
# --- Safe source ---
|
||||
# Safely source a config file — validates that it contains only KEY=VALUE
|
||||
# assignments (no command substitution, pipes, etc.) before sourcing
|
||||
safe_source() {
|
||||
local file="$1"
|
||||
if [ ! -f "$file" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local line
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
# Skip blank lines and comments
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
# Must match KEY=VALUE pattern (KEY: alphanumeric + underscore)
|
||||
if ! [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
|
||||
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Reject dangerous shell constructs in the value
|
||||
case "$line" in
|
||||
*'$('* | *'`'* | *';'* | *'||'* | *'&&'* | *'>'* | *'<'*)
|
||||
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done < "$file"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$file"
|
||||
}
|
||||
|
||||
# --- Install directory discovery ---
|
||||
# Discover installation directory from runtime config
|
||||
find_install_directory() {
|
||||
local search_order=(
|
||||
"/opt/assemblrr/.assemblrr-config"
|
||||
"$HOME/assemblrr/.assemblrr-config"
|
||||
"$HOME/.assemblrr-config"
|
||||
)
|
||||
|
||||
# Check ASSEMBLRR_DIR env var first
|
||||
if [ -n "${ASSEMBLRR_DIR:-}" ]; then
|
||||
echo "$ASSEMBLRR_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for config_file in "${search_order[@]}"; do
|
||||
if [ -f "$config_file" ]; then
|
||||
safe_source "$config_file"
|
||||
if [ -n "${INSTALL_DIRECTORY:-}" ]; then
|
||||
echo "$INSTALL_DIRECTORY"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Path utilities ---
|
||||
|
||||
# Expand tilde in user input paths (no eval — safe from code injection)
|
||||
expand_path() {
|
||||
local path="$1"
|
||||
# Only expand leading ~ to $HOME, nothing else
|
||||
echo "${path/#\~/$HOME}"
|
||||
}
|
||||
|
||||
# Validate that a path is safe to remove (used by safe_rm_rf)
|
||||
_is_safe_rm_path() {
|
||||
local dir="$1"
|
||||
# Refuse empty paths
|
||||
if [ -z "$dir" ]; then
|
||||
log_error "Refusing to remove empty path"
|
||||
return 1
|
||||
fi
|
||||
# Safety guard: never remove root, system paths, or shallow paths
|
||||
case "$dir" in
|
||||
""|"/"|"/home"|"/usr"|"/etc"|"/var"|"/opt"|"/root"|"/tmp"|"${HOME:-}")
|
||||
log_error "Refusing to remove system path: $dir"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
# Refuse to remove a user's home directory directly
|
||||
if [[ "$dir" =~ ^/home/[^/]+$ ]]; then
|
||||
log_error "Refusing to remove home directory: $dir"
|
||||
return 1
|
||||
fi
|
||||
# Refuse to remove paths shallower than /home/<user>/<app> (3+ slashes required)
|
||||
# Examples: /home/soulis (2 levels) = blocked, /home/soulis/assemblrr (3 levels) = allowed
|
||||
local depth
|
||||
depth=$(echo "$dir" | tr -cd '/' | wc -c)
|
||||
if [ "$depth" -lt 2 ]; then
|
||||
log_error "Refusing to remove shallow path (depth < 2): $dir"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove a directory with sudo fallback (Docker containers create root-owned files)
|
||||
safe_rm_rf() {
|
||||
local dir="$1"
|
||||
# Normalize trailing slashes
|
||||
while [ "${dir%/}" != "$dir" ]; do
|
||||
dir="${dir%/}"
|
||||
done
|
||||
|
||||
if ! _is_safe_rm_path "$dir"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! rm -rf "$dir" 2>/dev/null; then
|
||||
local parent_dir; parent_dir="$(dirname "$dir")"
|
||||
local base_name; base_name="$(basename "$dir")"
|
||||
# Run rm inside docker by mounting the parent directory
|
||||
if ! docker run --rm -v "$parent_dir:/target" alpine rm -rf "/target/$base_name" 2>/dev/null; then
|
||||
log_warning "Failed to remove $dir. You may need to remove it manually."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Directory helpers ---
|
||||
|
||||
create_and_verify_directory() {
|
||||
local dir="$1"
|
||||
local dir_type="$2"
|
||||
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "The directory \"$dir\" does not exist. Attempting to create..."
|
||||
if mkdir -p "$dir"; then
|
||||
log_success "Directory $dir created"
|
||||
else
|
||||
log_error "Failed to create $dir_type directory at \"$dir\". Check permissions"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -w "$dir" ] || [ ! -r "$dir" ]; then
|
||||
log_error "Directory \"$dir\" is not writable or readable. Check permissions"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_directory_structure() {
|
||||
local media_dir="$1"
|
||||
create_and_verify_directory "$media_dir" "media"
|
||||
for subdir in "${MEDIA_SUBDIRS[@]}"; do
|
||||
create_and_verify_directory "$media_dir/$subdir" "media subdirectory"
|
||||
done
|
||||
}
|
||||
|
||||
verify_user_permissions() {
|
||||
local username="$1"
|
||||
local directory="$2"
|
||||
|
||||
if ! id -u "$username" &>/dev/null; then
|
||||
log_error "User \"$username\" doesn't exist!"
|
||||
fi
|
||||
|
||||
# Check write access directly for the current user (no sudo needed)
|
||||
if [ "$username" = "$(id -un)" ]; then
|
||||
if [ ! -w "$directory" ]; then
|
||||
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
|
||||
fi
|
||||
elif ! sudo -u "$username" test -w "$directory"; then
|
||||
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
|
||||
fi
|
||||
}
|
||||
|
||||
verify_docker() {
|
||||
local docker_exe="/mnt/c/Program Files/Docker/Docker/Docker Desktop.exe"
|
||||
local is_wsl=false
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
is_wsl=true
|
||||
fi
|
||||
|
||||
# Check if docker command is missing
|
||||
if ! command -v docker &>/dev/null; then
|
||||
# If in WSL2 and Docker Desktop is installed on host, we can start it to mount it
|
||||
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
|
||||
log_info "Docker Desktop integration is offline. Attempting to start Docker Desktop on Windows..."
|
||||
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
|
||||
log_info "Waiting for Docker Desktop integration to become ready (up to 45s)..."
|
||||
for i in {1..45}; do
|
||||
dot_inline
|
||||
sleep 1
|
||||
if command -v docker &>/dev/null && docker info &>/dev/null; then
|
||||
echo ""
|
||||
log_success "Docker Desktop started successfully and integration is ready."
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
log_warning "Docker is not installed or not in PATH."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If docker command exists, check if daemon is running
|
||||
if ! docker info &>/dev/null; then
|
||||
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
|
||||
log_info "Docker daemon is not running. Attempting to start Docker Desktop on Windows..."
|
||||
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
|
||||
log_info "Waiting for Docker daemon to become ready (up to 45s)..."
|
||||
for i in {1..45}; do
|
||||
dot_inline
|
||||
sleep 1
|
||||
if docker info &>/dev/null; then
|
||||
echo ""
|
||||
log_success "Docker Desktop started successfully and is ready."
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Helper to generate PBKDF2 password hash for qBittorrent WebUI
|
||||
# Format: @ByteArray(salt:hash) where both salt and hash are Base64 encoded
|
||||
qbit_generate_pbkdf2() {
|
||||
local password="$1"
|
||||
if [ -z "$password" ]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
if command -v python3 &>/dev/null; then
|
||||
QBIT_PWD="$password" python3 -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
|
||||
elif command -v python &>/dev/null; then
|
||||
QBIT_PWD="$password" python -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
run_docker() {
|
||||
# Run the docker command directly
|
||||
# Disable exit on error temporarily so we can catch the status
|
||||
set +e
|
||||
docker "$@"
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
# If it failed, check if the Docker daemon is actually running or missing
|
||||
if ! command -v docker &>/dev/null || ! docker info &>/dev/null; then
|
||||
log_warning "Docker daemon is not running or integration is offline. Attempting auto-start..."
|
||||
if verify_docker; then
|
||||
# Retry the command once
|
||||
docker "$@"
|
||||
return $?
|
||||
else
|
||||
log_error "Docker daemon is not running. Please start Docker and try again."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# --- Masked input ---
|
||||
# Read masked input (shows asterisks for each character, works in WSL2)
|
||||
read_masked() {
|
||||
local prompt="$1"
|
||||
local var_name="$2"
|
||||
local value=""
|
||||
local charcount=0
|
||||
local old_settings
|
||||
|
||||
clear_masked_input() {
|
||||
while [ "$charcount" -gt 0 ]; do
|
||||
printf '\b \b' >&2
|
||||
charcount=$((charcount - 1))
|
||||
done
|
||||
value=""
|
||||
}
|
||||
|
||||
printf "%s" "$prompt"
|
||||
|
||||
# Save and disable terminal echo
|
||||
old_settings=$(stty -g)
|
||||
stty -echo
|
||||
|
||||
while IFS= read -r -n 1 char; do
|
||||
# Enter key
|
||||
if [[ $char == $'\n' ]] || [[ -z "$char" ]]; then
|
||||
break
|
||||
fi
|
||||
# Backspace / Delete
|
||||
if [[ $char == $'\177' ]] || [[ $char == $'\b' ]]; then
|
||||
if [ $charcount -gt 0 ]; then
|
||||
charcount=$((charcount - 1))
|
||||
printf '\b \b' >&2
|
||||
value="${value%?}"
|
||||
fi
|
||||
# Ctrl+U and Ctrl+W clear current masked input
|
||||
elif [[ $char == $'\025' ]] || [[ $char == $'\027' ]]; then
|
||||
clear_masked_input
|
||||
# Escape sequences (Ctrl+Backspace, Ctrl+Delete, etc.)
|
||||
elif [[ $char == $'\e' ]]; then
|
||||
local seq=""
|
||||
local next=""
|
||||
while IFS= read -r -s -n 1 -t 0.01 next; do
|
||||
seq+="$next"
|
||||
[[ $next == "~" ]] && break
|
||||
[ ${#seq} -ge 8 ] && break
|
||||
done
|
||||
|
||||
case "$seq" in
|
||||
"[3;5~"|"[127;5u"|"[8;5~")
|
||||
clear_masked_input
|
||||
;;
|
||||
esac
|
||||
else
|
||||
charcount=$((charcount + 1))
|
||||
printf '*' >&2
|
||||
value+="$char"
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore terminal settings
|
||||
stty "$old_settings"
|
||||
echo
|
||||
printf -v "$var_name" '%s' "$value"
|
||||
}
|
||||
155
lib/fzf-tui.sh
Normal file
155
lib/fzf-tui.sh
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/bin/bash
|
||||
# fzf-based TUI for selections (Prowlarr indexers, Jellyfin languages)
|
||||
# Provides reusable fzf selection patterns for assemblrr.
|
||||
# Sets PUBLIC_INDEXERS array for indexers.
|
||||
# Usage: source lib/fzf-tui.sh; configure_indexers "$api_key"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PUBLIC_INDEXERS=()
|
||||
|
||||
# --- TTY detection (reused across functions) ---
|
||||
_fzf_has_tty() {
|
||||
[ -t 0 ] && [ -t 2 ]
|
||||
}
|
||||
|
||||
_fzf_can_render() {
|
||||
command -v fzf &>/dev/null && echo "test" | timeout 1 fzf --height=5 --no-mouse --filter="test" &>/dev/null
|
||||
}
|
||||
|
||||
# --- Generic single-select fzf (for languages, etc.) ---
|
||||
# Args: $1 = url, $2 = awk filter, $3 = default (on failure)
|
||||
# Output: selected value or $3
|
||||
# Pre-selects "None" by default; Enter confirms, arrows change selection
|
||||
fzf_single_select() {
|
||||
local url="$1"
|
||||
local awk_filter="$2"
|
||||
local default="${3:-}"
|
||||
|
||||
local has_tty=false
|
||||
_fzf_has_tty && has_tty=true
|
||||
|
||||
# Download and parse
|
||||
local data
|
||||
data=$(curl -sf --connect-timeout 5 "$url" 2>/dev/null | awk -F'|' "$awk_filter" | sort -k2) || true
|
||||
|
||||
if [ -z "$data" ]; then
|
||||
[ -n "$default" ] && echo "$default"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Prepend "None" as first option (pre-selected)
|
||||
data=$'None\tNo subtitle language preference\n'"$data"
|
||||
|
||||
# If no TTY, show list and prompt
|
||||
if ! $has_tty; then
|
||||
echo "$data" | head -25 >&2
|
||||
echo "Enter selection (default: None): " >&2
|
||||
read -r input || true
|
||||
if [ -z "$input" ]; then
|
||||
[ -n "$default" ] && echo "$default"
|
||||
else
|
||||
echo "$input" | cut -f1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# fzf single-select with None pre-selected
|
||||
local selected
|
||||
selected=$(echo "$data" | fzf \
|
||||
--prompt="Subtitle language> " \
|
||||
--height=60% \
|
||||
--reverse \
|
||||
--border \
|
||||
--header="↑↓=navigate Enter=confirm Esc=default" \
|
||||
--scrollbar='│' \
|
||||
2>/dev/null | cut -f1) || true
|
||||
|
||||
if [ -z "$selected" ] || [ "$selected" = "None" ]; then
|
||||
[ -n "$default" ] && echo "$default" || echo ""
|
||||
else
|
||||
echo "$selected"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Generic multi-select fzf (for indexers) ---
|
||||
# Args: $1 = data_source (jq expression or curl), $2 = awk filter
|
||||
# Sets PUBLIC_INDEXERS array
|
||||
fzf_multi_select() {
|
||||
local data="$1"
|
||||
local has_tty=false
|
||||
|
||||
_fzf_has_tty && has_tty=true
|
||||
|
||||
if [ -z "$data" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if _fzf_can_render && $has_tty; then
|
||||
# fzf TUI: multi-select with toggle+confirm
|
||||
local selected
|
||||
selected=$(echo "$data" | fzf \
|
||||
--multi \
|
||||
--prompt="Select indexers> " \
|
||||
--delimiter='\t' \
|
||||
--with-nth=1..2 \
|
||||
--bind='enter:toggle' \
|
||||
--bind='tab:toggle' \
|
||||
--bind='ctrl-o:accept' \
|
||||
--header='Enter/TAB=toggle Ctrl-O=confirm Esc=skip [P]=private' \
|
||||
--height=80% \
|
||||
--layout=reverse \
|
||||
--border \
|
||||
2>/dev/null | cut -f1 || true)
|
||||
|
||||
if [ -n "$selected" ]; then
|
||||
while IFS= read -r idx; do
|
||||
[ -n "$idx" ] && PUBLIC_INDEXERS+=("$idx")
|
||||
done <<< "$selected"
|
||||
echo "Selected ${#PUBLIC_INDEXERS[@]} indexer(s): $(IFS=,; echo "${PUBLIC_INDEXERS[*]}")" >&2
|
||||
else
|
||||
echo "No indexers selected." >&2
|
||||
fi
|
||||
else
|
||||
# No TTY or no fzf — show top public indexers and prompt
|
||||
echo "No interactive terminal available — listing popular public indexers." >&2
|
||||
echo "$data" | jq -r 'sort_by(.name | ascii_downcase) | .[0:20][] | " \(.name)"' 2>/dev/null >&2
|
||||
echo "Enter indexer names separated by spaces (or blank to skip):" >&2
|
||||
read -r input_line || true
|
||||
for idx in $input_line; do
|
||||
PUBLIC_INDEXERS+=("$idx")
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Prowlarr Indexer Selection ---
|
||||
configure_indexers() {
|
||||
local provided_key="$1"
|
||||
echo >&2
|
||||
log_warning "Time to add some Indexers into Prowlarr." >&2
|
||||
|
||||
# Try to fetch indexer list from a running Prowlarr instance
|
||||
local prowlarr_key="${provided_key:-}"
|
||||
local indexer_data=""
|
||||
|
||||
if [ -z "$prowlarr_key" ]; then
|
||||
if command -v docker &>/dev/null && docker ps --format '{{.Names}}' 2>/dev/null | grep -q prowlarr; then
|
||||
prowlarr_key=$(docker exec prowlarr cat /config/config.xml 2>/dev/null | sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' | head -1 || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$prowlarr_key" ]; then
|
||||
indexer_data=$(curl -s --connect-timeout 3 \
|
||||
-H "X-Api-Key: $prowlarr_key" \
|
||||
"http://localhost:9696/api/v1/indexer/schema" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$indexer_data" ] && echo "$indexer_data" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
local formatted
|
||||
formatted=$(echo "$indexer_data" | jq -r 'sort_by(.name | ascii_downcase)[] | "\(.name)\t\(if .enable then "" else "[P]" end)\t\(.description // "" | gsub("\n"; " ") | .[0:120])"' 2>/dev/null)
|
||||
fzf_multi_select "$formatted"
|
||||
else
|
||||
echo "Prowlarr is not responding — skipping indexer selection." >&2
|
||||
echo "You can add indexers later via the Prowlarr web UI." >&2
|
||||
fi
|
||||
}
|
||||
458
lib/jellyfin.sh
Normal file
458
lib/jellyfin.sh
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr Jellyfin configuration — sourced by configure.sh
|
||||
# Provides: configure_jellyfin, configure_jellyfin_xml_fallback,
|
||||
# configure_jellyfin_libraries, configure_jellyfin_notifications
|
||||
# Requires: lib/core.sh (logging), lib/api.sh (api_get/post helpers),
|
||||
# configure.sh (_cfg_log_info, log_step, log_step_fail, AUTH_USERNAME, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Jellyfin configuration ---
|
||||
|
||||
configure_jellyfin() {
|
||||
local jellyfin_port=8096
|
||||
local max_wait=120
|
||||
local wait_time=0
|
||||
|
||||
# Step 1: Wait for Jellyfin to be responsive
|
||||
echo >&2
|
||||
echo -n "Waiting for Jellyfin to start" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
|
||||
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "000")
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
|
||||
if [ $wait_time -ge $max_wait ]; then
|
||||
log_step_fail "Jellyfin: not responsive after ${max_wait}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 2: Check if startup wizard is still pending
|
||||
local public_info
|
||||
public_info=$(curl -sf --connect-timeout 5 \
|
||||
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$public_info" ]; then
|
||||
local wizard_complete
|
||||
wizard_complete=$(echo "$public_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ "$wizard_complete" = "True" ] || [ "$wizard_complete" = "true" ]; then
|
||||
log_step "Jellyfin: startup wizard already completed"
|
||||
echo "Configuring Jellyfin libraries" >&2
|
||||
configure_jellyfin_libraries
|
||||
echo >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Wait for the default startup user to be initialized
|
||||
# Jellyfin creates a placeholder user on first boot; /Startup/User POST
|
||||
# will return 500 if we hit it before the default user exists in the DB
|
||||
_cfg_log_info "Jellyfin: attempting startup wizard bypass via API..."
|
||||
echo >&2
|
||||
echo -n "Waiting for Jellyfin startup user to initialize" >&2
|
||||
local first_user_wait=0
|
||||
while [ $first_user_wait -lt 60 ]; do
|
||||
local first_user_resp
|
||||
first_user_resp=$(curl -sf --connect-timeout 3 \
|
||||
"http://${API_HOST}:${jellyfin_port}/Startup/FirstUser" 2>/dev/null || echo "")
|
||||
if [ -n "$first_user_resp" ]; then
|
||||
local first_user_name
|
||||
first_user_name=$(echo "$first_user_resp" | jq -r '.Name // ""' 2>/dev/null || echo "")
|
||||
if [ -n "$first_user_name" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 3
|
||||
first_user_wait=$((first_user_wait + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
|
||||
if [ $first_user_wait -ge 60 ]; then
|
||||
log_step_fail "Jellyfin: startup user not initialized after 60s"
|
||||
configure_jellyfin_xml_fallback
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Step 4: Set startup configuration (locale)
|
||||
local locale_code
|
||||
locale_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' \
|
||||
"http://${API_HOST}:${jellyfin_port}/Startup/Configuration" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$locale_code" -ge 200 ] && [ "$locale_code" -lt 300 ]; then
|
||||
log_step "Jellyfin: set startup locale (en-US)"
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to set startup locale (HTTP $locale_code)"
|
||||
fi
|
||||
|
||||
# Step 5: Set admin user credentials via Startup API
|
||||
local escaped_username escaped_password
|
||||
escaped_username=$(printf '%s' "$AUTH_USERNAME" | jq -Rs . 2>/dev/null)
|
||||
escaped_password=$(printf '%s' "$AUTH_PASSWORD" | jq -Rs . 2>/dev/null)
|
||||
|
||||
# Fallback if jq fails
|
||||
escaped_username=${escaped_username:-"\"$AUTH_USERNAME\""}
|
||||
escaped_password=${escaped_password:-"\"$AUTH_PASSWORD\""}
|
||||
|
||||
local user_payload="{\"Name\":${escaped_username},\"Password\":${escaped_password},\"EnableAutoLogin\":false}"
|
||||
local user_code
|
||||
user_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$user_payload" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Startup/User" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$user_code" -ge 200 ] && [ "$user_code" -lt 300 ]; then
|
||||
log_step "Jellyfin: set admin user credentials ($AUTH_USERNAME)"
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to set admin user via API (HTTP $user_code)"
|
||||
configure_jellyfin_xml_fallback
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Step 6: Complete the startup wizard
|
||||
local complete_code
|
||||
complete_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Startup/Complete" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$complete_code" -ge 200 ] && [ "$complete_code" -lt 300 ]; then
|
||||
log_step "Jellyfin: startup wizard completed via API"
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to complete startup wizard (HTTP $complete_code)"
|
||||
configure_jellyfin_xml_fallback
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Step 7: Verify wizard is complete
|
||||
sleep 3
|
||||
local verify_info
|
||||
verify_info=$(curl -sf --connect-timeout 5 \
|
||||
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
|
||||
local wizard_status
|
||||
wizard_status=$(echo "$verify_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ "$wizard_status" = "True" ] || [ "$wizard_status" = "true" ]; then
|
||||
log_step "Jellyfin: startup wizard bypass verified"
|
||||
else
|
||||
log_step_fail "Jellyfin: wizard completion could not be verified via API"
|
||||
configure_jellyfin_xml_fallback
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Step 8: Add media libraries
|
||||
echo "Configuring Jellyfin libraries" >&2
|
||||
configure_jellyfin_libraries
|
||||
echo >&2
|
||||
}
|
||||
|
||||
configure_jellyfin_xml_fallback() {
|
||||
local system_xml="$INSTALL_DIR/config/jellyfin/system.xml"
|
||||
local max_wait=60
|
||||
local wait_time=0
|
||||
|
||||
# Wait for system.xml to appear
|
||||
echo >&2
|
||||
echo -n "Waiting for Jellyfin system.xml" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
if [ -f "$system_xml" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
|
||||
if [ ! -f "$system_xml" ]; then
|
||||
log_step_fail "Jellyfin: system.xml not found after ${max_wait}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Mark wizard as complete
|
||||
if grep -q '<IsStartupWizardCompleted>false</IsStartupWizardCompleted>' "$system_xml" 2>/dev/null; then
|
||||
sed -i 's/<IsStartupWizardCompleted>false<\/IsStartupWizardCompleted>/<IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>/g' "$system_xml"
|
||||
log_step "Jellyfin: marked startup wizard as complete in system.xml"
|
||||
elif grep -q '<IsStartupWizardCompleted>' "$system_xml" 2>/dev/null; then
|
||||
sed -i 's/<IsStartupWizardCompleted>[^<]*<\/IsStartupWizardCompleted>/<IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>/g' "$system_xml"
|
||||
log_step "Jellyfin: updated IsStartupWizardCompleted in system.xml"
|
||||
else
|
||||
# Tag doesn't exist — add it before the closing tag
|
||||
sed -i 's/<\/ServerConfiguration>/ <IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>\n<\/ServerConfiguration>/' "$system_xml"
|
||||
log_step "Jellyfin: added IsStartupWizardCompleted to system.xml"
|
||||
fi
|
||||
|
||||
# Restart Jellyfin to apply changes
|
||||
docker restart jellyfin 2>/dev/null || true
|
||||
log_step "Jellyfin: restarted to apply system.xml changes"
|
||||
|
||||
# Wait for Jellyfin to come back online
|
||||
echo >&2
|
||||
echo -n "Waiting for Jellyfin to come back online" >&2
|
||||
local back_wait=0
|
||||
while [ $back_wait -lt 60 ]; do
|
||||
if curl -sf --connect-timeout 3 "http://${API_HOST}:8096/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
back_wait=$((back_wait + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
}
|
||||
|
||||
configure_jellyfin_libraries() {
|
||||
local jellyfin_port=8096
|
||||
|
||||
# Authenticate to get access token
|
||||
local auth_response
|
||||
auth_response=$(curl -sf --connect-timeout 10 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H 'X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="setup-script", DeviceId="assemblrr-setup"' \
|
||||
-d "{\"Username\":\"${AUTH_USERNAME}\",\"Pw\":\"${AUTH_PASSWORD}\"}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$auth_response" ]; then
|
||||
log_step_fail "Jellyfin: failed to authenticate for library setup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local access_token
|
||||
access_token=$(echo "$auth_response" | jq -r '.AccessToken // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$access_token" ]; then
|
||||
log_step_fail "Jellyfin: failed to get access token"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local user_id
|
||||
user_id=$(echo "$auth_response" | jq -r '.User.Id // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Define media libraries to add
|
||||
local -a lib_names=("Movies" "TV Shows")
|
||||
local -a lib_types=("movies" "tvshows")
|
||||
local -a lib_paths=("/data/media/movies" "/data/media/tv")
|
||||
|
||||
# Only add Music if the host media/music folder exists
|
||||
if [ -n "${MEDIA_DIRECTORY:-}" ] && [ -d "${MEDIA_DIRECTORY}/media/music" ]; then
|
||||
lib_names+=("Music")
|
||||
lib_types+=("music")
|
||||
lib_paths+=("/data/media/music")
|
||||
fi
|
||||
|
||||
# Fetch existing libraries to avoid duplicates
|
||||
local existing_libs
|
||||
existing_libs=$(curl -sf --connect-timeout 5 \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders" 2>/dev/null || echo "[]")
|
||||
|
||||
# Remove duplicate libraries (same path, different name — e.g. "Movies2")
|
||||
local dupe_ids
|
||||
dupe_ids=$(echo "$existing_libs" | jq -r '.[] | . as $lib | .Locations[]? as $loc | {"/data/media/movies": "Movies", "/data/media/tv": "TV Shows", "/data/media/music": "Music"}[$loc] as $expected | select($expected and $lib.Name != $expected) | "\(.ItemId)|\(.Name)"' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$dupe_ids" ]; then
|
||||
while IFS='|' read -r dupe_id dupe_name; do
|
||||
curl -sf -o /dev/null -w "" -X DELETE \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Items/${dupe_id}" 2>/dev/null
|
||||
log_step "Jellyfin: removed duplicate library '${dupe_name}'"
|
||||
done <<< "$dupe_ids"
|
||||
# Re-fetch after deletions
|
||||
existing_libs=$(curl -sf --connect-timeout 5 \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders" 2>/dev/null || echo "[]")
|
||||
fi
|
||||
|
||||
for i in "${!lib_names[@]}"; do
|
||||
local lib_name="${lib_names[$i]}"
|
||||
local lib_type="${lib_types[$i]}"
|
||||
local lib_path="${lib_paths[$i]}"
|
||||
|
||||
# Check if a library with this name and path already exists
|
||||
local lib_exists
|
||||
lib_exists=$(echo "$existing_libs" | jq --arg ln "$lib_name" --arg lp "$lib_path" -r 'if [.[] | select(.Name == $ln and (.Locations // [] | index($lp)))][0] then "yes" else "no" end' 2>/dev/null || echo "no")
|
||||
|
||||
if [ "$lib_exists" = "yes" ]; then
|
||||
log_step "Jellyfin: ${lib_name} library already exists (${lib_path})"
|
||||
continue
|
||||
fi
|
||||
|
||||
# URL-encode the name and path for query params
|
||||
local enc_name enc_path
|
||||
enc_name=$(jq -rn --arg n "$lib_name" '$n | @uri' 2>/dev/null || echo "${lib_name}")
|
||||
enc_path=$(jq -rn --arg p "$lib_path" '($p | @uri) | gsub("%2F"; "/")' 2>/dev/null || echo "${lib_path}")
|
||||
|
||||
local add_code
|
||||
add_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders?name=${enc_name}&collectionType=${lib_type}&paths=${enc_path}&refreshProgress=0" \
|
||||
2>/dev/null || echo "000")
|
||||
|
||||
if [ "$add_code" -ge 200 ] && [ "$add_code" -lt 300 ]; then
|
||||
log_step "Jellyfin: added ${lib_name} library (${lib_path})"
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to add ${lib_name} library (HTTP $add_code)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Set subtitle preference: always show subtitles in user's preferred language
|
||||
# Jellyfin's SubtitleLanguagePreference uses ISO 639-2/T three-letter codes
|
||||
# (e.g. "eng", "fra", "deu") — NOT locale codes like "en-US" and NOT two-letter
|
||||
# codes like "en". The UI dropdown matches these codes to display language names.
|
||||
# We must also GET the full user config first and POST it back modified,
|
||||
# otherwise Jellyfin wipes all other user settings.
|
||||
if [ -n "$user_id" ]; then
|
||||
local sub_lang="${SUBTITLE_LANGUAGE:-en}"
|
||||
|
||||
# Convert ISO 639-1 (two-letter) to ISO 639-2/T (three-letter) code
|
||||
# Jellyfin uses ISO 639-2/T (e.g., "eng" not "en"), so we must convert.
|
||||
# If Jellyfin starts accepting 639-1 directly, this mapping can be removed.
|
||||
local jf_lang
|
||||
jf_lang=$(jq -rn --arg sl "$sub_lang" '($sl | ascii_downcase) | if length == 3 then . else ({"aa":"aar","ab":"abk","af":"afr","ak":"aka","am":"amh","an":"arg","ar":"ara","as":"asm","av":"ava","ay":"aym","az":"aze","ba":"bak","be":"bel","bg":"bul","bh":"bih","bi":"bis","bm":"bam","bn":"ben","bo":"bod","br":"bre","bs":"bos","ca":"cat","ce":"che","ch":"cha","co":"cos","cr":"cre","cs":"ces","cu":"chu","cv":"chv","cy":"cym","da":"dan","de":"deu","dv":"div","dz":"dzo","ee":"ewe","el":"ell","en":"eng","eo":"epo","es":"spa","et":"est","eu":"eus","fa":"fas","ff":"ful","fi":"fin","fj":"fij","fo":"fao","fr":"fra","fy":"fry","ga":"gle","gd":"gla","gl":"glg","gn":"grn","gu":"guj","gv":"glv","ha":"hau","he":"heb","hi":"hin","ho":"hmo","hr":"hrv","ht":"hat","hu":"hun","hy":"hye","hz":"her","ia":"ina","id":"ind","ie":"ile","ig":"ibo","ii":"iii","ik":"ipk","io":"ido","is":"isl","it":"ita","iu":"iku","ja":"jpn","jv":"jav","ka":"kat","kg":"kon","ki":"kik","kj":"kua","kk":"kaz","kl":"kal","km":"khm","kn":"kan","ko":"kor","kr":"kau","ks":"kas","ku":"kur","kv":"kom","kw":"cor","ky":"kir","la":"lat","lb":"ltz","lg":"lug","li":"lim","ln":"lin","lo":"lao","lt":"lit","lu":"lub","lv":"lav","mg":"mlg","mh":"mah","mi":"mri","mk":"mkd","ml":"mal","mn":"mon","mr":"mar","ms":"msa","mt":"mlt","my":"mya","na":"nau","nb":"nob","nd":"nde","ne":"nep","ng":"ndo","nl":"nld","nn":"nno","no":"nor","nr":"nbl","nv":"nav","ny":"nya","oc":"oci","oj":"oji","om":"orm","or":"ori","os":"oss","pa":"pan","pi":"pli","pl":"pol","ps":"pus","pt":"por","qu":"que","rm":"roh","rn":"run","ro":"ron","ru":"rus","rw":"kin","sa":"san","sc":"srd","sd":"snd","se":"sme","sg":"sag","si":"sin","sk":"slk","sl":"slv","sm":"smo","sn":"sna","so":"som","sq":"sqi","sr":"srp","ss":"ssw","st":"sot","su":"sun","sv":"swe","sw":"swa","ta":"tam","te":"tel","tg":"tgk","th":"tha","ti":"tir","tk":"tuk","tl":"tgl","tn":"tsn","to":"ton","tr":"tur","ts":"tso","tt":"tat","tw":"twi","ty":"tah","ug":"uig","uk":"ukr","ur":"urd","uz":"uzb","ve":"ven","vi":"vie","vo":"vol","wa":"wln","wo":"wol","xh":"xho","yi":"yid","yo":"yor","za":"zha","zh":"zho","zu":"zul"}[.] // .) end' 2>/dev/null || echo "$sub_lang")
|
||||
|
||||
# GET current config, patch subtitle fields, POST back
|
||||
# Note: GET /Users/{id}/Configuration returns 405; must GET /Users/{id} instead
|
||||
# and extract the Configuration object, then POST it to /Users/{id}/Configuration
|
||||
local user_obj
|
||||
user_obj=$(curl -s -H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null)
|
||||
|
||||
local patched_cfg
|
||||
patched_cfg=$(echo "$user_obj" | jq --arg lang "$jf_lang" '.Configuration.SubtitleMode = "Always" | .Configuration.SubtitleLanguagePreference = $lang | .Configuration' 2>/dev/null)
|
||||
|
||||
local sub_pref_code
|
||||
sub_pref_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
-d "$patched_cfg" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Configuration" \
|
||||
2>/dev/null || echo "000")
|
||||
|
||||
if [ "$sub_pref_code" -ge 200 ] && [ "$sub_pref_code" -lt 300 ]; then
|
||||
log_step "Jellyfin: set subtitle preference (Always, ${jf_lang})"
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to set subtitle preference (HTTP $sub_pref_code)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create an API key for Radarr/Sonarr notification connections
|
||||
# POST /Auth/Keys?app=assemblrr returns 204 with no body, so we must
|
||||
# create it first, then GET /Auth/Keys to retrieve the actual key value.
|
||||
mkdir -p "$INSTALL_DIR/secrets"
|
||||
|
||||
local key_create_code
|
||||
key_create_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Auth/Keys?app=assemblrr" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$key_create_code" = "204" ] || [ "$key_create_code" = "200" ]; then
|
||||
local keys_response
|
||||
keys_response=$(curl -s --connect-timeout 5 \
|
||||
-H "X-Emby-Token: ${access_token}" \
|
||||
"http://${API_HOST}:${jellyfin_port}/Auth/Keys" 2>/dev/null || echo "")
|
||||
|
||||
local jf_api_key
|
||||
jf_api_key=$(echo "$keys_response" | jq -r '.Items[] | select(.AppName == "assemblrr") | .AccessToken // ""' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -n "$jf_api_key" ]; then
|
||||
echo -n "$jf_api_key" > "$INSTALL_DIR/secrets/jellyfin_api_key.txt"
|
||||
chmod 600 "$INSTALL_DIR/secrets/jellyfin_api_key.txt"
|
||||
log_step "Jellyfin: created API key for Radarr/Sonarr connections"
|
||||
else
|
||||
log_step_fail "Jellyfin: API key created but failed to retrieve value"
|
||||
fi
|
||||
else
|
||||
log_step_fail "Jellyfin: failed to create API key (HTTP $key_create_code)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Jellyfin notification connections (Radarr/Sonarr → Jellyfin) ---
|
||||
|
||||
configure_jellyfin_notifications() {
|
||||
local jf_api_key
|
||||
jf_api_key=$(cat "$INSTALL_DIR/secrets/jellyfin_api_key.txt" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$jf_api_key" ]; then
|
||||
log_step_fail "Jellyfin notifications: API key not found — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local jf_host="jellyfin"
|
||||
local jf_port=8096
|
||||
|
||||
# NOTE: The MediaBrowser/Emby notification type calls /Library/Media/Updated which
|
||||
# only refreshes EXISTING items in Jellyfin's DB — it cannot discover NEW files.
|
||||
# We use a CustomScript that calls POST /Library/Refresh instead, which performs a
|
||||
# full library scan and discovers newly downloaded movies/episodes.
|
||||
|
||||
add_jellyfin_refresh_notif "Radarr" "7878" "$RADARR_API_KEY"
|
||||
add_jellyfin_refresh_notif "Sonarr" "8989" "$SONARR_API_KEY"
|
||||
}
|
||||
|
||||
# Add a CustomScript notification to an *arr app that triggers Jellyfin library refresh.
|
||||
# Usage: add_jellyfin_refresh_notif <app_name> <port> <api_key>
|
||||
add_jellyfin_refresh_notif() {
|
||||
local app_name="$1"
|
||||
local port="$2"
|
||||
local apikey="$3"
|
||||
|
||||
if [ -z "$apikey" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local notifications
|
||||
notifications=$(api_get "$port" "/api/v3/notification" "$apikey")
|
||||
local existing_id
|
||||
existing_id=$(echo "$notifications" | jq -r '.[] | select(.implementation == "CustomScript" and .name == "Jellyfin Refresh") | .id // ""' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -n "$existing_id" ]; then
|
||||
log_step "${app_name}: Jellyfin Refresh script already configured"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local schema
|
||||
schema=$(api_get "$port" "/api/v3/notification/schema" "$apikey")
|
||||
local payload
|
||||
payload=$(echo "$schema" | jq '
|
||||
[.[] | select(.implementation == "CustomScript")][0] |
|
||||
.fields = [.fields[] | if .name == "path" then .value = "/scripts/jellyfin-refresh.sh" else . end] |
|
||||
.onDownload = true | .onUpgrade = true | .name = "Jellyfin Refresh" | .tags = []
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$payload" ]; then
|
||||
log_step_fail "${app_name}: CustomScript notification schema not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ensure the mounted script is executable. Radarr/Sonarr try to start the
|
||||
# process when saving a CustomScript (even with forceSave) and reject the
|
||||
# notification with "Permission denied" if the bit is missing.
|
||||
if [ -f "$INSTALL_DIR/scripts/jellyfin-refresh.sh" ]; then
|
||||
chmod +x "$INSTALL_DIR/scripts/jellyfin-refresh.sh" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local result
|
||||
result=$(api_post_force "$port" "/api/v3/notification" "$apikey" "$payload")
|
||||
if jq_json_has_key "$result" "id"; then
|
||||
log_step "${app_name}: added Jellyfin Refresh script (auto-scan on import)"
|
||||
else
|
||||
local err_msg
|
||||
err_msg=$(echo "$result" | jq -r 'if type == "array" then .[0].errorMessage // empty else .errorMessage // empty end' 2>/dev/null || echo "")
|
||||
if [ -n "$err_msg" ]; then
|
||||
log_step_fail "${app_name}: failed to add Jellyfin Refresh script (${err_msg})"
|
||||
else
|
||||
log_step_fail "${app_name}: failed to add Jellyfin Refresh script"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
396
lib/prompts.sh
Normal file
396
lib/prompts.sh
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr setup prompts — sourced by setup.sh
|
||||
# Provides: interactive prompt functions for service configuration
|
||||
# Requires: lib/core.sh (logging, read_masked, expand_path, directory helpers),
|
||||
# SETUP_MODE, SUPPORTED_MEDIA_SERVICES, APP_DEFAULT_INSTALL_DIR,
|
||||
# APP_DEFAULT_MEDIA_DIR, MEDIA_SUBDIRS (all set by setup.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
configure_media_service() {
|
||||
# Express mode: use Jellyfin by default
|
||||
if [ "${SETUP_MODE:-}" = "express" ]; then
|
||||
media_service="jellyfin"
|
||||
media_service_port=8096
|
||||
log_success "Media service: Jellyfin (port $media_service_port)"
|
||||
export media_service media_service_port
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo
|
||||
log_info "Time to choose your media service."
|
||||
echo " 1) Jellyfin (recommended, easier)"
|
||||
echo " 2) Emby"
|
||||
echo " 3) Plex (advanced, uses host networking)"
|
||||
|
||||
while true; do
|
||||
read -p "Choose your media service [1]: " media_service_choice
|
||||
media_service_choice=${media_service_choice:-1}
|
||||
|
||||
case "$media_service_choice" in
|
||||
1) media_service="jellyfin"; break ;;
|
||||
2) media_service="emby"; break ;;
|
||||
3) media_service="plex"; break ;;
|
||||
*) media_service=$(echo "$media_service_choice" | awk '{print tolower($0)}') ;;
|
||||
esac
|
||||
|
||||
if [[ ! " ${SUPPORTED_MEDIA_SERVICES[@]} " =~ " ${media_service} " ]]; then
|
||||
log_warning "\"$media_service\" is not supported. Please choose 1, 2, or 3."
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Set media service port
|
||||
if [ "$media_service" == "plex" ]; then
|
||||
media_service_port=32400
|
||||
else
|
||||
media_service_port=8096
|
||||
fi
|
||||
|
||||
echo
|
||||
log_success "${APP_DISPLAY_NAME} will install \"$media_service\" on port \"$media_service_port\""
|
||||
|
||||
export media_service media_service_port
|
||||
}
|
||||
|
||||
configure_seerr_profile() {
|
||||
# Express mode: default to Ultra-HD (4K)
|
||||
if [ "${SETUP_MODE:-}" = "express" ]; then
|
||||
seerr_default_profile="5"
|
||||
seerr_is_4k="true"
|
||||
log_success "Quality profile: Ultra-HD (4K)"
|
||||
export seerr_default_profile seerr_is_4k
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo
|
||||
log_info "What default quality profile would you like to set for Seerr (Movies)?"
|
||||
echo " 1) Ultra-HD (4K - Optimized for HEVC/x265)"
|
||||
echo " 2) 1080p"
|
||||
echo " 3) Any (Radarr Default)"
|
||||
|
||||
while true; do
|
||||
read -p "Choose your Seerr default profile [1]: " seerr_profile_choice
|
||||
seerr_profile_choice=${seerr_profile_choice:-1}
|
||||
|
||||
case "$seerr_profile_choice" in
|
||||
1) seerr_default_profile="5"; seerr_is_4k="true"; break ;;
|
||||
2) seerr_default_profile="4"; seerr_is_4k="false"; break ;;
|
||||
3) seerr_default_profile="1"; seerr_is_4k="false"; break ;;
|
||||
*) log_warning "Invalid choice. Please choose 1, 2, or 3." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
export seerr_default_profile seerr_is_4k
|
||||
}
|
||||
|
||||
configure_subtitle_language() {
|
||||
# Jellyfin ISO 639-1 code (2-letter) to ISO 639-2/B (3-letter) mapping
|
||||
# Source: https://github.com/jellyfin/jellyfin/blob/master/Emby.Server.Implementations/Localization/iso6392.txt
|
||||
# Format: code|bibliographic|terminology|name|english_name
|
||||
local iso639_url="https://raw.githubusercontent.com/jellyfin/jellyfin/master/Emby.Server.Implementations/Localization/iso6392.txt"
|
||||
local awk_filter='$4 != "" {print $1 "\t" $4}'
|
||||
|
||||
echo
|
||||
echo
|
||||
log_info "Select your preferred subtitle language for Jellyfin."
|
||||
|
||||
# Use fzf single-select with fallback to "en"
|
||||
subtitle_language=$(fzf_single_select "$iso639_url" "$awk_filter" "")
|
||||
|
||||
if [ -n "$subtitle_language" ]; then
|
||||
log_success "Subtitle language: $subtitle_language"
|
||||
else
|
||||
log_success "Subtitle language: none (no preference)"
|
||||
fi
|
||||
export subtitle_language
|
||||
}
|
||||
|
||||
configure_vpn() {
|
||||
echo
|
||||
echo
|
||||
log_info "Time to set up the VPN."
|
||||
log_info "Supported VPN providers: https://github.com/qdm12/gluetun-wiki"
|
||||
|
||||
read -p "Configure VPN? (Y/n) [Default = y]: " setup_vpn
|
||||
setup_vpn=${setup_vpn:-"y"}
|
||||
|
||||
if [ "${setup_vpn,,}" != "y" ]; then
|
||||
export setup_vpn="n"
|
||||
export vpn_service=""
|
||||
export vpn_type="openvpn"
|
||||
export enable_port_forwarding="n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Supported VPN providers (from Gluetun)
|
||||
local -a vpn_providers=(
|
||||
"protonvpn"
|
||||
"surfshark"
|
||||
"mullvad"
|
||||
"nordvpn"
|
||||
"expressvpn"
|
||||
"pia"
|
||||
"windscribe"
|
||||
"ivyvpn"
|
||||
"custom"
|
||||
)
|
||||
|
||||
echo " 1) ProtonVPN"
|
||||
echo " 2) Surfshark"
|
||||
echo " 3) Mullvad"
|
||||
echo " 4) NordVPN"
|
||||
echo " 5) ExpressVPN"
|
||||
echo " 6) Private Internet Access"
|
||||
echo " 7) Windscribe"
|
||||
echo " 8) IvyVPN"
|
||||
echo " 9) Custom (manual configuration)"
|
||||
|
||||
read -p "Choose your VPN provider [1]: " vpn_choice
|
||||
vpn_choice=${vpn_choice:-1}
|
||||
|
||||
if [[ "$vpn_choice" =~ ^[0-9]+$ ]] && [ "$vpn_choice" -ge 1 ] && [ "$vpn_choice" -le ${#vpn_providers[@]} ]; then
|
||||
vpn_service="${vpn_providers[$((vpn_choice-1))]}"
|
||||
else
|
||||
vpn_service="$vpn_choice"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " 1) WireGuard (recommended)"
|
||||
echo " 2) OpenVPN"
|
||||
read -p "Choose VPN type [1]: " vpn_type_choice
|
||||
vpn_type_choice=${vpn_type_choice:-1}
|
||||
|
||||
while true; do
|
||||
case "$vpn_type_choice" in
|
||||
1) vpn_type="wireguard"; break ;;
|
||||
2) vpn_type="openvpn"; break ;;
|
||||
*) vpn_type="$vpn_type_choice" ;;
|
||||
esac
|
||||
|
||||
if [[ "$vpn_type" != "openvpn" && "$vpn_type" != "wireguard" ]]; then
|
||||
log_warning "Unsupported VPN type. Please choose 1 or 2."
|
||||
read -p "Choose VPN type [1]: " vpn_type_choice
|
||||
vpn_type_choice=${vpn_type_choice:-1}
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
log_info "Provider docs: https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/${vpn_service// /-}.md"
|
||||
echo
|
||||
|
||||
prompt_vpn_credentials
|
||||
|
||||
# Port forwarding
|
||||
read -p "Enable port forwarding? (y/N) [Default = n]: " enable_port_forwarding
|
||||
enable_port_forwarding=${enable_port_forwarding:-"n"}
|
||||
|
||||
export setup_vpn vpn_service vpn_type enable_port_forwarding
|
||||
export vpn_user vpn_password wireguard_private_key wireguard_addresses
|
||||
}
|
||||
|
||||
# Prompt only for credentials (uses existing vpn_type). Safe to call again on VPN test failure.
|
||||
prompt_vpn_credentials() {
|
||||
if [ "${vpn_type:-}" = "wireguard" ]; then
|
||||
wireguard_private_key=""
|
||||
while [ -z "$wireguard_private_key" ]; do
|
||||
read_masked "WireGuard private key: " wireguard_private_key
|
||||
[ -z "$wireguard_private_key" ] && log_warning "Private key cannot be empty. Try again."
|
||||
done
|
||||
wireguard_addresses=""
|
||||
while [ -z "$wireguard_addresses" ]; do
|
||||
read_masked "WireGuard interface addresses (e.g., 10.64.0.1/32): " wireguard_addresses
|
||||
[ -z "$wireguard_addresses" ] && log_warning "Addresses cannot be empty. Try again."
|
||||
done
|
||||
vpn_user=""
|
||||
vpn_password=""
|
||||
else
|
||||
vpn_user=""
|
||||
while [ -z "$vpn_user" ]; do
|
||||
read -p "VPN username (without spaces): " vpn_user
|
||||
[ -z "$vpn_user" ] && log_warning "Username cannot be empty. Try again."
|
||||
done
|
||||
vpn_password=""
|
||||
while [ -z "$vpn_password" ]; do
|
||||
read_masked "VPN password: " vpn_password
|
||||
if [ -z "$vpn_password" ]; then
|
||||
log_warning "Password cannot be empty. Try again."
|
||||
continue
|
||||
fi
|
||||
local vpn_password_confirm=""
|
||||
read_masked "Confirm VPN password: " vpn_password_confirm
|
||||
if [ "$vpn_password" != "$vpn_password_confirm" ]; then
|
||||
log_warning "Passwords do not match. Try again."
|
||||
vpn_password=""
|
||||
fi
|
||||
done
|
||||
wireguard_private_key=""
|
||||
wireguard_addresses=""
|
||||
fi
|
||||
|
||||
export vpn_user vpn_password wireguard_private_key wireguard_addresses
|
||||
}
|
||||
|
||||
configure_auth() {
|
||||
echo
|
||||
echo
|
||||
log_info "Set credentials for your service web UIs."
|
||||
log_info "These will be used to log into Radarr, Prowlarr, and other services."
|
||||
echo
|
||||
|
||||
auth_username=""
|
||||
while [ -z "$auth_username" ]; do
|
||||
read -p "Username for service login? [$USER]: " auth_username
|
||||
auth_username=${auth_username:-$USER}
|
||||
[ -z "$auth_username" ] && log_warning "Username cannot be empty. Try again."
|
||||
done
|
||||
|
||||
auth_password=""
|
||||
while [ -z "$auth_password" ]; do
|
||||
read_masked "Password for service login: " auth_password
|
||||
if [ -z "$auth_password" ]; then
|
||||
log_warning "Password cannot be empty. Try again."
|
||||
continue
|
||||
fi
|
||||
local auth_password_confirm=""
|
||||
read_masked "Confirm password: " auth_password_confirm
|
||||
if [ "$auth_password" != "$auth_password_confirm" ]; then
|
||||
log_warning "Passwords do not match. Try again."
|
||||
auth_password=""
|
||||
fi
|
||||
done
|
||||
|
||||
export auth_username auth_password
|
||||
}
|
||||
|
||||
configure_timezone() {
|
||||
local detected_tz
|
||||
detected_tz=$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone -v 2>/dev/null | tail -1 || echo "UTC")
|
||||
detected_tz=${detected_tz:-"UTC"}
|
||||
|
||||
# Express mode: auto-detect timezone
|
||||
if [ "${SETUP_MODE:-}" = "express" ]; then
|
||||
tz="$detected_tz"
|
||||
log_success "Timezone: $tz (auto-detected)"
|
||||
export tz
|
||||
return 0
|
||||
fi
|
||||
|
||||
read -p "Timezone? [$detected_tz]: " tz
|
||||
tz=${tz:-$detected_tz}
|
||||
|
||||
export tz
|
||||
}
|
||||
|
||||
running_services_location() {
|
||||
local host_ip
|
||||
# Get the IP of the default route interface (avoids Docker bridges, VPN tunnels)
|
||||
host_ip=$(ip -4 route show default 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n 1 || true)
|
||||
if [ -z "$host_ip" ]; then
|
||||
host_ip=$(hostname -I 2>/dev/null | awk '{ print $1 }' || true)
|
||||
fi
|
||||
host_ip=${host_ip:-"localhost"}
|
||||
|
||||
local -A services=(
|
||||
["qBittorrent"]="8081"
|
||||
["Radarr"]="7878"
|
||||
["Sonarr"]="8989"
|
||||
["Prowlarr"]="9696"
|
||||
["Seerr"]="5055"
|
||||
["$media_service"]="$media_service_port"
|
||||
["Portainer"]="9000"
|
||||
)
|
||||
|
||||
echo -e "Service URLs:"
|
||||
for service in "${!services[@]}"; do
|
||||
if [ "$service" = "plex" ]; then
|
||||
echo "$service: http://$host_ip:${services[$service]}/web"
|
||||
else
|
||||
echo "$service: http://$host_ip:${services[$service]}/"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
get_user_info() {
|
||||
# Express mode: use current user
|
||||
if [ "${SETUP_MODE:-}" = "express" ]; then
|
||||
username="$USER"
|
||||
puid=$(id -u "$username")
|
||||
pgid=$(id -g "$username")
|
||||
log_success "User: $username (PUID=$puid, PGID=$pgid)"
|
||||
export username puid pgid
|
||||
return 0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
read -p "User to own the media server files? [$USER]: " username
|
||||
username=${username:-$USER}
|
||||
|
||||
if id -u "$username" &>/dev/null; then
|
||||
puid=$(id -u "$username")
|
||||
pgid=$(id -g "$username")
|
||||
break
|
||||
else
|
||||
log_warning "User \"$username\" doesn't exist! Try again."
|
||||
fi
|
||||
done
|
||||
|
||||
export username puid pgid
|
||||
}
|
||||
|
||||
get_installation_paths() {
|
||||
# Express mode: use default paths
|
||||
if [ "${SETUP_MODE:-}" = "express" ]; then
|
||||
install_directory="$APP_DEFAULT_INSTALL_DIR"
|
||||
media_directory="$APP_DEFAULT_MEDIA_DIR"
|
||||
log_success "Install directory: $install_directory"
|
||||
log_success "Media directory: $media_directory"
|
||||
else
|
||||
read -p "Installation directory? [$APP_DEFAULT_INSTALL_DIR]: " install_directory
|
||||
install_directory=${install_directory:-$APP_DEFAULT_INSTALL_DIR}
|
||||
# FIX: Expand tilde in user input
|
||||
install_directory=$(expand_path "$install_directory")
|
||||
create_and_verify_directory "$install_directory" "installation"
|
||||
|
||||
read -p "Media directory? [$APP_DEFAULT_MEDIA_DIR]: " media_directory
|
||||
media_directory=${media_directory:-$APP_DEFAULT_MEDIA_DIR}
|
||||
# FIX: Expand tilde in user input
|
||||
media_directory=$(expand_path "$media_directory")
|
||||
|
||||
while true; do
|
||||
read -p "Are you sure your media directory is \"$media_directory\"? (Y/n) [Default = y]: " media_directory_correct
|
||||
media_directory_correct=${media_directory_correct:-"y"}
|
||||
|
||||
if [ "${media_directory_correct,,}" != "y" ]; then
|
||||
read -p "Media directory? [$APP_DEFAULT_MEDIA_DIR]: " media_directory
|
||||
media_directory=${media_directory:-$APP_DEFAULT_MEDIA_DIR}
|
||||
media_directory=$(expand_path "$media_directory")
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
setup_directory_structure "$media_directory"
|
||||
verify_user_permissions "$username" "$media_directory"
|
||||
|
||||
# Pre-create Seerr config dir with correct ownership (Seerr runs as node/UID 1000)
|
||||
# Docker would create this as root on first mount, causing EACCES
|
||||
mkdir -p "$install_directory/config/seerr"
|
||||
if [ "$(id -u)" -ne 1000 ]; then
|
||||
sudo chown -R 1000:1000 "$install_directory/config/seerr" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pre-create Recyclarr config dir to avoid root-owned volume files
|
||||
mkdir -p "$install_directory/config/recyclarr"
|
||||
if [ "$(id -u)" -ne "$puid" ] || [ "$(id -g)" -ne "$pgid" ]; then
|
||||
sudo chown -R "$puid:$pgid" "$install_directory/config/recyclarr" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
export install_directory media_directory
|
||||
}
|
||||
435
lib/seerr.sh
Normal file
435
lib/seerr.sh
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr Seerr & Recyclarr configuration — sourced by configure.sh
|
||||
# Provides: configure_seerr, configure_recyclarr
|
||||
# Requires: lib/core.sh (logging), lib/api.sh (api_get/post helpers),
|
||||
# lib/arr.sh (lookup_radarr_profile, lookup_sonarr_profile),
|
||||
# configure.sh (_cfg_log_info, log_step, log_step_fail, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Shared helper: connect a service (Radarr/Sonarr) to Seerr ---
|
||||
# Usage: seerr_connect_service <service_type> <port> <api_key> <profile_func> <active_dir> <is_4k> <cookie_jar>
|
||||
# Example: seerr_connect_service "radarr" 7878 "$RADARR_API_KEY" "lookup_radarr_profile" "/data/media/movies" "true" "$cookie_jar"
|
||||
seerr_connect_service() {
|
||||
local service_type="$1"
|
||||
local service_port="$2"
|
||||
local api_key="$3"
|
||||
local profile_func="$4"
|
||||
local active_dir="$5"
|
||||
local is_4k="$6"
|
||||
local cookie_jar="$7"
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
log_step_fail "Seerr: ${service_type} API key not provided"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$cookie_jar" ] || [ ! -f "$cookie_jar" ]; then
|
||||
log_step_fail "Seerr: cookie jar not found for ${service_type} connection"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Look up quality profile from the service (created by Recyclarr)
|
||||
local profile
|
||||
profile=$($profile_func "$api_key")
|
||||
local profile_id="${profile%%:*}"
|
||||
local profile_name="${profile##*:}"
|
||||
|
||||
# Build the service payload
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"name": "${service_type^}",
|
||||
"hostname": "${service_type}",
|
||||
"port": ${service_port},
|
||||
"apiKey": "${api_key}",
|
||||
"useSsl": false,
|
||||
"baseUrl": "",
|
||||
"isDefault": true,
|
||||
"is4k": ${is_4k},
|
||||
"minimumAvailability": "released",
|
||||
"syncEnabled": true,
|
||||
"activeProfileId": ${profile_id},
|
||||
"activeProfileName": "${profile_name}",
|
||||
"activeDirectory": "${active_dir}",
|
||||
"tags": []
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Special handling for Sonarr (needs enableSeasonFolders)
|
||||
if [ "$service_type" = "sonarr" ]; then
|
||||
payload=$(echo "$payload" | jq '.enableSeasonFolders = true' 2>/dev/null || echo "$payload")
|
||||
fi
|
||||
|
||||
# POST to Seerr
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST -b "$cookie_jar" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"http://${API_HOST}:5055/api/v1/settings/${service_type}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
log_step "Seerr: connected ${service_type^}"
|
||||
return 0
|
||||
else
|
||||
log_step_fail "Seerr: failed to connect ${service_type} (HTTP $http_code)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Shared helper: check if a service is already connected to Seerr ---
|
||||
# Usage: seerr_service_connected <service_type> <cookie_jar>
|
||||
# Returns 0 and prints count if connected, or 1 on failure
|
||||
seerr_service_connected() {
|
||||
local service_type="$1"
|
||||
local cookie_jar="$2"
|
||||
|
||||
local svc_list
|
||||
svc_list=$(curl -sf --connect-timeout 5 -b "$cookie_jar" \
|
||||
"http://${API_HOST}:5055/api/v1/service/${service_type}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$svc_list" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$svc_list" | jq 'length' 2>/dev/null || echo "0")
|
||||
echo "$count"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Shared helper: get Seerr session cookie ---
|
||||
# Usage: seerr_get_cookie <cookie_jar> <include_hostname>
|
||||
# Returns 0 on success, cookie file will be populated
|
||||
seerr_get_cookie() {
|
||||
local cookie_jar="$1"
|
||||
local include_hostname="$2" # "true" or "false"
|
||||
|
||||
local payload
|
||||
if [ "$include_hostname" = "true" ]; then
|
||||
# Full auth with hostname (for initial setup)
|
||||
local media_host="jellyfin"
|
||||
case "${MEDIA_SERVICE:-jellyfin}" in
|
||||
jellyfin) media_host="jellyfin"; media_port=8096; media_type=2 ;;
|
||||
emby) media_host="emby"; media_port=8096; media_type=3 ;;
|
||||
plex) media_host="host.docker.internal"; media_port=32400; media_type=1 ;;
|
||||
esac
|
||||
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"username": "${AUTH_USERNAME}",
|
||||
"password": "${AUTH_PASSWORD}",
|
||||
"hostname": "${media_host}",
|
||||
"port": ${media_port},
|
||||
"useSsl": false,
|
||||
"urlBase": "",
|
||||
"email": "",
|
||||
"serverType": ${media_type}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
else
|
||||
# Login-only (for re-runs when media server is already configured)
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"username": "${AUTH_USERNAME}",
|
||||
"password": "${AUTH_PASSWORD}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
local auth_endpoint="jellyfin"
|
||||
curl -s --connect-timeout 5 -c "$cookie_jar" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"http://${API_HOST}:5055/api/v1/auth/${auth_endpoint}" 2>/dev/null >/dev/null || return 1
|
||||
|
||||
# Verify cookie was created
|
||||
if [ ! -s "$cookie_jar" ] || ! grep -q "connect.sid" "$cookie_jar" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Recyclarr configuration ---
|
||||
|
||||
configure_recyclarr() {
|
||||
local recyclarr_config_dir="$INSTALL_DIR/config/recyclarr"
|
||||
mkdir -p "$recyclarr_config_dir"
|
||||
|
||||
if [ ! -w "$recyclarr_config_dir" ]; then
|
||||
log_step_fail "Recyclarr: config directory not writable (${recyclarr_config_dir})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Export variables for envsubst
|
||||
export RADARR_API_KEY SONARR_API_KEY
|
||||
|
||||
# Pick the right template based on user's 4K choice
|
||||
local template_file="recyclarr-full_hd.yml" # default: 1080p
|
||||
local profile_label="HD Bluray + WEB"
|
||||
if [ "${SEERR_IS_4K}" = "true" ]; then
|
||||
template_file="recyclarr-ultra_hd.yml" # 4K
|
||||
profile_label="UHD Bluray + WEB"
|
||||
fi
|
||||
|
||||
if [ -f "$INSTALL_DIR/templates/${template_file}" ]; then
|
||||
if envsubst < "$INSTALL_DIR/templates/${template_file}" > "$recyclarr_config_dir/recyclarr.yml"; then
|
||||
log_step "Recyclarr: generated config (${profile_label} profile)"
|
||||
else
|
||||
log_step_fail "Recyclarr: failed to write config (${recyclarr_config_dir}/recyclarr.yml)"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_step_fail "Recyclarr: template ${template_file} not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Trigger a sync
|
||||
_cfg_log_info "Recyclarr: syncing TRaSH Guides to Radarr and Sonarr... this might take a moment"
|
||||
if docker exec recyclarr recyclarr sync > /dev/null 2>&1; then
|
||||
log_step "Recyclarr: synced Custom Formats and Quality Profiles successfully"
|
||||
else
|
||||
log_step_fail "Recyclarr: failed to sync to Radarr/Sonarr (check docker logs recyclarr)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Seerr configuration ---
|
||||
|
||||
configure_seerr() {
|
||||
local seerr_port=5055
|
||||
local max_wait=120
|
||||
local wait_time=0
|
||||
|
||||
# Step 1: Wait for Seerr to be responsive
|
||||
echo >&2
|
||||
echo -n "Waiting for Seerr to start" >&2
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
|
||||
"http://${API_HOST}:${seerr_port}/api/v1/settings/public" 2>/dev/null || echo "000")
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
dot_inline
|
||||
done
|
||||
echo >&2
|
||||
|
||||
if [ $wait_time -ge $max_wait ]; then
|
||||
log_step_fail "Seerr: not responsive after ${max_wait}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 2: Check if setup is already complete
|
||||
local public_settings
|
||||
public_settings=$(curl -s --connect-timeout 5 \
|
||||
"http://${API_HOST}:${seerr_port}/api/v1/settings/public" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$public_settings" ]; then
|
||||
local initialized
|
||||
initialized=$(echo "$public_settings" | jq -r '.initialized // ""' 2>/dev/null || echo "")
|
||||
if [ "$initialized" = "True" ] || [ "$initialized" = "true" ]; then
|
||||
log_step "Seerr: setup already completed — configuring services"
|
||||
local seerr_cookie_jar="/tmp/seerr_cookie_jar_$$_${seerr_port}"
|
||||
|
||||
# Get session cookie (no hostname needed — already configured)
|
||||
if ! seerr_get_cookie "$seerr_cookie_jar" "false"; then
|
||||
log_step_fail "Seerr: failed to get session cookie for reconfiguration"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Connecting Seerr services" >&2
|
||||
|
||||
# Connect Radarr if not already connected
|
||||
if [ -n "$RADARR_API_KEY" ]; then
|
||||
local radarr_count
|
||||
radarr_count=$(seerr_service_connected "radarr" "$seerr_cookie_jar") || radarr_count=0
|
||||
if [ "$radarr_count" -eq 0 ]; then
|
||||
if ! seerr_connect_service "radarr" 7878 "$RADARR_API_KEY" "lookup_radarr_profile" \
|
||||
"/data/media/movies" "${SEERR_IS_4K:-false}" "$seerr_cookie_jar"; then
|
||||
log_step_fail "Seerr: failed to connect Radarr (re-run to retry)"
|
||||
fi
|
||||
else
|
||||
log_step "Seerr: Radarr already connected"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Connect Sonarr if not already connected
|
||||
if [ -n "$SONARR_API_KEY" ]; then
|
||||
local sonarr_count
|
||||
sonarr_count=$(seerr_service_connected "sonarr" "$seerr_cookie_jar") || sonarr_count=0
|
||||
if [ "$sonarr_count" -eq 0 ]; then
|
||||
if ! seerr_connect_service "sonarr" 8989 "$SONARR_API_KEY" "lookup_sonarr_profile" \
|
||||
"/data/media/tv" "false" "$seerr_cookie_jar"; then
|
||||
log_step_fail "Seerr: failed to connect Sonarr (re-run to retry)"
|
||||
fi
|
||||
else
|
||||
log_step "Seerr: Sonarr already connected"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo >&2
|
||||
rm -f "$seerr_cookie_jar"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Determine media server connection details
|
||||
local media_server_type=""
|
||||
local media_server_host=""
|
||||
local media_server_port=0
|
||||
|
||||
case "${MEDIA_SERVICE:-jellyfin}" in
|
||||
jellyfin)
|
||||
media_server_type="2"
|
||||
media_server_host="jellyfin"
|
||||
media_server_port=8096
|
||||
;;
|
||||
emby)
|
||||
media_server_type="3"
|
||||
media_server_host="emby"
|
||||
media_server_port=8096
|
||||
;;
|
||||
plex)
|
||||
# Plex uses host networking — Seerr needs the host IP
|
||||
media_server_type="1"
|
||||
media_server_host="host.docker.internal"
|
||||
media_server_port=32400
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$media_server_type" ]; then
|
||||
log_step_fail "Seerr: unsupported media service '${MEDIA_SERVICE:-}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Step 4: Authenticate against the media server via Seerr's setup endpoint
|
||||
# This creates the admin user in Seerr and stores the media server connection
|
||||
local auth_payload
|
||||
if [ "${MEDIA_SERVICE:-}" = "plex" ]; then
|
||||
# Plex requires an authToken from Plex.tv OAuth — cannot automate without user interaction
|
||||
log_step "Seerr: Plex detected — manual setup required via web UI at http://${API_HOST}:${seerr_port}"
|
||||
_cfg_log_info " Log in with your Plex account to complete Seerr setup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Jellyfin/Emby: use credential-based auth (both use /auth/jellyfin endpoint)
|
||||
local auth_endpoint="jellyfin"
|
||||
|
||||
auth_payload=$(cat <<EOF
|
||||
{
|
||||
"username": "${AUTH_USERNAME}",
|
||||
"password": "${AUTH_PASSWORD}",
|
||||
"hostname": "${media_server_host}",
|
||||
"port": ${media_server_port},
|
||||
"useSsl": false,
|
||||
"urlBase": "",
|
||||
"email": "",
|
||||
"serverType": ${media_server_type}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
_cfg_log_info "Seerr: authenticating against ${MEDIA_SERVICE:-jellyfin}..."
|
||||
|
||||
# Use -s (not -sf) so we can capture error responses too
|
||||
local auth_response
|
||||
auth_response=$(curl -s --connect-timeout 10 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$auth_payload" \
|
||||
"http://${API_HOST}:${seerr_port}/api/v1/auth/${auth_endpoint}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$auth_response" ]; then
|
||||
log_step_fail "Seerr: failed to authenticate (empty response)"
|
||||
_cfg_log_info " Complete setup manually at http://${API_HOST}:${seerr_port}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for error in response — "hostname already configured" means media server
|
||||
# was connected by a previous run, which is fine (admin user already exists)
|
||||
local auth_error
|
||||
auth_error=$(echo "$auth_response" | jq -r '.error // ""' 2>/dev/null || echo "")
|
||||
if [ -n "$auth_error" ]; then
|
||||
if echo "$auth_error" | grep -qi "already configured"; then
|
||||
log_step "Seerr: media server already connected"
|
||||
else
|
||||
local auth_message
|
||||
auth_message=$(echo "$auth_response" | jq -r '.message // .error // "unknown error"' 2>/dev/null || echo "unknown error")
|
||||
log_step_fail "Seerr: authentication failed — ${auth_message}"
|
||||
_cfg_log_info " Complete setup manually at http://${API_HOST}:${seerr_port}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Successful auth — verify admin user was created
|
||||
local auth_id
|
||||
auth_id=$(echo "$auth_response" | jq -r '.id // ""' 2>/dev/null || echo "")
|
||||
if [ -n "$auth_id" ]; then
|
||||
log_step "Seerr: authenticated against ${MEDIA_SERVICE:-jellyfin} (admin user created)"
|
||||
else
|
||||
log_step_fail "Seerr: authentication returned unexpected response"
|
||||
_cfg_log_info " Complete setup manually at http://${API_HOST}:${seerr_port}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 5: Get a session cookie for subsequent API calls
|
||||
local seerr_cookie_jar="/tmp/seerr_cookie_jar_$$_${seerr_port}"
|
||||
|
||||
# Get session cookie (with hostname for initial setup)
|
||||
if ! seerr_get_cookie "$seerr_cookie_jar" "true"; then
|
||||
# Retry with login-only if first attempt fails (hostname already configured)
|
||||
if ! seerr_get_cookie "$seerr_cookie_jar" "false"; then
|
||||
log_step_fail "Seerr: failed to get session cookie"
|
||||
rm -f "$seerr_cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Note: Library sync is skipped here — Jellyfin hasn't indexed anything yet on first setup.
|
||||
# Seerr will auto-sync libraries periodically (every 24h) once content exists.
|
||||
|
||||
# Step 6: Initialize Seerr (mark setup as complete)
|
||||
local init_code
|
||||
init_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST -b "$seerr_cookie_jar" \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://${API_HOST}:${seerr_port}/api/v1/settings/initialize" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$init_code" -ge 200 ] && [ "$init_code" -lt 300 ]; then
|
||||
log_step "Seerr: setup completed successfully"
|
||||
else
|
||||
log_step_fail "Seerr: failed to finalize setup (HTTP $init_code)"
|
||||
_cfg_log_info " Complete setup manually at http://${API_HOST}:${seerr_port}"
|
||||
rm -f "$seerr_cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Connecting Seerr services" >&2
|
||||
|
||||
# Connect Radarr and Sonarr using shared helper
|
||||
local connect_errors=0
|
||||
if [ -n "$RADARR_API_KEY" ]; then
|
||||
if ! seerr_connect_service "radarr" 7878 "$RADARR_API_KEY" "lookup_radarr_profile" \
|
||||
"/data/media/movies" "${SEERR_IS_4K:-false}" "$seerr_cookie_jar"; then
|
||||
connect_errors=$((connect_errors + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SONARR_API_KEY" ]; then
|
||||
if ! seerr_connect_service "sonarr" 8989 "$SONARR_API_KEY" "lookup_sonarr_profile" \
|
||||
"/data/media/tv" "false" "$seerr_cookie_jar"; then
|
||||
connect_errors=$((connect_errors + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo >&2
|
||||
rm -f "$seerr_cookie_jar"
|
||||
|
||||
if [ $connect_errors -gt 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
101
lib/vpn.sh
Normal file
101
lib/vpn.sh
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/bin/bash
|
||||
# Assemblrr VPN helpers — sourced by entry points that handle VPN orchestration
|
||||
# Requires: lib/core.sh (for logging, safe_source)
|
||||
|
||||
# Guard against double-sourcing
|
||||
if [ -n "${_ASSEMBLRR_VPN_SOURCED:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
readonly _ASSEMBLRR_VPN_SOURCED=1
|
||||
|
||||
# Tests VPN connection by starting Gluetun first and waiting for it to become healthy.
|
||||
# Usage: test_vpn_connection_shared "${DC[@]}"
|
||||
test_vpn_connection_shared() {
|
||||
local docker_cmd=("$@")
|
||||
|
||||
log_info "Testing VPN connection by starting Gluetun first..."
|
||||
|
||||
# Force recreate so updated secrets/.env from a credential retry always apply.
|
||||
if ! "${docker_cmd[@]}" up -d --force-recreate gluetun 2>/dev/null; then
|
||||
log_warning "Failed to start Gluetun container. VPN configuration may be incorrect."
|
||||
log_info "Check secrets/ (credentials) and .env (VPN_SERVICE, VPN_TYPE, WIREGUARD_ADDRESSES)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local wait_time=0
|
||||
local max_wait=90
|
||||
echo -n "Waiting for VPN connection"
|
||||
while [ $wait_time -lt $max_wait ]; do
|
||||
local health
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' gluetun 2>/dev/null || echo "unknown")
|
||||
|
||||
if [ "$health" = "healthy" ]; then
|
||||
echo
|
||||
log_success "VPN connection established successfully!"
|
||||
return 0
|
||||
elif [ "$health" = "unhealthy" ]; then
|
||||
echo
|
||||
log_warning "VPN connection failed. Gluetun logs:"
|
||||
"${docker_cmd[@]}" logs gluetun --tail=20 2>/dev/null
|
||||
echo
|
||||
log_info "Fix VPN credentials in secrets/ (and WIREGUARD_ADDRESSES in .env if using WireGuard), then try again."
|
||||
"${docker_cmd[@]}" stop gluetun 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
wait_time=$((wait_time + 3))
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
echo
|
||||
log_warning "VPN connection timed out after ${max_wait}s. Gluetun logs:"
|
||||
"${docker_cmd[@]}" logs gluetun --tail=20 2>/dev/null
|
||||
echo
|
||||
log_info "The VPN may need more time, or credentials may be incorrect (see secrets/ and .env)."
|
||||
"${docker_cmd[@]}" stop gluetun 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
|
||||
# Validate that VPN secrets exist when VPN is enabled.
|
||||
# Usage: validate_vpn_secrets "$install_dir" "$vpn_enabled" "$vpn_type"
|
||||
# Returns 0 if OK, 1 if secrets are missing (prints warnings).
|
||||
validate_vpn_secrets() {
|
||||
local _install_dir="$1"
|
||||
local _vpn_enabled="${2:-n}"
|
||||
local _vpn_type="${3:-openvpn}"
|
||||
|
||||
if [ "${_vpn_enabled,,}" != "y" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Source .env for WIREGUARD_ADDRESSES
|
||||
local _env_file="$_install_dir/.env"
|
||||
if [ -f "$_env_file" ]; then
|
||||
set -a; safe_source "$_env_file"; set +a
|
||||
fi
|
||||
|
||||
local _secrets_dir="$_install_dir/secrets"
|
||||
local _missing=()
|
||||
|
||||
if [ "${_vpn_type,,}" = "wireguard" ]; then
|
||||
[ ! -s "$_secrets_dir/wireguard_private_key.txt" ] && _missing+=("secrets/wireguard_private_key.txt")
|
||||
[ -z "${WIREGUARD_ADDRESSES:-}" ] && _missing+=("WIREGUARD_ADDRESSES in .env")
|
||||
else
|
||||
[ ! -s "$_secrets_dir/openvpn_user.txt" ] && _missing+=("secrets/openvpn_user.txt")
|
||||
[ ! -s "$_secrets_dir/openvpn_password.txt" ] && _missing+=("secrets/openvpn_password.txt")
|
||||
fi
|
||||
|
||||
if [ ${#_missing[@]} -gt 0 ]; then
|
||||
log_warning "VPN is enabled but the following required secrets are missing:"
|
||||
for var in "${_missing[@]}"; do
|
||||
log_warning " - $var"
|
||||
done
|
||||
echo
|
||||
log_warning "Services will NOT start until these are set."
|
||||
log_info "Edit secrets in $_secrets_dir and try again."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
14
platform/linux/bootstrap.sh
Normal file
14
platform/linux/bootstrap.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Assemblrr Bootstrap Script
|
||||
# Clones the repository and launches setup.sh
|
||||
# Usage (bash/zsh): bash <(curl -fsSL https://raw.githubusercontent.com/soulis-1256/assemblrr/main/platform/linux/bootstrap.sh)
|
||||
# Usage (fish): bash (curl -fsSL https://raw.githubusercontent.com/soulis-1256/assemblrr/main/platform/linux/bootstrap.sh | psub)
|
||||
|
||||
REPO="https://github.com/soulis-1256/assemblrr"
|
||||
TMPDIR=$(mktemp -d /tmp/assemblrr.XXXXXX)
|
||||
|
||||
git clone --depth=1 "$REPO" "$TMPDIR"
|
||||
cd "$TMPDIR"
|
||||
bash bin/setup.sh "$@"
|
||||
278
platform/windows/bootstrap-dev.ps1
Normal file
278
platform/windows/bootstrap-dev.ps1
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Assemblrr Dev Bootstrap
|
||||
# Copies local project files to WSL2 and runs setup.sh
|
||||
# Alternative to bootstrap.ps1 — skips git clone, uses local files instead
|
||||
# Usage:
|
||||
# .\bootstrap-dev.ps1 Show help
|
||||
# .\bootstrap-dev.ps1 -Full Full setup (copy files + run setup.sh)
|
||||
# .\bootstrap-dev.ps1 -Full -Clean Uninstall first, then full setup
|
||||
# .\bootstrap-dev.ps1 -Update Only copy updated files to WSL2
|
||||
# .\bootstrap-dev.ps1 -Exec <cmd> Run an assemblrr CLI command in WSL2
|
||||
# .\bootstrap-dev.ps1 -Help Show this help screen
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Help,
|
||||
[switch]$Full,
|
||||
[switch]$Clean,
|
||||
[switch]$Update,
|
||||
[string]$Exec
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ProjectDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$AppName = "assemblrr"
|
||||
|
||||
function Show-Help {
|
||||
Write-Host ""
|
||||
Write-Host "Assemblrr Dev Bootstrap" -ForegroundColor Cyan
|
||||
Write-Host "========================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Copies local project files to WSL2 for development." -ForegroundColor White
|
||||
Write-Host "Alternative to bootstrap.ps1 - skips git clone, uses local files instead." -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Usage:" -ForegroundColor Yellow
|
||||
Write-Host " .\bootstrap-dev.ps1 Show this help screen"
|
||||
Write-Host " .\bootstrap-dev.ps1 -Full Full setup: copy files to WSL2 and run setup.sh"
|
||||
Write-Host " .\bootstrap-dev.ps1 -Full -Clean Uninstall first, then full setup"
|
||||
Write-Host " .\bootstrap-dev.ps1 -Update Quick update: only copy changed files to WSL2"
|
||||
Write-Host " .\bootstrap-dev.ps1 -Exec <cmd> Run an assemblrr CLI command in WSL2"
|
||||
Write-Host " .\bootstrap-dev.ps1 -Help Show this help screen"
|
||||
Write-Host ""
|
||||
Write-Host "Parameters:" -ForegroundColor Yellow
|
||||
Write-Host " -Full Copy project files to WSL2 and launch setup.sh"
|
||||
Write-Host " -Clean Uninstall existing installation (use with -Full for clean reinstall)"
|
||||
Write-Host " -Update Copy project files to WSL2 without running setup.sh"
|
||||
Write-Host " Use this when you made changes on Windows that need to be"
|
||||
Write-Host " reflected in the WSL2 environment"
|
||||
Write-Host " -Exec Run an assemblrr CLI command in WSL2 (e.g. -Exec 'uninstall -f')"
|
||||
Write-Host " Requires an existing installation; runs with full TTY support"
|
||||
Write-Host " -Help Show this help screen"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Find-WSL2Distro {
|
||||
$distro = ""
|
||||
try {
|
||||
$guid = (Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss").DefaultDistribution
|
||||
$distro = (Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss\$guid").DistributionName
|
||||
} catch {
|
||||
$output = wsl --list --verbose 2>$null
|
||||
$line = $output | Where-Object { $_ -match '^\*' }
|
||||
if ($line) {
|
||||
$distro = ($line -replace '^\*\s+', '' -replace '\s+.*', '').Trim()
|
||||
$distro = $distro -replace "^\xEF\xBB\xBF", ""
|
||||
}
|
||||
}
|
||||
return $distro
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Copy-ProjectToWSL2 {
|
||||
param([string]$Distro)
|
||||
if (-not $AppName) {
|
||||
Write-Host "App name is empty, aborting." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Copying project to WSL2 /tmp/$AppName..." -ForegroundColor Yellow
|
||||
# Safety check: refuse to remove if path is a symlink (prevents TOCTOU race condition)
|
||||
wsl -d $Distro -- bash -c "if [ -L /tmp/$AppName ]; then echo 'Refusing to remove symlink at /tmp/$AppName' >&2; exit 1; fi; rm -rf /tmp/$AppName 2>/dev/null; mkdir -p /tmp/$AppName"
|
||||
|
||||
$driveLetter = $ProjectDir.Substring(0,1).ToLower()
|
||||
$wslSourcePath = "/mnt/$driveLetter" + ($ProjectDir.Substring(2) -replace '\\', '/')
|
||||
wsl -d $Distro -- bash -c "cp -r $wslSourcePath/* /tmp/$AppName/ && cp -r $wslSourcePath/.env.example $wslSourcePath/.gitignore /tmp/$AppName/ 2>/dev/null; true"
|
||||
|
||||
wsl -d $Distro -- bash -c "chmod +x /tmp/$AppName/bin/setup.sh /tmp/$AppName/bin/cli.sh /tmp/$AppName/bin/docker-install.sh /tmp/$AppName/bin/configure.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/scripts/*.sh 2>/dev/null"
|
||||
|
||||
# Convert CRLF to LF on shell scripts (Windows line endings break bash)
|
||||
wsl -d $Distro -- bash -c "sed -i 's/\r$//' /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/branding.conf /tmp/$AppName/compose/*.yaml /tmp/$AppName/compose/examples/*.yaml /tmp/$AppName/templates/*.env /tmp/$AppName/templates/*.yml /tmp/$AppName/scripts/*.sh 2>/dev/null; true"
|
||||
Write-Host "Files copied successfully." -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Test-InstallConfigExists {
|
||||
param([string]$Distro)
|
||||
$result = wsl -d $Distro -- bash -c "cat /opt/$AppName/.$AppName-config `$HOME/$AppName/.$AppName-config `$HOME/.$AppName-config 2>/dev/null | grep INSTALL_DIRECTORY"
|
||||
return (-not [string]::IsNullOrWhiteSpace($result))
|
||||
}
|
||||
|
||||
function Test-InstallationResidueExists {
|
||||
param([string]$Distro)
|
||||
$result = wsl -d $Distro -- bash -c "[ -e `"`$HOME/$AppName`" ] || [ -e `"`$HOME/$AppName-media`" ] || [ -e `"`$HOME/.local/bin/$AppName`" ] && echo found"
|
||||
return ($result -match "found")
|
||||
}
|
||||
|
||||
function Test-InstallationExists {
|
||||
param([string]$Distro)
|
||||
return ((Test-InstallConfigExists -Distro $Distro) -or (Test-InstallationResidueExists -Distro $Distro))
|
||||
}
|
||||
|
||||
function Invoke-ResidueCleanup {
|
||||
param([string]$Distro)
|
||||
|
||||
Write-Host "Partial installation detected without runtime config. Cleaning known WSL2 dev paths..." -ForegroundColor Yellow
|
||||
$cleanupScript = @"
|
||||
set -euo pipefail
|
||||
source /tmp/$AppName/lib/core.sh
|
||||
|
||||
install_dir="`$HOME/$AppName"
|
||||
media_dir="`$HOME/$AppName-media"
|
||||
cli_path="`$HOME/.local/bin/$AppName"
|
||||
system_cli="/usr/local/bin/$AppName"
|
||||
|
||||
for dir in "`$install_dir" "`$media_dir"; do
|
||||
case "`$dir" in
|
||||
"`$HOME/$AppName"|"`$HOME/$AppName-media") ;;
|
||||
*) echo "Refusing unexpected cleanup path: `$dir" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -e "`$dir" ]; then
|
||||
safe_rm_rf "`$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "`$cli_path" 2>/dev/null || true
|
||||
rm -f "`$system_cli" 2>/dev/null || true
|
||||
"@
|
||||
|
||||
$cleanupScriptBytes = [System.Text.Encoding]::UTF8.GetBytes($cleanupScript)
|
||||
$cleanupScriptBase64 = [Convert]::ToBase64String($cleanupScriptBytes)
|
||||
$remoteCleanupScript = "/tmp/$AppName-cleanup-residue.sh"
|
||||
wsl -d $Distro -- bash -c "printf '%s' '$cleanupScriptBase64' | base64 -d | sed 's/\r$//' > '$remoteCleanupScript' && bash '$remoteCleanupScript'; status=`$?; rm -f '$remoteCleanupScript'; exit `$status"
|
||||
}
|
||||
|
||||
function Invoke-Uninstall {
|
||||
param([string]$Distro, [switch]$ContinueOnError)
|
||||
if (-not (Test-InstallationExists -Distro $Distro)) {
|
||||
Write-Host "No existing installation to clean." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-InstallConfigExists -Distro $Distro)) {
|
||||
Invoke-ResidueCleanup -Distro $Distro
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Residue cleanup failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Residue cleanup complete." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Uninstalling existing installation..." -ForegroundColor Yellow
|
||||
wsl -d $Distro -- bash -c "source /tmp/$AppName/branding.conf && bash /tmp/$AppName/bin/cli.sh uninstall -f"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($ContinueOnError -and -not (Test-InstallationExists -Distro $Distro)) {
|
||||
Write-Host "Uninstall reported an error, but no installation files remain. Continuing..." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Uninstall failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Host "Uninstall complete." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# No parameters or -Help → show help
|
||||
if (-not $Full -and -not $Clean -and -not $Update -and -not $Exec -or $Help) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Detect WSL2 distro
|
||||
$defaultDistro = Find-WSL2Distro
|
||||
if (-not $defaultDistro) {
|
||||
Write-Host "No WSL2 distro found." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Using WSL2 distro: $defaultDistro" -ForegroundColor Cyan
|
||||
|
||||
|
||||
|
||||
# -Clean standalone: copy files first (need CLI for uninstall), then uninstall and exit
|
||||
if ($Clean -and -not $Full) {
|
||||
Copy-ProjectToWSL2 -Distro $defaultDistro
|
||||
Invoke-Uninstall -Distro $defaultDistro
|
||||
exit 0
|
||||
}
|
||||
|
||||
# -Update/-Exec: verify installation exists before doing anything
|
||||
if ($Update -or $Exec) {
|
||||
if (-not (Test-InstallationExists -Distro $defaultDistro)) {
|
||||
Write-Host "No existing installation found. Run -Full first to set up." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Copy project files to WSL2 (skip for -Exec — CLI is already installed)
|
||||
if (-not $Exec) {
|
||||
Copy-ProjectToWSL2 -Distro $defaultDistro
|
||||
}
|
||||
|
||||
# -Full: also run setup.sh
|
||||
if ($Full) {
|
||||
# -Clean: uninstall existing installation first
|
||||
# Use CLI from /tmp (just copied) — installed CLI may be missing/broken
|
||||
if ($Clean) {
|
||||
Invoke-Uninstall -Distro $defaultDistro -ContinueOnError
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "Launching setup.sh in WSL2..." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Run wsl directly in the current terminal — inherits ConPTY for full TTY/fzf support
|
||||
wsl -d $defaultDistro -- bash -i -c "cd /tmp/$AppName && bash bin/setup.sh"
|
||||
}
|
||||
|
||||
# -Update: sync files to install directory and reinstall CLI
|
||||
if ($Update) {
|
||||
Write-Host "Syncing updated files to install directory..." -ForegroundColor Yellow
|
||||
$updateScript = @"
|
||||
#!/bin/bash
|
||||
source /tmp/$AppName/branding.conf
|
||||
INSTALL_DIR=""
|
||||
for cfg in "/opt/`$APP_NAME/.`$APP_NAME-config" "`$HOME/`$APP_NAME/.`$APP_NAME-config" "`$HOME/.`$APP_NAME-config"; do
|
||||
if [ -f "`$cfg" ]; then
|
||||
INSTALL_DIR=`$(grep INSTALL_DIRECTORY "`$cfg" | cut -d= -f2 | tr -d '"' | tr -d "'")
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "`$INSTALL_DIR" ]; then
|
||||
echo "No existing installation found. Run -Full first to set up." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Install directory: `$INSTALL_DIR"
|
||||
cp /tmp/$AppName/bin/cli.sh /tmp/$AppName/bin/setup.sh /tmp/$AppName/bin/docker-install.sh /tmp/$AppName/bin/configure.sh /tmp/$AppName/branding.conf "`$INSTALL_DIR/"
|
||||
mkdir -p "`$INSTALL_DIR/lib"
|
||||
cp /tmp/$AppName/lib/*.sh "`$INSTALL_DIR/lib/"
|
||||
cp /tmp/$AppName/compose/base.yaml /tmp/$AppName/compose/vpn.yaml /tmp/$AppName/compose/direct-access.yaml "`$INSTALL_DIR/compose/"
|
||||
mkdir -p "`$INSTALL_DIR/compose/examples"
|
||||
cp /tmp/$AppName/compose/examples/custom.yaml.example "`$INSTALL_DIR/compose/examples/" 2>/dev/null || true
|
||||
cp /tmp/$AppName/templates/envsubst.env /tmp/$AppName/templates/recyclarr-full_hd.yml /tmp/$AppName/templates/recyclarr-ultra_hd.yml "`$INSTALL_DIR/templates/"
|
||||
mkdir -p "`$INSTALL_DIR/scripts"
|
||||
cp /tmp/$AppName/scripts/* "`$INSTALL_DIR/scripts/"
|
||||
echo "Files synced to install directory"
|
||||
mkdir -p "`$HOME/.local/bin/lib"
|
||||
cp "`$INSTALL_DIR/lib/"*.sh "`$HOME/.local/bin/lib/"
|
||||
cp "`$INSTALL_DIR/cli.sh" "`$HOME/.local/bin/`$APP_CLI_NAME" && chmod +x "`$HOME/.local/bin/`$APP_CLI_NAME"
|
||||
rm -f "/usr/local/bin/`$APP_CLI_NAME" 2>/dev/null || true
|
||||
echo "CLI reinstalled to `$HOME/.local/bin/`$APP_CLI_NAME"
|
||||
"@
|
||||
$updateScript | wsl -d $defaultDistro -- bash -c "sed 's/\r$//' | bash"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Update failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Update complete." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# -Exec: run an assemblrr CLI command in WSL2
|
||||
if ($Exec) {
|
||||
Write-Host "Running assemblrr $Exec in WSL2..." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Run wsl directly in the current terminal — inherits ConPTY for full TTY/fzf support
|
||||
# bash -l sources .profile (which has PATH set by install_cli)
|
||||
wsl -d $defaultDistro -- bash -l -c "$AppName $Exec"
|
||||
}
|
||||
88
platform/windows/bootstrap.ps1
Normal file
88
platform/windows/bootstrap.ps1
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Assemblrr Windows Bootstrap
|
||||
# Clones the repository, copies to WSL2, and launches setup.sh
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$AppName = "assemblrr"
|
||||
$RepoURL = "https://github.com/soulis-1256/assemblrr"
|
||||
|
||||
# Step 1: Check WSL2 is available
|
||||
if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "WSL2 is not installed." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Please install Docker Desktop for Windows first:" -ForegroundColor Yellow
|
||||
Write-Host "https://docs.docker.com/desktop/install/windows-install/" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Docker Desktop will set up WSL2 automatically." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Detect default WSL2 distro from Windows Registry
|
||||
$defaultDistro = ""
|
||||
try {
|
||||
$defaultGuid = (Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss").DefaultDistribution
|
||||
$defaultDistro = (Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss\$defaultGuid").DistributionName
|
||||
} catch {
|
||||
# Fallback: parse wsl --list --verbose output
|
||||
$wslOutput = wsl --list --verbose 2>$null
|
||||
$defaultLine = $wslOutput | Where-Object { $_ -match '^\*' }
|
||||
if ($defaultLine) {
|
||||
# Parse the * marker line: "* archlinux Running 2"
|
||||
$defaultDistro = ($defaultLine -replace '^\*\s+', '' -replace '\s+.*', '').Trim()
|
||||
# Strip BOM if present
|
||||
$defaultDistro = $defaultDistro -replace "^\xEF\xBB\xBF", ""
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $defaultDistro) {
|
||||
Write-Host "No WSL2 distribution found." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Please install Docker Desktop for Windows first:" -ForegroundColor Yellow
|
||||
Write-Host "https://docs.docker.com/desktop/install/windows-install/" -ForegroundColor Cyan
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using WSL2 distro: $defaultDistro" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 4: Clone repository to temporary Windows directory
|
||||
Write-Host "Cloning repository..." -ForegroundColor White
|
||||
$tempDir = [System.IO.Path]::GetTempPath() + $AppName + "." + [System.Guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||
try {
|
||||
git clone --depth=1 $RepoURL $tempDir
|
||||
} catch {
|
||||
Write-Host "Failed to clone repository. Is git installed?" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Repository cloned." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 5: Copy project files to WSL2
|
||||
Write-Host "Copying project to WSL2..." -ForegroundColor White
|
||||
wsl -d $defaultDistro -- bash -c "if [ -L /tmp/$AppName ]; then echo 'Refusing to remove symlink at /tmp/$AppName' >&2; exit 1; fi; rm -rf /tmp/$AppName 2>/dev/null; mkdir -p /tmp/$AppName"
|
||||
|
||||
# Convert Windows path to WSL path
|
||||
$wslSourcePath = $tempDir -replace '\\', '/'
|
||||
$driveLetter = $tempDir.Substring(0,1).ToLower()
|
||||
$wslSourcePath = "/mnt/$driveLetter" + ($tempDir.Substring(2) -replace '\\', '/')
|
||||
|
||||
wsl -d $defaultDistro -- bash -c "cp -r $wslSourcePath/* /tmp/$AppName/ && cp -r $wslSourcePath/.env.example $wslSourcePath/.gitignore /tmp/$AppName/ 2>/dev/null; true"
|
||||
|
||||
# Fix line endings and set permissions
|
||||
wsl -d $defaultDistro -- bash -c "sed -i 's/\r$//' /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/branding.conf /tmp/$AppName/compose/*.yaml /tmp/$AppName/compose/examples/*.yaml /tmp/$AppName/templates/*.env /tmp/$AppName/templates/*.yml /tmp/$AppName/scripts/*.sh /tmp/$AppName/platform/linux/bootstrap.sh 2>/dev/null; true"
|
||||
wsl -d $defaultDistro -- bash -c "chmod +x /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/scripts/*.sh /tmp/$AppName/platform/linux/bootstrap.sh 2>/dev/null; true"
|
||||
|
||||
# Cleanup temp directory
|
||||
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "Project files copied." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 6: Launch setup.sh in WSL2
|
||||
Write-Host "Launching ${AppName} setup in WSL2..." -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
# Pass any PowerShell args through to setup.sh
|
||||
$setupArgs = $args -join ' '
|
||||
# Run wsl directly in the current terminal — inherits ConPTY for full TTY/fzf support
|
||||
wsl -d $defaultDistro -- bash -i -c "cd /tmp/$AppName && bash bin/setup.sh $setupArgs"
|
||||
53
scripts/integration_test.sh
Normal file
53
scripts/integration_test.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source /home/soulis/assemblrr/.assemblrr-config
|
||||
|
||||
API="http://127.0.0.1:8081/api/v2"
|
||||
COOKIE="/tmp/qbit_cookie.txt"
|
||||
|
||||
# 1. Login
|
||||
echo "Logging in..."
|
||||
curl -s -c "$COOKIE" -H "Referer: http://127.0.0.1:8081" -d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" "${API}/auth/login"
|
||||
|
||||
# 2. Add Ubuntu Torrent via Tracker-Injected Magnet Link (Domain Independent)
|
||||
echo "Adding test torrent via Decentralized Magnet..."
|
||||
# Contains the Ubuntu 22.04 LTS hash + Opentrackr (one of the largest public trackers)
|
||||
TARGET_HASH="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
MAGNET="magnet:?xt=urn:btih:${TARGET_HASH}&dn=ubuntu-22.04.4-desktop-amd64.iso&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce"
|
||||
curl -s -b "$COOKIE" -d "urls=${MAGNET}" "${API}/torrents/add"
|
||||
|
||||
# Wait to grab peers
|
||||
echo "Waiting 15 seconds for peers..."
|
||||
sleep 15
|
||||
curl -s -b "$COOKIE" "${API}/torrents/info" | jq -r '.[] | "Torrent: \(.name) | State: \(.state) | Seeds: \(.num_seeds)/\(.num_complete)"'
|
||||
|
||||
# 3. Sabotage
|
||||
echo "Sabotaging global connections to 0 and restarting torrent..."
|
||||
curl -s -b "$COOKIE" -H "Referer: http://127.0.0.1:8081" -d 'json={"max_connecs": 0}' "${API}/app/setPreferences"
|
||||
|
||||
# Pause and resume to drop active connections instantly
|
||||
curl -s -b "$COOKIE" -d "hashes=${TARGET_HASH}" "${API}/torrents/stop"
|
||||
sleep 2
|
||||
curl -s -b "$COOKIE" -d "hashes=${TARGET_HASH}" "${API}/torrents/start"
|
||||
|
||||
echo "Waiting 15 seconds to stall..."
|
||||
sleep 15
|
||||
curl -s -b "$COOKIE" "${API}/torrents/info" | jq -r '.[] | "Torrent: \(.name) | State: \(.state) | Seeds: \(.num_seeds)/\(.num_complete)"'
|
||||
|
||||
# 4. Watchdog execution
|
||||
echo ">>> RUNNING WATCHDOG <<<"
|
||||
export AUTH_USERNAME AUTH_PASSWORD
|
||||
# Just in case CRLF was introduced during git syncs or creation
|
||||
sed -i 's/\r$//' scripts/vpn-watchdog.sh
|
||||
bash scripts/vpn-watchdog.sh
|
||||
|
||||
# 5. Restore & Clean up
|
||||
echo "Restoring max connections..."
|
||||
curl -s -b "$COOKIE" -H "Referer: http://127.0.0.1:8081" -d 'json={"max_connecs": 500}' "${API}/app/setPreferences"
|
||||
|
||||
echo "Deleting torrent..."
|
||||
curl -s -b "$COOKIE" -d "hashes=${TARGET_HASH}&deleteFiles=true" "${API}/torrents/delete"
|
||||
|
||||
rm -f "$COOKIE"
|
||||
echo "Done."
|
||||
38
scripts/jellyfin-refresh.sh
Executable file
38
scripts/jellyfin-refresh.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/bin/sh
|
||||
# Triggered by Radarr/Sonarr on movie/episode import.
|
||||
# Calls Jellyfin's full library refresh to discover new files.
|
||||
# /Library/Media/Updated only refreshes EXISTING items — it cannot
|
||||
# discover new ones. /Library/Refresh performs a full scan.
|
||||
|
||||
JF_HOST="${JELLYFIN_HOST:-jellyfin}"
|
||||
JF_PORT="${JELLYFIN_PORT:-8096}"
|
||||
|
||||
# Read API key from mounted secrets file (preferred) or env var (fallback)
|
||||
if [ -f "/run/secrets/jellyfin_api_key.txt" ]; then
|
||||
JF_API_KEY=$(cat /run/secrets/jellyfin_api_key.txt)
|
||||
else
|
||||
JF_API_KEY="${JELLYFIN_API_KEY:-}"
|
||||
fi
|
||||
|
||||
# Ignore the Test event (Radarr sends this when clicking "Test")
|
||||
if [ "${radarr_eventtype}" = "Test" ] || [ "${sonarr_eventtype}" = "Test" ]; then
|
||||
echo "Jellyfin refresh: test event received, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$JF_API_KEY" ]; then
|
||||
echo "Jellyfin refresh: JELLYFIN_API_KEY not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "X-Emby-Token: ${JF_API_KEY}" \
|
||||
"http://${JF_HOST}:${JF_PORT}/Library/Refresh" 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||
echo "Jellyfin refresh: triggered successfully (HTTP ${HTTP_CODE})"
|
||||
else
|
||||
echo "Jellyfin refresh: failed (HTTP ${HTTP_CODE})" >&2
|
||||
exit 1
|
||||
fi
|
||||
135
scripts/vpn-watchdog.sh
Executable file
135
scripts/vpn-watchdog.sh
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
#!/bin/bash
|
||||
# VPN Watchdog for qBittorrent - Automated Tracker Routing Check
|
||||
# Monitors qBittorrent for generic broken-network symptoms.
|
||||
# Restarts gluetun if a failure is detected.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QBIT_HOST="127.0.0.1:8081"
|
||||
AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
|
||||
AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"
|
||||
QBIT_USER="$(cat "$AUTH_USERNAME_FILE")"
|
||||
QBIT_PASS="$(cat "$AUTH_PASSWORD_FILE")"
|
||||
COOKIE_JAR="/tmp/qbit_watchdog_cookie.txt"
|
||||
|
||||
# Heartbeat and log levels config
|
||||
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}"
|
||||
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-3600}"
|
||||
LOG_LEVEL="${LOG_LEVEL:-INFO}"
|
||||
|
||||
log_watchdog() {
|
||||
local level="$1"
|
||||
shift
|
||||
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*"
|
||||
}
|
||||
|
||||
log_debug() {
|
||||
if [ "$LOG_LEVEL" = "DEBUG" ]; then
|
||||
log_watchdog DEBUG "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initial startup log (only printed on the very first execution in this container lifecycle)
|
||||
if [ ! -f "/tmp/qbit_watchdog_started" ]; then
|
||||
log_watchdog INFO "VPN Watchdog active. Monitoring qBittorrent at ${QBIT_HOST} (Interval: ${WATCHDOG_INTERVAL}s, Heartbeat: ${HEARTBEAT_INTERVAL}s)."
|
||||
touch "/tmp/qbit_watchdog_started"
|
||||
fi
|
||||
|
||||
login_qbit() {
|
||||
local http_code
|
||||
http_code=$(curl -s -o /tmp/qbit_watchdog_login_body.txt -w "%{http_code}" -c "$COOKIE_JAR" \
|
||||
-H "Referer: http://${QBIT_HOST}" \
|
||||
--data "username=${QBIT_USER}&password=${QBIT_PASS}" \
|
||||
"http://${QBIT_HOST}/api/v2/auth/login" 2>/dev/null || true)
|
||||
|
||||
if [ "$http_code" != "204" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
awk '$6 ~ /^QBT_SID_/ { found = 1 } END { exit(found ? 0 : 1) }' "$COOKIE_JAR" 2>/dev/null
|
||||
}
|
||||
|
||||
api_get() {
|
||||
local endpoint="$1"
|
||||
curl -s -b "$COOKIE_JAR" "http://${QBIT_HOST}${endpoint}"
|
||||
}
|
||||
|
||||
if ! login_qbit; then
|
||||
log_watchdog ERROR "Failed to authenticate to qBittorrent API."
|
||||
echo "auth_failed" > "/tmp/qbit_watchdog_last_status"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TORRENTS=$(api_get "/api/v2/torrents/info")
|
||||
|
||||
# Check if we recovered from a previous failure
|
||||
last_status=""
|
||||
if [ -f "/tmp/qbit_watchdog_last_status" ]; then
|
||||
last_status=$(cat "/tmp/qbit_watchdog_last_status")
|
||||
fi
|
||||
|
||||
if [ -n "$last_status" ] && [ "$last_status" != "success" ]; then
|
||||
log_watchdog INFO "Watchdog recovered: Connection to qBittorrent API restored."
|
||||
fi
|
||||
echo "success" > "/tmp/qbit_watchdog_last_status"
|
||||
|
||||
num_torrents=0
|
||||
if [ -n "$TORRENTS" ] && [ "$TORRENTS" != "[]" ]; then
|
||||
num_torrents=$(echo "$TORRENTS" | jq '. | length')
|
||||
fi
|
||||
|
||||
# Heartbeat log (periodic verification message)
|
||||
current_time=$(date +%s)
|
||||
last_heartbeat=0
|
||||
if [ -f "/tmp/qbit_watchdog_last_heartbeat" ]; then
|
||||
last_heartbeat=$(cat "/tmp/qbit_watchdog_last_heartbeat")
|
||||
fi
|
||||
|
||||
time_diff=$((current_time - last_heartbeat))
|
||||
if [ "$time_diff" -ge "$HEARTBEAT_INTERVAL" ]; then
|
||||
log_watchdog INFO "Heartbeat: Watchdog is active. Connected to qBittorrent. Monitoring ${num_torrents} torrent(s). VPN routing is healthy."
|
||||
echo "$current_time" > "/tmp/qbit_watchdog_last_heartbeat"
|
||||
fi
|
||||
|
||||
if [ -z "$TORRENTS" ] || [ "$TORRENTS" = "[]" ]; then
|
||||
log_debug "No torrents found to check. Watchdog check completed silently."
|
||||
rm -f "$COOKIE_JAR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DETECTED_TORRENTS=()
|
||||
|
||||
while IFS='|' read -r hash name; do
|
||||
[ -z "$hash" ] && continue
|
||||
|
||||
PROPERTIES=$(api_get "/api/v2/torrents/properties?hash=${hash}")
|
||||
TRACKERS=$(api_get "/api/v2/torrents/trackers?hash=${hash}")
|
||||
|
||||
has_metadata=$(echo "$PROPERTIES" | jq -r '.has_metadata')
|
||||
nb_connections=$(echo "$PROPERTIES" | jq -r '.nb_connections')
|
||||
failed_trackers=$(echo "$TRACKERS" | jq '[.[] | select(.status == 4)] | length')
|
||||
operation_not_permitted=$(echo "$TRACKERS" | jq '[.[] | select(.status == 4 and (.msg | contains("Operation not permitted")))] | length')
|
||||
timed_out=$(echo "$TRACKERS" | jq '[.[] | select(.status == 4 and (.msg | contains("timed out")))] | length')
|
||||
host_not_found=$(echo "$TRACKERS" | jq '[.[] | select(.status == 4 and (.msg | contains("Host not found")))] | length')
|
||||
|
||||
if [ "$has_metadata" = "false" ] && [ "$nb_connections" -eq 0 ] && [ "$failed_trackers" -ge 3 ] && \
|
||||
{ [ "$operation_not_permitted" -gt 0 ] || [ "$timed_out" -gt 0 ] || [ "$host_not_found" -gt 0 ]; }; then
|
||||
DETECTED_TORRENTS+=("$name")
|
||||
fi
|
||||
done < <(echo "$TORRENTS" | jq -r '.[] | [.hash, .name] | @tsv' | tr '\t' '|')
|
||||
|
||||
rm -f "$COOKIE_JAR"
|
||||
|
||||
if [ ${#DETECTED_TORRENTS[@]} -gt 0 ]; then
|
||||
log_watchdog DETECTED "VPN routing failure! Torrents with broken-network symptoms found:"
|
||||
for torrent_name in "${DETECTED_TORRENTS[@]}"; do
|
||||
log_watchdog DETECTED "$torrent_name"
|
||||
done
|
||||
log_watchdog ACTION "Triggering Gluetun restart to fetch a new VPN IP..."
|
||||
echo "routing_failed" > "/tmp/qbit_watchdog_last_status"
|
||||
docker restart gluetun
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_debug "Watchdog check completed successfully. Monitored ${num_torrents} torrents. All healthy."
|
||||
exit 0
|
||||
19
templates/envsubst.env
Normal file
19
templates/envsubst.env
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Base configuration
|
||||
PUID=${PUID}
|
||||
PGID=${PGID}
|
||||
MEDIA_DIRECTORY=${MEDIA_DIRECTORY}
|
||||
INSTALL_DIRECTORY=${INSTALL_DIRECTORY}
|
||||
MEDIA_SERVICE=${MEDIA_SERVICE}
|
||||
TZ=${TZ}
|
||||
|
||||
# VPN configuration
|
||||
VPN_ENABLED=${VPN_ENABLED}
|
||||
VPN_SERVICE=${VPN_SERVICE}
|
||||
VPN_TYPE=${VPN_TYPE}
|
||||
WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
||||
PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY}
|
||||
VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING}
|
||||
|
||||
# API Host for configuration script
|
||||
API_HOST=${API_HOST}
|
||||
|
||||
146
templates/recyclarr-full_hd.yml
Normal file
146
templates/recyclarr-full_hd.yml
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# yaml-language-server: $schema=https://schemas.recyclarr.dev/v8/config-schema.json
|
||||
# HD Bluray + WEB profile (1080p) — used when user chooses non-4K
|
||||
# Template: recyclarr-full_hd.yml
|
||||
|
||||
radarr:
|
||||
radarr-instance:
|
||||
base_url: http://radarr:7878
|
||||
api_key: ${RADARR_API_KEY}
|
||||
|
||||
quality_definition:
|
||||
type: movie
|
||||
|
||||
quality_profiles:
|
||||
- name: HD Bluray + WEB
|
||||
reset_unmatched_scores:
|
||||
enabled: true
|
||||
upgrade:
|
||||
allowed: true
|
||||
until_quality: Bluray-1080p
|
||||
until_score: 10000
|
||||
min_format_score: 0
|
||||
quality_sort: top
|
||||
qualities:
|
||||
- name: Bluray-1080p
|
||||
- name: WEB 1080p
|
||||
qualities:
|
||||
- WEBDL-1080p
|
||||
- WEBRip-1080p
|
||||
- name: Bluray-720p
|
||||
|
||||
custom_formats:
|
||||
# HQ Release Groups + Misc + Unwanted
|
||||
- trash_ids:
|
||||
- ed27ebfef2f323e964fb1f61391bcb35 # HD Bluray Tier 01
|
||||
- c20c8647f2746a1f4c4262b0fbbeeeae # HD Bluray Tier 02
|
||||
- 5608c71bcebba0a5e666223bae8c9227 # HD Bluray Tier 03
|
||||
- c20f169ef63c5f40c2def54abaf4438e # WEB Tier 01
|
||||
- 403816d65392c79236dcb6dd591aeda4 # WEB Tier 02
|
||||
- af94e0fe497124d1f9ce732069ec8c3b # WEB Tier 03
|
||||
- e7718d7a3ce595f289bfee26adc178f5 # Repack/Proper
|
||||
- ae43b294509409a6a13919dedd4764c4 # Repack2
|
||||
- 5caaaa1c08c1742aa4342d8c4cc463f2 # Repack3
|
||||
- ed38b889b31be83fda192888e2286d83 # BR-DISK
|
||||
- e6886871085226c3da1830830146846c # Generated Dynamic HDR
|
||||
- 90a6f9a284dff5103f6346090e6280c8 # LQ
|
||||
- e204b80c87be9497a8a6eaff48f72905 # LQ (Release Title)
|
||||
- dc98083864ea246d05a42df0d05f81cc # x265 (HD)
|
||||
- b8cd450cbfa689c0259a01d9e29ba3d6 # 3D
|
||||
- bfd8eb01832d646a0a89c4deb46f8564 # Upscaled
|
||||
- 0a3f082873eb454bde444150b70253cc # Extras
|
||||
- 712d74cd88bceb883ee32f773656b1f5 # Sing-Along Versions
|
||||
- cae4ca30163749b891686f95532519bd # AV1
|
||||
- cc5e51a9e85a6296ceefe097a77f12f4 # BCORE
|
||||
- 16622a6911d1ab5d5b8b713d5b0036d4 # CRiT
|
||||
- 2a6039655313bf5dab1e43523b62c374 # MA
|
||||
assign_scores_to:
|
||||
- name: HD Bluray + WEB
|
||||
|
||||
# Streaming Services
|
||||
- trash_ids:
|
||||
- b3b3a6ac74ecbd56bcdbefa4799fb9df # AMZN
|
||||
- 40e9380490e748672c2522eaaeb692f7 # ATVP
|
||||
- 84272245b2988854bfb76a16e60baea5 # DSNP
|
||||
- 509e5f41146e278f9eab1ddaceb34515 # HBO
|
||||
- 5763d1b0ce84aff3b21038eea8e9b8ad # HMAX
|
||||
- 526d445d4c16214309f0fd2b3be18a89 # Hulu
|
||||
- e0ec9672be6cac914ffad34a6b077209 # iT
|
||||
- 6a061313d22e51e0f25b7cd4dc065233 # MAX
|
||||
- 170b1d363bd8516fbf3a3eb05d4faff6 # NF
|
||||
- c9fd353f8f5f1baf56dc601c4cb29920 # PCOK
|
||||
- e36a0ba1bc902b26ee40818a1d59b8bd # PMTP
|
||||
- c2863d2a50c9acad1fb50e53ece60817 # STAN
|
||||
assign_scores_to:
|
||||
- name: HD Bluray + WEB
|
||||
|
||||
# Audio - Uncomment to enable Advanced Audio Formats
|
||||
- trash_ids:
|
||||
# - 496f355514737f7d83bf7aa4d24f8169 # TrueHD Atmos
|
||||
# - 2f22d89048b01681dde8afe203bf2e95 # DTS X
|
||||
# - 417804f7f2c4308c1f4c5d380d4c4475 # ATMOS (undefined)
|
||||
# - 1af239278386be2919e1bcee0bde047e # DD+ ATMOS
|
||||
# - 3cafb66171b47f226146a0770576870f # TrueHD
|
||||
# - dcf3ec6938fa32445f590a4da84256cd # DTS-HD MA
|
||||
# - a570d4a0e56a2874b64e5bfa55202a1b # FLAC
|
||||
# - e7c2fcae07cbada050a0af3357491d7b # PCM
|
||||
# - 8e109e50e0a0b83a5098b056e13bf6db # DTS-HD HRA
|
||||
# - 185f1dd7264c4562b9022d963ac37424 # DD+
|
||||
# - f9f847ac70a0af62ea4a08280b859636 # DTS-ES
|
||||
# - 1c1a4c5e823891c75bc50380a6866f73 # DTS
|
||||
# - 240770601cc226190c367ef59aba7463 # AAC
|
||||
# - c2998bd0d90ed5621d8df281e839436e # DD
|
||||
assign_scores_to:
|
||||
- name: HD Bluray + WEB
|
||||
|
||||
# Movie Versions - Uncomment to prefer specific versions
|
||||
- trash_ids:
|
||||
# - 570bc9ebecd92723d2d21500f4be314c # Remaster
|
||||
# - eca37840c13c6ef2dd0262b141a5482f # 4K Remaster
|
||||
# - e0c07d59beb37348e975a930d5e50319 # Criterion Collection
|
||||
# - 9d27d9d2181838f76dee150882bdc58c # Masters of Cinema
|
||||
# - db9b4c4b53d312a3ca5f1378f6440fc9 # Vinegar Syndrome
|
||||
# - 957d0f44b592285f26449575e8b1167e # Special Edition
|
||||
# - eecf3a857724171f968a66cb5719e152 # IMAX
|
||||
# - 9f6cbff8cfe4ebbc1bde14c7b7bec0de # IMAX Enhanced
|
||||
assign_scores_to:
|
||||
- name: HD Bluray + WEB
|
||||
|
||||
# Optional Unwanted
|
||||
- trash_ids:
|
||||
# - b6832f586342ef70d9c128d40c07b872 # Bad Dual Groups
|
||||
# - cc444569854e9de0b084ab2b8b1532b2 # Black and White Editions
|
||||
# - ae9b7c9ebde1f3bd336a8cbd1ec4c5e5 # No-RlsGroup
|
||||
# - 7357cf5161efbf8c4d5d0c30b4815ee2 # Obfuscated
|
||||
# - 5c44f52a8714fdd79bb4d98e2673be1f # Retags
|
||||
# - f537cf427b64c38c8e36298f657e4828 # Scene
|
||||
assign_scores_to:
|
||||
- name: HD Bluray + WEB
|
||||
|
||||
sonarr:
|
||||
sonarr-instance:
|
||||
base_url: http://sonarr:8989
|
||||
api_key: ${SONARR_API_KEY}
|
||||
|
||||
quality_definition:
|
||||
type: series
|
||||
|
||||
quality_profiles:
|
||||
- trash_id: a5ffae4bbda71217e132ce4ed37367c0 # WEB-1080p
|
||||
reset_unmatched_scores:
|
||||
enabled: true
|
||||
|
||||
custom_format_groups:
|
||||
add:
|
||||
- trash_id: 7a2ba4144a594826d4ac03515b371937 # [Optional] Streaming Services
|
||||
select_all: true
|
||||
|
||||
custom_formats:
|
||||
# Optional
|
||||
- trash_ids:
|
||||
# - 32b367365729d530ca1c124a0b180c64 # Bad Dual Groups
|
||||
# - 82d40da2bc6923f41e14394075dd4b03 # No-RlsGroup
|
||||
# - e1a997ddb54e3ecbfe06341ad323c458 # Obfuscated
|
||||
# - 06d66ab109d4d2eddb2794d21526d140 # Retags
|
||||
# - 1b3994c551cbb92a2c781af061f4ab44 # Scene
|
||||
assign_scores_to:
|
||||
- name: WEB-1080p
|
||||
152
templates/recyclarr-ultra_hd.yml
Normal file
152
templates/recyclarr-ultra_hd.yml
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# yaml-language-server: $schema=https://schemas.recyclarr.dev/v8/config-schema.json
|
||||
# UHD Bluray + WEB profile (4K) — used when user chooses Ultra-HD
|
||||
# Template: recyclarr-ultra_hd.yml
|
||||
|
||||
radarr:
|
||||
radarr-instance:
|
||||
base_url: http://radarr:7878
|
||||
api_key: ${RADARR_API_KEY}
|
||||
|
||||
quality_definition:
|
||||
type: movie
|
||||
|
||||
quality_profiles:
|
||||
- trash_id: 64fb5f9858489bdac2af690e27c8f42f # UHD Bluray + WEB
|
||||
reset_unmatched_scores:
|
||||
enabled: true
|
||||
|
||||
custom_format_groups:
|
||||
add:
|
||||
- trash_id: ff204bbcecdd487d1cefcefdbf0c278d # [Optional] Golden Rule UHD
|
||||
select:
|
||||
- dc98083864ea246d05a42df0d05f81cc # x265 (HD)
|
||||
- 839bea857ed2c0a8e084f3cbdbd65ecb # x265 (no HDR/DV)
|
||||
- trash_id: a3ac6af01d78e4f21fcb75f601ac96df # [Unwanted] Unwanted Formats
|
||||
select:
|
||||
- b8cd450cbfa689c0259a01d9e29ba3d6 # 3D
|
||||
- cae4ca30163749b891686f95532519bd # AV1
|
||||
- b6832f586342ef70d9c128d40c07b872 # Bad Dual Groups
|
||||
- cc444569854e9de0b084ab2b8b1532b2 # Black and White Editions
|
||||
- ed38b889b31be83fda192888e2286d83 # BR-DISK
|
||||
- 0a3f082873eb454bde444150b70253cc # Extras
|
||||
- e6886871085226c3da1830830146846c # Generated Dynamic HDR
|
||||
- 90a6f9a284dff5103f6346090e6280c8 # LQ
|
||||
- e204b80c87be9497a8a6eaff48f72905 # LQ (Release Title)
|
||||
- 712d74cd88bceb883ee32f773656b1f5 # Sing-Along Versions
|
||||
- bfd8eb01832d646a0a89c4deb46f8564 # Upscaled
|
||||
|
||||
custom_formats:
|
||||
# Audio - Uncomment to enable Advanced Audio Formats
|
||||
- trash_ids:
|
||||
# - 496f355514737f7d83bf7aa4d24f8169 # TrueHD Atmos
|
||||
# - 2f22d89048b01681dde8afe203bf2e95 # DTS X
|
||||
# - 417804f7f2c4308c1f4c5d380d4c4475 # ATMOS (undefined)
|
||||
# - 1af239278386be2919e1bcee0bde047e # DD+ ATMOS
|
||||
# - 3cafb66171b47f226146a0770576870f # TrueHD
|
||||
# - dcf3ec6938fa32445f590a4da84256cd # DTS-HD MA
|
||||
# - a570d4a0e56a2874b64e5bfa55202a1b # FLAC
|
||||
# - e7c2fcae07cbada050a0af3357491d7b # PCM
|
||||
# - 8e109e50e0a0b83a5098b056e13bf6db # DTS-HD HRA
|
||||
# - 185f1dd7264c4562b9022d963ac37424 # DD+
|
||||
# - f9f847ac70a0af62ea4a08280b859636 # DTS-ES
|
||||
# - 1c1a4c5e823891c75bc50380a6866f73 # DTS
|
||||
# - 240770601cc226190c367ef59aba7463 # AAC
|
||||
# - c2998bd0d90ed5621d8df281e839436e # DD
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
# Movie Versions - Uncomment to prefer specific versions
|
||||
- trash_ids:
|
||||
# - 570bc9ebecd92723d2d21500f4be314c # Remaster
|
||||
# - eca37840c13c6ef2dd0262b141a5482f # 4K Remaster
|
||||
# - e0c07d59beb37348e975a930d5e50319 # Criterion Collection
|
||||
# - 9d27d9d2181838f76dee150882bdc58c # Masters of Cinema
|
||||
# - db9b4c4b53d312a3ca5f1378f6440fc9 # Vinegar Syndrome
|
||||
# - 957d0f44b592285f26449575e8b1167e # Special Edition
|
||||
# - eecf3a857724171f968a66cb5719e152 # IMAX
|
||||
# - 9f6cbff8cfe4ebbc1bde14c7b7bec0de # IMAX Enhanced
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
# Optional Unwanted
|
||||
- trash_ids:
|
||||
# - b6832f586342ef70d9c128d40c07b872 # Bad Dual Groups
|
||||
# - cc444569854e9de0b084ab2b8b1532b2 # Black and White Editions
|
||||
# - ae9b7c9ebde1f3bd336a8cbd1ec4c5e5 # No-RlsGroup
|
||||
# - 7357cf5161efbf8c4d5d0c30b4815ee2 # Obfuscated
|
||||
# - 5c44f52a8714fdd79bb4d98e2673be1f # Retags
|
||||
# - f537cf427b64c38c8e36298f657e4828 # Scene
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
# HDR/DV Preferences
|
||||
- trash_ids:
|
||||
# Uncomment to allow x265 HD releases with HDR/DV
|
||||
# - dc98083864ea246d05a42df0d05f81cc # x265 (HD)
|
||||
# assign_scores_to:
|
||||
# - name: UHD Bluray + WEB
|
||||
# score: 0
|
||||
# - trash_ids:
|
||||
# - 839bea857ed2c0a8e084f3cbdbd65ecb # x265 (no HDR/DV)
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
- trash_ids:
|
||||
# Comment out the next line if all your setups are fully DV compatible
|
||||
- 923b6abef9b17f937fab56cfcf89e1f1 # DV (w/o HDR fallback)
|
||||
|
||||
# HDR10+ Boost - Uncomment if you want to prefer HDR10+ releases
|
||||
# DV Boost - Uncomment if you want to prefer DV releases
|
||||
# Uncomment both if you want to prefer both (DV HDR10+)
|
||||
# - b337d6812e06c200ec9a2d3cfa9d20a7 # DV Boost
|
||||
# - caa37d0df9c348912df1fb1d88f9273a # HDR10+ Boost
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
# SDR Blocking - Only use ONE of the following
|
||||
- trash_ids:
|
||||
- 9c38ebb7384dada637be8899efa68e6f # SDR (block ALL SDR releases)
|
||||
# - 25c12f78430a3a23413652cbd1d48d77 # SDR (no WEBDL) - allow SDR WEB only
|
||||
assign_scores_to:
|
||||
- name: UHD Bluray + WEB
|
||||
|
||||
sonarr:
|
||||
sonarr-instance:
|
||||
base_url: http://sonarr:8989
|
||||
api_key: ${SONARR_API_KEY}
|
||||
|
||||
quality_definition:
|
||||
type: series
|
||||
|
||||
quality_profiles:
|
||||
- trash_id: a5ffae4bbda71217e132ce4ed37367c0 # WEB-1080p
|
||||
reset_unmatched_scores:
|
||||
enabled: true
|
||||
|
||||
custom_format_groups:
|
||||
add:
|
||||
- trash_id: 7a2ba4144a594826d4ac03515b371937 # [Optional] Streaming Services
|
||||
select_all: true
|
||||
|
||||
custom_formats:
|
||||
# Optional
|
||||
- trash_ids:
|
||||
# - 32b367365729d530ca1c124a0b180c64 # Bad Dual Groups
|
||||
# - 82d40da2bc6923f41e14394075dd4b03 # No-RlsGroup
|
||||
# - e1a997ddb54e3ecbfe06341ad323c458 # Obfuscated
|
||||
# - 06d66ab109d4d2eddb2794d21526d140 # Retags
|
||||
# - 1b3994c551cbb92a2c781af061f4ab44 # Scene
|
||||
assign_scores_to:
|
||||
- name: WEB-1080p
|
||||
|
||||
# x265 HD handling
|
||||
- trash_ids:
|
||||
# Uncomment to allow x265 HD releases with HDR/DV
|
||||
# - 47435ece6b99a0b477caf360e79ba0bb # x265 (HD)
|
||||
# assign_scores_to:
|
||||
# - name: WEB-1080p
|
||||
# score: 0
|
||||
# - trash_ids:
|
||||
# - 9b64dff695c2115facf1b6ea59c9bd07 # x265 (no HDR/DV)
|
||||
assign_scores_to:
|
||||
- name: WEB-1080p
|
||||
31
test_integration.sh
Normal file
31
test_integration.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/bin/bash
|
||||
source /home/soulis/assemblrr/.assemblrr-config
|
||||
echo "[1] Authenticating..."
|
||||
curl -s -c /tmp/qbit_test_cookie -d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" http://localhost:8081/api/v2/auth/login
|
||||
|
||||
echo "[2] Adding Ubuntu torrent..."
|
||||
curl -s -b /tmp/qbit_test_cookie -d "urls=magnet:?xt=urn:btih:60fb0fdfb5eb78cffef4caebe5e5ce4e8031d279&dn=ubuntu-22.04" http://localhost:8081/api/v2/torrents/add
|
||||
|
||||
echo "[3] Waiting 15 seconds to connect to trackers..."
|
||||
sleep 15
|
||||
|
||||
curl -s -b /tmp/qbit_test_cookie http://localhost:8081/api/v2/torrents/info | jq -r '.[] | {name, state, num_complete, num_seeds}'
|
||||
|
||||
echo "[4] Sabotaging connections (max_connecs = 0)"
|
||||
curl -s -b /tmp/qbit_test_cookie -d 'json={"max_connecs": 0}' http://localhost:8081/api/v2/app/setPreferences
|
||||
|
||||
echo "[5] Waiting 15 seconds for stall..."
|
||||
sleep 15
|
||||
|
||||
curl -s -b /tmp/qbit_test_cookie http://localhost:8081/api/v2/torrents/info | jq -r '.[] | {name, state, num_complete, num_seeds}'
|
||||
|
||||
echo ">>> RUNNING WATCHDOG SCRIPT LIVE <<<"
|
||||
export AUTH_USERNAME AUTH_PASSWORD
|
||||
bash scripts/vpn-watchdog.sh
|
||||
|
||||
echo "[6] Restoring connections..."
|
||||
curl -s -b /tmp/qbit_test_cookie -d 'json={"max_connecs": 500}' http://localhost:8081/api/v2/app/setPreferences
|
||||
|
||||
echo "[7] Deleting test torrent..."
|
||||
curl -s -b /tmp/qbit_test_cookie -d "hashes=60fb0fdfb5eb78cffef4caebe5e5ce4e8031d279&deleteFiles=true" http://localhost:8081/api/v2/torrents/delete
|
||||
rm /tmp/qbit_test_cookie
|
||||
Loading…
Add table
Add a link
Reference in a new issue