Initial commit

Assemblrr: Docker-based bootstrap and wiring for a self-hosted media stack.
This commit is contained in:
soulis-1256 2026-08-04 23:42:34 +03:00
commit e9f356cffd
No known key found for this signature in database
36 changed files with 6664 additions and 0 deletions

36
.env.example Normal file
View 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
View file

@ -0,0 +1 @@
custom: ['https://www.paypal.com/paypalme/soulis1256']

13
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

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

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

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

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

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

View 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

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