fix huntarr-install naming issues

This commit is contained in:
bilulib
2025-05-09 19:19:04 +02:00
parent b32ed526e0
commit 699bf0a6f3
154 changed files with 34570 additions and 7 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
Huntarr.io-6.3.6/

15
Huntarr.io-6.3.6/.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
paypal: # https://www.paypal.com/donate?hosted_button_id=58AYJ68VVMGSC

View File

@@ -0,0 +1,110 @@
name: Docker Build and Push
on:
push:
branches:
- '*' # This will trigger on any branch push
tags:
- "*" # This will trigger on any tag push
pull_request:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1) Check out your repository code with full depth
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# 2) List files to verify huntarr.py is present
- name: List files in directory
run: ls -la
# 3) Set up QEMU for multi-architecture builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: arm64,amd64
# 4) Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# 5) Log in to Docker Hub
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
# 6) Extract metadata (version, branch name, etc.)
- name: Extract metadata
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "IS_TAG=true" >> $GITHUB_OUTPUT
else
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
echo "IS_TAG=false" >> $GITHUB_OUTPUT
fi
# 7a) Build & Push if on 'main' branch
- name: Build and Push (main)
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/huntarr:latest
huntarr/huntarr:${{ github.sha }}
# 7b) Build & Push if on 'dev' branch
- name: Build and Push (dev)
if: github.ref == 'refs/heads/dev' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/huntarr:dev
huntarr/huntarr:${{ github.sha }}
# 7c) Build & Push if it's a tag/release
- name: Build and Push (release)
if: steps.meta.outputs.IS_TAG == 'true' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/huntarr:${{ steps.meta.outputs.VERSION }}
huntarr/huntarr:latest
# 7d) Build & Push for any other branch
- name: Build and Push (feature branch)
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' && steps.meta.outputs.IS_TAG != 'true' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/huntarr:${{ steps.meta.outputs.BRANCH }}
huntarr/huntarr:${{ github.sha }}
# 7e) Just build on pull requests
- name: Build (PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: false
platforms: linux/amd64,linux/arm64

230
Huntarr.io-6.3.6/.gitignore vendored Normal file
View File

@@ -0,0 +1,230 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# vertual enviornments
.venv/
# React files
.idea/
.vscode/
build/
*.tgz
template/src/__tests__/__snapshots__/
lerna-debug.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.changelog
.npm/
.DS_STORE
node_modules
scripts/flow/*/.flowconfig
.flowconfig
*~
*.pyc
.grunt
_SpecRunner.html
__benchmarks__
build/
remote-repo/
coverage/
.module-cache
fixtures/dom/public/react-dom.js
fixtures/dom/public/react.js
test/the-files-to-test.generated.js
*.log*
chrome-user-data
*.sublime-project
*.sublime-workspace
.idea
*.iml
.vscode
*.swp
*.swo
packages/react-devtools-core/dist
packages/react-devtools-extensions/chrome/build
packages/react-devtools-extensions/chrome/*.crx
packages/react-devtools-extensions/chrome/*.pem
packages/react-devtools-extensions/firefox/build
packages/react-devtools-extensions/firefox/*.xpi
packages/react-devtools-extensions/firefox/*.pem
packages/react-devtools-extensions/shared/build
packages/react-devtools-extensions/.tempUserDataDir
packages/react-devtools-fusebox/dist
packages/react-devtools-inline/dist
packages/react-devtools-shell/dist
packages/react-devtools-timeline/dist
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

View File

@@ -0,0 +1,24 @@
FROM python:3.9-slim
WORKDIR /app
# Install required packages from the root requirements file
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . /app/
# Create necessary directories
RUN mkdir -p /config/settings /config/stateful /config/user /config/logs
RUN chmod -R 755 /config
# Set environment variables
ENV PYTHONPATH=/app
# ENV APP_TYPE=sonarr # APP_TYPE is likely managed via config now, remove if not needed
# Expose port
EXPOSE 9705
# Run the main application using the new entry point
CMD ["python3", "main.py"]

674
Huntarr.io-6.3.6/LICENSE Normal file
View File

@@ -0,0 +1,674 @@
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
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

230
Huntarr.io-6.3.6/README.md Normal file
View File

@@ -0,0 +1,230 @@
<h2 align="center">Huntarr - Find Missing & Upgrade Media Items</h2>
<p align="center">
<img src="frontend/static/logo/128.png" alt="Huntarr Logo" width="100" height="100">
</p>
---
<h2 align="center">Want to Help? Click the Star in the Upper-Right Corner! ⭐</h2>
<img src="https://github.com/user-attachments/assets/1ea6ca9c-0909-4b6a-b573-f778b65af8b2" width="100%"/>
| Application | Status |
| :---------- | :------------ |
| Sonarr | **✅ Ready** |
| Radarr | **✅ Ready** |
| Lidarr | **✅ Ready** |
| Readarr | **✅ Ready** |
| Whisparr v2 | **✅ Ready** |
| Whisparr v3 | **✅ Ready** |
| Bazarr | **❌ Not Ready** |
Keep in mind this is very early in program development. If you have a very special hand picked collection (because some users are extra special), test before you deploy.
## Table of Contents
- [Overview](#overview)
- [Other Projects](#other-projects)
- [Community](#community)
- [Indexers Approving of Huntarr](#indexers-approving-of-huntarr)
- [How It Works](#how-it-works)
- [Web Interface](#web-interface)
- [How to Access](#how-to-access)
- [Web UI Settings](#web-ui-settings)
- [Volume Mapping](#volume-mapping)
- [Installation Methods](#installation-methods)
- [Docker Run](#docker-run)
- [Docker Compose](#docker-compose)
- [Unraid Users](#unraid-users)
- [Tips](#tips)
- [Troubleshooting](#troubleshooting)
- [Change Log](#change-log)
## Overview
This application continually searches your media libraries for missing content and items that need quality upgrades. It automatically triggers searches for both missing items and those below your quality cutoff. It's designed to run continuously while being gentle on your indexers, helping you gradually complete your media collection with the best available quality.
For detailed documentation, please visit our [Wiki](https://github.com/plexguide/Huntarr/wiki).
## Other Projects
* [Unraid Intel ARC Deployment](https://github.com/plexguide/Unraid_Intel-ARC_Deployment) - Convert videos to AV1 Format (I've saved 325TB encoding to AV1)
* Visit [PlexGuide](https://plexguide.com) for more great scripts
## Community
<p align="center">
Join the community on Discord!
<br>
<a href="https://discord.gg/VQbZCGzQsn" target="_blank">
<img src="frontend/static/images/discord.png" alt="Discord" width="48" height="48">
</a>
</p>
## PayPal Donations For My Daughter's College Fund
My 12-year-old daughter is passionate about singing, dancing, and exploring STEM. She consistently earns A-B honors! Every donation goes directly into her college fund!
[![Donate with PayPal button](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/donate?hosted_button_id=58AYJ68VVMGSC)
## Indexers Approving of Huntarr:
* https://ninjacentral.co.za
## How It Works
### 🔄 Continuous Automation Cycle
#### 1⃣ Connect & Analyze
Huntarr connects to your Sonarr/Radarr/Lidarr/Readarr instance and analyzes your media library to identify both missing content and potential quality upgrades.
#### 2⃣ Hunt Missing Content
- 📊 **Smart Selection:** Choose between random or sequential processing
- 🔍 **Efficient Refreshing:** Optionally skip metadata refresh to reduce disk I/O
- 🔮 **Future-Aware:** Automatically skip content with future release dates
- 🎯 **Precise Control:** Set exactly how many items to process per cycle
#### 3⃣ Hunt Quality Upgrades
- ⬆️ **Quality Improvement:** Find content below your quality cutoff settings
- 📦 **Batch Processing:** Configure exactly how many upgrades to process at once
- 📚 **Large Library Support:** Smart pagination handles even massive libraries
- 🔀 **Flexible Modes:** Choose between random or sequential processing
#### 4⃣ State Management
- 📝 **History Tracking:** Remembers which items have been processed
- 💾 **Persistent Storage:** State data is saved in the `/config` directory
- ⏱️ **Automatic Reset:** State is cleared after your configured time period (default: 7 days)
#### 5⃣ Repeat & Rest
Huntarr waits for your configured interval (adjustable in settings) before starting the next cycle. This ensures your indexers aren't overloaded while maintaining continuous improvement of your library.
## Web Interface
Huntarr's live homepage will provide you statics about how many hunts have been pursed regarding missing media and upgrade searches! Note: Numbers reflected are but all required for testing... damn you Whisparr!
<p align="center">
<img width="100%" alt="image" src="https://github.com/user-attachments/assets/db725ad6-3009-4835-ab44-289dda80d385" />
<br>
<em>Homepage</em>
</p>
Huntarr includes a real-time log viewer and settings management web interface that allows you to monitor and configure its operation directly from your browser.
<p align="center">
<img width="100%" alt="image" src="https://github.com/user-attachments/assets/438e2013-2a54-4cf2-8418-63b0c6124730" />
<br>
<em>Logger UI</em>
</p>
### How to Access
The web interface is available on port 9705. Simply navigate to:
```
http://YOUR_SERVER_IP:9705
```
The URL will be displayed in the logs when Huntarr starts, using the same hostname you configured for your API_URL.
### Web UI Settings
The web interface allows you to configure all of Huntarr's settings:
<p align="center">
<img width="930" alt="image" src="https://github.com/user-attachments/assets/06003622-0af3-4398-a46d-0fa4fb1f455b" />
<br>
<em>Settings UI</em>
</p>
### Volume Mapping
To ensure data persistence, make sure you map the `/config` directory to a persistent volume on your host system:
```bash
-v /your-path/appdata/huntarr:/config
```
---
## Installation Methods
### Docker Run
The simplest way to run Huntarr is via Docker (all configuration is done via the web UI):
```bash
docker run -d --name huntarr \
--restart always \
-p 9705:9705 \
-v /your-path/huntarr:/config \
-e TZ=America/New_York \
huntarr/huntarr:latest
```
To check on the status of the program, you can use the web interface at http://YOUR_SERVER_IP:9705 or check the logs with:
```bash
docker logs huntarr
```
### Docker Compose
For those who prefer Docker Compose, add this to your `docker-compose.yml` file:
```yaml
services:
huntarr:
image: huntarr/huntarr:latest
container_name: huntarr
restart: always
ports:
- "9705:9705"
volumes:
- /your-path/huntarr:/config
environment:
- TZ=America/New_York
```
Then run:
```bash
docker-compose up -d huntarr
```
### Unraid Users
Run this from Command Line in Unraid:
```bash
docker run -d --name huntarr \
--restart always \
-p 9705:9705 \
-v /mnt/user/appdata/huntarr:/config \
-e TZ=America/New_York \
huntarr/huntarr:latest
```
## Tips
- **First-Time Setup**: Navigate to the web interface after installation to create your admin account with 2FA option
- **API Connections**: Configure connections to your *Arr applications through the dedicated settings pages
- **Search Frequency**: Adjust Sleep Duration (default: 900 seconds) based on your indexer's rate limits.
- **Batch Processing**: Set Hunt Missing and Upgrade values to control how many items are processed per cycle
- **Queue Management**: Use Minimum Download Queue Size to pause searching when downloads are backed up
- **Skip Processing**: Enable Skip Series/Movie Refresh to significantly reduce disk I/O and database load
- **Future Content**: Keep Skip Future Items enabled to avoid searching for unreleased content
- **Authentication**: Enable two-factor authentication for additional security on your Huntarr instance
## Troubleshooting
- **API Connection Issues**: Verify your API key and URL in the Settings page (check for missing http:// or https://)
- **Config URLs**: It is best practice to omit the trailing slash (/) at the end of the URL for each service. i.e. For Sonarr, instead of http://10.10.10.1:8989/ use http://10.10.10.1:8989. This is the most common cause of errors seen in the log each time a cycle runs.
- **Authentication Problems**: If you forget your password, delete `/config/user/credentials.json` and restart
- **Two-Factor Authentication**: If locked out of 2FA, remove credentials file to reset your account
- **Web Interface Not Loading**: Confirm port 9705 is correctly mapped and not blocked by firewalls
- **Logs Not Showing**: Check permissions on the `/config/logs/` directory inside your container
- **Missing State Data**: State files in `/config/stateful/` track processed items; verify permissions
- **Docker Volume Issues**: Ensure your volume mount for `/config` has correct permissions and ownership
- **Command Timeouts**: Adjust command_wait_attempts and command_wait_delay in advanced settings
- **Debug Information**: Enable Debug Mode temporarily to see detailed API responses in the logs
## Change Log
Visit: https://github.com/plexguide/Huntarr/releases/

View File

@@ -0,0 +1,17 @@
services:
huntarr:
build:
context: .
dockerfile: Dockerfile
container_name: huntarr
ports:
- "9705:9705"
volumes:
- huntarr-config:/config
environment:
- TZ=America/New_York
restart: unless-stopped
volumes:
huntarr-config:
name: huntarr-config

View File

@@ -0,0 +1,123 @@
import fs from 'fs';
import path from 'path';
import { json } from '@sveltejs/kit';
import { invalidateCache } from '$lib/config'; // Assuming config.js handles huntarr.json read/write
const CONFIG_FILE = path.resolve('huntarr.json'); // Path to the main config file
const DEFAULT_CONFIGS_DIR = path.resolve('src/primary/default_configs'); // Path to new default configs
// Helper function to load default settings for a specific app
function loadDefaultAppSettings(appName) {
const defaultFile = path.join(DEFAULT_CONFIGS_DIR, `${appName}.json`);
try {
if (fs.existsSync(defaultFile)) {
const data = fs.readFileSync(defaultFile, 'utf8');
return JSON.parse(data);
} else {
console.warn(`Default settings file not found for app: ${appName}`);
return {};
}
} catch (error) {
console.error(`Error loading default settings for ${appName}:`, error);
return {};
}
}
// Helper function to get all default settings combined
function getAllDefaultSettings() {
const allDefaults = {};
const appNames = ['sonarr', 'radarr', 'lidarr', 'readarr']; // Define known apps
appNames.forEach(appName => {
const defaults = loadDefaultAppSettings(appName);
if (Object.keys(defaults).length > 0) {
allDefaults[appName] = defaults;
}
});
// Add a default 'ui' section if needed by the frontend directly
// allDefaults.ui = { theme: 'dark', ... };
return allDefaults;
}
// Helper to read config, creating it from defaults if it doesn't exist
function readConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
// Handle potentially empty file
if (data.trim() === '') {
console.warn(`Config file ${CONFIG_FILE} is empty. Creating with defaults.`);
const defaultSettings = getAllDefaultSettings();
writeConfig(defaultSettings); // Write defaults back
return defaultSettings;
}
let parsedData = JSON.parse(data);
// Optional: Merge with defaults to ensure all keys exist?
// This might be better handled client-side or on save.
// For now, just return what's in the file. If file is missing/empty, defaults are used.
// Remove legacy sections if present
if (parsedData.global) delete parsedData.global;
// Keep UI section if it exists and is used
// if (parsedData.ui) ...
return parsedData;
} else {
// Create file with defaults if it doesn't exist
console.log(`Config file ${CONFIG_FILE} not found. Creating with defaults.`);
const defaultSettings = getAllDefaultSettings();
writeConfig(defaultSettings); // Write defaults to the file
return defaultSettings;
}
} catch (error) {
console.error('Error reading or parsing config file:', error);
// Fallback to defaults in case of error
return getAllDefaultSettings();
}
}
// Helper to write config
function writeConfig(config) {
try {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
invalidateCache(); // Invalidate cache after writing
return true;
} catch (error) {
console.error('Error writing config file:', error);
return false;
}
}
// GET request handler
export async function GET() {
const config = readConfig();
return json(config);
}
// POST request handler
export async function POST({ request }) {
try {
const newSettings = await request.json();
// Optional: Validate or sanitize newSettings here
// Read current config to potentially merge or just overwrite
// let currentConfig = readConfig();
// Merge logic could go here if needed, e.g., preserving a 'ui' section
// For simplicity, this example overwrites the entire config
if (writeConfig(newSettings)) {
return json({ success: true, message: 'Settings saved successfully.' });
} else {
return json({ success: false, message: 'Failed to write settings.' }, { status: 500 });
}
} catch (error) {
console.error('Error processing POST request:', error);
return json({ success: false, message: 'Invalid request data.' }, { status: 400 });
}
}

View File

@@ -0,0 +1,116 @@
<script>
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
let settings = writable({});
let showSuccessMessage = false;
let showErrorMessage = false;
let errorMessage = "";
let isSaving = false;
// Ensure settings are properly loaded on component mount
onMount(async () => {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const data = await response.json();
settings.set(ensureNumericValues(data));
}
} catch (error) {
console.error('Error loading settings:', error);
}
});
async function loadSettings() {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const data = await response.json();
// Ensure all numeric values are properly handled
settings.set(ensureNumericValues(data));
}
} catch (error) {
console.error('Error loading settings:', error);
}
}
// Helper to ensure numeric values are handled correctly
function ensureNumericValues(data) {
const numericFields = [
'hunt_missing_items', 'hunt_upgrade_items', 'sleep_duration',
'hunt_missing_books', 'hunt_upgrade_books',
'hunt_missing_movies', 'hunt_upgrade_movies'
];
const result = { ...data };
// Process top level numeric fields
numericFields.forEach(field => {
if (result[field] !== undefined) {
result[field] = parseInt(result[field], 10);
}
});
// Process nested objects
['sonarr', 'radarr', 'lidarr', 'readarr', 'huntarr', 'advanced'].forEach(section => {
if (result[section]) {
numericFields.forEach(field => {
if (result[section][field] !== undefined) {
result[section][field] = parseInt(result[section][field], 10);
}
});
}
});
return result;
}
// Modify the save function to ensure it updates the UI state
async function saveSettings() {
if (isSaving) return; // Prevent multiple simultaneous saves
isSaving = true;
try {
const settingsValue = $settings;
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settingsValue)
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
// Update local settings state with the saved values
const savedSettings = await response.json();
settings.set(ensureNumericValues(savedSettings));
showSuccessMessage = true;
setTimeout(() => {
showSuccessMessage = false;
}, 3000);
} catch (error) {
console.error('Error saving settings:', error);
errorMessage = error.message || "Unknown error occurred";
showErrorMessage = true;
setTimeout(() => {
showErrorMessage = false;
}, 3000);
} finally {
isSaving = false;
}
}
// Manually trigger reload of settings
async function refreshSettings() {
await loadSettings();
}
// ... existing code for the rest of the component ...
</script>
<!-- ... existing template code ... -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 B

View File

@@ -0,0 +1,739 @@
/**
* Huntarr - Apps Module
* Handles displaying and managing app settings for media server applications
*/
const appsModule = {
// State
currentApp: null,
isLoading: false,
settingsChanged: false, // Flag to track unsaved settings changes
originalSettings: {}, // Store original settings to compare
appsWithChanges: [], // Track which apps have unsaved changes
// DOM elements
elements: {},
// Initialize the apps module
init: function() {
// Initialize state
this.currentApp = null;
this.settingsChanged = false; // Flag to track unsaved settings changes
this.originalSettings = {}; // Store original settings to compare
// Set a global flag to indicate we've loaded
window._appsModuleLoaded = true;
// Add global variable to track if we're in the middle of saving
window._appsCurrentlySaving = false;
// Add global variable to disable change detection temporarily
window._appsSuppressChangeDetection = false;
// Cache DOM elements
this.cacheElements();
// Set up event listeners
this.setupEventListeners();
// Initialize state
this.settingsChanged = false;
// Load apps for initial display
this.loadApps();
// Register with the main unsaved changes system if available
this.registerUnsavedChangesHandler();
},
// Register with the main unsaved changes system
registerUnsavedChangesHandler: function() {
// Check if we already have the event listener
if (!this._unsavedChangesHandlerRegistered) {
this._unsavedChangesHandlerRegistered = true;
document.addEventListener('click', (event) => {
// Skip handling if currently saving or change detection is suppressed
if (window._appsCurrentlySaving || window._appsSuppressChangeDetection) {
return;
}
// Only check for navigation away from apps section
const navItem = event.target.closest('.nav-item, a');
if (navItem) {
const href = navItem.getAttribute('href');
// Skip if clicking within the apps section or on external links
if (!href || href === '#apps' || href.startsWith('http') || navItem.getAttribute('target') === '_blank') {
return;
}
// Immediately clear the settingsChanged flag if we're not actually on the apps page
if (window.location.hash !== '#apps') {
this.settingsChanged = false;
return;
}
// Check for unsaved changes
if (this.hasUnsavedChanges()) {
if (!confirm('You have unsaved changes. Are you sure you want to leave? Changes will be lost.')) {
event.preventDefault();
} else {
// User clicked OK, reset the settings changed flag
this.settingsChanged = false;
}
}
}
});
// Also handle browser back/forward navigation
window.addEventListener('beforeunload', (event) => {
// Skip if currently saving, suppression active, or not on apps page
if (window._appsCurrentlySaving ||
window._appsSuppressChangeDetection ||
window.location.hash !== '#apps') {
return;
}
// Check for unsaved changes
if (this.hasUnsavedChanges()) {
// Show standard browser confirmation
event.preventDefault();
event.returnValue = 'You have unsaved changes. Are you sure you want to leave? Changes will be lost.';
return event.returnValue;
}
});
}
},
// Check for unsaved changes before navigating away
hasUnsavedChanges: function() {
// If test connection suppression is active, return false to prevent dialog
if (window._suppressUnsavedChangesDialog === true) {
console.log('Unsaved changes check suppressed due to test connection');
return false;
}
// If the app is currently saving, don't consider it as having unsaved changes
if (window._appsCurrentlySaving) {
console.log('Skipping unsaved changes check because app is currently saving');
return false;
}
// Check if settings have changed
return this.settingsChanged === true;
},
// Cache DOM elements
cacheElements: function() {
this.elements = {
// Apps dropdown
appsOptions: document.querySelectorAll('#appsSection .log-option'),
currentAppsApp: document.getElementById('current-apps-app'),
appsDropdownBtn: document.querySelector('#appsSection .log-dropdown-btn'),
appsDropdownContent: document.querySelector('#appsSection .log-dropdown-content'),
// Apps panels
appAppsPanels: document.querySelectorAll('.app-apps-panel'),
// Controls
saveAppsButton: document.getElementById('saveAppsButton')
};
},
// Set up event listeners
setupEventListeners: function() {
// App selection via <select>
const appsAppSelect = document.getElementById('appsAppSelect');
if (appsAppSelect) {
appsAppSelect.addEventListener('change', (e) => {
const app = e.target.value;
this.handleAppsAppChange(app);
});
}
// Dropdown toggle
if (this.elements.appsDropdownBtn) {
this.elements.appsDropdownBtn.addEventListener('click', () => {
this.elements.appsDropdownContent.classList.toggle('show');
// Close all other dropdowns
document.querySelectorAll('.log-dropdown-content.show').forEach(dropdown => {
if (dropdown !== this.elements.appsDropdownContent) {
dropdown.classList.remove('show');
}
});
});
}
// Close dropdown when clicking outside
document.addEventListener('click', e => {
if (!e.target.matches('#appsSection .log-dropdown-btn') &&
!e.target.closest('#appsSection .log-dropdown-btn')) {
if (this.elements.appsDropdownContent && this.elements.appsDropdownContent.classList.contains('show')) {
this.elements.appsDropdownContent.classList.remove('show');
}
}
});
// Save button
if (this.elements.saveAppsButton) {
this.elements.saveAppsButton.addEventListener('click', (event) => this.saveApps(event));
}
},
// Load apps for initial display
loadApps: function() {
// Set default app if none is selected
if (!this.currentApp) {
this.currentApp = 'sonarr'; // Default to Sonarr
// Update the dropdown text to show current app
if (this.elements.currentAppsApp) {
this.elements.currentAppsApp.textContent = 'Sonarr';
}
// Mark the sonarr option as active in the dropdown
if (this.elements.appsOptions) {
this.elements.appsOptions.forEach(option => {
option.classList.remove('active');
if (option.getAttribute('data-app') === 'sonarr') {
option.classList.add('active');
}
});
}
}
// Load the currently selected app
this.loadAppSettings(this.currentApp);
},
// Load app settings
loadAppSettings: function(app) {
console.log(`[Apps] Loading settings for ${app}`);
// Get the container to put the settings in
const appPanel = document.getElementById(app + 'Apps');
if (!appPanel) {
console.error(`App panel not found for ${app}`);
return;
}
// Clear existing content
appPanel.innerHTML = '<div class="loading-panel"><i class="fas fa-spinner fa-spin"></i> Loading settings...</div>';
// Fetch settings for this app
fetch(`/api/settings/${app}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(appSettings => {
console.log(`[Apps] Received settings for ${app}:`, appSettings);
// Clear loading message
appPanel.innerHTML = '';
// Create a form container with the app-type attribute
const formElement = document.createElement('form');
formElement.classList.add('settings-form');
formElement.setAttribute('data-app-type', app);
appPanel.appendChild(formElement);
// Generate the form using SettingsForms module
if (typeof SettingsForms !== 'undefined') {
const formFunction = SettingsForms[`generate${app.charAt(0).toUpperCase()}${app.slice(1)}Form`];
if (typeof formFunction === 'function') {
// Use .call() to set the 'this' context correctly
formFunction.call(SettingsForms, formElement, appSettings);
// Update duration displays for this app
if (typeof SettingsForms.updateDurationDisplay === 'function') {
SettingsForms.updateDurationDisplay();
}
// Store original form values after form is generated
this.storeOriginalFormValues(appPanel);
// Add change listener to detect modifications
this.addFormChangeListeners(formElement);
} else {
console.warn(`Form generation function not found for ${app}`);
appPanel.innerHTML = `<div class="settings-message">Settings for ${app.charAt(0).toUpperCase() + app.slice(1)} are not available.</div>`;
}
} else {
console.error('SettingsForms module not found');
appPanel.innerHTML = '<div class="error-panel">Unable to generate settings form. Please reload the page.</div>';
}
})
.catch(error => {
console.error(`Error loading ${app} settings:`, error);
appPanel.innerHTML = `<div class="error-panel"><i class="fas fa-exclamation-triangle"></i> Error loading settings: ${error.message}</div>`;
});
},
// Add change event listeners to form elements
addFormChangeListeners: function(form) {
if (!form) return;
console.log(`Adding form change listeners to form with app type: ${form.getAttribute('data-app-type')}`);
// Function to handle form element changes
const handleChange = () => {
if (this.hasFormChanges(form)) {
console.log('Form changed, enabling save button');
this.markAppsAsChanged();
} else {
console.log('No actual changes, save button remains disabled');
}
};
// Add listeners to all form inputs, selects, and textareas
const formElements = form.querySelectorAll('input, select, textarea');
formElements.forEach(element => {
// Skip buttons
if (element.type === 'button' || element.type === 'submit') return;
// Remove any existing change listeners to avoid duplicates
element.removeEventListener('change', handleChange);
element.removeEventListener('input', handleChange);
// Add change listeners
element.addEventListener('change', handleChange);
// For text and number inputs, also listen for input events
if (element.type === 'text' || element.type === 'number' || element.type === 'textarea') {
element.addEventListener('input', handleChange);
}
console.log(`Added change listener to ${element.tagName} with id: ${element.id || 'no-id'}`);
});
// Also add a MutationObserver to detect when instances are added or removed
// This is needed because adding/removing instances doesn't trigger input events
try {
// Check if we already have an observer for this form
if (this.observer) {
this.observer.disconnect();
}
// Create a new MutationObserver
this.observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach(mutation => {
// Check for elements added or removed
if (mutation.type === 'childList' &&
(mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
shouldUpdate = true;
}
});
if (shouldUpdate) {
console.log('Instances container changed - checking for form changes');
if (this.hasFormChanges(form)) {
console.log('Form changed, enabling save button');
this.markAppsAsChanged();
} else {
console.log('No actual changes, save button remains disabled');
}
}
});
// Start observing instances container for changes
const instancesContainers = form.querySelectorAll('.instances-container');
instancesContainers.forEach(container => {
this.observer.observe(container, { childList: true, subtree: true });
console.log(`Added MutationObserver to container: ${container.className}`);
});
} catch (error) {
console.error('Error setting up MutationObserver:', error);
}
},
// Mark apps as changed
markAppsAsChanged: function() {
this.settingsChanged = true;
// Find the currently visible app panel and track it in our list of changed apps
const currentApp = document.querySelector('.app-panel:not([style*="display: none"])');
if (currentApp) {
const appType = currentApp.getAttribute('data-app-type');
if (appType) {
// Initialize the array if it doesn't exist yet
if (!this.appsWithChanges) {
this.appsWithChanges = [];
}
// Add this app to the list of apps with changes if not already there
if (!this.appsWithChanges.includes(appType)) {
this.appsWithChanges.push(appType);
console.log(`Added ${appType} to appsWithChanges:`, this.appsWithChanges);
}
// Set the global tracking flag for this specific app
if (!window._hasAppChanges) {
window._hasAppChanges = {};
}
window._hasAppChanges[appType] = true;
// Also update the huntarrUI tracking if available
if (window.huntarrUI && window.huntarrUI.formChanged) {
window.huntarrUI.formChanged[appType] = true;
window.huntarrUI.hasUnsavedChanges = true;
}
}
}
if (this.elements.saveAppsButton) {
this.elements.saveAppsButton.disabled = false;
console.log('Save button enabled');
} else {
console.error('Save button element not found');
}
},
// Check if the form has actual changes compared to original values
hasFormChanges: function(form) {
if (!form || !this.originalSettings) return true;
let hasChanges = false;
const formElements = form.querySelectorAll('input, select, textarea');
formElements.forEach(element => {
// Skip buttons
if (element.type === 'button' || element.type === 'submit') return;
const originalValue = this.originalSettings[element.id];
const currentValue = element.type === 'checkbox' ? element.checked : element.value;
// Compare with original value
if (originalValue !== undefined && String(originalValue) !== String(currentValue)) {
console.log(`Element changed: ${element.id}, Original: ${originalValue}, Current: ${currentValue}`);
hasChanges = true;
}
});
return hasChanges;
},
// Show specific app panel and hide others
showAppPanel: function(app) {
console.log(`Showing app panel for ${app}`);
// Hide all app panels
this.elements.appAppsPanels.forEach(panel => {
panel.style.display = 'none';
panel.classList.remove('active');
});
// Show the selected app panel
const appPanel = document.getElementById(`${app}Apps`);
if (appPanel) {
appPanel.style.display = 'block';
appPanel.classList.add('active');
// Ensure the panel has the correct data-app-type attribute
appPanel.setAttribute('data-app-type', app);
console.log(`App panel for ${app} is now active`);
} else {
console.error(`App panel for ${app} not found`);
}
},
// Handle app selection changes
handleAppsAppChange: function(selectedApp) {
// If called with an event, extract the value
if (selectedApp && selectedApp.target && typeof selectedApp.target.value === 'string') {
selectedApp = selectedApp.target.value;
}
if (!selectedApp || selectedApp === this.currentApp) return;
// Special case for Cleanuperr - it's an information page, not a settings page
if (selectedApp === 'cleanuperr') {
// Hide apps section and show Cleanuperr section
document.getElementById('appsSection').classList.remove('active');
document.getElementById('cleanuperrSection').classList.add('active');
// Update the page title
if (huntarrUI && typeof huntarrUI.switchSection === 'function') {
huntarrUI.currentSection = 'cleanuparr';
// We're not calling the full switchSection as that would alter navigation
// Just update the title
const pageTitleElement = document.getElementById('currentPageTitle');
if (pageTitleElement) {
pageTitleElement.textContent = 'Cleanuperr';
}
}
return;
}
// Check for unsaved changes
if (this.settingsChanged) {
const confirmSwitch = confirm('You have unsaved changes. Do you want to continue without saving?');
if (!confirmSwitch) {
// Reset the select to the current app
const appsAppSelect = document.getElementById('appsAppSelect');
if (appsAppSelect) appsAppSelect.value = this.currentApp;
return;
}
}
// Update the select value
const appsAppSelect = document.getElementById('appsAppSelect');
if (appsAppSelect) appsAppSelect.value = selectedApp;
// Show the selected app's panel
this.showAppPanel(selectedApp);
this.currentApp = selectedApp;
// Load the newly selected app's settings
this.loadAppSettings(selectedApp);
// Reset changed state
this.settingsChanged = false;
if (this.elements.saveAppsButton) this.elements.saveAppsButton.disabled = true;
},
// Save apps settings - completely rewritten for reliability
saveApps: function(event) {
if (event) event.preventDefault();
console.log('[Apps] Save button clicked');
// Set a flag that we're in the middle of saving
window._appsCurrentlySaving = true;
// Get the current app from module state
const appType = this.currentApp;
if (!appType) {
console.error('No current app selected');
// Emergency fallback - try to find the visible app panel
const visiblePanel = document.querySelector('.app-apps-panel[style*="display: block"]');
if (visiblePanel && visiblePanel.id) {
// Extract app type from panel ID (e.g., "sonarrApps" -> "sonarr")
const extractedType = visiblePanel.id.replace('Apps', '');
console.log(`Fallback: Found visible panel with ID ${visiblePanel.id}, extracted app type: ${extractedType}`);
if (extractedType) {
// Continue with the extracted app type
return this.saveAppSettings(extractedType, visiblePanel);
}
}
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification('Error: Could not determine which app settings to save', 'error');
} else {
alert('Error: Could not determine which app settings to save');
}
return;
}
// Direct DOM access to find the app panel
const appPanel = document.getElementById(`${appType}Apps`);
if (!appPanel) {
console.error(`App panel not found for ${appType}`);
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification(`Error: App panel not found for ${appType}`, 'error');
} else {
alert(`Error: App panel not found for ${appType}`);
}
return;
}
// Proceed with saving for the found app panel
this.saveAppSettings(appType, appPanel);
},
// Helper function to save settings for a specific app
saveAppSettings: function(appType, appPanel) {
console.log(`Saving settings for ${appType}`);
// For Whisparr, ensure we indicate we're working with V2
let apiVersion = "";
if (appType === "whisparr") {
console.log("Saving Whisparr V2 settings");
apiVersion = "V2";
} else if (appType === "eros") {
console.log("Saving Eros (Whisparr V3) settings");
}
let settings;
try {
// Make sure the app type is set on the panel for SettingsForms
appPanel.setAttribute('data-app-type', appType);
// Get settings from the form
settings = SettingsForms.getFormSettings(appPanel, appType);
console.log(`Collected settings for ${appType}:`, settings);
} catch (error) {
console.error(`Error collecting settings for ${appType}:`, error);
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification(`Error collecting settings: ${error.message}`, 'error');
} else {
alert(`Error collecting settings: ${error.message}`);
}
return;
}
// Add specific logging for settings critical to stateful management
if (appType === 'general') {
console.log('Stateful management settings being saved:', {
statefulExpirationHours: settings.statefulExpirationHours,
api_timeout: settings.api_timeout,
command_wait_delay: settings.command_wait_delay,
command_wait_attempts: settings.command_wait_attempts
});
}
// Send settings to the server
console.log(`Sending ${appType} settings to server...`);
fetch(`/api/settings/${appType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => {
console.log(`${appType} settings saved successfully:`, data);
// Temporarily suppress change detection
window._appsSuppressChangeDetection = true;
// Store the current form values as the new "original" values
this.storeOriginalFormValues(appPanel);
// Disable save button and reset state
this.settingsChanged = false;
if (this.elements.saveAppsButton) {
this.elements.saveAppsButton.disabled = true;
}
// Reset the saving flag
window._appsCurrentlySaving = false;
// Ensure form elements are properly updated to reflect saved state
this.markFormAsUnchanged(appPanel);
// After a short delay, re-enable change detection
setTimeout(() => {
window._appsSuppressChangeDetection = false;
}, 1000);
// Show success notification
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification(`${appType} settings saved successfully`, 'success');
} else {
alert(`${appType} settings saved successfully`);
}
})
.catch(error => {
console.error(`Error saving ${appType} settings:`, error);
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification(`Error saving settings: ${error.message}`, 'error');
} else {
alert(`Error saving settings: ${error.message}`);
}
// Reset the saving flag
window._appsCurrentlySaving = false;
});
},
// Store the current form values as the new "original" values
storeOriginalFormValues: function(appPanel) {
const form = appPanel.querySelector('form');
if (!form) return;
const originalValues = {};
const formElements = form.querySelectorAll('input, select, textarea');
formElements.forEach(element => {
originalValues[element.id] = element.value;
});
this.originalSettings = originalValues;
console.log('Original form values stored:', this.originalSettings);
},
// Mark form as unchanged
markFormAsUnchanged: function(appPanel) {
const form = appPanel.querySelector('form');
if (!form) return;
// First, remove the 'changed' class from all form elements
const formElements = form.querySelectorAll('input, select, textarea');
formElements.forEach(element => {
element.classList.remove('changed');
});
// Get the app type to properly handle app-specific flags
const appType = appPanel.getAttribute('data-app-type') || '';
console.log(`Marking form as unchanged for app type: ${appType}`);
// Clear app-specific change flags
if (window._hasAppChanges && typeof window._hasAppChanges === 'object') {
window._hasAppChanges[appType] = false;
}
// Ensure we reset all change tracking for this app
try {
// Reset any form change flags
if (form.dataset) {
form.dataset.hasChanges = 'false';
}
// Clear any app-specific data attributes that might be tracking changes
appPanel.querySelectorAll('[data-changed="true"]').forEach(el => {
el.setAttribute('data-changed', 'false');
});
// Reset the internal change tracking for this specific app
if (appType && this.appsWithChanges && this.appsWithChanges.includes(appType)) {
this.appsWithChanges = this.appsWithChanges.filter(app => app !== appType);
console.log(`Removed ${appType} from appsWithChanges:`, this.appsWithChanges);
}
// Force update overall app state
this.settingsChanged = this.appsWithChanges && this.appsWithChanges.length > 0;
// Explicitly handle Readarr, Lidarr, and Whisparr which seem to have issues
if (appType === 'readarr' || appType === 'lidarr' || appType === 'whisparr' || appType === 'whisparrv2') {
console.log(`Special handling for ${appType} to ensure changes are cleared`);
// Force additional global state updates
if (window.huntarrUI && window.huntarrUI.formChanged) {
window.huntarrUI.formChanged[appType] = false;
}
// Reset the global changed state tracker if this was the only app with changes
if (!this.settingsChanged && window.huntarrUI) {
window.huntarrUI.hasUnsavedChanges = false;
}
// Force immediate re-evaluation of the form state
setTimeout(() => {
this.hasFormChanges(form);
}, 10);
}
} catch (error) {
console.error(`Error in markFormAsUnchanged for ${appType}:`, error);
}
}
};
// Initialize when document is ready
document.addEventListener('DOMContentLoaded', () => {
appsModule.init();
// Add a direct event listener to the save button for maximum reliability
const saveButton = document.getElementById('saveAppsButton');
if (saveButton) {
saveButton.addEventListener('click', function(event) {
console.log('Save button clicked directly');
appsModule.saveApps(event);
});
}
});

View File

@@ -0,0 +1,7 @@
/**
* Cleanuperr - Information display component for Huntarr
* Provides information about the Cleanuperr project by Flaminel
*/
// No active functionality needed for this information page
console.log('Cleanuperr information module loaded');

View File

@@ -0,0 +1,196 @@
/**
* Eros.js - Handles Eros settings and interactions in the Huntarr UI
*/
document.addEventListener('DOMContentLoaded', function() {
// Don't call setupErosForm here, new-main.js will call it when the tab is active
// setupErosForm();
// setupErosLogs(); // Assuming logs are handled by the main logs section
// setupClearProcessedButtons('eros'); // Assuming this is handled elsewhere or not needed immediately
});
/**
* Setup Eros settings form and connection test
* This function is now called by new-main.js when the Eros settings tab is shown.
*/
function setupErosForm() {
console.log("[eros.js] Setting up Eros form...");
const panel = document.getElementById('erosSettings');
if (!panel) {
console.warn("[eros.js] Eros settings panel not found.");
return;
}
const testErosButton = panel.querySelector('#test-eros-button');
const erosStatusIndicator = panel.querySelector('#eros-connection-status');
const erosVersionDisplay = panel.querySelector('#eros-version');
const apiUrlInput = panel.querySelector('#eros_api_url');
const apiKeyInput = panel.querySelector('#eros_api_key');
// Check if event listener is already attached (prevents duplicate handlers)
if (!testErosButton || testErosButton.dataset.listenerAttached === 'true') {
console.log("[eros.js] Test button not found or listener already attached.");
return;
}
console.log("[eros.js] Setting up Eros form listeners.");
testErosButton.dataset.listenerAttached = 'true'; // Mark as attached
// Add event listener for connection test
testErosButton.addEventListener('click', function() {
console.log("[eros.js] Testing Eros connection...");
// Basic validation
if (!apiUrlInput.value || !apiKeyInput.value) {
if (typeof huntarrUI !== 'undefined') {
huntarrUI.showNotification('Please enter both API URL and API Key for Eros', 'error');
} else {
alert('Please enter both API URL and API Key for Eros');
}
return;
}
// Disable button during test and show pending status
testErosButton.disabled = true;
if (erosStatusIndicator) {
erosStatusIndicator.className = 'connection-status pending';
erosStatusIndicator.textContent = 'Testing...';
}
// Call API to test connection
HuntarrUtils.fetchWithTimeout('/api/eros/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: apiUrlInput.value,
api_key: apiKeyInput.value,
api_timeout: 30
})
}, 30000) // 30 second timeout
.then(response => response.json())
.then(data => {
// Enable the button again
testErosButton.disabled = false;
if (erosStatusIndicator) {
if (data.success) {
erosStatusIndicator.className = 'connection-status success';
erosStatusIndicator.textContent = 'Connected';
if (typeof huntarrUI !== 'undefined') {
huntarrUI.showNotification('Successfully connected to Eros', 'success');
}
getErosVersion(); // Fetch version after successful connection
} else {
erosStatusIndicator.className = 'connection-status failure';
erosStatusIndicator.textContent = 'Failed';
if (typeof huntarrUI !== 'undefined') {
huntarrUI.showNotification(data.message || 'Failed to connect to Eros', 'error');
} else {
alert(data.message || 'Failed to connect to Eros');
}
}
}
})
.catch(error => {
console.error('[eros.js] Error testing connection:', error);
testErosButton.disabled = false;
if (erosStatusIndicator) {
erosStatusIndicator.className = 'connection-status failure';
erosStatusIndicator.textContent = 'Error';
}
if (typeof huntarrUI !== 'undefined') {
huntarrUI.showNotification('Error testing connection: ' + error.message, 'error');
} else {
alert('Error testing connection: ' + error.message);
}
});
});
// Initialize form state and fetch data
refreshErosStatusAndVersion();
}
/**
* Get the Eros software version from the instance.
* This is separate from the API test.
*/
function getErosVersion() {
const panel = document.getElementById('erosSettings');
if (!panel) return;
const versionDisplay = panel.querySelector('#eros-version');
if (!versionDisplay) return;
// Try to get the API settings from the form
const apiUrlInput = panel.querySelector('#eros_api_url');
const apiKeyInput = panel.querySelector('#eros_api_key');
if (!apiUrlInput || !apiUrlInput.value || !apiKeyInput || !apiKeyInput.value) {
versionDisplay.textContent = 'N/A';
return;
}
// Endpoint to get version info - using the test endpoint since it returns version
HuntarrUtils.fetchWithTimeout('/api/eros/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: apiUrlInput.value,
api_key: apiKeyInput.value,
api_timeout: 10
})
}, 10000)
.then(response => response.json())
.then(data => {
if (data.success && data.version) {
versionDisplay.textContent = 'v' + data.version;
} else {
versionDisplay.textContent = 'Unknown';
}
})
.catch(error => {
console.error('[eros.js] Error fetching version:', error);
versionDisplay.textContent = 'Error';
});
}
/**
* Refresh the connection status and version display for Eros.
*/
function refreshErosStatusAndVersion() {
// Try to get current connection status from the server
fetch('/api/eros/status')
.then(response => response.json())
.then(data => {
const panel = document.getElementById('erosSettings');
if (!panel) return;
const statusIndicator = panel.querySelector('#eros-connection-status');
if (statusIndicator) {
if (data.connected) {
statusIndicator.className = 'connection-status success';
statusIndicator.textContent = 'Connected';
getErosVersion(); // Try to get version if connected
} else if (data.configured) {
statusIndicator.className = 'connection-status failure';
statusIndicator.textContent = 'Not Connected';
} else {
statusIndicator.className = 'connection-status pending';
statusIndicator.textContent = 'Not Configured';
}
}
})
.catch(error => {
console.error('[eros.js] Error checking status:', error);
});
}
// Mark functions as global if needed by other parts of the application
window.setupErosForm = setupErosForm;
window.getErosVersion = getErosVersion;
window.refreshErosStatusAndVersion = refreshErosStatusAndVersion;

View File

@@ -0,0 +1,101 @@
// Lidarr-specific functionality
(function(app) {
if (!app) {
console.error("Huntarr App core is not loaded!");
return;
}
const lidarrModule = {
elements: {
apiUrlInput: document.getElementById('lidarr_api_url'),
apiKeyInput: document.getElementById('lidarr_api_key'),
connectionTestButton: document.getElementById('test-lidarr-connection'),
huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
huntMissingItemsInput: document.getElementById('hunt_missing_items'),
huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
sleepDurationInput: document.getElementById('lidarr_sleep_duration'),
sleepDurationHoursSpan: document.getElementById('lidarr_sleep_duration_hours'),
stateResetIntervalInput: document.getElementById('lidarr_state_reset_interval_hours'),
monitoredOnlyInput: document.getElementById('lidarr_monitored_only'),
skipFutureReleasesInput: document.getElementById('lidarr_skip_future_releases'),
skipArtistRefreshInput: document.getElementById('skip_artist_refresh'),
randomMissingInput: document.getElementById('lidarr_random_missing'),
randomUpgradesInput: document.getElementById('lidarr_random_upgrades'),
debugModeInput: document.getElementById('lidarr_debug_mode'),
apiTimeoutInput: document.getElementById('lidarr_api_timeout'),
commandWaitDelayInput: document.getElementById('lidarr_command_wait_delay'),
commandWaitAttemptsInput: document.getElementById('lidarr_command_wait_attempts'),
minimumDownloadQueueSizeInput: document.getElementById('lidarr_minimum_download_queue_size')
},
init: function() {
console.log('[Lidarr Module] Initializing...');
// Cache elements specific to the Lidarr settings form
this.elements = {
apiUrlInput: document.getElementById('lidarr_api_url'),
apiKeyInput: document.getElementById('lidarr_api_key'),
connectionTestButton: document.getElementById('test-lidarr-connection'),
huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
huntMissingItemsInput: document.getElementById('hunt_missing_items'),
huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
// ...other element references
};
// Add event listeners
this.addEventListeners();
},
addEventListeners() {
// Add connection test button click handler
if (this.elements.connectionTestButton) {
this.elements.connectionTestButton.addEventListener('click', this.testConnection.bind(this));
}
// Add event listener to update help text when missing mode changes
if (this.elements.huntMissingModeSelect) {
this.elements.huntMissingModeSelect.addEventListener('change', this.updateHuntMissingModeHelp.bind(this));
// Initial update
this.updateHuntMissingModeHelp();
}
},
// Update help text based on selected missing mode
updateHuntMissingModeHelp() {
const mode = this.elements.huntMissingModeSelect.value;
const helpText = document.querySelector('#hunt_missing_items + .setting-help');
if (helpText) {
if (mode === 'artist') {
helpText.textContent = "Number of artists with missing albums to search per cycle (0 to disable)";
} else if (mode === 'album') {
helpText.textContent = "Number of specific albums to search per cycle (0 to disable)";
}
}
},
updateSleepDurationDisplay: function() {
// This function remains as it updates a specific UI element
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
// Assuming app.updateDurationDisplay exists and is accessible
if (app && typeof app.updateDurationDisplay === 'function') {
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
} else {
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
}
}
}
};
// Initialize Lidarr module when DOM content is loaded and if lidarrSettings exists
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('lidarrSettings')) {
lidarrModule.init();
if (app) {
app.lidarrModule = lidarrModule;
}
}
});
})(window.huntarrUI); // Pass the global UI object

View File

@@ -0,0 +1,75 @@
// Radarr-specific functionality
(function(app) {
if (!app) {
console.error("Huntarr App core is not loaded!");
return;
}
const radarrModule = {
elements: {},
init: function() {
console.log('[Radarr Module] Initializing...');
this.cacheElements();
this.setupEventListeners();
},
cacheElements: function() {
// Cache elements specific to the Radarr settings form
this.elements.apiUrlInput = document.getElementById('radarr_api_url');
this.elements.apiKeyInput = document.getElementById('radarr_api_key');
this.elements.huntMissingMoviesInput = document.getElementById('hunt_missing_movies');
this.elements.huntUpgradeMoviesInput = document.getElementById('hunt_upgrade_movies');
this.elements.sleepDurationInput = document.getElementById('radarr_sleep_duration');
this.elements.sleepDurationHoursSpan = document.getElementById('radarr_sleep_duration_hours');
this.elements.stateResetIntervalInput = document.getElementById('radarr_state_reset_interval_hours');
this.elements.monitoredOnlyInput = document.getElementById('radarr_monitored_only');
this.elements.skipFutureReleasesInput = document.getElementById('skip_future_releases'); // Note: ID might be shared
this.elements.skipMovieRefreshInput = document.getElementById('skip_movie_refresh');
this.elements.randomMissingInput = document.getElementById('radarr_random_missing');
this.elements.randomUpgradesInput = document.getElementById('radarr_random_upgrades');
this.elements.debugModeInput = document.getElementById('radarr_debug_mode');
this.elements.apiTimeoutInput = document.getElementById('radarr_api_timeout');
this.elements.commandWaitDelayInput = document.getElementById('radarr_command_wait_delay');
this.elements.commandWaitAttemptsInput = document.getElementById('radarr_command_wait_attempts');
this.elements.minimumDownloadQueueSizeInput = document.getElementById('radarr_minimum_download_queue_size');
// Add any other Radarr-specific elements
},
setupEventListeners: function() {
// Keep listeners ONLY for elements with specific UI updates beyond simple value changes
if (this.elements.sleepDurationInput) {
this.elements.sleepDurationInput.addEventListener('input', () => {
this.updateSleepDurationDisplay();
// No need to call checkForChanges here, handled by delegation
});
}
// Remove other input listeners previously used for checkForChanges
},
updateSleepDurationDisplay: function() {
// This function remains as it updates a specific UI element
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
// Assuming app.updateDurationDisplay exists and is accessible
if (app && typeof app.updateDurationDisplay === 'function') {
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
} else {
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
}
}
}
};
// Initialize Radarr module
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('radarrSettings')) {
radarrModule.init();
if (app) {
app.radarrModule = radarrModule;
}
}
});
})(window.huntarrUI); // Pass the global UI object

View File

@@ -0,0 +1,75 @@
// Readarr-specific functionality
(function(app) {
if (!app) {
console.error("Huntarr App core is not loaded!");
return;
}
const readarrModule = {
elements: {},
init: function() {
console.log('[Readarr Module] Initializing...');
this.cacheElements();
this.setupEventListeners();
},
cacheElements: function() {
// Cache elements specific to the Readarr settings form
this.elements.apiUrlInput = document.getElementById('readarr_api_url');
this.elements.apiKeyInput = document.getElementById('readarr_api_key');
this.elements.huntMissingBooksInput = document.getElementById('hunt_missing_books');
this.elements.huntUpgradeBooksInput = document.getElementById('hunt_upgrade_books');
this.elements.sleepDurationInput = document.getElementById('readarr_sleep_duration');
this.elements.sleepDurationHoursSpan = document.getElementById('readarr_sleep_duration_hours');
this.elements.stateResetIntervalInput = document.getElementById('readarr_state_reset_interval_hours');
this.elements.monitoredOnlyInput = document.getElementById('readarr_monitored_only');
this.elements.skipFutureReleasesInput = document.getElementById('readarr_skip_future_releases');
this.elements.skipAuthorRefreshInput = document.getElementById('skip_author_refresh');
this.elements.randomMissingInput = document.getElementById('readarr_random_missing');
this.elements.randomUpgradesInput = document.getElementById('readarr_random_upgrades');
this.elements.debugModeInput = document.getElementById('readarr_debug_mode');
this.elements.apiTimeoutInput = document.getElementById('readarr_api_timeout');
this.elements.commandWaitDelayInput = document.getElementById('readarr_command_wait_delay');
this.elements.commandWaitAttemptsInput = document.getElementById('readarr_command_wait_attempts');
this.elements.minimumDownloadQueueSizeInput = document.getElementById('readarr_minimum_download_queue_size');
// Add any other Readarr-specific elements
},
setupEventListeners: function() {
// Keep listeners ONLY for elements with specific UI updates beyond simple value changes
if (this.elements.sleepDurationInput) {
this.elements.sleepDurationInput.addEventListener('input', () => {
this.updateSleepDurationDisplay();
// No need to call checkForChanges here, handled by delegation
});
}
// Remove other input listeners previously used for checkForChanges
},
updateSleepDurationDisplay: function() {
// This function remains as it updates a specific UI element
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
// Assuming app.updateDurationDisplay exists and is accessible
if (app && typeof app.updateDurationDisplay === 'function') {
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
} else {
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
}
}
}
};
// Initialize Readarr module
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('readarrSettings')) {
readarrModule.init();
if (app) {
app.readarrModule = readarrModule;
}
}
});
})(window.huntarrUI); // Pass the global UI object

View File

@@ -0,0 +1,82 @@
// Sonarr-specific functionality
(function(app) {
if (!app) {
console.error("Huntarr App core is not loaded!");
return;
}
const sonarrModule = {
elements: {},
init: function() {
// Cache elements specific to Sonarr settings
this.cacheElements();
// Setup event listeners specific to Sonarr settings
this.setupEventListeners();
// Initial population of the form is handled by new-main.js
},
cacheElements: function() {
// Cache elements used by Sonarr settings form
this.elements.apiUrlInput = document.getElementById('sonarr_api_url');
this.elements.apiKeyInput = document.getElementById('sonarr_api_key');
this.elements.huntMissingItemsInput = document.getElementById('sonarr-hunt-missing-items');
this.elements.huntUpgradeItemsInput = document.getElementById('sonarr-hunt-upgrade-items');
this.elements.sleepDurationInput = document.getElementById('sonarr_sleep_duration');
this.elements.sleepDurationHoursSpan = document.getElementById('sonarr_sleep_duration_hours');
this.elements.monitoredOnlyInput = document.getElementById('sonarr_monitored_only');
this.elements.skipFutureEpisodesInput = document.getElementById('sonarr_skip_future_episodes');
this.elements.skipSeriesRefreshInput = document.getElementById('sonarr_skip_series_refresh');
this.elements.randomMissingInput = document.getElementById('sonarr_random_missing');
this.elements.randomUpgradesInput = document.getElementById('sonarr_random_upgrades');
this.elements.debugModeInput = document.getElementById('sonarr_debug_mode');
this.elements.apiTimeoutInput = document.getElementById('sonarr_api_timeout');
this.elements.commandWaitDelayInput = document.getElementById('sonarr_command_wait_delay');
this.elements.commandWaitAttemptsInput = document.getElementById('sonarr_command_wait_attempts');
this.elements.minimumDownloadQueueSizeInput = document.getElementById('sonarr_minimum_download_queue_size');
// Add other Sonarr-specific elements if any
},
setupEventListeners: function() {
// Add event listeners for Sonarr-specific controls if needed
// Example: If there were unique interactions for Sonarr settings
// Most change detection is now handled centrally by new-main.js
// Update sleep duration display on input change
if (this.elements.sleepDurationInput) {
this.elements.sleepDurationInput.addEventListener('input', () => {
this.updateSleepDurationDisplay();
// Central change detection handles the rest
});
}
},
updateSleepDurationDisplay: function() {
// Use the central utility function for updating duration display
if (this.elements.sleepDurationInput && this.elements.sleepDurationHoursSpan) {
const seconds = parseInt(this.elements.sleepDurationInput.value) || 900;
app.updateDurationDisplay(seconds, this.elements.sleepDurationHoursSpan);
}
},
// REMOVED: loadSettings function (handled by new-main.js)
// REMOVED: checkForChanges function (handled by new-main.js)
// REMOVED: updateSaveButtonState function (handled by new-main.js)
// REMOVED: getSettingsPayload function (handled by new-main.js)
// REMOVED: saveSettings function (handled by new-main.js)
// REMOVED: Overriding of app.saveSettings
};
// Initialize Sonarr module
sonarrModule.init();
// Add the Sonarr module to the app for reference if needed elsewhere
app.sonarrModule = sonarrModule;
})(window.huntarrUI); // Use the new global object name

View File

@@ -0,0 +1,381 @@
// Swaparr-specific functionality
(function(app) {
if (!app) {
console.error("Huntarr App core is not loaded!");
return;
}
const swaparrModule = {
elements: {},
isTableView: true, // Default to table view for Swaparr logs
hasRenderedAnyContent: false, // Track if we've rendered any content
// Store data for display
logData: {
config: {
platform: '',
maxStrikes: 3,
scanInterval: '10m',
maxDownloadTime: '2h',
ignoreAboveSize: '25 GB'
},
downloads: [], // Will store download status records
rawLogs: [] // Store raw logs for backup display
},
init: function() {
console.log('[Swaparr Module] Initializing...');
this.setupLogProcessor();
// Add a listener for when the log tab changes to Swaparr
const swaparrTab = document.querySelector('.log-tab[data-app="swaparr"]');
if (swaparrTab) {
swaparrTab.addEventListener('click', () => {
console.log('[Swaparr Module] Swaparr tab clicked');
// Small delay to ensure everything is ready
setTimeout(() => {
this.ensureContentRendered();
}, 200);
});
}
},
setupLogProcessor: function() {
// Setup a listener for custom event from huntarrUI's log processing
document.addEventListener('swaparrLogReceived', (event) => {
console.log('[Swaparr Module] Received log event:', event.detail.logData.substring(0, 100) + '...');
this.processLogLine(event.detail.logData);
});
},
processLogLine: function(logLine) {
// Always store raw logs for backup display
this.logData.rawLogs.push(logLine);
// Limit raw logs storage to prevent memory issues
if (this.logData.rawLogs.length > 500) {
this.logData.rawLogs.shift();
}
// Process log lines specific to Swaparr
if (!logLine) return;
// Check if this looks like a Swaparr config line and extract information
if (logLine.includes('Platform:') && logLine.includes('Max strikes:')) {
this.extractConfigInfo(logLine);
this.renderConfigPanel();
return;
}
// Look for strike-related logs from system
if (logLine.includes('Added strike') ||
logLine.includes('Max strikes reached') ||
logLine.includes('removing download') ||
logLine.includes('Would have removed')) {
this.processStrikeLog(logLine);
return;
}
// Check if this is a table header/separator line
if (logLine.includes('strikes') && logLine.includes('status') && logLine.includes('name') && logLine.includes('size') && logLine.includes('eta')) {
// This is the header line, we can ignore it or use it to confirm table format
return;
}
// Try to match download info line
// Format: [strikes/max] status name size eta
// Example: 2/3 Striked MyDownload.mkv 1.5 GB 2h 15m
const downloadLinePattern = /(\d+\/\d+)\s+(\w+)\s+(.+?)\s+(\d+(?:\.\d+)?)\s*(\w+)\s+([\ddhms\s]+|Infinite)/;
const match = logLine.match(downloadLinePattern);
if (match) {
// Extract download information
const downloadInfo = {
strikes: match[1],
status: match[2],
name: match[3],
size: match[4] + ' ' + match[5],
eta: match[6]
};
// Update or add to our list of downloads
this.updateDownloadsList(downloadInfo);
this.renderTableView();
}
// If we're viewing the Swaparr tab, always ensure content is rendered
if (app.currentLogApp === 'swaparr') {
this.ensureContentRendered();
}
},
// Process strike-related logs from system logs
processStrikeLog: function(logLine) {
// Try to extract download name and strike info
let downloadName = '';
let strikes = '1/3'; // Default value
let status = 'Striked';
// Extract download name
if (logLine.includes('Added strike')) {
const match = logLine.match(/Added strike \((\d+)\/(\d+)\) to (.+?) - Reason:/);
if (match) {
strikes = `${match[1]}/${match[2]}`;
downloadName = match[3];
status = 'Striked';
}
} else if (logLine.includes('Max strikes reached')) {
const match = logLine.match(/Max strikes reached for (.+?), removing download/);
if (match) {
downloadName = match[1];
status = 'Removed';
}
} else if (logLine.includes('Would have removed')) {
const match = logLine.match(/Would have removed (.+?) after (\d+) strikes/);
if (match) {
downloadName = match[1];
status = 'Pending Removal';
strikes = `${match[2]}/3`;
}
}
if (downloadName) {
// Create a download info object with partial information
const downloadInfo = {
strikes: strikes,
status: status,
name: downloadName,
size: 'Unknown',
eta: 'Unknown'
};
// Update downloads list
this.updateDownloadsList(downloadInfo);
this.renderTableView();
}
},
extractConfigInfo: function(logLine) {
// Extract the config data from the log line
const platformMatch = logLine.match(/Platform:\s+(\w+)/);
const maxStrikesMatch = logLine.match(/Max strikes:\s+(\d+)/);
const scanIntervalMatch = logLine.match(/Scan interval:\s+(\d+\w+)/);
const maxDownloadTimeMatch = logLine.match(/Max download time:\s+(\d+\w+)/);
const ignoreSizeMatch = logLine.match(/Ignore above size:\s+(\d+\s*\w+)/);
if (platformMatch) this.logData.config.platform = platformMatch[1];
if (maxStrikesMatch) this.logData.config.maxStrikes = maxStrikesMatch[1];
if (scanIntervalMatch) this.logData.config.scanInterval = scanIntervalMatch[1];
if (maxDownloadTimeMatch) this.logData.config.maxDownloadTime = maxDownloadTimeMatch[1];
if (ignoreSizeMatch) this.logData.config.ignoreAboveSize = ignoreSizeMatch[1];
},
updateDownloadsList: function(downloadInfo) {
// Find if this download already exists in our list
const existingIndex = this.logData.downloads.findIndex(item =>
item.name.trim() === downloadInfo.name.trim()
);
if (existingIndex >= 0) {
// Update existing entry
this.logData.downloads[existingIndex] = downloadInfo;
} else {
// Add new entry
this.logData.downloads.push(downloadInfo);
}
},
renderConfigPanel: function() {
// Find the logs container
const logsContainer = document.getElementById('logsContainer');
if (!logsContainer) return;
// If the user has selected swaparr logs, show the config panel at the top
if (app.currentLogApp === 'swaparr') {
// Check if config panel already exists
let configPanel = document.getElementById('swaparr-config-panel');
if (!configPanel) {
// Create the panel
configPanel = document.createElement('div');
configPanel.id = 'swaparr-config-panel';
configPanel.classList.add('swaparr-panel');
logsContainer.appendChild(configPanel);
}
// Update the panel content
configPanel.innerHTML = `
<div class="swaparr-config">
<h3>Swaparr${this.logData.config.platform ? ' — ' + this.logData.config.platform : ''}</h3>
<div class="swaparr-config-content">
<span>Max strikes: ${this.logData.config.maxStrikes}</span>
<span>Scan interval: ${this.logData.config.scanInterval}</span>
<span>Max download time: ${this.logData.config.maxDownloadTime}</span>
<span>Ignore above size: ${this.logData.config.ignoreAboveSize}</span>
</div>
</div>
`;
this.hasRenderedAnyContent = true;
}
},
renderTableView: function() {
// Find the logs container
const logsContainer = document.getElementById('logsContainer');
if (!logsContainer || app.currentLogApp !== 'swaparr') return;
// Check if table already exists
let tableView = document.getElementById('swaparr-table-view');
if (!tableView) {
// Create the table
tableView = document.createElement('div');
tableView.id = 'swaparr-table-view';
tableView.classList.add('swaparr-table');
logsContainer.appendChild(tableView);
}
// Only render table if we have downloads to show
if (this.logData.downloads.length > 0) {
// Generate table HTML
let tableHTML = `
<table>
<thead>
<tr>
<th>Strikes</th>
<th>Status</th>
<th>Name</th>
<th>Size</th>
<th>ETA</th>
</tr>
</thead>
<tbody>
`;
// Add each download as a row
this.logData.downloads.forEach(download => {
// Apply status-specific CSS class
let statusClass = download.status.toLowerCase();
// Normalize some status values
if (statusClass === 'pending removal') statusClass = 'pending';
if (statusClass === 'removed') statusClass = 'removed';
if (statusClass === 'striked') statusClass = 'striked';
if (statusClass === 'normal') statusClass = 'normal';
if (statusClass === 'ignored') statusClass = 'ignored';
tableHTML += `
<tr class="swaparr-status-${statusClass}">
<td>${download.strikes}</td>
<td>${download.status}</td>
<td>${download.name}</td>
<td>${download.size}</td>
<td>${download.eta}</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
`;
tableView.innerHTML = tableHTML;
this.hasRenderedAnyContent = true;
}
},
// Render raw logs if we don't have structured content
renderRawLogs: function() {
// Only show raw logs if we have no other content
if (this.hasRenderedAnyContent) return;
const logsContainer = document.getElementById('logsContainer');
if (!logsContainer || app.currentLogApp !== 'swaparr') return;
// Start with a message
const noDataMessage = document.createElement('div');
noDataMessage.classList.add('swaparr-panel');
noDataMessage.innerHTML = `
<div class="swaparr-config">
<h3>Swaparr Logs</h3>
<p>Waiting for structured Swaparr data. Showing raw logs below:</p>
</div>
`;
logsContainer.appendChild(noDataMessage);
// Add raw logs
for (const logLine of this.logData.rawLogs) {
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.innerHTML = `<span class="log-message">${logLine}</span>`;
// Basic level detection
if (logLine.includes('ERROR')) logEntry.classList.add('log-error');
else if (logLine.includes('WARN') || logLine.includes('WARNING')) logEntry.classList.add('log-warning');
else if (logLine.includes('DEBUG')) logEntry.classList.add('log-debug');
else logEntry.classList.add('log-info');
logsContainer.appendChild(logEntry);
}
this.hasRenderedAnyContent = true;
},
// Make sure we display something in the Swaparr tab
ensureContentRendered: function() {
console.log('[Swaparr Module] Ensuring content is rendered, has content:', this.hasRenderedAnyContent);
// Reset rendered flag
this.hasRenderedAnyContent = false;
// Check if we're viewing Swaparr tab
if (app.currentLogApp !== 'swaparr') return;
// First try to render structured content
this.renderConfigPanel();
this.renderTableView();
// If no structured content, show raw logs
if (!this.hasRenderedAnyContent) {
this.renderRawLogs();
}
},
// Clear the data when switching log views
clearData: function() {
this.logData.downloads = [];
// Keep raw logs for now
this.hasRenderedAnyContent = false;
}
};
// Initialize the module
document.addEventListener('DOMContentLoaded', () => {
swaparrModule.init();
if (app) {
app.swaparrModule = swaparrModule;
// Setup a handler for when log tabs are changed
document.querySelectorAll('.log-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
// If switching to swaparr tab, make sure we render the view
if (e.target.getAttribute('data-app') === 'swaparr') {
console.log('[Swaparr Module] Swaparr tab clicked via delegation');
// Small delay to allow logs to load
setTimeout(() => {
swaparrModule.ensureContentRendered();
}, 200);
}
// If switching away from swaparr tab, clear the data
else if (app.currentLogApp === 'swaparr') {
swaparrModule.clearData();
}
});
});
}
});
})(window.huntarrUI); // Pass the global UI object

View File

@@ -0,0 +1,195 @@
/**
* Whisparr.js - Handles Whisparr settings and interactions in the Huntarr UI
*/
document.addEventListener("DOMContentLoaded", function() {
// Don't call setupWhisparrForm here, new-main.js will call it when the tab is active
// setupWhisparrForm();
// setupWhisparrLogs(); // Assuming logs are handled by the main logs section
// setupClearProcessedButtons('whisparr'); // Assuming this is handled elsewhere or not needed immediately
});
/**
* Setup Whisparr settings form and connection test
* This function is now called by new-main.js when the Whisparr settings tab is shown.
*/
function setupWhisparrForm() {
// Use querySelector within the active panel to be safe, though IDs should be unique
const panel = document.getElementById('whisparrSettings');
if (!panel) {
console.warn("[whisparr.js] Whisparr settings panel not found.");
return;
}
const testWhisparrButton = panel.querySelector('#test-whisparr-button');
const whisparrStatusIndicator = panel.querySelector('#whisparr-connection-status');
const whisparrVersionDisplay = panel.querySelector('#whisparr-version');
const apiUrlInput = panel.querySelector('#whisparr_api_url');
const apiKeyInput = panel.querySelector('#whisparr_api_key');
// Check if elements exist and if listener already attached to prevent duplicates
if (!testWhisparrButton || testWhisparrButton.dataset.listenerAttached === 'true') {
console.log("[whisparr.js] Test button not found or listener already attached.");
return;
}
console.log("[whisparr.js] Setting up Whisparr form listeners.");
testWhisparrButton.dataset.listenerAttached = 'true'; // Mark as attached
// Test connection button
testWhisparrButton.addEventListener('click', function() {
const apiUrl = apiUrlInput ? apiUrlInput.value.trim() : '';
const apiKey = apiKeyInput ? apiKeyInput.value.trim() : '';
if (!apiUrl || !apiKey) {
// Use the main UI notification system if available
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Please enter both API URL and API Key for Whisparr', 'error');
} else {
alert('Please enter both API URL and API Key for Whisparr');
}
return;
}
testWhisparrButton.disabled = true;
if (whisparrStatusIndicator) {
whisparrStatusIndicator.className = 'connection-status pending';
whisparrStatusIndicator.textContent = 'Testing...';
}
// Direct connection test - let the backend handle version checking
HuntarrUtils.fetchWithTimeout('/api/whisparr/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: apiUrl,
api_key: apiKey
})
})
.then(response => response.json())
.then(data => {
if (whisparrStatusIndicator) {
if (data.success) {
whisparrStatusIndicator.className = 'connection-status success';
whisparrStatusIndicator.textContent = 'Connected';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Successfully connected to Whisparr V2', 'success');
}
getWhisparrVersion(); // Fetch version after successful connection
} else {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Failed';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Connection to Whisparr failed: ' + data.message, 'error');
}
}
}
})
.catch(error => {
if (whisparrStatusIndicator) {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Error';
}
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Error testing Whisparr connection: ' + error, 'error');
}
})
.finally(() => {
if (testWhisparrButton.disabled) {
testWhisparrButton.disabled = false;
}
});
});
// Get Whisparr version if connection details are present and version display exists
// Only perform auto-check if we haven't already fetched the version
if (apiUrlInput && apiKeyInput && whisparrVersionDisplay &&
apiUrlInput.value && apiKeyInput.value &&
(!whisparrVersionDisplay.textContent || whisparrVersionDisplay.textContent === 'Unknown')) {
// Set a flag to prevent automatic version checks from triggering unsaved changes
const wasSettingsChanged = typeof huntarrUI !== 'undefined' ? huntarrUI.settingsChanged : false;
getWhisparrVersion();
// Restore the original settingsChanged state after the version check
if (typeof huntarrUI !== 'undefined' && huntarrUI.settingsChanged !== wasSettingsChanged) {
setTimeout(() => {
huntarrUI.settingsChanged = wasSettingsChanged;
console.log("[whisparr.js] Restored settingsChanged state after version check");
// If there are no actual changes, update the save button state
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
huntarrUI.updateSaveResetButtonState(false);
}
}, 100);
}
}
// Function to get Whisparr version
function getWhisparrVersion() {
if (!whisparrVersionDisplay) return; // Check if element exists
const wasSettingsChanged = typeof huntarrUI !== 'undefined' ? huntarrUI.settingsChanged : false;
HuntarrUtils.fetchWithTimeout('/api/whisparr/get-versions')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch Whisparr version');
}
return response.json();
})
.then(data => {
if (data.success && data.version) {
// Temporarily store the textContent so we can detect if it actually changes
const oldContent = whisparrVersionDisplay.textContent;
const newContent = `v${data.version}`;
if (oldContent !== newContent) {
whisparrVersionDisplay.textContent = newContent; // Prepend 'v'
// Restore settings changed state to prevent triggering the dialog
if (typeof huntarrUI !== 'undefined') {
setTimeout(() => {
huntarrUI.settingsChanged = wasSettingsChanged;
// If there are no actual changes, update the save button state
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
huntarrUI.updateSaveResetButtonState(false);
}
}, 50);
}
}
} else {
whisparrVersionDisplay.textContent = 'Unknown';
}
})
.catch(error => {
whisparrVersionDisplay.textContent = 'Error';
console.error('Error fetching Whisparr version:', error);
})
.finally(() => {
// Final safety check to restore settings state
if (typeof huntarrUI !== 'undefined' && huntarrUI.settingsChanged !== wasSettingsChanged) {
setTimeout(() => {
huntarrUI.settingsChanged = wasSettingsChanged;
// If there are no actual changes, update the save button state
if (!wasSettingsChanged && typeof huntarrUI.updateSaveResetButtonState === 'function') {
huntarrUI.updateSaveResetButtonState(false);
}
}, 100);
}
});
}
}
// Helper function for escaping HTML (keep if needed elsewhere, e.g., if logs are added here later)
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -0,0 +1,181 @@
// Direct reset button implementation - completely separate from the regular UI
// This will add a new red button directly to the stateful management section
// Set a flag to prevent showing expiration update notification on reset
window.justCompletedStatefulReset = false;
// Keep track of the current stateful hours value to detect real changes
window.lastStatefulHoursValue = null;
// Run this code as soon as this script is loaded
(function() {
function insertDirectResetButton() {
// Look for the stateful header row
const headerRow = document.querySelector('.stateful-header-row');
if (!headerRow) {
// If we can't find it, try again soon
console.log('Stateful header not found, will try again in 1 second');
setTimeout(insertDirectResetButton, 1000);
return;
}
// Check if our button already exists to avoid duplicates
if (document.getElementById('emergency_reset_btn')) {
return;
}
console.log('Found stateful header, adding emergency reset button');
// Create the new button
const resetButton = document.createElement('button');
resetButton.id = 'emergency_reset_btn';
resetButton.innerText = '🔥 EMERGENCY RESET 🔥';
resetButton.style.background = 'linear-gradient(to right, #ff0000, #8b0000)';
resetButton.style.color = 'white';
resetButton.style.fontWeight = 'bold';
resetButton.style.border = 'none';
resetButton.style.borderRadius = '4px';
resetButton.style.padding = '8px 16px';
resetButton.style.marginLeft = '15px';
resetButton.style.cursor = 'pointer';
resetButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)';
// Add click handler for the new button
resetButton.onclick = function() {
if (confirm('⚠️ EMERGENCY RESET: Are you absolutely sure you want to reset all processed media IDs? This cannot be undone!')) {
// Show loading state
this.disabled = true;
this.innerText = '⏳ Resetting...';
this.style.background = '#666';
// Mark that we're performing a reset to prevent expiration notification
window.justCompletedStatefulReset = true;
// Make direct API call
fetch('/api/stateful/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Server returned status ' + response.status);
}
return response.json();
})
.then(data => {
alert('✅ Success! Stateful management has been reset.');
// Reload the page with a query parameter to indicate reset was done
window.location.href = window.location.pathname + '?reset=done' + window.location.hash;
})
.catch(error => {
console.error('Reset failed:', error);
alert('❌ Reset failed: ' + error.message);
// Restore button state
this.disabled = false;
this.innerText = '🔥 EMERGENCY RESET 🔥';
this.style.background = 'linear-gradient(to right, #ff0000, #8b0000)';
// Clear the reset flag since operation failed
window.justCompletedStatefulReset = false;
});
}
// Prevent event propagation
return false;
};
// Add the button to the page
headerRow.appendChild(resetButton);
console.log('Emergency reset button added successfully');
// Track the initial value of the stateful hours input
const hoursInput = document.getElementById('stateful_management_hours');
if (hoursInput) {
window.lastStatefulHoursValue = parseInt(hoursInput.value);
// Add a change listener to detect when the user actually changes the value
hoursInput.addEventListener('change', function() {
window.lastStatefulHoursValue = parseInt(this.value);
});
}
}
// Try to add the button immediately
insertDirectResetButton();
// Also try when the DOM is loaded
document.addEventListener('DOMContentLoaded', insertDirectResetButton);
// And again when everything is fully loaded
window.addEventListener('load', insertDirectResetButton);
// Also check periodically to make sure the button exists
setInterval(function() {
const headerRow = document.querySelector('.stateful-header-row');
if (headerRow && !document.getElementById('emergency_reset_btn')) {
console.log('Emergency reset button missing, re-adding it');
insertDirectResetButton();
}
}, 1000); // Check every second
// Also listen for potential UI updates that might remove our button
// Especially listen for when settings are saved
const saveButton = document.getElementById('saveSettingsButton');
if (saveButton) {
saveButton.addEventListener('click', function() {
// After settings are saved, the UI might refresh
// Wait a short moment then check if our button is still there
setTimeout(function() {
const headerRow = document.querySelector('.stateful-header-row');
if (headerRow && !document.getElementById('emergency_reset_btn')) {
console.log('Emergency reset button missing after save, re-adding it');
insertDirectResetButton();
}
}, 500); // Check half a second after save
});
}
// Add a global interceptor for the notification system
const originalShowNotification = window.huntarrUI && window.huntarrUI.showNotification;
if (originalShowNotification) {
window.huntarrUI.showNotification = function(message, type) {
// If we just completed a reset and this is an expiration update notification, don't show it
if (window.justCompletedStatefulReset && message.includes('Updated expiration to')) {
console.log('Suppressing expiration update notification after reset');
window.justCompletedStatefulReset = false; // Reset the flag
return;
}
// Also suppress expiration notifications when saving general settings if hours didn't change
if (message.includes('Updated expiration to')) {
const hoursInput = document.getElementById('stateful_management_hours');
if (hoursInput) {
const currentValue = parseInt(hoursInput.value);
// Only show notification if the value actually changed
if (window.lastStatefulHoursValue === currentValue) {
console.log('Suppressing expiration notification because hours value did not change');
return;
}
// Update our tracked value
window.lastStatefulHoursValue = currentValue;
}
}
// Saving settings already shows a "Settings saved successfully" notification,
// so we don't need the expiration one too - suppress it if we just saved settings
if (message.includes('Updated expiration to') && document.getElementById('saveSettingsButton')?.disabled) {
console.log('Suppressing expiration notification after saving general settings');
return;
}
// Otherwise, proceed with the original notification
return originalShowNotification.call(this, message, type);
};
console.log('Notification system intercepted to handle notifications properly');
}
})();

View File

@@ -0,0 +1,269 @@
/**
* GitHub Sponsors Integration
* Fetches and displays sponsors from GitHub for PlexGuide
*/
const GithubSponsors = {
// Constants
sponsorsUsername: 'plexguide',
sponsorsApiUrl: 'https://api.github.com/sponsors/',
cacheDuration: 3600000, // 1 hour in milliseconds
// Initialize the sponsors display
init: function() {
console.log('Initializing GitHub Sponsors display');
// Immediately call loadSponsors with mock data for a better user experience
// This prevents the loading spinner from staying visible
const mockSponsors = this.getImmediateMockSponsors();
this.displaySponsors(mockSponsors);
// Then load the actual data (which would be fetched from the API in a real implementation)
setTimeout(() => {
this.loadSponsors();
}, 100);
// Add event listener for manual refresh
document.addEventListener('click', function(e) {
if (e.target.closest('.action-button.refresh-sponsors')) {
GithubSponsors.loadSponsors(true);
}
});
},
// Get immediate mock sponsors without any delay
getImmediateMockSponsors: function() {
return [
{
name: 'MediaServer Pro',
url: 'https://github.com/mediaserverpro',
avatarUrl: 'https://ui-avatars.com/api/?name=MS&background=4A90E2&color=fff&size=200',
tier: 'Gold Sponsor'
},
{
name: 'StreamVault',
url: 'https://github.com/streamvault',
avatarUrl: 'https://ui-avatars.com/api/?name=SV&background=6C5CE7&color=fff&size=200',
tier: 'Gold Sponsor'
},
{
name: 'MediaStack',
url: 'https://github.com/mediastack',
avatarUrl: 'https://ui-avatars.com/api/?name=MS&background=00B894&color=fff&size=200',
tier: 'Silver Sponsor'
},
{
name: 'NASGuru',
url: 'https://github.com/nasguru',
avatarUrl: 'https://ui-avatars.com/api/?name=NG&background=FD79A8&color=fff&size=200',
tier: 'Silver Sponsor'
}
];
},
// Load sponsors data
loadSponsors: function(skipCache = false) {
// Elements
const loadingEl = document.getElementById('sponsors-loading');
const sponsorsListEl = document.getElementById('sponsors-list');
const errorEl = document.getElementById('sponsors-error');
if (!loadingEl || !sponsorsListEl || !errorEl) {
console.error('Sponsors DOM elements not found');
return;
}
// First check for cached data
const cachedData = this.getCachedSponsors();
if (!skipCache && cachedData && cachedData.sponsors) {
console.log('Using cached sponsors data');
this.displaySponsors(cachedData.sponsors);
return;
}
// Show loading state
loadingEl.style.display = 'block';
sponsorsListEl.style.display = 'none';
errorEl.style.display = 'none';
// Since GitHub's API requires authentication for the sponsors endpoint,
// we'll use a mock implementation for demonstration purposes.
// In a production environment, this would be replaced with a proper server-side
// implementation that securely accesses the GitHub API with appropriate tokens.
this.getMockSponsors()
.then(sponsors => {
// Cache the sponsors data
this.cacheSponsors(sponsors);
// Display the sponsors
this.displaySponsors(sponsors);
})
.catch(error => {
console.error('Error fetching sponsors:', error);
// Show error state
loadingEl.style.display = 'none';
errorEl.style.display = 'block';
errorEl.querySelector('span').textContent = 'Could not load sponsors: ' + error.message;
});
},
// Get cached sponsors data
getCachedSponsors: function() {
const cachedData = localStorage.getItem('huntarr-github-sponsors');
if (!cachedData) {
return null;
}
try {
const data = JSON.parse(cachedData);
// Check if cache is expired
if (Date.now() - data.timestamp > this.cacheDuration) {
console.log('Sponsors cache expired');
return null;
}
return data;
} catch (e) {
console.error('Error parsing cached sponsors data:', e);
return null;
}
},
// Cache sponsors data
cacheSponsors: function(sponsors) {
const data = {
sponsors: sponsors,
timestamp: Date.now()
};
localStorage.setItem('huntarr-github-sponsors', JSON.stringify(data));
console.log('Cached sponsors data');
},
// Display sponsors in the UI
displaySponsors: function(sponsors) {
const sponsorsListEl = document.getElementById('sponsors-list');
const loadingEl = document.getElementById('sponsors-loading');
if (!sponsorsListEl) {
console.error('Sponsors list element not found');
return;
}
// Clear existing content
sponsorsListEl.innerHTML = '';
// Hide loading spinner
if (loadingEl) {
loadingEl.style.display = 'none';
}
// Show sponsors list
sponsorsListEl.style.display = 'flex';
if (!sponsors || sponsors.length === 0) {
sponsorsListEl.innerHTML = '<div class="no-sponsors">No sponsors found</div>';
return;
}
// Shuffle and limit to 10 random sponsors
const shuffledSponsors = this.shuffleArray([...sponsors]);
const limitedSponsors = shuffledSponsors.slice(0, 10);
// Create sponsor elements
limitedSponsors.forEach(sponsor => {
const sponsorEl = document.createElement('a');
sponsorEl.href = sponsor.url;
sponsorEl.target = '_blank';
sponsorEl.className = 'sponsor-item';
sponsorEl.title = `${sponsor.name} - ${sponsor.tier}`;
sponsorEl.innerHTML = `
<img src="${sponsor.avatarUrl}" alt="${sponsor.name}" class="sponsor-avatar">
<div class="sponsor-name">${sponsor.name}</div>
<div class="sponsor-tier">${sponsor.tier}</div>
`;
sponsorsListEl.appendChild(sponsorEl);
});
},
// Utility function to shuffle an array (Fisher-Yates algorithm)
shuffleArray: function(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
},
// Mock implementation to get sponsors
getMockSponsors: function() {
return new Promise((resolve) => {
// Simulate API delay
setTimeout(() => {
const mockSponsors = [
{
name: 'MediaServer Pro',
url: 'https://github.com/mediaserverpro',
avatarUrl: 'https://ui-avatars.com/api/?name=MS&background=4A90E2&color=fff&size=200',
tier: 'Gold Sponsor'
},
{
name: 'StreamVault',
url: 'https://github.com/streamvault',
avatarUrl: 'https://ui-avatars.com/api/?name=SV&background=6C5CE7&color=fff&size=200',
tier: 'Gold Sponsor'
},
{
name: 'MediaStack',
url: 'https://github.com/mediastack',
avatarUrl: 'https://ui-avatars.com/api/?name=MS&background=00B894&color=fff&size=200',
tier: 'Silver Sponsor'
},
{
name: 'NASGuru',
url: 'https://github.com/nasguru',
avatarUrl: 'https://ui-avatars.com/api/?name=NG&background=FD79A8&color=fff&size=200',
tier: 'Silver Sponsor'
},
{
name: 'ServerSquad',
url: 'https://github.com/serversquad',
avatarUrl: 'https://ui-avatars.com/api/?name=SS&background=F1C40F&color=fff&size=200',
tier: 'Bronze Sponsor'
},
{
name: 'CloudCache',
url: 'https://github.com/cloudcache',
avatarUrl: 'https://ui-avatars.com/api/?name=CC&background=E74C3C&color=fff&size=200',
tier: 'Bronze Sponsor'
},
{
name: 'MediaMinder',
url: 'https://github.com/mediaminder',
avatarUrl: 'https://ui-avatars.com/api/?name=MM&background=9B59B6&color=fff&size=200',
tier: 'Bronze Sponsor'
},
{
name: 'StreamSage',
url: 'https://github.com/streamsage',
avatarUrl: 'https://ui-avatars.com/api/?name=SS&background=2ECC71&color=fff&size=200',
tier: 'Bronze Sponsor'
}
];
resolve(mockSponsors);
}, 800);
});
}
};
// Initialize when the document is ready
document.addEventListener('DOMContentLoaded', function() {
GithubSponsors.init();
});

View File

@@ -0,0 +1,345 @@
/**
* Huntarr - History Module
* Handles displaying and managing history entries for all media apps
*/
const historyModule = {
// State
currentApp: 'all',
currentPage: 1,
totalPages: 1,
pageSize: 20,
searchQuery: '',
isLoading: false,
// DOM elements
elements: {},
// Initialize the history module
init: function() {
this.cacheElements();
this.setupEventListeners();
// Initial load if history is active section
if (huntarrUI && huntarrUI.currentSection === 'history') {
this.loadHistory();
}
},
// Cache DOM elements
cacheElements: function() {
this.elements = {
// History dropdown
historyOptions: document.querySelectorAll('.history-option'),
currentHistoryApp: document.getElementById('current-history-app'),
historyDropdownBtn: document.querySelector('.history-dropdown-btn'),
historyDropdownContent: document.querySelector('.history-dropdown-content'),
// Table and containers
historyTable: document.querySelector('.history-table'),
historyTableBody: document.getElementById('historyTableBody'),
historyContainer: document.querySelector('.history-container'),
// Controls
historySearchInput: document.getElementById('historySearchInput'),
historySearchButton: document.getElementById('historySearchButton'),
historyPageSize: document.getElementById('historyPageSize'),
clearHistoryButton: document.getElementById('clearHistoryButton'),
// Pagination
historyPrevPage: document.getElementById('historyPrevPage'),
historyNextPage: document.getElementById('historyNextPage'),
historyCurrentPage: document.getElementById('historyCurrentPage'),
historyTotalPages: document.getElementById('historyTotalPages'),
// State displays
historyEmptyState: document.getElementById('historyEmptyState'),
historyLoading: document.getElementById('historyLoading')
};
},
// Set up event listeners
setupEventListeners: function() {
// App selection (native select)
const historyAppSelect = document.getElementById('historyAppSelect');
if (historyAppSelect) {
historyAppSelect.addEventListener('change', (e) => {
this.handleHistoryAppChange(e.target.value);
});
}
// App selection (legacy click)
this.elements.historyOptions.forEach(option => {
option.addEventListener('click', e => this.handleHistoryAppChange(e));
});
// Search
this.elements.historySearchButton.addEventListener('click', () => this.handleSearch());
this.elements.historySearchInput.addEventListener('keypress', e => {
if (e.key === 'Enter') this.handleSearch();
});
// Page size
this.elements.historyPageSize.addEventListener('change', () => this.handlePageSizeChange());
// Clear history
this.elements.clearHistoryButton.addEventListener('click', () => this.handleClearHistory());
// Pagination
this.elements.historyPrevPage.addEventListener('click', () => this.handlePagination('prev'));
this.elements.historyNextPage.addEventListener('click', () => this.handlePagination('next'));
},
// Load history data when section becomes active
loadHistory: function() {
if (this.elements.historyContainer) {
this.fetchHistoryData();
}
},
// Handle app selection changes
handleHistoryAppChange: function(eOrValue) {
let selectedApp;
if (typeof eOrValue === 'string') {
selectedApp = eOrValue;
} else if (eOrValue && eOrValue.target) {
selectedApp = eOrValue.target.getAttribute('data-app');
eOrValue.preventDefault();
}
if (!selectedApp || selectedApp === this.currentApp) return;
// Update UI (for legacy click)
if (this.elements.historyOptions) {
this.elements.historyOptions.forEach(option => {
option.classList.remove('active');
if (option.getAttribute('data-app') === selectedApp) {
option.classList.add('active');
}
});
}
// Update dropdown text (if present)
if (this.elements.currentHistoryApp) {
const displayName = selectedApp.charAt(0).toUpperCase() + selectedApp.slice(1);
this.elements.currentHistoryApp.textContent = displayName;
}
// Reset pagination
this.currentPage = 1;
// Update state and fetch data
this.currentApp = selectedApp;
this.fetchHistoryData();
},
// Handle search
handleSearch: function() {
const newSearchQuery = this.elements.historySearchInput.value.trim();
// Only fetch if search query changed
if (newSearchQuery !== this.searchQuery) {
this.searchQuery = newSearchQuery;
this.currentPage = 1; // Reset to first page
this.fetchHistoryData();
}
},
// Handle page size change
handlePageSizeChange: function() {
const newPageSize = parseInt(this.elements.historyPageSize.value);
if (newPageSize !== this.pageSize) {
this.pageSize = newPageSize;
this.currentPage = 1; // Reset to first page
this.fetchHistoryData();
}
},
// Handle pagination
handlePagination: function(direction) {
if (direction === 'prev' && this.currentPage > 1) {
this.currentPage--;
this.fetchHistoryData();
} else if (direction === 'next' && this.currentPage < this.totalPages) {
this.currentPage++;
this.fetchHistoryData();
}
},
// Handle clear history
handleClearHistory: function() {
if (confirm(`Are you sure you want to clear ${this.currentApp === 'all' ? 'all history' : this.currentApp + ' history'}?`)) {
this.clearHistory();
}
},
// Fetch history data from API
fetchHistoryData: function() {
this.setLoading(true);
// Construct URL with parameters
let url = `/api/history/${this.currentApp}?page=${this.currentPage}&page_size=${this.pageSize}`;
if (this.searchQuery) {
url += `&search=${encodeURIComponent(this.searchQuery)}`;
}
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
this.totalPages = data.total_pages;
this.renderHistoryData(data);
this.updatePaginationUI();
this.setLoading(false);
})
.catch(error => {
console.error('Error fetching history data:', error);
this.showError('Failed to load history data. Please try again later.');
this.setLoading(false);
});
},
// Clear history
clearHistory: function() {
this.setLoading(true);
fetch(`/api/history/${this.currentApp}`, {
method: 'DELETE',
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(() => {
// Reload data
this.fetchHistoryData();
})
.catch(error => {
console.error('Error clearing history:', error);
this.showError('Failed to clear history. Please try again later.');
this.setLoading(false);
});
},
// Render history data to table
renderHistoryData: function(data) {
const tableBody = this.elements.historyTableBody;
tableBody.innerHTML = '';
if (!data.entries || data.entries.length === 0) {
this.showEmptyState();
return;
}
// Hide empty state
this.elements.historyEmptyState.style.display = 'none';
this.elements.historyTable.style.display = 'table';
// Render rows
data.entries.forEach(entry => {
const row = document.createElement('tr');
// Format the instance name to include app type (capitalize first letter of app type)
const appType = entry.app_type ? entry.app_type.charAt(0).toUpperCase() + entry.app_type.slice(1) : '';
const formattedInstance = appType ? `${appType} - ${entry.instance_name}` : entry.instance_name;
row.innerHTML = `
<td>${entry.date_time_readable}</td>
<td>${this.escapeHtml(entry.processed_info)}</td>
<td>${this.formatOperationType(entry.operation_type)}</td>
<td>${this.escapeHtml(entry.id)}</td>
<td>${this.escapeHtml(formattedInstance)}</td>
<td>${this.escapeHtml(entry.how_long_ago)}</td>
`;
tableBody.appendChild(row);
});
},
// Update pagination UI
updatePaginationUI: function() {
this.elements.historyCurrentPage.textContent = this.currentPage;
this.elements.historyTotalPages.textContent = this.totalPages;
// Enable/disable pagination buttons
this.elements.historyPrevPage.disabled = this.currentPage <= 1;
this.elements.historyNextPage.disabled = this.currentPage >= this.totalPages;
},
// Show empty state
showEmptyState: function() {
this.elements.historyTable.style.display = 'none';
this.elements.historyEmptyState.style.display = 'flex';
},
// Show error
showError: function(message) {
// Use huntarrUI's notification system if available
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.showNotification === 'function') {
huntarrUI.showNotification(message, 'error');
} else {
alert(message);
}
},
// Set loading state
setLoading: function(isLoading) {
this.isLoading = isLoading;
if (isLoading) {
this.elements.historyLoading.style.display = 'flex';
this.elements.historyTable.style.display = 'none';
this.elements.historyEmptyState.style.display = 'none';
} else {
this.elements.historyLoading.style.display = 'none';
}
},
// Helper function to escape HTML
escapeHtml: function(text) {
if (text === null || text === undefined) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
},
// Helper function to format operation type
formatOperationType: function(operationType) {
switch (operationType) {
case 'missing':
return '<span class="operation-missing">Missing</span>';
case 'upgrade':
return '<span class="operation-upgrade">Upgrade</span>';
default:
return operationType ? this.escapeHtml(operationType.charAt(0).toUpperCase() + operationType.slice(1)) : 'Unknown';
}
}
};
// Initialize when huntarrUI is ready
document.addEventListener('DOMContentLoaded', () => {
historyModule.init();
// Connect with main app
if (typeof huntarrUI !== 'undefined') {
// Add loadHistory to the section switch handler
const originalSwitchSection = huntarrUI.switchSection;
huntarrUI.switchSection = function(section) {
// Call original function
originalSwitchSection.call(huntarrUI, section);
// Load history data when switching to history section
if (section === 'history') {
historyModule.loadHistory();
}
};
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
/**
* Huntarr - User Settings Page
* Handles user profile management functionality
*/
// Immediately execute this function to avoid global scope pollution
(function() {
// Wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('User settings page loaded');
// Initialize user settings functionality
initUserPage();
// Setup button handlers
setupEventHandlers();
});
function initUserPage() {
// Set active nav item
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => item.classList.remove('active'));
const userNav = document.getElementById('userNav');
if (userNav) userNav.classList.add('active');
const pageTitleElement = document.getElementById('currentPageTitle');
if (pageTitleElement) pageTitleElement.textContent = 'User Settings';
// Apply dark mode
document.body.classList.add('dark-theme');
localStorage.setItem('huntarr-dark-mode', 'true');
// Fetch user data
fetchUserInfo();
}
// Setup all event handlers for the page
function setupEventHandlers() {
// Change username handler
const saveUsernameBtn = document.getElementById('saveUsername');
if (saveUsernameBtn) {
saveUsernameBtn.addEventListener('click', handleUsernameChange);
}
// Change password handler
const savePasswordBtn = document.getElementById('savePassword');
if (savePasswordBtn) {
savePasswordBtn.addEventListener('click', handlePasswordChange);
}
// 2FA handlers
const enableTwoFactorBtn = document.getElementById('enableTwoFactor');
if (enableTwoFactorBtn) {
enableTwoFactorBtn.addEventListener('click', handleEnableTwoFactor);
}
const verifyTwoFactorBtn = document.getElementById('verifyTwoFactor');
if (verifyTwoFactorBtn) {
verifyTwoFactorBtn.addEventListener('click', handleVerifyTwoFactor);
}
const disableTwoFactorBtn = document.getElementById('disableTwoFactor');
if (disableTwoFactorBtn) {
disableTwoFactorBtn.addEventListener('click', handleDisableTwoFactor);
}
}
// Username change handler
function handleUsernameChange() {
const newUsername = document.getElementById('newUsername').value.trim();
const currentPassword = document.getElementById('currentPasswordForUsernameChange').value;
const statusElement = document.getElementById('usernameStatus');
if (!newUsername || !currentPassword) {
showStatus(statusElement, 'Please fill in all fields', 'error');
return;
}
// Min username length check
if (newUsername.length < 3) {
showStatus(statusElement, 'Username must be at least 3 characters long', 'error');
return;
}
fetch('/api/user/change-username', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: newUsername,
password: currentPassword
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showStatus(statusElement, 'Username updated successfully', 'success');
// Update displayed username
updateUsernameElements(newUsername);
// Clear form fields
document.getElementById('newUsername').value = '';
document.getElementById('currentPasswordForUsernameChange').value = '';
} else {
showStatus(statusElement, data.error || 'Failed to update username', 'error');
}
})
.catch(error => {
console.error('Error updating username:', error);
showStatus(statusElement, 'Error updating username: ' + error.message, 'error');
});
}
// Password change handler
function handlePasswordChange() {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
const statusElement = document.getElementById('passwordStatus');
if (!currentPassword || !newPassword || !confirmPassword) {
showStatus(statusElement, 'Please fill in all fields', 'error');
return;
}
if (newPassword !== confirmPassword) {
showStatus(statusElement, 'New passwords do not match', 'error');
return;
}
// Validate password (using function from user.html)
const passwordError = validatePassword(newPassword);
if (passwordError) {
showStatus(statusElement, passwordError, 'error');
return;
}
fetch('/api/user/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showStatus(statusElement, 'Password updated successfully', 'success');
// Clear form fields
document.getElementById('currentPassword').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
} else {
showStatus(statusElement, data.error || 'Failed to update password', 'error');
}
})
.catch(error => {
console.error('Error updating password:', error);
showStatus(statusElement, 'Error updating password: ' + error.message, 'error');
});
}
// 2FA setup handler
function handleEnableTwoFactor() {
fetch('/api/user/2fa/setup', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
// Update QR code and secret
const qrCodeImg = document.getElementById('qrCode');
if (qrCodeImg) {
qrCodeImg.src = data.qr_code_url;
}
const secretKeyElement = document.getElementById('secretKey');
if (secretKeyElement) {
secretKeyElement.textContent = data.secret;
}
// Show setup section
updateVisibility('enableTwoFactorSection', false);
updateVisibility('setupTwoFactorSection', true);
} else {
console.error('Failed to setup 2FA:', data.error);
alert('Failed to setup 2FA: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error setting up 2FA:', error);
alert('Error setting up 2FA: ' + error.message);
});
}
// 2FA verification handler
function handleVerifyTwoFactor() {
const code = document.getElementById('verificationCode').value;
const verifyStatusElement = document.getElementById('verifyStatus');
if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) {
showStatus(verifyStatusElement, 'Please enter a valid 6-digit verification code', 'error');
return;
}
fetch('/api/user/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showStatus(verifyStatusElement, '2FA enabled successfully', 'success');
// Update UI state
setTimeout(() => {
update2FAStatus(true);
document.getElementById('verificationCode').value = '';
}, 1500); // Short delay to allow user to see success message
} else {
showStatus(verifyStatusElement, data.error || 'Invalid verification code', 'error');
}
})
.catch(error => {
console.error('Error verifying 2FA:', error);
showStatus(verifyStatusElement, 'Error verifying code: ' + error.message, 'error');
});
}
// 2FA disable handler
function handleDisableTwoFactor() {
const password = document.getElementById('currentPasswordFor2FADisable').value;
const otpCode = document.getElementById('otpCodeFor2FADisable').value;
const disableStatusElement = document.getElementById('disableStatus');
if (!password) {
showStatus(disableStatusElement, 'Please enter your current password', 'error');
return;
}
if (!otpCode || otpCode.length !== 6 || !/^\d{6}$/.test(otpCode)) {
showStatus(disableStatusElement, 'Please enter a valid 6-digit verification code', 'error');
return;
}
fetch('/api/user/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password: password,
code: otpCode
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showStatus(disableStatusElement, '2FA disabled successfully', 'success');
// Update UI state
setTimeout(() => {
update2FAStatus(false);
document.getElementById('currentPasswordFor2FADisable').value = '';
document.getElementById('otpCodeFor2FADisable').value = '';
}, 1500); // Short delay to allow user to see success message
} else {
showStatus(disableStatusElement, data.error || 'Failed to disable 2FA', 'error');
}
})
.catch(error => {
console.error('Error disabling 2FA:', error);
showStatus(disableStatusElement, 'Error disabling 2FA: ' + error.message, 'error');
});
}
// Helper function for validation
function validatePassword(password) {
// Only check for minimum length of 8 characters
if (password.length < 8) {
return 'Password must be at least 8 characters long.';
}
return null; // Password is valid
}
// Helper function to show status messages
function showStatus(element, message, type) {
if (!element) return;
element.textContent = message;
element.className = type === 'success' ? 'status-success' : 'status-error';
element.style.display = 'block';
// Auto-hide after 5 seconds
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
// Function to fetch user information
function fetchUserInfo() {
fetch('/api/user/info')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
// Update username elements
updateUsernameElements(data.username);
// Update 2FA status
update2FAStatus(data.is_2fa_enabled);
})
.catch(error => {
console.error('Error loading user info:', error);
// Show error state in the UI
showErrorState();
});
}
// Helper functions
function updateUsernameElements(username) {
if (!username) return;
const usernameElements = [
document.getElementById('username'),
document.getElementById('currentUsername')
];
usernameElements.forEach(element => {
if (element) {
element.textContent = username;
}
});
}
function update2FAStatus(isEnabled) {
const statusElement = document.getElementById('twoFactorEnabled');
if (statusElement) {
statusElement.textContent = isEnabled ? 'Enabled' : 'Disabled';
}
// Update visibility of relevant sections
updateVisibility('enableTwoFactorSection', !isEnabled);
updateVisibility('setupTwoFactorSection', false);
updateVisibility('disableTwoFactorSection', isEnabled);
}
function updateVisibility(elementId, isVisible) {
const element = document.getElementById(elementId);
if (element) {
element.style.display = isVisible ? 'block' : 'none';
}
}
function showErrorState() {
const usernameElement = document.getElementById('currentUsername');
if (usernameElement) {
usernameElement.textContent = 'Error loading username';
}
const statusElement = document.getElementById('twoFactorEnabled');
if (statusElement) {
statusElement.textContent = 'Error loading status';
}
}
})();

View File

@@ -0,0 +1,95 @@
// Direct approach: Create a new reset button next to the existing one
// Run immediately
(function() {
// Function to create and insert our custom reset button
function createDirectResetButton() {
console.log('Creating direct reset button');
// Check if we're on the settings page and can find the stateful header
const statefulHeader = document.querySelector('.stateful-header-row');
if (!statefulHeader) {
console.log('Stateful header not found yet, waiting...');
setTimeout(createDirectResetButton, 500);
return;
}
// Check if we already added our button
if (document.getElementById('direct_reset_btn')) {
console.log('Direct reset button already exists');
return;
}
// Create our new reset button
const resetBtn = document.createElement('button');
resetBtn.id = 'direct_reset_btn';
resetBtn.className = 'danger-reset-button';
resetBtn.innerHTML = '<i class="fas fa-trash"></i> Reset (Direct)';
resetBtn.style.marginLeft = '5px';
resetBtn.style.backgroundColor = '#d9534f';
resetBtn.style.color = 'white';
resetBtn.style.border = 'none';
resetBtn.style.borderRadius = '4px';
resetBtn.style.padding = '8px 15px';
resetBtn.style.cursor = 'pointer';
// Add click handler
resetBtn.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
console.log('Direct reset button clicked!');
// Ask for confirmation
if (!confirm('Are you sure you want to reset stateful management? This will clear all processed media IDs.')) {
return false;
}
console.log('Reset confirmed, making API call');
// Show loading state
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting...';
// Make API call
fetch('/api/stateful/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(function(response) {
console.log('Got response:', response.status);
if (!response.ok) {
throw new Error('Server returned ' + response.status);
}
return response.json();
})
.then(function(data) {
console.log('Reset successful!', data);
alert('Stateful management has been reset successfully!');
window.location.reload();
})
.catch(function(error) {
console.error('Reset failed:', error);
alert('Reset failed: ' + error.message);
resetBtn.disabled = false;
resetBtn.innerHTML = '<i class="fas fa-trash"></i> Reset (Direct)';
});
return false;
};
// Add the button to the page
statefulHeader.appendChild(resetBtn);
console.log('Direct reset button added to page!');
}
// Try to create the button immediately
createDirectResetButton();
// Also try when the page is fully loaded
window.addEventListener('load', createDirectResetButton);
// And periodically check for the stateful header
setInterval(createDirectResetButton, 2000);
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
/**
* Stats Reset Handler
* Provides a unified way to handle stats reset operations
*/
document.addEventListener('DOMContentLoaded', function() {
// Find the reset button on the home page
const resetButton = document.getElementById('reset-stats');
if (resetButton) {
console.log('Stats reset button found, attaching handler');
resetButton.addEventListener('click', function(e) {
e.preventDefault();
// Prevent double-clicks
if (this.disabled) return;
// First update the UI immediately for responsive feedback
resetStatsUI();
// Then make the API call to persist the changes
resetStatsAPI()
.then(response => {
console.log('Stats reset response:', response);
if (!response.success) {
console.warn('Server reported an error with stats reset:', response.error);
}
})
.catch(error => {
console.error('Error during stats reset:', error);
});
});
}
});
/**
* Reset the stats UI immediately for responsive feedback
*/
function resetStatsUI() {
// Find all stat counters and reset them to 0
const statCounters = document.querySelectorAll('.stat-number');
statCounters.forEach(counter => {
if (counter && counter.textContent) {
counter.textContent = '0';
}
});
// Show success notification if available
if (window.huntarrUI && typeof window.huntarrUI.showNotification === 'function') {
window.huntarrUI.showNotification('Statistics reset successfully', 'success');
}
}
/**
* Make the API call to reset stats on the server
* @param {string|null} appType - Optional specific app to reset
* @returns {Promise} - Promise resolving to the API response
*/
function resetStatsAPI(appType = null) {
const requestBody = appType ? { app_type: appType } : {};
// Use the public endpoint that doesn't require authentication
return fetch('/api/stats/reset_public', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
if (!response.ok) {
throw new Error('Server responded with status: ' + response.status);
}
return response.json();
});
}
// Make resetStatsAPI available globally so other scripts can use it
window.resetStatsAPI = resetStatsAPI;

View File

@@ -0,0 +1,108 @@
(function() {
// Store logo URL consistently across the app - use local path instead of GitHub
const LOGO_URL = '/static/logo/256.png';
// Create and preload image with local path
const preloadImg = new Image();
preloadImg.src = LOGO_URL;
// Always enforce dark theme
document.documentElement.classList.add('dark-theme');
localStorage.setItem('huntarr-dark-mode', 'true');
// Add inline style to immediately set background color
// This prevents flash before the CSS files load
const style = document.createElement('style');
style.textContent = `
body, html {
background-color: #1a1d24 !important;
color: #f8f9fa !important;
}
.sidebar {
background-color: #121212 !important;
}
.top-bar {
background-color: #252a34 !important;
}
.login-container {
background-color: #252a34 !important;
}
.login-header {
background-color: #121212 !important;
}
`;
document.head.appendChild(style);
// Store the logo URL in localStorage for persistence across page loads
localStorage.setItem('huntarr-logo-url', LOGO_URL);
// Create a global function to apply the logo to all logo elements
window.applyLogoToAllElements = function() {
const logoUrl = localStorage.getItem('huntarr-logo-url') || LOGO_URL;
const logoElements = document.querySelectorAll('.logo, .login-logo');
logoElements.forEach(img => {
if (!img.src || img.src !== logoUrl) {
img.src = logoUrl;
}
// Handle image load event properly
if (img.complete) {
img.classList.add('loaded');
} else {
img.onload = function() {
this.classList.add('loaded');
};
img.onerror = function() {
// Fallback if local path fails
console.warn('Logo failed to load, trying alternate source');
if (this.src !== '/logo/256.png') {
this.src = '/logo/256.png';
}
};
}
});
// Check if the logo source needs updating
document.querySelectorAll('img[alt*="Logo"]').forEach(img => {
// Check if the src is not the correct static path
const currentSrc = new URL(img.src, window.location.origin).pathname;
if (currentSrc !== LOGO_URL) {
// Check against the old incorrect path as well, just in case
if (currentSrc === '/logo/64.png') {
img.src = LOGO_URL;
}
// You might want to add more specific checks or broader updates here
// For now, we only correct the specific incorrect path found
}
});
};
// Apply logo as soon as DOM is interactive
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.applyLogoToAllElements);
} else {
// DOMContentLoaded already fired
window.applyLogoToAllElements();
}
// Set up MutationObserver to catch any dynamically added logo elements
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function(mutations) {
let shouldApplyLogos = false;
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
shouldApplyLogos = true;
}
});
if (shouldApplyLogos) {
window.applyLogoToAllElements();
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
// Ensure logo is loaded when navigating with AJAX
window.addEventListener('load', window.applyLogoToAllElements);
})();

View File

@@ -0,0 +1,53 @@
/**
* Huntarr - User Settings Page
* Handles user profile management functionality
*/
document.addEventListener('DOMContentLoaded', function() {
// This file serves as a placeholder for any additional user management
// functionality that might be needed in the future
console.log('User settings page loaded');
// Most of the user functionality is implemented inline in the HTML page
// The following functions could be moved here in the future:
// Function to load user information
function loadUserInfo() {
fetch('/api/user/info')
.then(response => response.json())
.then(data => {
if (data.username) {
document.getElementById('username').textContent = data.username;
document.getElementById('currentUsername').value = data.username;
}
})
.catch(error => console.error('Error loading user info:', error));
}
// Function to check 2FA status
function check2FAStatus() {
fetch('/api/user/2fa-status')
.then(response => response.json())
.then(data => {
const enable2FACheckbox = document.getElementById('enable2FA');
const setup2FAContainer = document.getElementById('setup2FAContainer');
const remove2FAContainer = document.getElementById('remove2FAContainer');
if (data.enabled) {
enable2FACheckbox.checked = true;
setup2FAContainer.style.display = 'none';
remove2FAContainer.style.display = 'block';
} else {
enable2FACheckbox.checked = false;
setup2FAContainer.style.display = 'none';
remove2FAContainer.style.display = 'none';
}
})
.catch(error => console.error('Error checking 2FA status:', error));
}
// Call these functions if needed
// loadUserInfo();
// check2FAStatus();
});

View File

@@ -0,0 +1,71 @@
/**
* Huntarr - Utility Functions
* Shared functions for use across the application
*/
const HuntarrUtils = {
/**
* Fetch with timeout using the global settings
* @param {string} url - The URL to fetch
* @param {Object} options - Fetch options
* @returns {Promise} - Fetch promise with timeout handling
*/
fetchWithTimeout: function(url, options = {}) {
// Get the API timeout from global settings, default to 120 seconds if not set
let apiTimeout = 120000; // Default 120 seconds in milliseconds
// Try to get timeout from huntarrUI if available
if (window.huntarrUI && window.huntarrUI.originalSettings &&
window.huntarrUI.originalSettings.general &&
window.huntarrUI.originalSettings.general.api_timeout) {
apiTimeout = window.huntarrUI.originalSettings.general.api_timeout * 1000;
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), apiTimeout);
// Merge options with signal from AbortController
const fetchOptions = {
...options,
signal: controller.signal
};
return fetch(url, fetchOptions)
.then(response => {
clearTimeout(timeoutId);
return response;
})
.catch(error => {
clearTimeout(timeoutId);
// Customize the error if it was a timeout
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${apiTimeout / 1000} seconds`);
}
throw error;
});
},
/**
* Get the global API timeout value in seconds
* @returns {number} - API timeout in seconds
*/
getApiTimeout: function() {
// Default value
let timeout = 120;
// Try to get from global settings
if (window.huntarrUI && window.huntarrUI.originalSettings &&
window.huntarrUI.originalSettings.general &&
window.huntarrUI.originalSettings.general.api_timeout) {
timeout = window.huntarrUI.originalSettings.general.api_timeout;
}
return timeout;
}
};
// If running in Node.js environment
if (typeof module !== 'undefined' && module.exports) {
module.exports = HuntarrUtils;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<!-- ...existing code... -->
<!-- Scripts -->
<!-- <script src="/static/js/settings_sync_utility.js"></script> Removed -->
<!-- <script src="/static/js/core.js"></script> Removed -->
<!-- <script src="/static/js/settings_loader.js"></script> Removed -->
<script src="/static/js/settings_forms.js"></script> <!-- Keep for now -->
<!-- <script src="/static/js/settings_initializer.js"></script> Removed -->
<!-- <script src="/static/js/settings_sync.js"></script> Removed -->
<script src="/static/js/new-main.js"></script> <!-- Consolidated main script -->
<!-- App-specific scripts -->
<script src="/static/js/apps/sonarr.js"></script>
<script src="/static/js/apps/radarr.js"></script>
<script src="/static/js/apps/lidarr.js"></script>
<script src="/static/js/apps/readarr.js"></script>
<script src="/static/js/apps/swaparr.js"></script>
<!-- ...existing code... -->
</body>
</html>

View File

@@ -0,0 +1,233 @@
<section id="appsSection" class="content-section">
<div class="section-header">
<div class="log-dropdown">
<select id="appsAppSelect" class="styled-select">
<option value="sonarr">Sonarr</option>
<option value="radarr">Radarr</option>
<option value="lidarr">Lidarr</option>
<option value="readarr">Readarr</option>
<option value="whisparr">Whisparr V2</option>
<option value="eros">Whisparr V3</option>
<option value="swaparr">Swaparr</option>
<option value="cleanuperr">Cleanuperr</option>
</select>
</div>
<button id="saveAppsButton" class="save-button" disabled>
<i class="fas fa-save"></i> Save
</button>
</div>
<!-- Single scrollable container for all content -->
<div class="single-scroll-container">
<div id="appsContainer" class="apps-container">
<!-- Settings content will be shown here -->
</div>
<div id="appsStatus" class="apps-status"></div>
<!-- App panels at the bottom -->
<div class="app-panels-container">
<div id="sonarrApps" class="app-apps-panel app-content-panel active" style="display: block;"></div>
<div id="radarrApps" class="app-apps-panel app-content-panel"></div>
<div id="lidarrApps" class="app-apps-panel app-content-panel"></div>
<div id="readarrApps" class="app-apps-panel app-content-panel"></div>
<div id="whisparrApps" class="app-apps-panel app-content-panel"></div>
<div id="erosApps" class="app-apps-panel app-content-panel"></div>
<div id="swaparrApps" class="app-apps-panel app-content-panel"></div>
<div id="cleanuperrApps" class="app-apps-panel app-content-panel"></div>
</div>
</div>
</section>
<style>
/* Apps Section Layout - Complete Redesign */
#appsSection {
display: none;
width: 100%;
height: auto;
overflow: hidden; /* Prevent double scrollbar */
padding-bottom: 60px; /* Clear space at the bottom */
}
#appsSection.active {
display: block;
}
/* Single scroll container - ONLY this element should scroll */
.single-scroll-container {
display: block;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 100px; /* Significant padding to avoid content being cut off */
min-height: 100%;
height: auto;
max-height: unset; /* Remove any max-height restriction */
scrollbar-width: thin; /* Firefox */
position: relative;
}
/* Force-hide scrollbars on all other elements */
body, html, .main-content, .content-section,
.app-apps-panel, .app-content-panel, #appsContainer,
.app-panels-container, #appsStatus {
overflow: hidden !important;
scrollbar-width: none !important; /* Firefox */
-ms-overflow-style: none !important; /* IE/Edge */
}
/* Proper table positioning at bottom */
.app-panels-container {
margin-top: auto;
padding: 10px 0 0;
width: 100%;
}
/* Ensure Additional Options section is fully visible */
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps, #whisparrApps, #erosApps, #swaparrApps {
padding-bottom: 150px; /* Extra padding to ensure bottom content is visible */
margin-bottom: 50px;
}
/* Add explicit styling for the Additional Options section */
.additional-options-section, .additional-options {
margin-bottom: 100px;
padding-bottom: 100px;
}
/* Ensure Skip Series Refresh is visible */
.skip-series-refresh {
margin-bottom: 50px;
padding-bottom: 50px;
}
/* Panel styling */
.app-apps-panel {
padding-bottom: 10px;
min-height: 0;
height: auto;
}
/* Fix dropdown positioning for apps section */
.log-dropdown-content {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
z-index: 100;
background: linear-gradient(135deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(90, 109, 137, 0.2);
margin-top: 5px;
transform: translateY(0);
max-height: 300px;
overflow-y: auto;
}
/* Apps container styling */
#appsContainer {
height: auto;
flex: 1;
overflow: visible;
}
/* Prevent scrollbars on all table elements */
table, tbody, tr, td {
overflow: visible !important;
}
/* Hide scrollbars except the main content wrapper */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Hide all scrollbars except on .single-scroll-container */
body::-webkit-scrollbar,
html::-webkit-scrollbar,
.main-content::-webkit-scrollbar,
.content-section::-webkit-scrollbar,
#appsSection::-webkit-scrollbar,
#appsContainer::-webkit-scrollbar,
.app-panels-container::-webkit-scrollbar,
.app-content-panel::-webkit-scrollbar,
.app-apps-panel::-webkit-scrollbar,
#sonarrApps::-webkit-scrollbar,
#radarrApps::-webkit-scrollbar,
#lidarrApps::-webkit-scrollbar,
#readarrApps::-webkit-scrollbar,
#whisparrApps::-webkit-scrollbar,
#erosApps::-webkit-scrollbar,
#swaparrApps::-webkit-scrollbar,
table::-webkit-scrollbar,
tr::-webkit-scrollbar,
td::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* Complete overhaul for mobile and desktop visibility */
@media (max-width: 768px) {
/* Full display mode for mobile */
#appsSection, #appsSection.active {
display: block;
height: auto;
overflow-y: visible;
overflow-x: hidden;
padding-bottom: 150px;
}
/* Completely redesign scroll container for mobile */
.single-scroll-container {
display: block;
position: relative;
overflow-y: visible;
height: auto;
min-height: 100%;
padding-bottom: 200px;
}
/* Ensure proper section isolation */
body, html, .main-content {
overflow-y: auto !important;
height: auto !important;
}
/* Prevent other sections from showing */
.content-section:not(.active) {
display: none !important;
height: 0 !important;
visibility: hidden !important;
position: absolute !important;
z-index: -999 !important;
overflow: hidden !important;
opacity: 0 !important;
}
/* Extra space for bottom content */
.app-panels-container {
margin-bottom: 100px;
}
/* Force bottom option visibility */
.additional-options, .skip-series-refresh {
margin-bottom: 150px !important;
padding-bottom: 150px !important;
}
}
/* Desktop padding adjustments */
@media (min-width: 769px) {
.single-scroll-container {
padding-bottom: 200px;
}
.additional-options, .skip-series-refresh {
margin-bottom: 150px;
padding-bottom: 150px;
}
}
</style>

View File

@@ -0,0 +1,426 @@
<section id="cleanuperrSection" class="content-section">
<div class="section-header">
<div class="header-buttons">
<button id="backToAppsButton" class="back-button">
<i class="fas fa-arrow-left"></i> Back to Apps
</button>
</div>
<h2>Cleanuperr</h2>
</div>
<div class="cleanuperr-container">
<div class="cleanuperr-info-box">
<div class="cleanuperr-header">
<div class="cleanuperr-logo">
<img src="/static/logo/apps/cleanuperr.png" alt="Cleanuperr Logo" class="app-logo">
</div>
<div class="cleanuperr-title">
<h3>Cleanuperr by <a href="https://github.com/Flaminel" target="_blank">Flaminel</a></h3>
</div>
</div>
<div class="github-info-container">
<a href="https://github.com/flmorg/cleanuperr" class="github-link" target="_blank">
<i class="fab fa-github"></i> View on GitHub
</a>
<div class="github-stars">
<i class="fas fa-star star-icon"></i>
<span id="cleanuperr-stars-count">Loading...</span>
</div>
</div>
<div class="cleanuperr-description">
<p>Cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuperr can also trigger a search to replace the deleted shows/movies.</p>
<h4>Key Features:</h4>
<ul>
<li>Strike system to mark stalled or downloads stuck in metadata downloading</li>
<li>Remove and block downloads that reached a maximum number of strikes</li>
<li>Remove and block downloads that have a low download speed or high estimated completion time</li>
<li>Remove downloads blocked by qBittorrent or by Cleanuperr's content blocker</li>
<li>Trigger a search for downloads removed from the *arrs</li>
<li>Clean up downloads that have been seeding for a certain amount of time</li>
<li>Notify on strike or download removal</li>
<li>Ignore certain torrent hashes, categories, tags or trackers from being processed</li>
</ul>
<div class="origin-section">
<p>Cleanuperr was created primarily to address malicious files, such as *.lnk or *.zipx, that were getting stuck in Sonarr/Radarr and required manual intervention. It supports both qBittorrent's built-in exclusion features and its own blocklist-based system.</p>
</div>
<div class="author-section">
<div class="author-info">
<img src="https://avatars.githubusercontent.com/u/6135377?v=4" alt="Flaminel" class="author-avatar">
<div class="author-details">
<h4>About the Author</h4>
<p>Cleanuperr is developed by <a href="https://github.com/Flaminel" target="_blank">Flaminel</a>, a passionate developer focused on creating tools that enhance the media server experience.</p>
</div>
</div>
</div>
<div class="collaboration-note">
<p>Huntarr is proud to feature Cleanuperr as part of our commitment to helping other projects grow. We believe in collaboration across the media server community to create better tools for everyone.</p>
</div>
<div class="cleanuperr-cta">
<a href="https://github.com/flmorg/cleanuperr" class="button primary-button" target="_blank">
<i class="fab fa-github"></i> GitHub
</a>
<a href="https://discord.com/invite/SCtMCgtsc4" class="button discord-button" target="_blank">
<i class="fab fa-discord"></i> Join Discord
</a>
<a href="https://github.com/flmorg/cleanuperr#installation" class="button secondary-button" target="_blank">
<i class="fas fa-book"></i> Installation Guide
</a>
</div>
</div>
</div>
</div>
</section>
<style>
#cleanuperrSection {
padding: 20px;
max-width: 100%;
display: none;
}
#cleanuperrSection.active {
display: block;
}
.cleanuperr-container {
width: 100%;
margin-bottom: 20px;
}
.cleanuperr-info-box {
background: linear-gradient(135deg, rgba(28, 36, 54, 0.9), rgba(24, 32, 48, 0.8));
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
border: 1px solid rgba(90, 109, 137, 0.2);
}
.cleanuperr-header {
display: flex;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 20px;
}
.cleanuperr-logo {
display: flex;
align-items: center;
justify-content: center;
}
.app-logo {
width: 64px;
height: 64px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}
.github-info-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin: 15px 0;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
border: 1px solid rgba(90, 109, 137, 0.2);
}
.github-stars {
display: flex;
align-items: center;
gap: 5px;
font-size: 16px;
}
.star-icon {
color: #f1c40f;
}
.cleanuperr-title {
flex: 2;
}
.cleanuperr-title h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 24px;
color: #e0e6ed;
}
.github-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: linear-gradient(135deg, #2c3e50, #1a2632);
border-radius: 6px;
color: #e0e6ed;
text-decoration: none;
font-size: 14px;
transition: all 0.3s ease;
border: 1px solid rgba(90, 109, 137, 0.3);
}
.github-link:hover {
background: linear-gradient(135deg, #34495e, #2c3e50);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.cleanuperr-description {
color: #e0e6ed;
line-height: 1.6;
}
.cleanuperr-description h4 {
margin-top: 20px;
margin-bottom: 10px;
color: #3498db;
}
.cleanuperr-description ul {
margin-left: 20px;
margin-bottom: 20px;
}
.cleanuperr-description li {
margin-bottom: 8px;
}
.author-section {
margin: 30px 0;
padding: 20px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
border: 1px solid rgba(90, 109, 137, 0.2);
}
.author-info {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.author-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid #3498db;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.author-details {
flex: 1;
}
.author-details h4 {
margin-top: 0;
color: #3498db;
}
.origin-section {
margin: 25px 0;
padding: 15px;
background: rgba(41, 128, 185, 0.1);
border-radius: 8px;
border: 1px solid rgba(41, 128, 185, 0.2);
}
.collaboration-note {
margin: 30px 0;
padding: 15px;
background: rgba(52, 152, 219, 0.1);
border-left: 4px solid #3498db;
border-radius: 0 8px 8px 0;
}
.cleanuperr-cta {
display: flex;
gap: 15px;
margin-top: 30px;
flex-wrap: wrap;
}
.button, .back-button {
padding: 10px 20px;
border-radius: 8px;
font-weight: bold;
text-decoration: none;
text-align: center;
transition: all 0.3s ease;
}
.back-button {
background: linear-gradient(135deg, #34495e, #2c3e50);
color: white;
border: none;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.back-button:hover {
background: linear-gradient(135deg, #3a536b, #34495e);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
.header-buttons {
margin-right: 20px;
}
.primary-button {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
}
.primary-button:hover {
background: linear-gradient(135deg, #2980b9, #2471a3);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.3);
}
.discord-button {
background: linear-gradient(135deg, #5865F2, #4752C4);
color: white;
border: none;
}
.discord-button:hover {
background: linear-gradient(135deg, #4752C4, #3C45A5);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(88, 101, 242, 0.4);
}
.secondary-button {
background: transparent;
color: #3498db;
border: 1px solid #3498db;
}
.secondary-button:hover {
background: rgba(52, 152, 219, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.2);
}
@media (max-width: 768px) {
.cleanuperr-header {
flex-direction: column;
}
.cleanuperr-logo {
width: 100%;
}
.author-info {
flex-direction: column;
text-align: center;
}
.cleanuperr-cta {
flex-direction: column;
}
.button {
width: 100%;
}
}
</style>
<script>
// Add event listener to the back button to return to Apps section
document.addEventListener('DOMContentLoaded', function() {
const backToAppsButton = document.getElementById('backToAppsButton');
if (backToAppsButton) {
backToAppsButton.addEventListener('click', function() {
// Hide Cleanuperr section
document.getElementById('cleanuperrSection').classList.remove('active');
// Show Apps section
document.getElementById('appsSection').classList.add('active');
// Update the current section in the main UI
if (huntarrUI) {
huntarrUI.currentSection = 'apps';
// Update the page title
const pageTitleElement = document.getElementById('currentPageTitle');
if (pageTitleElement) {
pageTitleElement.textContent = 'Apps';
}
// Update the selected menu item
const menuItems = document.querySelectorAll('.nav-item');
menuItems.forEach(item => {
item.classList.remove('active');
if (item.getAttribute('data-section') === 'apps') {
item.classList.add('active');
}
});
// Reset the app selector to a default app (not Cleanuperr)
const appsAppSelect = document.getElementById('appsAppSelect');
if (appsAppSelect) {
// Set to the first option that isn't Cleanuperr
for (let i = 0; i < appsAppSelect.options.length; i++) {
if (appsAppSelect.options[i].value !== 'cleanuperr') {
appsAppSelect.selectedIndex = i;
break;
}
}
}
}
});
}
});
// Load GitHub star count for Cleanuperr
function loadCleanuperrStarCount() {
fetch('https://api.github.com/repos/flmorg/cleanuperr')
.then(response => {
if (!response.ok) {
// Handle rate limiting or other errors
if (response.status === 403) {
console.warn('GitHub API rate limit likely exceeded.');
throw new Error('Rate limited');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
const starsElement = document.getElementById('cleanuperr-stars-count');
if (starsElement && data && data.stargazers_count !== undefined) {
starsElement.textContent = data.stargazers_count;
} else if (starsElement) {
starsElement.textContent = 'N/A';
}
})
.catch(error => {
console.error('Error loading Cleanuperr star count from GitHub:', error);
const starsElement = document.getElementById('cleanuperr-stars-count');
if (starsElement) {
starsElement.textContent = error.message === 'Rate limited' ? 'Rate Limited' : 'Error';
}
});
}
// Load star count when the page is loaded
loadCleanuperrStarCount();
</script>

View File

@@ -0,0 +1,68 @@
<!-- Empty footer - links moved to Support Huntarr card on homepage -->
<style>
/* Footer styling */
.banner-footer {
text-align: center;
padding: 10px 20px;
background-color: #1c2231;
border-top: 1px solid rgba(90, 109, 137, 0.15);
position: fixed;
bottom: 0;
width: 100%;
left: 0;
z-index: 1000;
}
/* Add padding to the body to prevent content from being hidden behind the footer */
body {
padding-bottom: 50px;
}
/* Make sure dropdowns appear above the footer */
.dropdown-menu {
z-index: 1001;
}
.banner-footer .footer-content {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
max-width: 1200px;
margin: 0 auto;
}
.banner-footer .footer-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #e0e6ed;
}
.banner-footer a {
color: #59a0ff;
text-decoration: none;
transition: color 0.2s ease;
}
.banner-footer a:hover {
color: #4a90e2;
text-decoration: underline;
}
.banner-footer .footer-divider {
width: 1px;
height: 20px;
background-color: rgba(138, 153, 175, 0.3);
}
.donation-icon {
color: #e74c3c;
}
.star-icon {
color: #f1c40f;
}
</style>

View File

@@ -0,0 +1,19 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Preload logo to prevent flashing -->
<link rel="preload" href="/static/logo/256.png" as="image" fetchpriority="high">
<!-- Preload theme script to prevent flashing -->
<script src="/static/js/theme-preload.js"></script>
<link rel="stylesheet" href="/static/css/new-style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="icon" href="/static/logo/16.png">
<!-- Better logo visibility handling -->
<style>
.logo, .login-logo {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.logo.loaded, .login-logo.loaded {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,703 @@
<section id="historySection" class="content-section">
<div class="section-header">
<!-- History App Dropdown -->
<div class="history-dropdown-container">
<select id="historyAppSelect" class="styled-select">
<option value="all">All</option>
<option value="sonarr">Sonarr</option>
<option value="radarr">Radarr</option>
<option value="lidarr">Lidarr</option>
<option value="readarr">Readarr</option>
<option value="whisparr">Whisparr</option>
<option value="eros">Whisparr V3</option>
</select>
</div>
<!-- Search and Controls -->
<div class="history-controls">
<div class="history-search">
<input type="text" id="historySearchInput" placeholder="Search...">
<button id="historySearchButton">
<i class="fas fa-search"></i>
</button>
</div>
<div class="history-page-size">
<label for="historyPageSize">Show:</label>
<select id="historyPageSize">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="1000">1000</option>
</select>
</div>
<button id="clearHistoryButton" class="clear-button">
<i class="fas fa-trash-alt"></i> Clear
</button>
</div>
</div>
<div class="history-container">
<div class="modern-table-wrapper force-scrollbar">
<table class="modern-table history-table">
<thead>
<tr>
<th style="min-width: 140px; width: 15%;">Date and Time</th>
<th style="min-width: 250px; width: 35%;">Processed Information</th>
<th style="min-width: 100px; width: 10%;">Operation</th>
<th style="min-width: 100px; width: 10%;">ID Number</th>
<th style="min-width: 120px; width: 15%;">Name of Instance</th>
<th style="min-width: 120px; width: 15%;">How Long Ago</th>
</tr>
</thead>
<tbody id="historyTableBody">
<!-- History items will be loaded here -->
</tbody>
</table>
</div>
<!-- Empty state message -->
<div id="historyEmptyState" class="empty-state-message">
<i class="fas fa-history fa-3x"></i>
<p>No history found. Items will appear here when media is processed.</p>
</div>
<!-- Loading indicator -->
<div id="historyLoading" class="loading-indicator">
<i class="fas fa-spinner fa-spin fa-3x"></i>
<p>Loading history...</p>
</div>
</div>
<!-- Pagination controls -->
<div class="pagination-controls mobile-sticky">
<button id="historyPrevPage" class="pagination-button"><i class="fas fa-chevron-left"></i> Previous</button>
<span id="historyPageInfo">Page <span id="historyCurrentPage">1</span> of <span id="historyTotalPages">1</span></span>
<button id="historyNextPage" class="pagination-button">Next <i class="fas fa-chevron-right"></i></button>
</div>
</section>
<style>
/* Scope all styles to this section only */
#historySection {
width: 100%;
display: none;
}
#historySection.active {
display: block;
}
/* Scrollbar handling for only this section */
#historySection .history-container {
overflow: visible !important;
}
/* Hide WebKit scrollbar for main elements */
html::-webkit-scrollbar, body::-webkit-scrollbar, #app::-webkit-scrollbar,
.main-content::-webkit-scrollbar, .content-section::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* Table scrollbar styling - same as logs section */
.modern-table-wrapper::-webkit-scrollbar {
width: 10px;
}
.modern-table-wrapper::-webkit-scrollbar-track {
background: rgba(18, 22, 30, 0.5);
border-radius: 0 10px 10px 0;
}
.modern-table-wrapper::-webkit-scrollbar-thumb {
background: rgba(90, 109, 137, 0.5);
border-radius: 5px;
}
.modern-table-wrapper::-webkit-scrollbar-thumb:hover {
background: rgba(90, 109, 137, 0.8);
}
/* Make space for the fixed pagination on mobile */
.history-container {
padding-bottom: 60px;
}
/* Main container styling */
.content-section {
max-width: 100%;
}
/* Modern History Section Styling */
#historySection {
padding: 20px;
max-width: 100%;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.history-container {
width: 100%;
margin-bottom: 20px;
}
/* Dropdown styling */
.history-dropdown {
position: relative;
display: inline-block;
}
.history-dropdown-btn {
background: linear-gradient(135deg, rgba(28, 36, 54, 0.9), rgba(24, 32, 48, 0.8));
color: white;
padding: 8px 15px;
border: 1px solid rgba(90, 109, 137, 0.3);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
min-width: 120px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.history-dropdown-btn:hover {
background: linear-gradient(135deg, rgba(38, 46, 64, 0.9), rgba(34, 42, 58, 0.8));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.history-dropdown-btn i {
margin-left: auto;
transition: transform 0.3s ease;
}
.history-dropdown-content {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
z-index: 100;
background: linear-gradient(135deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(90, 109, 137, 0.2);
overflow: hidden;
margin-top: 5px;
padding-top: 5px;
padding-bottom: 5px;
}
/* Fix dropdown disappearing when moving to content */
.history-dropdown-wrapper {
position: relative;
padding-bottom: 5px; /* Create space to safely move to dropdown */
}
.history-dropdown-wrapper:after {
content: '';
position: absolute;
height: 10px; /* Invisible bridge between button and content */
bottom: 0;
left: 0;
right: 0;
z-index: 99;
}
.history-dropdown-wrapper:hover .history-dropdown-content,
.history-dropdown-content:hover {
display: block;
}
.history-dropdown-wrapper:hover .history-dropdown-btn i {
transform: rotate(180deg);
}
.history-option {
color: white;
padding: 10px 15px;
text-decoration: none;
display: block;
transition: all 0.2s ease;
text-align: left;
border-bottom: 1px solid rgba(90, 109, 137, 0.1);
}
.history-option:last-child {
border-bottom: none;
}
.history-option:hover {
background: rgba(65, 105, 225, 0.2);
}
.history-option.active {
background: rgba(65, 105, 225, 0.3);
font-weight: 500;
}
/* Search and controls styling */
.history-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.history-search {
position: relative;
width: 250px;
}
.history-search input {
width: 100%;
padding: 8px 40px 8px 12px;
border: 1px solid rgba(90, 109, 137, 0.3);
border-radius: 8px;
background-color: rgba(28, 36, 54, 0.6);
color: white;
font-size: 14px;
transition: all 0.3s ease;
}
.history-search input:focus {
outline: none;
border-color: rgba(65, 105, 225, 0.6);
box-shadow: 0 0 0 2px rgba(65, 105, 225, 0.2);
}
.history-search button {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 5px;
transition: color 0.2s ease;
}
.history-search button:hover {
color: rgba(255, 255, 255, 1);
}
.history-page-size {
display: flex;
align-items: center;
gap: 8px;
}
.history-page-size label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
.history-page-size select {
padding: 6px 10px;
border: 1px solid rgba(90, 109, 137, 0.3);
border-radius: 6px;
background-color: rgba(28, 36, 54, 0.6);
color: white;
cursor: pointer;
transition: all 0.3s ease;
}
.history-page-size select:focus {
outline: none;
border-color: rgba(65, 105, 225, 0.6);
}
.clear-button {
padding: 8px 16px;
background: linear-gradient(135deg, #e74c3c 0%, #f15846 100%);
border: none;
border-radius: 8px;
color: white;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(231, 76, 60, 0.3);
}
.clear-button:hover {
background: linear-gradient(135deg, #f15846 0%, #f3695a 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
/* Modern-table-wrapper for scrollable tables - using the same approach as logs */
.modern-table-wrapper {
width: 100%;
height: calc(100vh - 200px); /* Same method used in logs section */
overflow-y: auto;
overflow-x: auto;
position: relative;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding-right: 5px;
}
/* We don't need this class anymore since we're using a simpler approach */
/* Make Processed Information column text wrap for very long content */
.history-table td:nth-child(2) {
white-space: normal;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
max-width: 400px;
}
.modern-table, .history-table {
width: 100%;
min-width: 830px; /* Sum of all min-widths */
border-collapse: collapse;
text-align: left;
color: white;
table-layout: fixed;
}
.modern-table thead, .history-table thead {
background: linear-gradient(90deg, rgba(18, 22, 30, 0.95), rgba(24, 28, 37, 0.9));
position: sticky;
top: 0;
z-index: 10;
}
.modern-table th, .history-table th {
padding: 14px 15px;
font-weight: 600;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(90, 109, 137, 0.25);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modern-table td, .history-table td {
padding: 12px 15px;
border-bottom: 1px solid rgba(90, 109, 137, 0.1);
font-size: 14px;
transition: background-color 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modern-table tbody tr, .history-table tbody tr {
transition: background-color 0.2s ease;
}
.modern-table tbody tr:hover, .history-table tbody tr:hover {
background-color: rgba(65, 105, 225, 0.1);
}
.modern-table tbody tr:last-child td, .history-table tbody tr:last-child td {
border-bottom: none;
}
/* Operation status styling */
.operation-status {
display: inline-block;
padding: 5px 10px;
border-radius: 4px;
font-weight: 500;
font-size: 12px;
text-align: center;
min-width: 80px;
}
.operation-status.success {
background-color: rgba(40, 167, 69, 0.2);
color: #2ecc71;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.operation-status.error, .operation-status.missing {
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.operation-status.warning {
background-color: rgba(255, 193, 7, 0.2);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.operation-status.upgrade {
background-color: rgba(0, 123, 255, 0.2);
color: #03a9f4;
border: 1px solid rgba(0, 123, 255, 0.3);
}
/* Empty state and loading styling */
.empty-state-message, .loading-indicator {
text-align: center;
padding: 50px 20px;
color: rgba(255, 255, 255, 0.7);
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
.empty-state-message i, .loading-indicator i {
margin-bottom: 15px;
opacity: 0.8;
}
.empty-state-message p, .loading-indicator p {
font-size: 16px;
}
/* Pagination controls styling */
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
padding: 10px;
background: rgba(22, 26, 34, 0.9);
z-index: 1000;
}
.mobile-sticky {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(90, 109, 137, 0.3);
}
@media (min-width: 769px) {
.mobile-sticky {
position: static;
box-shadow: none;
border-top: none;
background: transparent;
}
}
.pagination-button {
padding: 8px 16px;
background: linear-gradient(135deg, rgba(28, 36, 54, 0.8), rgba(24, 32, 48, 0.7));
border: 1px solid rgba(90, 109, 137, 0.3);
border-radius: 8px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.pagination-button:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(38, 46, 64, 0.9), rgba(34, 42, 58, 0.8));
transform: translateY(-1px);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#historyPageInfo {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
/* Mobile Table Adjustments */
@media (max-width: 768px) {
/* Keep all columns at their natural width */
.modern-table, .history-table {
table-layout: fixed;
min-width: 830px; /* Sum of all min-widths */
width: auto;
}
/* Ensure the wrapper allows horizontal scrolling */
.modern-table-wrapper {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
/* General responsive styling */
.section-header {
flex-direction: column;
align-items: flex-start;
}
.history-dropdown-container, .history-controls {
width: 100%;
}
.history-search {
width: 100%;
}
.pagination-controls {
flex-wrap: wrap;
margin: 0;
display: flex;
justify-content: space-between;
width: 100%;
padding: 10px;
z-index: 1000;
}
.pagination-button {
padding: 8px 12px;
font-size: 14px;
min-width: 85px;
}
#historyPageInfo {
font-size: 14px;
white-space: nowrap;
}
/* Ensure the table wrapper doesn't overflow in mobile */
.modern-table-wrapper {
max-height: calc(100vh - 300px);
padding-bottom: 60px; /* Add padding to ensure content isn't hidden under pagination */
}
/* Fix for small screen height */
@media (max-height: 700px) {
.pagination-controls {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
width: 100% !important;
}
}
}
</style>
<script>
// Fixes for formatting the history items
document.addEventListener('DOMContentLoaded', function() {
// Simplified scrollbar setup for history section - matches logs approach
const fixHistoryScrolling = function() {
// Apply minimal CSS needed for proper table scrolling
const style = document.createElement('style');
style.id = 'history-scrollbar-fix';
style.innerHTML = `
/* Ensure the history container allows proper scrolling */
.history-container {
overflow: visible !important;
}
/* Ensure the table wrapper has proper scrolling */
.modern-table-wrapper {
height: calc(100vh - 200px) !important;
overflow-y: auto !important;
overflow-x: auto !important;
}
/* Prevent text from wrapping in table cells except for the second column */
.modern-table td:not(:nth-child(2)), .history-table td:not(:nth-child(2)) {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
`;
document.head.appendChild(style);
// Keep the table wrapper reference for potential future use
const tableWrapper = document.querySelector('.modern-table-wrapper');
if (tableWrapper) {
// Remove any inline styles that could be interfering
tableWrapper.style.removeProperty('overflow-y');
tableWrapper.style.removeProperty('overflow-x');
tableWrapper.style.removeProperty('height');
tableWrapper.style.removeProperty('max-height');
tableWrapper.style.removeProperty('min-height');
// Let the CSS handle the scrollbar
tableWrapper.classList.remove('force-scrollbar');
}
};
// Run immediately for history page
if (huntarrUI && huntarrUI.currentSection === 'history') {
setTimeout(fixHistoryScrolling, 100);
}
// Add event listener for section changes
document.addEventListener('sectionChanged', function(e) {
if (e.detail.section === 'history') {
setTimeout(fixHistoryScrolling, 100);
} else {
// Remove existing style if it exists
const existingStyle = document.getElementById('history-scrollbar-fix');
if (existingStyle) {
existingStyle.remove();
}
// Force the table wrapper to have a fixed height to ensure scrollbar appears
const tableWrapper = document.querySelector('.modern-table-wrapper');
if (tableWrapper) {
// Add a class to force scrollbars to be visible
tableWrapper.classList.add('force-scrollbar');
}
// Also clear inline styles
document.querySelectorAll('#app, .main-content, body, #main, .main-content > .inner, .section-content').forEach(container => {
if (container) {
container.style.overflow = '';
}
});
}
});
// Original function exists in history.js
const originalRenderHistoryData = historyModule.renderHistoryData;
if (typeof historyModule !== 'undefined') {
// Override the render method to apply our styling
historyModule.renderHistoryData = function(data) {
// Call the original render method
originalRenderHistoryData.call(this, data);
// After the data is rendered, format the operation status columns
const operationCells = document.querySelectorAll('#historyTableBody tr td:nth-child(3)');
operationCells.forEach(cell => {
const operationText = cell.textContent.trim();
const statusClass = operationText.toLowerCase() === 'success' ? 'success' :
operationText.toLowerCase() === 'missing' ? 'missing' :
operationText.toLowerCase() === 'upgrade' ? 'upgrade' :
operationText.toLowerCase() === 'warning' ? 'warning' : 'error';
cell.innerHTML = `<span class="operation-status ${statusClass}">${operationText}</span>`;
});
};
}
});
</script>

View File

@@ -0,0 +1,959 @@
<section id="homeSection" class="content-section">
<div class="dashboard-grid stack-layout mobile-scrollable">
<!-- Support Links: Header removed, integrated directly into the layout -->
<div class="card support-links-card">
<div class="support-links-container">
<div class="support-links-grid">
<a href="https://github.com/plexguide/huntarr" target="_blank" class="support-link github">
<div class="icon-container">
<i class="fas fa-star star-icon"></i>
</div>
<div class="link-details">
<span class="link-title">Star on GitHub</span>
<span class="link-description">Helps the project!</span>
</div>
</a>
<a href="https://donate.plex.one" target="_blank" class="support-link donate">
<div class="icon-container">
<i class="fas fa-heart donation-icon"></i>
</div>
<div class="link-details">
<span class="link-title">Donate</span>
<span class="link-description">Daughter's College Fund!</span>
</div>
</a>
</div>
</div>
</div>
<!-- Community & Resources Hub (redesigned links section) -->
<div class="card community-hub-card">
<div class="card-header">
<h3>Community & Resources</h3>
</div>
<div class="community-resources-container">
<div class="community-links-grid">
<a href="https://discord.gg/VQbZCGzQsn" target="_blank" class="community-link discord">
<div class="icon-container">
<i class="fab fa-discord"></i>
</div>
<div class="link-details">
<span class="link-title">Discord</span>
</div>
</a>
<a href="https://www.reddit.com/r/huntarr/" target="_blank" class="community-link reddit">
<div class="icon-container">
<i class="fab fa-reddit"></i>
</div>
<div class="link-details">
<span class="link-title">Reddit</span>
</div>
</a>
<a href="https://github.com/plexguide/Huntarr/issues" target="_blank" class="community-link github">
<div class="icon-container">
<i class="fas fa-bug"></i>
</div>
<div class="link-details">
<span class="link-title">Issues</span>
</div>
</a>
<a href="https://github.com/plexguide/Huntarr/wiki" target="_blank" class="community-link wiki">
<div class="icon-container">
<i class="fas fa-book"></i>
</div>
<div class="link-details">
<span class="link-title">Wiki</span>
</div>
</a>
<a href="https://github.com/plexguide/Huntarr.io/releases" target="_blank" class="community-link changelog">
<div class="icon-container">
<i class="fas fa-history"></i>
</div>
<div class="link-details">
<span class="link-title">Changelog</span>
</div>
</a>
</div>
</div>
</div>
<!-- Updated statistics card with new layout -->
<div class="card stats-card">
<div class="card-header">
<h3>Live Hunts Executed</h3>
<button id="reset-stats" class="action-button danger"><i class="fas fa-trash"></i> Reset</button>
</div>
<div class="media-stats-container">
<div class="app-stats-grid">
<!-- Sonarr Card -->
<div class="app-stats-card sonarr">
<div class="status-container">
<span id="sonarrHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="sonarr"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-sonarr.png" alt="Sonarr Logo" class="app-logo">
</div>
<h4>Sonarr</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<div class="stat-number" id="sonarr-hunted">0</div>
<div class="stat-label">Searches Triggered</div>
</div>
<div class="stat-box">
<div class="stat-number" id="sonarr-upgraded">0</div>
<div class="stat-label">Upgrades Triggered</div>
</div>
</div>
</div>
<!-- Radarr Card -->
<div class="app-stats-card radarr">
<div class="status-container">
<span id="radarrHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="radarr"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-radarr.png" alt="Radarr Logo" class="app-logo">
</div>
<h4>Radarr</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<span class="stat-number" id="radarr-hunted">0</span>
<span class="stat-label">Searches Triggered</span>
</div>
<div class="stat-box">
<span class="stat-number" id="radarr-upgraded">0</span>
<span class="stat-label">Upgrades Triggered</span>
</div>
</div>
</div>
<!-- Lidarr Card -->
<div class="app-stats-card lidarr">
<div class="status-container">
<span id="lidarrHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="lidarr"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-lidarr.png" alt="Lidarr Logo" class="app-logo">
</div>
<h4>Lidarr</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<span class="stat-number" id="lidarr-hunted">0</span>
<span class="stat-label">Searches Triggered</span>
</div>
<div class="stat-box">
<span class="stat-number" id="lidarr-upgraded">0</span>
<span class="stat-label">Upgrades Triggered</span>
</div>
</div>
</div>
<!-- Readarr Card -->
<div class="app-stats-card readarr">
<div class="status-container">
<span id="readarrHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="readarr"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-readarr.png" alt="Readarr Logo" class="app-logo">
</div>
<h4>Readarr</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<span class="stat-number" id="readarr-hunted">0</span>
<span class="stat-label">Searches Triggered</span>
</div>
<div class="stat-box">
<span class="stat-number" id="readarr-upgraded">0</span>
<span class="stat-label">Upgrades Triggered</span>
</div>
</div>
</div>
<!-- Whisparr Card -->
<div class="app-stats-card whisparr">
<div class="status-container">
<span id="whisparrHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="whisparr"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-whisparr.png" alt="Whisparr V2 Logo" class="app-logo">
</div>
<h4>Whisparr V2</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<span class="stat-number" id="whisparr-hunted">0</span>
<span class="stat-label">Searches Triggered</span>
</div>
<div class="stat-box">
<span class="stat-number" id="whisparr-upgraded">0</span>
<span class="stat-label">Upgrades Triggered</span>
</div>
</div>
</div>
<!-- Eros Card -->
<div class="app-stats-card eros">
<div class="status-container">
<span id="erosHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
<button class="cycle-reset-button" data-app="eros"><i class="fas fa-sync-alt"></i> Reset Cycle</button>
</div>
<div class="app-content">
<div class="app-icon-wrapper">
<img src="/static/arrs/48-whisparr.png" alt="Whisparr V3 Logo" class="app-logo">
</div>
<h4>Whisparr V3</h4>
</div>
<div class="stats-numbers">
<div class="stat-box">
<span class="stat-number" id="eros-hunted">0</span>
<span class="stat-label">Searches Triggered</span>
</div>
<div class="stat-box">
<span class="stat-number" id="eros-upgraded">0</span>
<span class="stat-label">Upgrades Triggered</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
.stack-layout {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
padding-bottom: 20px; /* Reduced padding since we removed the fixed banner */
}
.stack-layout .card {
width: 100%;
}
.thanks-list {
text-align: left;
padding-left: 20px;
}
.thanks-list li {
margin-bottom: 10px;
}
/* Banner card styles */
.banner-card {
background-color: var(--bg-secondary);
padding: 12px 15px;
border-left: 3px solid var(--accent-color);
}
.banner-content {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.donation-icon {
color: #ff6b6b;
}
.star-icon {
color: #ffd700;
}
/* Improved important links */
.important-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
justify-content: center;
}
.link-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background-color: var(--bg-tertiary);
border-radius: 6px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
font-weight: 500;
font-size: 0.85rem;
box-shadow: none;
border: 1px solid var(--border-color);
}
.link-button i {
font-size: 0.85em;
}
.link-button:hover {
transform: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.sponsor-button {
background-color: #4e54c8;
color: white;
}
.sponsor-button:hover {
background-color: #24c6dc;
}
/* Stats card styling */
.stats-card {
width: 100%;
background-color: rgba(24, 28, 37, 0.6);
border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 10px;
overflow: hidden;
}
.stats-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background-color: rgba(32, 38, 50, 0.6);
border-bottom: 1px solid rgba(90, 109, 137, 0.2);
margin-bottom: 0;
}
.stats-card .card-header h3 {
margin: 0;
color: #e0e6ed;
font-size: 18px;
font-weight: 600;
}
.media-stats-container {
padding: 20px;
background-color: rgba(15, 18, 24, 0.4);
}
.app-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
}
.app-stats-card {
background: rgba(24, 28, 37, 0.8);
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-height: 200px;
justify-content: space-between;
border: 1px solid rgba(120, 140, 180, 0.15);
}
/* Removing card-level border styles since we only want rings around the icons */
.app-stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
border-color: rgba(120, 140, 180, 0.3);
}
/* Status container styling */
.status-container {
width: 100%;
margin-bottom: 15px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.status-badge {
padding: 6px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
/* App content styling */
.app-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 15px;
}
.app-icon-wrapper {
width: 70px;
height: 70px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background: rgba(28, 34, 46, 0.8);
margin-bottom: 12px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
border: 2px solid rgba(120, 140, 180, 0.2);
}
/* Sonarr blue icon ring */
.app-stats-card.sonarr .app-icon-wrapper {
border: 2px solid rgba(0, 174, 240, 0.8);
box-shadow: 0 0 15px rgba(0, 174, 240, 0.4);
}
/* Radarr yellow icon ring */
.app-stats-card.radarr .app-icon-wrapper {
border: 2px solid rgba(253, 203, 43, 0.8);
box-shadow: 0 0 15px rgba(253, 203, 43, 0.4);
}
/* Lidarr green icon ring */
.app-stats-card.lidarr .app-icon-wrapper {
border: 2px solid rgba(49, 197, 56, 0.8);
box-shadow: 0 0 15px rgba(49, 197, 56, 0.4);
}
/* Readarr red icon ring */
.app-stats-card.readarr .app-icon-wrapper {
border: 2px solid rgba(192, 45, 40, 0.8);
box-shadow: 0 0 15px rgba(192, 45, 40, 0.4);
}
/* Whisparr purple icon ring */
.app-stats-card.whisparr .app-icon-wrapper {
border: 2px solid rgba(195, 0, 230, 0.8);
box-shadow: 0 0 15px rgba(195, 0, 230, 0.4);
}
/* Eros purple icon ring */
.app-stats-card.eros .app-icon-wrapper {
border: 2px solid rgba(195, 0, 230, 0.8);
box-shadow: 0 0 15px rgba(195, 0, 230, 0.4);
}
.app-logo {
width: 40px;
height: 40px;
}
.app-stats-card h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #e0e6ed;
letter-spacing: 0.5px;
}
/* Stats display styling */
.stats-numbers {
display: flex;
gap: 15px;
width: 100%;
padding-top: 15px;
margin-top: auto;
border-top: 1px solid rgba(120, 140, 180, 0.15);
}
.stat-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-number {
font-size: 28px;
font-weight: 700;
line-height: 1;
background: linear-gradient(to bottom right, #fff, #a0a8b8);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 6px;
}
.stat-label {
font-size: 12px;
color: #a0a8b8;
font-weight: 500;
}
/* App-specific accent colors for sonarr */
.app-stats-card.sonarr .app-icon-wrapper {
border-color: rgba(0, 174, 240, 0.5);
box-shadow: 0 0 15px rgba(0, 174, 240, 0.2);
}
.app-stats-card.sonarr .stat-number {
background: linear-gradient(to bottom right, #5acdff, #00aef0);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App-specific accent colors for radarr */
.app-stats-card.radarr .app-icon-wrapper {
border-color: rgba(253, 203, 43, 0.5);
box-shadow: 0 0 15px rgba(253, 203, 43, 0.2);
}
.app-stats-card.radarr .stat-number {
background: linear-gradient(to bottom right, #ffe066, #fdcb2b);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App-specific accent colors for lidarr */
.app-stats-card.lidarr .app-icon-wrapper {
border-color: rgba(49, 197, 56, 0.5);
box-shadow: 0 0 15px rgba(49, 197, 56, 0.2);
}
.app-stats-card.lidarr .stat-number {
background: linear-gradient(to bottom right, #5de264, #31c538);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App-specific accent colors for readarr */
.app-stats-card.readarr .app-icon-wrapper {
border-color: rgba(192, 45, 40, 0.5);
box-shadow: 0 0 15px rgba(192, 45, 40, 0.2);
}
.app-stats-card.readarr .stat-number {
background: linear-gradient(to bottom right, #e35751, #c02d28);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App-specific accent colors for whisparr */
.app-stats-card.whisparr .app-icon-wrapper {
border-color: rgba(195, 0, 230, 0.5);
box-shadow: 0 0 15px rgba(195, 0, 230, 0.2);
}
.app-stats-card.whisparr .stat-number {
background: linear-gradient(to bottom right, #e64dff, #c300e6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App-specific accent colors for eros */
.app-stats-card.eros .app-icon-wrapper {
border-color: rgba(195, 0, 230, 0.5);
box-shadow: 0 0 15px rgba(195, 0, 230, 0.2);
}
.app-stats-card.eros .stat-number {
background: linear-gradient(to bottom right, #e64dff, #c300e6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Status badge colors */
.status-badge.connected {
background-color: rgba(39, 174, 96, 0.15);
color: #2ecc71;
border: 1px solid rgba(39, 174, 96, 0.3);
}
.status-badge.partially-connected {
background-color: rgba(243, 156, 18, 0.15);
color: #f39c12;
border: 1px solid rgba(243, 156, 18, 0.3);
}
.status-badge.not-connected {
background-color: rgba(231, 76, 60, 0.15);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.3);
}
.status-badge.loading {
background-color: rgba(41, 128, 185, 0.15);
color: #3498db;
border: 1px solid rgba(41, 128, 185, 0.3);
}
/* Header for cards */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.card h3 {
margin-top: 0;
margin-bottom: 0;
}
/* Ensure button in header doesn't inherit odd margins and adjust padding */
.card-header .action-button {
margin: 0;
padding: 6px 12px;
font-size: 0.9em;
flex-grow: 0;
}
/* Re-add danger button style */
.action-button.danger {
background-color: rgba(244, 67, 54, 0.1);
color: #f44336;
}
.action-button.danger:hover {
background-color: #f44336;
color: white;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.important-links {
flex-direction: column;
}
.link-button {
width: 100%;
justify-content: center;
}
}
/* Version display styling */
.version-icon {
color: #3498db;
}
#version-number {
color: var(--text-primary);
font-weight: 500;
}
#version-value {
font-weight: 600;
}
/* Added style for latest version number */
#latest-version-value {
font-weight: 600;
}
/* Developer credit styling */
.developer-credit {
color: var(--text-secondary);
font-weight: 500;
}
.developer-credit a {
color: #3498db;
font-weight: 600;
text-decoration: none;
}
.developer-credit a:hover {
text-decoration: underline;
}
/* Community & Resources Hub styling */
.community-hub-card {
width: 100%;
background-color: rgba(24, 28, 37, 0.6);
border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 10px;
overflow: hidden;
}
.community-hub-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background-color: rgba(32, 38, 50, 0.6);
border-bottom: 1px solid rgba(90, 109, 137, 0.2);
}
.community-hub-card .card-header h3 {
margin: 0;
color: #e0e6ed;
font-size: 18px;
font-weight: 600;
}
.community-resources-container {
padding: 20px;
background-color: rgba(15, 18, 24, 0.4);
}
.community-links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.community-link {
display: flex;
align-items: center;
background: rgba(28, 34, 46, 0.7);
border-radius: 8px;
padding: 10px 12px;
transition: all 0.2s ease;
text-decoration: none;
color: #e1e6ef;
}
.community-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
background: rgba(38, 46, 63, 0.8);
}
.icon-container {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
margin-right: 12px;
flex-shrink: 0;
font-size: 18px;
color: white;
}
.link-details {
display: flex;
flex-direction: column;
}
.link-title {
font-weight: 600;
font-size: 14px;
color: #e0e6ed;
}
/* Icon styles for each link type */
.community-link.discord .icon-container {
background: linear-gradient(135deg, #7289DA, #5865F2);
}
.community-link.reddit .icon-container {
background: linear-gradient(135deg, #FF4500, #FF6A33);
}
.community-link.github .icon-container {
background: linear-gradient(135deg, #24292E, #404448);
}
.community-link.wiki .icon-container {
background: linear-gradient(135deg, #1ABC9C, #16A085);
}
.community-link.changelog .icon-container {
background: linear-gradient(135deg, #3498DB, #2980B9);
}
/* Media queries for responsiveness */
@media (max-width: 768px) {
.community-links-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
}
@media (max-width: 500px) {
.community-links-grid {
grid-template-columns: 1fr;
}
}
/* Mobile scrolling fix */
.mobile-scrollable {
padding-bottom: 100px;
}
@media (max-width: 768px) {
#homeSection {
overflow-y: auto !important;
height: auto !important;
min-height: 100%;
}
.mobile-scrollable {
padding-bottom: 150px;
}
}
/* Support Links styling */
.support-links-container {
padding: 4px;
}
.support-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.support-link {
display: flex;
align-items: center;
padding: 15px;
background: linear-gradient(145deg, rgba(32, 37, 49, 0.7), rgba(27, 32, 43, 0.7));
border: 1px solid rgba(90, 109, 137, 0.1);
border-radius: 10px;
transition: all 0.3s ease;
text-decoration: none;
color: #e1e6ef;
}
.support-link:hover {
transform: translateY(-3px);
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.2);
border-color: rgba(90, 109, 137, 0.3);
background: linear-gradient(145deg, rgba(37, 42, 54, 0.8), rgba(32, 37, 48, 0.8));
}
.support-link .icon-container {
display: flex;
justify-content: center;
align-items: center;
width: 45px;
height: 45px;
font-size: 1.4rem;
margin-right: 15px;
flex-shrink: 0;
}
.support-link .link-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.support-link .link-title {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 3px;
white-space: nowrap;
}
.support-link .link-description {
font-size: 0.8rem;
color: rgba(225, 230, 239, 0.7);
white-space: normal;
word-wrap: break-word;
}
/* Mobile optimizations for support links */
@media (max-width: 768px) {
.support-links-grid {
gap: 8px;
}
.support-link {
padding: 12px;
}
.support-link .icon-container {
width: 36px;
height: 36px;
font-size: 1.2rem;
margin-right: 10px;
}
.support-link .link-title {
font-size: 0.9rem;
}
.support-link .link-description {
font-size: 0.75rem;
}
}
/* Even smaller screens */
@media (max-width: 480px) {
.support-links-grid {
grid-template-columns: 1fr;
}
}
.support-link.donate .icon-container {
color: #ff6b6b;
}
.support-link.github .icon-container {
color: #ffc107;
}
/* Cycle reset button styles */
.cycle-reset-button {
margin-left: 10px;
padding: 4px 8px;
background-color: rgba(52, 152, 219, 0.15);
color: #3498db;
border: 1px solid rgba(52, 152, 219, 0.3);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 5px;
}
.cycle-reset-button:hover {
background-color: rgba(52, 152, 219, 0.25);
}
.cycle-reset-button:active {
background-color: rgba(52, 152, 219, 0.35);
}
.cycle-reset-button i {
font-size: 0.75rem;
}
.cycle-reset-button.resetting {
opacity: 0.7;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,426 @@
<section id="logsSection" class="content-section">
<div class="section-header">
<!-- Replace Log Tabs with Dropdown -->
<div class="log-dropdown-container">
<select id="logAppSelect" class="styled-select">
<option value="all">All</option>
<option value="sonarr">Sonarr</option>
<option value="radarr">Radarr</option>
<option value="lidarr">Lidarr</option>
<option value="readarr">Readarr</option>
<option value="whisparr">Whisparr V2</option>
<option value="eros">Whisparr V3</option>
<option value="swaparr">Swaparr</option>
<option value="system">System</option>
</select>
</div>
<div class="log-controls">
<div class="connection-status">
Status: <span id="logConnectionStatus" class="status-disconnected">Disconnected</span>
</div>
<div class="log-options">
<label class="auto-scroll">
<input type="checkbox" id="autoScrollCheckbox" checked>
<span>Auto-scroll</span>
</label>
<button id="clearLogsButton" class="clear-button">
<i class="fas fa-trash-alt"></i> Clear
</button>
</div>
</div>
</div>
<div id="logsContainer" class="logs"></div>
</section>
<style>
/* Modern Logs Section Styling */
#logsSection {
padding: 20px;
padding-bottom: 60px; /* Extra padding to account for the footer */
overflow: hidden; /* Prevent outer scrolling */
display: none; /* Hide by default */
width: 100%;
}
/* Only show when active */
#logsSection.active {
display: flex;
flex-direction: column;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
/* Dropdown styling */
.log-dropdown {
position: relative;
display: inline-block;
}
.log-dropdown-btn {
background: linear-gradient(135deg, rgba(28, 36, 54, 0.9), rgba(24, 32, 48, 0.8));
color: white;
padding: 8px 15px;
border: 1px solid rgba(90, 109, 137, 0.3);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
min-width: 120px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.log-dropdown-btn:hover {
background: linear-gradient(135deg, rgba(38, 46, 64, 0.9), rgba(34, 42, 58, 0.8));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.log-dropdown-btn i {
margin-left: auto;
transition: transform 0.3s ease;
}
.log-dropdown-content {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
z-index: 100;
background: linear-gradient(135deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(90, 109, 137, 0.2);
margin-top: 5px;
transform: translateY(0);
max-height: 300px;
overflow-y: auto;
}
/* Fix dropdown disappearing when moving to content */
.log-dropdown-wrapper {
position: relative;
padding-bottom: 5px; /* Create space to safely move to dropdown */
}
.log-dropdown-wrapper:after {
content: '';
position: absolute;
height: 10px; /* Invisible bridge between button and content */
bottom: 0;
left: 0;
right: 0;
z-index: 99;
}
.log-dropdown-wrapper:hover .log-dropdown-content,
.log-dropdown-content:hover {
display: block;
}
.log-dropdown-wrapper:hover .log-dropdown-btn i {
transform: rotate(180deg);
}
.log-option {
color: white;
padding: 10px 15px;
text-decoration: none;
display: block;
transition: all 0.2s ease;
text-align: left;
border-bottom: 1px solid rgba(90, 109, 137, 0.1);
}
.log-option:last-child {
border-bottom: none;
}
.log-option:hover {
background: rgba(65, 105, 225, 0.2);
}
.log-option.active {
background: rgba(65, 105, 225, 0.3);
font-weight: 500;
}
/* Controls styling */
.log-controls {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.connection-status {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
padding: 5px 10px;
border-radius: 8px;
background: rgba(28, 36, 54, 0.6);
border: 1px solid rgba(90, 109, 137, 0.3);
}
#logConnectionStatus {
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
#logConnectionStatus.status-connected {
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
border: 1px solid rgba(46, 204, 113, 0.3);
}
#logConnectionStatus.status-disconnected {
color: #95a5a6;
background: rgba(149, 165, 166, 0.1);
border: 1px solid rgba(149, 165, 166, 0.3);
}
#logConnectionStatus.status-error {
color: #ff6b6b;
background: rgba(231, 76, 60, 0.1);
border: 1px solid rgba(231, 76, 60, 0.3);
}
.log-options {
display: flex;
align-items: center;
gap: 10px;
}
.auto-scroll {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 5px 10px;
border-radius: 8px;
background: rgba(28, 36, 54, 0.6);
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
user-select: none;
transition: all 0.2s ease;
}
.auto-scroll:hover {
background: rgba(38, 46, 64, 0.7);
}
.auto-scroll input {
margin: 0;
cursor: pointer;
}
.clear-button {
padding: 8px 16px;
background: linear-gradient(135deg, #e74c3c 0%, #f15846 100%);
border: none;
border-radius: 8px;
color: white;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(231, 76, 60, 0.3);
}
.clear-button:hover {
background: linear-gradient(135deg, #f15846 0%, #f3695a 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
/* Logs container styling */
.logs {
height: calc(100vh - 200px);
overflow-y: auto;
background: linear-gradient(180deg, rgba(22, 26, 34, 0.8), rgba(18, 22, 30, 0.75));
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(90, 109, 137, 0.15);
padding: 15px;
font-family: 'Courier New', monospace;
line-height: 1.5;
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
-webkit-overflow-scrolling: touch; /* For smooth scrolling on iOS */
padding-bottom: 70px; /* Extra padding at bottom to ensure content isn't cut off */
}
.logs::-webkit-scrollbar {
width: 10px;
}
.logs::-webkit-scrollbar-track {
background: rgba(18, 22, 30, 0.5);
border-radius: 0 10px 10px 0;
}
.logs::-webkit-scrollbar-thumb {
background: rgba(90, 109, 137, 0.5);
border-radius: 5px;
}
.logs::-webkit-scrollbar-thumb:hover {
background: rgba(90, 109, 137, 0.8);
}
/* Log entry styling */
.log-entry {
padding: 4px 0;
margin-bottom: 2px;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
line-height: 1.4;
}
.log-timestamp {
color: #95a5a6;
font-weight: 600;
min-width: 65px;
}
.log-app {
color: #3498db;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
background: rgba(52, 152, 219, 0.1);
border: 1px solid rgba(52, 152, 219, 0.3);
}
.log-level {
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
min-width: 50px;
text-align: center;
}
.log-level-debug {
color: #95a5a6;
background: rgba(149, 165, 166, 0.1);
border: 1px solid rgba(149, 165, 166, 0.3);
}
.log-level-info {
color: #3498db;
background: rgba(52, 152, 219, 0.1);
border: 1px solid rgba(52, 152, 219, 0.3);
}
.log-level-warning, .log-level-warn {
color: #f39c12;
background: rgba(243, 156, 18, 0.1);
border: 1px solid rgba(243, 156, 18, 0.3);
}
.log-level-error {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
border: 1px solid rgba(231, 76, 60, 0.3);
}
.log-logger {
color: #9b59b6;
font-style: italic;
font-size: 12px;
}
.log-message {
flex: 1 0 100%;
white-space: pre-wrap;
word-break: break-word;
margin-top: 2px;
padding-left: 4px;
border-left: 3px solid rgba(90, 109, 137, 0.3);
}
/* Log entry colors based on level */
.log-entry.log-debug .log-message {
color: rgba(255, 255, 255, 0.7);
}
.log-entry.log-info .log-message {
color: rgba(255, 255, 255, 0.9);
}
.log-entry.log-warning .log-message, .log-entry.log-warn .log-message {
color: #f39c12;
}
.log-entry.log-error .log-message {
color: #e74c3c;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
align-items: flex-start;
}
.log-dropdown-container, .log-controls {
width: 100%;
}
.log-controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.log-options {
width: 100%;
justify-content: space-between;
}
/* Mobile specific log container styling */
.logs {
height: calc(100vh - 240px); /* Adjusted height for mobile */
padding-bottom: 120px; /* Extra padding to ensure no content is hidden */
margin-bottom: 60px; /* Space for mobile browser UI */
}
/* Make space for fixed elements at bottom on mobile */
#logsSection {
padding-bottom: 80px;
}
}
/* For dropdowns with many items, position upward if near bottom of screen */
@media (max-height: 700px) {
.log-dropdown-content {
bottom: 100%;
top: auto;
margin-top: 0;
margin-bottom: 5px;
}
}
</style>

View File

@@ -0,0 +1,3 @@
<!-- Existing scripts -->
<script src="/static/js/utils.js"></script>
<script src="/static/js/new-main.js"></script>

View File

@@ -0,0 +1,69 @@
<section id="settingsSection" class="content-section">
<div class="section-header">
<h2>General Settings</h2>
<div class="settings-actions">
<button id="saveSettingsButton" class="save-button" disabled>
<i class="fas fa-save"></i> Save
</button>
</div>
</div>
<div class="settings-form">
<div id="generalSettings" class="app-settings-panel active" style="display: block;"></div>
</div>
</section>
<style>
/* Fixed banner footer styling */
.fixed-banner-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
padding: 10px 20px;
background-color: var(--bg-secondary);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
border-top: 1px solid var(--border-color);
z-index: 1000; /* Ensure it's above other elements */
}
/* Add bottom padding to the settings panel to prevent content from being hidden */
.app-settings-panel {
padding-bottom: 50px;
}
.fixed-banner-footer p {
margin: 0;
font-size: 1em;
color: var(--text-primary);
font-weight: 500;
}
.fixed-banner-footer a {
color: var(--accent-color);
text-decoration: none;
font-weight: 600;
transition: color 0.2s, text-decoration 0.2s;
}
.fixed-banner-footer a:hover {
text-decoration: underline;
color: var(--accent-hover);
}
.fixed-banner-footer .donation-icon {
color: var(--error-color, #ff6b6b);
}
.fixed-banner-footer .star-icon {
color: var(--warning-color, #ffd700);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// ...existing code...
});
</script>

View File

@@ -0,0 +1,245 @@
<div class="sidebar" id="sidebar">
<div class="logo-container">
<img src="/static/logo/256.png" alt="Huntarr Logo" class="logo logo-large">
<img src="/static/logo/32.png" alt="Huntarr Logo" class="logo logo-small">
<h1>Huntarr</h1>
</div>
<nav class="nav-menu">
<a href="/" class="nav-item" id="homeNav">
<div class="nav-icon-wrapper">
<i class="fas fa-home"></i>
</div>
<span>Home</span>
</a>
<a href="/#history" class="nav-item" id="historyNav">
<div class="nav-icon-wrapper">
<i class="fas fa-history"></i>
</div>
<span>History</span>
</a>
<a href="/#logs" class="nav-item" id="logsNav">
<div class="nav-icon-wrapper">
<i class="fas fa-list-alt"></i>
</div>
<span>Logs</span>
</a>
<a href="/#apps" class="nav-item" id="appsNav">
<div class="nav-icon-wrapper">
<i class="fas fa-tools"></i>
</div>
<span>Apps</span>
</a>
<a href="/#settings" class="nav-item" id="settingsNav">
<div class="nav-icon-wrapper">
<i class="fas fa-cog"></i>
</div>
<span>Settings</span>
</a>
<a href="/user" class="nav-item" id="userNav">
<div class="nav-icon-wrapper">
<i class="fas fa-user"></i>
</div>
<span>User</span>
</a>
<a href="https://github.com/plexguide/Unraid_Intel-ARC_Deployment" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="fas fa-film"></i>
</div>
<span>My AV1 Guide</span>
</a>
<a href="https://github.com/sponsors/plexguide" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper heart-icon">
<i class="fas fa-heart"></i>
</div>
<span>Project Sponsors</span>
</a>
</nav>
</div>
<style>
.sidebar {
width: 250px;
height: 100vh;
background: linear-gradient(180deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-right: 1px solid rgba(90, 109, 137, 0.15);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
z-index: 1000;
flex-shrink: 0;
overflow-y: auto;
position: relative;
}
.logo-container {
display: flex;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(90, 109, 137, 0.15);
}
.logo {
width: 40px;
height: 40px;
margin-right: 10px;
}
.logo-small {
display: none;
}
.logo-container h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #fff;
}
.nav-menu {
display: flex;
flex-direction: column;
padding: 20px 0;
flex-grow: 1;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: all 0.3s ease;
border-radius: 0;
margin-bottom: 5px;
}
.nav-icon-wrapper {
width: 35px;
height: 35px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
position: relative;
border-radius: 8px;
}
.nav-icon-wrapper i {
font-size: 16px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.nav-item span {
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: rgba(59, 130, 246, 0.2);
color: #fff;
}
.nav-item.active {
background: rgba(59, 130, 246, 0.9);
color: #ffffff;
}
/* Mobile view optimization */
@media (max-width: 768px) {
.sidebar {
width: 60px !important;
min-width: 60px !important;
max-width: 60px !important;
}
.sidebar .nav-item span {
display: none;
}
.sidebar .logo-container {
justify-content: center;
padding: 15px 0;
height: 60px;
}
.sidebar .logo-container h1 {
display: none;
}
.logo-large {
display: none;
}
.logo-small {
display: block;
width: 32px;
height: 32px;
margin: 0;
}
.nav-menu {
padding: 10px 0;
}
.nav-item {
width: 60px;
justify-content: center;
padding: 12px 0;
margin-bottom: 8px;
}
.nav-item:hover,
.nav-item.active {
width: 42px;
height: 42px;
margin: 0 auto 8px auto;
border-radius: 10px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon-wrapper {
margin-right: 0;
width: 32px;
height: 32px;
}
.nav-icon-wrapper i {
font-size: 15px;
}
}
</style>
<script>
// Function to set active nav item based on the current hash
function setActiveNavItem() {
// Get all nav items
const navItems = document.querySelectorAll('.nav-item');
// Remove active class from all items
navItems.forEach(item => {
item.classList.remove('active');
});
// Get current hash, default to home if none
const currentHash = window.location.hash || '#home';
// Find nav item with matching href and add active class
const activeItem = document.querySelector(`.nav-item[href="${currentHash}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
}
// Set active on page load
document.addEventListener('DOMContentLoaded', setActiveNavItem);
// Update active when hash changes
window.addEventListener('hashchange', setActiveNavItem);
</script>

View File

@@ -0,0 +1,267 @@
<div class="top-bar">
<div class="topbar-section left">
<div class="page-title" id="currentPageTitle"></div>
</div>
<div class="topbar-section center">
<div class="version-bar">
<div class="version-item">
<i class="fas fa-code-branch version-icon"></i>
<span>Version <a href="https://github.com/plexguide/Huntarr.io/releases" target="_blank"><span id="version-value">6.2.1</span></a></span>
</div>
<div class="version-divider"></div>
<div class="version-item">
<i class="fas fa-cloud-download-alt version-icon"></i>
<span>Latest: <a href="https://github.com/plexguide/Huntarr.io/releases/latest" target="_blank"><span id="latest-version-value">6.2.1</span></a></span>
</div>
<div class="version-divider"></div>
<div class="version-item">
<i class="fas fa-flask version-icon beta-icon"></i>
<span>Beta: <a href="https://github.com/plexguide/Huntarr.io/wiki/Beta-Notes" target="_blank">Info</a></span>
</div>
<div class="version-divider"></div>
<div class="version-item">
<i class="fas fa-star version-icon star-icon"></i>
<span><a href="https://github.com/plexguide/huntarr" target="_blank"><span id="github-stars-value">764</span></a></span>
</div>
<div class="version-divider"></div>
<div class="version-item">
<span class="developer-credit">Thanks 4 Using Huntarr - <a href="https://github.com/Admin9705/" target="_blank">Admin9705</a></span>
</div>
</div>
</div>
<div class="topbar-section right">
<div class="user-info" id="userInfoContainer">
<span id="username">User</span>
<a href="#" id="logoutLink" class="logout-btn"><i class="fas fa-sign-out-alt"></i></a>
</div>
</div>
</div>
<style>
.top-bar {
height: 60px;
background: linear-gradient(180deg, rgba(18, 22, 30, 0.95), rgba(24, 28, 37, 0.98));
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(90, 109, 137, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.topbar-section {
display: flex;
align-items: center;
}
.topbar-section.left {
flex: 1;
justify-content: flex-start;
}
.topbar-section.center {
flex: 1;
justify-content: center;
align-items: center;
display: flex;
position: static;
left: unset;
transform: unset;
width: auto;
}
.topbar-section.right {
flex: 1;
justify-content: flex-end;
}
.version-bar {
display: flex;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.version-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #e0e6ed;
}
.version-divider {
width: 1px;
height: 16px;
background-color: rgba(120, 140, 180, 0.3);
}
.version-icon {
color: #3498db;
font-size: 14px;
}
.star-icon {
color: #ffd700;
}
.version-bar a {
color: #3498db;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
}
.version-bar a:hover {
color: #5dade2;
}
.developer-credit {
color: #e0e6ed;
}
.developer-credit a {
color: #3498db;
font-weight: 600;
}
/* Responsive styling */
@media (max-width: 768px) {
.version-bar {
display: flex !important;
gap: 8px !important;
}
.version-item {
display: none !important;
}
.version-item:nth-child(1),
.version-item:nth-child(3) {
display: flex !important;
font-size: 14px !important;
}
.version-divider {
display: none !important;
}
.version-divider:nth-child(2) {
display: block !important;
}
.sidebar {
display: flex !important;
flex-direction: column !important;
justify-content: center !important;
align-items: center !important;
height: 100vh !important;
}
.nav-menu {
flex-direction: column !important;
align-items: center !important;
width: 100%;
}
.nav-item {
display: flex !important;
justify-content: center !important;
align-items: center !important;
width: 60px !important;
margin: 0 auto 8px auto !important;
padding: 0 !important;
}
.nav-icon-wrapper {
margin: 0 !important;
width: 32px !important;
height: 32px !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
}
@media (max-width: 1024px) {
/* Sidebar: icons only */
.sidebar .nav-item span {
display: none !important;
}
.sidebar {
width: 60px !important;
min-width: 60px !important;
max-width: 60px !important;
}
.sidebar .logo-container h1 {
display: none !important;
}
.logo-large {
display: none !important;
}
.logo-small {
display: block !important;
width: 32px !important;
height: 32px !important;
margin: 0 !important;
}
/* Top bar: only Version and Latest */
.version-item {
display: none !important;
}
.version-item:nth-child(1),
.version-item:nth-child(3) {
display: flex !important;
font-size: 14px !important;
}
.version-divider {
display: none !important;
}
.version-divider:nth-child(2) {
display: block !important;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Connect to the loaded elements from home_section
const versionElement = document.getElementById('version-value');
const latestVersionElement = document.getElementById('latest-version-value');
const starsElement = document.getElementById('github-stars-value');
// Function to get the values from localStorage if they exist
function updateVersionInfo() {
// Version
try {
const versionInfo = localStorage.getItem('huntarr-version-info');
if (versionInfo) {
const parsedInfo = JSON.parse(versionInfo);
if (parsedInfo.currentVersion) {
versionElement.textContent = parsedInfo.currentVersion;
}
if (parsedInfo.latestVersion) {
latestVersionElement.textContent = parsedInfo.latestVersion;
}
if (parsedInfo.betaVersion) {
const betaVersionElement = document.getElementById('beta-version-value');
if (betaVersionElement) {
betaVersionElement.textContent = parsedInfo.betaVersion;
}
}
}
// Stars
const starsInfo = localStorage.getItem('huntarr-github-stars');
if (starsInfo) {
starsElement.textContent = starsInfo;
}
} catch (e) {
console.error('Error updating version info:', e);
}
}
// Initial update
updateVersionInfo();
// Watch for changes (in case the home page updates values)
window.addEventListener('storage', function(e) {
if (e.key === 'huntarr-version-info' || e.key === 'huntarr-github-stars') {
updateVersionInfo();
}
});
});
</script>

View File

@@ -0,0 +1,137 @@
<div class="user-section">
<div class="user-card">
<h3><i class="fas fa-user-edit"></i> Change Username</h3>
<div class="form-group">
<label for="currentUsername">Current Username:</label>
<div class="current-value" id="currentUsername">Loading...</div>
</div>
<div class="form-group">
<label for="newUsername">New Username:</label>
<input type="text" id="newUsername" class="form-control">
</div>
<div class="form-actions">
<button id="saveUsername" class="action-button primary-button">
<i class="fas fa-save"></i> Save Username
</button>
</div>
<div id="usernameStatus" class="status-message" style="display: none;"></div>
</div>
<div class="user-card">
<h3><i class="fas fa-key"></i> Change Password</h3>
<div class="form-group">
<label for="currentPassword">Current Password:</label>
<div class="password-field">
<input type="password" id="currentPassword" class="form-control">
<i class="toggle-password fas fa-eye" data-target="currentPassword"></i>
</div>
</div>
<div class="form-group">
<label for="newPassword">New Password:</label>
<div class="password-field">
<input type="password" id="newPassword" class="form-control">
<i class="toggle-password fas fa-eye" data-target="newPassword"></i>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password:</label>
<div class="password-field">
<input type="password" id="confirmPassword" class="form-control">
<i class="toggle-password fas fa-eye" data-target="confirmPassword"></i>
</div>
</div>
<div class="form-actions">
<button id="savePassword" class="action-button primary-button">
<i class="fas fa-save"></i> Save Password
</button>
</div>
<div id="passwordStatus" class="status-message" style="display: none;"></div>
</div>
<div class="user-card">
<h3><i class="fas fa-shield-alt"></i> Two-Factor Authentication</h3>
<div class="form-group">
<label>Status:</label>
<div class="status-badge" id="twoFactorEnabled">Loading...</div>
</div>
<div id="enableTwoFactorSection" style="display: none;">
<div class="form-actions">
<button id="enableTwoFactor" class="action-button secondary-button">
<i class="fas fa-lock"></i> Enable 2FA
</button>
</div>
</div>
<div id="setupTwoFactorSection" style="display: none;">
<div class="qr-container">
<div class="qr-code">
<img id="qrCode" src="" alt="QR Code">
</div>
<div class="form-group">
<label for="secretKey">Secret Key:</label>
<div class="secret-key-container">
<div class="secret-key" id="secretKey"></div>
<button class="copy-button" onclick="copySecretKey()">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<p class="help-text">Use this key if you can't scan the QR code</p>
</div>
</div>
<div class="form-group">
<label for="verificationCode">Verification Code:</label>
<input type="text" id="verificationCode" class="form-control verification-code" placeholder="Enter 6-digit code" maxlength="6">
</div>
<div class="form-actions">
<button id="verifyTwoFactor" class="action-button primary-button">
<i class="fas fa-check-circle"></i> Verify and Enable
</button>
</div>
<div id="verifyStatus" class="status-message" style="display: none;"></div>
</div>
<div id="disableTwoFactorSection" style="display: none;">
<div class="form-actions">
<button id="disableTwoFactor" class="action-button danger-button">
<i class="fas fa-unlock"></i> Disable 2FA
</button>
</div>
</div>
</div>
</div>
<script>
// Toggle password visibility
document.querySelectorAll('.toggle-password').forEach(function(toggle) {
toggle.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const target = document.getElementById(targetId);
if (target.type === 'password') {
target.type = 'text';
this.classList.remove('fa-eye');
this.classList.add('fa-eye-slash');
} else {
target.type = 'password';
this.classList.remove('fa-eye-slash');
this.classList.add('fa-eye');
}
});
});
// Copy secret key to clipboard
function copySecretKey() {
const secretKey = document.getElementById('secretKey').textContent;
navigator.clipboard.writeText(secretKey).then(function() {
const copyButton = document.querySelector('.copy-button');
const originalText = copyButton.innerHTML;
copyButton.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(function() {
copyButton.innerHTML = originalText;
}, 2000);
});
}
</script>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'components/head.html' %}
<title>Huntarr - Home</title>
</head>
<body>
<div class="app-container mobile-optimized">
{% include 'components/sidebar.html' %}
<div class="main-content">
{% include 'components/topbar.html' %}
<!-- Home Section -->
{% include 'components/home_section.html' %}
<!-- Logs Section -->
{% include 'components/logs_section.html' %}
<!-- History Section -->
{% include 'components/history_section.html' %}
<!-- Apps Section -->
{% include 'components/apps_section.html' %}
<!-- Cleanuperr Section -->
{% include 'components/cleanuperr_section.html' %}
<!-- Settings Section -->
{% include 'components/settings_section.html' %}
</div>
</div>
{% include 'components/footer.html' %}
{% include 'components/scripts.html' %}
<!-- Load settings-related scripts -->
<script src="/static/js/settings_forms.js"></script>
<!-- Load history script -->
<script src="/static/js/history.js"></script>
<!-- Load apps script -->
<script src="/static/js/apps.js"></script>
<!-- Emergency reset button implementation -->
<script src="/static/js/direct-reset.js"></script>
<!-- Stats reset handler -->
<script src="/static/js/stats-reset.js"></script>
</body>
</html>

View File

@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Huntarr Login</title>
<!-- Inline script to prevent theme flashing -->
<script>
// Check theme preference immediately before any rendering
(function() {
var prefersDarkMode = localStorage.getItem('huntarr-dark-mode') === 'true';
if (prefersDarkMode || window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark-theme');
// Add inline styles to prevent flash
document.write('<style>body, html { background-color: #1a1d24 !important; color: #f8f9fa !important; } .login-container { background-color: #252a34 !important; } .login-header { background-color: #121212 !important; }</style>');
}
})();
</script>
<!-- Preload logo -->
<link rel="preload" href="/static/logo/256.png" as="image" fetchpriority="high">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="icon" href="/static/logo/16.png">
<!-- Preload script to prevent flashing -->
<script src="/static/js/theme-preload.js"></script>
<!-- Modern login styles -->
<style>
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #13171f 0%, #1c2230 100%);
margin: 0;
padding: 20px;
}
.login-container {
width: 400px;
max-width: 90%;
background: linear-gradient(180deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 15px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid rgba(90, 109, 137, 0.15);
}
.login-header {
background: linear-gradient(180deg, rgba(18, 22, 30, 0.98), rgba(24, 28, 37, 0.95));
padding: 30px 0;
text-align: center;
border-bottom: 1px solid rgba(90, 109, 137, 0.15);
}
.login-logo {
width: 80px;
height: 80px;
margin-bottom: 10px;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2));
transition: transform 0.3s ease, opacity 0.2s ease-in-out;
}
.login-logo.loaded {
opacity: 1;
}
.login-logo:hover {
transform: scale(1.05);
}
.login-header h1 {
margin: 0;
color: #fff;
font-size: 24px;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.login-form {
padding: 30px;
}
.login-form h2 {
margin: 0 0 25px;
text-align: center;
font-size: 20px;
color: #fff;
font-weight: 500;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-group label {
display: flex;
align-items: center;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-group label i {
margin-right: 8px;
color: rgba(65, 105, 225, 0.9);
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 8px;
background-color: rgba(28, 36, 54, 0.6);
color: #fff;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
border-color: rgba(65, 105, 225, 0.6);
box-shadow: 0 0 0 3px rgba(65, 105, 225, 0.2);
outline: none;
}
.toggle-password {
position: absolute;
right: 12px;
top: 40px;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
z-index: 10;
}
.toggle-password:hover {
color: rgba(255, 255, 255, 0.9);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
}
.form-check {
display: flex;
align-items: center;
}
.login-button {
background: linear-gradient(135deg, #3a71e4 0%, #5481e6 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
transition: all 0.3s ease;
width: 100%;
margin-top: 10px;
box-shadow: 0 4px 10px rgba(58, 113, 228, 0.3);
}
.login-button:hover {
background: linear-gradient(135deg, #4a7deb 0%, #6491fa 100%);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(58, 113, 228, 0.4);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(58, 113, 228, 0.3);
}
.additional-options {
text-align: center;
margin-top: 25px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
}
.error-message {
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
border-left: 4px solid #dc3545;
display: none;
}
@media (max-width: 768px) {
.login-container {
width: 95%;
}
.login-form {
padding: 20px;
}
.login-header {
padding: 20px 0;
}
.login-logo {
width: 60px;
height: 60px;
}
}
</style>
</head>
<body class="login-page">
<div class="login-container">
<div class="login-header">
<img src="/static/logo/256.png" alt="Huntarr Logo" class="login-logo" onload="this.classList.add('loaded')">
<h1>Huntarr</h1>
</div>
<div class="login-form">
<h2>Log in to your account</h2>
<div id="errorMessage" class="error-message"></div>
<form action="/login" method="POST" id="loginForm">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
<span>Username</span>
</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
<span>Password</span>
</label>
<input type="password" id="password" name="password" required>
<i class="toggle-password fas fa-eye" id="togglePassword"></i>
</div>
<!-- 2FA field will be inserted here when needed -->
<div id="twoFactorContainer"></div>
<div class="form-actions">
<div class="form-check">
<input type="checkbox" id="rememberMe" name="rememberMe">
<label for="rememberMe" style="margin-left: 5px;">Remember me</label>
</div>
</div>
<button type="submit" class="login-button" id="loginButton">
<i class="fas fa-sign-in-alt"></i> Log In
</button>
</form>
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const errorMessage = document.getElementById('errorMessage');
const togglePassword = document.getElementById('togglePassword');
const twoFactorContainer = document.getElementById('twoFactorContainer');
let otpInput = null;
let twoFactorMode = false;
// Toggle password visibility
togglePassword.addEventListener('click', function() {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
this.classList.toggle('fa-eye');
this.classList.toggle('fa-eye-slash');
});
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
// Clear previous errors
errorMessage.style.display = 'none';
errorMessage.textContent = '';
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) {
showError('Please enter both username and password.');
return;
}
// Check if we're in 2FA mode and validate the 2FA code
if (twoFactorMode && otpInput) {
const otpCode = otpInput.value.trim();
if (!otpCode) {
showError('Please enter your two-factor authentication code.');
return;
}
if (otpCode.length !== 6 || !/^\d+$/.test(otpCode)) {
showError('Two-factor code must be 6 digits.');
return;
}
}
// Show loading state on button
const loginButton = document.getElementById('loginButton');
const originalText = loginButton.innerHTML;
loginButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Logging in...';
loginButton.disabled = true;
// Prepare login data
const loginData = {
username: username,
password: password,
rememberMe: document.getElementById('rememberMe').checked
};
// Add 2FA code if we're in 2FA mode
if (twoFactorMode && otpInput) {
loginData.twoFactorCode = otpInput.value.trim();
}
// Submit the form data
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData)
})
.then(response => response.json().then(data => ({ status: response.status, body: data })))
.then(({ status, body }) => {
console.log('Login response:', status, body);
// Check for 2FA requirement
const requires2FA = body.requires_2fa || body.requiresTwoFactor || body.requires2fa || body.requireTwoFactor || false;
if (status === 200 && body.success) {
// Login successful
window.location.href = body.redirect || '/';
} else if (status === 401 && requires2FA) {
// 2FA is required
console.log('2FA required, showing 2FA input field');
twoFactorMode = true;
// Add 2FA field
twoFactorContainer.innerHTML = `
<div class="form-group" id="twoFactorGroup">
<label for="twoFactorCode">
<i class="fas fa-shield-alt"></i>
<span>Two-Factor Code</span>
</label>
<input type="text" id="twoFactorCode" name="twoFactorCode"
placeholder="Enter your 6-digit code" maxlength="6"
style="width: 100%; padding: 12px 15px; border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 8px; background-color: rgba(28, 36, 54, 0.6);
color: #fff; font-size: 16px;">
</div>`;
// Update reference to the new input
otpInput = document.getElementById('twoFactorCode');
if (otpInput) {
otpInput.focus();
// Add input validation
otpInput.addEventListener('input', function() {
// Only allow digits
this.value = this.value.replace(/[^0-9]/g, '');
});
}
// Reset button
loginButton.innerHTML = originalText;
loginButton.disabled = false;
// Show message
showError('Please enter your two-factor authentication code.');
} else {
// Show error message
showError(body.error || 'Invalid username or password.');
// Reset button
loginButton.innerHTML = originalText;
loginButton.disabled = false;
}
})
.catch(error => {
console.error('Login error:', error);
showError('An error occurred during login. Please try again.');
// Reset button
loginButton.innerHTML = originalText;
loginButton.disabled = false;
});
});
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,768 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup - Huntarr</title>
<!-- Inline script to prevent theme flashing -->
<script>
// Always use dark mode for setup page
document.documentElement.classList.add('dark-theme');
document.write('<style>body, html { background-color: #1a1d24 !important; color: #f8f9fa !important; } .login-container { background-color: #252a34 !important; } .login-header { background-color: #121212 !important; }</style>');
</script>
<!-- Preload logo -->
<link rel="preload" href="/static/logo/256.png" as="image" fetchpriority="high">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="icon" href="/static/logo/16.png">
<!-- Preload script to prevent flashing -->
<script src="/static/js/theme-preload.js"></script>
<style>
/* Modern setup page styles */
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #13171f 0%, #1c2230 100%);
margin: 0;
padding: 20px;
}
.login-container {
width: 500px;
max-width: 90%;
background: linear-gradient(180deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 15px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid rgba(90, 109, 137, 0.15);
}
.login-header {
background: linear-gradient(180deg, rgba(18, 22, 30, 0.98), rgba(24, 28, 37, 0.95));
padding: 25px 0;
text-align: center;
border-bottom: 1px solid rgba(90, 109, 137, 0.15);
}
.login-logo {
width: 70px;
height: 70px;
margin-bottom: 10px;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2));
transition: transform 0.3s ease;
opacity: 1;
}
.login-logo:hover {
transform: scale(1.05);
}
.login-header h1 {
margin: 0;
color: #fff;
font-size: 24px;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.login-form {
padding: 25px;
}
/* Setup steps navigation */
.setup-steps {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
gap: 10px;
}
.step {
padding: 10px;
background: rgba(28, 36, 54, 0.6);
border-radius: 10px;
font-weight: 500;
flex: 1;
text-align: center;
margin: 0;
opacity: 0.6;
font-size: 0.85em;
transition: all 0.3s ease;
border: 1px solid rgba(90, 109, 137, 0.2);
color: rgba(255, 255, 255, 0.7);
}
.step.active {
background: linear-gradient(135deg, #3a71e4 0%, #5481e6 100%);
color: white;
opacity: 1;
border-color: rgba(90, 109, 137, 0.3);
box-shadow: 0 4px 10px rgba(58, 113, 228, 0.2);
transform: translateY(-2px);
}
.step.completed {
background: linear-gradient(135deg, #28a745 0%, #34ce57 100%);
color: white;
opacity: 1;
border-color: rgba(40, 167, 69, 0.3);
}
/* Form styling */
.setup-section {
display: none;
animation: fadeIn 0.3s ease;
}
.setup-section.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-group label {
display: flex;
align-items: center;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-group label i {
margin-right: 8px;
color: rgba(65, 105, 225, 0.9);
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 8px;
background-color: rgba(28, 36, 54, 0.6);
color: #fff;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
border-color: rgba(65, 105, 225, 0.6);
box-shadow: 0 0 0 3px rgba(65, 105, 225, 0.2);
outline: none;
}
.password-requirements {
margin-top: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.requirement {
display: flex;
align-items: center;
margin-bottom: 3px;
}
.requirement i {
margin-right: 5px;
font-size: 12px;
}
.requirement.valid i {
color: #28a745;
}
.requirement.invalid i {
color: #dc3545;
}
.toggle-password {
position: absolute;
right: 12px;
top: 40px;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
}
.toggle-password:hover {
color: rgba(255, 255, 255, 0.9);
}
/* Buttons and actions */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 25px;
}
.next-button, .back-button, .submit-button {
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
font-size: 16px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.next-button, .submit-button {
background: linear-gradient(135deg, #3a71e4 0%, #5481e6 100%);
color: white;
border: none;
box-shadow: 0 4px 10px rgba(58, 113, 228, 0.3);
}
.next-button:hover, .submit-button:hover {
background: linear-gradient(135deg, #4a7deb 0%, #6491fa 100%);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(58, 113, 228, 0.4);
}
.back-button {
background: transparent;
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.8);
}
.back-button:hover {
background: rgba(90, 109, 137, 0.1);
border-color: rgba(90, 109, 137, 0.5);
color: white;
}
.next-button:active, .back-button:active, .submit-button:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(58, 113, 228, 0.3);
}
/* Skip button styling */
.skip-button {
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
font-size: 16px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, rgba(220, 53, 69, 0.9) 0%, rgba(220, 53, 69, 0.8) 100%);
color: white;
border: none;
box-shadow: 0 4px 10px rgba(220, 53, 69, 0.3);
cursor: pointer;
}
.skip-button:hover {
background: linear-gradient(135deg, rgba(220, 53, 69, 1) 0%, rgba(220, 53, 69, 0.9) 100%);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(220, 53, 69, 0.4);
}
.skip-button:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
}
/* Error message styling */
.error-message {
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
border-left: 4px solid #dc3545;
display: none;
}
/* Success message */
.success-message {
background-color: rgba(40, 167, 69, 0.2);
color: #2ecc71;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
border-left: 4px solid #28a745;
}
/* QR code styling */
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
}
.qr-code {
background: white;
padding: 10px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
width: 64px;
height: 64px;
margin: 0 auto 15px;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
/* Responsive QR code for mobile */
@media (max-width: 768px) {
.qr-code {
width: 30%;
height: auto;
}
}
.secret-key {
font-family: monospace;
padding: 10px 15px;
background: rgba(28, 36, 54, 0.8);
border-radius: 8px;
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.9);
margin-bottom: 15px;
width: 100%;
text-align: center;
font-size: 14px;
user-select: all;
}
.copy-button {
background: transparent;
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.8);
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
transition: all 0.2s ease;
}
.copy-button:hover {
background: rgba(90, 109, 137, 0.1);
border-color: rgba(90, 109, 137, 0.5);
color: white;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.login-container {
width: 95%;
}
.login-form {
padding: 20px;
}
.login-header {
padding: 20px 0;
}
.login-logo {
width: 60px;
height: 60px;
}
.form-actions {
flex-direction: column-reverse;
gap: 10px;
}
.next-button, .back-button, .submit-button {
width: 100%;
justify-content: center;
}
.step {
font-size: 0.75em;
padding: 8px 5px;
}
}
</style>
</head>
<body class="login-page dark-mode">
<div class="login-container">
<div class="login-header">
<img src="/static/logo/256.png" alt="Huntarr Logo" class="login-logo">
<h1>Huntarr</h1>
</div>
<div class="login-form">
<div class="setup-steps">
<div id="step1" class="step active">1. Create Account</div>
<div id="step2" class="step">2. Setup 2FA</div>
<div id="step3" class="step">3. Finish</div>
</div>
<div id="accountSetup" class="setup-section active">
<h2>Create Your Account</h2>
<p>Set up your administrator credentials</p>
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
<span>Username</span>
</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
<span>Password</span>
</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="confirm_password">
<i class="fas fa-check-circle"></i>
<span>Confirm Password</span>
</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="error-message" id="errorMessage" style="display: none;"></div>
<div class="form-actions">
<button type="button" id="accountNextButton" class="next-button">
<i class="fas fa-arrow-right"></i> Next
</button>
</div>
</div>
<div id="twoFactorSetup" class="setup-section">
<h2>Setup Two-Factor Authentication</h2>
<div class="qr-container">
<p>Scan this QR code with your auth app:</p>
<div class="qr-code" id="qrCode">
<img src="" alt="QR Code" style="display: none;"> <!-- Add img tag, initially hidden -->
</div>
<p>Or enter this code manually in your app:</p>
<div class="secret-key" id="secretKey">Generating...</div>
<div class="form-group">
<label for="verificationCode">
<i class="fas fa-shield-alt"></i>
<span>Verification Code</span>
</label>
<input type="text" id="verificationCode" placeholder="Enter 6-digit code" maxlength="6">
</div>
<div class="form-actions">
<button type="button" id="twoFactorNextButton" class="next-button">
<i class="fas fa-check"></i> Verify & Continue
</button>
<button id="skip2FALink" class="skip-button">
<i class="fas fa-times"></i> Skip 2FA setup
</button>
</div>
</div>
</div>
<div id="setupComplete" class="setup-section">
<h2>Setup Complete</h2>
<p>Your Huntarr account has been created successfully!</p>
<div style="text-align: center; margin-top: 20px;">
<div style="background-color: #2c2c2c; border-radius: 8px; padding: 15px; margin-bottom: 20px;">
<p><i class="fas fa-shield-alt" style="color: #4a90e2; font-size: 24px;"></i></p>
<p style="font-weight: bold; margin-top: 10px;">Local Authentication Bypass Available</p>
<p style="margin-top: 5px;">To enable authentication bypass for local network access:</p>
<p style="color: #aaa; font-size: 0.9em; margin-top: 5px;">
Go to <strong>Settings</strong><strong>General</strong> and toggle on <strong>Local Network Auth Bypass</strong>
</p>
</div>
<p><i class="fas fa-check-circle" style="color: #4CAF50;"></i> You can now proceed to the main interface.</p>
</div>
<div class="form-actions" style="justify-content: center;">
<button type="button" id="finishSetupButton" class="submit-button">
<i class="fas fa-home"></i> Go to Dashboard
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Elements
const steps = document.querySelectorAll('.step');
const screens = document.querySelectorAll('.setup-section');
const errorMessage = document.getElementById('errorMessage');
// Account setup elements
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const confirmPasswordInput = document.getElementById('confirm_password');
const accountNextButton = document.getElementById('accountNextButton');
// 2FA setup elements
const qrCodeElement = document.getElementById('qrCode');
const secretKeyElement = document.getElementById('secretKey');
const verificationCodeInput = document.getElementById('verificationCode');
const skip2FALink = document.getElementById('skip2FALink');
const twoFactorNextButton = document.getElementById('twoFactorNextButton');
// Complete setup elements
const finishSetupButton = document.getElementById('finishSetupButton');
// Current step tracking
let currentStep = 1;
let accountCreated = false;
let twoFactorEnabled = false;
// Store user data
let userData = {
username: '',
password: ''
};
// Show a specific step
function showStep(step) {
steps.forEach((s, index) => {
if (index + 1 < step) {
s.classList.remove('active');
s.classList.add('completed');
} else if (index + 1 === step) {
s.classList.add('active');
s.classList.remove('completed');
} else {
s.classList.remove('active');
s.classList.remove('completed');
}
});
screens.forEach((screen, index) => {
if (index + 1 === step) {
screen.classList.add('active');
} else {
screen.classList.remove('active');
}
});
currentStep = step;
}
// Show error message
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
// Hide after 5 seconds
setTimeout(() => {
errorMessage.style.display = 'none';
}, 5000);
}
// Password validation function
function validatePassword(password) {
// Only check for minimum length of 8 characters
if (password.length < 8) {
return 'Password must be at least 8 characters long.';
}
return null; // Password is valid
}
// Account creation
accountNextButton.addEventListener('click', function() {
const username = usernameInput.value.trim(); // Trim whitespace
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (!username || !password || !confirmPassword) {
showError('All fields are required');
return;
}
// Add username length validation
if (username.length < 3) {
showError('Username must be at least 3 characters long');
return;
}
if (password !== confirmPassword) {
showError('Passwords do not match');
return;
}
// Validate password complexity
const passwordError = validatePassword(password);
if (passwordError) {
showError(passwordError);
return;
}
// Store user data
userData.username = username;
userData.password = password;
if (accountCreated) {
// If account already created, just move to next step
showStep(2);
return;
}
// Create user account with improved error handling
fetch('/setup', { // Corrected endpoint from /api/setup to /setup
method: 'POST',
redirect: 'error', // Add this line to prevent following redirects
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username,
password: password,
confirm_password: confirmPassword // Keep confirm_password if backend expects it, otherwise remove
})
})
.then(response => {
// Check if response is ok before parsing JSON
if (!response.ok) {
// Check content type to see if it's likely JSON
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
// If it seems like JSON, try to parse it for an error message
return response.json().then(data => {
// Use data.error first, then data.message as fallback
throw new Error(data.error || data.message || `Server error: ${response.status}`);
});
} else {
// If not JSON (e.g., HTML error page), throw a generic HTTP error
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// If response is ok, parse the JSON body
return response.json();
})
.then(data => { // This block only runs if response.ok was true and response.json() succeeded
if (data.success) {
accountCreated = true;
console.log('Account created successfully. User credentials should be saved to credentials.json');
// Generate 2FA setup - Use the correct endpoint and method
fetch('/api/user/2fa/setup', { method: 'POST' }) // Specify POST method
.then(response => {
// Check for unauthorized specifically
if (response.status === 401) {
throw new Error('Unauthorized - Session likely not established yet.');
}
if (!response.ok) {
// Try to parse error from JSON response
return response.json().then(errData => {
throw new Error(errData.error || `Server error: ${response.status}`);
}).catch(() => {
// Fallback if response is not JSON
throw new Error(`Server error: ${response.status}`);
});
}
return response.json();
})
.then(twoFactorData => {
if (twoFactorData.success) {
// Use the correct property 'qr_code_url' and set the img src directly
const qrCodeImg = qrCodeElement.querySelector('img'); // Find the img tag within the div
if (qrCodeImg) {
qrCodeImg.src = twoFactorData.qr_code_url; // Set src directly
qrCodeImg.style.display = 'block'; // Ensure it's visible
} else {
// Fallback if img tag wasn't there initially
qrCodeElement.innerHTML = `<img src="${twoFactorData.qr_code_url}" alt="QR Code" style="display: block; max-width: 100%; height: auto;">`;
}
secretKeyElement.textContent = twoFactorData.secret;
showStep(2);
} else {
// Use .error if available, otherwise provide a default
showError('Failed to generate 2FA setup: ' + (twoFactorData.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error generating 2FA:', error);
// Display the specific error message caught
showError('Failed to generate 2FA setup: ' + error.message);
});
} else {
showError(data.error || 'Failed to create account'); // Use .error
}
})
.catch(error => { // Catches errors thrown from the .then blocks above or network errors
console.error('Setup error:', error);
showError('Error: ' + error.message); // Display the error message
});
});
// 2FA setup navigation
twoFactorNextButton.addEventListener('click', function() {
const code = verificationCodeInput.value;
if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) { // Add validation
showError('Please enter a valid 6-digit verification code');
return;
}
// Verify 2FA code - Use the correct endpoint
fetch('/api/user/2fa/verify', { // Corrected endpoint
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code: code })
})
.then(response => response.json())
.then(data => {
if (data.success) {
twoFactorEnabled = true;
showStep(3);
} else {
showError(data.message || 'Invalid verification code');
}
})
.catch(error => {
console.error('Error verifying 2FA code:', error);
showError('Failed to verify code');
});
});
// Skip 2FA setup
skip2FALink.addEventListener('click', function() {
showStep(3);
});
// Complete setup navigation
finishSetupButton.addEventListener('click', function() {
window.location.href = '/';
});
// Allow pressing Enter to continue
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission
if (currentStep === 1 && document.activeElement !== accountNextButton) {
accountNextButton.click();
} else if (currentStep === 2 && document.activeElement !== twoFactorNextButton) {
twoFactorNextButton.click();
} else if (currentStep === 3 && document.activeElement !== finishSetupButton) {
finishSetupButton.click();
}
}
});
// Always use dark mode
document.body.classList.add('dark-mode');
localStorage.setItem('huntarr-dark-mode', 'true');
});
</script>
</body>
</html>

View File

@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>User Settings - Huntarr</title>
{% include 'components/head.html' %}
<style>
.user-section {
padding: 15px;
max-width: 600px;
margin: 0 auto;
}
/* Ensure main content area is scrollable */
.main-content {
overflow-y: auto;
max-height: 100vh;
}
.user-card {
background: linear-gradient(180deg, rgba(22, 26, 34, 0.98), rgba(18, 22, 30, 0.95));
border-radius: 10px;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
padding: 18px;
margin-bottom: 18px;
border: 1px solid rgba(90, 109, 137, 0.15);
overflow: hidden;
}
.user-card h3 {
display: flex;
align-items: center;
margin-top: 0;
margin-bottom: 18px;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.user-card h3 i {
margin-right: 8px;
color: rgba(65, 105, 225, 0.9);
font-size: 14px;
}
.form-group {
margin-bottom: 14px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
font-size: 13px;
}
.current-value {
padding: 8px 10px;
background: rgba(28, 36, 54, 0.6);
border-radius: 6px;
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(90, 109, 137, 0.2);
font-size: 14px;
}
.form-control {
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(90, 109, 137, 0.2);
border-radius: 6px;
background-color: rgba(28, 36, 54, 0.6);
color: #fff;
font-size: 14px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: rgba(65, 105, 225, 0.6);
box-shadow: 0 0 0 2px rgba(65, 105, 225, 0.2);
outline: none;
}
.password-field {
position: relative;
}
.toggle-password {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
z-index: 10;
font-size: 12px;
}
.toggle-password:hover {
color: rgba(255, 255, 255, 0.9);
}
.form-actions {
margin-top: 14px;
display: flex;
gap: 7px;
}
.action-button {
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
border: none;
}
.primary-button {
background: linear-gradient(135deg, #3a71e4 0%, #5481e6 100%);
color: white;
box-shadow: 0 3px 8px rgba(58, 113, 228, 0.3);
}
.primary-button:hover {
background: linear-gradient(135deg, #4a7deb 0%, #6491fa 100%);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(58, 113, 228, 0.4);
}
.secondary-button {
background: linear-gradient(135deg, #38495a 0%, #465b70 100%);
color: white;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
}
.secondary-button:hover {
background: linear-gradient(135deg, #465b70 0%, #546d84 100%);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
.danger-button {
background: linear-gradient(135deg, #e74c3c 0%, #f15846 100%);
color: white;
box-shadow: 0 3px 8px rgba(231, 76, 60, 0.3);
}
.danger-button:hover {
background: linear-gradient(135deg, #f15846 0%, #f3695a 100%);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(231, 76, 60, 0.4);
}
.status-message {
margin-top: 12px;
padding: 8px;
border-radius: 6px;
font-size: 12px;
}
.status-message.success {
background-color: rgba(40, 167, 69, 0.2);
color: #2ecc71;
border-left: 3px solid #28a745;
}
.status-message.error {
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border-left: 3px solid #dc3545;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 15px;
font-weight: 600;
font-size: 12px;
}
.status-badge.enabled {
background-color: rgba(40, 167, 69, 0.2);
color: #2ecc71;
border: 1px solid rgba(40, 167, 69, 0.4);
}
.status-badge.disabled {
background-color: rgba(108, 117, 125, 0.2);
color: #adb5bd;
border: 1px solid rgba(108, 117, 125, 0.4);
}
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 14px 0;
}
.qr-code {
background: white;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
width: 64px;
height: 64px;
margin: 0 auto 10px;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
/* Responsive QR code for mobile */
@media (max-width: 768px) {
.qr-code {
width: 30%;
height: auto;
}
}
.secret-key-container {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 7px;
}
.secret-key {
font-family: monospace;
padding: 7px 10px;
background: rgba(28, 36, 54, 0.8);
border-radius: 6px;
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.9);
flex: 1;
font-size: 12px;
user-select: all;
}
.copy-button {
background: transparent;
border: 1px solid rgba(90, 109, 137, 0.3);
color: rgba(255, 255, 255, 0.8);
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
transition: all 0.2s ease;
}
.copy-button:hover {
background: rgba(90, 109, 137, 0.1);
border-color: rgba(90, 109, 137, 0.5);
color: white;
}
.help-text {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-top: 4px;
}
.verification-code {
font-family: monospace;
letter-spacing: 2px;
text-align: center;
}
@media (max-width: 768px) {
.user-card {
padding: 15px;
}
.form-actions {
flex-direction: column;
}
.action-button {
width: 100%;
justify-content: center;
}
.secret-key-container {
flex-direction: column;
align-items: stretch;
}
.copy-button {
margin-top: 7px;
}
}
</style>
</head>
<body>
<div class="app-container mobile-optimized">
{% include 'components/sidebar.html' %}
<div class="main-content">
{% include 'components/topbar.html' %}
<div class="user-section">
<div class="user-card">
<h3><i class="fas fa-user-edit"></i> Change Username</h3>
<div class="form-group">
<label for="currentUsername">Current Username:</label>
<span id="currentUsername" class="current-value">Loading...</span>
</div>
<div class="form-group">
<label for="newUsername">New Username:</label>
<input type="text" id="newUsername" class="form-control">
</div>
<div class="form-group">
<label for="currentPasswordForUsernameChange">Current Password:</label>
<input type="password" id="currentPasswordForUsernameChange" class="form-control" required>
</div>
<div class="form-actions">
<button id="saveUsername" class="action-button primary-button">Save Username</button>
</div>
<div id="usernameStatus" class="status-message" style="display: none;"></div>
</div>
<div class="user-card">
<h3><i class="fas fa-key"></i> Change Password</h3>
<div class="form-group">
<label for="currentPassword">Current Password:</label>
<input type="password" id="currentPassword" class="form-control">
</div>
<div class="form-group">
<label for="newPassword">New Password:</label>
<input type="password" id="newPassword" class="form-control">
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password:</label>
<input type="password" id="confirmPassword" class="form-control">
</div>
<div class="form-actions">
<button id="savePassword" class="action-button primary-button">Save Password</button>
</div>
<div id="passwordStatus" class="status-message" style="display: none;"></div>
</div>
<div class="user-card">
<h3><i class="fas fa-shield-alt"></i> Two-Factor Authentication</h3>
<div class="form-group">
<label>Status:</label>
<span id="twoFactorEnabled" class="status-badge" style="display: none;">Loading...</span>
</div>
<div id="enableTwoFactorSection" style="display: none;">
<div class="form-actions">
<button id="enableTwoFactor" class="action-button primary-button">Enable 2FA</button>
</div>
</div>
<div id="setupTwoFactorSection" style="display: none;">
<div class="qr-container">
<div class="qr-code">
<img id="qrCode" src="" alt="QR Code">
</div>
</div>
<div class="form-group">
<label for="secretKey">Secret Key:</label>
<div class="secret-key-container">
<div id="secretKey" class="secret-key"></div>
<button class="copy-button">Copy</button>
</div>
<p class="help-text">Use this key if you can't scan the QR code</p>
</div>
<div class="form-group">
<label for="verificationCode">Verification Code:</label>
<input type="text" id="verificationCode" class="form-control" placeholder="000000" maxlength="6">
</div>
<div class="form-actions">
<button id="verifyTwoFactor" class="action-button primary-button">Verify and Enable</button>
</div>
<div id="verifyStatus" class="status-message" style="display: none;"></div>
</div>
<div id="disableTwoFactorSection" style="display: none;">
<div class="form-group">
<label for="currentPasswordFor2FADisable">Current Password:</label>
<input type="password" id="currentPasswordFor2FADisable" class="form-control" placeholder="Enter your password" required>
</div>
<div class="form-group">
<label for="otpCodeFor2FADisable">Current OTP Code:</label>
<input type="text" id="otpCodeFor2FADisable" class="form-control" placeholder="000000" maxlength="6" required>
</div>
<div class="form-actions">
<button id="disableTwoFactor" class="action-button danger-button">Disable 2FA</button>
</div>
<div id="disableStatus" class="status-message" style="display: none;"></div>
</div>
</div>
</div>
</div>
</div>
{% include 'components/scripts.html' %}
<!-- Add specific reference to new-user.js -->
<script src="/static/js/new-user.js"></script>
<script>
// Initialize dark mode
document.addEventListener('DOMContentLoaded', function() {
// Apply dark theme
document.body.classList.add('dark-theme');
localStorage.setItem('huntarr-dark-mode', 'true');
// Update server setting to dark mode
fetch('/api/settings/theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ dark_mode: true })
}).catch(error => console.error('Error saving theme:', error));
});
// Password validation function
function validatePassword(password) {
// Only check for minimum length of 8 characters
if (password.length < 8) {
return 'Password must be at least 8 characters long.';
}
return null; // Password is valid
}
</script>
</body>
</html>

217
Huntarr.io-6.3.6/main.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Main entry point for Huntarr
Starts both the web server and the background processing tasks.
"""
import os
import threading
import sys
import signal
import logging # Use standard logging for initial setup
# Ensure the 'src' directory is in the Python path
# This allows importing modules from 'src.primary' etc.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src')))
# --- Early Logging Setup (Before importing app components) ---
# Basic logging to capture early errors during import or setup
log_level = logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO
logging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
root_logger = logging.getLogger("HuntarrRoot") # Specific logger for this entry point
root_logger.info("--- Huntarr Main Process Starting ---")
root_logger.info(f"Python sys.path: {sys.path}")
# Check for Windows service commands
if sys.platform == 'win32' and len(sys.argv) > 1:
if sys.argv[1] == '--install-service':
try:
from src.primary.windows_service import install_service
success = install_service()
sys.exit(0 if success else 1)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error installing Windows service: {e}")
sys.exit(1)
elif sys.argv[1] == '--remove-service':
try:
from src.primary.windows_service import remove_service
success = remove_service()
sys.exit(0 if success else 1)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error removing Windows service: {e}")
sys.exit(1)
elif sys.argv[1] in ['--start', '--stop', '--restart', '--debug', '--update']:
try:
import win32serviceutil
service_name = "Huntarr"
if sys.argv[1] == '--start':
win32serviceutil.StartService(service_name)
print(f"Started {service_name} service")
elif sys.argv[1] == '--stop':
win32serviceutil.StopService(service_name)
print(f"Stopped {service_name} service")
elif sys.argv[1] == '--restart':
win32serviceutil.RestartService(service_name)
print(f"Restarted {service_name} service")
elif sys.argv[1] == '--debug':
# Run the service in debug mode directly
from src.primary.windows_service import HuntarrService
win32serviceutil.HandleCommandLine(HuntarrService)
elif sys.argv[1] == '--update':
# Update the service
win32serviceutil.StopService(service_name)
from src.primary.windows_service import install_service
install_service()
win32serviceutil.StartService(service_name)
print(f"Updated {service_name} service")
sys.exit(0)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error managing Windows service: {e}")
sys.exit(1)
try:
# Import the Flask app instance
from primary.web_server import app
# Import the background task starter function and shutdown helpers from the renamed file
from primary.background import start_huntarr, stop_event, shutdown_threads
# Configure logging first
import logging
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from primary.utils.logger import setup_main_logger, get_logger
# Initialize main logger
huntarr_logger = setup_main_logger()
huntarr_logger.info("Successfully imported application components.")
except ImportError as e:
root_logger.critical(f"Fatal Error: Failed to import application components: {e}", exc_info=True)
root_logger.critical("Please ensure the application structure is correct, dependencies are installed (`pip install -r requirements.txt`), and the script is run from the project root.")
sys.exit(1)
except Exception as e:
root_logger.critical(f"Fatal Error: An unexpected error occurred during initial imports: {e}", exc_info=True)
sys.exit(1)
def run_background_tasks():
"""Runs the Huntarr background processing."""
bg_logger = get_logger("HuntarrBackground") # Use app's logger
try:
bg_logger.info("Starting Huntarr background tasks...")
start_huntarr() # This function contains the main loop and shutdown logic
except Exception as e:
bg_logger.exception(f"Critical error in Huntarr background tasks: {e}")
finally:
bg_logger.info("Huntarr background tasks stopped.")
def run_web_server():
"""Runs the Flask web server using Waitress in production."""
web_logger = get_logger("WebServer") # Use app's logger
debug_mode = os.environ.get('DEBUG', 'false').lower() == 'true'
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('PORT', 9705)) # Use PORT for consistency
web_logger.info(f"Starting web server on {host}:{port} (Debug: {debug_mode})...")
if debug_mode:
# Use Flask's development server for debugging (less efficient, auto-reloads)
# Note: use_reloader=True can cause issues with threads starting twice.
web_logger.warning("Running in DEBUG mode with Flask development server.")
try:
app.run(host=host, port=port, debug=True, use_reloader=False)
except Exception as e:
web_logger.exception(f"Flask development server failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
else:
# Use Waitress for production
try:
from waitress import serve
web_logger.info("Running with Waitress production server.")
# Adjust threads as needed, default is 4
serve(app, host=host, port=port, threads=8)
except ImportError:
web_logger.error("Waitress not found. Falling back to Flask development server (NOT recommended for production).")
web_logger.error("Install waitress ('pip install waitress') for production use.")
try:
app.run(host=host, port=port, debug=False, use_reloader=False)
except Exception as e:
web_logger.exception(f"Flask development server (fallback) failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
except Exception as e:
web_logger.exception(f"Waitress server failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
def main_shutdown_handler(signum, frame):
"""Gracefully shut down the application."""
huntarr_logger.warning(f"Received signal {signal.Signals(signum).name}. Initiating shutdown...")
if not stop_event.is_set():
stop_event.set()
# The rest of the cleanup happens after run_web_server() returns or in the finally block.
if __name__ == '__main__':
# Register signal handlers for graceful shutdown in the main process
signal.signal(signal.SIGINT, main_shutdown_handler)
signal.signal(signal.SIGTERM, main_shutdown_handler)
background_thread = None
try:
# Start background tasks in a daemon thread
# Daemon threads exit automatically if the main thread exits unexpectedly,
# but we'll try to join() them for a graceful shutdown.
background_thread = threading.Thread(target=run_background_tasks, name="HuntarrBackground", daemon=True)
background_thread.start()
# Start the web server in the main thread (blocking)
# This will run until the server is stopped (e.g., by Ctrl+C)
run_web_server()
except KeyboardInterrupt:
huntarr_logger.info("KeyboardInterrupt received in main thread. Shutting down...")
if not stop_event.is_set():
stop_event.set()
except Exception as e:
huntarr_logger.exception(f"An unexpected error occurred in the main execution block: {e}")
if not stop_event.is_set():
stop_event.set() # Ensure shutdown is triggered on unexpected errors
finally:
# --- Cleanup ---
huntarr_logger.info("Web server has stopped. Initiating final shutdown sequence...")
# Ensure the stop event is set (might already be set by signal handler or error)
if not stop_event.is_set():
huntarr_logger.warning("Stop event was not set before final cleanup. Setting now.")
stop_event.set()
# Wait for the background thread to finish cleanly
if background_thread and background_thread.is_alive():
huntarr_logger.info("Waiting for background tasks to complete...")
background_thread.join(timeout=30) # Wait up to 30 seconds
if background_thread.is_alive():
huntarr_logger.warning("Background thread did not stop gracefully within the timeout.")
elif background_thread:
huntarr_logger.info("Background thread already stopped.")
else:
huntarr_logger.info("Background thread was not started.")
# Call the shutdown_threads function from primary.main (if it does more than just join)
# This might be redundant if start_huntarr handles its own cleanup via stop_event
# huntarr_logger.info("Calling shutdown_threads()...")
# shutdown_threads() # Uncomment if primary.main.shutdown_threads() does more cleanup
huntarr_logger.info("--- Huntarr Main Process Exiting ---")
# Use os._exit(0) for a more forceful exit if necessary, but sys.exit(0) is generally preferred
sys.exit(0)

View File

@@ -0,0 +1,7 @@
Flask==3.0.0
requests==2.31.0
waitress==2.1.2
bcrypt==4.1.2
qrcode[pil]==7.4.2 # Added qrcode with PIL support
pyotp==2.9.0 # Added pyotp
pywin32==306; sys_platform == 'win32' # For Windows service support

View File

@@ -0,0 +1,62 @@
from flask import Flask, render_template, request, redirect, send_file
app = Flask(__name__)
import os
import json
def get_ui_preference():
"""Determine which UI to use based on config and user preference"""
# Check if ui_settings.json exists
config_file = os.path.join(os.path.dirname(__file__), 'config/ui_settings.json')
use_new_ui = False
if os.path.exists(config_file):
try:
with open(config_file, 'r') as f:
settings = json.load(f)
use_new_ui = settings.get('use_new_ui', False)
except Exception as e:
print(f"Error loading UI settings: {e}")
# Allow URL parameter to override
ui_param = request.args.get('ui', None)
if ui_param == 'new':
use_new_ui = True
elif ui_param == 'classic':
use_new_ui = False
return use_new_ui
@app.route('/')
def index():
"""Root route with UI switching capability"""
if get_ui_preference():
return redirect('/new')
else:
return render_template('index.html')
@app.route('/user')
def user_page():
"""User settings page with UI switching capability"""
return render_template('user.html')
@app.route('/user/new')
def user_new_page():
"""User settings page for new UI"""
return render_template('user.html')
@app.route('/version.txt')
def version_txt():
"""Serve version.txt file directly"""
version_path = os.path.join(os.path.dirname(__file__), 'version.txt')
print(f"Serving version.txt from path: {version_path}") # Debug log
try:
return send_file(version_path, mimetype='text/plain')
except Exception as e:
print(f"Error serving version.txt: {e}") # Log any errors
return str(e), 500 # Return error message and 500 status code
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -0,0 +1,6 @@
"""
Huntarr - Find Missing & Upgrade Media Items
A unified tool for Sonarr, Radarr, Lidarr, and Readarr
"""
__version__ = "4.0.0"

View File

@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
Arr API Helper Functions
Handles all communication with the Arr API
"""
import requests
import time
from typing import List, Dict, Any, Optional, Union
from primary.utils.logger import logger, debug_log
from primary.config import API_KEY, API_URL, API_TIMEOUT, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS, APP_TYPE
from src.primary.stats_manager import get_stats, reset_stats
# Create a session for reuse
session = requests.Session()
def arr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Union[Dict, List]]:
"""
Make a request to the Arr API.
`endpoint` should be something like 'series', 'command', 'wanted/cutoff', etc.
"""
# Determine the API version based on app type
if APP_TYPE == "sonarr":
api_base = "api/v3"
elif APP_TYPE == "radarr":
api_base = "api/v3"
elif APP_TYPE == "lidarr":
api_base = "api/v1"
elif APP_TYPE == "readarr":
api_base = "api/v1"
else:
# Default to v3 for unknown app types
api_base = "api/v3"
url = f"{API_URL}/{api_base}/{endpoint}"
headers = {
"X-Api-Key": API_KEY,
"Content-Type": "application/json"
}
try:
if method.upper() == "GET":
response = session.get(url, headers=headers, timeout=API_TIMEOUT)
elif method.upper() == "POST":
response = session.post(url, headers=headers, json=data, timeout=API_TIMEOUT)
else:
logger.error(f"Unsupported HTTP method: {method}")
return None
# Check for 401 Unauthorized or other error status codes
if response.status_code == 401:
logger.error(f"API request error: 401 Client Error: Unauthorized for url: {url}")
return None
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"API request error: {e}")
return None
def check_connection(app_type: str = None) -> bool:
"""
Check if we can connect to the Arr API.
Returns True if connection is successful, False otherwise.
Args:
app_type: Optional app type to check connection for (sonarr, radarr, etc.).
If None, uses the global APP_TYPE.
"""
# Determine which app type to use
current_app_type = app_type or APP_TYPE
# Get API credentials for the specified app type
from primary import keys_manager
api_url, api_key = keys_manager.get_api_keys(current_app_type)
# First explicitly check if API URL and Key are configured
if not api_url:
logger.error(f"API URL is not configured for {current_app_type} in settings. Please set it up in the Settings page.")
return False
if not api_key:
logger.error(f"API Key is not configured for {current_app_type} in settings. Please set it up in the Settings page.")
return False
# Log what we're attempting to connect to
logger.debug(f"Attempting to connect to {current_app_type.title()} at {api_url}")
# Try to access the system/status endpoint which should be available on all Arr applications
try:
endpoint = "system/status"
# Determine the API version based on app type
if current_app_type == "sonarr":
api_base = "api/v3"
elif current_app_type == "radarr":
api_base = "api/v3"
elif current_app_type == "lidarr":
api_base = "api/v1"
elif current_app_type == "readarr":
api_base = "api/v1"
else:
# Default to v3 for unknown app types
api_base = "api/v3"
url = f"{api_url}/{api_base}/{endpoint}"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
logger.debug(f"Testing connection with URL: {url}")
response = session.get(url, headers=headers, timeout=API_TIMEOUT)
if response.status_code == 401:
logger.error(f"Connection test failed: 401 Client Error: Unauthorized - Invalid API key for {current_app_type.title()}")
return False
response.raise_for_status()
logger.info(f"Connection to {current_app_type.title()} at {api_url} successful")
return True
except requests.exceptions.RequestException as e:
logger.error(f"Connection test failed for {current_app_type}: {e}")
return False
def wait_for_command(command_id: int):
logger.debug(f"Waiting for command {command_id} to complete...")
attempts = 0
while True:
try:
time.sleep(COMMAND_WAIT_DELAY)
response = arr_request(f"command/{command_id}")
logger.debug(f"Command {command_id} Status: {response['status']}")
except Exception as error:
logger.error(f"Error fetching command status on attempt {attempts + 1}: {error}")
return False
attempts += 1
if response['status'].lower() in ['complete', 'completed'] or attempts >= COMMAND_WAIT_ATTEMPTS:
break
if response['status'].lower() not in ['complete', 'completed']:
logger.warning(f"Command {command_id} did not complete within the allowed attempts.")
return False
time.sleep(0.5)
return response['status'].lower() in ['complete', 'completed']
# Sonarr-specific functions
def get_series() -> List[Dict]:
"""Get all series from Sonarr."""
if APP_TYPE != "sonarr":
logger.error("get_series() called but APP_TYPE is not sonarr")
return []
series_list = arr_request("series")
if series_list:
debug_log("Raw series API response sample:", series_list[:2] if len(series_list) > 2 else series_list)
return series_list or []
def refresh_series(series_id: int) -> bool:
"""
POST /api/v5/command
{
"name": "RefreshSeries",
"seriesId": <series_id>
}
"""
if APP_TYPE != "sonarr":
logger.error("refresh_series() called but APP_TYPE is not sonarr")
return False
data = {
"name": "RefreshSeries",
"seriesId": series_id
}
response = arr_request("command", method="POST", data=data)
if not response or 'id' not in response:
return False
return wait_for_command(response['id'])
def episode_search_episodes(episode_ids: List[int]) -> bool:
"""
POST /api/v5/command
{
"name": "EpisodeSearch",
"episodeIds": [...]
}
"""
if APP_TYPE != "sonarr":
logger.error("episode_search_episodes() called but APP_TYPE is not sonarr")
return False
data = {
"name": "EpisodeSearch",
"episodeIds": episode_ids
}
response = arr_request("command", method="POST", data=data)
if not response or 'id' not in response:
return False
return wait_for_command(response['id'])
def get_download_queue_size() -> int:
"""
GET /api/v5/queue
Returns total number of items in the queue with the status 'downloading'.
"""
# Endpoint is the same for all apps
response = arr_request("queue?status=downloading")
if not response:
return 0
total_records = response.get("totalRecords", 0)
if not isinstance(total_records, int):
total_records = 0
logger.debug(f"Download Queue Size: {total_records}")
return total_records
def get_cutoff_unmet(page: int = 1) -> Optional[Dict]:
"""
GET /api/v5/wanted/cutoff?sortKey=airDateUtc&sortDirection=descending&includeSeriesInformation=true
&page=<page>&pageSize=200
Returns JSON with a "records" array and "totalRecords".
"""
if APP_TYPE != "sonarr":
logger.error("get_cutoff_unmet() called but APP_TYPE is not sonarr")
return None
endpoint = (
"wanted/cutoff?"
"sortKey=airDateUtc&sortDirection=descending&includeSeriesInformation=true"
f"&page={page}&pageSize=200"
)
return arr_request(endpoint, method="GET")
def get_cutoff_unmet_total_pages() -> int:
"""
To find total pages, call the endpoint with page=1&pageSize=1, read totalRecords,
then compute how many pages if each pageSize=200.
"""
if APP_TYPE != "sonarr":
logger.error("get_cutoff_unmet_total_pages() called but APP_TYPE is not sonarr")
return 0
response = arr_request("wanted/cutoff?page=1&pageSize=1")
if not response or "totalRecords" not in response:
return 0
total_records = response.get("totalRecords", 0)
if not isinstance(total_records, int) or total_records < 1:
return 0
# Each page has up to 200 episodes
total_pages = (total_records + 200 - 1) // 200
return max(total_pages, 1)
def get_episodes_for_series(series_id: int) -> Optional[List[Dict]]:
"""Get all episodes for a specific series"""
if APP_TYPE != "sonarr":
logger.error("get_episodes_for_series() called but APP_TYPE is not sonarr")
return None
return arr_request(f"episode?seriesId={series_id}", method="GET")
def get_missing_episodes(pageSize: int = 1000) -> Optional[Dict]:
"""
GET /api/v5/wanted/missing?pageSize=<pageSize>&includeSeriesInformation=true
Returns JSON with a "records" array of missing episodes and "totalRecords".
"""
if APP_TYPE != "sonarr":
logger.error("get_missing_episodes() called but APP_TYPE is not sonarr")
return None
endpoint = f"wanted/missing?pageSize={pageSize}&includeSeriesInformation=true"
result = arr_request(endpoint, method="GET")
# Better debugging for missing episodes query
if result:
logger.debug(f"Found {result.get('totalRecords', 0)} total missing episodes")
if result.get('records'):
logger.debug(f"First few missing episodes: {result['records'][:2] if len(result['records']) > 2 else result['records']}")
else:
logger.warning("Missing episodes query returned no data")
return result
def get_series_with_missing_episodes() -> List[Dict]:
"""
Fetch all shows that have missing episodes using the wanted/missing endpoint.
Returns a list of series objects with an additional 'missingEpisodes' field
containing the list of missing episodes for that series.
"""
if APP_TYPE != "sonarr":
logger.error("get_series_with_missing_episodes() called but APP_TYPE is not sonarr")
return []
# Log request attempt
logger.debug("Requesting missing episodes from Sonarr API")
missing_data = get_missing_episodes()
if not missing_data or "records" not in missing_data:
logger.error("Failed to get missing episodes data or no 'records' field in response")
return []
# Group missing episodes by series ID
series_with_missing = {}
for episode in missing_data.get("records", []):
series_id = episode.get("seriesId")
if not series_id:
logger.warning(f"Found episode without seriesId: {episode}")
continue
series_title = None
# Try to get series info from the episode record
if "series" in episode and isinstance(episode["series"], dict):
series_info = episode["series"]
series_title = series_info.get("title")
# Initialize the series entry if it doesn't exist
if series_id not in series_with_missing:
series_with_missing[series_id] = {
"id": series_id,
"title": series_title or "Unknown Show",
"monitored": series_info.get("monitored", False),
"missingEpisodes": []
}
else:
# If we don't have series info, need to fetch it
if series_id not in series_with_missing:
# Get series info directly
series_info = arr_request(f"series/{series_id}", method="GET")
if series_info:
series_with_missing[series_id] = {
"id": series_id,
"title": series_info.get("title", "Unknown Show"),
"monitored": series_info.get("monitored", False),
"missingEpisodes": []
}
else:
logger.warning(f"Could not get series info for ID {series_id}, skipping episode")
continue
# Add the episode to the series record
if series_id in series_with_missing:
series_with_missing[series_id]["missingEpisodes"].append(episode)
# Convert to list and add count for convenience
result = []
for series_id, series_data in series_with_missing.items():
series_data["missingEpisodeCount"] = len(series_data["missingEpisodes"])
result.append(series_data)
logger.debug(f"Processed missing episodes data into {len(result)} series with missing episodes")
return result
def get_media_stats():
"""Get statistics for hunted and upgraded media"""
try:
stats = get_stats()
return jsonify({
"success": True,
"stats": stats
})
except Exception as e:
logger.error(f"Error retrieving media statistics: {e}")
return jsonify({
"success": False,
"message": "Error retrieving media statistics."
}), 500
def reset_media_stats():
"""Reset statistics for hunted and upgraded media"""
try:
app_type = request.json.get('app_type') if request.json else None
reset_stats(app_type)
return jsonify({
"success": True,
"message": f"Successfully reset statistics for {'all apps' if app_type is None else app_type}."
})
except Exception as e:
logger.error(f"Error resetting media statistics: {e}")
return jsonify({
"success": False,
"message": "Error resetting media statistics."
}), 500

View File

@@ -0,0 +1,129 @@
import logging
import json
import pathlib
from datetime import datetime
import time
class WebAddressFilter(logging.Filter):
"""Filter out web interface availability messages"""
def filter(self, record):
if "Web interface available at http://" in record.getMessage():
return False
return True
def configure_logging():
# Get timezone set in the environment (this will be updated when user changes the timezone in UI)
try:
# Create a custom formatter that includes timezone information
class TimezoneFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None):
ct = self.converter(record.created)
if datefmt:
return time.strftime(datefmt, ct)
else:
# Include timezone in the timestamp
return time.strftime("%Y-%m-%d %H:%M:%S %z", ct)
# Configure the formatter for all handlers
formatter = TimezoneFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Reset the root logger and reconfigure with proper timezone handling
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(level=logging.INFO)
# Apply the formatter to all handlers
for handler in logging.root.handlers:
handler.setFormatter(formatter)
except Exception as e:
# Fallback to basic logging if any issues
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.error(f"Error setting up timezone-aware logging: {e}")
# Add filter to remove web interface URL logs
for handler in logging.root.handlers:
handler.addFilter(WebAddressFilter())
logging.info("Logging is configured.")
def migrate_settings():
"""Migrate settings from nested to flat structure"""
# Settings file path
SETTINGS_DIR = pathlib.Path("/config")
SETTINGS_FILE = SETTINGS_DIR / "huntarr.json"
if not SETTINGS_FILE.exists():
logging.info(f"Settings file {SETTINGS_FILE} does not exist, nothing to migrate.")
return
try:
# Read current settings
with open(SETTINGS_FILE, "r", encoding="utf-8") as file:
settings = json.load(file)
# Flag to track if changes were made
changes_made = False
# Check and migrate each app's settings
for app in ["sonarr", "radarr", "lidarr", "readarr"]:
if app in settings and "huntarr" in settings[app]:
logging.info(f"Found nested huntarr section in {app}, migrating...")
# Move all settings from app.huntarr to app level
for key, value in settings[app]["huntarr"].items():
if key not in settings[app]:
settings[app][key] = value
# Remove the huntarr section
del settings[app]["huntarr"]
changes_made = True
# Check for advanced section
if app in settings and "advanced" in settings[app]:
logging.info(f"Found advanced section in {app}, migrating...")
# Move all settings from app.advanced to app level
for key, value in settings[app]["advanced"].items():
if key not in settings[app]:
settings[app][key] = value
# Remove the advanced section
del settings[app]["advanced"]
changes_made = True
# Remove global section if present
if "global" in settings:
logging.info("Removing global section...")
del settings["global"]
changes_made = True
# Remove UI section if present
if "ui" in settings:
logging.info("Removing UI section...")
del settings["ui"]
changes_made = True
# Save changes if needed
if changes_made:
with open(SETTINGS_FILE, "w", encoding="utf-8") as file:
json.dump(settings, file, indent=2)
logging.info("Settings migration completed successfully.")
else:
logging.info("No changes needed, settings are already in the correct format.")
except Exception as e:
logging.error(f"Error migrating settings: {e}")
if __name__ == "__main__":
configure_logging()
logging.info("Starting Huntarr application")
# Migrate settings to flat structure
migrate_settings()
# Using filtered logging
logging.info("Web interface available at http://localhost:8080")
logging.info("Application started")

View File

@@ -0,0 +1,41 @@
# If this file doesn't exist, we'll create it
import os
from src.primary.utils.logger import get_logger
from src.primary.settings_manager import load_settings
logger = get_logger("app_manager")
# List of supported app types
SUPPORTED_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]
def initialize_apps():
"""Initialize all supported applications"""
for app_type in SUPPORTED_APP_TYPES:
initialize_app(app_type)
# Also load general settings but don't treat it as a regular app
load_general_settings()
def initialize_app(app_type):
"""Initialize a specific application"""
if app_type not in SUPPORTED_APP_TYPES:
logger.warning(f"Attempted to initialize unsupported app type: {app_type}")
return False
# Load settings for this app
settings = load_settings(app_type)
# Additional initialization as needed
# ...
return True
def load_general_settings():
"""Load general settings without treating it as a regular app"""
settings = load_settings("general")
logger.info("--- Configuration for general ---")
# Log the settings as needed
# ...
logger.info("--- End Configuration for general ---")
return settings

View File

@@ -0,0 +1,24 @@
"""
Centralized blueprint imports
This module provides a single location to import all app blueprints
to avoid circular import issues
"""
# Import blueprints from the renamed route files
from src.primary.apps.sonarr_routes import sonarr_bp
from src.primary.apps.radarr_routes import radarr_bp
from src.primary.apps.lidarr_routes import lidarr_bp
from src.primary.apps.readarr_routes import readarr_bp
from src.primary.apps.whisparr_routes import whisparr_bp
from src.primary.apps.swaparr_routes import swaparr_bp
from src.primary.apps.eros_routes import eros_bp
__all__ = [
"sonarr_bp",
"radarr_bp",
"lidarr_bp",
"readarr_bp",
"whisparr_bp",
"swaparr_bp",
"eros_bp"
]

View File

@@ -0,0 +1,171 @@
from flask import Blueprint, request, jsonify
import datetime, os, requests
from primary import keys_manager
from src.primary.utils.logger import get_logger
from src.primary.state import get_state_file_path
from src.primary.settings_manager import load_settings, settings_manager
eros_bp = Blueprint('eros', __name__)
eros_logger = get_logger("eros")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("eros", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("eros", "processed_upgrades")
@eros_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to an Eros API instance with comprehensive diagnostics"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
# Log the test attempt
eros_logger.info(f"Testing connection to Eros API at {api_url}")
# First check if URL is properly formatted
if not (api_url.startswith('http://') or api_url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
eros_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# Try multiple API path combinations to handle different Whisparr V3/Eros setups
api_paths = [
"/api/v3/system/status", # Standard V3 path
"/api/system/status", # Standard V2 path that might still work
"/system/status" # Direct path without /api prefix
]
success = False
last_error = None
response_data = None
for api_path in api_paths:
test_url = f"{api_url.rstrip('/')}{api_path}"
headers = {'X-Api-Key': api_key}
eros_logger.debug(f"Trying Eros API path: {test_url}")
try:
# Use a connection timeout separate from read timeout
response = requests.get(test_url, headers=headers, timeout=(10, api_timeout))
# Log HTTP status code for diagnostic purposes
eros_logger.debug(f"Eros API status code: {response.status_code} for path {api_path}")
# Check HTTP status code
if response.status_code == 404:
# Try next path if 404
continue
response.raise_for_status()
# Ensure the response is valid JSON
try:
response_data = response.json()
eros_logger.debug(f"Eros API response: {response_data}")
# Verify this is actually an Eros API by checking for version
version = response_data.get('version', None)
if not version:
# No version info, try next path
last_error = "API response doesn't contain version information"
continue
# The version number should start with 3 for Eros
if version.startswith('3'):
eros_logger.info(f"Successfully connected to Eros API version {version} using path {api_path}")
success = True
break
elif version.startswith('2'):
error_msg = f"Connected to Whisparr V2 (version {version}). Use the Whisparr integration for V2."
eros_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
else:
# Connected to some other version, try next path
last_error = f"Connected to unknown version {version}, but Huntarr requires Eros V3"
continue
except ValueError:
last_error = "Invalid JSON response from API"
continue
except requests.exceptions.Timeout:
last_error = f"Connection timed out after {api_timeout} seconds"
continue
except requests.exceptions.ConnectionError:
last_error = "Failed to connect. Check that the URL is correct and that Eros is running."
continue
except requests.exceptions.HTTPError as e:
last_error = f"HTTP error: {str(e)}"
continue
except Exception as e:
last_error = f"Unexpected error: {str(e)}"
continue
# After trying all paths
if success:
return jsonify({
"success": True,
"message": f"Successfully connected to Eros (version {response_data.get('version')})",
"version": response_data.get('version')
})
else:
error_msg = last_error or "Failed to connect to Eros API. Please check your URL and API key."
eros_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# Function to check if Eros is configured
def is_configured():
"""Check if Eros API credentials are configured"""
try:
settings = load_settings("eros")
instances = settings.get("instances", [])
for instance in instances:
if instance.get("enabled", True):
return True
return False
except Exception as e:
eros_logger.error(f"Error checking if Eros is configured: {str(e)}")
return False
# Get all valid instances from settings
def get_configured_instances():
"""Get all configured and enabled Eros instances"""
try:
settings = load_settings("eros")
instances = settings.get("instances", [])
enabled_instances = []
for instance in instances:
if not instance.get("enabled", True):
continue
api_url = instance.get("api_url")
api_key = instance.get("api_key")
if not api_url or not api_key:
continue
# Add name and timeout
instance_name = instance.get("name", "Default")
api_timeout = instance.get("api_timeout", 90)
enabled_instances.append({
"api_url": api_url,
"api_key": api_key,
"instance_name": instance_name,
"api_timeout": api_timeout
})
return enabled_instances
except Exception as e:
eros_logger.error(f"Error getting configured Eros instances: {str(e)}")
return []

View File

@@ -0,0 +1,95 @@
"""
Eros app module for Huntarr
Contains functionality for missing items and quality upgrades in Eros
Exclusively supports the v3 API.
"""
# Module exports
from src.primary.apps.eros.missing import process_missing_items
from src.primary.apps.eros.upgrade import process_cutoff_upgrades
from src.primary.settings_manager import load_settings
from src.primary.utils.logger import get_logger
# Define logger for this module
eros_logger = get_logger("eros")
# For backward compatibility
process_missing_scenes = process_missing_items
def get_configured_instances():
"""Get all configured and enabled Eros instances"""
settings = load_settings("eros")
instances = []
# Use debug level to avoid log spam on new installations
eros_logger.debug(f"Loaded Eros settings for instance check: {settings}")
if not settings:
eros_logger.debug("No settings found for Eros")
return instances
# Always use Eros V3 API
# Use debug level to avoid log spam on new installations
eros_logger.debug("Using Eros API v3 exclusively")
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
# Use debug level to avoid log spam on new installations
eros_logger.debug(f"Found 'instances' list with {len(settings['instances'])} items. Processing...")
for idx, instance in enumerate(settings["instances"]):
eros_logger.debug(f"Checking instance #{idx}: {instance}")
# Enhanced validation
api_url = instance.get("api_url", "").strip()
api_key = instance.get("api_key", "").strip()
# Enhanced URL validation - ensure URL has proper scheme
if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')):
eros_logger.warning(f"Instance '{instance.get('name', 'Unnamed')}' has URL without http(s) scheme: {api_url}")
api_url = f"http://{api_url}"
eros_logger.warning(f"Auto-correcting URL to: {api_url}")
is_enabled = instance.get("enabled", True)
# Only include properly configured instances
if is_enabled and api_url and api_key:
instance_name = instance.get("name", "Default")
# Create a settings object for this instance by combining global settings with instance-specific ones
instance_settings = settings.copy()
# Remove instances list to avoid confusion
if "instances" in instance_settings:
del instance_settings["instances"]
# Override with instance-specific settings
instance_settings["api_url"] = api_url
instance_settings["api_key"] = api_key
instance_settings["instance_name"] = instance_name
# Add timeout setting with default if not present
if "api_timeout" not in instance_settings:
instance_settings["api_timeout"] = 30
# Use debug level to prevent log spam
eros_logger.debug(f"Adding configured Eros instance: {instance_name}")
instances.append(instance_settings)
else:
name = instance.get("name", "Unnamed")
if not is_enabled:
eros_logger.debug(f"Skipping disabled instance: {name}")
else:
# For brand new installations, don't spam logs with warnings about default instances
if name == 'Default':
# Use debug level for default instances to avoid log spam on new installations
eros_logger.debug(f"Skipping instance {name} due to missing API URL or API Key")
else:
# Still log warnings for non-default instances
eros_logger.warning(f"Skipping instance {name} due to missing API URL or API Key")
else:
eros_logger.debug("No instances array found in settings or it's empty")
# Use debug level to avoid spamming logs, especially with 0 instances
eros_logger.debug(f"Found {len(instances)} configured and enabled Eros instances")
return instances
__all__ = ["process_missing_items", "process_missing_scenes", "process_cutoff_upgrades", "get_configured_instances"]

View File

@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""
Eros-specific API functions
Handles all communication with the Eros API
Exclusively uses the Eros API v3
"""
import requests
import json
import time
import datetime
import traceback
import sys
from typing import List, Dict, Any, Optional, Union
from src.primary.utils.logger import get_logger
# Get logger for the Eros app
eros_logger = get_logger("eros")
# Use a session for better performance
session = requests.Session()
def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any:
"""
Make a request to the Eros API.
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
endpoint: The API endpoint to call
method: HTTP method (GET, POST, PUT, DELETE)
data: Optional data to send with the request
Returns:
The JSON response from the API, or None if the request failed
"""
if not api_url or not api_key:
eros_logger.error("API URL or API key is missing. Check your settings.")
return None
# Always use v3 API path
api_base = "api/v3"
eros_logger.debug(f"Using Eros API path: {api_base}")
# Full URL - ensure no double slashes
url = f"{api_url.rstrip('/')}/{api_base}/{endpoint.lstrip('/')}"
# Add debug logging for the exact URL being called
eros_logger.debug(f"Making {method} request to: {url}")
# Headers
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
try:
if method == "GET":
response = session.get(url, headers=headers, timeout=api_timeout)
elif method == "POST":
response = session.post(url, headers=headers, json=data, timeout=api_timeout)
elif method == "PUT":
response = session.put(url, headers=headers, json=data, timeout=api_timeout)
elif method == "DELETE":
response = session.delete(url, headers=headers, timeout=api_timeout)
else:
eros_logger.error(f"Unsupported HTTP method: {method}")
return None
# Check if the request was successful
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
eros_logger.error(f"Error during {method} request to {endpoint}: {e}, Status Code: {response.status_code}")
eros_logger.debug(f"Response content: {response.text[:200]}")
return None
# Try to parse JSON response
try:
if response.text:
result = response.json()
eros_logger.debug(f"Response from {response.url}: Status {response.status_code}, JSON parsed successfully")
return result
else:
eros_logger.debug(f"Response from {response.url}: Status {response.status_code}, Empty response")
return {}
except json.JSONDecodeError:
eros_logger.error(f"Invalid JSON response from API: {response.text[:200]}")
return None
except requests.exceptions.RequestException as e:
eros_logger.error(f"Request failed: {e}")
return None
except Exception as e:
eros_logger.error(f"Unexpected error during API request: {e}")
return None
def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int:
"""
Get the current size of the download queue.
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
Returns:
The number of items in the download queue, or -1 if the request failed
"""
response = arr_request(api_url, api_key, api_timeout, "queue")
if response is None:
return -1
# V3 API returns a list directly
if isinstance(response, list):
return len(response)
# Fallback to records format if needed
elif isinstance(response, dict) and "records" in response:
return len(response["records"])
else:
return -1
def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]:
"""
Get a list of items with missing files (not downloaded/available).
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored items.
search_mode: The search mode to use - 'movie' for movie-based or 'scene' for scene-based
Returns:
A list of item objects with missing files, or None if the request failed.
"""
try:
eros_logger.debug(f"Retrieving missing items using search mode: {search_mode}...")
if search_mode == "movie":
# In movie mode, we get all movies and filter for ones without files
endpoint = "movie"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
return None
# Extract the movies with missing files
items = []
if isinstance(response, list):
# Filter for movies that don't have files (hasFile = false)
items = [item for item in response if not item.get("hasFile", True)]
elif isinstance(response, dict) and "records" in response:
# Fallback to old format if somehow it returns in this format
items = [item for item in response["records"] if not item.get("hasFile", True)]
elif search_mode == "scene":
# In scene mode, we try to use scene-specific endpoints
# First check if the movie-scene endpoint exists
endpoint = "scene/missing?pageSize=1000"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
# Fallback to regular movie filtering if scene endpoint doesn't exist
eros_logger.warning("Scene endpoint not available, falling back to movie mode")
return get_items_with_missing(api_url, api_key, api_timeout, monitored_only, "movie")
# Extract the scenes
items = []
if isinstance(response, dict) and "records" in response:
items = response["records"]
elif isinstance(response, list):
items = response
else:
# Invalid search mode
eros_logger.error(f"Invalid search mode: {search_mode}. Must be 'movie' or 'scene'")
return None
# Filter monitored if needed
if monitored_only:
items = [item for item in items if item.get("monitored", False)]
eros_logger.debug(f"Found {len(items)} missing items using {search_mode} mode")
return items
except Exception as e:
eros_logger.error(f"Error retrieving missing items: {str(e)}")
return None
def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
"""
Get a list of items that don't meet their quality profile cutoff.
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored items.
Returns:
A list of item objects that need quality upgrades, or None if the request failed.
"""
try:
eros_logger.debug(f"Retrieving cutoff unmet items...")
# Endpoint
endpoint = "wanted/cutoff?pageSize=1000&sortKey=airDateUtc&sortDirection=descending"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
return None
# Extract the episodes/items
items = []
if isinstance(response, dict) and "records" in response:
items = response["records"]
elif isinstance(response, list):
items = response
eros_logger.debug(f"Found {len(items)} cutoff unmet items")
# Just filter monitored if needed
if monitored_only:
items = [item for item in items if item.get("monitored", False)]
eros_logger.debug(f"Found {len(items)} cutoff unmet items after filtering monitored")
return items
except Exception as e:
eros_logger.error(f"Error retrieving cutoff unmet items: {str(e)}")
return None
def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]:
"""
Get a list of items that can be upgraded to better quality.
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored items.
search_mode: The search mode to use - 'movie' for movie-based or 'scene' for scene-based
Returns:
A list of item objects that need quality upgrades, or None if the request failed.
"""
try:
eros_logger.debug(f"Retrieving quality upgrade items using search mode: {search_mode}...")
if search_mode == "movie":
# In movie mode, we get all movies and filter for ones that have files but need quality upgrades
endpoint = "movie"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
return None
# Extract movies that have files but need quality upgrades
items = []
if isinstance(response, list):
# Filter for movies that have files but haven't met quality cutoff
items = [item for item in response if item.get("hasFile", False) and item.get("qualityCutoffNotMet", False)]
elif isinstance(response, dict) and "records" in response:
# Fallback to old format if somehow it returns in this format
items = [item for item in response["records"] if item.get("hasFile", False) and item.get("qualityCutoffNotMet", False)]
elif search_mode == "scene":
# In scene mode, try to use scene-specific endpoints
endpoint = "scene/cutoff?pageSize=1000"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
# Fallback to regular movie filtering if scene endpoint doesn't exist
eros_logger.warning("Scene cutoff endpoint not available, falling back to movie mode")
return get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, "movie")
# Extract the scenes
items = []
if isinstance(response, dict) and "records" in response:
items = response["records"]
elif isinstance(response, list):
items = response
else:
# Invalid search mode
eros_logger.error(f"Invalid search mode: {search_mode}. Must be 'movie' or 'scene'")
return None
# Filter monitored if needed
if monitored_only:
items = [item for item in items if item.get("monitored", False)]
eros_logger.debug(f"Found {len(items)} quality upgrade items using {search_mode} mode")
return items
except Exception as e:
eros_logger.error(f"Error retrieving quality upgrade items: {str(e)}")
return None
def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int) -> int:
"""
Refresh a movie in Whisparr V3.
Args:
api_url: The base URL of the Whisparr V3 API
api_key: The API key for authentication
api_timeout: Timeout for the API request
item_id: The ID of the movie to refresh
Returns:
The command ID if the refresh was triggered successfully, None otherwise
"""
try:
eros_logger.info(f"Explicitly refreshing movie with ID {item_id} via API call")
# In Whisparr V3, we use RefreshMovie command directly with the movieId
payload = {
"name": "RefreshMovie",
"movieId": item_id
}
# Command endpoint
command_endpoint = "command"
# Make the API request
response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload)
if response and "id" in response:
command_id = response["id"]
eros_logger.info(f"Refresh movie command triggered with ID {command_id} for movie {item_id}")
return command_id
else:
eros_logger.error(f"Failed to trigger refresh command for movie {item_id} - no command ID returned")
return None
except Exception as e:
eros_logger.error(f"Error refreshing movie {item_id}: {str(e)}")
return None
def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int]) -> int:
"""
Trigger a search for one or more movies in Whisparr V3.
Args:
api_url: The base URL of the Whisparr V3 API
api_key: The API key for authentication
api_timeout: Timeout for the API request
item_ids: A list of movie IDs to search for
Returns:
The command ID if the search command was triggered successfully, None otherwise
"""
try:
if not item_ids:
eros_logger.warning("No movie IDs provided for search.")
return None
eros_logger.debug(f"Searching for movies with IDs: {item_ids}")
# Try several possible command formats, as the API might be in flux
possible_commands = [
# Format 1: MoviesSearch with integer IDs (Radarr-like) and no auto-refresh
{
"name": "MoviesSearch",
"movieIds": item_ids,
"updateScheduledTask": False,
"runRefreshAfterSearch": False,
"sendUpdatesToClient": False
},
# Format 2: MovieSearch with integer IDs and no auto-refresh
{
"name": "MovieSearch",
"movieIds": item_ids,
"updateScheduledTask": False,
"runRefreshAfterSearch": False,
"sendUpdatesToClient": False
},
# Format 3: MoviesSearch with string IDs and no auto-refresh
{
"name": "MoviesSearch",
"movieIds": [str(id) for id in item_ids],
"updateScheduledTask": False,
"runRefreshAfterSearch": False,
"sendUpdatesToClient": False
},
# Format 4: MovieSearch with string IDs and no auto-refresh
{
"name": "MovieSearch",
"movieIds": [str(id) for id in item_ids],
"updateScheduledTask": False,
"runRefreshAfterSearch": False,
"sendUpdatesToClient": False
},
# Fallback to original formats if the above don't work
{
"name": "MoviesSearch",
"movieIds": item_ids
},
{
"name": "MovieSearch",
"movieIds": item_ids
},
{
"name": "MoviesSearch",
"movieIds": [str(id) for id in item_ids]
},
{
"name": "MovieSearch",
"movieIds": [str(id) for id in item_ids]
}
]
# Command endpoint
command_endpoint = "command"
# Try each command format until one works
for i, payload in enumerate(possible_commands):
eros_logger.debug(f"Trying search command format {i+1}: {payload}")
# Make the API request
response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload)
if response and "id" in response:
command_id = response["id"]
eros_logger.debug(f"Search command format {i+1} succeeded with ID {command_id}")
return command_id
# If we've tried all formats and none worked:
eros_logger.error("All search command formats failed - no command ID returned")
return None
except Exception as e:
eros_logger.error(f"Error searching for movies: {str(e)}")
return None
def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict]:
"""
Get the status of a specific command.
Args:
api_url: The base URL of the Eros API
api_key: The API key for authentication
api_timeout: Timeout for the API request
command_id: The ID of the command to check
Returns:
A dictionary containing the command status, or None if the request failed.
"""
if not command_id:
eros_logger.error("No command ID provided for status check.")
return None
try:
command_endpoint = f"command/{command_id}"
# Make the API request
result = arr_request(api_url, api_key, api_timeout, command_endpoint)
if result:
eros_logger.debug(f"Command {command_id} status: {result.get('status', 'unknown')}")
return result
else:
eros_logger.error(f"Failed to get command status for ID {command_id}")
return None
except Exception as e:
eros_logger.error(f"Error getting command status for ID {command_id}: {e}")
return None
def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
"""
Check the connection to Whisparr V3 API.
Args:
api_url: The base URL of the Whisparr V3 API
api_key: The API key for authentication
api_timeout: Timeout for the API request
Returns:
True if the connection is successful, False otherwise
"""
try:
eros_logger.debug(f"Checking connection to Whisparr V3 instance at {api_url}")
endpoint = "system/status"
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is not None:
# Get the version information if available
version = response.get("version", "unknown")
# Simply check if we received a valid response - Whisparr V3 is in development
# so the version number might be in various formats
if version and isinstance(version, str):
eros_logger.info(f"Successfully connected to Whisparr V3 API, reported version: {version}")
return True
else:
eros_logger.warning(f"Connected to server but found unexpected version format: {version}")
return False
else:
eros_logger.error("Failed to connect to Whisparr V3 API")
return False
except Exception as e:
eros_logger.error(f"Error checking connection to Whisparr V3 API: {str(e)}")
return False

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Missing Items Processing for Eros
Handles searching for missing items in Eros
Exclusively supports the v3 API.
"""
import time
import random
import datetime
from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.eros import api as eros_api
from src.primary.settings_manager import load_settings, get_advanced_setting
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.stats_manager import increment_stat
from src.primary.utils.history_utils import log_processed_media
from src.primary.state import check_state_reset
# Get logger for the app
eros_logger = get_logger("eros")
def process_missing_items(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process missing items in Eros based on provided settings.
Args:
app_settings: Dictionary containing all settings for Eros
stop_check: A function that returns True if the process should stop
Returns:
True if any items were processed, False otherwise.
"""
eros_logger.info("Starting missing items processing cycle for Eros.")
processed_any = False
# Reset state files if enough time has passed
check_state_reset("eros")
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
instance_name = app_settings.get("instance_name", "Eros Default")
# Load general settings to get centralized timeout
general_settings = load_settings('general')
monitored_only = app_settings.get("monitored_only", True)
skip_future_releases = app_settings.get("skip_future_releases", True)
skip_item_refresh = app_settings.get("skip_item_refresh", False)
eros_logger.info(f"Skip item refresh setting: {skip_item_refresh}")
search_mode = app_settings.get("search_mode", "movie") # Default to movie mode if not specified
eros_logger.info(f"Using search mode: {search_mode} for missing items")
# Use the new hunt_missing_items parameter name, falling back to hunt_missing_scenes for backwards compatibility
hunt_missing_items = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 0))
# Use advanced settings from general.json for command operations
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
# Use the centralized advanced setting for stateful management hours
stateful_management_hours = get_advanced_setting("stateful_management_hours", 168)
# Log that we're using Eros v3 API
eros_logger.info(f"Using Eros API v3 for instance: {instance_name}")
# Skip if hunt_missing_items is set to a negative value or 0
if hunt_missing_items <= 0:
eros_logger.info("'hunt_missing_items' setting is 0 or less. Skipping missing item processing.")
return False
# Check for stop signal
if stop_check():
eros_logger.info("Stop requested before starting missing items. Aborting...")
return False
# Get missing items
eros_logger.info(f"Retrieving items with missing files...")
missing_items = eros_api.get_items_with_missing(api_url, api_key, api_timeout, monitored_only, search_mode)
if missing_items is None: # API call failed
eros_logger.error("Failed to retrieve missing items from Eros API.")
return False
if not missing_items:
eros_logger.info("No missing items found.")
return False
# Check for stop signal after retrieving items
if stop_check():
eros_logger.info("Stop requested after retrieving missing items. Aborting...")
return False
eros_logger.info(f"Found {len(missing_items)} items with missing files.")
# Filter out future releases if configured
if skip_future_releases:
now = datetime.datetime.now(datetime.timezone.utc)
original_count = len(missing_items)
# Eros item object has 'airDateUtc' for release dates
missing_items = [
item for item in missing_items
if not item.get('airDateUtc') or (
item.get('airDateUtc') and
datetime.datetime.fromisoformat(item['airDateUtc'].replace('Z', '+00:00')) < now
)
]
skipped_count = original_count - len(missing_items)
if skipped_count > 0:
eros_logger.info(f"Skipped {skipped_count} future item releases based on air date.")
if not missing_items:
eros_logger.info("No missing items left to process after filtering future releases.")
return False
# Filter out already processed items using stateful management
unprocessed_items = []
for item in missing_items:
item_id = str(item.get("id"))
if not is_processed("eros", instance_name, item_id):
unprocessed_items.append(item)
else:
eros_logger.debug(f"Skipping already processed item ID: {item_id}")
eros_logger.info(f"Found {len(unprocessed_items)} unprocessed items out of {len(missing_items)} total items with missing files.")
if not unprocessed_items:
eros_logger.info(f"No unprocessed items found for {instance_name}. All available items have been processed.")
return False
items_processed = 0
processing_done = False
# Select items to search based on configuration
eros_logger.info(f"Randomly selecting up to {hunt_missing_items} missing items.")
items_to_search = random.sample(unprocessed_items, min(len(unprocessed_items), hunt_missing_items))
eros_logger.info(f"Selected {len(items_to_search)} missing items to search.")
# Process selected items
for item in items_to_search:
# Check for stop signal before each item
if stop_check():
eros_logger.info("Stop requested during item processing. Aborting...")
break
# Re-check limit in case it changed
current_limit = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 1))
if items_processed >= current_limit:
eros_logger.info(f"Reached HUNT_MISSING_ITEMS limit ({current_limit}) for this cycle.")
break
item_id = item.get("id")
title = item.get("title", "Unknown Title")
# For movies, we don't use season/episode format
if search_mode == "movie":
item_info = title
else:
# If somehow using scene mode, try to format as S/E if available
season_number = item.get('seasonNumber')
episode_number = item.get('episodeNumber')
if season_number is not None and episode_number is not None:
season_episode = f"S{season_number:02d}E{episode_number:02d}"
item_info = f"{title} - {season_episode}"
else:
item_info = title
eros_logger.info(f"Processing missing item: \"{item_info}\" (Item ID: {item_id})")
# Mark the item as processed BEFORE triggering any searches
add_processed_id("eros", instance_name, str(item_id))
eros_logger.debug(f"Added item ID {item_id} to processed list for {instance_name}")
# Refresh the item information if not skipped
refresh_command_id = None
if not skip_item_refresh:
eros_logger.info(" - Refreshing item information...")
refresh_command_id = eros_api.refresh_item(api_url, api_key, api_timeout, item_id)
if refresh_command_id:
eros_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...")
time.sleep(5) # Basic wait
else:
eros_logger.warning(f"Failed to trigger refresh command for item ID: {item_id}. Proceeding without refresh.")
else:
eros_logger.info(" - Skipping item refresh (skip_item_refresh=true)")
# Check for stop signal before searching
if stop_check():
eros_logger.info(f"Stop requested before searching for {title}. Aborting...")
break
# Search for the item
eros_logger.info(" - Searching for missing item...")
search_command_id = eros_api.item_search(api_url, api_key, api_timeout, [item_id])
if search_command_id:
eros_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.")
# Log to history system
log_processed_media("eros", item_info, item_id, instance_name, "missing")
eros_logger.debug(f"Logged history entry for item: {item_info}")
items_processed += 1
processing_done = True
# Increment the hunted statistics for Eros
increment_stat("eros", "hunted", 1)
eros_logger.debug(f"Incremented eros hunted statistics by 1")
# Log progress
current_limit = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 1))
eros_logger.info(f"Processed {items_processed}/{current_limit} missing items this cycle.")
else:
eros_logger.warning(f"Failed to trigger search command for item ID {item_id}.")
# Do not mark as processed if search couldn't be triggered
continue
# Log final status
if items_processed > 0:
eros_logger.info(f"Completed processing {items_processed} missing items for this cycle.")
else:
eros_logger.info("No new missing items were processed in this run.")
return processing_done
# For backward compatibility with the background processing system
def process_missing_scenes(app_settings, stop_check):
"""
Backwards compatibility function that calls process_missing_items.
Args:
app_settings: Dictionary containing all settings for Eros
stop_check: A function that returns True if the process should stop
Returns:
Result from process_missing_items
"""
return process_missing_items(app_settings, stop_check)

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Quality Upgrade Processing for Eros
Handles searching for items that need quality upgrades in Eros
Exclusively supports the v3 API.
"""
import time
import random
import datetime
from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.eros import api as eros_api
from src.primary.settings_manager import load_settings, get_advanced_setting
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.stats_manager import increment_stat
from src.primary.utils.history_utils import log_processed_media
from src.primary.state import check_state_reset
# Get logger for the app
eros_logger = get_logger("eros")
def process_cutoff_upgrades(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process quality cutoff upgrades for Eros based on settings.
Args:
app_settings: Dictionary containing all settings for Eros
stop_check: A function that returns True if the process should stop
Returns:
True if any items were processed for upgrades, False otherwise.
"""
eros_logger.info("Starting quality cutoff upgrades processing cycle for Eros.")
processed_any = False
# Reset state files if enough time has passed
check_state_reset("eros")
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
instance_name = app_settings.get("instance_name", "Eros Default")
# Load general settings to get centralized timeout
general_settings = load_settings('general')
monitored_only = app_settings.get("monitored_only", True)
skip_item_refresh = app_settings.get("skip_item_refresh", False)
eros_logger.info(f"Skip item refresh setting: {skip_item_refresh}")
search_mode = app_settings.get("search_mode", "movie") # Default to movie mode if not specified
eros_logger.info(f"Using search mode: {search_mode} for quality upgrades")
# Use the new hunt_upgrade_items parameter name, falling back to hunt_upgrade_scenes for backwards compatibility
hunt_upgrade_items = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 0))
# Use advanced settings from general.json for command operations
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
state_reset_interval_hours = get_advanced_setting("stateful_management_hours", 168)
# Log that we're using Eros API v3
eros_logger.info(f"Using Eros API v3 for instance: {instance_name}")
# Skip if hunt_upgrade_items is set to 0
if hunt_upgrade_items <= 0:
eros_logger.info("'hunt_upgrade_items' setting is 0 or less. Skipping quality upgrade processing.")
return False
# Check for stop signal
if stop_check():
eros_logger.info("Stop requested before starting quality upgrades. Aborting...")
return False
# Get items eligible for upgrade
eros_logger.info(f"Retrieving items eligible for cutoff upgrade...")
upgrade_eligible_data = eros_api.get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, search_mode)
if not upgrade_eligible_data:
eros_logger.info("No items found eligible for upgrade or error retrieving them.")
return False
# Check for stop signal after retrieving eligible items
if stop_check():
eros_logger.info("Stop requested after retrieving upgrade eligible items. Aborting...")
return False
eros_logger.info(f"Found {len(upgrade_eligible_data)} items eligible for quality upgrade.")
# Filter out already processed items using stateful management
unprocessed_items = []
for item in upgrade_eligible_data:
item_id = str(item.get("id"))
if not is_processed("eros", instance_name, item_id):
unprocessed_items.append(item)
else:
eros_logger.debug(f"Skipping already processed item ID: {item_id}")
eros_logger.info(f"Found {len(unprocessed_items)} unprocessed items out of {len(upgrade_eligible_data)} total items eligible for quality upgrade.")
if not unprocessed_items:
eros_logger.info(f"No unprocessed items found for {instance_name}. All available items have been processed.")
return False
items_processed = 0
processing_done = False
# Always use random selection for upgrades
eros_logger.info(f"Randomly selecting up to {hunt_upgrade_items} items for quality upgrade.")
items_to_upgrade = random.sample(unprocessed_items, min(len(unprocessed_items), hunt_upgrade_items))
eros_logger.info(f"Selected {len(items_to_upgrade)} items for quality upgrade.")
# Process selected items
for item in items_to_upgrade:
# Check for stop signal before each item
if stop_check():
eros_logger.info("Stop requested during item processing. Aborting...")
break
# Re-check limit in case it changed
current_limit = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 1))
if items_processed >= current_limit:
eros_logger.info(f"Reached HUNT_UPGRADE_ITEMS limit ({current_limit}) for this cycle.")
break
item_id = item.get("id")
title = item.get("title", "Unknown Title")
# For movies, we don't use season/episode format
if search_mode == "movie":
item_info = title
# In Whisparr, movie quality is stored differently than TV shows
current_quality = item.get("movieFile", {}).get("quality", {}).get("quality", {}).get("name", "Unknown")
else:
# If somehow using scene mode, try to format as S/E if available
season_number = item.get('seasonNumber')
episode_number = item.get('episodeNumber')
if season_number is not None and episode_number is not None:
season_episode = f"S{season_number:02d}E{episode_number:02d}"
item_info = f"{title} - {season_episode}"
else:
item_info = title
# Legacy episode quality path
current_quality = item.get("episodeFile", {}).get("quality", {}).get("quality", {}).get("name", "Unknown")
eros_logger.info(f"Processing item for quality upgrade: \"{item_info}\" (Item ID: {item_id})")
eros_logger.info(f" - Current quality: {current_quality}")
# Mark the item as processed BEFORE triggering any searches
add_processed_id("eros", instance_name, str(item_id))
eros_logger.debug(f"Added item ID {item_id} to processed list for {instance_name}")
# Refresh the item information if not skipped
refresh_command_id = None
if not skip_item_refresh:
eros_logger.info(" - Refreshing item information...")
refresh_command_id = eros_api.refresh_item(api_url, api_key, api_timeout, item_id)
if refresh_command_id:
eros_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...")
time.sleep(5) # Basic wait
else:
eros_logger.warning(f"Failed to trigger refresh command for item ID: {item_id}. Proceeding without refresh.")
else:
eros_logger.info(" - Skipping item refresh (skip_item_refresh=true)")
# Check for stop signal before searching
if stop_check():
eros_logger.info(f"Stop requested before searching for {title}. Aborting...")
break
# Search for the item
eros_logger.info(" - Searching for quality upgrade...")
search_command_id = eros_api.item_search(api_url, api_key, api_timeout, [item_id])
if search_command_id:
eros_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.")
# Log to history so the upgrade appears in the history UI
log_processed_media("eros", item_info, item_id, instance_name, "upgrade")
eros_logger.debug(f"Logged quality upgrade to history for item ID {item_id}")
items_processed += 1
processing_done = True
# Increment the upgraded statistics for Eros
increment_stat("eros", "upgraded", 1)
eros_logger.debug(f"Incremented eros upgraded statistics by 1")
# Log progress
current_limit = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 1))
eros_logger.info(f"Processed {items_processed}/{current_limit} items for quality upgrade this cycle.")
else:
eros_logger.warning(f"Failed to trigger search command for item ID {item_id}.")
# Do not mark as processed if search couldn't be triggered
continue
# Log final status
if items_processed > 0:
eros_logger.info(f"Completed processing {items_processed} items for quality upgrade for this cycle.")
else:
eros_logger.info("No new items were processed for quality upgrade in this run.")
return processing_done

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
from flask import Blueprint, request, jsonify
import datetime, os, requests
from src.primary import keys_manager
from src.primary.state import get_state_file_path, reset_state_file
from src.primary.utils.logger import get_logger, APP_LOG_FILES
from src.primary.settings_manager import load_settings
import traceback
import socket
from urllib.parse import urlparse
from src.primary.apps.eros import api as eros_api
eros_bp = Blueprint('eros', __name__)
eros_logger = get_logger("eros")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("eros", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("eros", "processed_upgrades")
def get_configured_instances():
# Load Eros settings
settings = load_settings("eros")
instances = settings.get("instances", [])
return instances
def test_connection(url, api_key):
# Validate URL format
if not (url.startswith('http://') or url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
# Try to establish a socket connection first to check basic connectivity
parsed_url = urlparse(url)
hostname = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
try:
# Try socket connection for quick feedback on connectivity issues
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3) # Short timeout for quick feedback
result = sock.connect_ex((hostname, port))
sock.close()
if result != 0:
error_msg = f"Connection refused - Unable to connect to {hostname}:{port}. Please check if the server is running and the port is correct."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except socket.gaierror:
error_msg = f"DNS resolution failed - Cannot resolve hostname: {hostname}. Please check your URL."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except Exception as e:
# Log the socket testing error but continue with the full request
eros_logger.debug(f"Socket test error, continuing with full request: {str(e)}")
# For Eros, we only use v3 API path
api_url = f"{url.rstrip('/')}/api/v3/system/status"
headers = {'X-Api-Key': api_key}
try:
# Make the request with appropriate timeouts
eros_logger.debug(f"Trying API path: {api_url}")
response = requests.get(api_url, headers=headers, timeout=(5, 30))
try:
response.raise_for_status()
# Check if we got a valid JSON response
try:
response_data = response.json()
# Verify this is actually an Eros server by checking for version
version = response_data.get('version')
if not version:
error_msg = "API response doesn't contain version information. This doesn't appear to be a valid Eros server."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
# Version check - should be v3.x for Eros
if version.startswith('3'):
detected_version = "v3"
eros_logger.info(f"Successfully connected to Eros API version: {version} (API {detected_version})")
# Success!
return {"success": True, "message": "Successfully connected to Eros API", "version": version, "api_version": detected_version}
elif version.startswith('2'):
error_msg = f"Incompatible version detected: {version}. This appears to be Whisparr V2, not Eros."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
else:
error_msg = f"Unexpected version {version} detected. Eros requires API v3."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except ValueError:
error_msg = "Invalid JSON response from Eros API - This doesn't appear to be a valid Eros server"
eros_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return {"success": False, "message": error_msg}
except requests.exceptions.HTTPError:
# Handle specific HTTP errors
if response.status_code == 401:
error_msg = "Invalid API key - Authentication failed"
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
elif response.status_code == 404:
error_msg = "API endpoint not found: This doesn't appear to be a valid Eros server. Check your URL."
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
else:
error_msg = f"Eros server error (HTTP {response.status_code}): The Eros server is experiencing issues"
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except requests.exceptions.ConnectionError as e:
# Connection error - server might be down or unreachable
error_details = str(e)
if "Connection refused" in error_details:
error_msg = f"Connection refused - Eros is not running on {url} or the port is incorrect"
else:
error_msg = f"Connection error - Check if Eros is running: {error_details}"
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except requests.exceptions.Timeout:
error_msg = f"Connection timed out - Eros took too long to respond"
eros_logger.error(error_msg)
return {"success": False, "message": error_msg}
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
eros_logger.error(f"{error_msg}\n{traceback.format_exc()}")
return {"success": False, "message": error_msg}
@eros_bp.route('/status', methods=['GET'])
def get_status():
"""Get the status of all configured Eros instances"""
try:
instances = get_configured_instances()
eros_logger.debug(f"Eros configured instances: {instances}")
if instances:
connected_count = 0
for instance in instances:
if test_connection(instance['url'], instance['api_key'])['success']:
connected_count += 1
return jsonify({
"configured": True,
"connected": connected_count > 0,
"connected_count": connected_count,
"total_configured": len(instances)
})
else:
eros_logger.debug("No Eros instances configured")
return jsonify({"configured": False, "connected": False})
except Exception as e:
eros_logger.error(f"Error getting Eros status: {str(e)}")
return jsonify({"configured": False, "connected": False, "error": str(e)})
@eros_bp.route('/test-connection', methods=['POST'])
def test_connection_endpoint():
"""Test connection to an Eros API instance"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
eros_logger.info(f"Testing connection to Eros API at {api_url}")
return test_connection(api_url, api_key)
@eros_bp.route('/test-settings', methods=['GET'])
def test_eros_settings():
"""Debug endpoint to test Eros settings loading"""
try:
# Directly read the settings file to bypass any potential caching
import json
import os
# Check all possible settings locations
possible_locations = [
"/config/eros.json", # Main Docker mount
"/app/config/eros.json", # Alternate location
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "config", "eros.json") # Relative path
]
results = {}
# Try all locations
for location in possible_locations:
results[location] = {"exists": os.path.exists(location)}
if os.path.exists(location):
try:
with open(location, 'r') as f:
results[location]["content"] = json.load(f)
except Exception as e:
results[location]["error"] = str(e)
# Also try loading via settings_manager
try:
from src.primary.settings_manager import load_settings
settings = load_settings("eros")
results["settings_manager"] = settings
except Exception as e:
results["settings_manager_error"] = str(e)
return jsonify(results)
except Exception as e:
return jsonify({"error": str(e)})
@eros_bp.route('/reset-processed', methods=['POST'])
def reset_processed_state():
"""Reset the processed state files for Eros"""
try:
# Reset the state files for missing and upgrades
reset_state_file("eros", "processed_missing")
reset_state_file("eros", "processed_upgrades")
eros_logger.info("Successfully reset Eros processed state files")
return jsonify({"success": True, "message": "Successfully reset processed state"})
except Exception as e:
error_msg = f"Error resetting Eros state: {str(e)}"
eros_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 500

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
Lidarr Blueprint for Huntarr
Defines Flask routes for interacting with Lidarr
"""
import json
import traceback
import requests
from flask import Blueprint, jsonify, request
from src.primary.utils.logger import get_logger
from src.primary.apps.lidarr import api as lidarr_api
from src.primary.state import reset_state_file, get_state_file_path
from src.primary.settings_manager import load_settings
import src.primary.config as config
# Create a logger for this module
lidarr_logger = get_logger("lidarr")
# Create Blueprint for Lidarr routes
lidarr_bp = Blueprint('lidarr', __name__)
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("lidarr", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("lidarr", "processed_upgrades")
# Function to check if Lidarr is configured
def is_configured():
"""Check if Lidarr API credentials are configured by checking if at least one instance is enabled"""
settings = load_settings("lidarr")
if not settings:
lidarr_logger.debug("No settings found for Lidarr")
return False
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
lidarr_logger.debug(f"Found configured Lidarr instance: {instance.get('name', 'Unnamed')}")
return True
lidarr_logger.debug("No enabled Lidarr instances found with valid API URL and key")
return False
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
return bool(api_url and api_key)
# Get all valid instances from settings
def get_configured_instances():
"""Get all configured and enabled Lidarr instances"""
settings = load_settings("lidarr")
instances = []
if not settings:
lidarr_logger.debug("No settings found for Lidarr")
return instances
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
# Create a settings object for this instance by combining global settings with instance-specific ones
instance_settings = settings.copy()
# Remove instances list to avoid confusion
if "instances" in instance_settings:
del instance_settings["instances"]
# Override with instance-specific connection settings
instance_settings["api_url"] = instance.get("api_url")
instance_settings["api_key"] = instance.get("api_key")
instance_settings["instance_name"] = instance.get("name", "Default")
instances.append(instance_settings)
else:
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
if api_url and api_key:
settings["instance_name"] = "Default"
instances.append(settings)
lidarr_logger.info(f"Found {len(instances)} configured and enabled Lidarr instances")
return instances
@lidarr_bp.route('/status', methods=['GET'])
def status():
"""Get Lidarr connection status and version."""
try:
# Get API settings from config
settings = config.get_app_settings("lidarr")
if not settings or not settings.get("api_url") or not settings.get("api_key"):
return jsonify({"connected": False, "message": "Lidarr is not configured"}), 200
api_url = settings["api_url"]
api_key = settings["api_key"]
api_timeout = settings.get("api_timeout", 30)
# Check connection and get system status
system_status = lidarr_api.get_system_status(api_url, api_key, api_timeout)
if system_status is not None:
version = system_status.get("version", "Unknown")
return jsonify({
"connected": True,
"version": version,
"message": f"Connected to Lidarr {version}"
}), 200
else:
return jsonify({
"connected": False,
"message": "Failed to connect to Lidarr"
}), 200
except Exception as e:
error_message = f"Error checking Lidarr status: {str(e)}"
lidarr_logger.error(error_message)
lidarr_logger.error(traceback.format_exc())
return jsonify({"connected": False, "message": error_message}), 500
@lidarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to Lidarr with provided API settings."""
try:
# Extract API settings from request
data = request.json
api_url = data.get("api_url", "").rstrip('/')
api_key = data.get("api_key", "")
api_timeout = int(data.get("api_timeout", 30))
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
# Test connection to Lidarr
system_status = lidarr_api.get_system_status(api_url, api_key, api_timeout)
if system_status is not None:
version = system_status.get("version", "Unknown")
return jsonify({
"success": True,
"version": version,
"message": f"Successfully connected to Lidarr {version}"
}), 200
else:
return jsonify({
"success": False,
"message": "Failed to connect to Lidarr. Check URL and API Key."
}), 400
except requests.exceptions.RequestException as e:
error_message = f"Connection error: {str(e)}"
if hasattr(e, 'response'):
if e.response is not None:
error_message += f" - Status Code: {e.response.status_code}, Response: {e.response.text[:200]}"
lidarr_logger.error(f"Lidarr connection error: {error_message}")
return jsonify({"success": False, "message": error_message}), 500
except Exception as e: # Catch any other unexpected errors
lidarr_logger.error(f"An unexpected error occurred during Lidarr connection test: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500
@lidarr_bp.route('/stats', methods=['GET'])
def get_stats():
"""Get statistics about Lidarr library."""
try:
# Get API settings from config
settings = config.get_app_settings("lidarr")
if not settings or not settings.get("api_url") or not settings.get("api_key"):
return jsonify({"error": "Lidarr is not configured"}), 400
api_url = settings["api_url"]
api_key = settings["api_key"]
api_timeout = settings.get("api_timeout", 30)
monitored_only = settings.get("monitored_only", True)
# Get all artists from Lidarr
all_artists = lidarr_api.get_artists(api_url, api_key, api_timeout)
if all_artists is None:
return jsonify({"error": "Failed to get artists from Lidarr"}), 500
# Count total artists and monitored artists
total_artists = len(all_artists)
monitored_artists = sum(1 for artist in all_artists if artist.get("monitored", False))
# Get missing albums
missing_albums = lidarr_api.get_missing_albums(api_url, api_key, api_timeout, monitored_only)
total_missing = len(missing_albums) if missing_albums is not None else 0
# Get cutoff unmet albums
cutoff_unmet = lidarr_api.get_cutoff_unmet_albums(api_url, api_key, api_timeout, monitored_only)
total_upgradable = len(cutoff_unmet) if cutoff_unmet is not None else 0
# Get download queue
queue_size = lidarr_api.get_download_queue_size(api_url, api_key, api_timeout)
# Return stats
return jsonify({
"total_artists": total_artists,
"monitored_artists": monitored_artists,
"missing_albums": total_missing,
"upgradable_albums": total_upgradable,
"queue_size": queue_size
}), 200
except Exception as e:
error_message = f"Error getting Lidarr stats: {str(e)}"
lidarr_logger.error(error_message)
lidarr_logger.error(traceback.format_exc())
return jsonify({"error": error_message}), 500
@lidarr_bp.route('/reset-state', methods=['POST'])
def reset_state():
"""Reset the Lidarr state files to clear processed IDs."""
try:
# JSON object with flags for which states to reset
data = request.json or {}
reset_missing = data.get('reset_missing', True)
reset_upgrades = data.get('reset_upgrades', True)
# Reset missing state if requested
if reset_missing:
reset_state_file("lidarr", "processed_missing")
lidarr_logger.info("Reset Lidarr missing albums state")
# Reset upgrades state if requested
if reset_upgrades:
reset_state_file("lidarr", "processed_upgrades")
lidarr_logger.info("Reset Lidarr upgrades state")
return jsonify({
"success": True,
"message": "Lidarr state reset successfully"
}), 200
except Exception as e:
error_message = f"Error resetting Lidarr state: {str(e)}"
lidarr_logger.error(error_message)
lidarr_logger.error(traceback.format_exc())
return jsonify({"error": error_message}), 500

View File

@@ -0,0 +1,91 @@
"""
Lidarr app module for Huntarr
Contains functionality for missing albums and quality upgrades in Lidarr
"""
# Module exports
from src.primary.apps.lidarr.missing import process_missing_albums
from src.primary.apps.lidarr.upgrade import process_cutoff_upgrades
from src.primary.settings_manager import load_settings
from src.primary.utils.logger import get_logger
# Define logger for this module
lidarr_logger = get_logger("lidarr")
def get_configured_instances():
"""Get all configured and enabled Lidarr instances"""
settings = load_settings("lidarr")
instances = []
# lidarr_logger.info(f"Loaded Lidarr settings for instance check: {settings}") # Removed verbose log
if not settings:
lidarr_logger.debug("No settings found for Lidarr")
return instances
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
# lidarr_logger.info(f"Found 'instances' list with {len(settings['instances'])} items. Processing...") # Removed verbose log
for idx, instance in enumerate(settings["instances"]):
lidarr_logger.debug(f"Checking instance #{idx}: {instance}")
# Enhanced validation
api_url = instance.get("api_url", "").strip()
api_key = instance.get("api_key", "").strip()
# Enhanced URL validation - ensure URL has proper scheme
if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')):
lidarr_logger.warning(f"Instance '{instance.get('name', 'Unnamed')}' has URL without http(s) scheme: {api_url}")
api_url = f"http://{api_url}"
lidarr_logger.warning(f"Auto-correcting URL to: {api_url}")
is_enabled = instance.get("enabled", True)
# Only include properly configured instances
if is_enabled and api_url and api_key:
# Return only essential instance details
instance_data = {
"instance_name": instance.get("name", "Default"),
"api_url": api_url,
"api_key": api_key,
}
instances.append(instance_data)
# lidarr_logger.info(f"Added valid instance: {instance_data}") # Removed verbose log
elif not is_enabled:
lidarr_logger.debug(f"Skipping disabled instance: {instance.get('name', 'Unnamed')}")
else:
# For brand new installations, don't spam logs with warnings about default instances
instance_name = instance.get('name', 'Unnamed')
if instance_name == 'Default':
# Use debug level for default instances to avoid log spam on new installations
lidarr_logger.debug(f"Skipping instance '{instance_name}' due to missing API URL or key (URL: '{api_url}', Key Set: {bool(api_key)})")
else:
# Still log warnings for non-default instances
lidarr_logger.warning(f"Skipping instance '{instance_name}' due to missing API URL or key (URL: '{api_url}', Key Set: {bool(api_key)})")
else:
# lidarr_logger.info("No 'instances' list found or list is empty. Checking legacy config.") # Removed verbose log
# Fallback to legacy single-instance config
api_url = settings.get("api_url", "").strip()
api_key = settings.get("api_key", "").strip()
# Ensure URL has proper scheme
if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')):
lidarr_logger.warning(f"API URL missing http(s) scheme: {api_url}")
api_url = f"http://{api_url}"
lidarr_logger.warning(f"Auto-correcting URL to: {api_url}")
if api_url and api_key:
# Create a clean instance_data dict for the legacy instance
instance_data = {
"instance_name": "Default",
"api_url": api_url,
"api_key": api_key,
}
instances.append(instance_data)
# lidarr_logger.info(f"Added valid legacy instance: {instance_data}") # Removed verbose log
else:
lidarr_logger.warning("No API URL or key found in legacy configuration")
# Use debug level to avoid spamming logs, especially with 0 instances
lidarr_logger.debug(f"Found {len(instances)} configured and enabled Lidarr instances")
return instances
__all__ = ["process_missing_albums", "process_cutoff_upgrades", "get_configured_instances"]

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Lidarr-specific API functions
Handles all communication with the Lidarr API (v1)
"""
import requests
import json
import sys
import time
import datetime
import traceback
import logging
from typing import List, Dict, Any, Optional, Union
from src.primary.utils.logger import get_logger
# Get logger for the Lidarr app
lidarr_logger = get_logger("lidarr")
# Use a session for better performance
session = requests.Session()
def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any:
"""
Make a request to the Lidarr API.
Args:
api_url: The base URL of the Lidarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
endpoint: The API endpoint to call
method: HTTP method (GET, POST, PUT, DELETE)
data: Optional data to send with the request
params: Optional query parameters
Returns:
The JSON response from the API, or None if the request failed
"""
if not api_url or not api_key:
lidarr_logger.error("API URL or API key is missing. Check your settings.")
return None
# Ensure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
lidarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return None
# Make sure URL is properly formed
full_url = f"{api_url.rstrip('/')}/api/v1/{endpoint.lstrip('/')}"
# Set up headers
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
lidarr_logger.debug(f"Lidarr API Request: {method} {full_url} Params: {params} Data: {data}")
try:
response = session.request(
method=method.upper(),
url=full_url,
headers=headers,
json=data if method.upper() in ["POST", "PUT"] else None,
params=params if method.upper() == "GET" else None,
timeout=api_timeout
)
lidarr_logger.debug(f"Lidarr API Response Status: {response.status_code}")
# Log response body only in debug mode and if small enough
if lidarr_logger.level == logging.DEBUG and len(response.content) < 1000:
lidarr_logger.debug(f"Lidarr API Response Body: {response.text}")
elif lidarr_logger.level == logging.DEBUG:
lidarr_logger.debug(f"Lidarr API Response Body (truncated): {response.text[:500]}...")
# Check for successful response
response.raise_for_status()
# Parse response if there is content
if response.content and response.headers.get('Content-Type', '').startswith('application/json'):
return response.json()
elif response.status_code in [200, 201, 202]: # Success codes that might not return JSON
return True
else: # Should have been caught by raise_for_status, but as a fallback
lidarr_logger.warning(f"Request successful (status {response.status_code}) but no JSON content returned from {endpoint}")
return True # Indicate success even without content
except requests.exceptions.RequestException as e:
error_msg = f"Error during {method} request to Lidarr endpoint '{endpoint}': {str(e)}"
if e.response is not None:
error_msg += f" | Status: {e.response.status_code} | Response: {e.response.text[:500]}"
lidarr_logger.error(error_msg)
return None
except json.JSONDecodeError:
lidarr_logger.error(f"Error decoding JSON response from Lidarr endpoint '{endpoint}'. Response: {response.text[:500]}")
return None
except Exception as e:
# Catch all exceptions and log them with traceback
error_msg = f"CRITICAL ERROR in Lidarr arr_request: {str(e)}"
lidarr_logger.error(error_msg)
lidarr_logger.error(f"Full traceback: {traceback.format_exc()}")
print(error_msg, file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
return None
# --- Specific API Functions ---
def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Optional[Dict]:
"""Get Lidarr system status."""
return arr_request(api_url, api_key, api_timeout, "system/status")
def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
"""Check the connection to Lidarr API."""
try:
# Ensure api_url is properly formatted
if not api_url:
lidarr_logger.error("API URL is empty or not set")
return False
# Make sure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
lidarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return False
# Ensure URL doesn't end with a slash before adding the endpoint
base_url = api_url.rstrip('/')
full_url = f"{base_url}/api/v1/system/status"
response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
lidarr_logger.info("Successfully connected to Lidarr.")
return True
except requests.exceptions.RequestException as e:
lidarr_logger.error(f"Error connecting to Lidarr: {e}")
return False
except Exception as e:
lidarr_logger.error(f"An unexpected error occurred during Lidarr connection check: {e}")
return False
def get_artists(api_url: str, api_key: str, api_timeout: int, artist_id: Optional[int] = None) -> Union[List, Dict, None]:
"""Get artist information from Lidarr."""
endpoint = f"artist/{artist_id}" if artist_id else "artist"
return arr_request(api_url, api_key, api_timeout, endpoint)
def get_albums(api_url: str, api_key: str, api_timeout: int, album_id: Optional[int] = None, artist_id: Optional[int] = None) -> Union[List, Dict, None]:
"""Get album information from Lidarr."""
params = {}
if artist_id:
params['artistId'] = artist_id
if album_id:
endpoint = f"album/{album_id}"
else:
endpoint = "album"
return arr_request(api_url, api_key, api_timeout, endpoint, params=params if params else None)
def get_tracks(api_url: str, api_key: str, api_timeout: int, album_id: Optional[int] = None) -> Union[List, None]:
"""Get track information for a specific album."""
if not album_id:
lidarr_logger.warning("get_tracks requires an album_id.")
return None
params = {'albumId': album_id}
return arr_request(api_url, api_key, api_timeout, "track", params=params)
def get_queue(api_url: str, api_key: str, api_timeout: int) -> List:
"""Get the current queue from Lidarr (handles pagination)."""
# Lidarr v1 queue endpoint supports pagination, unlike Sonarr v3's simple list
all_records = []
page = 1
page_size = 1000 # Request large page size
while True:
params = {
"page": page,
"pageSize": page_size,
"sortKey": "timeleft", # Example sort key
"sortDir": "asc"
}
response = arr_request(api_url, api_key, api_timeout, "queue", params=params)
if response and isinstance(response, dict) and 'records' in response:
records = response.get('records', [])
if not records:
break # No more records
all_records.extend(records)
# Check if this was the last page
total_records = response.get('totalRecords', 0)
if len(all_records) >= total_records:
break
page += 1
else:
lidarr_logger.error(f"Failed to get queue page {page} or invalid response format.")
break # Return what we have so far
return all_records
def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int:
"""Get the current size of the Lidarr download queue."""
params = {"pageSize": 1} # Only need 1 record to get totalRecords
response = arr_request(api_url, api_key, api_timeout, "queue", params=params)
if response and isinstance(response, dict) and 'totalRecords' in response:
queue_size = response.get('totalRecords', 0)
lidarr_logger.debug(f"Lidarr download queue size: {queue_size}")
return queue_size
else:
lidarr_logger.error("Error getting Lidarr download queue size.")
return -1 # Indicate error
def get_missing_albums(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
"""Get missing albums from Lidarr, handling pagination."""
endpoint = "wanted/missing"
page = 1
page_size = 1000
all_missing_albums = []
total_records_reported = -1
lidarr_logger.debug(f"Starting fetch for missing albums (monitored_only={monitored_only}).")
while True:
params = {
"page": page,
"pageSize": page_size,
"includeArtist": "true" # Include artist info for filtering
# Removed sortKey and sortDir
}
lidarr_logger.debug(f"Requesting missing albums page {page} with params: {params}")
response = arr_request(api_url, api_key, api_timeout, endpoint, params=params)
if response and isinstance(response, dict) and 'records' in response:
records = response.get('records', [])
total_records_on_page = len(records)
if page == 1:
total_records_reported = response.get('totalRecords', 0)
lidarr_logger.debug(f"Lidarr API reports {total_records_reported} total missing albums.")
lidarr_logger.debug(f"Parsed {total_records_on_page} missing album records from Lidarr API JSON (page {page}).")
if not records:
lidarr_logger.debug(f"No more missing records found on page {page}. Stopping pagination.")
break
all_missing_albums.extend(records)
if total_records_reported >= 0 and len(all_missing_albums) >= total_records_reported:
lidarr_logger.debug(f"Fetched {len(all_missing_albums)} records, matching or exceeding total reported ({total_records_reported}). Assuming last page.")
break
if total_records_on_page < page_size:
lidarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Assuming last page.")
break
page += 1
# time.sleep(0.1) # Optional delay
else:
lidarr_logger.error(f"Failed to get missing albums page {page} or invalid response format.")
break # Return what we have so far
lidarr_logger.info(f"Total missing albums fetched across all pages: {len(all_missing_albums)}")
# Apply monitored filter after fetching
if monitored_only:
original_count = len(all_missing_albums)
# Check both album and artist monitored status
filtered_missing = [
album for album in all_missing_albums
if album.get('monitored', False) and album.get('artist', {}).get('monitored', False)
]
lidarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_missing)} monitored missing albums remain (out of {original_count} total).")
return filtered_missing
else:
lidarr_logger.debug(f"Returning {len(all_missing_albums)} missing albums (monitored_only=False).")
return all_missing_albums
def get_cutoff_unmet_albums(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
"""Get cutoff unmet albums from Lidarr, handling pagination."""
# Note: Lidarr API returns ALBUMS for cutoff unmet, not tracks.
endpoint = "wanted/cutoff"
page = 1
page_size = 1000 # Adjust page size if needed, Lidarr default might be smaller
all_cutoff_unmet = []
total_records_reported = -1
lidarr_logger.debug(f"Starting fetch for cutoff unmet albums (monitored_only={monitored_only}).")
while True:
params = {
"page": page,
"pageSize": page_size,
"includeArtist": "true" # Include artist info for filtering
# Removed sortKey and sortDir
}
lidarr_logger.debug(f"Requesting cutoff unmet albums page {page} with params: {params}")
response = arr_request(api_url, api_key, api_timeout, endpoint, params=params)
if response and isinstance(response, dict) and 'records' in response:
records = response.get('records', [])
total_records_on_page = len(records)
if page == 1:
total_records_reported = response.get('totalRecords', 0)
lidarr_logger.debug(f"Lidarr API reports {total_records_reported} total cutoff unmet albums.")
lidarr_logger.debug(f"Parsed {total_records_on_page} cutoff unmet album records from Lidarr API JSON (page {page}).")
if not records:
lidarr_logger.debug(f"No more cutoff unmet records found on page {page}. Stopping pagination.")
break
all_cutoff_unmet.extend(records)
# Check if we have fetched all reported records
if total_records_reported >= 0 and len(all_cutoff_unmet) >= total_records_reported:
lidarr_logger.debug(f"Fetched {len(all_cutoff_unmet)} records, matching or exceeding total reported ({total_records_reported}). Assuming last page.")
break
# Check if the number of records received is less than the page size
if total_records_on_page < page_size:
lidarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Assuming last page.")
break
page += 1
# time.sleep(0.1) # Optional small delay between pages
else:
# Log the error based on the response received (handled in arr_request)
lidarr_logger.error(f"Error getting cutoff unmet albums from Lidarr (page {page}) or invalid response format. Stopping pagination.")
# Return what we have so far, or indicate complete failure? Let's return what we have.
break
lidarr_logger.info(f"Total cutoff unmet albums fetched across all pages: {len(all_cutoff_unmet)}")
# Apply monitored filter after fetching all pages
if monitored_only:
original_count = len(all_cutoff_unmet)
# Check both album and artist monitored status
filtered_cutoff_unmet = [
album for album in all_cutoff_unmet
if album.get('monitored', False) and album.get('artist', {}).get('monitored', False)
]
lidarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_cutoff_unmet)} monitored cutoff unmet albums remain (out of {original_count} total).")
return filtered_cutoff_unmet
else:
lidarr_logger.debug(f"Returning {len(all_cutoff_unmet)} cutoff unmet albums (monitored_only=False).")
return all_cutoff_unmet
def search_albums(api_url: str, api_key: str, api_timeout: int, album_ids: List[int]) -> Optional[Dict]:
"""Trigger a search for specific albums in Lidarr."""
if not album_ids:
lidarr_logger.warning("No album IDs provided for search.")
return None
payload = {
"name": "AlbumSearch",
"albumIds": album_ids
}
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
if response and isinstance(response, dict) and 'id' in response:
command_id = response.get('id')
lidarr_logger.info(f"Triggered Lidarr AlbumSearch for album IDs: {album_ids}. Command ID: {command_id}")
return response # Return the full command object including ID
else:
lidarr_logger.error(f"Failed to trigger Lidarr AlbumSearch for album IDs {album_ids}. Response: {response}")
return None
def search_artist(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict]:
"""Trigger a search for a specific artist in Lidarr."""
payload = {
"name": "ArtistSearch",
"artistIds": [artist_id]
}
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
if response and isinstance(response, dict) and 'id' in response:
command_id = response.get('id')
lidarr_logger.info(f"Triggered Lidarr ArtistSearch for artist ID: {artist_id}. Command ID: {command_id}")
return response # Return the full command object
else:
lidarr_logger.error(f"Failed to trigger Lidarr ArtistSearch for artist ID {artist_id}. Response: {response}")
return None
def refresh_artist(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict]:
"""Trigger a refresh for a specific artist in Lidarr."""
payload = {
"name": "RefreshArtist",
"artistId": artist_id
}
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
if response and isinstance(response, dict) and 'id' in response:
command_id = response.get('id')
lidarr_logger.info(f"Triggered Lidarr RefreshArtist for artist ID: {artist_id}. Command ID: {command_id}")
return response # Return the full command object
else:
lidarr_logger.error(f"Failed to trigger Lidarr RefreshArtist for artist ID {artist_id}. Response: {response}")
return None
def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict[str, Any]]:
"""Get the status of a Lidarr command."""
response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}")
if response and isinstance(response, dict):
lidarr_logger.debug(f"Checked Lidarr command status for ID {command_id}: {response.get('status')}")
return response
else:
lidarr_logger.error(f"Error getting Lidarr command status for ID {command_id}. Response: {response}")
return None
def get_artist_by_id(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict[str, Any]]:
"""Get artist details by ID from Lidarr."""
return arr_request(api_url, api_key, api_timeout, f"artist/{artist_id}")

View File

@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""
Lidarr missing content processing module for Huntarr
Handles missing albums or artists based on configuration.
"""
import time
import random
import datetime
import os
import json
from typing import Dict, Any, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.lidarr import api as lidarr_api
from src.primary.stats_manager import increment_stat
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.utils.history_utils import log_processed_media
from src.primary.settings_manager import load_settings, get_advanced_setting
from src.primary.state import get_state_file_path, check_state_reset
import json
import os
# Get the logger for the Lidarr module
lidarr_logger = get_logger(__name__) # Use __name__ for correct logger hierarchy
def process_missing_albums(
app_settings: Dict[str, Any], # Combined settings dictionary
stop_check: Callable[[], bool] = None # Function to check for stop signal
) -> bool:
"""
Processes missing albums for a specific Lidarr instance based on settings.
Args:
app_settings (dict): Dictionary containing combined instance and general settings.
stop_check (Callable[[], bool]): Function to check if shutdown is requested.
Returns:
bool: True if any items were processed, False otherwise.
"""
# Copy instance-specific information
instance_name = app_settings.get("instance_name", "Default")
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
monitored_only = app_settings.get("monitored_only", True)
skip_future_releases = app_settings.get("skip_future_releases", False)
hunt_missing_items = app_settings.get("hunt_missing_items", 0)
hunt_missing_mode = app_settings.get("hunt_missing_mode", "album")
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
# Early exit for disabled features
if not api_url or not api_key:
lidarr_logger.warning(f"Missing API URL or API key, skipping missing processing for {instance_name}")
return False
if hunt_missing_items <= 0:
lidarr_logger.debug(f"Hunting for missing items is disabled (hunt_missing_items={hunt_missing_items}) for {instance_name}")
return False
# Make sure any requested stop function is executable
stop_check = stop_check if callable(stop_check) else lambda: False
lidarr_logger.info(f"Looking for missing albums for {instance_name}")
lidarr_logger.debug(f"Processing up to {hunt_missing_items} missing items in {hunt_missing_mode} mode")
# Reset state files if enough time has passed
check_state_reset("lidarr")
# Initialize processed counter and tracking containers
processed_count = 0
processed_any = False
processed_artists_or_albums = set()
total_items_to_process = hunt_missing_items
try:
# Fetch all missing albums first
lidarr_logger.info(f"Fetching all missing albums for {instance_name}...")
missing_items = lidarr_api.get_missing_albums(
api_url,
api_key,
monitored_only=monitored_only,
api_timeout=api_timeout
)
if missing_items is None: # API call failed or returned None
lidarr_logger.error(f"Failed to get missing items from Lidarr API for {instance_name}.")
return False
if not missing_items:
lidarr_logger.info(f"No missing albums found for {instance_name} after initial fetch and filtering.")
return False
lidarr_logger.info(f"Found {len(missing_items)} potentially missing albums for {instance_name} after initial fetch.")
# --- Filter Future Releases --- #
original_count = len(missing_items)
if skip_future_releases:
now = datetime.datetime.now(datetime.timezone.utc)
valid_missing_items = []
skipped_count = 0
for item in missing_items:
release_date_str = item.get('releaseDate')
if release_date_str:
try:
# Lidarr dates often include 'Z' for UTC
release_date = datetime.datetime.fromisoformat(release_date_str.replace('Z', '+00:00'))
if release_date <= now:
valid_missing_items.append(item)
else:
# lidarr_logger.debug(f"Skipping future album ID {item.get('id')} ('{item.get('title')}') release: {release_date_str}")
skipped_count += 1
except ValueError as e:
lidarr_logger.warning(f"Could not parse release date '{release_date_str}' for album ID {item.get('id')}. Error: {e}. Including it.")
valid_missing_items.append(item) # Keep if date is invalid
else:
valid_missing_items.append(item) # Keep if no release date
missing_items = valid_missing_items # Replace with filtered list
if skipped_count > 0:
lidarr_logger.info(f"Skipped {skipped_count} future albums based on release date. {len(missing_items)} remaining.")
else:
lidarr_logger.debug("Skipping future release filtering as 'skip_future_releases' is False.")
# Check if any items remain after filtering
if not missing_items:
lidarr_logger.info(f"No missing albums left after filtering future releases for {instance_name}.")
return False
# Process based on mode
lidarr_logger.info(f"Processing missing items in '{hunt_missing_mode}' mode.")
target_entities = []
search_entity_type = "album" # Default to album
if hunt_missing_mode == "artist":
search_entity_type = "artist"
# Group by artist ID
items_by_artist = {}
for item in missing_items: # Use the potentially filtered missing_items list
artist_id = item.get('artistId')
lidarr_logger.debug(f"Missing album item: {item.get('title')} by artistId: {artist_id}")
if artist_id:
if artist_id not in items_by_artist:
items_by_artist[artist_id] = []
items_by_artist[artist_id].append(item)
# In artist mode, map from artists to their albums
# First, get all artist IDs
target_entities = list(items_by_artist.keys())
# Filter out already processed artists
lidarr_logger.info(f"Found {len(target_entities)} artists with missing albums before filtering")
unprocessed_entities = [eid for eid in target_entities
if not is_processed("lidarr", instance_name, str(eid))]
lidarr_logger.info(f"Found {len(unprocessed_entities)} unprocessed artists out of {len(target_entities)} total")
else:
# In album mode, directly track album IDs
target_entities = [item['id'] for item in missing_items]
# Filter out processed albums
lidarr_logger.info(f"Found {len(target_entities)} missing albums before filtering")
unprocessed_entities = [eid for eid in target_entities
if not is_processed("lidarr", instance_name, str(eid))]
lidarr_logger.info(f"Found {len(unprocessed_entities)} unprocessed albums out of {len(target_entities)} total")
if not unprocessed_entities:
lidarr_logger.info(f"No unprocessed {search_entity_type}s found for {instance_name}. All available {search_entity_type}s have been processed.")
return False
# Select entities to search
if not unprocessed_entities:
lidarr_logger.info(f"No {search_entity_type}s found to process after grouping/filtering.")
return False
entities_to_search_ids = random.sample(unprocessed_entities, min(len(unprocessed_entities), total_items_to_process))
lidarr_logger.info(f"Randomly selected {len(entities_to_search_ids)} {search_entity_type}s to search.")
lidarr_logger.debug(f"Unprocessed entities: {unprocessed_entities}")
lidarr_logger.debug(f"Entities to search: {entities_to_search_ids}")
# --- Trigger Search (Artist or Album) ---
if hunt_missing_mode == "artist":
lidarr_logger.info(f"Artist-based missing mode selected")
lidarr_logger.info(f"Found {len(entities_to_search_ids)} unprocessed artists to search.")
# Prepare a list for artist details log
artist_details_log = []
# First, fetch detailed artist info for each artist ID to enhance logs
artist_details = {}
for artist_id in entities_to_search_ids:
# Get artist details from API for better logging
artist_data = lidarr_api.get_artist_by_id(api_url, api_key, api_timeout, artist_id)
if artist_data:
artist_details[artist_id] = artist_data
lidarr_logger.info(f"Artists selected for processing in this cycle:")
for i, artist_id in enumerate(entities_to_search_ids):
# Get artist name and any additional details
artist_name = f"Artist ID {artist_id}" # Default if name not found
artist_metadata = ""
if artist_id in artist_details:
artist_data = artist_details[artist_id]
artist_name = artist_data.get('artistName', artist_name)
# Add year active or debut year if available
if 'statistics' in artist_data and 'albumCount' in artist_data['statistics']:
album_count = artist_data['statistics']['albumCount']
artist_metadata = f"({album_count} albums)"
# Get genre info if available
if 'genres' in artist_data and artist_data['genres']:
genres = ", ".join(artist_data['genres'][:2]) # Limit to first 2 genres
if artist_metadata:
artist_metadata = f"{artist_metadata} - {genres}"
else:
artist_metadata = f"({genres})"
detail_line = f"{i+1}. {artist_name} {artist_metadata} - ID: {artist_id}"
artist_details_log.append(detail_line)
lidarr_logger.info(f" {detail_line}")
lidarr_logger.info(f"Triggering Artist Search for {len(entities_to_search_ids)} artists on {instance_name}...")
for i, artist_id in enumerate(entities_to_search_ids):
if stop_check(): # Use the new stop_check function
lidarr_logger.warning("Shutdown requested during artist search trigger.")
break
# Get artist name from cached details or first album
artist_name = f"Artist ID {artist_id}" # Default if name not found
if artist_id in artist_details:
artist_data = artist_details[artist_id]
artist_name = artist_data.get('artistName', artist_name)
elif artist_id in items_by_artist and items_by_artist[artist_id]:
# Fallback to album info if direct artist details not available
first_album = items_by_artist[artist_id][0]
artist_info = first_album.get('artist')
if artist_info and isinstance(artist_info, dict):
artist_name = artist_info.get('artistName', artist_name)
# Mark the artist as processed right away - BEFORE triggering the search
success = add_processed_id("lidarr", instance_name, str(artist_id))
lidarr_logger.debug(f"Added artist ID {artist_id} to processed list for {instance_name}, success: {success}")
# Trigger the search AFTER marking as processed
command_result = lidarr_api.search_artist(api_url, api_key, api_timeout, artist_id)
command_id = command_result.get('id', 'unknown') if command_result else 'failed'
lidarr_logger.info(f"Triggered Lidarr ArtistSearch for artist ID: {artist_id}, Command ID: {command_id}")
# Increment stats for UI tracking
if command_result:
increment_stat("lidarr", "hunted")
processed_count += 1 # Count successful searches
processed_artists_or_albums.add(artist_id)
# Also mark all albums from this artist as processed
if artist_id in items_by_artist:
for album in items_by_artist[artist_id]:
album_id = album.get('id')
if album_id:
album_success = add_processed_id("lidarr", instance_name, str(album_id))
lidarr_logger.debug(f"Added album ID {album_id} to processed list for {instance_name}, success: {album_success}")
# Log to history system
log_processed_media("lidarr", f"{artist_name}", artist_id, instance_name, "missing")
lidarr_logger.debug(f"Logged history entry for artist: {artist_name}")
time.sleep(0.1) # Small delay between triggers
else: # Album mode
album_ids_to_search = list(entities_to_search_ids)
if stop_check(): # Use the new stop_check function
lidarr_logger.warning("Shutdown requested before album search trigger.")
return False
# Prepare descriptive list for logging
album_details_log = []
# Create a dict for quick lookup based on album ID
missing_items_dict = {item['id']: item for item in missing_items if 'id' in item}
# First, fetch additional album details for better logging if needed
album_details = {}
for album_id in album_ids_to_search:
album_details[album_id] = lidarr_api.get_albums(api_url, api_key, api_timeout, album_id)
lidarr_logger.info(f"Albums selected for processing in this cycle:")
for idx, album_id in enumerate(album_ids_to_search):
album_info = missing_items_dict.get(album_id)
if album_info:
# Safely get title and artist name, provide defaults
title = album_info.get('title', f'Album ID {album_id}')
artist_name = album_info.get('artist', {}).get('artistName', 'Unknown Artist')
# Get additional metadata if available
release_year = ""
if 'releaseDate' in album_info and album_info['releaseDate']:
try:
release_date = album_info['releaseDate'].split('T')[0]
release_year = f"({release_date[:4]})"
except (IndexError, ValueError):
pass
# Get quality if available
quality_info = ""
if album_details.get(album_id) and 'quality' in album_details[album_id]:
quality = album_details[album_id]['quality'].get('quality', {}).get('name', '')
if quality:
quality_info = f"[{quality}]"
detail_line = f"{idx+1}. {artist_name} - {title} {release_year} {quality_info} - ID: {album_id}"
album_details_log.append(detail_line)
lidarr_logger.info(f" {detail_line}")
else:
# Fallback if album ID wasn't found in the fetched missing items (should be rare)
detail_line = f"{idx+1}. Album ID {album_id} (Details not found)"
album_details_log.append(detail_line)
lidarr_logger.info(f" {detail_line}")
# Mark the albums as processed BEFORE triggering the search
for album_id in album_ids_to_search:
success = add_processed_id("lidarr", instance_name, str(album_id))
lidarr_logger.debug(f"Added album ID {album_id} to processed list for {instance_name}, success: {success}")
# Now trigger the search
command_id = lidarr_api.search_albums(api_url, api_key, api_timeout, album_ids_to_search)
if command_id:
# Log after successful search
lidarr_logger.debug(f"Album search command triggered with ID: {command_id} for albums: [{', '.join(album_details_log)}]")
increment_stat("lidarr", "hunted") # Changed from "missing" to "hunted"
processed_count += len(album_ids_to_search) # Count albums searched
processed_artists_or_albums.update(album_ids_to_search)
# Log to history system
for album_id in album_ids_to_search:
album_info = missing_items_dict.get(album_id)
if album_info:
# Get title and artist name for the history entry
title = album_info.get('title', f'Album ID {album_id}')
artist_name = album_info.get('artist', {}).get('artistName', 'Unknown Artist')
media_name = f"{artist_name} - {title}"
log_processed_media("lidarr", media_name, album_id, instance_name, "missing")
lidarr_logger.debug(f"Logged history entry for album: {media_name}")
time.sleep(command_wait_delay) # Basic delay after the single command
else:
lidarr_logger.warning(f"Failed to trigger album search for IDs {album_ids_to_search} on {instance_name}.")
except Exception as e:
lidarr_logger.error(f"An error occurred during missing album processing for {instance_name}: {e}", exc_info=True)
return False
lidarr_logger.info(f"Missing album processing finished for {instance_name}. Processed {processed_count} items/searches ({len(processed_artists_or_albums)} unique {search_entity_type}s).")
return processed_count > 0

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Lidarr cutoff upgrade processing module for Huntarr
Handles albums that do not meet the configured quality cutoff.
"""
import time
import random
from typing import Dict, Any, Optional, Callable, List, Union, Set # Added List, Union and Set
from src.primary.utils.logger import get_logger
from src.primary.apps.lidarr import api as lidarr_api
from src.primary.utils.history_utils import log_processed_media
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.stats_manager import increment_stat
from src.primary.settings_manager import load_settings, get_advanced_setting
from src.primary.state import check_state_reset # Add the missing import
# Get logger for the app
lidarr_logger = get_logger(__name__) # Use __name__ for correct logger hierarchy
def process_cutoff_upgrades(
app_settings: Dict[str, Any], # Changed signature: Use app_settings
stop_check: Callable[[], bool] # Changed signature: Use stop_check
) -> bool:
"""
Processes cutoff upgrades for albums in a specific Lidarr instance.
Args:
app_settings (dict): Dictionary containing combined instance and general Lidarr settings.
stop_check (Callable[[], bool]): Function to check if shutdown is requested.
Returns:
bool: True if any items were processed, False otherwise.
"""
lidarr_logger.info("Starting quality cutoff upgrades processing cycle for Lidarr.")
processed_any = False
# --- Extract Settings --- #
# Instance details are now part of app_settings passed from background loop
instance_name = app_settings.get("instance_name", "Lidarr Default")
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
# Get command wait settings from general.json
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
# General Lidarr settings (also from app_settings)
hunt_upgrade_items = app_settings.get("hunt_upgrade_items", 0)
monitored_only = app_settings.get("monitored_only", True)
lidarr_logger.info(f"Using API timeout of {api_timeout} seconds for Lidarr upgrades")
lidarr_logger.debug(f"Processing upgrades for instance: {instance_name}")
# lidarr_logger.debug(f"Instance Config (extracted): {{ 'api_url': '{api_url}', 'api_key': '***' }}")
# lidarr_logger.debug(f"General Settings (from app_settings): {app_settings}") # Avoid logging full settings potentially containing sensitive info
# Check if API URL or Key are missing
if not api_url or not api_key:
lidarr_logger.error(f"Missing API URL or Key for instance '{instance_name}'. Cannot process upgrades.")
return False
# Check if upgrade hunting is enabled
if hunt_upgrade_items <= 0:
lidarr_logger.info(f"'hunt_upgrade_items' is {hunt_upgrade_items} or less. Skipping upgrade processing for {instance_name}.")
return False
lidarr_logger.info(f"Looking for quality upgrades for {instance_name}")
lidarr_logger.debug(f"Processing up to {hunt_upgrade_items} items for quality upgrade")
# Reset state files if enough time has passed
check_state_reset("lidarr")
processed_count = 0
processed_any = False
try:
lidarr_logger.info(f"Fetching cutoff unmet albums for {instance_name}...")
# Pass necessary details extracted above to the API function
# Corrected function name from get_cutoff_unmet to get_cutoff_unmet_albums
cutoff_unmet_albums = lidarr_api.get_cutoff_unmet_albums(
api_url,
api_key,
monitored_only=monitored_only,
api_timeout=api_timeout
)
if not cutoff_unmet_albums:
lidarr_logger.info(f"No cutoff unmet albums found for {instance_name}.")
return False
lidarr_logger.info(f"Found {len(cutoff_unmet_albums)} cutoff unmet albums for {instance_name}.")
# Filter out already processed items
unprocessed_albums = []
for album in cutoff_unmet_albums:
album_id = str(album.get('id'))
if not is_processed("lidarr", instance_name, album_id):
unprocessed_albums.append(album)
else:
lidarr_logger.debug(f"Skipping already processed album ID: {album_id}")
lidarr_logger.info(f"Found {len(unprocessed_albums)} unprocessed albums out of {len(cutoff_unmet_albums)} total albums eligible for quality upgrade.")
if not unprocessed_albums:
lidarr_logger.info("No unprocessed albums found for quality upgrade. Skipping cycle.")
return False
# Always select albums randomly
albums_to_search = random.sample(unprocessed_albums, min(len(unprocessed_albums), hunt_upgrade_items))
lidarr_logger.info(f"Randomly selected {len(albums_to_search)} albums for upgrade search.")
album_ids_to_search = [album['id'] for album in albums_to_search]
if not album_ids_to_search:
lidarr_logger.info("No album IDs selected for upgrade search. Skipping trigger.")
return False
# Prepare detailed album information for logging
album_details_log = []
for i, album in enumerate(albums_to_search):
# Extract useful information for logging
album_title = album.get('title', f'Album ID {album["id"]}')
artist_name = album.get('artist', {}).get('artistName', 'Unknown Artist')
quality = album.get('quality', {}).get('quality', {}).get('name', 'Unknown Quality')
album_details_log.append(f"{i+1}. {artist_name} - {album_title} (ID: {album['id']}, Current Quality: {quality})")
# Log each album on a separate line for better readability
if album_details_log:
lidarr_logger.info(f"Albums selected for quality upgrade in this cycle:")
for album_detail in album_details_log:
lidarr_logger.info(f" {album_detail}")
# Check stop event before triggering search
if stop_check and stop_check(): # Use the passed stop_check function
lidarr_logger.warning("Shutdown requested, stopping upgrade album search.")
return False # Return False as no search was triggered in this case
# Mark albums as processed BEFORE triggering search
for album_id in album_ids_to_search:
add_processed_id("lidarr", instance_name, str(album_id))
lidarr_logger.debug(f"Added album ID {album_id} to processed list for {instance_name}")
lidarr_logger.info(f"Triggering Album Search for {len(album_ids_to_search)} albums for upgrade on instance {instance_name}: {album_ids_to_search}")
# Pass necessary details extracted above to the API function
command_id = lidarr_api.search_albums(
api_url,
api_key,
api_timeout,
album_ids_to_search
)
if command_id:
lidarr_logger.debug(f"Upgrade album search command triggered with ID: {command_id} for albums: {album_ids_to_search}")
increment_stat("lidarr", "upgraded") # Use appropriate stat key
# Log to history
for album_id in album_ids_to_search:
# Find the album info for this ID to log to history
for album in albums_to_search:
if album['id'] == album_id:
album_title = album.get('title', f'Album ID {album_id}')
artist_name = album.get('artist', {}).get('artistName', 'Unknown Artist')
media_name = f"{artist_name} - {album_title}"
log_processed_media("lidarr", media_name, album_id, instance_name, "upgrade")
lidarr_logger.debug(f"Logged quality upgrade to history for album ID {album_id}")
break
time.sleep(command_wait_delay) # Basic delay
processed_count += len(album_ids_to_search)
processed_any = True # Mark that we processed something
# Consider adding wait_for_command logic if needed
# wait_for_command(api_url, api_key, command_id, command_wait_delay, command_wait_attempts)
else:
lidarr_logger.warning(f"Failed to trigger upgrade album search for IDs {album_ids_to_search} on {instance_name}.")
except Exception as e:
lidarr_logger.error(f"An error occurred during upgrade album processing for {instance_name}: {e}", exc_info=True)
return False # Indicate failure
lidarr_logger.info(f"Upgrade album processing finished for {instance_name}. Triggered searches for {processed_count} items.")
return processed_any # Return True if anything was processed

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
from flask import Blueprint, request, jsonify
import datetime, os, requests
from src.primary import keys_manager
from src.primary.state import get_state_file_path, reset_state_file
from src.primary.utils.logger import get_logger
import traceback
import socket
from urllib.parse import urlparse
lidarr_bp = Blueprint('lidarr', __name__)
lidarr_logger = get_logger("lidarr")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("lidarr", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("lidarr", "processed_upgrades")
@lidarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to a Lidarr API instance"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
lidarr_logger.info(f"Testing connection to Lidarr API at {api_url}")
# Validate URL format
if not (api_url.startswith('http://') or api_url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# Try to establish a socket connection first to check basic connectivity
parsed_url = urlparse(api_url)
hostname = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
try:
# Try socket connection for quick feedback on connectivity issues
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3) # Short timeout for quick feedback
result = sock.connect_ex((hostname, port))
sock.close()
if result != 0:
error_msg = f"Connection refused - Unable to connect to {hostname}:{port}. Please check if the server is running and the port is correct."
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except socket.gaierror:
error_msg = f"DNS resolution failed - Cannot resolve hostname: {hostname}. Please check your URL."
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except Exception as e:
# Log the socket testing error but continue with the full request
lidarr_logger.debug(f"Socket test error, continuing with full request: {str(e)}")
# For Lidarr, use api/v1
url = f"{api_url.rstrip('/')}/api/v1/system/status"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
try:
response = requests.get(url, headers=headers, timeout=(10, api_timeout))
# For HTTP errors, provide more specific feedback
if response.status_code == 401:
error_msg = "Authentication failed: Invalid API key"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 401
elif response.status_code == 403:
error_msg = "Access forbidden: Check API key permissions"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 403
elif response.status_code == 404:
error_msg = "API endpoint not found: This doesn't appear to be a valid Lidarr server. Check your URL."
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
elif response.status_code >= 500:
error_msg = f"Lidarr server error (HTTP {response.status_code}): The Lidarr server is experiencing issues"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), response.status_code
# Raise for other HTTP errors
response.raise_for_status()
try:
response_data = response.json()
version = response_data.get('version', 'unknown')
lidarr_logger.info(f"Successfully connected to Lidarr API version: {version}")
return jsonify({
"success": True,
"message": "Successfully connected to Lidarr API",
"version": version
})
except ValueError:
error_msg = "Invalid JSON response from Lidarr API - This doesn't appear to be a valid Lidarr server"
lidarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return jsonify({"success": False, "message": error_msg}), 500
except requests.exceptions.ConnectionError as e:
# Handle different types of connection errors
error_details = str(e)
if "Connection refused" in error_details:
error_msg = f"Connection refused - Lidarr is not running on {api_url} or the port is incorrect"
elif "Name or service not known" in error_details or "getaddrinfo failed" in error_details:
error_msg = f"DNS resolution failed - Cannot find host '{urlparse(api_url).hostname}'. Check your URL."
else:
error_msg = f"Connection error - Check if Lidarr is running: {error_details}"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except requests.exceptions.Timeout:
error_msg = f"Connection timed out - Lidarr took too long to respond"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 504
except requests.exceptions.RequestException as e:
error_msg = f"Connection test failed: {str(e)}"
lidarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 500

View File

@@ -0,0 +1,147 @@
from flask import Blueprint, request, jsonify
import datetime, os, requests
from src.primary import keys_manager
from src.primary.utils.logger import get_logger
from src.primary.state import get_state_file_path
from src.primary.settings_manager import load_settings
radarr_bp = Blueprint('radarr', __name__)
radarr_logger = get_logger("radarr")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("radarr", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("radarr", "processed_upgrades")
@radarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to a Radarr API instance with comprehensive diagnostics"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
# Log the test attempt
radarr_logger.info(f"Testing connection to Radarr API at {api_url}")
# First check if URL is properly formatted
if not (api_url.startswith('http://') or api_url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# For Radarr, use api/v3
api_base = "api/v3"
test_url = f"{api_url.rstrip('/')}/{api_base}/system/status"
headers = {'X-Api-Key': api_key}
try:
# Use a connection timeout separate from read timeout
response = requests.get(test_url, headers=headers, timeout=(10, api_timeout))
# Log HTTP status code for diagnostic purposes
radarr_logger.debug(f"Radarr API status code: {response.status_code}")
# Check HTTP status code
response.raise_for_status()
# Ensure the response is valid JSON
try:
response_data = response.json()
# We no longer save keys here since we use instances
# keys_manager.save_api_keys("radarr", api_url, api_key)
radarr_logger.info(f"Successfully connected to Radarr API version: {response_data.get('version', 'unknown')}")
# Return success with some useful information
return jsonify({
"success": True,
"message": "Successfully connected to Radarr API",
"version": response_data.get('version', 'unknown')
})
except ValueError:
error_msg = "Invalid JSON response from Radarr API"
radarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return jsonify({"success": False, "message": error_msg}), 500
except requests.exceptions.Timeout as e:
error_msg = f"Connection timed out after {api_timeout} seconds"
radarr_logger.error(f"{error_msg}: {str(e)}")
return jsonify({"success": False, "message": error_msg}), 504
except requests.exceptions.ConnectionError as e:
error_msg = "Connection error - check hostname and port"
details = str(e)
# Check for common DNS resolution errors
if "Name or service not known" in details or "getaddrinfo failed" in details:
error_msg = "DNS resolution failed - check hostname"
# Check for common connection refused errors
elif "Connection refused" in details:
error_msg = "Connection refused - check if Radarr is running and the port is correct"
radarr_logger.error(f"{error_msg}: {details}")
return jsonify({"success": False, "message": f"{error_msg}: {details}"}), 502
except requests.exceptions.RequestException as e:
error_message = f"Connection failed: {str(e)}"
if hasattr(e, 'response') and e.response is not None:
status_code = e.response.status_code
# Add specific messages based on common status codes
if status_code == 401:
error_message = "Authentication failed: Invalid API key"
elif status_code == 403:
error_message = "Access forbidden: Check API key permissions"
elif status_code == 404:
error_message = "API endpoint not found: Check API URL"
elif status_code >= 500:
error_message = f"Radarr server error (HTTP {status_code}): The Radarr server is experiencing issues"
# Try to extract more error details if available
try:
error_details = e.response.json()
error_message += f" - {error_details.get('message', 'No details')}"
except ValueError:
if e.response.text:
error_message += f" - Response: {e.response.text[:200]}"
radarr_logger.error(error_message)
return jsonify({"success": False, "message": error_message}), 500
except Exception as e:
error_msg = f"An unexpected error occurred: {str(e)}"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 500
# Function to check if Radarr is configured
def is_configured():
"""Check if Radarr API credentials are configured by checking if at least one instance is enabled"""
settings = load_settings("radarr")
if not settings:
radarr_logger.debug("No settings found for Radarr")
return False
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
radarr_logger.debug(f"Found configured Radarr instance: {instance.get('name', 'Unnamed')}")
return True
radarr_logger.debug("No enabled Radarr instances found with valid API URL and key")
return False
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
return bool(api_url and api_key)
# Get all valid instances from settings
# get_configured_instances function has been moved to src/primary/apps/radarr/__init__.py
# Function to reset the processed IDs files

View File

@@ -0,0 +1,53 @@
"""
Radarr app module for Huntarr
Contains functionality for missing movies and quality upgrades in Radarr
"""
# Module exports
from src.primary.apps.radarr.missing import process_missing_movies
from src.primary.apps.radarr.upgrade import process_cutoff_upgrades
# Add necessary imports for get_configured_instances
from src.primary.settings_manager import load_settings
from src.primary.utils.logger import get_logger
radarr_logger = get_logger("radarr") # Get the logger instance
def get_configured_instances():
"""Get all configured and enabled Radarr instances"""
settings = load_settings("radarr")
instances = []
if not settings:
radarr_logger.debug("No settings found for Radarr")
return instances
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
# Create a settings object for this instance by combining global settings with instance-specific ones
instance_settings = settings.copy()
# Remove instances list to avoid confusion
if "instances" in instance_settings:
del instance_settings["instances"]
# Override with instance-specific connection settings
instance_settings["api_url"] = instance.get("api_url")
instance_settings["api_key"] = instance.get("api_key")
instance_settings["instance_name"] = instance.get("name", "Default")
instances.append(instance_settings)
else:
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
if api_url and api_key:
settings["instance_name"] = "Default"
instances.append(settings)
# Use debug level to avoid spamming logs, especially with 0 instances
radarr_logger.debug(f"Found {len(instances)} configured and enabled Radarr instances")
return instances
__all__ = ["process_missing_movies", "process_cutoff_upgrades", "get_configured_instances"]

View File

@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
Radarr-specific API functions
Handles all communication with the Radarr API
"""
import requests
import json
import time
import datetime
from typing import List, Dict, Any, Optional, Union
# Correct the import path
from src.primary.utils.logger import get_logger
# Get logger for the Radarr app
radarr_logger = get_logger("radarr")
# Use a session for better performance
session = requests.Session()
def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any:
"""
Make a request to the Radarr API.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
endpoint: The API endpoint to call
method: HTTP method (GET, POST, PUT, DELETE)
data: Optional data to send with the request
Returns:
The JSON response from the API, or None if the request failed
"""
if not api_url or not api_key:
radarr_logger.error("API URL or API key is missing. Check your settings.")
return None
# Ensure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
radarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return None
# Full URL - ensure no double slashes
url = f"{api_url.rstrip('/')}/api/v3/{endpoint.lstrip('/')}"
# Headers
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
try:
if method == "GET":
response = session.get(url, headers=headers, timeout=api_timeout)
elif method == "POST":
response = session.post(url, headers=headers, json=data, timeout=api_timeout)
elif method == "PUT":
response = session.put(url, headers=headers, json=data, timeout=api_timeout)
elif method == "DELETE":
response = session.delete(url, headers=headers, timeout=api_timeout)
else:
radarr_logger.error(f"Unsupported HTTP method: {method}")
return None
# Check for errors
response.raise_for_status()
# Parse JSON response
if response.text:
return response.json()
return {}
except requests.exceptions.RequestException as e:
radarr_logger.error(f"API request failed: {e}")
return None
def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int:
"""
Get the current size of the download queue.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
Returns:
The number of items in the download queue, or -1 if the request failed
"""
if not api_url or not api_key:
radarr_logger.error("Radarr API URL or API Key not provided for queue size check.")
return -1
try:
# Radarr uses /api/v3/queue
endpoint = f"{api_url.rstrip('/')}/api/v3/queue?page=1&pageSize=1000" # Fetch a large page size
headers = {"X-Api-Key": api_key}
response = session.get(endpoint, headers=headers, timeout=api_timeout)
response.raise_for_status()
queue_data = response.json()
queue_size = queue_data.get('totalRecords', 0)
radarr_logger.debug(f"Radarr download queue size: {queue_size}")
return queue_size
except requests.exceptions.RequestException as e:
radarr_logger.error(f"Error getting Radarr download queue size: {e}")
return -1 # Return -1 to indicate an error
except Exception as e:
radarr_logger.error(f"An unexpected error occurred while getting Radarr queue size: {e}")
return -1
def get_movies_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> Optional[List[Dict]]:
"""
Get a list of movies with missing files (not downloaded/available).
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored movies.
Returns:
A list of movie objects with missing files, or None if the request failed.
"""
# Use the updated arr_request with passed arguments
movies = arr_request(api_url, api_key, api_timeout, "movie")
if movies is None: # Check for None explicitly, as an empty list is valid
radarr_logger.error("Failed to retrieve movies from Radarr API.")
return None
missing_movies = []
for movie in movies:
is_monitored = movie.get("monitored", False)
has_file = movie.get("hasFile", False)
# Apply monitored_only filter if requested
if not has_file and (not monitored_only or is_monitored):
missing_movies.append(movie)
radarr_logger.debug(f"Found {len(missing_movies)} missing movies (monitored_only={monitored_only}).")
return missing_movies
def get_cutoff_unmet_movies(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> Optional[List[Dict]]:
"""
Get a list of movies that don't meet their quality profile cutoff.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored movies.
Returns:
A list of movie objects that need quality upgrades, or None if the request failed.
"""
# Radarr API endpoint for cutoff unmet movies
# Note: Radarr's /api/v3/movie endpoint doesn't directly support a simple 'cutoffUnmet=true' like Sonarr's wanted/cutoff.
# We need to fetch all movies and filter locally, or use the /api/v3/movie/lookup endpoint if searching by TMDB/IMDB ID.
# Fetching all movies is simpler for now.
radarr_logger.debug("Fetching all movies to determine cutoff unmet status...")
movies = arr_request(api_url, api_key, api_timeout, "movie")
if movies is None:
radarr_logger.error("Failed to retrieve movies from Radarr API for cutoff check.")
return None
# Need quality profile information to determine cutoff unmet status.
# Fetch quality profiles first.
profiles = arr_request(api_url, api_key, api_timeout, "qualityprofile")
if profiles is None:
radarr_logger.error("Failed to retrieve quality profiles from Radarr API.")
return None
# Create a map for easy lookup: profile_id -> cutoff_format_score (or cutoff quality ID)
# Radarr profiles have 'cutoff' (quality ID) and potentially 'cutoffFormatScore'
profile_cutoff_map = {p['id']: p.get('cutoff') for p in profiles}
# TODO: Potentially incorporate cutoffFormatScore if needed for more complex logic
unmet_movies = []
for movie in movies:
is_monitored = movie.get("monitored", False)
has_file = movie.get("hasFile", False)
profile_id = movie.get("qualityProfileId")
movie_file = movie.get("movieFile")
# Apply monitored_only filter if requested
if not monitored_only or is_monitored:
if has_file and movie_file and profile_id in profile_cutoff_map:
cutoff_quality_id = profile_cutoff_map[profile_id]
current_quality_id = movie_file.get("quality", {}).get("quality", {}).get("id")
# Simple check: if current quality ID is less than cutoff quality ID
# This assumes quality IDs are ordered correctly (lower ID = lower quality)
# A more robust check might involve comparing quality *names* or *scores* if IDs aren't reliable order indicators.
if current_quality_id is not None and cutoff_quality_id is not None and current_quality_id < cutoff_quality_id:
# TODO: Add check for cutoffFormatScore if necessary
unmet_movies.append(movie)
# else: # Log why a movie wasn't considered unmet (optional)
# if not has_file: radarr_logger.debug(f"Skipping {movie.get('title')} - no file.")
# elif not movie_file: radarr_logger.debug(f"Skipping {movie.get('title')} - no movieFile info.")
# elif profile_id not in profile_cutoff_map: radarr_logger.debug(f"Skipping {movie.get('title')} - profile ID {profile_id} not found.")
radarr_logger.debug(f"Found {len(unmet_movies)} cutoff unmet movies (monitored_only={monitored_only}).")
return unmet_movies
def refresh_movie(api_url: str, api_key: str, api_timeout: int, movie_id: int,
command_wait_delay: int = 1, command_wait_attempts: int = 600) -> Optional[int]:
"""
Refresh a movie in Radarr.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
movie_id: The ID of the movie to refresh
command_wait_delay: Seconds to wait between command status checks
command_wait_attempts: Maximum number of status check attempts
Returns:
The command ID if the refresh was triggered successfully, None otherwise
"""
endpoint = "command"
data = {
"name": "RefreshMovie",
"movieIds": [movie_id]
}
# Use the updated arr_request
response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data)
if response and 'id' in response:
command_id = response['id']
radarr_logger.debug(f"Triggered refresh for movie ID {movie_id}. Command ID: {command_id}")
# Wait for command to complete if requested
if command_wait_delay > 0 and command_wait_attempts > 0:
radarr_logger.debug(f"Waiting for refresh command {command_id} to complete...")
success = wait_for_command(api_url, api_key, api_timeout, command_id,
delay_seconds=command_wait_delay,
max_attempts=command_wait_attempts)
if success:
radarr_logger.debug(f"Refresh command {command_id} completed successfully")
else:
radarr_logger.warning(f"Timed out waiting for refresh command {command_id} to complete")
return command_id
else:
radarr_logger.error(f"Failed to trigger refresh command for movie ID {movie_id}. Response: {response}")
return None
def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[int]) -> Optional[int]:
"""
Trigger a search for one or more movies.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
movie_ids: A list of movie IDs to search for
Returns:
The command ID if the search command was triggered successfully, None otherwise
"""
if not movie_ids:
radarr_logger.warning("No movie IDs provided for search.")
return None
endpoint = "command"
data = {
"name": "MoviesSearch",
"movieIds": movie_ids
}
# Use the updated arr_request
response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data)
if response and 'id' in response:
command_id = response['id']
radarr_logger.debug(f"Triggered search for movie IDs: {movie_ids}. Command ID: {command_id}")
return command_id
else:
radarr_logger.error(f"Failed to trigger search command for movie IDs {movie_ids}. Response: {response}")
return None
def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
"""Check the connection to Radarr API."""
try:
# Ensure api_url is properly formatted
if not api_url:
radarr_logger.error("API URL is empty or not set")
return False
# Make sure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
radarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return False
# Ensure URL doesn't end with a slash before adding the endpoint
base_url = api_url.rstrip('/')
full_url = f"{base_url}/api/v3/system/status"
response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
radarr_logger.info("Successfully connected to Radarr.")
return True
except requests.exceptions.RequestException as e:
radarr_logger.error(f"Error connecting to Radarr: {e}")
return False
except Exception as e:
radarr_logger.error(f"An unexpected error occurred during Radarr connection check: {e}")
return False
def wait_for_command(api_url: str, api_key: str, api_timeout: int, command_id: int,
delay_seconds: int = 1, max_attempts: int = 600) -> bool:
"""
Wait for a command to complete.
Args:
api_url: The base URL of the Radarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
command_id: The ID of the command to wait for
delay_seconds: Seconds to wait between command status checks
max_attempts: Maximum number of status check attempts
Returns:
True if the command completed successfully, False if timed out
"""
attempts = 0
while attempts < max_attempts:
response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}")
if response and 'state' in response:
state = response['state']
if state == "completed":
return True
elif state == "failed":
radarr_logger.error(f"Command {command_id} failed")
return False
time.sleep(delay_seconds)
attempts += 1
radarr_logger.warning(f"Timed out waiting for command {command_id} to complete")
return False

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Missing Movies Processing for Radarr
Handles searching for missing movies in Radarr
"""
import time
import random
import datetime
from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.radarr import api as radarr_api
from src.primary.stats_manager import increment_stat
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.utils.history_utils import log_processed_media
from src.primary.settings_manager import load_settings, get_advanced_setting
# Get logger for the app
radarr_logger = get_logger("radarr")
def process_missing_movies(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process missing movies in Radarr based on provided settings.
Args:
app_settings: Dictionary containing all settings for Radarr
stop_check: A function that returns True if the process should stop
Returns:
True if any movies were processed, False otherwise.
"""
processed_any = False
# Get instance name - check for instance_name first, fall back to legacy "name" key if needed
instance_name = app_settings.get("instance_name", app_settings.get("name", "Radarr Default"))
# Log important settings
radarr_logger.info("=== Radarr Missing Movies Settings ===")
radarr_logger.info(f"Instance Name: {instance_name}")
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
monitored_only = app_settings.get("monitored_only", True)
skip_future_releases = app_settings.get("skip_future_releases", True)
skip_movie_refresh = app_settings.get("skip_movie_refresh", False)
hunt_missing_movies = app_settings.get("hunt_missing_movies", 0)
# Use advanced settings from general.json for command operations
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
release_type = app_settings.get("release_type", "physical")
radarr_logger.info(f"Hunt Missing Movies: {hunt_missing_movies}")
radarr_logger.info(f"Monitored Only: {monitored_only}")
radarr_logger.info(f"Skip Future Releases: {skip_future_releases}")
radarr_logger.info(f"Skip Movie Refresh: {skip_movie_refresh}")
radarr_logger.info(f"Release Type for Future Status: {release_type}")
release_type_field = 'physicalRelease'
if release_type == 'digital':
release_type_field = 'digitalRelease'
elif release_type == 'cinema':
release_type_field = 'inCinemas'
radarr_logger.info(f"Using {release_type_field} date to determine future releases")
radarr_logger.info("=======================================")
radarr_logger.info("Starting missing movies processing cycle for Radarr.")
if not api_url or not api_key:
radarr_logger.error("API URL or Key not configured in settings. Cannot process missing movies.")
return False
# Skip if hunt_missing_movies is set to 0
if hunt_missing_movies <= 0:
radarr_logger.info("'hunt_missing_movies' setting is 0 or less. Skipping missing movie processing.")
return False
# Check for stop signal
if stop_check():
radarr_logger.info("Stop requested before starting missing movies. Aborting...")
return False
# Get missing movies
radarr_logger.info("Retrieving movies with missing files...")
missing_movies = radarr_api.get_movies_with_missing(api_url, api_key, api_timeout, monitored_only)
if missing_movies is None: # API call failed
radarr_logger.error("Failed to retrieve missing movies from Radarr API.")
return False
if not missing_movies:
radarr_logger.info("No missing movies found.")
return False
# Check for stop signal after retrieving movies
if stop_check():
radarr_logger.info("Stop requested after retrieving missing movies. Aborting...")
return False
radarr_logger.info(f"Found {len(missing_movies)} movies with missing files.")
# Filter out future releases if configured
if skip_future_releases:
now = datetime.datetime.now(datetime.timezone.utc)
original_count = len(missing_movies)
missing_movies = [
movie for movie in missing_movies
if movie.get(release_type_field) and datetime.datetime.fromisoformat(movie[release_type_field].replace('Z', '+00:00')) < now
]
skipped_count = original_count - len(missing_movies)
if skipped_count > 0:
radarr_logger.info(f"Skipped {skipped_count} future movie releases based on {release_type} release date.")
if not missing_movies:
radarr_logger.info("No missing movies left to process after filtering future releases.")
return False
movies_processed = 0
processing_done = False
# Filter out already processed movies using stateful management
unprocessed_movies = []
for movie in missing_movies:
movie_id = str(movie.get("id"))
if not is_processed("radarr", instance_name, movie_id):
unprocessed_movies.append(movie)
else:
radarr_logger.debug(f"Skipping already processed movie ID: {movie_id}")
radarr_logger.info(f"Found {len(unprocessed_movies)} unprocessed missing movies out of {len(missing_movies)} total.")
if not unprocessed_movies:
radarr_logger.info("No unprocessed missing movies found. All available movies have been processed.")
return False
# Always use random selection for missing movies
radarr_logger.info(f"Using random selection for missing movies")
if len(unprocessed_movies) > hunt_missing_movies:
movies_to_process = random.sample(unprocessed_movies, hunt_missing_movies)
else:
movies_to_process = unprocessed_movies
radarr_logger.info(f"Selected {len(movies_to_process)} movies to process.")
# Add detailed logging for selected movies
if movies_to_process:
radarr_logger.info(f"Movies selected for processing in this cycle:")
for idx, movie in enumerate(movies_to_process):
movie_id = movie.get("id")
movie_title = movie.get("title", "Unknown Title")
year = movie.get("year", "Unknown Year")
radarr_logger.info(f" {idx+1}. {movie_title} ({year}) - ID: {movie_id}")
# Process each movie
for movie in movies_to_process:
if stop_check():
radarr_logger.info("Stop requested during processing. Aborting...")
break
movie_id = movie.get("id")
movie_title = movie.get("title", "Unknown Title")
# Optional: Refresh the movie before searching
if not skip_movie_refresh:
radarr_logger.info(f"Refreshing movie metadata for '{movie_title}' (ID: {movie_id})...")
refresh_success = radarr_api.refresh_movie(api_url, api_key, api_timeout, movie_id, command_wait_delay, command_wait_attempts)
if not refresh_success:
radarr_logger.warning(f"Failed to refresh movie metadata for '{movie_title}'. Continuing anyway...")
# Search for the movie
radarr_logger.info(f"Searching for movie '{movie_title}' (ID: {movie_id})...")
search_success = radarr_api.movie_search(api_url, api_key, api_timeout, [movie_id])
if search_success:
radarr_logger.info(f"Successfully triggered search for movie '{movie_title}'")
# Immediately add to processed IDs to prevent duplicate processing
success = add_processed_id("radarr", instance_name, str(movie_id))
radarr_logger.debug(f"Added processed ID: {movie_id}, success: {success}")
# Log to history system
year = movie.get("year", "Unknown Year")
media_name = f"{movie_title} ({year})"
log_processed_media("radarr", media_name, movie_id, instance_name, "missing")
radarr_logger.debug(f"Logged history entry for movie: {media_name}")
increment_stat("radarr", "hunted")
movies_processed += 1
processed_any = True
else:
radarr_logger.warning(f"Failed to trigger search for movie '{movie_title}'")
radarr_logger.info(f"Finished processing missing movies. Processed {movies_processed} of {len(movies_to_process)} selected movies.")
return processed_any

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Quality Upgrade Processing for Radarr
Handles searching for movies that need quality upgrades in Radarr
"""
import time
import random
from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.radarr import api as radarr_api
from src.primary.stats_manager import increment_stat
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.utils.history_utils import log_processed_media
from src.primary.settings_manager import get_advanced_setting
# Get logger for the app
radarr_logger = get_logger("radarr")
def process_cutoff_upgrades(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process quality cutoff upgrades for Radarr based on settings.
Args:
app_settings: Dictionary containing all settings for Radarr
stop_check: A function that returns True if the process should stop
Returns:
True if any movies were processed for upgrades, False otherwise.
"""
radarr_logger.info("Starting quality cutoff upgrades processing cycle for Radarr.")
processed_any = False
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
monitored_only = app_settings.get("monitored_only", True)
skip_movie_refresh = app_settings.get("skip_movie_refresh", False)
hunt_upgrade_movies = app_settings.get("hunt_upgrade_movies", 0)
# Use advanced settings from general.json for command operations
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
# Get instance name - check for instance_name first, fall back to legacy "name" key if needed
instance_name = app_settings.get("instance_name", app_settings.get("name", "Radarr Default"))
# Get movies eligible for upgrade
radarr_logger.info("Retrieving movies eligible for cutoff upgrade...")
upgrade_eligible_data = radarr_api.get_cutoff_unmet_movies(api_url, api_key, api_timeout, monitored_only)
if not upgrade_eligible_data:
radarr_logger.info("No movies found eligible for upgrade or error retrieving them.")
return False
radarr_logger.info(f"Found {len(upgrade_eligible_data)} movies eligible for upgrade.")
# Filter out already processed movies using stateful management
unprocessed_movies = []
for movie in upgrade_eligible_data:
movie_id = str(movie.get("id"))
if not is_processed("radarr", instance_name, movie_id):
unprocessed_movies.append(movie)
else:
radarr_logger.debug(f"Skipping already processed movie ID: {movie_id}")
radarr_logger.info(f"Found {len(unprocessed_movies)} unprocessed movies for upgrade out of {len(upgrade_eligible_data)} total.")
if not unprocessed_movies:
radarr_logger.info("No upgradeable movies found to process (after filtering already processed). Skipping.")
return False
radarr_logger.info(f"Randomly selecting up to {hunt_upgrade_movies} movies for upgrade search.")
movies_to_process = random.sample(unprocessed_movies, min(hunt_upgrade_movies, len(unprocessed_movies)))
radarr_logger.info(f"Selected {len(movies_to_process)} movies to search for upgrades.")
processed_count = 0
processed_something = False
for movie in movies_to_process:
if stop_check():
radarr_logger.info("Stop signal received, aborting Radarr upgrade cycle.")
break
movie_id = movie.get("id")
movie_title = movie.get("title")
movie_year = movie.get("year")
radarr_logger.info(f"Processing upgrade for movie: \"{movie_title}\" ({movie_year}) (Movie ID: {movie_id})")
# Refresh movie (optional)
if not skip_movie_refresh:
radarr_logger.info(f" - Refreshing movie info...")
refresh_result = radarr_api.refresh_movie(api_url, api_key, api_timeout, movie_id)
if not refresh_result:
radarr_logger.warning(f" - Failed to trigger movie refresh. Continuing search anyway.")
else:
radarr_logger.debug(f" - Skipping movie refresh (skip_movie_refresh=true)")
# Search for cutoff upgrade
radarr_logger.info(f" - Searching for quality upgrade...")
search_result = radarr_api.movie_search(api_url, api_key, api_timeout, [movie_id])
if search_result:
radarr_logger.info(f" - Successfully triggered search for quality upgrade.")
add_processed_id("radarr", instance_name, str(movie_id))
increment_stat("radarr", "upgraded")
# Log to history so the upgrade appears in the history UI
media_name = f"{movie_title} ({movie_year})"
log_processed_media("radarr", media_name, movie_id, instance_name, "upgrade")
radarr_logger.debug(f"Logged quality upgrade to history for movie ID {movie_id}")
processed_count += 1
processed_something = True
else:
radarr_logger.warning(f" - Failed to trigger search for quality upgrade.")
# Log final status
radarr_logger.info(f"Completed processing {processed_count} movies for quality upgrades.")
return processed_something

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
from flask import Blueprint, request, jsonify
import datetime, os, requests
from src.primary import keys_manager
from src.primary.state import get_state_file_path, reset_state_file
from src.primary.utils.logger import get_logger
import traceback
import socket
from urllib.parse import urlparse
radarr_bp = Blueprint('radarr', __name__)
radarr_logger = get_logger("radarr")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("radarr", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("radarr", "processed_upgrades")
@radarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to a Radarr API instance"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
radarr_logger.info(f"Testing connection to Radarr API at {api_url}")
# Validate URL format
if not (api_url.startswith('http://') or api_url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# Try to establish a socket connection first to check basic connectivity
parsed_url = urlparse(api_url)
hostname = parsed_url.hostname
port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80)
try:
# Try socket connection for quick feedback on connectivity issues
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3) # Short timeout for quick feedback
result = sock.connect_ex((hostname, port))
sock.close()
if result != 0:
error_msg = f"Connection refused - Unable to connect to {hostname}:{port}. Please check if the server is running and the port is correct."
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except socket.gaierror:
error_msg = f"DNS resolution failed - Cannot resolve hostname: {hostname}. Please check your URL."
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except Exception as e:
# Log the socket testing error but continue with the full request
radarr_logger.debug(f"Socket test error, continuing with full request: {str(e)}")
# For Radarr, use api/v3
url = f"{api_url.rstrip('/')}/api/v3/system/status"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
try:
response = requests.get(url, headers=headers, timeout=(10, api_timeout))
# For HTTP errors, provide more specific feedback
if response.status_code == 401:
error_msg = "Authentication failed: Invalid API key"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 401
elif response.status_code == 403:
error_msg = "Access forbidden: Check API key permissions"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 403
elif response.status_code == 404:
error_msg = "API endpoint not found: This doesn't appear to be a valid Radarr server. Check your URL."
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
elif response.status_code >= 500:
error_msg = f"Radarr server error (HTTP {response.status_code}): The Radarr server is experiencing issues"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), response.status_code
# Raise for other HTTP errors
response.raise_for_status()
try:
response_data = response.json()
version = response_data.get('version', 'unknown')
radarr_logger.info(f"Successfully connected to Radarr API version: {version}")
return jsonify({
"success": True,
"message": "Successfully connected to Radarr API",
"version": version
})
except ValueError:
error_msg = "Invalid JSON response from Radarr API - This doesn't appear to be a valid Radarr server"
radarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return jsonify({"success": False, "message": error_msg}), 500
except requests.exceptions.ConnectionError as e:
# Handle different types of connection errors
error_details = str(e)
if "Connection refused" in error_details:
error_msg = f"Connection refused - Radarr is not running on {api_url} or the port is incorrect"
elif "Name or service not known" in error_details or "getaddrinfo failed" in error_details:
error_msg = f"DNS resolution failed - Cannot find host '{urlparse(api_url).hostname}'. Check your URL."
else:
error_msg = f"Connection error - Check if Radarr is running: {error_details}"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 404
except requests.exceptions.Timeout:
error_msg = f"Connection timed out - Radarr took too long to respond"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 504
except requests.exceptions.RequestException as e:
error_msg = f"Connection test failed: {str(e)}"
radarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 500

View File

@@ -0,0 +1,179 @@
from flask import Blueprint, request, jsonify
import datetime, os, requests
from primary import keys_manager
from src.primary.utils.logger import get_logger
from src.primary.state import get_state_file_path
from src.primary.settings_manager import load_settings
readarr_bp = Blueprint('readarr', __name__)
readarr_logger = get_logger("readarr")
# Make sure we're using the correct state files
PROCESSED_MISSING_FILE = get_state_file_path("readarr", "processed_missing")
PROCESSED_UPGRADES_FILE = get_state_file_path("readarr", "processed_upgrades")
@readarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""Test connection to a Readarr API instance with comprehensive diagnostics"""
data = request.json
api_url = data.get('api_url')
api_key = data.get('api_key')
api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
# Log the test attempt
readarr_logger.info(f"Testing connection to Readarr API at {api_url}")
# First check if URL is properly formatted
if not (api_url.startswith('http://') or api_url.startswith('https://')):
error_msg = "API URL must start with http:// or https://"
readarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# For Readarr, use api/v1
api_base = "api/v1"
test_url = f"{api_url.rstrip('/')}/{api_base}/system/status"
headers = {'X-Api-Key': api_key}
try:
# Use a connection timeout separate from read timeout
response = requests.get(test_url, headers=headers, timeout=(10, api_timeout))
# Log HTTP status code for diagnostic purposes
readarr_logger.debug(f"Readarr API status code: {response.status_code}")
# Check HTTP status code
response.raise_for_status()
# Ensure the response is valid JSON
try:
response_data = response.json()
# We no longer save keys here since we use instances
# keys_manager.save_api_keys("readarr", api_url, api_key)
readarr_logger.info(f"Successfully connected to Readarr API version: {response_data.get('version', 'unknown')}")
# Return success with some useful information
return jsonify({
"success": True,
"message": "Successfully connected to Readarr API",
"version": response_data.get('version', 'unknown')
})
except ValueError:
error_msg = "Invalid JSON response from Readarr API"
readarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return jsonify({"success": False, "message": error_msg}), 500
except requests.exceptions.Timeout as e:
error_msg = f"Connection timed out after {api_timeout} seconds"
readarr_logger.error(f"{error_msg}: {str(e)}")
return jsonify({"success": False, "message": error_msg}), 504
except requests.exceptions.ConnectionError as e:
error_msg = "Connection error - check hostname and port"
details = str(e)
# Check for common DNS resolution errors
if "Name or service not known" in details or "getaddrinfo failed" in details:
error_msg = "DNS resolution failed - check hostname"
# Check for common connection refused errors
elif "Connection refused" in details:
error_msg = "Connection refused - check if Readarr is running and the port is correct"
readarr_logger.error(f"{error_msg}: {details}")
return jsonify({"success": False, "message": f"{error_msg}: {details}"}), 502
except requests.exceptions.RequestException as e:
error_message = f"Connection failed: {str(e)}"
if hasattr(e, 'response') and e.response is not None:
status_code = e.response.status_code
# Add specific messages based on common status codes
if status_code == 401:
error_message = "Authentication failed: Invalid API key"
elif status_code == 403:
error_message = "Access forbidden: Check API key permissions"
elif status_code == 404:
error_message = "API endpoint not found: Check API URL"
elif status_code >= 500:
error_message = f"Readarr server error (HTTP {status_code}): The Readarr server is experiencing issues"
# Try to extract more error details if available
try:
error_details = e.response.json()
error_message += f" - {error_details.get('message', 'No details')}"
except ValueError:
if e.response.text:
error_message += f" - Response: {e.response.text[:200]}"
readarr_logger.error(error_message)
return jsonify({"success": False, "message": error_message}), 500
except Exception as e:
error_msg = f"An unexpected error occurred: {str(e)}"
readarr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 500
# Function to check if Readarr is configured
def is_configured():
"""Check if Readarr API credentials are configured by checking if at least one instance is enabled"""
settings = load_settings("readarr")
if not settings:
readarr_logger.debug("No settings found for Readarr")
return False
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
readarr_logger.debug(f"Found configured Readarr instance: {instance.get('name', 'Unnamed')}")
return True
readarr_logger.debug("No enabled Readarr instances found with valid API URL and key")
return False
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
return bool(api_url and api_key)
# Get all valid instances from settings
def get_configured_instances():
"""Get all configured and enabled Readarr instances"""
settings = load_settings("readarr")
instances = []
if not settings:
readarr_logger.debug("No settings found for Readarr")
return instances
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
for instance in settings["instances"]:
if instance.get("enabled", True) and instance.get("api_url") and instance.get("api_key"):
# Create a settings object for this instance by combining global settings with instance-specific ones
instance_settings = settings.copy()
# Remove instances list to avoid confusion
if "instances" in instance_settings:
del instance_settings["instances"]
# Override with instance-specific connection settings
instance_settings["api_url"] = instance.get("api_url")
instance_settings["api_key"] = instance.get("api_key")
instance_settings["instance_name"] = instance.get("name", "Default")
instances.append(instance_settings)
else:
# Fallback to legacy single-instance config
api_url = settings.get("api_url")
api_key = settings.get("api_key")
if api_url and api_key:
settings["instance_name"] = "Default"
instances.append(settings)
readarr_logger.info(f"Found {len(instances)} configured and enabled Readarr instances")
return instances

View File

@@ -0,0 +1,91 @@
"""
Readarr module initialization
"""
# Use src.primary imports
from src.primary.apps.readarr.missing import process_missing_books
from src.primary.apps.readarr.upgrade import process_cutoff_upgrades
# Add necessary imports
from src.primary.settings_manager import load_settings
from src.primary.utils.logger import get_logger
# Define logger for this module
readarr_logger = get_logger("readarr")
def get_configured_instances():
"""Get all configured and enabled Readarr instances"""
settings = load_settings("readarr")
instances = []
# readarr_logger.info(f"Loaded Readarr settings for instance check: {settings}") # Removed verbose log
if not settings:
readarr_logger.debug("No settings found for Readarr")
return instances
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
# readarr_logger.info(f"Found 'instances' list with {len(settings['instances'])} items. Processing...") # Removed verbose log
for idx, instance in enumerate(settings["instances"]):
readarr_logger.debug(f"Checking instance #{idx}: {instance}")
# Enhanced validation
api_url = instance.get("api_url", "").strip()
api_key = instance.get("api_key", "").strip()
# Enhanced URL validation - ensure URL has proper scheme
if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')):
readarr_logger.warning(f"Instance '{instance.get('name', 'Unnamed')}' has URL without http(s) scheme: {api_url}")
api_url = f"http://{api_url}"
readarr_logger.warning(f"Auto-correcting URL to: {api_url}")
is_enabled = instance.get("enabled", True)
# Only include properly configured instances
if is_enabled and api_url and api_key:
# Return only essential instance details
instance_data = {
"instance_name": instance.get("name", "Default"),
"api_url": api_url,
"api_key": api_key,
}
instances.append(instance_data)
# readarr_logger.info(f"Added valid instance: {instance_data}") # Removed verbose log
elif not is_enabled:
readarr_logger.debug(f"Skipping disabled instance: {instance.get('name', 'Unnamed')}")
else:
# For brand new installations, don't spam logs with warnings about default instances
instance_name = instance.get('name', 'Unnamed')
if instance_name == 'Default':
# Use debug level for default instances to avoid log spam on new installations
readarr_logger.debug(f"Skipping instance '{instance_name}' due to missing API URL or key (URL: '{api_url}', Key Set: {bool(api_key)})")
else:
# Still log warnings for non-default instances
readarr_logger.warning(f"Skipping instance '{instance_name}' due to missing API URL or key (URL: '{api_url}', Key Set: {bool(api_key)})")
else:
# readarr_logger.info("No 'instances' list found or list is empty. Checking legacy config.") # Removed verbose log
# Fallback to legacy single-instance config
api_url = settings.get("api_url", "").strip()
api_key = settings.get("api_key", "").strip()
# Ensure URL has proper scheme
if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')):
readarr_logger.warning(f"API URL missing http(s) scheme: {api_url}")
api_url = f"http://{api_url}"
readarr_logger.warning(f"Auto-correcting URL to: {api_url}")
if api_url and api_key:
# Create a clean instance_data dict for the legacy instance
instance_data = {
"instance_name": "Default",
"api_url": api_url,
"api_key": api_key,
}
instances.append(instance_data)
# readarr_logger.info(f"Added valid legacy instance: {instance_data}") # Removed verbose log
else:
readarr_logger.warning("No API URL or key found in legacy configuration")
# Use debug level to avoid spamming logs, especially with 0 instances
readarr_logger.debug(f"Found {len(instances)} configured and enabled Readarr instances")
return instances
__all__ = ["process_missing_books", "process_cutoff_upgrades", "get_configured_instances"]

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
Readarr-specific API functions
Handles all communication with the Readarr API
"""
import requests
import json
import time
import datetime
from typing import List, Dict, Any, Optional, Union
# Correct the import path
from src.primary.utils.logger import get_logger
# Import load_settings
from src.primary.settings_manager import load_settings
# Get app-specific logger
logger = get_logger("readarr")
# Use a session for better performance
session = requests.Session()
# Default API timeout in seconds - used as fallback only
API_TIMEOUT = 30
def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
"""Check the connection to Readarr API."""
try:
# Ensure api_url is properly formatted
if not api_url:
logger.error("API URL is empty or not set")
return False
# Make sure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return False
# Ensure URL doesn't end with a slash before adding the endpoint
base_url = api_url.rstrip('/')
full_url = f"{base_url}/api/v1/system/status"
response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
logger.info("Successfully connected to Readarr.")
return True
except requests.exceptions.RequestException as e:
logger.error(f"Error connecting to Readarr: {e}")
return False
except Exception as e:
logger.error(f"An unexpected error occurred during Readarr connection check: {e}")
return False
def get_download_queue_size(api_url: str = None, api_key: str = None, timeout: int = 30) -> int:
"""
Get the current size of the download queue.
Args:
api_url: Optional API URL (if not provided, will be fetched from settings)
api_key: Optional API key (if not provided, will be fetched from settings)
timeout: Timeout in seconds for the request
Returns:
The number of items in the download queue, or 0 if the request failed
"""
try:
# If API URL and key are provided, use them directly
if api_url and api_key:
# Clean up API URL
api_url = api_url.rstrip('/')
url = f"{api_url}/api/v1/queue"
# Headers
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
# Make the request
response = session.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
# Parse JSON response
data = response.json()
if "totalRecords" in data:
return data["totalRecords"]
return 0
else:
# Use the arr_request function if API URL and key aren't provided
response = arr_request("queue")
if response and "totalRecords" in response:
return response["totalRecords"]
return 0
except Exception as e:
logger.error(f"Error getting download queue size: {e}")
return 0
def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: str = "readarr",
api_url: Optional[str] = None, api_key: Optional[str] = None, api_timeout: Optional[int] = None) -> Any:
"""
Make a request to the Readarr API.
Now accepts optional api_url, api_key, and api_timeout.
Args:
endpoint: The API endpoint to call
method: HTTP method (GET, POST, PUT, DELETE)
data: Optional data to send with the request
app_type: The app type (readarr by default)
api_url: Optional API URL (overrides loaded settings)
api_key: Optional API key (overrides loaded settings)
api_timeout: Optional API timeout (overrides loaded settings)
Returns:
The JSON response from the API, or None if the request failed
"""
# Load settings only if credentials are not provided directly
if api_url is None or api_key is None or api_timeout is None:
settings = load_settings(app_type)
loaded_api_url = settings.get('api_url', '')
loaded_api_key = settings.get('api_key', '')
loaded_api_timeout = settings.get('api_timeout', 60)
# Use provided args if available, otherwise use loaded settings
api_url = api_url if api_url is not None else loaded_api_url
api_key = api_key if api_key is not None else loaded_api_key
api_timeout = api_timeout if api_timeout is not None else loaded_api_timeout
if not api_url or not api_key:
logger.error("API URL or API key is missing. Check your settings.")
return None
# Ensure api_url has a scheme
if not (api_url.startswith('http://') or api_url.startswith('https://')):
logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://")
return None
# Determine the API version
api_base = "api/v1" # Readarr uses v1
# Full URL
url = f"{api_url.rstrip('/')}/{api_base}/{endpoint.lstrip('/')}"
# Headers
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
try:
if method == "GET":
response = session.get(url, headers=headers, timeout=api_timeout)
elif method == "POST":
response = session.post(url, headers=headers, json=data, timeout=api_timeout)
elif method == "PUT":
response = session.put(url, headers=headers, json=data, timeout=api_timeout)
elif method == "DELETE":
response = session.delete(url, headers=headers, timeout=api_timeout)
else:
logger.error(f"Unsupported HTTP method: {method}")
return None
# Check for errors
response.raise_for_status()
# Parse JSON response
if response.text:
return response.json()
return {}
except requests.exceptions.RequestException as e:
logger.error(f"API request failed: {e}")
return None
def get_books_with_missing_files() -> List[Dict]:
"""
Get a list of books with missing files (not downloaded/available).
Returns:
A list of book objects with missing files
"""
# First, get all books
books = arr_request("book")
if not books:
return []
# Filter for books with missing files
missing_books = []
for book in books:
# Check if book is monitored and doesn't have a file
if book.get("monitored", False) and not book.get("bookFile", None):
missing_books.append(book)
return missing_books
def get_cutoff_unmet_books(api_url: Optional[str] = None, api_key: Optional[str] = None, api_timeout: Optional[int] = None) -> List[Dict]:
"""
Get a list of books that don't meet their quality profile cutoff.
Accepts optional API credentials.
Args:
api_url: Optional API URL
api_key: Optional API key
api_timeout: Optional API timeout
Returns:
A list of book objects that need quality upgrades
"""
# The cutoffUnmet endpoint in Readarr
params = "cutoffUnmet=true"
# Pass credentials to arr_request
books = arr_request(f"wanted/cutoff?{params}", api_url=api_url, api_key=api_key, api_timeout=api_timeout)
if not books or "records" not in books:
return []
return books.get("records", [])
def get_wanted_missing_books(api_url: str, api_key: str, api_timeout: int, monitored_only: bool = True) -> List[Dict]:
"""
Get wanted/missing books from Readarr, handling pagination.
Args:
api_url: The base URL of the Readarr API.
api_key: The API key for authentication.
api_timeout: Timeout for the API request.
monitored_only: If True, only return monitored books (Readarr API default seems to handle this).
Returns:
A list of dictionaries, each representing a missing book, or an empty list on error.
"""
all_missing_books = []
page = 1
page_size = 100 # Adjust as needed, check Readarr API limits
endpoint = "wanted/missing"
# Ensure api_url is properly formatted
if not (api_url.startswith('http://') or api_url.startswith('https://')):
logger.error(f"Invalid URL format: {api_url}")
return []
base_url = api_url.rstrip('/')
url = f"{base_url}/api/v1/{endpoint.lstrip('/')}"
headers = {"X-Api-Key": api_key}
while True:
params = {
'page': page,
'pageSize': page_size,
# Removed sorting parameters due to potential API issues
# 'sortKey': 'author.sortName',
# 'sortDirection': 'ascending',
# 'monitored': monitored_only # Note: Check if Readarr API supports this directly for wanted/missing
}
try:
response = requests.get(url, headers=headers, params=params, timeout=api_timeout)
response.raise_for_status()
data = response.json()
if not data or 'records' not in data or not data['records']:
break # No more data or unexpected format
all_missing_books.extend(data['records'])
total_records = data.get('totalRecords', 0)
if len(all_missing_books) >= total_records:
break # We have fetched all records
page += 1
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching missing books (page {page}) from {url}: {e}")
return [] # Return empty list on error
except json.JSONDecodeError:
logger.error(f"Error decoding JSON response from {url} (page {page}). Response: {response.text[:200]}")
return []
except Exception as e:
logger.error(f"Unexpected error fetching missing books (page {page}): {e}", exc_info=True)
return []
logger.info(f"Successfully fetched {len(all_missing_books)} missing books from Readarr.")
return all_missing_books
def refresh_author(author_id: int, api_url: Optional[str] = None, api_key: Optional[str] = None, api_timeout: Optional[int] = None) -> bool:
"""
Refresh an author in Readarr.
Accepts optional API credentials.
Args:
author_id: The ID of the author to refresh
api_url: Optional API URL
api_key: Optional API key
api_timeout: Optional API timeout
Returns:
True if the refresh was successful, False otherwise
"""
endpoint = f"command"
data = {
"name": "RefreshAuthor",
"authorId": author_id
}
# Pass credentials to arr_request
response = arr_request(endpoint, method="POST", data=data, api_url=api_url, api_key=api_key, api_timeout=api_timeout)
if response:
logger.debug(f"Refreshed author ID {author_id}")
return True
return False
def book_search(book_ids: List[int], api_url: Optional[str] = None, api_key: Optional[str] = None, api_timeout: Optional[int] = None) -> bool:
"""
Trigger a search for one or more books.
Accepts optional API credentials.
Args:
book_ids: A list of book IDs to search for
api_url: Optional API URL
api_key: Optional API key
api_timeout: Optional API timeout
Returns:
True if the search command was successful, False otherwise
"""
endpoint = "command"
data = {
"name": "BookSearch",
"bookIds": book_ids
}
# Pass credentials to arr_request
response = arr_request(endpoint, method="POST", data=data, api_url=api_url, api_key=api_key, api_timeout=api_timeout)
# Return the response object (contains command ID) instead of just True/False
# The calling function expects the command object now.
return response
def get_author_details(api_url: str, api_key: str, author_id: int, api_timeout: int = 120) -> Optional[Dict]:
"""Fetches details for a specific author from the Readarr API."""
endpoint = f"{api_url}/api/v1/author/{author_id}"
headers = {'X-Api-Key': api_key}
try:
response = requests.get(endpoint, headers=headers, timeout=api_timeout)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
author_data = response.json()
logger.debug(f"Successfully fetched details for author ID {author_id}.")
return author_data
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching author details for ID {author_id} from {endpoint}: {e}")
return None
except Exception as e:
logger.error(f"An unexpected error occurred fetching author details for ID {author_id}: {e}")
return None
def search_books(api_url: str, api_key: str, book_ids: List[int], api_timeout: int = 120) -> Optional[Dict]:
"""Triggers a search for specific book IDs in Readarr."""
endpoint = f"{api_url}/api/v1/command" # This uses the full URL, not arr_request
headers = {'X-Api-Key': api_key}
payload = {
'name': 'BookSearch',
'bookIds': book_ids
}
try:
# This uses requests.post directly, not arr_request. It's already correct.
response = requests.post(endpoint, headers=headers, json=payload, timeout=api_timeout)
response.raise_for_status()
command_data = response.json()
command_id = command_data.get('id')
logger.info(f"Successfully triggered BookSearch command for book IDs: {book_ids}. Command ID: {command_id}")
return command_data # Return the full command object which includes the ID
except requests.exceptions.RequestException as e:
logger.error(f"Error triggering BookSearch command for book IDs {book_ids} via {endpoint}: {e}")
return None
except Exception as e:
logger.error(f"An unexpected error occurred triggering BookSearch for book IDs {book_ids}: {e}")
return None

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Missing Books Processing for Readarr
Handles searching for missing books in Readarr
"""
import time
import random
from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
from src.primary.apps.readarr import api as readarr_api
from src.primary.stats_manager import increment_stat
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.utils.history_utils import log_processed_media
from src.primary.settings_manager import load_settings, get_advanced_setting
from src.primary.state import check_state_reset
# Get logger for the app
readarr_logger = get_logger("readarr")
def process_missing_books(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process missing books in Readarr based on provided settings.
Args:
app_settings: Dictionary containing all settings for Readarr
stop_check: A function that returns True if the process should stop
Returns:
True if any books were processed, False otherwise.
"""
readarr_logger.info("Starting missing books processing cycle for Readarr.")
processed_any = False
# Reset state files if enough time has passed
check_state_reset("readarr")
# Get the settings for the instance
general_settings = readarr_api.load_settings('general')
# Extract necessary settings
api_url = app_settings.get("api_url", "").strip()
api_key = app_settings.get("api_key", "").strip()
api_timeout = get_advanced_setting("api_timeout", 120) # Use general.json value
instance_name = app_settings.get("instance_name", "Readarr Default")
readarr_logger.info(f"Using API timeout of {api_timeout} seconds for Readarr")
monitored_only = app_settings.get("monitored_only", True)
skip_future_releases = app_settings.get("skip_future_releases", True)
skip_author_refresh = app_settings.get("skip_author_refresh", False)
hunt_missing_books = app_settings.get("hunt_missing_books", 0)
# Use advanced settings from general.json for command operations
command_wait_delay = get_advanced_setting("command_wait_delay", 1)
command_wait_attempts = get_advanced_setting("command_wait_attempts", 600)
# Get missing books
readarr_logger.info("Retrieving wanted/missing books...")
readarr_logger.info("Retrieving wanted/missing books...")
# Call the correct function to get missing books
missing_books_data = readarr_api.get_wanted_missing_books(api_url, api_key, api_timeout, monitored_only)
if missing_books_data is None: # Check if None was returned due to an API error
readarr_logger.error(f"Failed to retrieve missing books data. Skipping processing.")
return False
readarr_logger.info(f"Found {len(missing_books_data)} missing books.")
# Group by author ID (optional)
books_by_author = {}
for book in missing_books_data:
author_id = book.get("authorId")
if author_id:
if author_id not in books_by_author:
books_by_author[author_id] = []
books_by_author[author_id].append(book)
author_ids = list(books_by_author.keys())
# Filter out already processed authors using stateful management
unprocessed_authors = []
for author_id in author_ids:
if not is_processed("readarr", instance_name, str(author_id)):
unprocessed_authors.append(author_id)
else:
readarr_logger.debug(f"Skipping already processed author ID: {author_id}")
readarr_logger.info(f"Found {len(unprocessed_authors)} unprocessed authors out of {len(author_ids)} total authors with missing books.")
if not unprocessed_authors:
readarr_logger.info(f"No unprocessed authors found for {instance_name}. All available authors have been processed.")
return False
# Always randomly select authors/books to process
readarr_logger.info(f"Randomly selecting up to {hunt_missing_books} authors with missing books.")
authors_to_process = random.sample(unprocessed_authors, min(hunt_missing_books, len(unprocessed_authors)))
readarr_logger.info(f"Selected {len(authors_to_process)} authors to search for missing books.")
processed_count = 0
processed_something = False
processed_authors = [] # Track author names processed
for author_id in authors_to_process:
if stop_check():
readarr_logger.info("Stop signal received, aborting Readarr missing cycle.")
break
author_info = readarr_api.get_author_details(api_url, api_key, author_id, api_timeout) # Assuming this exists
author_name = author_info.get("authorName", f"Author ID {author_id}") if author_info else f"Author ID {author_id}"
readarr_logger.info(f"Processing missing books for author: \"{author_name}\" (Author ID: {author_id})")
# Refresh author (optional)
if not skip_author_refresh:
readarr_logger.info(f" - Refreshing author info...")
refresh_result = readarr_api.refresh_author(author_id, api_url, api_key, api_timeout)
time.sleep(5) # Basic wait
if not refresh_result:
readarr_logger.warning(f" - Failed to trigger author refresh. Continuing search anyway.")
else:
readarr_logger.info(f" - Skipping author refresh (skip_author_refresh=true)")
# Search for missing books associated with the author
readarr_logger.info(f" - Searching for missing books...")
book_ids_for_author = [book['id'] for book in books_by_author[author_id]] # 'id' is bookId
# Create detailed log with book titles
book_details = []
for book in books_by_author[author_id]:
book_title = book.get('title', f"Book ID {book['id']}")
book_details.append(f"'{book_title}' (ID: {book['id']})")
# Construct detailed log message
details_string = ', '.join(book_details)
log_message = f"Triggering Book Search for {len(book_details)} books by author '{author_name}': [{details_string}]"
readarr_logger.debug(log_message) # Changed level from INFO to DEBUG
# Mark author as processed BEFORE triggering any searches
add_processed_id("readarr", instance_name, str(author_id))
readarr_logger.debug(f"Added author ID {author_id} to processed list for {instance_name}")
# Now trigger the search
search_command_result = readarr_api.search_books(api_url, api_key, book_ids_for_author, api_timeout)
if search_command_result:
# Extract command ID if the result is a dictionary, otherwise use the result directly
command_id = search_command_result.get('id') if isinstance(search_command_result, dict) else search_command_result
readarr_logger.info(f"Triggered book search command {command_id} for author {author_name}. Assuming success for now.") # Log only command ID
increment_stat("readarr", "hunted")
# Log to history system
log_processed_media("readarr", author_name, author_id, instance_name, "missing")
readarr_logger.debug(f"Logged history entry for author: {author_name}")
processed_count += 1 # Count processed authors/groups
processed_authors.append(author_name) # Add to list of processed authors
processed_something = True
readarr_logger.info(f"Processed {processed_count}/{len(authors_to_process)} authors/groups for missing books this cycle.")
else:
readarr_logger.error(f"Failed to trigger search for author {author_name}.")
if processed_count >= hunt_missing_books:
readarr_logger.info(f"Reached target of {hunt_missing_books} authors/groups processed for this cycle.")
break
if processed_authors:
authors_list = '", "'.join(processed_authors)
readarr_logger.info(f'Completed processing {processed_count} authors/groups for missing books this cycle: "{authors_list}"')
else:
readarr_logger.info(f"Completed processing {processed_count} authors/groups for missing books this cycle.")
return processed_something

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Quality Upgrade Processing for Readarr
Handles searching for books that need quality upgrades in Readarr
"""
import time
import random
import datetime # Import the datetime module
from typing import List, Dict, Any, Set, Callable, Union, Optional
from src.primary.utils.logger import get_logger
from src.primary.apps.readarr import api as readarr_api
from src.primary.stats_manager import increment_stat
from src.primary.stateful_manager import is_processed, add_processed_id
from src.primary.utils.history_utils import log_processed_media
from src.primary.state import check_state_reset
from src.primary.settings_manager import load_settings # Import load_settings function
# Get logger for the app
readarr_logger = get_logger("readarr")
def process_cutoff_upgrades(
app_settings: Dict[str, Any],
stop_check: Callable[[], bool] # Function to check if stop is requested
) -> bool:
"""
Process quality cutoff upgrades for Readarr based on settings.
Args:
app_settings: Dictionary containing all settings for Readarr
stop_check: A function that returns True if the process should stop
Returns:
True if any books were processed for upgrades, False otherwise.
"""
readarr_logger.info("Starting quality cutoff upgrades processing cycle for Readarr.")
# Reset state files if enough time has passed
check_state_reset("readarr")
processed_any = False
# Load general settings to get centralized timeout
general_settings = load_settings('general')
# Get the API credentials for this instance
api_url = app_settings.get('api_url', '')
api_key = app_settings.get('api_key', '')
# Use the centralized timeout from general settings with app-specific as fallback
api_timeout = general_settings.get("api_timeout", app_settings.get("api_timeout", 90)) # Use centralized timeout
readarr_logger.info(f"Using API timeout of {api_timeout} seconds for Readarr")
# Extract necessary settings
instance_name = app_settings.get("instance_name", "Readarr Default")
monitored_only = app_settings.get("monitored_only", True)
skip_author_refresh = app_settings.get("skip_author_refresh", False)
hunt_upgrade_books = app_settings.get("hunt_upgrade_books", 0)
command_wait_delay = app_settings.get("command_wait_delay", 5)
command_wait_attempts = app_settings.get("command_wait_attempts", 12)
# Get books eligible for upgrade
readarr_logger.info("Retrieving books eligible for quality upgrade...")
# Pass API credentials explicitly
upgrade_eligible_data = readarr_api.get_cutoff_unmet_books(api_url=api_url, api_key=api_key, api_timeout=api_timeout)
if upgrade_eligible_data is None: # Check if the API call failed (assuming it returns None on error)
readarr_logger.error("Error retrieving books eligible for upgrade from Readarr API.")
return False
elif not upgrade_eligible_data: # Check if the list is empty
readarr_logger.info("No books found eligible for upgrade.")
return False
readarr_logger.info(f"Found {len(upgrade_eligible_data)} books eligible for quality upgrade.")
# Filter out future releases if configured
skip_future_releases = app_settings.get("skip_future_releases", True)
if skip_future_releases:
now = datetime.datetime.now(datetime.timezone.utc)
original_count = len(upgrade_eligible_data)
filtered_books = []
for book in upgrade_eligible_data:
release_date_str = book.get('releaseDate')
if release_date_str:
try:
# Try to parse ISO format first (with time component)
try:
# Handle ISO format date strings like '2023-10-17T04:00:00Z'
# fromisoformat doesn't handle 'Z' timezone, so we replace it
release_date_str_fixed = release_date_str.replace('Z', '+00:00')
release_date = datetime.datetime.fromisoformat(release_date_str_fixed)
except ValueError:
# Fall back to simple YYYY-MM-DD format
release_date = datetime.datetime.strptime(release_date_str, '%Y-%m-%d')
# Add UTC timezone for consistent comparison
release_date = release_date.replace(tzinfo=datetime.timezone.utc)
if release_date <= now:
filtered_books.append(book)
else:
readarr_logger.debug(f"Skipping future book ID {book.get('id')} with release date {release_date_str}")
except ValueError:
readarr_logger.warning(f"Could not parse release date '{release_date_str}' for book ID {book.get('id')}. Including anyway.")
filtered_books.append(book)
else:
filtered_books.append(book) # Include books without a release date
upgrade_eligible_data = filtered_books
skipped_count = original_count - len(upgrade_eligible_data)
if skipped_count > 0:
readarr_logger.info(f"Skipped {skipped_count} future books based on release date for upgrades.")
if not upgrade_eligible_data:
readarr_logger.info("No upgradeable books found to process (after potential filtering). Skipping.")
return False
# Filter out already processed books using stateful management
unprocessed_books = []
for book in upgrade_eligible_data:
book_id = str(book.get("id"))
if not is_processed("readarr", instance_name, book_id):
unprocessed_books.append(book)
else:
readarr_logger.debug(f"Skipping already processed book ID: {book_id}")
readarr_logger.info(f"Found {len(unprocessed_books)} unprocessed books out of {len(upgrade_eligible_data)} total books eligible for upgrade.")
if not unprocessed_books:
readarr_logger.info(f"No unprocessed books found for {instance_name}. All available books have been processed.")
return False
# Always randomly select books to process
readarr_logger.info(f"Randomly selecting up to {hunt_upgrade_books} books for upgrade search.")
books_to_process = random.sample(unprocessed_books, min(hunt_upgrade_books, len(unprocessed_books)))
readarr_logger.info(f"Selected {len(books_to_process)} books to search for upgrades.")
processed_count = 0
processed_something = False
book_ids_to_search = [book.get("id") for book in books_to_process]
# Mark books as processed BEFORE triggering any searches
for book_id in book_ids_to_search:
add_processed_id("readarr", instance_name, str(book_id))
readarr_logger.debug(f"Added book ID {book_id} to processed list for {instance_name}")
# Now trigger the search
search_command_result = readarr_api.search_books(api_url, api_key, book_ids_to_search, api_timeout)
if search_command_result:
command_id = search_command_result
readarr_logger.info(f"Triggered upgrade search command {command_id} for {len(book_ids_to_search)} books.")
increment_stat("readarr", "upgraded")
# Log to history system for each book
for book in books_to_process:
author_name = book.get("authorName")
book_title = book.get("title")
media_name = f"{author_name} - {book_title}"
log_processed_media("readarr", media_name, book.get("id"), instance_name, "upgrade")
readarr_logger.debug(f"Logged quality upgrade to history for book ID {book.get('id')}")
processed_count += len(book_ids_to_search)
processed_something = True
readarr_logger.info(f"Processed {processed_count} book upgrades this cycle.")
else:
readarr_logger.error(f"Failed to trigger search for book upgrades.")
readarr_logger.info(f"Completed processing {processed_count} books for upgrade this cycle.")
return processed_something

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