fix huntarr-install naming issues
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Huntarr.io-6.3.6/
|
||||
15
Huntarr.io-6.3.6/.github/FUNDING.yml
vendored
Normal 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
|
||||
110
Huntarr.io-6.3.6/.github/workflows/docker-image.yml
vendored
Normal 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
@@ -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
|
||||
24
Huntarr.io-6.3.6/Dockerfile
Normal 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
@@ -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
@@ -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!
|
||||
|
||||
[](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/
|
||||
17
Huntarr.io-6.3.6/docker-compose.yml
Normal 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
|
||||
123
Huntarr.io-6.3.6/frontend/src/routes/api/settings/+server.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
116
Huntarr.io-6.3.6/frontend/src/routes/settings/+page.svelte
Normal 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 ... -->
|
||||
BIN
Huntarr.io-6.3.6/frontend/static/arrs/48-lidarr.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/arrs/48-radarr.png
Normal file
|
After Width: | Height: | Size: 987 B |
BIN
Huntarr.io-6.3.6/frontend/static/arrs/48-readarr.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/arrs/48-sonarr.png
Normal file
|
After Width: | Height: | Size: 824 B |
BIN
Huntarr.io-6.3.6/frontend/static/arrs/48-whisparr.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
1818
Huntarr.io-6.3.6/frontend/static/css/new-style.css
Normal file
1540
Huntarr.io-6.3.6/frontend/static/css/style.css
Normal file
BIN
Huntarr.io-6.3.6/frontend/static/images/discord.png
Normal file
|
After Width: | Height: | Size: 740 B |
739
Huntarr.io-6.3.6/frontend/static/js/apps.js
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
7
Huntarr.io-6.3.6/frontend/static/js/apps/cleanuperr.js
Normal 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');
|
||||
196
Huntarr.io-6.3.6/frontend/static/js/apps/eros.js
Normal 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;
|
||||
101
Huntarr.io-6.3.6/frontend/static/js/apps/lidarr.js
Normal 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
|
||||
75
Huntarr.io-6.3.6/frontend/static/js/apps/radarr.js
Normal 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
|
||||
75
Huntarr.io-6.3.6/frontend/static/js/apps/readarr.js
Normal 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
|
||||
82
Huntarr.io-6.3.6/frontend/static/js/apps/sonarr.js
Normal 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
|
||||
381
Huntarr.io-6.3.6/frontend/static/js/apps/swaparr.js
Normal 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
|
||||
195
Huntarr.io-6.3.6/frontend/static/js/apps/whisparr.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
181
Huntarr.io-6.3.6/frontend/static/js/direct-reset.js
Normal 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');
|
||||
}
|
||||
})();
|
||||
269
Huntarr.io-6.3.6/frontend/static/js/github-sponsors.js
Normal 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();
|
||||
});
|
||||
345
Huntarr.io-6.3.6/frontend/static/js/history.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
2655
Huntarr.io-6.3.6/frontend/static/js/new-main.js
Normal file
363
Huntarr.io-6.3.6/frontend/static/js/new-user.js
Normal 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';
|
||||
}
|
||||
}
|
||||
})();
|
||||
95
Huntarr.io-6.3.6/frontend/static/js/reset-stateful.js
Normal 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);
|
||||
})();
|
||||
1912
Huntarr.io-6.3.6/frontend/static/js/settings_forms.js
Normal file
80
Huntarr.io-6.3.6/frontend/static/js/stats-reset.js
Normal 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;
|
||||
108
Huntarr.io-6.3.6/frontend/static/js/theme-preload.js
Normal 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);
|
||||
})();
|
||||
53
Huntarr.io-6.3.6/frontend/static/js/user.js
Normal 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();
|
||||
});
|
||||
71
Huntarr.io-6.3.6/frontend/static/js/utils.js
Normal 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;
|
||||
}
|
||||
BIN
Huntarr.io-6.3.6/frontend/static/logo/128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/16.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/256.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/32.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/40.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/400.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/48.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/512.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/64.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/800.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/864.png
Normal file
|
After Width: | Height: | Size: 553 KiB |
10
Huntarr.io-6.3.6/frontend/static/logo/Huntarr.svg
Normal file
|
After Width: | Height: | Size: 775 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/apps/cleanuperr.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
Huntarr.io-6.3.6/frontend/static/logo/huntarr.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
27
Huntarr.io-6.3.6/frontend/templates/base.html
Normal 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>
|
||||
233
Huntarr.io-6.3.6/frontend/templates/components/apps_section.html
Normal 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>
|
||||
@@ -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>
|
||||
68
Huntarr.io-6.3.6/frontend/templates/components/footer.html
Normal 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>
|
||||
19
Huntarr.io-6.3.6/frontend/templates/components/head.html
Normal 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>
|
||||
@@ -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>
|
||||
959
Huntarr.io-6.3.6/frontend/templates/components/home_section.html
Normal 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>
|
||||
426
Huntarr.io-6.3.6/frontend/templates/components/logs_section.html
Normal 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- Existing scripts -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/new-main.js"></script>
|
||||
@@ -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>
|
||||
245
Huntarr.io-6.3.6/frontend/templates/components/sidebar.html
Normal 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>
|
||||
267
Huntarr.io-6.3.6/frontend/templates/components/topbar.html
Normal 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>
|
||||
137
Huntarr.io-6.3.6/frontend/templates/components/user_profile.html
Normal 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>
|
||||
50
Huntarr.io-6.3.6/frontend/templates/index.html
Normal 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>
|
||||
400
Huntarr.io-6.3.6/frontend/templates/login.html
Normal 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>
|
||||
768
Huntarr.io-6.3.6/frontend/templates/setup.html
Normal 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>
|
||||
439
Huntarr.io-6.3.6/frontend/templates/user.html
Normal 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
@@ -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)
|
||||
7
Huntarr.io-6.3.6/requirements.txt
Normal 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
|
||||
62
Huntarr.io-6.3.6/routes.py
Normal 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)
|
||||
6
Huntarr.io-6.3.6/src/primary/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Huntarr - Find Missing & Upgrade Media Items
|
||||
A unified tool for Sonarr, Radarr, Lidarr, and Readarr
|
||||
"""
|
||||
|
||||
__version__ = "4.0.0"
|
||||
389
Huntarr.io-6.3.6/src/primary/api.py
Normal 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
|
||||
129
Huntarr.io-6.3.6/src/primary/app.py
Normal 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")
|
||||
41
Huntarr.io-6.3.6/src/primary/app_manager.py
Normal 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
|
||||
24
Huntarr.io-6.3.6/src/primary/apps/blueprints.py
Normal 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"
|
||||
]
|
||||
171
Huntarr.io-6.3.6/src/primary/apps/eros.py
Normal 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 []
|
||||
95
Huntarr.io-6.3.6/src/primary/apps/eros/__init__.py
Normal 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"]
|
||||
517
Huntarr.io-6.3.6/src/primary/apps/eros/api.py
Normal 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
|
||||
245
Huntarr.io-6.3.6/src/primary/apps/eros/missing.py
Normal 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)
|
||||
209
Huntarr.io-6.3.6/src/primary/apps/eros/upgrade.py
Normal 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
|
||||
229
Huntarr.io-6.3.6/src/primary/apps/eros_routes.py
Normal 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
|
||||
242
Huntarr.io-6.3.6/src/primary/apps/lidarr.py
Normal 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
|
||||
91
Huntarr.io-6.3.6/src/primary/apps/lidarr/__init__.py
Normal 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"]
|
||||
419
Huntarr.io-6.3.6/src/primary/apps/lidarr/api.py
Normal 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}")
|
||||
355
Huntarr.io-6.3.6/src/primary/apps/lidarr/missing.py
Normal 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
|
||||
184
Huntarr.io-6.3.6/src/primary/apps/lidarr/upgrade.py
Normal 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
|
||||
127
Huntarr.io-6.3.6/src/primary/apps/lidarr_routes.py
Normal 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
|
||||
147
Huntarr.io-6.3.6/src/primary/apps/radarr.py
Normal 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
|
||||
53
Huntarr.io-6.3.6/src/primary/apps/radarr/__init__.py
Normal 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"]
|
||||
337
Huntarr.io-6.3.6/src/primary/apps/radarr/api.py
Normal 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
|
||||
201
Huntarr.io-6.3.6/src/primary/apps/radarr/missing.py
Normal 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
|
||||
126
Huntarr.io-6.3.6/src/primary/apps/radarr/upgrade.py
Normal 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
|
||||
127
Huntarr.io-6.3.6/src/primary/apps/radarr_routes.py
Normal 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
|
||||
179
Huntarr.io-6.3.6/src/primary/apps/readarr.py
Normal 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
|
||||
91
Huntarr.io-6.3.6/src/primary/apps/readarr/__init__.py
Normal 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"]
|
||||
372
Huntarr.io-6.3.6/src/primary/apps/readarr/api.py
Normal 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
|
||||
177
Huntarr.io-6.3.6/src/primary/apps/readarr/missing.py
Normal 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
|
||||
172
Huntarr.io-6.3.6/src/primary/apps/readarr/upgrade.py
Normal 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
|
||||