Compare commits
32 Commits
d00191324b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aa14f9070 | |||
| 0fb0f0515a | |||
| a749188dc1 | |||
| abe5a03b05 | |||
| ad5c3f328c | |||
| af7f73759f | |||
| 428cf951b2 | |||
| e754f1f7c3 | |||
| c9ad6b2f06 | |||
| 41887e5465 | |||
| c87508d190 | |||
| 5e24c6b9cc | |||
| b29ac935a9 | |||
| 554cdbe749 | |||
| c6d884dc3f | |||
| 04acda6400 | |||
| 6d3c6fed0f | |||
| 1502d4d9d0 | |||
| 196ac2ee07 | |||
| 2359c03bc8 | |||
| eeef1fdb73 | |||
| 9b38d78042 | |||
| 9b437fe5e5 | |||
| 024a6305e2 | |||
| 6fbc5395a1 | |||
| f9cf3cef09 | |||
| 044ece0387 | |||
| edbe291389 | |||
| 1509614d3a | |||
| a6ff18c8c3 | |||
| c978bcc4ef | |||
| 901a3b1ad7 |
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls /home/michael/ripster/*.sh)",
|
||||
"Bash(ls /home/michael/ripster/backend/*.sh)",
|
||||
"Bash(systemctl list-units --type=service)",
|
||||
"Bash(pip install -q -r requirements-docs.txt)",
|
||||
"Bash(mkdocs build --strict)",
|
||||
"Read(//mnt/external/media/**)",
|
||||
"WebFetch(domain:www.makemkv.com)",
|
||||
"Bash(node --check backend/src/services/pipelineService.js)",
|
||||
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)",
|
||||
"Bash(find /home/michael/ripster -name *.db)",
|
||||
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/db/database.js)",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)",
|
||||
"Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
|
||||
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
|
||||
"Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")",
|
||||
"Bash(lsblk -o NAME,TYPE,MODEL)",
|
||||
"Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Suggested GitHub repository metadata
|
||||
|
||||
## Description
|
||||
|
||||
Local media workflow platform for Blu-ray, DVD and audio CD ripping, audiobook
|
||||
processing, metadata management, hardware-accelerated encoding and media
|
||||
archiving.
|
||||
|
||||
## Website
|
||||
|
||||
https://mboehmlaender.github.io/ripster/
|
||||
|
||||
## Topics
|
||||
|
||||
- media-ripping
|
||||
- blu-ray
|
||||
- dvd
|
||||
- audio-cd
|
||||
- audiobooks
|
||||
- media-archiving
|
||||
- media-workflow
|
||||
- ffmpeg
|
||||
- handbrake
|
||||
- makemkv
|
||||
- react
|
||||
- nodejs
|
||||
- self-hosted
|
||||
- media-converter
|
||||
- hardware-encoding
|
||||
|
||||
## German description
|
||||
|
||||
Lokales Medien-Workflow-System für Blu-ray-, DVD- und Audio-CD-Ripping,
|
||||
Hörbuchverarbeitung, Metadatenverwaltung, hardwarebeschleunigtes Encoding und
|
||||
Medienarchivierung.
|
||||
@@ -1,9 +1,13 @@
|
||||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
@@ -45,13 +49,102 @@ jobs:
|
||||
- name: Install MkDocs and dependencies
|
||||
run: pip install -r requirements-docs.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build --strict --verbose
|
||||
- name: Build MkDocs site (main -> /, dev -> /dev)
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site"
|
||||
WORKTREE_ROOT="${RUNNER_TEMP:-$PWD}/mkdocs-worktrees"
|
||||
|
||||
has_ref() {
|
||||
git rev-parse --verify --quiet "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Fetch refs separately (robust when one branch is not yet mirrored to GitHub).
|
||||
git fetch origin main || true
|
||||
git fetch origin dev || true
|
||||
|
||||
MAIN_REF="origin/main"
|
||||
DEV_REF="origin/dev"
|
||||
if [ "${REF_NAME}" = "main" ]; then
|
||||
MAIN_REF="${SHA}"
|
||||
fi
|
||||
if [ "${REF_NAME}" = "dev" ]; then
|
||||
DEV_REF="${SHA}"
|
||||
fi
|
||||
|
||||
if ! has_ref "${MAIN_REF}"; then
|
||||
echo "::error::Konnte Main-Ref '${MAIN_REF}' nicht auflösen. Main-Doku kann nicht gebaut werden."
|
||||
exit 1
|
||||
fi
|
||||
if ! has_ref "${DEV_REF}"; then
|
||||
echo "::warning::Dev-Ref '${DEV_REF}' nicht gefunden. Es wird ein Platzhalter unter /dev erzeugt."
|
||||
DEV_REF=""
|
||||
fi
|
||||
|
||||
# Build each branch in its own worktree and merge into one Pages artifact:
|
||||
# - main docs at site/
|
||||
# - dev docs at site/dev/
|
||||
rm -rf "${WORKTREE_ROOT}" "${SITE_ROOT}"
|
||||
mkdir -p "${WORKTREE_ROOT}"
|
||||
mkdir -p "${SITE_ROOT}"
|
||||
git worktree add --detach "${WORKTREE_ROOT}/main" "${MAIN_REF}"
|
||||
|
||||
if ! mkdocs build --strict --verbose \
|
||||
--config-file "${WORKTREE_ROOT}/main/mkdocs.yml" \
|
||||
--site-dir "${SITE_ROOT}"; then
|
||||
echo "::error::Main-Dokumentation konnte nicht gebaut werden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${DEV_REF}" ]; then
|
||||
git worktree add --detach "${WORKTREE_ROOT}/dev" "${DEV_REF}"
|
||||
if ! mkdocs build --strict --verbose \
|
||||
--config-file "${WORKTREE_ROOT}/dev/mkdocs.yml" \
|
||||
--site-dir "${SITE_ROOT}/dev"; then
|
||||
echo "::warning::Dev-Dokumentation konnte nicht gebaut werden. Es wird ein Platzhalter unter /dev erzeugt."
|
||||
rm -rf "${SITE_ROOT}/dev"
|
||||
mkdir -p "${SITE_ROOT}/dev"
|
||||
cat > "${SITE_ROOT}/dev/index.html" <<'EOF'
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Dev Doku</title></head>
|
||||
<body><p>Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.</p></body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
else
|
||||
mkdir -p "${SITE_ROOT}/dev"
|
||||
cat > "${SITE_ROOT}/dev/index.html" <<'EOF'
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Dev Doku</title></head>
|
||||
<body><p>Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.</p></body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Verify Pages artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site"
|
||||
if [ ! -d "${SITE_ROOT}" ]; then
|
||||
echo "::error::site/ wurde nicht erzeugt."
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${SITE_ROOT}/index.html" ]; then
|
||||
echo "::error::site/index.html fehlt."
|
||||
find "${SITE_ROOT}" -maxdepth 3 -type f | head -n 50 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site/
|
||||
path: ${{ github.workspace }}/site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
+9
-1
@@ -13,9 +13,16 @@ frontend/.vite/
|
||||
backend/dist/
|
||||
dist/
|
||||
build/
|
||||
.build/
|
||||
.venv-docs/
|
||||
.worktrees/
|
||||
site/
|
||||
.cache/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
third_party/handbrake/work/
|
||||
third_party/handbrake/build/
|
||||
!third_party/handbrake/source/*.tar.zst
|
||||
|
||||
# ----------------------------
|
||||
# Runtime state / PIDs / temp
|
||||
@@ -82,4 +89,5 @@ Thumbs.db
|
||||
/scripts/
|
||||
/release.sh
|
||||
/Audible_Tool
|
||||
/AddOns
|
||||
/AddOns
|
||||
/oldignore
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
python3
|
||||
@@ -1 +0,0 @@
|
||||
/usr/bin/python3
|
||||
@@ -1 +0,0 @@
|
||||
python3
|
||||
@@ -1 +0,0 @@
|
||||
lib
|
||||
@@ -1,5 +0,0 @@
|
||||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.3
|
||||
executable = /usr/bin/python3.12
|
||||
command = /usr/bin/python3 -m venv /home/michael/ripster/.venv-docs
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) 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
|
||||
this service 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 make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. 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.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the 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 a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE 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.
|
||||
|
||||
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
|
||||
convey 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 2 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, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision 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, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This 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.
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) 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
|
||||
this service 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 make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. 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.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the 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 a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE 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.
|
||||
|
||||
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
|
||||
convey 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 2 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, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision 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, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This 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.
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) 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
|
||||
this service 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 make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. 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.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the 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 a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE 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.
|
||||
|
||||
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
|
||||
convey 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 2 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, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision 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, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This 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.
|
||||
@@ -1,219 +1,237 @@
|
||||
# Ripster
|
||||
|
||||
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit MakeMKV + HandBrake inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
|
||||

|
||||
|
||||
---
|
||||
Ripster ist eine lokale Web-Anwendung für Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Das System kombiniert MakeMKV, HandBrake, FFmpeg, `cdparanoia`, `mkvmerge`, SQLite und eine React-Oberfläche zu einer durchgehenden Pipeline mit Queue, Historie, Downloads, Skript-Automation und Echtzeitstatus per WebSocket.
|
||||
|
||||
## Statushinweis: CD-Ripping (experimentell)
|
||||
## ⚠️ Rechtlicher Hinweis
|
||||
|
||||
- Die grundlegende CD-Ripping-Funktion im ersten Durchgang funktioniert.
|
||||
- Das Frontend ist dafür noch nicht vollständig angepasst.
|
||||
- Funktionen wie Restart, bestimmte Ansichten und Folge-Workflows können aktuell eingeschränkt sein oder fehlschlagen.
|
||||
- CD-Ripping wird weiterentwickelt und sollte derzeit als experimentell betrachtet werden.
|
||||
Ripster ist ausschließlich für die Sicherung von Titeln gedacht, die dir selbst gehören oder für die du die erforderlichen Nutzungsrechte besitzt. Bitte prüfe vor der Nutzung die in deinem Land geltenden Urheberrechts-, Privatkopie- und Kopierschutzregelungen. Die Software ist nicht für Piraterie oder die Verarbeitung unberechtigt beschaffter Inhalte gedacht.
|
||||
|
||||
## Was Ripster kann
|
||||
## KI-unterstützte Entwicklung
|
||||
|
||||
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
||||
- Medienprofil-Erkennung (Blu-ray/DVD/Sonstiges) aus Device-/Filesystem-Heuristik
|
||||
- Metadaten-Suche und Zuordnung über OMDb
|
||||
- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings
|
||||
- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args
|
||||
- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten)
|
||||
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
|
||||
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung)
|
||||
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen (Skripte, Ketten, Cron, Tasks) in Echtzeit im Dashboard
|
||||
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
|
||||
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard
|
||||
Teile dieses Projekts, einschließlich Quellcode, Dokumentation, Tests und konzeptioneller Entwürfe, wurden mit Unterstützung generativer KI erstellt oder überarbeitet.
|
||||
|
||||
## Tech-Stack
|
||||
KI-Werkzeuge wurden insbesondere für Implementierungsvorschläge, Fehleranalyse, Refactoring, Dokumentation und die Ausarbeitung einzelner Funktionen eingesetzt. Auswahl, Anpassung, Integration und Freigabe der Ergebnisse lagen beim Projektverantwortlichen. Die technische und rechtliche Verantwortung für das veröffentlichte Projekt verbleibt beim menschlichen Maintainer.
|
||||
|
||||
- Backend: Node.js, Express, SQLite, WebSocket (`ws`)
|
||||
- Frontend: React, Vite, PrimeReact
|
||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||
Trotz manueller Prüfung können Fehler oder unvollständige Annahmen enthalten sein. Entsprechende Hinweise, Issues und Pull Requests sind willkommen.
|
||||
|
||||
## Dokumentation
|
||||
## 🎬 Funktionsumfang
|
||||
|
||||
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
|
||||
### 🎞️ Medien-Workflows
|
||||
- Blu-ray-Ripping und -Encoding
|
||||
- DVD-Ripping und -Encoding
|
||||
- Audio-CD-Ripping und -Encoding
|
||||
- Audiobook-Verarbeitung aus `.aax`
|
||||
- Datei-Converter für Audio-, Video- und ISO-Dateien
|
||||
|
||||
## Voraussetzungen
|
||||
### 📀 Video (Blu-ray / DVD)
|
||||
- automatische Disc-Erkennung, Rescan einzelner Laufwerke und Live-Status im Ripper
|
||||
- Metadaten-Suche über TMDB für Filme und Serien
|
||||
- HandBrake-Review mit Playlist-/Titel-Auswahl, Audio-/Untertitel-Track-Auswahl und Encode-Vorschau
|
||||
- MakeMKV-Rip mit profilspezifischen Modi und Zusatzargumenten
|
||||
- HandBrake-Encoding mit User-Presets, offiziellen HandBrake-Presets und Extra-Args
|
||||
- Serien-Workflows für DVD- und Blu-ray-Discs inkl. Episoden-Zuordnung und Batch-Encoding
|
||||
- Multipart-Movie-Workflows über mehrere Discs inkl. Merge-Job via `mkvmerge`
|
||||
- Re-Encode aus RAW, Review-Neustart, Encode-Neustart, Retry und Resume laufender/unterbrochener Jobs
|
||||
|
||||
- Debian 11/12 oder Ubuntu 22.04/24.04
|
||||
- root-Rechte + Internetzugang
|
||||
- optisches Laufwerk (oder gemountete Quelle)
|
||||
### 🎵 Audio-CD
|
||||
- TOC-Analyse und Rip mit `cdparanoia`
|
||||
- MusicBrainz-Suche und Übernahme von Album-/Track-Metadaten
|
||||
- Track-Auswahl und Ausgabe als FLAC, WAV, MP3, Opus oder Ogg Vorbis
|
||||
- RAW-Wiederverwendung und CD-Review-/Encode-Neustart aus vorhandenen Daten
|
||||
|
||||
## Schnellstart (`install.sh`)
|
||||
### 🎧 Audiobooks
|
||||
|
||||
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
|
||||
Diese Funktion ist für eine Archivierung von gekauften Titeln. Das System ermittelt keine Activation-Bytes und verweist dafür auf eine externe Seite
|
||||
|
||||
- Upload und Verarbeitung von Audible-/AAX-Dateien
|
||||
- Metadaten- und Kapitelanreicherung über Audnex plus lokale Probe-Daten
|
||||
- Ausgabe als M4B, MP3 oder FLAC
|
||||
- Einzeldatei oder kapitelweises Splitten
|
||||
|
||||
### 🔄 Converter (Beta)
|
||||
- Dateibaum und Datei-Explorer für das Converter-RAW-Verzeichnis
|
||||
- Upload, Ordner anlegen, Umbenennen, Verschieben und Löschen
|
||||
- Jobs aus Dateiauswahl erzeugen, Dateien bestehenden Jobs zuweisen oder daraus entfernen
|
||||
- Audio-/Video-Erkennung und automatische RAW-Scans per Polling
|
||||
- TMDB-Metadatenzuordnung für passende Video-Jobs
|
||||
|
||||
Der Converter ist noch nicht vollständig entwickelt und auch noch nicht vollständig geprüft. Er kann verwendet werden, es funktionieren aber ggf. nicht alle Funktionen vollständig!
|
||||
|
||||
### 🛠️ Automation, Betrieb und Verwaltung
|
||||
- Queue mit normalen Jobs sowie zusätzlichen `script`-, `chain`- und `wait`-Einträgen
|
||||
- Pre-/Post-Encode-Skripte und Skript-Ketten
|
||||
- Cron-Jobs für Skripte und Ketten inkl. Validierung, Logs und manueller Auslösung
|
||||
- Download-Queue für ZIP-Archive aus Historienjobs
|
||||
- Historie mit Detailansicht, Re-Encode, Review-/Encode-Neustart, Retry und Löschfunktionen
|
||||
- `/database`-Ansicht für Orphan-RAW-Ordner (Import oder Löschen vorhandener RAW-Daten)
|
||||
- Hardware-Monitoring mit Live-Werten und Verlaufshistorie
|
||||
- Pushover-Benachrichtigungen für zentrale Pipeline-Ereignisse
|
||||
- MakeMKV-Betakey-Prüfung/-Übernahme und Cover-Art-Recovery
|
||||
|
||||
## 🖥️ Oberfläche
|
||||
|
||||
- `Ripper`: Disc-Erkennung, Pipeline-Status, Queue, Review-Dialoge und aktive Jobs
|
||||
- `Converter`: Dateibaum, Uploads und Converter-Jobs
|
||||
- `Audiobooks`: AAX-Uploads und Audiobook-Jobs
|
||||
- `Settings`: Pfade, Tools, Templates, Queue, Monitoring, Notifications, Scripts, Chains, Presets
|
||||
- `Historie`: abgeschlossene und fehlerhafte Jobs mit Folgeaktionen
|
||||
- `Downloads`: vorbereitete ZIP-Downloads
|
||||
- `Database`: Orphan-RAW-Verwaltung (im Expertenmodus)
|
||||
- Zusatzansichten: `Hardware` und `TMDB Migration`
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
Der korrekte Bootstrap läuft über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und startet es mit denselben Parametern.
|
||||
|
||||
Unterstützte Systeme laut Installer:
|
||||
- Debian
|
||||
- Ubuntu
|
||||
|
||||
Standardinstallation:
|
||||
|
||||
```bash
|
||||
wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh
|
||||
sudo bash install.sh
|
||||
wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Alternativ direkt per Pipe:
|
||||
Es gibt 2 Branches: main und dev
|
||||
|
||||
main enthält das aktuelle stbale release
|
||||
dev enthält den aktuellen Stand der Weiterentwicklung
|
||||
|
||||
Hinweise:
|
||||
- `setup.sh` nutzt bei Bedarf `sudo`; root-Rechte und Internetzugang sind erforderlich.
|
||||
- Ohne `--branch` bietet `setup.sh` lokal eine Branch-Auswahl an.
|
||||
- Der eigentliche Installer ist `install.sh`; `setup.sh` ist der empfohlene Einstiegspunkt.
|
||||
|
||||
Wichtige Standardparameter für den Installationslauf:
|
||||
|
||||
Verfügbare Optionen:
|
||||
- `--branch <branch>`
|
||||
- `--dir <pfad>`
|
||||
- `--user <benutzer>`
|
||||
- `--port <port>`
|
||||
- `--host <hostname|ip>`
|
||||
- `--no-makemkv`
|
||||
- `--no-handbrake`
|
||||
- `--no-nginx`
|
||||
- `--no-system-deps`
|
||||
- `--accept-makemkv-eula`
|
||||
- `--force-license-prompts`
|
||||
- `--reinstall`
|
||||
- `--help`
|
||||
|
||||
Was der Installer typischerweise einrichtet:
|
||||
- Node.js 20
|
||||
- `ffmpeg`, `ffprobe`, `mediainfo`, `mkvtoolnix`
|
||||
- CD-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||
- optional MakeMKV
|
||||
- optional HandBrakeCLI
|
||||
- optional nginx
|
||||
- Repository-Checkout bzw. Update
|
||||
- npm-Abhängigkeiten, Frontend-Build und `ripster-backend`-systemd-Service
|
||||
|
||||
> [!NOTE]
|
||||
> Ripster orchestriert mehrere externe Medientools. Diese Tools bleiben ihren eigenen Lizenzen und Nutzungsbedingungen unterworfen. Siehe [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) für Details.
|
||||
|
||||
Mit `--force-license-prompts` werden die MakeMKV-EULA-Abfrage und die
|
||||
HandBrake-Drittanbieterhinweise auch dann erneut angezeigt, wenn die jeweiligen
|
||||
Tools bereits installiert sind. `--no-makemkv` und `--no-handbrake` überspringen
|
||||
weiterhin den jeweils abgewählten Hinweis.
|
||||
|
||||
## ♻️ Update
|
||||
|
||||
Standard-Update einer bestehenden Installation:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||
sudo bash Pfad_zur_Installation/install.sh --reinstall
|
||||
```
|
||||
|
||||
`install.sh` übernimmt u. a.:
|
||||
|
||||
- Node.js 20 (falls nötig)
|
||||
- Basistools inkl. `mediainfo`
|
||||
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||
- MakeMKV
|
||||
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
|
||||
- nginx
|
||||
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
|
||||
|
||||
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
|
||||
|
||||
Wichtige Optionen:
|
||||
Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, diese beim Update wieder mitgeben:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev)
|
||||
sudo bash install.sh --dir /opt/ripster # Installationspfad
|
||||
sudo bash install.sh --user ripster # Service-User
|
||||
sudo bash install.sh --port 3001 # Backend-Port
|
||||
sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS
|
||||
sudo bash install.sh --no-makemkv # MakeMKV überspringen
|
||||
sudo bash install.sh --no-handbrake # HandBrake überspringen
|
||||
sudo bash install.sh --no-nginx # nginx-Setup überspringen
|
||||
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
|
||||
sudo bash install.sh --help # Hilfe anzeigen
|
||||
sudo bash Pfad_zur_Installation/install.sh --reinstall --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
Alternativ kann auch erneut über `setup.sh` gebootstrapped werden:
|
||||
|
||||
### UI-Settings (empfohlen)
|
||||
|
||||
Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert:
|
||||
|
||||
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Sonstiges-Varianten)
|
||||
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
|
||||
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
|
||||
- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)`
|
||||
- Benachrichtigungen: PushOver
|
||||
|
||||
### Umgebungsvariablen
|
||||
|
||||
Backend (`backend/src/config.js`):
|
||||
|
||||
- `PORT` (Default: `3001`)
|
||||
- `DB_PATH` (Default: `backend/data/ripster.db`)
|
||||
- `LOG_DIR` (Default: `backend/logs`)
|
||||
- `CORS_ORIGIN` (Default: `*`)
|
||||
- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`)
|
||||
|
||||
Frontend (Vite):
|
||||
|
||||
- `VITE_API_BASE` (Default: `/api`)
|
||||
- `VITE_WS_URL` (optional, überschreibt automatische WS-URL)
|
||||
- optional für Remote-Dev: `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_PROTOCOL`, `VITE_HMR_HOST`, `VITE_HMR_CLIENT_PORT`
|
||||
|
||||
## Logs und Daten
|
||||
|
||||
Log-Ziel ist primär der in den Settings gepflegte `log_dir`.
|
||||
|
||||
- Backend-Logs: `<log_dir>/backend/backend-latest.log` und Tagesdateien
|
||||
- Job-Logs: `<log_dir>/job-<id>.process.log`
|
||||
- DB: `backend/data/ripster.db`
|
||||
|
||||
Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migriert.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
ripster/
|
||||
backend/
|
||||
src/
|
||||
routes/
|
||||
services/
|
||||
db/
|
||||
utils/
|
||||
frontend/
|
||||
src/
|
||||
pages/
|
||||
components/
|
||||
api/
|
||||
db/schema.sql
|
||||
start.sh
|
||||
install.sh
|
||||
install-dev.sh
|
||||
```bash
|
||||
sudo bash Pfad_zur_Installation/setup.sh --reinstall
|
||||
```
|
||||
|
||||
## API-Überblick
|
||||
`--reinstall` aktualisiert die Installation und behält die persistenten Daten der bestehenden Instanz bei.
|
||||
|
||||
**Health**
|
||||
- `GET /api/health`
|
||||
## 🧪 Entwicklung
|
||||
|
||||
**Pipeline**
|
||||
- `GET /api/pipeline/state`
|
||||
- `POST /api/pipeline/analyze`
|
||||
- `POST /api/pipeline/rescan-disc`
|
||||
- `POST /api/pipeline/select-metadata`
|
||||
- `POST /api/pipeline/start/:jobId`
|
||||
- `POST /api/pipeline/confirm-encode/:jobId`
|
||||
- `POST /api/pipeline/cancel`
|
||||
- `POST /api/pipeline/retry/:jobId`
|
||||
- `POST /api/pipeline/reencode/:jobId`
|
||||
- `POST /api/pipeline/restart-review/:jobId`
|
||||
- `POST /api/pipeline/restart-encode/:jobId`
|
||||
- `POST /api/pipeline/resume-ready/:jobId`
|
||||
- `GET /api/pipeline/queue`
|
||||
- `POST /api/pipeline/queue/reorder`
|
||||
- `POST /api/pipeline/queue/entry`
|
||||
- `DELETE /api/pipeline/queue/entry/:entryId`
|
||||
Schnellstart für lokale Entwicklung:
|
||||
|
||||
**History**
|
||||
- `GET /api/history`
|
||||
- `GET /api/history/:id`
|
||||
- `GET /api/history/database`
|
||||
- `GET /api/history/orphan-raw`
|
||||
- `POST /api/history/orphan-raw/import`
|
||||
- `POST /api/history/:id/omdb/assign`
|
||||
- `POST /api/history/:id/delete-files`
|
||||
- `POST /api/history/:id/delete`
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Settings**
|
||||
- `GET /api/settings`
|
||||
- `PUT /api/settings/:key`
|
||||
- `PUT /api/settings`
|
||||
- `GET/POST/PUT/DELETE /api/settings/scripts...`
|
||||
- `GET/POST/PUT/DELETE /api/settings/script-chains...`
|
||||
- `GET/POST/PUT/DELETE /api/settings/user-presets...`
|
||||
- `POST /api/settings/pushover/test`
|
||||
`start.sh` prüft Node.js, installiert fehlende Abhängigkeiten und startet Backend und Frontend im Dev-Modus.
|
||||
|
||||
**Cron-Jobs**
|
||||
- `GET /api/crons`
|
||||
- `POST /api/crons`
|
||||
- `GET /api/crons/:id`
|
||||
- `PUT /api/crons/:id`
|
||||
- `DELETE /api/crons/:id`
|
||||
- `GET /api/crons/:id/logs`
|
||||
- `POST /api/crons/:id/run`
|
||||
- `POST /api/crons/validate-expression`
|
||||
Wichtige npm-Skripte:
|
||||
- `npm run dev`
|
||||
- `npm run dev:backend`
|
||||
- `npm run dev:frontend`
|
||||
- `npm run build:frontend`
|
||||
- `npm run start`
|
||||
|
||||
**Runtime-Aktivitäten**
|
||||
- `GET /api/activities`
|
||||
- `POST /api/activities/:id/cancel`
|
||||
- `POST /api/activities/:id/next-step`
|
||||
- `POST /api/activities/clear-recent`
|
||||
## ⚙️ Konfiguration
|
||||
|
||||
## Troubleshooting
|
||||
Die meisten Einstellungen werden in der Weboberfläche unter `Settings` gepflegt und in SQLite gespeichert. Dazu gehören insbesondere:
|
||||
- Pfade und Output-Templates für Blu-ray, DVD, Serie, CD, Audiobook, Converter, Downloads und Logs
|
||||
- Tool-Kommandos für MakeMKV, HandBrake, MediaInfo, FFmpeg, FFprobe, `cdparanoia` und `mkvmerge`
|
||||
- Laufwerksmodus, Disc-Erkennung und Queue-/Parallelisierungsregeln
|
||||
- Hardware-Monitoring, Logging, Expert Mode und Cover-Art-Recovery
|
||||
- TMDB-Zugangsdaten, Pushover, Skripte, Skript-Ketten, User-Presets und Preset-Defaults
|
||||
|
||||
- WebSocket verbindet nicht:
|
||||
- prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend)
|
||||
- bei Reverse-Proxy Upgrade-Header für `/ws` setzen
|
||||
- Keine Disc erkannt:
|
||||
- in den Settings `Laufwerksmodus` auf `Explizites Device` stellen und `Device Pfad` setzen (z. B. `/dev/sr0`)
|
||||
- HandBrake/MakeMKV Fehler:
|
||||
- CLI-Binaries im `PATH` prüfen
|
||||
- Preset-Name mit `HandBrakeCLI -z` prüfen
|
||||
- Startfehler wegen Schema:
|
||||
- `db/schema.sql` vorhanden halten
|
||||
Zusätzliche Bootstrap-/Override-Variablen:
|
||||
|
||||
## Sicherheit
|
||||
Backend:
|
||||
- `PORT`
|
||||
- `DB_PATH`
|
||||
- `LOG_DIR`
|
||||
- `LOG_LEVEL`
|
||||
- `CORS_ORIGIN`
|
||||
- `DEFAULT_TEMP_DIR`
|
||||
- `DEFAULT_RAW_DIR`
|
||||
- `DEFAULT_MOVIE_DIR`
|
||||
- `DEFAULT_SERIES_DIR`
|
||||
- `DEFAULT_CD_DIR`
|
||||
- `DEFAULT_AUDIOBOOK_RAW_DIR`
|
||||
- `DEFAULT_AUDIOBOOK_DIR`
|
||||
- `DEFAULT_DOWNLOAD_DIR`
|
||||
- `DEFAULT_CONVERTER_RAW_DIR`
|
||||
- `DEFAULT_CONVERTER_MOVIE_DIR`
|
||||
- `DEFAULT_CONVERTER_AUDIO_DIR`
|
||||
|
||||
- Keine echten Tokens/Passwörter ins Repository committen.
|
||||
- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren.
|
||||
Frontend:
|
||||
- `VITE_API_BASE`
|
||||
- `VITE_WS_URL`
|
||||
|
||||
## 🗂️ Daten, Logs und API
|
||||
|
||||
- Standard-DB: `backend/data/ripster.db`
|
||||
- Standard-Logs: `backend/logs`
|
||||
- Standard-Outputs liegen relativ zum Datenverzeichnis, sofern in den Settings nichts anderes gesetzt ist.
|
||||
- Das Schema wird beim Start geprüft und fehlende DB-Elemente werden migriert.
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
Ausführlichere Dokumentation liegt in `docs/` und veröffentlicht unter:
|
||||
|
||||
https://mboehmlaender.github.io/ripster/
|
||||
|
||||
## Lizenz
|
||||
|
||||
Der von diesem Repository entwickelte Ripster-Quellcode steht unter der GNU General Public License Version 2 oder jeder späteren Version (`GPL-2.0-or-later`).
|
||||
|
||||
Ripster enthält eine separat ausführbare HandBrakeCLI-Version für hardwarebeschleunigtes Encoding. Diese mitgelieferte Drittanbieterkomponente bleibt unter der GNU General Public License Version 2 (`GPL-2.0-only`) lizenziert.
|
||||
|
||||
Der vollständige korrespondierende Quellcode, Buildskripte, Patches, Prüfsummen und Lizenzhinweise zur mitgelieferten HandBrakeCLI befinden sich unter [`third_party/handbrake/`](third_party/handbrake/).
|
||||
|
||||
Weitere Hinweise zu Drittanbieterkomponenten befinden sich in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Ripster uses and orchestrates third-party software and services. These
|
||||
components remain subject to their own licenses, copyright notices and terms of
|
||||
use.
|
||||
|
||||
The Ripster license does not replace or modify the licenses of these
|
||||
third-party components.
|
||||
|
||||
## Bundled HandBrakeCLI
|
||||
|
||||
Ripster includes a separately executable build of HandBrakeCLI to provide
|
||||
hardware-accelerated video encoding on supported Linux systems.
|
||||
|
||||
HandBrakeCLI is an independent third-party program developed by the HandBrake
|
||||
project. It is not part of the original Ripster source code and is not licensed
|
||||
under Ripster's `GPL-2.0-or-later` license.
|
||||
|
||||
The bundled HandBrakeCLI executable is distributed under the GNU General Public
|
||||
License version 2 (`GPL-2.0-only`).
|
||||
|
||||
The corresponding license text, build information, checksums, build scripts,
|
||||
patches and corresponding source notes are provided under:
|
||||
|
||||
- `third_party/handbrake/COPYING`
|
||||
- `third_party/handbrake/BUILDINFO.md`
|
||||
- `third_party/handbrake/SHA256SUMS`
|
||||
- `third_party/handbrake/build-handbrake.sh`
|
||||
- `third_party/handbrake/patches/`
|
||||
- `third_party/handbrake/source/`
|
||||
|
||||
The currently bundled binary contains FDK-AAC related strings. Redistribution
|
||||
of this binary is marked as unresolved until a separate legal review is
|
||||
completed or the binary is replaced by a clean rebuild without FDK-AAC.
|
||||
|
||||
Ripster is not affiliated with or endorsed by the HandBrake project.
|
||||
|
||||
## External media tools
|
||||
|
||||
Depending on the selected workflow and system configuration, Ripster can invoke
|
||||
external tools including:
|
||||
|
||||
- MakeMKV
|
||||
- FFmpeg and FFprobe
|
||||
- MediaInfo
|
||||
- MKVToolNix / `mkvmerge`
|
||||
- cdparanoia
|
||||
- FLAC
|
||||
- LAME
|
||||
- Opus Tools
|
||||
- Vorbis Tools
|
||||
|
||||
These programs are not relicensed by Ripster. Users and distributors are
|
||||
responsible for complying with their respective licenses and usage conditions.
|
||||
MakeMKV is subject to its own license and registration terms.
|
||||
|
||||
## JavaScript dependencies
|
||||
|
||||
The frontend and backend use open-source packages distributed through the
|
||||
Node.js ecosystem. Their respective licenses and copyright notices remain
|
||||
applicable.
|
||||
|
||||
Dependency information can be found in the relevant `package.json` and lock
|
||||
files.
|
||||
|
||||
## External metadata and notification services
|
||||
|
||||
Ripster may integrate with external services such as:
|
||||
|
||||
- TMDB
|
||||
- MusicBrainz
|
||||
- Audnexus
|
||||
- Pushover
|
||||
|
||||
Use of these services is subject to their respective terms, API policies and
|
||||
data licenses.
|
||||
|
||||
### TMDB notice
|
||||
|
||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
@@ -8,5 +8,6 @@ LOG_LEVEL=debug
|
||||
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
|
||||
DEFAULT_RAW_DIR=
|
||||
DEFAULT_MOVIE_DIR=
|
||||
DEFAULT_SERIES_DIR=
|
||||
DEFAULT_CD_DIR=
|
||||
DEFAULT_DOWNLOAD_DIR=
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"watch": [
|
||||
"src",
|
||||
".env"
|
||||
],
|
||||
"ext": "js,json,env",
|
||||
"ignore": [
|
||||
"data/**",
|
||||
"logs/**"
|
||||
]
|
||||
}
|
||||
Generated
+3
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.11.0-5",
|
||||
"version": "1.0.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.11.0-5",
|
||||
"version": "1.0.0-1",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
+10
-1
@@ -1,7 +1,16 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.11.0-5",
|
||||
"version": "1.0.0-1",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mboehmlaender/ripster.git"
|
||||
},
|
||||
"homepage": "https://mboehmlaender.github.io/ripster/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/mboehmlaender/ripster/issues"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
@@ -18,13 +20,18 @@ module.exports = {
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||
dbPath: resolvedDbPath,
|
||||
dataDir,
|
||||
tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'),
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'),
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads')
|
||||
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads'),
|
||||
defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'),
|
||||
defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'),
|
||||
defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio')
|
||||
};
|
||||
|
||||
+569
-2
@@ -528,8 +528,10 @@ async function openAndPrepareDatabase() {
|
||||
await migrateLegacyProfiledToolSettings(dbInstance);
|
||||
await migrateOutputTemplates(dbInstance);
|
||||
await removeDeprecatedSettings(dbInstance);
|
||||
await migrateTmdbMigrationFlags(dbInstance);
|
||||
await migrateSettingsSchemaMetadata(dbInstance);
|
||||
await ensurePipelineStateRow(dbInstance);
|
||||
await backfillJobOutputFolders(dbInstance);
|
||||
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||
logger.info('log-root:synced', {
|
||||
configured: syncedLogRoot.configured || null,
|
||||
@@ -769,6 +771,8 @@ async function migrateOutputTemplates(db) {
|
||||
async function removeDeprecatedSettings(db) {
|
||||
const deprecatedKeys = [
|
||||
'pushover_notify_disc_detected',
|
||||
'pushover_notify_cron_success',
|
||||
'pushover_notify_cron_error',
|
||||
'mediainfo_extra_args',
|
||||
'makemkv_rip_mode',
|
||||
'makemkv_analyze_extra_args',
|
||||
@@ -789,7 +793,28 @@ async function removeDeprecatedSettings(db) {
|
||||
'filename_template_dvd',
|
||||
'output_folder_template_bluray',
|
||||
'output_folder_template_dvd',
|
||||
'output_extension_audiobook'
|
||||
'output_extension_audiobook',
|
||||
'use_plugin_architecture',
|
||||
'use_plugin_architecture_bluray',
|
||||
'use_plugin_architecture_bluray_analyze',
|
||||
'use_plugin_architecture_bluray_rip',
|
||||
'use_plugin_architecture_bluray_review',
|
||||
'use_plugin_architecture_bluray_encode',
|
||||
'use_plugin_architecture_dvd',
|
||||
'use_plugin_architecture_dvd_analyze',
|
||||
'use_plugin_architecture_dvd_rip',
|
||||
'use_plugin_architecture_dvd_review',
|
||||
'use_plugin_architecture_dvd_encode',
|
||||
'use_plugin_architecture_cd',
|
||||
'use_plugin_architecture_cd_analyze',
|
||||
'use_plugin_architecture_cd_rip',
|
||||
'use_plugin_architecture_audiobook',
|
||||
'use_plugin_architecture_audiobook_analyze',
|
||||
'use_plugin_architecture_audiobook_encode',
|
||||
'dvd_series_plugin_enabled',
|
||||
'dvd_series_prescan_enabled',
|
||||
'dvd_series_reuse_disc_profiles',
|
||||
'musicbrainz_enabled'
|
||||
];
|
||||
for (const key of deprecatedKeys) {
|
||||
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||
@@ -806,9 +831,73 @@ async function removeDeprecatedSettings(db) {
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateTmdbMigrationFlags(db) {
|
||||
// Keep this migration idempotent. It helps initialize the new migrate_tmdb flag
|
||||
// for existing rows after OMDb -> TMDb transition.
|
||||
const nullableReset = await db.run(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET migrate_tmdb = 0
|
||||
WHERE migrate_tmdb IS NULL
|
||||
`
|
||||
);
|
||||
|
||||
// Audio-only domains are outside TMDb migration scope.
|
||||
const nonVideoMarked = await db.run(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET migrate_tmdb = 1
|
||||
WHERE COALESCE(migrate_tmdb, 0) = 0
|
||||
AND LOWER(TRIM(COALESCE(media_type, ''))) IN ('cd', 'audiobook')
|
||||
`
|
||||
);
|
||||
|
||||
// Rows with obvious TMDb assignment can be marked as migrated.
|
||||
const obviousTmdbMarked = await db.run(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET migrate_tmdb = 1
|
||||
WHERE COALESCE(migrate_tmdb, 0) = 0
|
||||
AND (
|
||||
makemkv_info_json LIKE '%"metadataProvider":"tmdb"%'
|
||||
OR makemkv_info_json LIKE '%"tmdbId":%'
|
||||
OR encode_plan_json LIKE '%"metadataProvider":"tmdb"%'
|
||||
OR encode_plan_json LIKE '%"tmdbId":%'
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
// Explicit OMDb footprints must stay pending for manual migration.
|
||||
const explicitOmdbPending = await db.run(
|
||||
`
|
||||
UPDATE jobs
|
||||
SET migrate_tmdb = 0
|
||||
WHERE (
|
||||
COALESCE(selected_from_omdb, 0) = 1
|
||||
OR (omdb_json IS NOT NULL AND TRIM(omdb_json) <> '')
|
||||
OR makemkv_info_json LIKE '%"metadataProvider":"omdb"%'
|
||||
OR encode_plan_json LIKE '%"metadataProvider":"omdb"%'
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
logger.info('migrate:tmdb-flag:done', {
|
||||
nullableReset: Number(nullableReset?.changes || 0),
|
||||
nonVideoMarked: Number(nonVideoMarked?.changes || 0),
|
||||
obviousTmdbMarked: Number(obviousTmdbMarked?.changes || 0),
|
||||
explicitOmdbPending: Number(explicitOmdbPending?.changes || 0)
|
||||
});
|
||||
}
|
||||
|
||||
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
|
||||
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
|
||||
const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
{
|
||||
key: 'makemkv_registration_key',
|
||||
required: 0,
|
||||
description: 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.',
|
||||
validation_json: '{}'
|
||||
},
|
||||
{
|
||||
key: 'handbrake_preset_bluray',
|
||||
required: 0,
|
||||
@@ -820,6 +909,42 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
required: 0,
|
||||
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||
validation_json: '{}'
|
||||
},
|
||||
{
|
||||
key: 'dvd_series_language',
|
||||
required: 1,
|
||||
description: 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.',
|
||||
validation_json: '{"minLength":2}'
|
||||
},
|
||||
{
|
||||
key: 'dvd_series_fallback_languages',
|
||||
required: 1,
|
||||
description: 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.',
|
||||
validation_json: '{"minLength":2}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_bluray_series_episode',
|
||||
required: 1,
|
||||
description: 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_bluray_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_episode',
|
||||
required: 1,
|
||||
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -827,10 +952,23 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
const SETTINGS_CATEGORY_MOVES = [
|
||||
{ key: 'cd_output_template', category: 'Pfade' },
|
||||
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||
{ key: 'output_template_bluray_series_episode', category: 'Pfade' },
|
||||
{ key: 'output_template_bluray_series_multi_episode', category: 'Pfade' },
|
||||
{ key: 'output_template_dvd', category: 'Pfade' },
|
||||
{ key: 'output_template_audiobook', category: 'Pfade' },
|
||||
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
|
||||
{ key: 'audiobook_raw_template', category: 'Pfade' }
|
||||
{ key: 'audiobook_raw_template', category: 'Pfade' },
|
||||
{ key: 'converter_raw_dir', category: 'Pfade' },
|
||||
{ key: 'converter_movie_dir', category: 'Pfade' },
|
||||
{ key: 'converter_audio_dir', category: 'Pfade' },
|
||||
{ key: 'converter_output_template_video', category: 'Pfade' },
|
||||
{ key: 'converter_output_template_audio', category: 'Pfade' },
|
||||
{ key: 'server_console_log_output_enabled', category: 'Logging' },
|
||||
{ key: 'server_console_log_http_enabled', category: 'Logging' },
|
||||
{ key: 'server_console_log_debug_enabled', category: 'Logging' },
|
||||
{ key: 'server_console_log_info_enabled', category: 'Logging' },
|
||||
{ key: 'server_console_log_warn_enabled', category: 'Logging' },
|
||||
{ key: 'server_console_log_error_enabled', category: 'Logging' }
|
||||
];
|
||||
|
||||
async function migrateSettingsSchemaMetadata(db) {
|
||||
@@ -875,12 +1013,130 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
});
|
||||
}
|
||||
|
||||
const externalStorageDescription = 'Wird links unter "Freie Speicher" angezeigt.';
|
||||
const externalStorageDescResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'external_storage_paths' AND description != ?`,
|
||||
[externalStorageDescription, externalStorageDescription]
|
||||
);
|
||||
if (externalStorageDescResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-external-storage-description-updated', {
|
||||
key: 'external_storage_paths'
|
||||
});
|
||||
}
|
||||
|
||||
const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden';
|
||||
const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).';
|
||||
const metadataReadyNotifyResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`,
|
||||
[
|
||||
metadataReadyNotifyLabel,
|
||||
metadataReadyNotifyDescription,
|
||||
metadataReadyNotifyLabel,
|
||||
metadataReadyNotifyDescription
|
||||
]
|
||||
);
|
||||
if (metadataReadyNotifyResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-metadata-ready-notify-updated', {
|
||||
key: 'pushover_notify_metadata_ready',
|
||||
label: metadataReadyNotifyLabel
|
||||
});
|
||||
}
|
||||
|
||||
const metadataLanguageLabel = 'Film-Sprache';
|
||||
const metadataLanguageDescription = 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.';
|
||||
const metadataLanguageResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'dvd_series_language' AND (label != ? OR description != ?)`,
|
||||
[metadataLanguageLabel, metadataLanguageDescription, metadataLanguageLabel, metadataLanguageDescription]
|
||||
);
|
||||
if (metadataLanguageResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-language-updated', {
|
||||
key: 'dvd_series_language',
|
||||
label: metadataLanguageLabel
|
||||
});
|
||||
}
|
||||
|
||||
const metadataFallbackLanguageLabel = 'Fallback Sprache';
|
||||
const metadataFallbackLanguageDescription = 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.';
|
||||
const metadataFallbackLanguageResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'dvd_series_fallback_languages' AND (label != ? OR description != ?)`,
|
||||
[
|
||||
metadataFallbackLanguageLabel,
|
||||
metadataFallbackLanguageDescription,
|
||||
metadataFallbackLanguageLabel,
|
||||
metadataFallbackLanguageDescription
|
||||
]
|
||||
);
|
||||
if (metadataFallbackLanguageResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-fallback-language-updated', {
|
||||
key: 'dvd_series_fallback_languages',
|
||||
label: metadataFallbackLanguageLabel
|
||||
});
|
||||
}
|
||||
|
||||
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
|
||||
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
|
||||
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
|
||||
];
|
||||
const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', ');
|
||||
await db.run(
|
||||
`
|
||||
UPDATE settings_schema
|
||||
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'output_template_dvd_series_multi_episode'
|
||||
AND (
|
||||
default_value IS NULL
|
||||
OR TRIM(default_value) = ''
|
||||
OR default_value IN (${legacyTemplatePlaceholders})
|
||||
)
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
|
||||
);
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO settings_values (key, value, updated_at)
|
||||
VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault]
|
||||
);
|
||||
await db.run(
|
||||
`
|
||||
UPDATE settings_values
|
||||
SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'output_template_dvd_series_multi_episode'
|
||||
AND (
|
||||
value IS NULL
|
||||
OR TRIM(value) = ''
|
||||
OR value IN (${legacyTemplatePlaceholders})
|
||||
)
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
|
||||
);
|
||||
|
||||
// Migrate raw_dir_cd_owner label
|
||||
await db.run(
|
||||
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||
);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige
|
||||
// Settings per INSERT OR IGNORE angelegt werden.
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
// Add movie_dir_cd if not already present
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -901,12 +1157,68 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('mkvmerge_command', 'Tools', 'mkvmerge Kommando', 'string', 1, 'Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt.', 'mkvmerge', '[]', '{"minLength":1}', 2325)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mkvmerge_command', 'mkvmerge')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0')`);
|
||||
|
||||
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_upper_pct'`);
|
||||
|
||||
await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true')`);
|
||||
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -950,6 +1262,150 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_language', 'Metadaten', 'Film-Sprache', 'string', 1, 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'Fallback Sprache', 'string', 1, 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
|
||||
|
||||
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
await db.run(`DELETE FROM settings_values WHERE key = 'dvd_series_order_preference'`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key = 'dvd_series_order_preference'`);
|
||||
|
||||
// Converter-Pfade und Eigentümer
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_raw_dir', 'Pfade', 'Converter RAW-Ordner', 'path', 0, 'Quellordner für Converter-Eingabedateien. Leer = Standardpfad.', NULL, '[]', '{}', 106)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_movie_dir', 'Pfade', 'Converter Video Output-Ordner', 'path', 0, 'Zielordner für konvertierte Video-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 116)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_audio_dir', 'Pfade', 'Converter Audio Output-Ordner', 'path', 0, 'Zielordner für konvertierte Audio-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 117)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_video', 'Pfade', 'Converter Video Output-Template', 'string', 0, 'Template für Video-Ausgabedateinamen, z.B. {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 738)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_audio', 'Pfade', 'Converter Audio Output-Template', 'string', 0, 'Template für Audio-Ausgabedateinamen, z.B. {artist} - {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 739)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_raw_dir_owner', 'Pfade', 'Eigentümer Converter RAW-Ordner', 'string', 0, 'Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1065)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_movie_dir_owner', 'Pfade', 'Eigentümer Converter Video Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1165)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_audio_dir_owner', 'Pfade', 'Eigentümer Converter Audio Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1166)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
|
||||
@@ -962,6 +1418,12 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)`
|
||||
@@ -974,6 +1436,24 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false')`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -981,6 +1461,93 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
}
|
||||
|
||||
async function backfillJobOutputFolders(db) {
|
||||
// Remove duplicate (job_id, output_path) rows before unique index is enforced.
|
||||
await db.run(`
|
||||
DELETE FROM job_output_folders
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path
|
||||
)
|
||||
`);
|
||||
|
||||
// Populate job_output_folders from jobs.output_path for pre-feature jobs.
|
||||
// Only inserts rows where no entry exists for the job yet.
|
||||
const rows = await db.all(`
|
||||
SELECT j.id AS job_id, j.output_path
|
||||
FROM jobs j
|
||||
WHERE j.output_path IS NOT NULL
|
||||
AND j.output_path != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id
|
||||
)
|
||||
`);
|
||||
if (!Array.isArray(rows) || rows.length === 0) return;
|
||||
let inserted = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await db.run(
|
||||
'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)',
|
||||
[row.job_id, row.output_path]
|
||||
);
|
||||
inserted += 1;
|
||||
} catch (err) {
|
||||
logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message });
|
||||
}
|
||||
}
|
||||
if (inserted > 0) {
|
||||
logger.info('backfill:job-output-folders:done', { inserted });
|
||||
}
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const http = require('http');
|
||||
@@ -13,22 +15,72 @@ const historyRoutes = require('./routes/historyRoutes');
|
||||
const downloadRoutes = require('./routes/downloadRoutes');
|
||||
const cronRoutes = require('./routes/cronRoutes');
|
||||
const runtimeRoutes = require('./routes/runtimeRoutes');
|
||||
const converterRoutes = require('./routes/converterRoutes');
|
||||
const wsService = require('./services/websocketService');
|
||||
const pipelineService = require('./services/pipelineService');
|
||||
const settingsService = require('./services/settingsService');
|
||||
const converterScanService = require('./services/converterScanService');
|
||||
const cronService = require('./services/cronService');
|
||||
const downloadService = require('./services/downloadService');
|
||||
const diskDetectionService = require('./services/diskDetectionService');
|
||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||
const tempCleanupService = require('./services/tempCleanupService');
|
||||
const { fetchCurrentBetaKey } = require('./services/makemkvKeyService');
|
||||
const logger = require('./services/logger').child('BOOT');
|
||||
const { errorToMeta } = require('./utils/errorMeta');
|
||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||
|
||||
function getDelayUntilNextBetaKeyCheck(now = new Date()) {
|
||||
const next = new Date(now);
|
||||
next.setHours(0, 1, 0, 0);
|
||||
if (next.getTime() <= now.getTime()) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return Math.max(1000, next.getTime() - now.getTime());
|
||||
}
|
||||
|
||||
async function start() {
|
||||
logger.info('backend:start:init');
|
||||
await initDatabase();
|
||||
try {
|
||||
const runtimeSettings = await settingsService.applyRuntimeSettings();
|
||||
logger.info('backend:runtime-settings:applied', runtimeSettings);
|
||||
} catch (error) {
|
||||
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
|
||||
}
|
||||
let betaKeyRefreshTimer = null;
|
||||
const scheduleBetaKeyRefresh = () => {
|
||||
clearTimeout(betaKeyRefreshTimer);
|
||||
const delayMs = getDelayUntilNextBetaKeyCheck();
|
||||
betaKeyRefreshTimer = setTimeout(runBetaKeyRefresh, delayMs);
|
||||
betaKeyRefreshTimer.unref?.();
|
||||
logger.info('beta-key:daily-check:scheduled', {
|
||||
delayMs,
|
||||
nextRunAt: new Date(Date.now() + delayMs).toISOString()
|
||||
});
|
||||
};
|
||||
const runBetaKeyRefresh = async () => {
|
||||
try {
|
||||
const result = await fetchCurrentBetaKey({ forceRefresh: true });
|
||||
logger.info('beta-key:daily-check:done', {
|
||||
sourceUrl: result?.sourceUrl || null,
|
||||
validUntil: result?.validUntil || null,
|
||||
fetchedAt: result?.fetchedAt || null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:daily-check:failed', { error: errorToMeta(error) });
|
||||
} finally {
|
||||
scheduleBetaKeyRefresh();
|
||||
}
|
||||
};
|
||||
scheduleBetaKeyRefresh();
|
||||
await tempCleanupService.init();
|
||||
await pipelineService.init();
|
||||
await converterScanService.startPolling();
|
||||
await cronService.init();
|
||||
await downloadService.init();
|
||||
await coverArtRecoveryService.init();
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
@@ -45,6 +97,7 @@ async function start() {
|
||||
app.use('/api/downloads', downloadRoutes);
|
||||
app.use('/api/crons', cronRoutes);
|
||||
app.use('/api/runtime', runtimeRoutes);
|
||||
app.use('/api/converter', converterRoutes);
|
||||
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -84,9 +137,13 @@ async function start() {
|
||||
|
||||
const shutdown = () => {
|
||||
logger.warn('backend:shutdown:received');
|
||||
converterScanService.stopPolling();
|
||||
diskDetectionService.stop();
|
||||
coverArtRecoveryService.stop();
|
||||
hardwareMonitorService.stop();
|
||||
cronService.stop();
|
||||
tempCleanupService.stop();
|
||||
clearTimeout(betaKeyRefreshTimer);
|
||||
server.close(() => {
|
||||
logger.warn('backend:shutdown:completed');
|
||||
process.exit(0);
|
||||
|
||||
@@ -26,6 +26,7 @@ function truncate(value, maxLen = 1500) {
|
||||
module.exports = function requestLogger(req, res, next) {
|
||||
const reqId = randomUUID();
|
||||
const startedAt = Date.now();
|
||||
let completionLogged = false;
|
||||
|
||||
req.reqId = reqId;
|
||||
|
||||
@@ -38,15 +39,37 @@ module.exports = function requestLogger(req, res, next) {
|
||||
body: truncate(req.body)
|
||||
});
|
||||
|
||||
const logCompletion = (kind) => {
|
||||
if (completionLogged) {
|
||||
return;
|
||||
}
|
||||
completionLogged = true;
|
||||
logger.info('request:end', {
|
||||
reqId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
completion: kind
|
||||
});
|
||||
};
|
||||
|
||||
res.on('finish', () => {
|
||||
logCompletion('finish');
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
logger.warn('request:aborted', {
|
||||
reqId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return;
|
||||
}
|
||||
logCompletion('close');
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const audiobookService = require('../services/audiobookService');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Audiobooks (AAX-Dateien).
|
||||
*
|
||||
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
|
||||
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
|
||||
* - Kein Rip-Schritt — die AAX-Datei ist der Input
|
||||
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
|
||||
*
|
||||
* Deshalb:
|
||||
* rip() → No-Op (Datei liegt bereits im rawDir)
|
||||
* review() → null (kein HandBrake-Preview)
|
||||
* encode() → ffmpeg Single-File oder kapitelweise
|
||||
*
|
||||
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
|
||||
* Datei-Uploads) oder eine Dateiendung im discInfo.
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra für analyze():
|
||||
* filePath - Absoluter Pfad zur .aax-Datei
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra für encode():
|
||||
* inputPath - Absoluter Pfad zur .aax-Input-Datei
|
||||
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
|
||||
* outputFormat - 'm4b' | 'mp3' | 'flac'
|
||||
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
|
||||
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
|
||||
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
|
||||
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
|
||||
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*/
|
||||
class AudiobookPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'audiobook';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Audiobook (AAX)';
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Audiobooks.
|
||||
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'audiobook' || profile === 'audio_book') {
|
||||
return true;
|
||||
}
|
||||
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
|
||||
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert eine .aax-Datei mit ffprobe.
|
||||
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
|
||||
*
|
||||
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
|
||||
*/
|
||||
async analyze(filePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
const inputPath = ctx.extra?.filePath || filePath;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
const originalName = path.basename(inputPath);
|
||||
|
||||
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
|
||||
|
||||
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
|
||||
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
|
||||
|
||||
const probe = audiobookService.parseProbeOutput(captured.stdout);
|
||||
if (!probe) {
|
||||
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
|
||||
|
||||
ctx.logger.info('audiobook:analyze:done', {
|
||||
jobId: job?.id,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
chapterCount: metadata.chapters?.length || 0,
|
||||
durationMs: metadata.durationMs
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-Op: Audiobooks haben keinen Rip-Schritt.
|
||||
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
|
||||
*/
|
||||
async rip(_job, _ctx) {
|
||||
_ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
// Für Audiobooks gibt es keinen separaten Rip-Schritt
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
if (!settings.audiobook_encoding_enabled) {
|
||||
ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputFormat = 'mp3',
|
||||
formatOptions = {},
|
||||
metadata = {},
|
||||
activationBytes = null,
|
||||
isSplitOutput = false,
|
||||
chapterPlan = null,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
|
||||
}
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
|
||||
|
||||
ctx.logger.info('audiobook:encode:start', {
|
||||
jobId: job?.id,
|
||||
format: normalizedFormat,
|
||||
isSplitOutput,
|
||||
inputPath
|
||||
});
|
||||
|
||||
if (isSplitOutput) {
|
||||
await this._encodeChapters({
|
||||
job, ctx, ffmpegCommand, inputPath, chapterPlan,
|
||||
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||
isCancelled, onProcessHandle
|
||||
});
|
||||
} else {
|
||||
await this._encodeSingleFile({
|
||||
job, ctx, ffmpegCommand, inputPath, outputPath,
|
||||
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||
isCancelled, onProcessHandle
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('audiobook:encode:done', {
|
||||
jobId: job?.id,
|
||||
format: normalizedFormat,
|
||||
outputPath
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = {
|
||||
outputPath,
|
||||
format: normalizedFormat,
|
||||
chapterCount: isSplitOutput
|
||||
? (chapterPlan?.outputFiles?.length || 0)
|
||||
: 1
|
||||
};
|
||||
|
||||
return ctx.extra.encodeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
|
||||
*/
|
||||
async review(_job, _ctx) {
|
||||
_ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings für Audiobooks.
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'audiobook_encoding_enabled',
|
||||
category: 'Features',
|
||||
label: 'Audiobook Encoding',
|
||||
type: 'boolean',
|
||||
required: 1,
|
||||
description: 'Audiobook-Encoding über das Plugin aktivieren.',
|
||||
default_value: 'true',
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 100
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg_command',
|
||||
category: 'Tools',
|
||||
label: 'FFmpeg Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
|
||||
default_value: 'ffmpeg',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 232
|
||||
},
|
||||
{
|
||||
key: 'ffprobe_command',
|
||||
category: 'Tools',
|
||||
label: 'FFprobe Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
|
||||
default_value: 'ffprobe',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 233
|
||||
},
|
||||
{
|
||||
key: 'output_template_audiobook',
|
||||
category: 'Pfade',
|
||||
label: 'Output Template (Audiobook)',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
|
||||
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 735
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private Encode-Methoden ──────────────────────────────────────────────────
|
||||
|
||||
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const ffmpegConfig = audiobookService.buildEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
outputPath,
|
||||
normalizedFormat,
|
||||
normalizedOptions,
|
||||
{ activationBytes, metadata }
|
||||
);
|
||||
|
||||
ctx.logger.info('audiobook:encode:single-file', {
|
||||
jobId: job?.id,
|
||||
cmd: ffmpegConfig.cmd,
|
||||
outputPath
|
||||
});
|
||||
|
||||
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
|
||||
await _spawnAndWait({
|
||||
cmd: ffmpegConfig.cmd,
|
||||
args: ffmpegConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser,
|
||||
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'AudiobookPlugin' }
|
||||
});
|
||||
}
|
||||
|
||||
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
|
||||
if (outputFiles.length === 0) {
|
||||
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
|
||||
}
|
||||
|
||||
for (let index = 0; index < outputFiles.length; index++) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const entry = outputFiles[index];
|
||||
const chapter = entry?.chapter || {};
|
||||
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
|
||||
const startPct = (index / outputFiles.length) * 100;
|
||||
const endPct = ((index + 1) / outputFiles.length) * 100;
|
||||
|
||||
ensureDir(path.dirname(entry.outputPath));
|
||||
|
||||
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
entry.outputPath,
|
||||
normalizedFormat,
|
||||
normalizedOptions,
|
||||
metadata,
|
||||
chapter,
|
||||
outputFiles.length,
|
||||
{ activationBytes }
|
||||
);
|
||||
|
||||
ctx.logger.info('audiobook:encode:chapter', {
|
||||
jobId: job?.id,
|
||||
chapterIndex: index + 1,
|
||||
chapterTotal: outputFiles.length,
|
||||
chapterTitle,
|
||||
outputPath: entry.outputPath
|
||||
});
|
||||
|
||||
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
|
||||
const scaledParser = baseParser
|
||||
? (line) => {
|
||||
const progress = baseParser(line);
|
||||
if (!progress || progress.percent == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
percent: startPct + (endPct - startPct) * (progress.percent / 100),
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
: null;
|
||||
|
||||
ctx.emitProgress(
|
||||
Math.round(startPct),
|
||||
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: ffmpegConfig.cmd,
|
||||
args: ffmpegConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: scaledParser,
|
||||
onProgress: (pct) => ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||
),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||
|
||||
function _assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCaptured(cmd, args, _context = {}) {
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
|
||||
return {
|
||||
stdout: String(error?.stdout || ''),
|
||||
stderr: String(error?.stderr || ''),
|
||||
exitCode: error?.code ?? 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: () => {},
|
||||
onStderrLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent);
|
||||
}
|
||||
}
|
||||
},
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
return await handle.promise;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AudiobookPlugin };
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Blu-ray Discs.
|
||||
*
|
||||
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||
* Überschreibt nur die Identifikations-Properties und detect().
|
||||
*
|
||||
* Erkennungsmerkmale:
|
||||
* - mediaProfile === 'bluray'
|
||||
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
|
||||
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
|
||||
*/
|
||||
class BluRayPlugin extends VideoDiscPlugin {
|
||||
get id() {
|
||||
return 'bluray';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Blu-ray';
|
||||
}
|
||||
|
||||
get mediaProfile() {
|
||||
return 'bluray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
|
||||
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
|
||||
* theoretisch auf UDF-Discs matchen könnten.
|
||||
*/
|
||||
get priority() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Blu-ray Discs.
|
||||
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||
*
|
||||
* @param {object} discInfo
|
||||
* @param {string} [discInfo.mediaProfile] - 'bluray'
|
||||
* @param {string} [discInfo.fstype] - 'udf'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
|
||||
return true;
|
||||
}
|
||||
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
|
||||
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
|
||||
// vertrauen und nicht via Modell-Marker überschreiben.
|
||||
if (profile) {
|
||||
return false;
|
||||
}
|
||||
// Fallback: nur wenn kein mediaProfile bekannt ist.
|
||||
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BluRayPlugin };
|
||||
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const cdRipService = require('../services/cdRipService');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Audio-CDs.
|
||||
*
|
||||
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
|
||||
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
|
||||
*
|
||||
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
|
||||
* verzahnt (rip track → encode track → nächster Track).
|
||||
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
|
||||
*
|
||||
* Erwartete ctx.extra-Felder für rip():
|
||||
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
|
||||
* selectedTracks, tracks, metadata, ...)
|
||||
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
|
||||
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
|
||||
* outputTemplate - CD-Output-Template String
|
||||
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
|
||||
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
|
||||
*/
|
||||
class CdPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Audio CD';
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
|
||||
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
|
||||
*
|
||||
* Akzeptierte Werte: 'cd', 'audio_cd'
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
|
||||
*
|
||||
* @param {string} devicePath - z.B. '/dev/sr0'
|
||||
* @param {object} job - Job-Record (id wird für Logging genutzt)
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const detectedTitle = String(
|
||||
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
|
||||
).trim();
|
||||
|
||||
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
|
||||
|
||||
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
|
||||
|
||||
if (!tracks.length) {
|
||||
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
|
||||
|
||||
return {
|
||||
tracks,
|
||||
cdparanoiaCmd,
|
||||
detectedTitle
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt und encodiert alle ausgewählten Tracks der CD.
|
||||
*
|
||||
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
|
||||
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra:
|
||||
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
|
||||
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
|
||||
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
|
||||
* outputTemplate - Template-String für Dateinamen
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
ripConfig = {},
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
outputTemplate,
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
onProgress: onRawProgress,
|
||||
onLog: onExternalLog
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!rawWavDir) {
|
||||
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
|
||||
}
|
||||
const skipRip = Boolean(ripConfig?.skipRip);
|
||||
const skipEncode = Boolean(ripConfig?.skipEncode);
|
||||
|
||||
if (!skipEncode && !outputDir) {
|
||||
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
|
||||
}
|
||||
|
||||
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const devicePath = String(job?.disc_device || '').trim();
|
||||
if (!devicePath && !skipRip) {
|
||||
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
|
||||
}
|
||||
|
||||
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
|
||||
const formatOptions = ripConfig.formatOptions || {};
|
||||
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||
|
||||
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
|
||||
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
|
||||
const incomingByPos = new Map(
|
||||
incomingTracks
|
||||
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
|
||||
.map((t) => [Math.trunc(Number(t.position)), t])
|
||||
);
|
||||
|
||||
const mergedTracks = tocTracks
|
||||
.map((track) => {
|
||||
const pos = Math.trunc(Number(track?.position));
|
||||
if (!Number.isFinite(pos) || pos <= 0) {
|
||||
return null;
|
||||
}
|
||||
const incoming = incomingByPos.get(pos) || null;
|
||||
return {
|
||||
...track,
|
||||
position: pos,
|
||||
title: incoming?.title || track.title || `Track ${pos}`,
|
||||
artist: incoming?.artist || track.artist || null,
|
||||
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedMeta = cdInfo.selectedMetadata || {};
|
||||
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
|
||||
? ripConfig.metadata
|
||||
: {};
|
||||
const meta = {
|
||||
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
|
||||
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
|
||||
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
|
||||
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
|
||||
};
|
||||
|
||||
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
|
||||
? ripConfig.selectedTracks
|
||||
.map((v) => Math.trunc(Number(v)))
|
||||
.filter((v) => Number.isFinite(v) && v > 0)
|
||||
: [];
|
||||
const selectedTrackPositions = rawSelectedPositions.length > 0
|
||||
? rawSelectedPositions
|
||||
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||
|
||||
ctx.logger.info('cd:rip:start', {
|
||||
jobId: job?.id,
|
||||
devicePath: devicePath || null,
|
||||
skipRip,
|
||||
skipEncode,
|
||||
format,
|
||||
trackCount: selectedTrackPositions.length
|
||||
});
|
||||
|
||||
const result = await cdRipService.ripAndEncode({
|
||||
jobId: job?.id,
|
||||
devicePath: devicePath || null,
|
||||
cdparanoiaCmd,
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selectedTrackPositions,
|
||||
tracks: mergedTracks,
|
||||
meta,
|
||||
outputTemplate: effectiveTemplate,
|
||||
skipRip,
|
||||
skipEncode,
|
||||
onProgress: (progressEvent) => {
|
||||
if (typeof onRawProgress === 'function') {
|
||||
onRawProgress(progressEvent);
|
||||
}
|
||||
const pct = Number(progressEvent?.percent ?? 0);
|
||||
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
|
||||
const trackIdx = progressEvent?.trackIndex || 1;
|
||||
const trackTotal = progressEvent?.trackTotal || 1;
|
||||
ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
`${phase} Track ${trackIdx}/${trackTotal} …`
|
||||
);
|
||||
},
|
||||
onLog: (level, msg) => {
|
||||
if (ctx.logger[level]) {
|
||||
ctx.logger[level](msg, { jobId: job?.id });
|
||||
}
|
||||
if (typeof onExternalLog === 'function') {
|
||||
onExternalLog(level, msg);
|
||||
}
|
||||
},
|
||||
onProcessHandle,
|
||||
isCancelled,
|
||||
context: { jobId: job?.id, source: 'CdPlugin' }
|
||||
});
|
||||
|
||||
ctx.logger.info('cd:rip:done', {
|
||||
jobId: job?.id,
|
||||
format,
|
||||
trackCount: result.trackCount,
|
||||
outputDir: result.outputDir
|
||||
});
|
||||
|
||||
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
|
||||
ctx.extra.ripResult = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
|
||||
*/
|
||||
async encode(_job, _ctx) {
|
||||
_ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* CDs brauchen keine Encode-Review (kein HandBrake).
|
||||
*/
|
||||
async review(_job, _ctx) {
|
||||
_ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings für Audio CDs.
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'cdparanoia_command',
|
||||
category: 'Tools',
|
||||
label: 'cdparanoia Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
|
||||
default_value: 'cdparanoia',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 240
|
||||
},
|
||||
{
|
||||
key: 'cd_output_template',
|
||||
category: 'Pfade',
|
||||
label: 'CD Output Template',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
|
||||
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 730
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
|
||||
|
||||
function _safeParseJson(value) {
|
||||
try {
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _normText(value) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.normalize('NFC')
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function _normYear(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
module.exports = { CdPlugin };
|
||||
@@ -0,0 +1,660 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { syncRegistrationKeyToConfig } = require('../services/makemkvKeyService');
|
||||
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
||||
const ISO_EXTENSIONS = new Set(['iso']);
|
||||
|
||||
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
|
||||
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
|
||||
|
||||
/**
|
||||
* Erkennt den Medientyp anhand der Dateiendung.
|
||||
* @returns {'video'|'audio'|'iso'|null}
|
||||
*/
|
||||
function detectMediaTypeFromPath(filePath) {
|
||||
const ext = path.extname(String(filePath || '')).slice(1).toLowerCase();
|
||||
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAudioExt(ext) {
|
||||
return AUDIO_EXTENSIONS.has(String(ext).toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv).
|
||||
*/
|
||||
function listAudioFilesInDir(dirPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1)))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei.
|
||||
*/
|
||||
function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) {
|
||||
const args = ['-y', '-i', inputFile];
|
||||
|
||||
if (format === 'flac') {
|
||||
const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5)));
|
||||
args.push('-codec:a', 'flac', '-compression_level', String(level));
|
||||
} else if (format === 'mp3') {
|
||||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
args.push('-codec:a', 'libmp3lame');
|
||||
if (mode === 'vbr') {
|
||||
args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)))));
|
||||
} else {
|
||||
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
|
||||
}
|
||||
} else if (format === 'opus') {
|
||||
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
|
||||
} else if (format === 'ogg') {
|
||||
args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)))));
|
||||
} else if (format === 'wav') {
|
||||
args.push('-codec:a', 'pcm_s16le');
|
||||
} else {
|
||||
args.push('-codec:a', 'copy');
|
||||
}
|
||||
|
||||
// Metadata tags
|
||||
const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null;
|
||||
const trackArtist = (opts.trackArtist || opts.albumArtist)
|
||||
? String(opts.trackArtist || opts.albumArtist).trim() : null;
|
||||
const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null;
|
||||
const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null;
|
||||
const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null;
|
||||
|
||||
if (trackTitle) args.push('-metadata', `title=${trackTitle}`);
|
||||
if (trackArtist) args.push('-metadata', `artist=${trackArtist}`);
|
||||
if (albumTitle) args.push('-metadata', `album=${albumTitle}`);
|
||||
if (albumYear) args.push('-metadata', `date=${albumYear}`);
|
||||
if (trackNumber) args.push('-metadata', `track=${trackNumber}`);
|
||||
|
||||
args.push(outputFile);
|
||||
return { cmd: ffmpegCmd, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-Plugin für den Converter.
|
||||
*
|
||||
* Unterstützte Eingaben:
|
||||
* - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode
|
||||
* - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode
|
||||
* - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg
|
||||
* - ISO: MakeMKV-Rip → HandBrake-Encode
|
||||
*
|
||||
* Erkennung via mediaProfile === 'converter'.
|
||||
*
|
||||
* Erforderliche Felder in ctx.extra für encode():
|
||||
* inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path)
|
||||
* outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder
|
||||
* outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien)
|
||||
* encodePlan - aus encode_plan_json
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*/
|
||||
class ConverterPlugin extends SourcePlugin {
|
||||
get id() { return 'converter'; }
|
||||
get name() { return 'Converter'; }
|
||||
get priority() { return 3; }
|
||||
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
return profile === 'converter';
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert die Eingabedatei/-ordner.
|
||||
* - Video/ISO: HandBrake --scan für Track-Info
|
||||
* - Audio: ffprobe für Metadaten
|
||||
* - Audio-Ordner: Dateiliste + ffprobe auf erste Datei
|
||||
*/
|
||||
async analyze(inputPath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const actualInput = ctx.extra?.filePath || inputPath;
|
||||
const encodePlan = ctx.extra?.encodePlan || {};
|
||||
const converterMediaType = encodePlan.converterMediaType
|
||||
|| detectMediaTypeFromPath(actualInput);
|
||||
|
||||
ctx.logger.info('converter:analyze:start', {
|
||||
jobId: job?.id, inputPath: actualInput, converterMediaType
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI';
|
||||
|
||||
let analysisResult = { converterMediaType, inputPath: actualInput };
|
||||
|
||||
if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
||||
// HandBrake --scan für Track-Informationen
|
||||
try {
|
||||
const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput);
|
||||
const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id });
|
||||
const hbInfo = this._parseHandBrakeScan(captured.stdout);
|
||||
analysisResult.handbrakeInfo = hbInfo;
|
||||
ctx.logger.info('converter:analyze:handbrake-scan', {
|
||||
jobId: job?.id,
|
||||
titleCount: hbInfo?.TitleList?.length || 0
|
||||
});
|
||||
} catch (scanError) {
|
||||
ctx.logger.warn('converter:analyze:handbrake-scan-failed', {
|
||||
jobId: job?.id, error: scanError?.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (converterMediaType === 'audio') {
|
||||
const stat = fs.statSync(actualInput);
|
||||
if (stat.isDirectory()) {
|
||||
// Ordner mit Audio-Dateien
|
||||
const audioFiles = listAudioFilesInDir(actualInput);
|
||||
analysisResult.isFolder = true;
|
||||
analysisResult.audioFiles = audioFiles;
|
||||
// Erster Track: ffprobe für Metadaten
|
||||
if (audioFiles.length > 0) {
|
||||
const firstFile = path.join(actualInput, audioFiles[0]);
|
||||
try {
|
||||
const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id });
|
||||
analysisResult.folderMeta = meta;
|
||||
} catch (_err) {
|
||||
// Metadaten optional
|
||||
}
|
||||
}
|
||||
ctx.logger.info('converter:analyze:audio-folder', {
|
||||
jobId: job?.id, fileCount: audioFiles.length
|
||||
});
|
||||
} else {
|
||||
// Einzelne Audio-Datei
|
||||
analysisResult.isFolder = false;
|
||||
try {
|
||||
const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id });
|
||||
analysisResult.fileMeta = meta;
|
||||
} catch (_err) {
|
||||
// Metadaten optional
|
||||
}
|
||||
ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id });
|
||||
}
|
||||
}
|
||||
|
||||
return analysisResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip-Schritt:
|
||||
* - Video/Audio-Dateien: No-Op (Datei liegt bereits vor)
|
||||
* - ISO: MakeMKV backup → raw_path
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const encodePlan = ctx.extra?.encodePlan || {};
|
||||
const converterMediaType = encodePlan.converterMediaType;
|
||||
|
||||
if (converterMediaType !== 'iso') {
|
||||
ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType });
|
||||
return;
|
||||
}
|
||||
|
||||
// ISO: MakeMKV backup
|
||||
const {
|
||||
inputPath,
|
||||
rawJobDir,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
if (!rawJobDir) {
|
||||
throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt');
|
||||
}
|
||||
|
||||
ensureDir(rawJobDir);
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon';
|
||||
await syncRegistrationKeyToConfig(settings?.makemkv_registration_key || '');
|
||||
|
||||
const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir];
|
||||
|
||||
ctx.logger.info('converter:rip:iso:start', {
|
||||
jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd
|
||||
});
|
||||
ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …');
|
||||
|
||||
const runInfo = await _spawnAndWait({
|
||||
cmd: makemkvCmd, args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseMakeMkvProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:rip:iso:done', {
|
||||
jobId: job?.id, exitCode: runInfo?.exitCode ?? null
|
||||
});
|
||||
|
||||
ctx.extra.ripRunInfo = runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode-Schritt:
|
||||
* - Video: HandBrakeCLI
|
||||
* - Audio Einzeldatei: ffmpeg
|
||||
* - Audio Ordner: ffmpeg je Datei
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputDir,
|
||||
encodePlan = {},
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
const converterMediaType = encodePlan.converterMediaType;
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
|
||||
if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
||||
await this._encodeVideo({
|
||||
job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle
|
||||
});
|
||||
} else if (converterMediaType === 'audio') {
|
||||
await this._encodeAudio({
|
||||
job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle
|
||||
});
|
||||
} else {
|
||||
throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video-Encode (HandBrake) ─────────────────────────────────────────────
|
||||
|
||||
async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) {
|
||||
if (!outputPath) {
|
||||
throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt');
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
||||
trackSelection: encodePlan.trackSelection || null,
|
||||
titleId: encodePlan.handBrakeTitleId || null,
|
||||
mediaProfile: null,
|
||||
settingsMap: settings,
|
||||
userPreset: encodePlan.userPreset || null
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:video:start', {
|
||||
jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null
|
||||
});
|
||||
ctx.emitProgress(0, 'Converter: Video Encoding läuft …');
|
||||
|
||||
const runInfo = await _spawnAndWait({
|
||||
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseHandBrakeProgress,
|
||||
onProgress: (pct, statusText, eta) => ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
statusText || `Encoding ${pct}%`,
|
||||
eta ?? null
|
||||
),
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'HANDBRAKE',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:video:done', {
|
||||
jobId: job?.id, outputPath, exitCode: runInfo.exitCode
|
||||
});
|
||||
|
||||
ctx.extra.encodeRunInfo = runInfo;
|
||||
}
|
||||
|
||||
// ── Audio-Encode (ffmpeg) ────────────────────────────────────────────────
|
||||
|
||||
async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) {
|
||||
const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
|
||||
const formatOptions = encodePlan.audioFormatOptions || {};
|
||||
const isFolder = Boolean(encodePlan.isFolder);
|
||||
const isSharedAudio = Boolean(encodePlan.isSharedAudio)
|
||||
|| (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder);
|
||||
|
||||
// Metadata from user config
|
||||
const planMetadata = encodePlan.metadata || {};
|
||||
const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : [];
|
||||
|
||||
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
|
||||
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
|
||||
}
|
||||
|
||||
/** Build per-track metadata opts merged with album-level metadata */
|
||||
function trackMetaOpts(position, defaultBaseName) {
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
return {
|
||||
...formatOptions,
|
||||
trackTitle: trackMeta.title || defaultBaseName || null,
|
||||
trackArtist: trackMeta.artist || planMetadata.albumArtist || null,
|
||||
albumTitle: planMetadata.albumTitle || null,
|
||||
albumArtist: planMetadata.albumArtist || null,
|
||||
albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null,
|
||||
trackNumber: position
|
||||
};
|
||||
}
|
||||
|
||||
/** Build output filename with optional track number + title */
|
||||
function buildOutputFileName(position, trackMeta, defaultBaseName) {
|
||||
const numStr = String(position).padStart(2, '0');
|
||||
const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null;
|
||||
const templateRaw = String(encodePlan.audioTrackTemplate || '').trim();
|
||||
if (templateRaw) {
|
||||
const rendered = renderTemplate(templateRaw, {
|
||||
trackNr: numStr,
|
||||
trackNumber: String(position),
|
||||
title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`,
|
||||
artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown',
|
||||
album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown',
|
||||
year: planMetadata.albumYear || 'unknown'
|
||||
});
|
||||
const sanitized = sanitizeFileName(rendered);
|
||||
if (sanitized) {
|
||||
return `${sanitized}.${outputFormat}`;
|
||||
}
|
||||
}
|
||||
if (title) return `${numStr} - ${title}.${outputFormat}`;
|
||||
return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`;
|
||||
}
|
||||
|
||||
if (isSharedAudio) {
|
||||
// Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job)
|
||||
const inputPaths = encodePlan.inputPaths || [];
|
||||
if (inputPaths.length === 0) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job');
|
||||
}
|
||||
if (!outputDir) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job');
|
||||
}
|
||||
ensureDir(outputDir);
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:start', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat
|
||||
});
|
||||
|
||||
for (let i = 0; i < inputPaths.length; i++) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const inputFile = inputPaths[i];
|
||||
const fileName = path.basename(inputFile);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / inputPaths.length) * 100),
|
||||
`Audio: ${position}/${inputPaths.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:done', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length };
|
||||
} else if (isFolder) {
|
||||
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
|
||||
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
|
||||
|
||||
if (!outputDir) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job');
|
||||
}
|
||||
ensureDir(outputDir);
|
||||
|
||||
ctx.logger.info('converter:encode:audio:folder:start', {
|
||||
jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length
|
||||
});
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fileName = audioFiles[i];
|
||||
const inputFile = path.join(inputPath, fileName);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / audioFiles.length) * 100),
|
||||
`Audio: ${position}/${audioFiles.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('converter:encode:audio:folder:done', {
|
||||
jobId: job?.id, fileCount: audioFiles.length, outputDir
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length };
|
||||
} else {
|
||||
// Einzelne Datei
|
||||
if (!outputPath) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt');
|
||||
}
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
ctx.logger.info('converter:encode:audio:file:start', {
|
||||
jobId: job?.id, inputPath, outputPath, format: outputFormat
|
||||
});
|
||||
ctx.emitProgress(0, `Audio: Encoding läuft …`);
|
||||
|
||||
const baseName = path.basename(inputPath, path.extname(inputPath));
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputPath, outputPath, outputFormat,
|
||||
trackMetaOpts(1, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:audio:file:done', {
|
||||
jobId: job?.id, outputPath
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputPath, format: outputFormat };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ───────────────────────────────────────────────────────
|
||||
|
||||
async _runCaptured(cmd, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ exitCode: code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
_parseHandBrakeScan(rawOutput) {
|
||||
try {
|
||||
const jsonStart = rawOutput.indexOf('{');
|
||||
if (jsonStart < 0) return null;
|
||||
return JSON.parse(rawOutput.slice(jsonStart));
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) {
|
||||
const args = [
|
||||
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath
|
||||
];
|
||||
const result = await this._runCaptured(ffprobeCmd, args, options);
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({
|
||||
cmd,
|
||||
args,
|
||||
jobId,
|
||||
progressParser,
|
||||
onProgress,
|
||||
appendLogLine,
|
||||
logSource = 'PROCESS',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context
|
||||
}) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const handleLine = (stream, line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.detail || null, progress.eta ?? null);
|
||||
}
|
||||
}
|
||||
if (typeof appendLogLine === 'function') {
|
||||
try {
|
||||
appendLogLine(logSource, line, stream);
|
||||
} catch (_error) {
|
||||
// ignore log append errors in process stream
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: (line) => handleLine('stdout', line),
|
||||
onStderrLine: (line) => handleLine('stderr', line),
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handle.promise;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConverterPlugin };
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||
|
||||
/**
|
||||
* Source-Plugin für DVD-Discs.
|
||||
*
|
||||
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||
* Überschreibt nur die Identifikations-Properties und detect().
|
||||
*
|
||||
* Erkennungsmerkmale:
|
||||
* - mediaProfile === 'dvd'
|
||||
* - Dateisystem: UDF (1.02) oder ISO9660
|
||||
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
|
||||
*/
|
||||
class DVDPlugin extends VideoDiscPlugin {
|
||||
get id() {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD';
|
||||
}
|
||||
|
||||
get mediaProfile() {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
|
||||
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
|
||||
*/
|
||||
get priority() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt DVD-Discs.
|
||||
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||
*
|
||||
* @param {object} discInfo
|
||||
* @param {string} [discInfo.mediaProfile] - 'dvd'
|
||||
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'dvd') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
|
||||
if (hasBlurayMarker) {
|
||||
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
|
||||
}
|
||||
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDPlugin };
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const dvdSeriesScanService = require('../services/dvdSeriesScanService');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
|
||||
/**
|
||||
* Companion-Plugin für Serien-DVDs.
|
||||
*
|
||||
* Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem
|
||||
* späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine
|
||||
* serientypische Titelstruktur vermuten lässt.
|
||||
*/
|
||||
class DVDSeriesPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'dvd_series';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD Series';
|
||||
}
|
||||
|
||||
detect() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async analyze(_devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
|
||||
const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]);
|
||||
|
||||
const scanText = String(ctx.extra?.scanText || '').trim();
|
||||
if (!scanText) {
|
||||
return {
|
||||
parsedScan: null,
|
||||
seriesAnalysis: null,
|
||||
seriesLookupHint: fallbackSeriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {});
|
||||
const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: result?.parsed?.discTitle || '',
|
||||
source: 'handbrake_disc_title'
|
||||
},
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]) || fallbackSeriesLookupHint;
|
||||
return {
|
||||
parsedScan: result.parsed,
|
||||
seriesAnalysis: result.analysis,
|
||||
seriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
async review(_job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async rip() {
|
||||
throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async encode() {
|
||||
throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
return tmdbService.searchSeries(query, options);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
return tmdbService.searchSeriesWithSeason(query, seasonNumber, options);
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'tmdb_api_read_access_token',
|
||||
category: 'Metadaten',
|
||||
label: 'TMDb Read Access Token',
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).',
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 401
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDSeriesPlugin };
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Abstrakte Basisklasse für Source-Plugins.
|
||||
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
|
||||
*
|
||||
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
|
||||
*
|
||||
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
|
||||
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
|
||||
*/
|
||||
class SourcePlugin {
|
||||
/**
|
||||
* Eindeutiger Bezeichner des Plugins.
|
||||
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
throw new Error(`${this.constructor.name}: id nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename des Plugins (für Logs und UI).
|
||||
* @returns {string}
|
||||
*/
|
||||
get name() {
|
||||
throw new Error(`${this.constructor.name}: name nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
|
||||
* Standard: 0
|
||||
* @returns {number}
|
||||
*/
|
||||
get priority() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
|
||||
* Wird vom PluginRegistry.findPlugin() aufgerufen.
|
||||
*
|
||||
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||
* @param {string} [discInfo.devicePath]
|
||||
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
|
||||
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
|
||||
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
|
||||
*
|
||||
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
|
||||
* @param {object} job - Bestehender Job-Record aus der DB
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} Analyse-Ergebnis
|
||||
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt das Medium in den RAW-Ordner.
|
||||
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async rip(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
|
||||
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
|
||||
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object|null>} Review-Daten oder null
|
||||
*/
|
||||
async review(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
|
||||
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async encode(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: kein Finalize-Schritt notwendig
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
|
||||
* Wird vom Orchestrator bei cancel() aufgerufen.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
|
||||
* Wird vom Orchestrator vor retry() aufgerufen.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings-Schema-Einträge.
|
||||
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
|
||||
* und können zur Laufzeit in die DB eingetragen werden.
|
||||
*
|
||||
* Format je Eintrag:
|
||||
* { key, category, label, type, required, description,
|
||||
* default_value, options_json, validation_json, order_index }
|
||||
*
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SourcePlugin };
|
||||
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
|
||||
function normalizeExecutionStage(value) {
|
||||
const stage = String(value || '').trim().toLowerCase();
|
||||
return stage || 'unknown';
|
||||
}
|
||||
|
||||
function normalizePluginExecutionState(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
|
||||
const pluginName = String(value.pluginName || '').trim() || pluginId;
|
||||
const pluginFile = String(value.pluginFile || '').trim() || null;
|
||||
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
|
||||
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
|
||||
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
|
||||
const rawLastStage = String(value.lastStage || '').trim();
|
||||
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
|
||||
const jobIdRaw = Number(value.jobId);
|
||||
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
|
||||
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
|
||||
? value.byStage
|
||||
: {};
|
||||
const byStage = {};
|
||||
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
|
||||
const normalizedStage = normalizeExecutionStage(stageKey);
|
||||
const count = Number(stageMeta?.count);
|
||||
byStage[normalizedStage] = {
|
||||
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
|
||||
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
|
||||
};
|
||||
}
|
||||
const explicitStages = Array.isArray(value.stages)
|
||||
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
|
||||
: [];
|
||||
const stages = Array.from(new Set([
|
||||
...explicitStages,
|
||||
...Object.keys(byStage),
|
||||
...(lastStage ? [lastStage] : [])
|
||||
]));
|
||||
|
||||
return {
|
||||
markerSource,
|
||||
pluginId,
|
||||
pluginName,
|
||||
pluginFile,
|
||||
jobId,
|
||||
firstMarkedAt,
|
||||
lastMarkedAt,
|
||||
lastStage,
|
||||
stages,
|
||||
byStage
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginExecutionState(existingState, marker) {
|
||||
const existing = normalizePluginExecutionState(existingState);
|
||||
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
|
||||
if (!normalizedMarker) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const stage = normalizeExecutionStage(normalizedMarker.stage);
|
||||
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
|
||||
const previousStageMeta = existing?.byStage?.[stage] || null;
|
||||
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
|
||||
const nextByStage = {
|
||||
...(existing?.byStage || {}),
|
||||
[stage]: {
|
||||
count: nextStageCount,
|
||||
lastMarkedAt: markedAt
|
||||
}
|
||||
};
|
||||
const nextStages = Array.from(new Set([
|
||||
...(Array.isArray(existing?.stages) ? existing.stages : []),
|
||||
stage
|
||||
]));
|
||||
|
||||
return {
|
||||
markerSource: 'plugin-file',
|
||||
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|
||||
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|
||||
|| 'unknown',
|
||||
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
|
||||
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
|
||||
? Math.trunc(Number(normalizedMarker.jobId))
|
||||
: (existing?.jobId || null),
|
||||
firstMarkedAt: existing?.firstMarkedAt || markedAt,
|
||||
lastMarkedAt: markedAt,
|
||||
lastStage: stage,
|
||||
stages: nextStages,
|
||||
byStage: nextByStage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
|
||||
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
|
||||
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
|
||||
*
|
||||
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
|
||||
*/
|
||||
class PluginContext {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
|
||||
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
|
||||
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
|
||||
* @param {object} options.websocket - websocketService (broadcast() etc.)
|
||||
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
|
||||
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
|
||||
* @param {Function} options.emitState - (newState: string, context?: object) => void
|
||||
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
|
||||
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
|
||||
*/
|
||||
constructor({
|
||||
settings,
|
||||
db,
|
||||
logger,
|
||||
websocket,
|
||||
processRunner,
|
||||
emitProgress,
|
||||
emitState,
|
||||
onPluginExecution,
|
||||
extra = {}
|
||||
} = {}) {
|
||||
this.settings = settings;
|
||||
this.db = db;
|
||||
this.logger = logger;
|
||||
this.websocket = websocket;
|
||||
this.processRunner = processRunner;
|
||||
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
|
||||
this.emitState = typeof emitState === 'function' ? emitState : () => {};
|
||||
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
|
||||
this.extra = extra;
|
||||
this.pluginExecution = null;
|
||||
}
|
||||
|
||||
markExecution(stage, payload = {}) {
|
||||
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
|
||||
const marker = {
|
||||
markerSource: 'plugin-file',
|
||||
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||
pluginName: String(payload?.pluginName || '').trim()
|
||||
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|
||||
|| 'unknown',
|
||||
pluginFile: String(payload?.pluginFile || '').trim() || null,
|
||||
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
|
||||
stage: normalizeExecutionStage(stage),
|
||||
markedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
|
||||
|
||||
try {
|
||||
this.onPluginExecution(marker, this.getPluginExecution());
|
||||
} catch (error) {
|
||||
if (this.logger && typeof this.logger.warn === 'function') {
|
||||
this.logger.warn('plugin:execution:callback-failed', {
|
||||
pluginId: marker.pluginId,
|
||||
pluginFile: marker.pluginFile,
|
||||
stage: marker.stage,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getPluginExecution();
|
||||
}
|
||||
|
||||
getPluginExecution() {
|
||||
const normalized = normalizePluginExecutionState(this.pluginExecution);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
stages: [...normalized.stages],
|
||||
byStage: Object.fromEntries(
|
||||
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
|
||||
stage,
|
||||
{
|
||||
count: Number(meta?.count || 1),
|
||||
lastMarkedAt: meta?.lastMarkedAt || null
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginContext };
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
|
||||
|
||||
/**
|
||||
* Registry für alle Source-Plugins.
|
||||
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
|
||||
*
|
||||
* Verwendung:
|
||||
* const { registry } = require('./PluginRegistry');
|
||||
* registry.register(new BluRayPlugin());
|
||||
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
|
||||
*/
|
||||
class PluginRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, SourcePlugin>} */
|
||||
this._plugins = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert ein Plugin.
|
||||
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
|
||||
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
|
||||
*
|
||||
* @param {SourcePlugin} plugin
|
||||
*/
|
||||
register(plugin) {
|
||||
if (!(plugin instanceof SourcePlugin)) {
|
||||
throw new Error(
|
||||
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
|
||||
);
|
||||
}
|
||||
const id = plugin.id;
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
|
||||
}
|
||||
if (this._plugins.has(id)) {
|
||||
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
|
||||
}
|
||||
this._plugins.set(id, plugin);
|
||||
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet das passende Plugin für eine erkannte Disc.
|
||||
*
|
||||
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
|
||||
* dessen detect() true zurückgibt, wird verwendet.
|
||||
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
|
||||
*
|
||||
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
|
||||
*/
|
||||
findPlugin(discInfo) {
|
||||
const sorted = [...this._plugins.values()]
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
for (const plugin of sorted) {
|
||||
try {
|
||||
if (plugin.detect(discInfo)) {
|
||||
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
|
||||
return plugin;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('registry:detect-error', {
|
||||
id: plugin.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt ein Plugin anhand seiner ID zurück.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {SourcePlugin|null}
|
||||
*/
|
||||
getPlugin(id) {
|
||||
return this._plugins.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
|
||||
*
|
||||
* @returns {SourcePlugin[]}
|
||||
*/
|
||||
getAllPlugins() {
|
||||
return [...this._plugins.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregiert die Settings-Schemata aller registrierten Plugins.
|
||||
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
|
||||
*
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getSettingsSchemas() {
|
||||
const schemas = [];
|
||||
for (const plugin of this._plugins.values()) {
|
||||
try {
|
||||
const pluginSchemas = plugin.getSettingsSchema();
|
||||
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
|
||||
schemas.push(...pluginSchemas);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('registry:schema-error', {
|
||||
id: plugin.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der registrierten Plugins zurück.
|
||||
* @returns {number}
|
||||
*/
|
||||
get size() {
|
||||
return this._plugins.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
|
||||
const registry = new PluginRegistry();
|
||||
|
||||
module.exports = { PluginRegistry, registry };
|
||||
@@ -0,0 +1,544 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
|
||||
/**
|
||||
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
|
||||
*
|
||||
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
|
||||
* analyze() → TMDb-Suche + Disc-Metadaten vorbereiten
|
||||
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
|
||||
* rip() → makemkvcon backup/mkv
|
||||
* encode() → HandBrakeCLI
|
||||
*
|
||||
* Subklassen müssen implementieren:
|
||||
* get id() → 'bluray' | 'dvd'
|
||||
* get name() → Anzeigename
|
||||
* get mediaProfile() → 'bluray' | 'dvd'
|
||||
* detect(discInfo) → boolean
|
||||
*
|
||||
* ctx.extra-Felder für rip():
|
||||
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
|
||||
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
|
||||
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
|
||||
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
|
||||
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* ctx.extra-Felder für encode():
|
||||
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
|
||||
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
|
||||
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* ctx.extra-Felder für review():
|
||||
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
|
||||
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
|
||||
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
|
||||
*/
|
||||
class VideoDiscPlugin extends SourcePlugin {
|
||||
/**
|
||||
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
|
||||
* Muss von Subklassen implementiert werden.
|
||||
* @returns {'bluray'|'dvd'}
|
||||
*/
|
||||
get mediaProfile() {
|
||||
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine TMDb-Suche für den erkannten Disc-Titel durch.
|
||||
* Gibt { detectedTitle, metadataCandidates } zurück.
|
||||
*
|
||||
* @param {string} devicePath
|
||||
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>}
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const discInfo = ctx.extra?.discInfo || {};
|
||||
const detectedTitle = String(
|
||||
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
|
||||
).trim();
|
||||
|
||||
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
|
||||
|
||||
const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => {
|
||||
ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, {
|
||||
jobId: job?.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return [];
|
||||
});
|
||||
|
||||
ctx.logger.info(`${this.id}:analyze:done`, {
|
||||
jobId: job?.id,
|
||||
detectedTitle,
|
||||
metadataCandidates: metadataCandidates.length
|
||||
});
|
||||
|
||||
return { detectedTitle, metadataCandidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
|
||||
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
|
||||
*
|
||||
* ctx.extra.reviewMode:
|
||||
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
|
||||
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
|
||||
*/
|
||||
async review(job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
|
||||
const deviceInfo = ctx.extra?.deviceInfo || null;
|
||||
const rawJobDir = ctx.extra?.rawJobDir || null;
|
||||
|
||||
ctx.logger.info(`${this.id}:review:start`, {
|
||||
jobId: job?.id,
|
||||
reviewMode,
|
||||
deviceInfo: deviceInfo?.path || null,
|
||||
rawJobDir: rawJobDir || null
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
|
||||
let scanConfig;
|
||||
if (reviewMode === 'rip' && rawJobDir) {
|
||||
// Post-Rip scan: scan from the ripped RAW folder
|
||||
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings
|
||||
});
|
||||
} else {
|
||||
// Pre-Rip scan: scan directly from disc device
|
||||
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:review:scan-command`, {
|
||||
jobId: job?.id,
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args
|
||||
});
|
||||
|
||||
const scanLines = [];
|
||||
const scanAnalysisLines = [];
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK';
|
||||
const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN';
|
||||
const reviewSilent = Boolean(ctx.extra?.reviewSilent);
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
try {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: reviewStage,
|
||||
source: reviewSource,
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
collectLines: scanLines,
|
||||
// Keep JSON parsing stable by collecting only stdout in scanLines.
|
||||
// Stderr is still mirrored into scanAnalysisLines for heuristics.
|
||||
collectStderrLines: false,
|
||||
onStdoutLine: (line) => scanAnalysisLines.push(line),
|
||||
onStderrLine: (line) => scanAnalysisLines.push(line),
|
||||
silent: reviewSilent
|
||||
});
|
||||
} catch (cmdError) {
|
||||
// runCommand collected stdout lines via callbacks before throwing.
|
||||
// Recover the runInfo from the error so the caller can still parse
|
||||
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
|
||||
runInfo = cmdError?.runInfo || {
|
||||
status: 'ERROR',
|
||||
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
|
||||
errorMessage: cmdError?.message || String(cmdError)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
runInfo = await _runCommandCaptured({
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
onStdoutLine: (line) => {
|
||||
scanLines.push(line);
|
||||
scanAnalysisLines.push(line);
|
||||
},
|
||||
onStderrLine: (line) => scanAnalysisLines.push(line),
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:review:scan-done`, {
|
||||
jobId: job?.id,
|
||||
exitCode: runInfo.exitCode,
|
||||
lineCount: scanAnalysisLines.length
|
||||
});
|
||||
|
||||
// Return raw scan data — the Orchestrator (or legacy pipelineService)
|
||||
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
|
||||
return {
|
||||
scanLines,
|
||||
scanAnalysisLines,
|
||||
runInfo,
|
||||
reviewMode,
|
||||
mediaProfile: this.mediaProfile,
|
||||
sourceArg: scanConfig.sourceArg || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt die Disc mit makemkvcon in den rawJobDir.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
rawJobDir,
|
||||
deviceInfo,
|
||||
selectedTitleId = null,
|
||||
backupOutputBase = null,
|
||||
disableMinLengthFilter = false,
|
||||
sourceArgOverride = null,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!rawJobDir) {
|
||||
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
|
||||
}
|
||||
if (!deviceInfo) {
|
||||
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
|
||||
}
|
||||
|
||||
ensureDir(rawJobDir);
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
|
||||
selectedTitleId,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
backupOutputBase,
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride
|
||||
});
|
||||
|
||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
|
||||
|
||||
ctx.logger.info(`${this.id}:rip:start`, {
|
||||
jobId: job?.id,
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
selectedTitleId,
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride: sourceArgOverride || null
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, ripMode === 'backup'
|
||||
? `${this.name}: Backup läuft …`
|
||||
: `${this.name}: Ripping läuft …`
|
||||
);
|
||||
|
||||
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
|
||||
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
|
||||
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function'
|
||||
? ctx.extra.makeMkvLineFilter
|
||||
: null;
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: 'RIPPING',
|
||||
source: 'MAKEMKV_RIP',
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
parser: parseMakeMkvProgress,
|
||||
lineFilter: makeMkvLineFilter
|
||||
});
|
||||
} else {
|
||||
runInfo = await _spawnAndWait({
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
cwd: rawJobDir,
|
||||
jobId: job?.id,
|
||||
progressParser: parseMakeMkvProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:rip:done`, {
|
||||
jobId: job?.id,
|
||||
rawJobDir,
|
||||
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
|
||||
});
|
||||
|
||||
ctx.extra.ripRunInfo = runInfo;
|
||||
return runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die gerippten Dateien mit HandBrakeCLI.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
encodePlan = {},
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
||||
trackSelection: encodePlan.trackSelection || null,
|
||||
titleId: encodePlan.handBrakeTitleId || null,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
userPreset: encodePlan.userPreset || null
|
||||
});
|
||||
|
||||
ctx.logger.info(`${this.id}:encode:start`, {
|
||||
jobId: job?.id,
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
titleId: encodePlan.handBrakeTitleId || null
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
|
||||
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE';
|
||||
const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
|
||||
const encodeParser = typeof ctx.extra?.progressParser === 'function'
|
||||
? ctx.extra.progressParser
|
||||
: parseHandBrakeProgress;
|
||||
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: encodeStage,
|
||||
source: encodeSource,
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
parser: encodeParser
|
||||
});
|
||||
} else {
|
||||
runInfo = await _spawnAndWait({
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseHandBrakeProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:encode:done`, {
|
||||
jobId: job?.id,
|
||||
outputPath,
|
||||
exitCode: runInfo.exitCode
|
||||
});
|
||||
|
||||
ctx.extra.encodeRunInfo = runInfo;
|
||||
return runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
const profile = this.mediaProfile;
|
||||
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||
const orderBase = profile === 'bluray' ? 200 : 220;
|
||||
|
||||
return [
|
||||
{
|
||||
key: `handbrake_preset_${profile}`,
|
||||
category: 'Tools',
|
||||
label: `HandBrake Preset (${label})`,
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: orderBase
|
||||
},
|
||||
{
|
||||
key: `output_extension_${profile}`,
|
||||
category: 'Tools',
|
||||
label: `Ausgabe-Format (${label})`,
|
||||
type: 'select',
|
||||
required: 1,
|
||||
description: `Dateiendung des encodierten ${label}-Outputs.`,
|
||||
default_value: 'mkv',
|
||||
options_json: '["mkv","mp4"]',
|
||||
validation_json: '{}',
|
||||
order_index: orderBase + 1
|
||||
},
|
||||
{
|
||||
key: `output_template_${profile}`,
|
||||
category: 'Pfade',
|
||||
label: `Output Template (${label})`,
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
|
||||
default_value: '{title} ({year})/{title} ({year})',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 700 + (profile === 'bluray' ? 0 : 5)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||
|
||||
function _assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
|
||||
const lines = [];
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStdoutLine === 'function') {
|
||||
onStdoutLine(line);
|
||||
}
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStderrLine === 'function') {
|
||||
onStderrLine(line);
|
||||
}
|
||||
},
|
||||
context: context || {}
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.promise;
|
||||
return { exitCode: 0, lines };
|
||||
} catch (error) {
|
||||
return {
|
||||
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
||||
lines,
|
||||
error: error?.message || String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.statusText || null);
|
||||
}
|
||||
}
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.statusText || null);
|
||||
}
|
||||
}
|
||||
},
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
return await handle.promise;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VideoDiscPlugin };
|
||||
@@ -0,0 +1,412 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const converterScanService = require('../services/converterScanService');
|
||||
const historyService = require('../services/historyService');
|
||||
const logger = require('../services/logger').child('CONVERTER_ROUTE');
|
||||
const { tempDir } = require('../config');
|
||||
|
||||
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
|
||||
function resolveTarget(rawDir, input) {
|
||||
const rel = converterScanService.normalizeRelPath(input != null ? String(input) : '');
|
||||
if (rel === null) return { error: 'Ungültiger Pfad' };
|
||||
const absolute = path.join(rawDir, rel || '.');
|
||||
return { rel: rel || '', absolute };
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads');
|
||||
fs.mkdirSync(converterUploadDir, { recursive: true });
|
||||
|
||||
const converterUpload = multer({
|
||||
dest: converterUploadDir
|
||||
});
|
||||
|
||||
// ── Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/converter/tree
|
||||
* Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB).
|
||||
*/
|
||||
router.get(
|
||||
'/tree',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:tree');
|
||||
const result = await converterScanService.getTree();
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/converter/browse?parent=relPath
|
||||
* File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht).
|
||||
*/
|
||||
router.get(
|
||||
'/browse',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parent = req.query.parent ? String(req.query.parent).trim() : null;
|
||||
logger.debug('get:browse', { parent });
|
||||
const entries = await converterScanService.getEntries(parent);
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
res.json({ entries, rawDir });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/scan
|
||||
* Manuellen Scan des converter_raw_dir auslösen.
|
||||
*/
|
||||
router.post(
|
||||
'/scan',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:scan');
|
||||
const result = await converterScanService.scan();
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
// ── Jobs erstellen ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/converter/create-jobs
|
||||
* Body: { entries: [{ relPath, converterMediaType }] }
|
||||
* Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl).
|
||||
*/
|
||||
router.post(
|
||||
'/create-jobs',
|
||||
asyncHandler(async (req, res) => {
|
||||
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
|
||||
if (entries.length === 0) {
|
||||
const error = new Error('Keine Einträge ausgewählt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:create-jobs', { count: entries.length });
|
||||
|
||||
const jobs = [];
|
||||
for (const entry of entries) {
|
||||
const relPath = String(entry?.relPath || '').trim();
|
||||
if (!relPath) continue;
|
||||
const job = await pipelineService.createFileJob({
|
||||
kind: 'converter_entry',
|
||||
relPath,
|
||||
options: {
|
||||
converterMediaType: entry?.converterMediaType || null
|
||||
}
|
||||
});
|
||||
jobs.push(job);
|
||||
}
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/upload
|
||||
* Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen).
|
||||
* Gibt { folders: [{folderRelPath, fileCount}] } zurück.
|
||||
*/
|
||||
router.post(
|
||||
'/upload',
|
||||
converterUpload.array('files', 50),
|
||||
asyncHandler(async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
if (files.length === 0) {
|
||||
const error = new Error('Keine Dateien hochgeladen.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null;
|
||||
logger.info('post:upload', {
|
||||
fileCount: files.length,
|
||||
folderName,
|
||||
files: files.map((f) => ({ name: f.originalname, size: f.size }))
|
||||
});
|
||||
const result = await pipelineService.uploadConverterFiles(files, { folderName });
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/from-selection
|
||||
* Body: { relPaths: string[], audioMode: 'individual'|'shared' }
|
||||
* Erstellt Jobs aus im File-Explorer ausgewählten Dateien.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/from-selection',
|
||||
asyncHandler(async (req, res) => {
|
||||
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
|
||||
const audioMode = String(req.body?.audioMode || 'individual');
|
||||
if (relPaths.length === 0) {
|
||||
const error = new Error('Keine Dateien ausgewählt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode });
|
||||
const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode);
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/assign-files
|
||||
* Body: { relPaths: string[] }
|
||||
* Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/assign-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (relPaths.length === 0) {
|
||||
return res.status(400).json({ detail: 'Keine Dateien übergeben.' });
|
||||
}
|
||||
logger.info('post:jobs:assign-files', { jobId, count: relPaths.length });
|
||||
const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-file
|
||||
* Body: { relPath: string }
|
||||
* Entfernt eine Datei aus einem bestehenden Converter-Job.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-file',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!relPath) {
|
||||
return res.status(400).json({ detail: 'relPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-file', { jobId, relPath });
|
||||
const result = await pipelineService.removeConverterFileFromJob(jobId, relPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-input
|
||||
* Body: { inputPath: string }
|
||||
* Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-input',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const inputPath = String(req.body?.inputPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!inputPath) {
|
||||
return res.status(400).json({ detail: 'inputPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-input', { jobId, inputPath });
|
||||
const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/config
|
||||
* Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand)
|
||||
* Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/config',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
logger.debug('post:jobs:config', { jobId });
|
||||
const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* DELETE /api/converter/files
|
||||
* Body: { relPath }
|
||||
* Datei oder Ordner löschen (fs.rmSync, ohne DB).
|
||||
*/
|
||||
router.delete(
|
||||
'/files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' });
|
||||
const target = resolveTarget(rawDir, relPath);
|
||||
if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' });
|
||||
logger.info('delete:files', { relPath });
|
||||
fs.rmSync(target.absolute, { recursive: true, force: true });
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/rename
|
||||
* Body: { relPath, newName }
|
||||
* Umbenennen (fs.renameSync, ohne DB).
|
||||
*/
|
||||
router.post(
|
||||
'/files/rename',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
const newName = String(req.body?.newName || '').trim();
|
||||
if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' });
|
||||
if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
|
||||
const source = resolveTarget(rawDir, relPath);
|
||||
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
|
||||
const parentAbs = path.dirname(source.absolute);
|
||||
const destAbs = path.join(parentAbs, newName);
|
||||
logger.info('post:files:rename', { relPath, newName });
|
||||
fs.renameSync(source.absolute, destAbs);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/move
|
||||
* Body: { relPath, targetParentRelPath }
|
||||
* Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root.
|
||||
*/
|
||||
router.post(
|
||||
'/files/move',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' });
|
||||
const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : '';
|
||||
const source = resolveTarget(rawDir, relPath);
|
||||
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
|
||||
const targetParent = resolveTarget(rawDir, targetParentRelPath);
|
||||
if (targetParent.error) return res.status(400).json({ detail: targetParent.error });
|
||||
const name = path.basename(source.absolute);
|
||||
const destAbs = path.join(targetParent.absolute, name);
|
||||
logger.info('post:files:move', { relPath, targetParentRelPath });
|
||||
fs.renameSync(source.absolute, destAbs);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/folder
|
||||
* Body: { parentRelPath, name }
|
||||
* Neuen Ordner anlegen (fs.mkdirSync, ohne DB).
|
||||
*/
|
||||
router.post(
|
||||
'/files/folder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : '';
|
||||
const name = String(req.body?.name || '').trim();
|
||||
if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
|
||||
const parent = resolveTarget(rawDir, parentRelPath);
|
||||
if (parent.error) return res.status(400).json({ detail: parent.error });
|
||||
logger.info('post:files:folder', { parentRelPath, name });
|
||||
fs.mkdirSync(path.join(parent.absolute, name), { recursive: true });
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
// ── Job-Status ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/converter/jobs
|
||||
* Alle Converter-Jobs zurückgeben.
|
||||
*/
|
||||
router.get(
|
||||
'/jobs',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobs = await pipelineService.getConverterJobs();
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/converter/jobs/:jobId
|
||||
* Einzelnen Converter-Job abrufen.
|
||||
*/
|
||||
router.get(
|
||||
'/jobs/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const job = await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/start
|
||||
* Job mit finaler Konfiguration starten.
|
||||
* Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions }
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/start',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const config = req.body || {};
|
||||
logger.info('post:jobs:start', {
|
||||
jobId,
|
||||
converterMediaType: config.converterMediaType,
|
||||
outputFormat: config.outputFormat
|
||||
});
|
||||
const result = await pipelineService.startConverterJob(jobId, config);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/cancel
|
||||
* Job abbrechen.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/cancel',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:jobs:cancel', { jobId });
|
||||
const result = await pipelineService.cancel(jobId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/converter/jobs/:jobId
|
||||
* Job aus der DB löschen.
|
||||
*/
|
||||
router.delete(
|
||||
'/jobs/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('delete:jobs', { jobId });
|
||||
await historyService.deleteJob(jobId, 'none', { includeRelated: false });
|
||||
await converterScanService.clearAssignmentsForJob(jobId);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -30,12 +30,14 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const target = String(req.body?.target || 'raw').trim();
|
||||
const outputPath = String(req.body?.outputPath || '').trim() || null;
|
||||
logger.info('post:downloads:history', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
target
|
||||
target,
|
||||
outputPath
|
||||
});
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target);
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath });
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
...result,
|
||||
summary: downloadService.getSummary()
|
||||
|
||||
@@ -6,6 +6,21 @@ const logger = require('../services/logger').child('HISTORY_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function parseSelectedJobIds(value, options = {}) {
|
||||
const { hasExplicitValue = true } = options;
|
||||
if (!hasExplicitValue) {
|
||||
return null;
|
||||
}
|
||||
const sourceValues = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '')
|
||||
.split(',');
|
||||
return sourceValues
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -18,13 +33,15 @@ router.get(
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||
const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase());
|
||||
logger.info('get:jobs', {
|
||||
reqId: req.reqId,
|
||||
status: req.query.status,
|
||||
statuses: statuses.length > 0 ? statuses : null,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
lite
|
||||
lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
const jobs = await historyService.getJobs({
|
||||
@@ -32,7 +49,8 @@ router.get(
|
||||
statuses,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
includeFsChecks: !lite
|
||||
includeFsChecks: !lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
res.json({ jobs });
|
||||
@@ -48,23 +66,95 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb-migration/pending',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsedLimit = Number(req.query.limit);
|
||||
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.trunc(parsedLimit)
|
||||
: 500;
|
||||
logger.info('get:tmdb-migration:pending', {
|
||||
reqId: req.reqId,
|
||||
limit
|
||||
});
|
||||
const jobs = await historyService.getTmdbMigrationPendingJobs({
|
||||
limit,
|
||||
includeFsChecks: false
|
||||
});
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/tmdb-migration',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const migrated = ['1', 'true', 'yes', 'on'].includes(
|
||||
String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase()
|
||||
);
|
||||
logger.info('post:tmdb-migration:flag', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
migrated
|
||||
});
|
||||
const job = await historyService.setTmdbMigrationFlag(id, migrated);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/orphan-raw/import',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawPath = String(req.body?.rawPath || '').trim();
|
||||
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
|
||||
const job = await historyService.importOrphanRawFolder(rawPath);
|
||||
const uiReset = await pipelineService.resetFrontendState('history_orphan_import');
|
||||
res.json({ job, uiReset });
|
||||
const importedJob = await historyService.importOrphanRawFolder(rawPath);
|
||||
const importedJobId = Number(importedJob?.id || 0);
|
||||
let activation = null;
|
||||
let activationError = null;
|
||||
if (Number.isFinite(importedJobId) && importedJobId > 0) {
|
||||
try {
|
||||
activation = await pipelineService.analyzeRawImportJob(importedJobId, {
|
||||
rawPath: importedJob?.raw_path || rawPath
|
||||
});
|
||||
} catch (error) {
|
||||
activationError = error?.message || String(error);
|
||||
logger.warn('post:orphan-raw:import:activation-failed', {
|
||||
reqId: req.reqId,
|
||||
jobId: importedJobId,
|
||||
rawPath,
|
||||
error: activationError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedJob = importedJobId > 0
|
||||
? await historyService.getJobById(importedJobId)
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
job: refreshedJob || importedJob,
|
||||
activation,
|
||||
...(activationError ? { activationError } : {})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/omdb/assign',
|
||||
'/orphan-raw/delete',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawPath = String(req.body?.rawPath || '').trim();
|
||||
logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath });
|
||||
const result = await historyService.deleteOrphanRawFolder(rawPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/metadata/assign',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('post:job:omdb:assign', {
|
||||
logger.info('post:job:metadata:assign', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
imdbId: payload?.imdbId || null,
|
||||
@@ -72,11 +162,11 @@ router.post(
|
||||
hasYear: Boolean(payload?.year)
|
||||
});
|
||||
|
||||
const job = await historyService.assignOmdbMetadata(id, payload);
|
||||
const job = await historyService.assignMetadata(id, payload);
|
||||
|
||||
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||
pipelineService.renameJobFolders(id).catch((err) => {
|
||||
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
|
||||
logger.warn('post:job:metadata:assign:rename-failed', { id, error: err.message });
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
@@ -97,7 +187,15 @@ router.post(
|
||||
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
|
||||
});
|
||||
|
||||
const job = await historyService.assignCdMetadata(id, payload);
|
||||
const job = await pipelineService.selectCdMetadata({
|
||||
jobId: id,
|
||||
title: payload?.title,
|
||||
artist: payload?.artist,
|
||||
year: payload?.year,
|
||||
mbId: payload?.mbId,
|
||||
coverUrl: payload?.coverUrl,
|
||||
tracks: payload?.tracks
|
||||
});
|
||||
|
||||
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||
pipelineService.renameJobFolders(id).catch((err) => {
|
||||
@@ -108,19 +206,68 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/error/ack',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:error:ack', { reqId: req.reqId, id });
|
||||
const job = await historyService.acknowledgeJobError(id);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/nfo/generate',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:nfo:generate', { reqId: req.reqId, id });
|
||||
const result = await historyService.generateJobNfo(id, {
|
||||
mode: 'manual',
|
||||
requireSettingDisabled: true,
|
||||
failIfExists: true,
|
||||
failIfOutputMissing: true
|
||||
});
|
||||
const job = await historyService.getJobWithLogs(id, { includeFsChecks: true });
|
||||
res.json({ result, job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/delete-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'both');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||
});
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
logger.warn('post:delete-files', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target
|
||||
target,
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJobFiles(id, target);
|
||||
const result = await historyService.deleteJobFiles(id, target, {
|
||||
includeRelated,
|
||||
selectedJobIds,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
@@ -130,14 +277,18 @@ router.get(
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
|
||||
const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds')
|
||||
});
|
||||
|
||||
logger.info('get:delete-preview', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
includeRelated
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0
|
||||
});
|
||||
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||
res.json({ preview });
|
||||
})
|
||||
);
|
||||
@@ -148,17 +299,122 @@ router.post(
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'none');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||
});
|
||||
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
|
||||
const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined;
|
||||
const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice
|
||||
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||
: null;
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
logger.warn('post:delete-job', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target,
|
||||
includeRelated
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||
requestedResetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
requestedKeepDetectedDevice,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJob(id, target, { includeRelated });
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||
res.json({ ...result, uiReset });
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||
const containsOrphanRawImportJob = Boolean(
|
||||
preview?.flags?.containsOrphanRawImportJob
|
||||
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some(
|
||||
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
|
||||
))
|
||||
);
|
||||
const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds
|
||||
: [id];
|
||||
const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []);
|
||||
const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => {
|
||||
const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase();
|
||||
const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
const lastState = String(job?.last_state || '').trim().toUpperCase();
|
||||
const isCdJob = mediaType === 'cd'
|
||||
|| jobKind === 'cd'
|
||||
|| status.startsWith('CD_')
|
||||
|| lastState.startsWith('CD_');
|
||||
if (!isCdJob) {
|
||||
return false;
|
||||
}
|
||||
const discDevice = String(job?.disc_device || job?.discDevice || '').trim();
|
||||
return Boolean(discDevice) && !discDevice.startsWith('__virtual__');
|
||||
});
|
||||
|
||||
const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd;
|
||||
const resetDriveState = containsOrphanRawImportJob
|
||||
? false
|
||||
: requestedResetDriveStateEffective;
|
||||
const keepDetectedDevice = containsOrphanRawImportJob
|
||||
? true
|
||||
: (hasRequestedKeepDetectedDevice
|
||||
? requestedKeepDetectedDevice
|
||||
: !requestedResetDriveStateEffective);
|
||||
|
||||
if (containsOrphanRawImportJob && requestedResetDriveState) {
|
||||
logger.info('post:delete-job:orphan-drive-reset-skipped', {
|
||||
reqId: req.reqId,
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) {
|
||||
logger.info('post:delete-job:auto-drive-reset-cd', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
selectedJobIds: selectedDeleteJobIds
|
||||
});
|
||||
}
|
||||
|
||||
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||
? preview.relatedJobs
|
||||
.filter((row) => Boolean(row?.selected))
|
||||
.map((row) => String(row?.discDevice || '').trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const result = await historyService.deleteJob(id, target, {
|
||||
includeRelated,
|
||||
selectedJobIds,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths,
|
||||
preserveRawForImportJobs
|
||||
});
|
||||
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
|
||||
resetDriveState,
|
||||
devicePaths: relatedDevicePaths
|
||||
}).catch((error) => {
|
||||
logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) });
|
||||
});
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete', {
|
||||
keepDetectedDevice
|
||||
});
|
||||
res.json({
|
||||
...result,
|
||||
uiReset,
|
||||
safeguards: {
|
||||
containsOrphanRawImportJob,
|
||||
resetDriveStateApplied: resetDriveState,
|
||||
keepDetectedDeviceApplied: keepDetectedDevice
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
const express = require('express');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const historyService = require('../services/historyService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const settingsService = require('../services/settingsService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const { tempDir } = require('../config');
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads');
|
||||
fs.mkdirSync(audiobookUploadDir, { recursive: true });
|
||||
const audiobookUpload = multer({
|
||||
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
|
||||
dest: audiobookUploadDir
|
||||
});
|
||||
|
||||
router.get(
|
||||
@@ -26,6 +31,68 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/hardware/history',
|
||||
asyncHandler(async (req, res) => {
|
||||
const hoursRaw = Number(req.query?.hours);
|
||||
const maxPointsRaw = Number(req.query?.maxPoints);
|
||||
const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24;
|
||||
const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720;
|
||||
const sinceIso = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString();
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
captured_at,
|
||||
cpu_usage_percent,
|
||||
cpu_temperature_c,
|
||||
ram_usage_percent,
|
||||
ram_used_bytes,
|
||||
ram_total_bytes,
|
||||
gpu_usage_percent,
|
||||
gpu_temperature_c,
|
||||
vram_used_bytes,
|
||||
vram_total_bytes
|
||||
FROM hardware_metrics_history
|
||||
WHERE captured_at >= ?
|
||||
ORDER BY captured_at ASC
|
||||
`,
|
||||
[sinceIso]
|
||||
);
|
||||
|
||||
const stride = rows.length > maxPoints ? Math.ceil(rows.length / maxPoints) : 1;
|
||||
const sampled = [];
|
||||
for (let index = 0; index < rows.length; index += stride) {
|
||||
sampled.push(rows[index]);
|
||||
}
|
||||
if (rows.length > 0 && sampled[sampled.length - 1] !== rows[rows.length - 1]) {
|
||||
sampled.push(rows[rows.length - 1]);
|
||||
}
|
||||
|
||||
const points = sampled.map((row) => ({
|
||||
capturedAt: row.captured_at,
|
||||
cpuUsagePercent: row.cpu_usage_percent,
|
||||
cpuTemperatureC: row.cpu_temperature_c,
|
||||
ramUsagePercent: row.ram_usage_percent,
|
||||
ramUsedBytes: row.ram_used_bytes,
|
||||
ramTotalBytes: row.ram_total_bytes,
|
||||
gpuUsagePercent: row.gpu_usage_percent,
|
||||
gpuTemperatureC: row.gpu_temperature_c,
|
||||
vramUsedBytes: row.vram_used_bytes,
|
||||
vramTotalBytes: row.vram_total_bytes
|
||||
}));
|
||||
|
||||
res.json({
|
||||
history: {
|
||||
hours,
|
||||
maxPoints,
|
||||
totalPoints: rows.length,
|
||||
points
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/analyze',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -70,11 +137,22 @@ router.post(
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/omdb/search',
|
||||
'/tmdb/movie/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
logger.info('get:omdb:search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchOmdb(String(query));
|
||||
logger.info('get:tmdb:movie-search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchTmdbMovies(String(query));
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb/series/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
const seasonNumber = req.query.season || null;
|
||||
logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber });
|
||||
const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber);
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
@@ -83,8 +161,12 @@ router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchMusicBrainz(String(query));
|
||||
const trackCountRaw = Number(req.query.trackCount);
|
||||
const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0
|
||||
? Math.trunc(trackCountRaw)
|
||||
: null;
|
||||
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount });
|
||||
const results = await pipelineService.searchMusicBrainz(String(query), { trackCount });
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
@@ -174,9 +256,13 @@ router.post(
|
||||
mimeType: String(req.file.mimetype || '').trim() || null,
|
||||
tempPath: String(req.file.path || '').trim() || null
|
||||
});
|
||||
const result = await pipelineService.uploadAudiobookFile(req.file, {
|
||||
format: req.body?.format,
|
||||
startImmediately: req.body?.startImmediately
|
||||
const result = await pipelineService.createFileJob({
|
||||
kind: 'audiobook_upload',
|
||||
file: req.file,
|
||||
options: {
|
||||
format: req.body?.format,
|
||||
startImmediately: req.body?.startImmediately
|
||||
}
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
@@ -219,10 +305,44 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/audiobook/jobs',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const jobs = await pipelineService.getAudiobookJobs();
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId } = req.body;
|
||||
const {
|
||||
jobId,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
mbId,
|
||||
coverUrl,
|
||||
tracks,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
} = req.body;
|
||||
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
@@ -234,29 +354,91 @@ router.post(
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
fromOmdb,
|
||||
mbId,
|
||||
coverUrl,
|
||||
trackCount: Array.isArray(tracks) ? tracks.length : null,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
const looksLikeCdMetadataPayload = (
|
||||
artist !== undefined
|
||||
|| mbId !== undefined
|
||||
|| coverUrl !== undefined
|
||||
|| Array.isArray(tracks)
|
||||
);
|
||||
if (looksLikeCdMetadataPayload) {
|
||||
logger.warn('post:select-metadata:compat-cd-route', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
trackCount: Array.isArray(tracks) ? tracks.length : 0
|
||||
});
|
||||
const job = await pipelineService.selectCdMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
mbId,
|
||||
coverUrl: coverUrl || poster || null,
|
||||
tracks
|
||||
});
|
||||
res.json({ job });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await pipelineService.selectMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/jobs/:jobId/raw-decision',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const { decision } = req.body;
|
||||
logger.info('post:raw-decision', { reqId: req.reqId, jobId, decision });
|
||||
const result = await pipelineService.submitRawDecision(jobId, decision);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/start/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -267,25 +449,111 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:jobId/reorder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds)
|
||||
? req.body.orderedSourceJobIds
|
||||
: [];
|
||||
logger.info('post:multipart-merge:reorder', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
orderedCount: orderedSourceJobIds.length
|
||||
});
|
||||
const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:jobId/settings',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const deleteInputsAfterMerge = Boolean(req.body?.deleteInputsAfterMerge);
|
||||
logger.info('post:multipart-merge:settings', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
deleteInputsAfterMerge
|
||||
});
|
||||
const job = await pipelineService.updateMultipartMergeSettings(jobId, {
|
||||
deleteInputsAfterMerge
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/multipart-merge/:jobId/preview',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('get:multipart-merge:preview', {
|
||||
reqId: req.reqId,
|
||||
jobId
|
||||
});
|
||||
const preview = await pipelineService.getMultipartMergePreview(jobId);
|
||||
res.json({ preview });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:containerJobId/restore',
|
||||
asyncHandler(async (req, res) => {
|
||||
const containerJobId = Number(req.params.containerJobId);
|
||||
logger.info('post:multipart-merge:restore', {
|
||||
reqId: req.reqId,
|
||||
containerJobId
|
||||
});
|
||||
const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId);
|
||||
await pipelineService.emitQueueChanged().catch((error) => {
|
||||
logger.warn('post:multipart-merge:restore:queue-emit-failed', {
|
||||
reqId: req.reqId,
|
||||
containerJobId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
res.json({
|
||||
result: {
|
||||
restored: true,
|
||||
mergeJobId: Number(job?.id || 0) || null
|
||||
},
|
||||
job
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/confirm-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const hasSelectedUserPresetId = Object.prototype.hasOwnProperty.call(body, 'selectedUserPresetId');
|
||||
const hasSelectedHandBrakePreset = Object.prototype.hasOwnProperty.call(body, 'selectedHandBrakePreset');
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
const episodeAssignments = req.body?.episodeAssignments ?? null;
|
||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
|
||||
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
|
||||
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
|
||||
const selectedUserPresetId = hasSelectedUserPresetId
|
||||
? body.selectedUserPresetId
|
||||
: undefined;
|
||||
const selectedHandBrakePreset = hasSelectedHandBrakePreset
|
||||
? body.selectedHandBrakePreset
|
||||
: undefined;
|
||||
logger.info('post:confirm-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset,
|
||||
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
||||
? selectedPostEncodeScriptIds.length
|
||||
: 0,
|
||||
@@ -301,13 +569,16 @@ router.post(
|
||||
});
|
||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIds,
|
||||
selectedTrackSelection,
|
||||
episodeAssignments,
|
||||
selectedPostEncodeScriptIds,
|
||||
selectedPreEncodeScriptIds,
|
||||
selectedPostEncodeChainIds,
|
||||
selectedPreEncodeChainIds,
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
@@ -330,8 +601,9 @@ router.post(
|
||||
'/retry/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:retry', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.retry(jobId);
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob });
|
||||
const result = await pipelineService.retry(jobId, { createNewJob });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -346,12 +618,34 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folders = await historyService.getJobOutputFoldersForLineage(jobId);
|
||||
res.json({ folders });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/delete-output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : [];
|
||||
logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length });
|
||||
const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/reencode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:reencode', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.reencodeFromRaw(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -360,8 +654,26 @@ router.post(
|
||||
'/restart-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:restart-review', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
const reuseCurrentJob = Object.prototype.hasOwnProperty.call(req.body || {}, 'reuseCurrentJob')
|
||||
? Boolean(req.body?.reuseCurrentJob)
|
||||
: !createNewJob;
|
||||
logger.info('post:restart-review', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
createNewJob,
|
||||
reuseCurrentJob,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
reuseCurrentJob,
|
||||
createNewJob
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -370,8 +682,36 @@ router.post(
|
||||
'/restart-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
logger.info('post:restart-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
createNewJob,
|
||||
restartMode,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
restartMode,
|
||||
createNewJob
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/restart-cd-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -8,8 +8,11 @@ const pipelineService = require('../services/pipelineService');
|
||||
const wsService = require('../services/websocketService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const userPresetService = require('../services/userPresetService');
|
||||
const userPresetDefaultsService = require('../services/userPresetDefaultsService');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
||||
const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService');
|
||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
@@ -21,7 +24,7 @@ function isSensitiveSettingKey(key) {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized);
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
|
||||
}
|
||||
|
||||
router.get(
|
||||
@@ -51,6 +54,71 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/makemkv/beta-key',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
||||
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
|
||||
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
||||
const cached = await getCachedBetaKey({ allowExpired: true });
|
||||
|
||||
res.json({
|
||||
betaKey: cached?.key || '',
|
||||
sourceUrl: cached?.sourceUrl || null,
|
||||
validUntil: cached?.validUntil || null,
|
||||
fetchedAt: cached?.fetchedAt || null,
|
||||
cached: cached?.cached === true,
|
||||
stale: cached?.stale === true,
|
||||
appliedKey: existingKey || null,
|
||||
appliedMatchesCache: Boolean(cached?.key) && existingKey === cached.key
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/makemkv/beta-key/check',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:makemkv:beta-key:check', { reqId: req.reqId });
|
||||
const betaKeyResult = await fetchCurrentBetaKey({ forceRefresh: true });
|
||||
res.json({
|
||||
betaKey: betaKeyResult.key,
|
||||
sourceUrl: betaKeyResult.sourceUrl,
|
||||
validUntil: betaKeyResult.validUntil || null,
|
||||
fetchedAt: betaKeyResult.fetchedAt || null,
|
||||
cached: betaKeyResult.cached === true,
|
||||
stale: betaKeyResult.stale === true
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/makemkv/beta-key/apply',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
|
||||
const requestedKey = String(req.body?.betaKey || '').trim();
|
||||
const cachedResult = requestedKey
|
||||
? { key: requestedKey }
|
||||
: await getCachedBetaKey({ allowExpired: true });
|
||||
const betaKey = String(cachedResult?.key || '').trim();
|
||||
|
||||
if (!betaKey) {
|
||||
const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey);
|
||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||
|
||||
res.json({
|
||||
applied: true,
|
||||
setting: updated,
|
||||
betaKey,
|
||||
sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/scripts',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -106,6 +174,26 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/scripts/:id/favorite',
|
||||
asyncHandler(async (req, res) => {
|
||||
const scriptId = Number(req.params.id);
|
||||
const isFavorite = req.body?.isFavorite === true;
|
||||
logger.info('put:settings:scripts:favorite', {
|
||||
reqId: req.reqId,
|
||||
scriptId,
|
||||
isFavorite
|
||||
});
|
||||
const script = await scriptService.setScriptFavorite(scriptId, isFavorite);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', {
|
||||
action: 'favorite-updated',
|
||||
id: script.id,
|
||||
isFavorite: script.isFavorite === true
|
||||
});
|
||||
res.json({ script });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/scripts/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -199,6 +287,22 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/script-chains/:id/favorite',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
const isFavorite = req.body?.isFavorite === true;
|
||||
logger.info('put:settings:script-chains:favorite', { reqId: req.reqId, chainId, isFavorite });
|
||||
const chain = await scriptChainService.setChainFavorite(chainId, isFavorite);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', {
|
||||
action: 'favorite-updated',
|
||||
id: chain.id,
|
||||
isFavorite: chain.isFavorite === true
|
||||
});
|
||||
res.json({ chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/script-chains/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -210,6 +314,88 @@ router.delete(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/coverart/recover',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:coverart:recover', { reqId: req.reqId });
|
||||
const result = await coverArtRecoveryService.runNow({
|
||||
trigger: 'manual',
|
||||
force: true,
|
||||
logFailures: true
|
||||
});
|
||||
res.json({
|
||||
result,
|
||||
scheduler: coverArtRecoveryService.getStatus()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// ── User Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mediaType = req.query.media_type || null;
|
||||
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
||||
const presets = await userPresetService.listPresets(mediaType);
|
||||
res.json({ presets });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:user-preset-defaults', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.listDefaults();
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-preset-defaults:update', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.updateDefaults(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'defaults-updated' });
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
||||
const preset = await userPresetService.createPreset(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
||||
res.status(201).json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
||||
const preset = await userPresetService.updatePreset(presetId, payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
||||
res.json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
||||
const removed = await userPresetService.deletePreset(presetId);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -259,6 +445,18 @@ router.put(
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged([key]);
|
||||
} catch (error) {
|
||||
logger.warn('put:setting:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||
|
||||
res.json({ setting: updated, reviewRefresh });
|
||||
@@ -312,58 +510,23 @@ router.put(
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key));
|
||||
} catch (error) {
|
||||
logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||
|
||||
res.json({ changes, reviewRefresh });
|
||||
})
|
||||
);
|
||||
|
||||
// ── User Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mediaType = req.query.media_type || null;
|
||||
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
||||
const presets = await userPresetService.listPresets(mediaType);
|
||||
res.json({ presets });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
||||
const preset = await userPresetService.createPreset(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
||||
res.status(201).json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
||||
const preset = await userPresetService.updatePreset(presetId, payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
||||
res.json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
||||
const removed = await userPresetService.deletePreset(presetId);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/pushover/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -414,8 +577,10 @@ router.get(
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
||||
const match = name.match(/sr(\d+)$/);
|
||||
const discIndex = match ? Number(match[1]) : null;
|
||||
const resolvedIndex = Number(d.makemkvIndex);
|
||||
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
|
||||
? Math.trunc(resolvedIndex)
|
||||
: null;
|
||||
return {
|
||||
path: devicePath,
|
||||
name,
|
||||
@@ -429,6 +594,50 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/drives/force-unlock',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { devicePath, all } = req.body || {};
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
|
||||
if (!expertMode) {
|
||||
const error = new Error('Expertenmodus erforderlich.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const devices = await diskDetectionService.getBlockDeviceInfo();
|
||||
const drives = devices
|
||||
.filter((d) => d.type === 'rom')
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
return d.path || (name ? `/dev/${name}` : null);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
|
||||
.map((entry) => String(entry?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const targetPaths = all
|
||||
? Array.from(new Set([...drives, ...activeLockPaths]))
|
||||
: [String(devicePath || '').trim()].filter(Boolean);
|
||||
if (targetPaths.length === 0) {
|
||||
const error = new Error('Kein Laufwerk angegeben.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of targetPaths) {
|
||||
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
// User preferences (UI state, persisted per-installation)
|
||||
router.get(
|
||||
'/prefs/:key',
|
||||
|
||||
@@ -22,13 +22,16 @@ const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artis
|
||||
function parseToc(tocOutput) {
|
||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||
const tracks = [];
|
||||
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||||
const tocTablePattern =
|
||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||||
|
||||
for (const line of lines) {
|
||||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||
if (trackMatch) {
|
||||
const position = Number(trackMatch[1]);
|
||||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
||||
.replace(tocTimePattern, ' ');
|
||||
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||
if (sectorValues.length < 2) {
|
||||
continue;
|
||||
@@ -58,9 +61,7 @@ function parseToc(tocOutput) {
|
||||
// Alternative cdparanoia -Q table style:
|
||||
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||
// ^ length sectors ^ start sector
|
||||
const tableMatch = line.match(
|
||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
|
||||
);
|
||||
const tableMatch = line.match(tocTablePattern);
|
||||
if (!tableMatch) {
|
||||
continue;
|
||||
}
|
||||
@@ -244,7 +245,19 @@ function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
||||
}
|
||||
const offset = outputSegments.length - relativeSegments.length;
|
||||
for (let i = 0; i < relativeSegments.length; i++) {
|
||||
if (outputSegments[offset + i] !== relativeSegments[i]) {
|
||||
const outputPart = outputSegments[offset + i];
|
||||
const relativePart = relativeSegments[i];
|
||||
if (outputPart === relativePart) {
|
||||
continue;
|
||||
}
|
||||
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
|
||||
// Treat this as containing the same relative directory to avoid nesting:
|
||||
// Album (2007)_2/Album (2007)/...
|
||||
const isLastRelativeSegment = i === relativeSegments.length - 1;
|
||||
const matchesNumberedSuffix = isLastRelativeSegment
|
||||
&& outputPart.startsWith(`${relativePart}_`)
|
||||
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
|
||||
if (!matchesNumberedSuffix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -368,6 +381,35 @@ async function runProcessTracked({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
|
||||
* Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
|
||||
*/
|
||||
function getSortedPlainWavFiles(rawWavDir) {
|
||||
try {
|
||||
return fs.readdirSync(rawWavDir)
|
||||
.filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the WAV file path for a given track position.
|
||||
* Primary: track01.cdda.wav (cdparanoia format)
|
||||
* Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
|
||||
*/
|
||||
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
|
||||
const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
|
||||
if (fs.existsSync(cddaPath)) return cddaPath;
|
||||
if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
|
||||
const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
|
||||
if (fs.existsSync(fallback)) return fallback;
|
||||
}
|
||||
return cddaPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip and encode a CD.
|
||||
*
|
||||
@@ -383,6 +425,7 @@ async function runProcessTracked({
|
||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||
* @param {object} options.meta - album metadata {title, artist, year}
|
||||
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||
* @param {boolean} options.skipEncode - true => rip only (no encode)
|
||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||
* @param {Function} options.onLog - (level, msg) => void
|
||||
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||
@@ -406,10 +449,12 @@ async function ripAndEncode(options) {
|
||||
onLog,
|
||||
onProcessHandle,
|
||||
isCancelled,
|
||||
context
|
||||
context,
|
||||
skipRip = false,
|
||||
skipEncode = false
|
||||
} = options;
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(format)) {
|
||||
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
|
||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||
}
|
||||
|
||||
@@ -422,7 +467,9 @@ async function ripAndEncode(options) {
|
||||
}
|
||||
|
||||
await ensureDir(rawWavDir);
|
||||
await ensureDir(outputDir);
|
||||
if (!skipEncode) {
|
||||
await ensureDir(outputDir);
|
||||
}
|
||||
|
||||
logger.info('rip:start', {
|
||||
jobId,
|
||||
@@ -435,8 +482,22 @@ async function ripAndEncode(options) {
|
||||
logger[level] && logger[level](msg, { jobId });
|
||||
onLog && onLog(level, msg);
|
||||
};
|
||||
const ripPercentSpan = skipEncode ? 100 : 50;
|
||||
const encodePercentStart = ripPercentSpan;
|
||||
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||
|
||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
|
||||
|
||||
if (skipRip) {
|
||||
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||||
for (const track of tracksToRip) {
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
@@ -450,7 +511,7 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: (i / tracksToRip.length) * 50
|
||||
percent: (i / tracksToRip.length) * ripPercentSpan
|
||||
});
|
||||
|
||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||
@@ -464,7 +525,7 @@ async function ripAndEncode(options) {
|
||||
onStderrLine(line) {
|
||||
const parsed = parseCdParanoiaProgress(line);
|
||||
if (parsed && parsed.percent !== null) {
|
||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'progress',
|
||||
@@ -496,11 +557,21 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: ((i + 1) / tracksToRip.length) * 50
|
||||
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} gerippt.`);
|
||||
}
|
||||
} // end if (!skipRip)
|
||||
|
||||
if (skipEncode) {
|
||||
return {
|
||||
outputDir: null,
|
||||
format: 'raw',
|
||||
trackCount: tracksToRip.length,
|
||||
encodeResults: []
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||
const encodeResults = [];
|
||||
@@ -510,7 +581,7 @@ async function ripAndEncode(options) {
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
@@ -519,7 +590,7 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
ensureDir(path.dirname(outFile));
|
||||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||
@@ -531,7 +602,7 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||||
@@ -542,7 +613,7 @@ async function ripAndEncode(options) {
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||
@@ -558,7 +629,7 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
|
||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||
@@ -605,7 +676,7 @@ async function ripAndEncode(options) {
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} encodiert.`);
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('CONVERTER_SCAN');
|
||||
const {
|
||||
defaultConverterRawDir
|
||||
} = require('../config');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
||||
const ISO_EXTENSIONS = new Set(['iso']);
|
||||
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
|
||||
|
||||
let _pollingTimer = null;
|
||||
let _pollingEnabled = false;
|
||||
let _pollingInterval = 300;
|
||||
|
||||
function detectMediaType(fileName) {
|
||||
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
|
||||
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectFormat(fileName) {
|
||||
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
|
||||
}
|
||||
|
||||
function getConfiguredExtensions(settings) {
|
||||
const raw = String(settings?.converter_scan_extensions || '').trim();
|
||||
if (!raw) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
const configured = raw.split(',')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
|
||||
if (filtered.length === 0) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
return new Set(filtered);
|
||||
}
|
||||
|
||||
function getFileSize(fullPath) {
|
||||
try {
|
||||
return fs.statSync(fullPath).size;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SCAN_MAX_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
|
||||
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
|
||||
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
|
||||
*/
|
||||
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
|
||||
const entries = [];
|
||||
|
||||
if (depth >= SCAN_MAX_DEPTH) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
||||
} catch (_err) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const dirent of dirEntries) {
|
||||
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
|
||||
const fullPath = path.join(rawDir, relPath);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'directory',
|
||||
fileSize: null,
|
||||
detectedMediaType: null,
|
||||
detectedFormat: null
|
||||
});
|
||||
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
|
||||
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
|
||||
entries.push(...subEntries);
|
||||
} else if (dirent.isFile()) {
|
||||
const ext = path.extname(dirent.name).slice(1).toLowerCase();
|
||||
if (!allowedExtensions.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'file',
|
||||
fileSize: getFileSize(fullPath),
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
|
||||
* nicht mehr vorhandene Einträge ohne Job entfernen.
|
||||
*/
|
||||
async function persistScanResults(rawDir, entries) {
|
||||
const db = await getDb();
|
||||
|
||||
if (entries.length > 0) {
|
||||
const stmt = await db.prepare(`
|
||||
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(rel_path) DO UPDATE SET
|
||||
entry_type = excluded.entry_type,
|
||||
file_size = excluded.file_size,
|
||||
detected_media_type = excluded.detected_media_type,
|
||||
detected_format = excluded.detected_format,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`);
|
||||
|
||||
for (const entry of entries) {
|
||||
await stmt.run(
|
||||
entry.relPath,
|
||||
entry.entryType,
|
||||
entry.fileSize,
|
||||
entry.detectedMediaType,
|
||||
entry.detectedFormat
|
||||
);
|
||||
}
|
||||
await stmt.finalize();
|
||||
}
|
||||
|
||||
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
|
||||
const existingEntries = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
|
||||
);
|
||||
const currentRelPaths = new Set(entries.map((e) => e.relPath));
|
||||
|
||||
for (const row of existingEntries) {
|
||||
if (!currentRelPaths.has(row.rel_path)) {
|
||||
const fullPath = path.join(rawDir, row.rel_path);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
|
||||
[row.rel_path]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
|
||||
*/
|
||||
async function scan() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
|
||||
if (!rawDir) {
|
||||
logger.warn('converter:scan:no-raw-dir');
|
||||
return { rawDir: null, entryCount: 0 };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
logger.warn('converter:scan:dir-missing', { rawDir });
|
||||
return { rawDir, entryCount: 0 };
|
||||
}
|
||||
|
||||
const allowedExtensions = getConfiguredExtensions(settings);
|
||||
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
|
||||
|
||||
const entries = scanDirectory(rawDir, allowedExtensions);
|
||||
await persistScanResults(rawDir, entries);
|
||||
|
||||
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
|
||||
|
||||
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
|
||||
|
||||
return { rawDir, entryCount: entries.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Einträge für den File-Explorer zurückgeben.
|
||||
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
|
||||
*/
|
||||
async function getEntries(parentRelPath = null) {
|
||||
const db = await getDb();
|
||||
|
||||
let rows;
|
||||
if (!parentRelPath) {
|
||||
// Root-Ebene: nur Einträge ohne '/' im rel_path
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Nur direkte Kinder (kein '/' im rel_path)
|
||||
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
|
||||
} else {
|
||||
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Direkte Kinder des angegebenen Verzeichnisses
|
||||
rows = rows.filter((r) => {
|
||||
const rel = String(r.rel_path);
|
||||
if (!rel.startsWith(prefix)) return false;
|
||||
const remainder = rel.slice(prefix.length);
|
||||
return !remainder.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
relPath: r.rel_path,
|
||||
entryType: r.entry_type,
|
||||
fileSize: r.file_size,
|
||||
detectedMediaType: r.detected_media_type,
|
||||
detectedFormat: r.detected_format,
|
||||
jobId: r.job_id,
|
||||
jobStatus: r.job_status || null,
|
||||
jobTitle: r.job_title || null,
|
||||
lastSeenAt: r.last_seen_at
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEntryById(id) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE id = ?`,
|
||||
[Number(id)]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function getEntryByRelPath(relPath) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
|
||||
[relPath]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function setEntryJobAssignment(relPath, jobId) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
|
||||
}
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const existing = await db.get(
|
||||
`SELECT entry_type, file_size, detected_media_type, detected_format
|
||||
FROM converter_scan_entries
|
||||
WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
|
||||
let entryType = String(existing?.entry_type || 'file').trim() || 'file';
|
||||
let fileSize = existing?.file_size ?? null;
|
||||
let detectedMediaType = existing?.detected_media_type ?? null;
|
||||
let detectedFormat = existing?.detected_format ?? null;
|
||||
|
||||
const rawDir = await getRawDir();
|
||||
const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
|
||||
if (absPath && fs.existsSync(absPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
entryType = stat.isDirectory() ? 'directory' : 'file';
|
||||
fileSize = stat.isFile() ? stat.size : null;
|
||||
if (stat.isFile()) {
|
||||
const fileName = path.basename(normalizedRelPath);
|
||||
detectedMediaType = detectMediaType(fileName);
|
||||
detectedFormat = detectFormat(fileName);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Keep existing metadata values if stat/read fails.
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO converter_scan_entries (
|
||||
rel_path,
|
||||
entry_type,
|
||||
file_size,
|
||||
detected_media_type,
|
||||
detected_format,
|
||||
job_id,
|
||||
last_seen_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(rel_path) DO UPDATE SET
|
||||
entry_type = excluded.entry_type,
|
||||
file_size = excluded.file_size,
|
||||
detected_media_type = excluded.detected_media_type,
|
||||
detected_format = excluded.detected_format,
|
||||
job_id = excluded.job_id,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`,
|
||||
[
|
||||
normalizedRelPath,
|
||||
entryType,
|
||||
fileSize,
|
||||
detectedMediaType,
|
||||
detectedFormat,
|
||||
Math.trunc(normalizedJobId)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearEntryJobAssignment(relPath, expectedJobId = null) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
const normalizedExpected = Number(expectedJobId);
|
||||
if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
|
||||
[normalizedRelPath, Math.trunc(normalizedExpected)]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearAssignmentsForJob(jobId) {
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
|
||||
[Math.trunc(normalizedJobId)]
|
||||
);
|
||||
}
|
||||
|
||||
async function assignEntriesToJob(relPaths, jobId) {
|
||||
const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
|
||||
for (const relPath of normalizedPaths) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
async function markEntryAsJob(relPath, jobId) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
|
||||
async function getRawDir() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling-Loop starten.
|
||||
*/
|
||||
async function startPolling() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
|
||||
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (!_pollingEnabled) {
|
||||
logger.info('converter:polling:disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
await scan();
|
||||
} catch (error) {
|
||||
logger.error('converter:polling:error', { error: error?.message });
|
||||
}
|
||||
if (_pollingEnabled) {
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
};
|
||||
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_pollingTimer) {
|
||||
clearTimeout(_pollingTimer);
|
||||
_pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartPolling() {
|
||||
await startPolling();
|
||||
}
|
||||
|
||||
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
|
||||
|
||||
/**
|
||||
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
|
||||
* Gibt null zurück wenn der Pfad ungültig ist.
|
||||
*/
|
||||
function normalizeRelPath(input) {
|
||||
if (input === null || input === undefined) return '';
|
||||
const raw = String(input).replace(/\\/g, '/').trim();
|
||||
if (!raw || raw === '.') return '';
|
||||
if (raw.startsWith('/')) return null;
|
||||
const normalized = path.posix.normalize(raw);
|
||||
if (normalized.startsWith('..')) return null;
|
||||
if (normalized === '.') return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function makeError(msg, code) {
|
||||
const err = new Error(msg);
|
||||
err.statusCode = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function deleteEntry(relPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
|
||||
|
||||
const absPath = path.join(rawDir, rel);
|
||||
// Traversal-Schutz
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
|
||||
|
||||
fs.rmSync(absPath, { recursive: true, force: true });
|
||||
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
|
||||
return { deleted: rel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function renameEntry(relPath, newName) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const safeName = String(newName || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Name.', 400);
|
||||
}
|
||||
|
||||
const parentRel = path.posix.dirname(rel);
|
||||
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function moveEntry(relPath, targetParentRelPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
|
||||
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
|
||||
|
||||
const name = path.posix.basename(rel);
|
||||
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
|
||||
|
||||
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
|
||||
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
|
||||
*/
|
||||
async function createFolder(parentRelPath, name) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
|
||||
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
|
||||
|
||||
const safeName = String(name || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Ordnername.', 400);
|
||||
}
|
||||
|
||||
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
|
||||
const absPath = path.join(rawDir, newRel);
|
||||
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
|
||||
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
|
||||
return { relPath: newRel };
|
||||
}
|
||||
|
||||
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
|
||||
|
||||
const TREE_MAX_DEPTH = 8;
|
||||
|
||||
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
||||
if (depth >= TREE_MAX_DEPTH) return [];
|
||||
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
||||
let dirents;
|
||||
try {
|
||||
dirents = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
dirents.sort((a, b) => {
|
||||
const ad = a.isDirectory() ? 0 : 1;
|
||||
const bd = b.isDirectory() ? 0 : 1;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const nodes = [];
|
||||
for (const dirent of dirents) {
|
||||
// Versteckte Einträge überspringen
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) {
|
||||
const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
||||
} else if (dirent.isFile()) {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
||||
const assignment = assignments.get(childRel) || null;
|
||||
nodes.push({
|
||||
name: dirent.name,
|
||||
type: 'file',
|
||||
path: childRel,
|
||||
size,
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name),
|
||||
jobId: assignment?.jobId || null,
|
||||
jobTitle: assignment?.jobTitle || null,
|
||||
jobStatus: assignment?.jobStatus || null
|
||||
});
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
async function getTree() {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir || !fs.existsSync(rawDir)) {
|
||||
return { rawDir: rawDir || null, tree: null };
|
||||
}
|
||||
const db = await getDb();
|
||||
const rows = await db.all(`
|
||||
SELECT
|
||||
e.rel_path,
|
||||
e.job_id,
|
||||
j.title AS job_title,
|
||||
j.detected_title AS job_detected_title,
|
||||
j.status AS job_status
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
WHERE e.job_id IS NOT NULL
|
||||
`);
|
||||
const assignments = new Map();
|
||||
for (const row of rows) {
|
||||
const rel = String(row?.rel_path || '').trim();
|
||||
const jobId = Number(row?.job_id);
|
||||
if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
|
||||
assignments.set(rel, {
|
||||
jobId: Math.trunc(jobId),
|
||||
jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
|
||||
jobStatus: String(row?.job_status || '').trim() || null
|
||||
});
|
||||
}
|
||||
const children = buildRawTree(rawDir, '', 0, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
return {
|
||||
rawDir,
|
||||
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scan,
|
||||
getEntries,
|
||||
getEntryById,
|
||||
getEntryByRelPath,
|
||||
markEntryAsJob,
|
||||
setEntryJobAssignment,
|
||||
clearEntryJobAssignment,
|
||||
clearAssignmentsForJob,
|
||||
assignEntriesToJob,
|
||||
getRawDir,
|
||||
normalizeRelPath,
|
||||
getTree,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
restartPolling,
|
||||
detectMediaType,
|
||||
detectFormat,
|
||||
deleteEntry,
|
||||
renameEntry,
|
||||
moveEntry,
|
||||
createFolder
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('COVERART_RECOVERY');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
|
||||
const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled';
|
||||
const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours';
|
||||
const DEFAULT_INTERVAL_HOURS = 6;
|
||||
const MIN_INTERVAL_HOURS = 1;
|
||||
const MAX_INTERVAL_HOURS = 168;
|
||||
const RUNNING_JOB_STATUSES = new Set([
|
||||
'ANALYZING',
|
||||
'RIPPING',
|
||||
'MEDIAINFO_CHECK',
|
||||
'ENCODING',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
]);
|
||||
|
||||
function parseJsonSafe(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value, fallback = false) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeIntervalHours(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_INTERVAL_HOURS;
|
||||
}
|
||||
return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deriveCoverArtArchiveUrl(mbId) {
|
||||
const normalized = String(mbId || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`;
|
||||
}
|
||||
|
||||
function isLikelyMusicBrainzId(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^tt\d{6,12}$/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-z0-9-]{8,}$/i.test(normalized);
|
||||
}
|
||||
|
||||
function collectCoverCandidates(row) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const push = (url, source) => {
|
||||
const normalized = normalizeExternalUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
candidates.push({
|
||||
url: normalized,
|
||||
source: String(source || '').trim() || null
|
||||
});
|
||||
};
|
||||
|
||||
push(row?.poster_url, 'job.poster_url');
|
||||
|
||||
const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
|
||||
push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl');
|
||||
push(selectedMetadata?.poster, 'selectedMetadata.poster');
|
||||
push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl');
|
||||
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| row?.imdb_id
|
||||
|| ''
|
||||
).trim();
|
||||
if (isLikelyMusicBrainzId(mbId)) {
|
||||
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
class CoverArtRecoveryService {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.inFlight = null;
|
||||
this.nextRunAt = null;
|
||||
this.schedulerEnabled = false;
|
||||
this.intervalHours = DEFAULT_INTERVAL_HOURS;
|
||||
this.lastRunSummary = null;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
nextRunAt: this.nextRunAt,
|
||||
running: Boolean(this.inFlight),
|
||||
lastRunSummary: this.lastRunSummary
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.nextRunAt = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.refreshSchedule({ runStartupCheck: true });
|
||||
}
|
||||
|
||||
async handleSettingsChanged(changedKeys = []) {
|
||||
const normalizedKeys = Array.isArray(changedKeys)
|
||||
? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (
|
||||
normalizedKeys.length > 0
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY)
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY)
|
||||
) {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.refreshSchedule({ runStartupCheck: false });
|
||||
}
|
||||
|
||||
async refreshSchedule(options = {}) {
|
||||
const runStartupCheck = options?.runStartupCheck !== false;
|
||||
this.stop();
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]);
|
||||
|
||||
logger.info('scheduler:refresh', {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
runStartupCheck
|
||||
});
|
||||
|
||||
if (this.schedulerEnabled && runStartupCheck) {
|
||||
this.runNow({ trigger: 'startup' }).catch((error) => {
|
||||
logger.warn('scheduler:startup-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.schedulerEnabled) {
|
||||
this.scheduleNextAutoRun();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
scheduleNextAutoRun() {
|
||||
this.stop();
|
||||
if (!this.schedulerEnabled) {
|
||||
return;
|
||||
}
|
||||
const delayMs = this.intervalHours * 60 * 60 * 1000;
|
||||
this.nextRunAt = new Date(Date.now() + delayMs).toISOString();
|
||||
this.timer = setTimeout(() => {
|
||||
this.runNow({ trigger: 'auto' })
|
||||
.catch((error) => {
|
||||
logger.warn('scheduler:auto-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.scheduleNextAutoRun();
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async runNow(options = {}) {
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
let promise = null;
|
||||
promise = this._runNowInternal(options)
|
||||
.finally(() => {
|
||||
if (this.inFlight === promise) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.inFlight = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runNowInternal(options = {}) {
|
||||
const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual';
|
||||
const force = Boolean(options?.force);
|
||||
const logFailures = options?.logFailures !== false;
|
||||
const startedMs = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
|
||||
if (!enabled && !force) {
|
||||
const skipped = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
skipped: true,
|
||||
reason: 'disabled'
|
||||
};
|
||||
this.lastRunSummary = skipped;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
detected_title,
|
||||
status,
|
||||
poster_url,
|
||||
imdb_id,
|
||||
makemkv_info_json
|
||||
FROM jobs
|
||||
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||
`
|
||||
);
|
||||
|
||||
const summary = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
durationMs: 0,
|
||||
scannedJobs: Array.isArray(rows) ? rows.length : 0,
|
||||
runningSkipped: 0,
|
||||
alreadyLocal: 0,
|
||||
noCandidate: 0,
|
||||
recovered: 0,
|
||||
failed: 0,
|
||||
failedJobs: []
|
||||
};
|
||||
|
||||
for (const row of rows || []) {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = String(row?.status || '').trim().toUpperCase();
|
||||
if (RUNNING_JOB_STATUSES.has(status)) {
|
||||
summary.runningSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPosterUrl = String(row?.poster_url || '').trim();
|
||||
if (
|
||||
currentPosterUrl
|
||||
&& thumbnailService.isLocalUrl(currentPosterUrl)
|
||||
&& thumbnailService.localThumbnailUrlExists(currentPosterUrl)
|
||||
) {
|
||||
summary.alreadyLocal += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = collectCoverCandidates(row);
|
||||
if (candidates.length === 0) {
|
||||
summary.noCandidate += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let recovered = false;
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, {
|
||||
source: 'Coverart',
|
||||
logFailures: false
|
||||
});
|
||||
if (result?.ok && result?.localUrl) {
|
||||
recovered = true;
|
||||
summary.recovered += 1;
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastError = String(result?.error || result?.reason || 'download_failed');
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
summary.failed += 1;
|
||||
const failedInfo = {
|
||||
jobId,
|
||||
title: String(row?.title || row?.detected_title || `Job #${jobId}`),
|
||||
attemptedUrls: candidates.map((item) => item.url),
|
||||
error: lastError
|
||||
};
|
||||
summary.failedJobs.push(failedInfo);
|
||||
if (logFailures && trigger !== 'auto') {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Math.max(0, Date.now() - startedMs);
|
||||
const result = {
|
||||
...summary,
|
||||
finishedAt,
|
||||
durationMs
|
||||
};
|
||||
this.lastRunSummary = result;
|
||||
logger.info('recovery:done', {
|
||||
trigger: result.trigger,
|
||||
scannedJobs: result.scannedJobs,
|
||||
recovered: result.recovered,
|
||||
failed: result.failed,
|
||||
alreadyLocal: result.alreadyLocal,
|
||||
noCandidate: result.noCandidate,
|
||||
runningSkipped: result.runningSkipped,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CoverArtRecoveryService();
|
||||
@@ -52,6 +52,40 @@ function flattenDevices(nodes, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
function normalizeOpticalDevicePath(entry) {
|
||||
const directPath = String(entry?.path || '').trim();
|
||||
if (directPath) {
|
||||
return directPath;
|
||||
}
|
||||
const name = String(entry?.name || '').trim();
|
||||
return name ? `/dev/${name}` : '';
|
||||
}
|
||||
|
||||
function compareOpticalDevicePaths(left, right) {
|
||||
return String(left || '').localeCompare(String(right || ''), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
}
|
||||
|
||||
function buildMakeMkvIndexByDevicePath(entries = []) {
|
||||
const candidates = (Array.isArray(entries) ? entries : [])
|
||||
.filter((entry) => String(entry?.type || '').trim() === 'rom')
|
||||
.map((entry) => normalizeOpticalDevicePath(entry))
|
||||
.filter(Boolean);
|
||||
const sortedUniquePaths = Array.from(new Set(candidates)).sort(compareOpticalDevicePaths);
|
||||
const map = new Map();
|
||||
for (let index = 0; index < sortedUniquePaths.length; index += 1) {
|
||||
const devicePath = sortedUniquePaths[index];
|
||||
map.set(devicePath, index);
|
||||
const devName = devicePath.startsWith('/dev/') ? devicePath.slice(5) : '';
|
||||
if (devName) {
|
||||
map.set(devName, index);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildSignature(info) {
|
||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||
}
|
||||
@@ -159,6 +193,11 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isIsoLikeFsType(rawFsType) {
|
||||
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||
return fstype.includes('iso9660') || fstype.includes('cdfs');
|
||||
}
|
||||
|
||||
function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
||||
const normalizedKey = String(key || '').trim().toUpperCase();
|
||||
@@ -170,14 +209,44 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
|
||||
if (hasFlag('ID_CDROM_MEDIA_BD')) {
|
||||
const parseTrackCount = (rawValue) => {
|
||||
const normalized = String(rawValue ?? '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const hasExactFlag = (key) => flags[String(key || '').trim().toUpperCase()] === '1';
|
||||
// Only use exact media-presence keys here. Prefix matching would also catch
|
||||
// drive capability flags (e.g. ID_CDROM_MEDIA_BD_R=1) and misclassify DVDs
|
||||
// in BD-capable drives as Blu-ray media.
|
||||
const hasBD = hasExactFlag('ID_CDROM_MEDIA_BD');
|
||||
const hasDVD = hasExactFlag('ID_CDROM_MEDIA_DVD');
|
||||
const hasCD = hasExactFlag('ID_CDROM_MEDIA_CD');
|
||||
const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO);
|
||||
const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA);
|
||||
const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0;
|
||||
const hasDataTracks = Number.isFinite(dataTrackCount) && dataTrackCount > 0;
|
||||
|
||||
// Prefer audio-CD detection when udev exposes track counters.
|
||||
if (hasCD && hasAudioTracks && !hasDataTracks) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasBD) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_DVD')) {
|
||||
if (hasDVD) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||
if (hasCD) {
|
||||
return 'cd';
|
||||
}
|
||||
return null;
|
||||
@@ -305,13 +374,67 @@ class DiskDetectionService extends EventEmitter {
|
||||
return String(devicePath || '').trim();
|
||||
}
|
||||
|
||||
lockDevice(devicePath, owner = null) {
|
||||
_resolveDeviceRealPath(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized || !normalized.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (fs.realpathSync && typeof fs.realpathSync.native === 'function') {
|
||||
return fs.realpathSync.native(normalized);
|
||||
}
|
||||
return fs.realpathSync(normalized);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_deviceBaseName(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
return String(parts[parts.length - 1] || '').trim();
|
||||
}
|
||||
|
||||
_isSameDevicePath(leftPath, rightPath) {
|
||||
const left = this.normalizeDevicePath(leftPath);
|
||||
const right = this.normalizeDevicePath(rightPath);
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
const leftReal = this._resolveDeviceRealPath(left);
|
||||
const rightReal = this._resolveDeviceRealPath(right);
|
||||
if (leftReal && rightReal && leftReal === rightReal) {
|
||||
return true;
|
||||
}
|
||||
const leftBase = this._deviceBaseName(left);
|
||||
const rightBase = this._deviceBaseName(right);
|
||||
if (leftBase && rightBase && leftBase === rightBase && /^sr\d+$/i.test(leftBase)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_resolveLockKey(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return this._resolveDeviceRealPath(normalized) || normalized;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized) || {
|
||||
lockDevice(devicePath, owner = null) {
|
||||
const lockKey = this._resolveLockKey(devicePath);
|
||||
if (!lockKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey) || {
|
||||
count: 0,
|
||||
owners: []
|
||||
};
|
||||
@@ -320,16 +443,16 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (owner) {
|
||||
entry.owners.push(owner);
|
||||
}
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
|
||||
logger.info('lock:add', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
|
||||
return {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
};
|
||||
}
|
||||
@@ -340,35 +463,76 @@ class DiskDetectionService extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys());
|
||||
const lockKey = (directKey && this.deviceLocks.has(directKey))
|
||||
? directKey
|
||||
: (candidateKeys.find((key) => this._isSameDevicePath(key, normalized)) || null);
|
||||
if (!lockKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.count = Math.max(0, entry.count - 1);
|
||||
if (entry.count === 0) {
|
||||
this.deviceLocks.delete(normalized);
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.info('lock:remove', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
logger.info('lock:decrement', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
}
|
||||
|
||||
forceUnlockDevice(devicePath, options = {}) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys())
|
||||
.filter((key) => this._isSameDevicePath(key, normalized));
|
||||
if (candidateKeys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const lockKey of candidateKeys) {
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
const previousCount = Math.max(0, Number(entry?.count) || 0);
|
||||
removed += previousCount;
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: lockKey,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
isDeviceLocked(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.deviceLocks.has(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
if (directKey && this.deviceLocks.has(directKey)) {
|
||||
return true;
|
||||
}
|
||||
return Array.from(this.deviceLocks.keys()).some((key) => this._isSameDevicePath(key, normalized));
|
||||
}
|
||||
|
||||
getActiveLocks() {
|
||||
@@ -479,6 +643,26 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Supplement: any drive already tracked in detectedDiscs that was not found by auto-scan
|
||||
// gets a second chance via detectExplicit (which includes the sysfs-size fallback).
|
||||
// This prevents polling from falsely emitting discRemoved for drives that were manually
|
||||
// rescanned but cannot be auto-detected (e.g. VM/passthrough devices invisible to lsblk).
|
||||
const foundPaths = new Set(autoResults.map((d) => String(d.path || '')));
|
||||
const supplementChecks = [];
|
||||
for (const [trackedPath] of this.detectedDiscs) {
|
||||
if (!trackedPath.startsWith('__virtual__') && !foundPaths.has(trackedPath)) {
|
||||
supplementChecks.push(this.detectExplicit(trackedPath));
|
||||
}
|
||||
}
|
||||
if (supplementChecks.length > 0) {
|
||||
const supplementResults = await Promise.all(supplementChecks);
|
||||
for (const result of supplementResults) {
|
||||
if (result) {
|
||||
autoResults.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return autoResults;
|
||||
}
|
||||
|
||||
@@ -488,6 +672,19 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (!normalized) {
|
||||
return { present: false, emitted: 'none', device: null };
|
||||
}
|
||||
if (this.isDeviceLocked(normalized)) {
|
||||
const existing = this.detectedDiscs.get(normalized) || null;
|
||||
logger.info('rescan-drive:skip-locked', {
|
||||
devicePath: normalized,
|
||||
activeLocks: this.getActiveLocks()
|
||||
});
|
||||
return {
|
||||
present: Boolean(existing),
|
||||
emitted: 'none',
|
||||
device: existing,
|
||||
locked: true
|
||||
};
|
||||
}
|
||||
try {
|
||||
logger.info('rescan-drive:requested', { devicePath: normalized });
|
||||
const detected = await this.detectExplicit(normalized);
|
||||
@@ -551,6 +748,16 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve currently tracked locked devices that are intentionally skipped
|
||||
// by detectAll* while a rip lock is active.
|
||||
for (const [devicePath, device] of this.detectedDiscs) {
|
||||
if (newMap.has(devicePath) || !this.isDeviceLocked(devicePath)) {
|
||||
continue;
|
||||
}
|
||||
newMap.set(devicePath, device);
|
||||
results.push({ path: devicePath, emitted: 'none', device, locked: true });
|
||||
}
|
||||
|
||||
// Check for removed devices
|
||||
for (const [devicePath, device] of this.detectedDiscs) {
|
||||
if (!newMap.has(devicePath)) {
|
||||
@@ -590,16 +797,36 @@ class DiskDetectionService extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
const inferredIndex = Number(
|
||||
makemkvIndexByPath.get(devicePath)
|
||||
?? makemkvIndexByPath.get(match.name || '')
|
||||
?? match.makemkvIndex
|
||||
);
|
||||
|
||||
// Always call checkMediaPresent to get the filesystem type (needed for accurate
|
||||
// mediaProfile detection). Use lsblk SIZE as fallback presence indicator for
|
||||
// drives where blkid/udevadm fail (VM/passthrough).
|
||||
const mediaState = await this.checkMediaPresent(devicePath);
|
||||
if (!mediaState.hasMedia) {
|
||||
const hasSizeMedia = (match.sizeBytes || 0) > 0;
|
||||
if (!mediaState.hasMedia && !hasSizeMedia) {
|
||||
logger.debug('detect:explicit:no-media', { devicePath });
|
||||
return null;
|
||||
}
|
||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||
logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes });
|
||||
}
|
||||
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||
const discLabel = await this.getDiscLabel(devicePath);
|
||||
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null;
|
||||
// Preserve explicit audio-CD detection from checkMediaPresent even if lsblk
|
||||
// reports ambiguous optical fs markers like iso9660/cdfs.
|
||||
const detectedFsType = String(
|
||||
mediaType === 'audio_cd'
|
||||
? mediaType
|
||||
: (match.fstype || mediaType || '')
|
||||
).trim() || null;
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||
discLabel,
|
||||
@@ -619,7 +846,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: match.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(match.name || devicePath)
|
||||
index: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||
? Math.trunc(inferredIndex)
|
||||
: this.guessDiscIndex(match.name || devicePath)
|
||||
};
|
||||
logger.debug('detect:explicit:success', { detected });
|
||||
return detected;
|
||||
@@ -627,6 +856,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
|
||||
async detectAuto() {
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||
|
||||
for (const item of romCandidates) {
|
||||
@@ -647,8 +877,13 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (!mediaState.hasMedia) {
|
||||
continue;
|
||||
}
|
||||
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||
const discLabel = await this.getDiscLabel(path);
|
||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
||||
const detectedFsType = String(
|
||||
mediaType === 'audio_cd'
|
||||
? mediaType
|
||||
: (item.fstype || mediaType || '')
|
||||
).trim() || null;
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(path, {
|
||||
discLabel,
|
||||
@@ -657,6 +892,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
fstype: detectedFsType,
|
||||
mountpoint: item.mountpoint
|
||||
});
|
||||
const detectedIndex = Number(
|
||||
makemkvIndexByPath.get(path)
|
||||
?? makemkvIndexByPath.get(item.name || '')
|
||||
?? item.makemkvIndex
|
||||
);
|
||||
|
||||
const detected = {
|
||||
mode: 'auto',
|
||||
@@ -668,7 +908,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: item.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(item.name)
|
||||
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||
? Math.trunc(detectedIndex)
|
||||
: this.guessDiscIndex(item.name)
|
||||
};
|
||||
logger.debug('detect:auto:success', { detected });
|
||||
return detected;
|
||||
@@ -680,6 +922,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
|
||||
async detectAllAuto() {
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||
const results = [];
|
||||
|
||||
@@ -697,12 +940,25 @@ class DiskDetectionService extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always call checkMediaPresent to get the filesystem type (needed for accurate
|
||||
// mediaProfile detection via inferMediaProfile). Use lsblk SIZE as a fallback
|
||||
// presence indicator for drives where blkid/udevadm fail (VM/passthrough).
|
||||
const mediaState = await this.checkMediaPresent(path);
|
||||
if (!mediaState.hasMedia) {
|
||||
const hasSizeMedia = item.sizeBytes > 0;
|
||||
if (!mediaState.hasMedia && !hasSizeMedia) {
|
||||
logger.debug('detect:all-auto:no-media', { path, sizeBytes: item.sizeBytes });
|
||||
continue;
|
||||
}
|
||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||
logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes });
|
||||
}
|
||||
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||
const discLabel = await this.getDiscLabel(path);
|
||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
||||
const detectedFsType = String(
|
||||
mediaType === 'audio_cd'
|
||||
? mediaType
|
||||
: (item.fstype || mediaType || '')
|
||||
).trim() || null;
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(path, {
|
||||
discLabel,
|
||||
@@ -711,6 +967,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
fstype: detectedFsType,
|
||||
mountpoint: item.mountpoint
|
||||
});
|
||||
const detectedIndex = Number(
|
||||
makemkvIndexByPath.get(path)
|
||||
?? makemkvIndexByPath.get(item.name || '')
|
||||
?? item.makemkvIndex
|
||||
);
|
||||
|
||||
const detected = {
|
||||
mode: 'auto',
|
||||
@@ -722,7 +983,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: item.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(item.name)
|
||||
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||
? Math.trunc(detectedIndex)
|
||||
: this.guessDiscIndex(item.name)
|
||||
};
|
||||
logger.debug('detect:all-auto:found', { detected });
|
||||
results.push(detected);
|
||||
@@ -736,8 +999,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('lsblk', [
|
||||
'-J',
|
||||
'-b',
|
||||
'-o',
|
||||
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
|
||||
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL,SIZE'
|
||||
]);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
||||
@@ -747,30 +1011,84 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: entry.mountpoint,
|
||||
fstype: entry.fstype,
|
||||
label: entry.label,
|
||||
model: entry.model
|
||||
model: entry.model,
|
||||
sizeBytes: Number(entry.size) || 0
|
||||
}));
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(devices);
|
||||
const withMakeMkvIndex = devices.map((entry) => {
|
||||
if (entry.type !== 'rom') {
|
||||
return { ...entry, makemkvIndex: null };
|
||||
}
|
||||
const devicePath = normalizeOpticalDevicePath(entry);
|
||||
const inferredIndex = Number(
|
||||
makemkvIndexByPath.get(devicePath)
|
||||
?? makemkvIndexByPath.get(entry.name || '')
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
makemkvIndex: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||
? Math.trunc(inferredIndex)
|
||||
: null
|
||||
};
|
||||
});
|
||||
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||
return devices;
|
||||
return withMakeMkvIndex;
|
||||
} catch (error) {
|
||||
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async probeAudioCdWithCdparanoia(devicePath, command = 'cdparanoia') {
|
||||
const cdparanoiaCmd = String(command || '').trim() || 'cdparanoia';
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(cdparanoiaCmd, ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd', { devicePath, cmd: cdparanoiaCmd, trackCount: tracks.length });
|
||||
return true;
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath, cmd: cdparanoiaCmd });
|
||||
return true;
|
||||
} catch (error) {
|
||||
const stderr = String(error?.stderr || '');
|
||||
const stdout = String(error?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd-from-error-streams', {
|
||||
devicePath,
|
||||
cmd: cdparanoiaCmd,
|
||||
trackCount: tracks.length
|
||||
});
|
||||
return true;
|
||||
}
|
||||
logger.debug('cdparanoia:no-audio-cd', {
|
||||
devicePath,
|
||||
cmd: cdparanoiaCmd,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkMediaPresent(devicePath) {
|
||||
let blkidType = null;
|
||||
let blkidError = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||
} catch (_error) {
|
||||
blkidError = String(_error?.message || _error || 'unknown');
|
||||
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||
}
|
||||
logger.info('check-media:blkid', { devicePath, blkidType, blkidError });
|
||||
|
||||
if (blkidType) {
|
||||
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
||||
return { hasMedia: true, type: blkidType };
|
||||
}
|
||||
|
||||
let hasOpticalMediaHintFromUdev = false;
|
||||
|
||||
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||
try {
|
||||
const { stdout } = await execFileAsync('udevadm', [
|
||||
@@ -787,13 +1105,26 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
|
||||
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
const inferredByUdev = inferMediaProfileFromUdevProperties(props);
|
||||
const audioTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO || '').trim(), 10);
|
||||
const dataTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_DATA || '').trim(), 10);
|
||||
logger.info('check-media:udevadm', {
|
||||
devicePath,
|
||||
inferredByUdev,
|
||||
audioTrackCount: Number.isFinite(audioTrackCount) ? audioTrackCount : null,
|
||||
dataTrackCount: Number.isFinite(dataTrackCount) ? dataTrackCount : null
|
||||
});
|
||||
if (inferredByUdev === 'cd') {
|
||||
logger.debug('udevadm:audio-cd', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') {
|
||||
logger.debug('udevadm:optical-media', { devicePath, inferredByUdev });
|
||||
// Keep this as a presence hint, but still probe cdparanoia. Some drives
|
||||
// expose mixed DVD/CD flags for audio CDs and would otherwise be
|
||||
// downgraded to "other" before TOC probing.
|
||||
hasOpticalMediaHintFromUdev = true;
|
||||
}
|
||||
} catch (_udevError) {
|
||||
// udevadm not available or failed – ignore
|
||||
}
|
||||
@@ -804,24 +1135,31 @@ class DiskDetectionService extends EventEmitter {
|
||||
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
||||
// Keep compatibility with previous behavior: exit 0 counts as media even
|
||||
// when TOC output format cannot be parsed.
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||
if (hasAudioCdToc) {
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
} catch (cdError) {
|
||||
const stderr = String(cdError?.stderr || '');
|
||||
const stdout = String(cdError?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
|
||||
if (hasOpticalMediaHintFromUdev) {
|
||||
return { hasMedia: true, type: null };
|
||||
}
|
||||
|
||||
// Final fallback: check block device size via sysfs.
|
||||
// In VM/passthrough environments udev metadata may be absent even though
|
||||
// the kernel reports a valid disc size (visible in lsblk). A non-zero
|
||||
// 512-byte block count means media is physically present.
|
||||
try {
|
||||
const devName = String(devicePath || '').split('/').pop();
|
||||
if (devName) {
|
||||
const sizeStr = fs.readFileSync(`/sys/block/${devName}/size`, 'utf8').trim();
|
||||
const sizeBlocks = parseInt(sizeStr, 10);
|
||||
if (Number.isFinite(sizeBlocks) && sizeBlocks > 0) {
|
||||
logger.info('check-media:sysfs-size', { devicePath, sizeBlocks });
|
||||
return { hasMedia: true, type: null };
|
||||
}
|
||||
}
|
||||
// cdparanoia failed and no TOC output could be parsed.
|
||||
} catch (_sysError) {
|
||||
// sysfs not available or device not found there
|
||||
}
|
||||
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||
@@ -930,7 +1268,15 @@ class DiskDetectionService extends EventEmitter {
|
||||
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
|
||||
// marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the
|
||||
// early return and fall through to the blkid check which uses the UDF version number.
|
||||
if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
|
||||
// Also guard: when hintFstype is empty (no filesystem info at all), the drive model
|
||||
// alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD.
|
||||
// In that case skip this early return and let blkid -p determine the actual disc type.
|
||||
if (
|
||||
hintFstype
|
||||
&& byFsTypeHint
|
||||
&& !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')
|
||||
&& !(isIsoLikeFsType(hintFstype) && byFsTypeHint === 'dvd')
|
||||
) {
|
||||
return byFsTypeHint;
|
||||
}
|
||||
|
||||
@@ -974,14 +1320,14 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
||||
if (byBlkidFsType) {
|
||||
if (byBlkidFsType && !(isIsoLikeFsType(type) && byBlkidFsType === 'dvd')) {
|
||||
return byBlkidFsType;
|
||||
}
|
||||
|
||||
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
||||
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
||||
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
||||
if (byBlkidFsTypeWithoutModel) {
|
||||
if (byBlkidFsTypeWithoutModel && !(isIsoLikeFsType(type) && byBlkidFsTypeWithoutModel === 'dvd')) {
|
||||
return byBlkidFsTypeWithoutModel;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -995,6 +1341,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
return udfHintFallback;
|
||||
}
|
||||
|
||||
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||
if (hasAudioCdToc) {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ class DownloadService {
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const pendingResumeIds = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||
@@ -136,11 +137,34 @@ class DownloadService {
|
||||
|
||||
let changed = false;
|
||||
if (item.status === 'queued' || item.status === 'processing') {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.';
|
||||
item.finishedAt = nowIso;
|
||||
changed = true;
|
||||
await this._safeUnlink(item.partialPath);
|
||||
const archiveExists = await this._pathExists(item.archivePath);
|
||||
if (archiveExists) {
|
||||
const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null);
|
||||
item.status = 'ready';
|
||||
item.errorMessage = null;
|
||||
item.finishedAt = item.finishedAt || nowIso;
|
||||
item.sizeBytes = Number.isFinite(Number(archiveStat?.size))
|
||||
? archiveStat.size
|
||||
: item.sizeBytes;
|
||||
changed = true;
|
||||
logger.warn('download:init:recovered-ready-archive', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
} else {
|
||||
item.status = 'queued';
|
||||
item.startedAt = null;
|
||||
item.finishedAt = null;
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = null;
|
||||
changed = true;
|
||||
pendingResumeIds.push(item.id);
|
||||
await this._safeUnlink(item.partialPath);
|
||||
logger.warn('download:init:requeue-interrupted-job', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
}
|
||||
} else if (item.status === 'ready') {
|
||||
const exists = await this._pathExists(item.archivePath);
|
||||
if (!exists) {
|
||||
@@ -157,6 +181,17 @@ class DownloadService {
|
||||
await this._persistItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingResumeIds.length > 0) {
|
||||
logger.warn('download:init:resume-pending-jobs', {
|
||||
count: pendingResumeIds.length
|
||||
});
|
||||
setImmediate(() => {
|
||||
for (const id of pendingResumeIds) {
|
||||
void this._startArchiveJob(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeLoadedItem(rawItem, fallbackDir) {
|
||||
@@ -260,9 +295,9 @@ class DownloadService {
|
||||
return this.items.get(normalizedId);
|
||||
}
|
||||
|
||||
async enqueueHistoryJob(jobId, target) {
|
||||
async enqueueHistoryJob(jobId, target, options = {}) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target);
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options);
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
'use strict';
|
||||
|
||||
const TITLE_KIND = Object.freeze({
|
||||
EPISODE_CANDIDATE: 'episode_candidate',
|
||||
PLAY_ALL: 'play_all',
|
||||
EXTRA: 'extra',
|
||||
DUPLICATE: 'duplicate',
|
||||
SHORT: 'short',
|
||||
UNKNOWN: 'unknown'
|
||||
});
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
minEpisodeMinutes: 18,
|
||||
maxEpisodeMinutes: 75,
|
||||
minChapterCount: 3,
|
||||
shortTitleMinutes: 5
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(rawValue) {
|
||||
const text = String(rawValue || '').trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
|
||||
}
|
||||
|
||||
function roundToStep(value, step) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || step <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(num / step) * step;
|
||||
}
|
||||
|
||||
function median(values = []) {
|
||||
const nums = (Array.isArray(values) ? values : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (nums.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const middle = Math.floor(nums.length / 2);
|
||||
if (nums.length % 2 === 1) {
|
||||
return nums[middle];
|
||||
}
|
||||
return (nums[middle - 1] + nums[middle]) / 2;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
|
||||
const raw = String(rawCode || '').trim().toLowerCase();
|
||||
if (raw && raw.length === 3) {
|
||||
return raw;
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim().toLowerCase();
|
||||
if (label.startsWith('de')) return 'deu';
|
||||
if (label.startsWith('en')) return 'eng';
|
||||
if (label.startsWith('fr')) return 'fra';
|
||||
if (label.startsWith('es')) return 'spa';
|
||||
if (label.startsWith('nl')) return 'nld';
|
||||
return raw || 'und';
|
||||
}
|
||||
|
||||
function normalizeSeriesLookupTitle(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/[_./]+/g, ' ')
|
||||
.replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ')
|
||||
.replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ')
|
||||
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}x\b/gi, ' ')
|
||||
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveSeriesLookupHint(inputs = []) {
|
||||
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return {
|
||||
value: String(entry.value || '').trim(),
|
||||
source: String(entry.source || '').trim() || 'unknown'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: String(entry || '').trim(),
|
||||
source: 'unknown'
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value);
|
||||
|
||||
for (const entry of normalizedInputs) {
|
||||
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!compactValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compactSeasonDiscMatch = compactValue.match(
|
||||
/(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i
|
||||
);
|
||||
const seasonMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
|
||||
if (!seasonMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonNumber = Number(seasonMatch[1] || 0);
|
||||
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i);
|
||||
const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null;
|
||||
const discNumber = discMatch
|
||||
? Number(compactDiscToken || discMatch[1] || 0) || null
|
||||
: null;
|
||||
|
||||
const baseTitle = normalizeSeriesLookupTitle(compactValue);
|
||||
if (!baseTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
query: baseTitle,
|
||||
seriesTitle: baseTitle,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
source: entry.source,
|
||||
sourceLabel: entry.value,
|
||||
confidence: discNumber ? 'high' : 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTitleRecord(title = {}) {
|
||||
const durationSeconds = Number(title.durationSeconds || 0);
|
||||
const chapterCount = Number(title.chapterCount || 0);
|
||||
const roundedDurationBucket = roundToStep(durationSeconds, 30);
|
||||
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
index: Number(title.index || 0),
|
||||
durationSeconds,
|
||||
durationLabel: String(title.durationLabel || '').trim() || null,
|
||||
chapterCount,
|
||||
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
|
||||
aspectRatio: String(title.aspectRatio || '').trim() || null,
|
||||
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
|
||||
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
|
||||
flags: Array.isArray(title.flags) ? title.flags : [],
|
||||
roundedDurationBucket,
|
||||
signature: JSON.stringify({
|
||||
duration: roundedDurationBucket,
|
||||
chapterCount,
|
||||
audioLanguages,
|
||||
subtitleLanguages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildBestEpisodeCluster(titles = [], options = {}) {
|
||||
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
|
||||
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
|
||||
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
|
||||
const candidates = titles.filter((title) =>
|
||||
title.durationSeconds >= minEpisodeSeconds
|
||||
&& title.durationSeconds <= maxEpisodeSeconds
|
||||
&& title.chapterCount >= minChapterCount
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
titles: [],
|
||||
medianDurationSeconds: 0,
|
||||
medianChapterCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bestCluster = [];
|
||||
for (const anchor of candidates) {
|
||||
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
|
||||
const cluster = candidates.filter((candidate) => {
|
||||
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
|
||||
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
|
||||
return durationGap <= durationTolerance && chapterGap <= 2;
|
||||
});
|
||||
|
||||
if (cluster.length > bestCluster.length) {
|
||||
bestCluster = cluster;
|
||||
continue;
|
||||
}
|
||||
if (cluster.length === bestCluster.length) {
|
||||
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
|
||||
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
|
||||
if (clusterMedian > bestMedian) {
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titles: bestCluster,
|
||||
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
|
||||
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
|
||||
};
|
||||
}
|
||||
|
||||
function classifyTitles(titles = [], cluster = {}, options = {}) {
|
||||
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
|
||||
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
|
||||
const duplicateFirstBySignature = new Map();
|
||||
|
||||
for (const title of titles) {
|
||||
if (!duplicateFirstBySignature.has(title.signature)) {
|
||||
duplicateFirstBySignature.set(title.signature, title.index);
|
||||
}
|
||||
}
|
||||
|
||||
const playAllCandidate = (() => {
|
||||
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
|
||||
return null;
|
||||
}
|
||||
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
|
||||
return titles
|
||||
.filter((title) =>
|
||||
!clusterTitleIds.has(Number(title.index))
|
||||
&& title.durationSeconds >= minPlayAllSeconds * 0.75
|
||||
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
|
||||
)
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
|
||||
})();
|
||||
|
||||
return titles.map((title) => {
|
||||
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
|
||||
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
|
||||
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
|
||||
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
|
||||
const kind = isPlayAll
|
||||
? TITLE_KIND.PLAY_ALL
|
||||
: isEpisodeCandidate
|
||||
? TITLE_KIND.EPISODE_CANDIDATE
|
||||
: !isFirstWithSignature
|
||||
? TITLE_KIND.DUPLICATE
|
||||
: isShort
|
||||
? TITLE_KIND.SHORT
|
||||
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
|
||||
|
||||
let confidence = 0.2;
|
||||
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
|
||||
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
|
||||
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
|
||||
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
|
||||
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
|
||||
|
||||
return {
|
||||
...title,
|
||||
kind,
|
||||
confidence,
|
||||
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
|
||||
? duplicateFirstBySignature.get(title.signature)
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscSignature(parsedScan = {}) {
|
||||
return JSON.stringify({
|
||||
discTitle: String(parsedScan.discTitle || '').trim() || null,
|
||||
discSerial: String(parsedScan.discSerial || '').trim() || null,
|
||||
titleCount: Number(parsedScan.titleCount || 0),
|
||||
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
|
||||
.map((title) => ({
|
||||
index: Number(title.index || 0),
|
||||
durationBucket: roundToStep(title.durationSeconds || 0, 30),
|
||||
chapterCount: Number(title.chapterCount || 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
|
||||
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
|
||||
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
|
||||
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
|
||||
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
|
||||
|
||||
const reasons = [];
|
||||
let confidence = 'low';
|
||||
let seriesLike = false;
|
||||
|
||||
if (episodeCount >= 3) {
|
||||
seriesLike = true;
|
||||
confidence = playAllCount > 0 ? 'high' : 'medium';
|
||||
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
|
||||
} else if (episodeCount === 2) {
|
||||
seriesLike = true;
|
||||
confidence = 'medium';
|
||||
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
|
||||
}
|
||||
|
||||
if (playAllCount > 0) {
|
||||
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
|
||||
}
|
||||
if (cluster.medianDurationSeconds > 0) {
|
||||
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
|
||||
}
|
||||
if (extrasCount > 0) {
|
||||
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesLike,
|
||||
confidence,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandBrakeScanText(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const parsed = {
|
||||
source: 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: null,
|
||||
discSerial: null,
|
||||
titleCount: 0,
|
||||
rawLineCount: lines.length,
|
||||
titles: []
|
||||
};
|
||||
|
||||
let currentTitle = null;
|
||||
const ensureCurrentTitle = (index) => {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
|
||||
return currentTitle;
|
||||
}
|
||||
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
|
||||
if (existing) {
|
||||
currentTitle = existing;
|
||||
return currentTitle;
|
||||
}
|
||||
currentTitle = {
|
||||
index: normalizedIndex,
|
||||
durationLabel: null,
|
||||
durationSeconds: 0,
|
||||
chapterCount: 0,
|
||||
chapterDurationsMs: [],
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
aspectRatio: null,
|
||||
flags: []
|
||||
};
|
||||
parsed.titles.push(currentTitle);
|
||||
return currentTitle;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discTitle = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discSerial = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+title\s+(\d+):/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
|
||||
if (match) {
|
||||
const title = ensureCurrentTitle(match[1]);
|
||||
if (title) {
|
||||
title.chapterCount = Number(match[2] || 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
|
||||
if (match && currentTitle) {
|
||||
const track = {
|
||||
languageLabel: String(match[1] || '').trim() || null,
|
||||
languageCode: normalizeLanguageCode(match[2], match[1])
|
||||
};
|
||||
if (/\[VOBSUB\]/i.test(line)) {
|
||||
currentTitle.subtitleTracks.push(track);
|
||||
} else {
|
||||
currentTitle.audioTracks.push(track);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+ignoring title \(too short\)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.flags.push('ignored_too_short');
|
||||
}
|
||||
}
|
||||
|
||||
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
|
||||
if (!parsed.titleCount) {
|
||||
parsed.titleCount = parsed.titles.length;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function analyzeParsedScan(parsedScan = {}, options = {}) {
|
||||
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
|
||||
const cluster = buildBestEpisodeCluster(titles, options);
|
||||
const classifiedTitles = classifyTitles(titles, cluster, options);
|
||||
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
|
||||
|
||||
return {
|
||||
source: parsedScan.source || 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: parsedScan.discTitle || null,
|
||||
discSerial: parsedScan.discSerial || null,
|
||||
titleCount: Number(parsedScan.titleCount || titles.length || 0),
|
||||
discSignature: buildDiscSignature({
|
||||
discTitle: parsedScan.discTitle,
|
||||
discSerial: parsedScan.discSerial,
|
||||
titleCount: parsedScan.titleCount,
|
||||
titles
|
||||
}),
|
||||
summary: {
|
||||
...summary,
|
||||
titleCount: classifiedTitles.length,
|
||||
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
|
||||
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
|
||||
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
|
||||
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
|
||||
typicalEpisodeMinutes: cluster.medianDurationSeconds
|
||||
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
|
||||
: 0
|
||||
},
|
||||
titles: classifiedTitles
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeHandBrakeScan(rawOutput, options = {}) {
|
||||
const parsed = parseHandBrakeScanText(rawOutput);
|
||||
const analysis = analyzeParsedScan(parsed, options);
|
||||
return {
|
||||
parsed,
|
||||
analysis
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TITLE_KIND,
|
||||
parseHandBrakeScanText,
|
||||
analyzeParsedScan,
|
||||
analyzeHandBrakeScan,
|
||||
deriveSeriesLookupHint
|
||||
};
|
||||
@@ -7,12 +7,14 @@ const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('HWMON');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5000;
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
const MAX_INTERVAL_MS = 60000;
|
||||
const HISTORY_RETENTION_PREVIOUS_DAYS = 3;
|
||||
const DF_TIMEOUT_MS = 1800;
|
||||
const SENSORS_TIMEOUT_MS = 1800;
|
||||
const NVIDIA_SMI_TIMEOUT_MS = 1800;
|
||||
@@ -26,13 +28,35 @@ const RELEVANT_SETTINGS_KEYS = new Set([
|
||||
'movie_dir',
|
||||
'movie_dir_bluray',
|
||||
'movie_dir_dvd',
|
||||
'log_dir'
|
||||
'log_dir',
|
||||
'external_storage_paths'
|
||||
]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toLocalDayKey(inputDate = new Date()) {
|
||||
const date = inputDate instanceof Date ? inputDate : new Date(inputDate);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addLocalDays(inputDate, deltaDays) {
|
||||
const date = inputDate instanceof Date ? new Date(inputDate.getTime()) : new Date(inputDate);
|
||||
date.setDate(date.getDate() + Number(deltaDays || 0));
|
||||
return date;
|
||||
}
|
||||
|
||||
function isAbortError(error) {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return error.name === 'AbortError' || error.code === 'ABORT_ERR';
|
||||
}
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
@@ -57,6 +81,52 @@ function normalizePathSetting(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function parseExternalStoragePaths(rawValue) {
|
||||
const normalized = String(rawValue || '').trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = JSON.parse(normalized);
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const item of list) {
|
||||
const pathCandidate = typeof item === 'string'
|
||||
? item
|
||||
: (item && typeof item === 'object' ? item.path : '');
|
||||
const labelCandidate = item && typeof item === 'object'
|
||||
? (item.name ?? item.label ?? '')
|
||||
: '';
|
||||
const pathValue = normalizePathSetting(pathCandidate);
|
||||
const labelValue = normalizePathSetting(labelCandidate);
|
||||
if (!pathValue || seen.has(pathValue)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(pathValue);
|
||||
unique.push({
|
||||
path: pathValue,
|
||||
label: labelValue || null
|
||||
});
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function isRootPath(inputPath) {
|
||||
const normalized = String(inputPath || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const resolved = path.resolve(normalized);
|
||||
return resolved === path.parse(resolved).root;
|
||||
}
|
||||
|
||||
function clampIntervalMs(rawValue) {
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -290,6 +360,8 @@ class HardwareMonitorService {
|
||||
this.running = false;
|
||||
this.timer = null;
|
||||
this.pollInFlight = false;
|
||||
this.activePollAbortController = null;
|
||||
this.reloadSettingsSeq = 0;
|
||||
this.lastCpuTimes = null;
|
||||
this.sensorsCommandAvailable = null;
|
||||
this.nvidiaSmiAvailable = null;
|
||||
@@ -300,6 +372,7 @@ class HardwareMonitorService {
|
||||
sample: null,
|
||||
error: null
|
||||
};
|
||||
this.lastHistoryCleanupDayKey = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -343,6 +416,8 @@ class HardwareMonitorService {
|
||||
}
|
||||
|
||||
async reloadFromSettings(options = {}) {
|
||||
const requestSeq = this.reloadSettingsSeq + 1;
|
||||
this.reloadSettingsSeq = requestSeq;
|
||||
const forceBroadcast = Boolean(options?.forceBroadcast);
|
||||
const forceImmediatePoll = Boolean(options?.forceImmediatePoll);
|
||||
let settingsMap = {};
|
||||
@@ -352,6 +427,9 @@ class HardwareMonitorService {
|
||||
logger.warn('settings:load:failed', { error: errorToMeta(error) });
|
||||
return this.getSnapshot();
|
||||
}
|
||||
if (requestSeq !== this.reloadSettingsSeq) {
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
const nextEnabled = toBoolean(settingsMap.hardware_monitoring_enabled);
|
||||
const nextIntervalMs = clampIntervalMs(settingsMap.hardware_monitoring_interval_ms);
|
||||
@@ -371,11 +449,19 @@ class HardwareMonitorService {
|
||||
|
||||
if (!this.enabled) {
|
||||
this.stopPolling();
|
||||
let storage = [];
|
||||
try {
|
||||
storage = await this.collectStorageMetrics();
|
||||
} catch (error) {
|
||||
logger.debug('storage:collect:disabled-mode:failed', { error: errorToMeta(error) });
|
||||
}
|
||||
this.lastSnapshot = {
|
||||
enabled: false,
|
||||
intervalMs: this.intervalMs,
|
||||
updatedAt: nowIso(),
|
||||
sample: null,
|
||||
sample: {
|
||||
storage
|
||||
},
|
||||
error: null
|
||||
};
|
||||
this.broadcastUpdate();
|
||||
@@ -405,13 +491,16 @@ class HardwareMonitorService {
|
||||
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
||||
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
||||
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
||||
const externalStoragePaths = parseExternalStoragePaths(sourceMap.external_storage_paths);
|
||||
const monitoredPaths = [];
|
||||
|
||||
const addPath = (key, label, monitoredPath) => {
|
||||
const addPath = (key, label, monitoredPath, options = {}) => {
|
||||
monitoredPaths.push({
|
||||
key,
|
||||
label,
|
||||
path: normalizePathSetting(monitoredPath)
|
||||
path: normalizePathSetting(monitoredPath),
|
||||
hideWhenUnavailable: Boolean(options?.hideWhenUnavailable),
|
||||
requireDedicatedMount: Boolean(options?.requireDedicatedMount)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -431,6 +520,19 @@ class HardwareMonitorService {
|
||||
}
|
||||
|
||||
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
|
||||
externalStoragePaths.forEach((externalEntry, index) => {
|
||||
const externalPath = normalizePathSetting(externalEntry?.path);
|
||||
const externalLabel = normalizePathSetting(externalEntry?.label) || `Externer Speicher ${index + 1}`;
|
||||
addPath(
|
||||
`external_storage_path_${index}`,
|
||||
externalLabel,
|
||||
externalPath,
|
||||
{
|
||||
hideWhenUnavailable: true,
|
||||
requireDedicatedMount: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return monitoredPaths;
|
||||
}
|
||||
@@ -458,6 +560,14 @@ class HardwareMonitorService {
|
||||
this.running = false;
|
||||
this.pollInFlight = false;
|
||||
this.lastCpuTimes = null;
|
||||
if (this.activePollAbortController) {
|
||||
try {
|
||||
this.activePollAbortController.abort();
|
||||
} catch (_error) {
|
||||
// ignore abort errors
|
||||
}
|
||||
this.activePollAbortController = null;
|
||||
}
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
@@ -490,8 +600,15 @@ class HardwareMonitorService {
|
||||
return;
|
||||
}
|
||||
this.pollInFlight = true;
|
||||
const pollAbortController = new AbortController();
|
||||
this.activePollAbortController = pollAbortController;
|
||||
try {
|
||||
const sample = await this.collectSample();
|
||||
const sample = await this.collectSample(pollAbortController.signal);
|
||||
try {
|
||||
await this.persistHistorySample(sample);
|
||||
} catch (historyError) {
|
||||
logger.warn('history:write:failed', { error: errorToMeta(historyError) });
|
||||
}
|
||||
this.lastSnapshot = {
|
||||
enabled: true,
|
||||
intervalMs: this.intervalMs,
|
||||
@@ -501,6 +618,10 @@ class HardwareMonitorService {
|
||||
};
|
||||
this.broadcastUpdate();
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('poll:aborted');
|
||||
return;
|
||||
}
|
||||
logger.warn('poll:failed', { error: errorToMeta(error) });
|
||||
this.lastSnapshot = {
|
||||
...this.lastSnapshot,
|
||||
@@ -511,6 +632,9 @@ class HardwareMonitorService {
|
||||
};
|
||||
this.broadcastUpdate();
|
||||
} finally {
|
||||
if (this.activePollAbortController === pollAbortController) {
|
||||
this.activePollAbortController = null;
|
||||
}
|
||||
this.pollInFlight = false;
|
||||
if (this.running && this.enabled) {
|
||||
this.scheduleNext(this.intervalMs);
|
||||
@@ -518,16 +642,107 @@ class HardwareMonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
buildHistoryRecord(sample = {}) {
|
||||
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
|
||||
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
|
||||
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
|
||||
const gpuDevices = Array.isArray(gpu.devices) ? gpu.devices : [];
|
||||
const primaryGpu = gpuDevices[0] || null;
|
||||
const capturedAt = new Date();
|
||||
|
||||
return {
|
||||
capturedAtIso: capturedAt.toISOString(),
|
||||
dayKey: toLocalDayKey(capturedAt),
|
||||
cpuUsagePercent: roundNumber(cpu?.overallUsagePercent, 1),
|
||||
cpuTemperatureC: roundNumber(cpu?.overallTemperatureC, 1),
|
||||
ramUsagePercent: roundNumber(memory?.usagePercent, 1),
|
||||
ramUsedBytes: Number.isFinite(Number(memory?.usedBytes)) ? Math.max(0, Math.round(Number(memory.usedBytes))) : null,
|
||||
ramTotalBytes: Number.isFinite(Number(memory?.totalBytes)) ? Math.max(0, Math.round(Number(memory.totalBytes))) : null,
|
||||
gpuUsagePercent: roundNumber(primaryGpu?.utilizationPercent, 1),
|
||||
gpuTemperatureC: roundNumber(primaryGpu?.temperatureC, 1),
|
||||
vramUsedBytes: Number.isFinite(Number(primaryGpu?.memoryUsedBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryUsedBytes))) : null,
|
||||
vramTotalBytes: Number.isFinite(Number(primaryGpu?.memoryTotalBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryTotalBytes))) : null
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupHistoryForDay(currentDayKey, referenceDate = new Date()) {
|
||||
const dayKey = String(currentDayKey || '').trim();
|
||||
if (!dayKey || this.lastHistoryCleanupDayKey === dayKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffDayKey = toLocalDayKey(addLocalDays(referenceDate, -HISTORY_RETENTION_PREVIOUS_DAYS));
|
||||
const db = await getDb();
|
||||
const result = await db.run(
|
||||
`
|
||||
DELETE FROM hardware_metrics_history
|
||||
WHERE day_key < ?
|
||||
`,
|
||||
[cutoffDayKey]
|
||||
);
|
||||
this.lastHistoryCleanupDayKey = dayKey;
|
||||
logger.info('history:cleanup', {
|
||||
dayKey,
|
||||
cutoffDayKey,
|
||||
deletedRows: Number(result?.changes || 0)
|
||||
});
|
||||
}
|
||||
|
||||
async persistHistorySample(sample = {}) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
const record = this.buildHistoryRecord(sample);
|
||||
if (!record.dayKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cleanupHistoryForDay(record.dayKey, new Date(record.capturedAtIso));
|
||||
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO hardware_metrics_history (
|
||||
captured_at,
|
||||
day_key,
|
||||
cpu_usage_percent,
|
||||
cpu_temperature_c,
|
||||
ram_usage_percent,
|
||||
ram_used_bytes,
|
||||
ram_total_bytes,
|
||||
gpu_usage_percent,
|
||||
gpu_temperature_c,
|
||||
vram_used_bytes,
|
||||
vram_total_bytes
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
record.capturedAtIso,
|
||||
record.dayKey,
|
||||
record.cpuUsagePercent,
|
||||
record.cpuTemperatureC,
|
||||
record.ramUsagePercent,
|
||||
record.ramUsedBytes,
|
||||
record.ramTotalBytes,
|
||||
record.gpuUsagePercent,
|
||||
record.gpuTemperatureC,
|
||||
record.vramUsedBytes,
|
||||
record.vramTotalBytes
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
broadcastUpdate() {
|
||||
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
|
||||
}
|
||||
|
||||
async collectSample() {
|
||||
async collectSample(signal) {
|
||||
const memory = this.collectMemoryMetrics();
|
||||
const [cpu, gpu, storage] = await Promise.all([
|
||||
this.collectCpuMetrics(),
|
||||
this.collectGpuMetrics(),
|
||||
this.collectStorageMetrics()
|
||||
this.collectCpuMetrics(signal),
|
||||
this.collectGpuMetrics(signal),
|
||||
this.collectStorageMetrics(signal)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -602,13 +817,13 @@ class HardwareMonitorService {
|
||||
};
|
||||
}
|
||||
|
||||
async collectCpuMetrics() {
|
||||
async collectCpuMetrics(signal) {
|
||||
const cpus = os.cpus() || [];
|
||||
const currentTimes = this.getCpuTimes();
|
||||
const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []);
|
||||
this.lastCpuTimes = currentTimes;
|
||||
|
||||
const tempMetrics = await this.collectCpuTemperatures();
|
||||
const tempMetrics = await this.collectCpuTemperatures(signal);
|
||||
const tempByCoreIndex = new Map(
|
||||
(tempMetrics.perCore || []).map((item) => [Number(item.index), item.temperatureC])
|
||||
);
|
||||
@@ -645,8 +860,8 @@ class HardwareMonitorService {
|
||||
};
|
||||
}
|
||||
|
||||
async collectCpuTemperatures() {
|
||||
const sensors = await this.collectTempsViaSensors();
|
||||
async collectCpuTemperatures(signal) {
|
||||
const sensors = await this.collectTempsViaSensors(signal);
|
||||
if (sensors.available) {
|
||||
return sensors;
|
||||
}
|
||||
@@ -669,7 +884,7 @@ class HardwareMonitorService {
|
||||
};
|
||||
}
|
||||
|
||||
async collectTempsViaSensors() {
|
||||
async collectTempsViaSensors(signal) {
|
||||
if (this.sensorsCommandAvailable === false) {
|
||||
return {
|
||||
source: 'sensors',
|
||||
@@ -682,7 +897,8 @@ class HardwareMonitorService {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sensors', ['-j'], {
|
||||
timeout: SENSORS_TIMEOUT_MS,
|
||||
maxBuffer: 2 * 1024 * 1024
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
signal
|
||||
});
|
||||
this.sensorsCommandAvailable = true;
|
||||
const parsed = JSON.parse(String(stdout || '{}'));
|
||||
@@ -693,6 +909,9 @@ class HardwareMonitorService {
|
||||
...mapTemperatureCandidates(preferred)
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (isCommandMissingError(error)) {
|
||||
this.sensorsCommandAvailable = false;
|
||||
}
|
||||
@@ -800,7 +1019,7 @@ class HardwareMonitorService {
|
||||
};
|
||||
}
|
||||
|
||||
async collectGpuMetrics() {
|
||||
async collectGpuMetrics(signal) {
|
||||
if (this.nvidiaSmiAvailable === false) {
|
||||
return {
|
||||
source: 'nvidia-smi',
|
||||
@@ -819,7 +1038,8 @@ class HardwareMonitorService {
|
||||
],
|
||||
{
|
||||
timeout: NVIDIA_SMI_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024
|
||||
maxBuffer: 1024 * 1024,
|
||||
signal
|
||||
}
|
||||
);
|
||||
|
||||
@@ -832,6 +1052,10 @@ class HardwareMonitorService {
|
||||
.filter(Boolean);
|
||||
|
||||
if (devices.length === 0) {
|
||||
const fallback = this.getLastKnownGpuMetrics();
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
source: 'nvidia-smi',
|
||||
available: false,
|
||||
@@ -847,26 +1071,50 @@ class HardwareMonitorService {
|
||||
message: null
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const commandMissing = isCommandMissingError(error);
|
||||
if (commandMissing) {
|
||||
this.nvidiaSmiAvailable = false;
|
||||
}
|
||||
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
|
||||
const fallback = this.getLastKnownGpuMetrics();
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
source: 'nvidia-smi',
|
||||
available: false,
|
||||
devices: [],
|
||||
message: commandMissing
|
||||
? 'nvidia-smi ist nicht verfuegbar.'
|
||||
: (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220))
|
||||
: 'GPU-Daten derzeit nicht verfuegbar.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async collectStorageMetrics() {
|
||||
getLastKnownGpuMetrics() {
|
||||
const previousGpu = this.lastSnapshot?.sample?.gpu;
|
||||
if (!previousGpu || typeof previousGpu !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(previousGpu.devices) || previousGpu.devices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...previousGpu,
|
||||
message: null
|
||||
};
|
||||
}
|
||||
|
||||
async collectStorageMetrics(signal) {
|
||||
const list = [];
|
||||
for (const entry of this.monitoredPaths) {
|
||||
list.push(await this.collectStorageForPath(entry));
|
||||
const metric = await this.collectStorageForPath(entry, signal);
|
||||
if (metric && !metric.hidden) {
|
||||
list.push(metric);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -893,11 +1141,28 @@ class HardwareMonitorService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async collectStorageForPath(entry) {
|
||||
async collectStorageForPath(entry, signal) {
|
||||
const key = String(entry?.key || '');
|
||||
const label = String(entry?.label || key || 'Pfad');
|
||||
const rawPath = String(entry?.path || '').trim();
|
||||
const hideWhenUnavailable = Boolean(entry?.hideWhenUnavailable);
|
||||
const requireDedicatedMount = Boolean(entry?.requireDedicatedMount);
|
||||
|
||||
const hiddenResult = (base = {}) => ({
|
||||
key,
|
||||
label,
|
||||
hidden: true,
|
||||
...base
|
||||
});
|
||||
|
||||
if (!rawPath) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: null,
|
||||
queryPath: null,
|
||||
exists: false
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -919,6 +1184,13 @@ class HardwareMonitorService {
|
||||
const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
|
||||
|
||||
if (!queryPath) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath: null,
|
||||
exists: false
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -938,10 +1210,18 @@ class HardwareMonitorService {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('df', ['-Pk', queryPath], {
|
||||
timeout: DF_TIMEOUT_MS,
|
||||
maxBuffer: 256 * 1024
|
||||
maxBuffer: 256 * 1024,
|
||||
signal
|
||||
});
|
||||
const parsed = parseDfStats(stdout);
|
||||
if (!parsed) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath,
|
||||
exists
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -957,6 +1237,18 @@ class HardwareMonitorService {
|
||||
error: 'Dateisystemdaten konnten nicht geparst werden.'
|
||||
};
|
||||
}
|
||||
const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint;
|
||||
const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint);
|
||||
// External storage entries must only be visible when a dedicated filesystem
|
||||
// mount is present. Existing directories alone are not enough.
|
||||
const shouldShowExternal = hasDedicatedMount;
|
||||
if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath,
|
||||
exists
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -965,10 +1257,21 @@ class HardwareMonitorService {
|
||||
queryPath,
|
||||
exists,
|
||||
...parsed,
|
||||
mountPoint: mountPoint || null,
|
||||
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath,
|
||||
exists
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
|
||||
+6657
-284
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logLevel } = require('../config');
|
||||
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
||||
const { getServerTimestamp } = require('../utils/serverTime');
|
||||
|
||||
const LEVELS = {
|
||||
debug: 10,
|
||||
@@ -11,6 +12,23 @@ const LEVELS = {
|
||||
};
|
||||
|
||||
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
||||
let consoleConfig = {
|
||||
enabled: true,
|
||||
levels: {
|
||||
debug: true,
|
||||
info: true,
|
||||
warn: true,
|
||||
error: true
|
||||
},
|
||||
http: true
|
||||
};
|
||||
|
||||
function normalizeBoolean(value, fallback = true) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function ensureLogDir(logDirPath) {
|
||||
try {
|
||||
@@ -106,7 +124,7 @@ function emit(level, scope, message, meta = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const timestamp = getServerTimestamp();
|
||||
const payload = {
|
||||
timestamp,
|
||||
level: normLevel,
|
||||
@@ -118,6 +136,10 @@ function emit(level, scope, message, meta = null) {
|
||||
const line = safeJson(payload);
|
||||
writeLine(line);
|
||||
|
||||
if (!shouldEmitToConsole(normLevel, scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
||||
if (normLevel === 'error') {
|
||||
console.error(print, payload.meta ? payload.meta : '');
|
||||
@@ -128,6 +150,24 @@ function emit(level, scope, message, meta = null) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitToConsole(level, scope) {
|
||||
if (!consoleConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedLevel = String(level || 'info').toLowerCase();
|
||||
if (consoleConfig.levels[normalizedLevel] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedScope = String(scope || '').trim().toUpperCase();
|
||||
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function child(scope) {
|
||||
return {
|
||||
debug(message, meta) {
|
||||
@@ -145,7 +185,55 @@ function child(scope) {
|
||||
};
|
||||
}
|
||||
|
||||
function setConsoleOutputEnabled(enabled) {
|
||||
consoleConfig.enabled = Boolean(enabled);
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function isConsoleOutputEnabled() {
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function configureConsoleOutput(options = {}) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
|
||||
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
|
||||
}
|
||||
|
||||
if (opts.levels && typeof opts.levels === 'object') {
|
||||
const sourceLevels = opts.levels;
|
||||
for (const level of Object.keys(LEVELS)) {
|
||||
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
|
||||
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
|
||||
consoleConfig.http = normalizeBoolean(opts.http, true);
|
||||
}
|
||||
|
||||
return getConsoleOutputConfig();
|
||||
}
|
||||
|
||||
function getConsoleOutputConfig() {
|
||||
return {
|
||||
enabled: Boolean(consoleConfig.enabled),
|
||||
levels: {
|
||||
debug: Boolean(consoleConfig.levels.debug),
|
||||
info: Boolean(consoleConfig.levels.info),
|
||||
warn: Boolean(consoleConfig.levels.warn),
|
||||
error: Boolean(consoleConfig.levels.error)
|
||||
},
|
||||
http: Boolean(consoleConfig.http)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
child,
|
||||
emit
|
||||
emit,
|
||||
setConsoleOutputEnabled,
|
||||
isConsoleOutputEnabled,
|
||||
configureConsoleOutput,
|
||||
getConsoleOutputConfig
|
||||
};
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('MAKEMKV_KEY');
|
||||
const backendPackage = require('../../package.json');
|
||||
|
||||
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json';
|
||||
const MAKEMKV_BETA_KEY_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1';
|
||||
const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000;
|
||||
const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max(
|
||||
1,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS || 24)
|
||||
);
|
||||
|
||||
const RIPSTER_VERSION = String(backendPackage?.version || 'dev').trim() || 'dev';
|
||||
const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (linux/manual; Node/manual) +https://github.com/mboehmlaender/ripster`;
|
||||
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|
||||
|| DEFAULT_USER_AGENT;
|
||||
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
|
||||
1000,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
||||
);
|
||||
const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max(
|
||||
30 * 1000,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 1000))
|
||||
);
|
||||
|
||||
let cachedBetaKeyEntry = null;
|
||||
let blockedUntilMs = 0;
|
||||
|
||||
function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') {
|
||||
const betaKey = String(value || '').trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{15,}$/.test(betaKey)) {
|
||||
const error = new Error(invalidMessage);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
return betaKey;
|
||||
}
|
||||
|
||||
|
||||
function normalizeCacheEntry(rawEntry) {
|
||||
if (!rawEntry || typeof rawEntry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let key = '';
|
||||
try {
|
||||
key = parseBetaKeyCandidate(rawEntry.key || rawEntry.betaKey || '', 'Betakey-Cache ist ungültig.');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_API_URL).trim() || MAKEMKV_BETA_KEY_API_URL;
|
||||
const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim();
|
||||
const validUntilMs = Date.parse(validUntilIsoRaw);
|
||||
|
||||
if (!Number.isFinite(validUntilMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchedAtIsoRaw = String(rawEntry.fetchedAt || rawEntry.fetchedAtIso || '').trim();
|
||||
const fetchedAtMs = Date.parse(fetchedAtIsoRaw);
|
||||
|
||||
return {
|
||||
key,
|
||||
sourceUrl,
|
||||
validUntil: new Date(validUntilMs).toISOString(),
|
||||
validUntilMs,
|
||||
fetchedAt: Number.isFinite(fetchedAtMs) ? new Date(fetchedAtMs).toISOString() : null,
|
||||
fetchedAtMs: Number.isFinite(fetchedAtMs) ? fetchedAtMs : null
|
||||
};
|
||||
}
|
||||
|
||||
function isCacheEntryValid(entry, nowMs = Date.now()) {
|
||||
return Boolean(
|
||||
entry
|
||||
&& typeof entry === 'object'
|
||||
&& typeof entry.key === 'string'
|
||||
&& entry.key.length >= 16
|
||||
&& Number.isFinite(entry.validUntilMs)
|
||||
&& entry.validUntilMs > (nowMs + MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function buildPublicResult(entry, options = {}) {
|
||||
return {
|
||||
key: entry.key,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
validUntil: entry.validUntil,
|
||||
fetchedAt: entry.fetchedAt || null,
|
||||
cached: options.cached === true,
|
||||
stale: options.stale === true
|
||||
};
|
||||
}
|
||||
|
||||
function setInMemoryCache(entry) {
|
||||
cachedBetaKeyEntry = normalizeCacheEntry(entry);
|
||||
}
|
||||
|
||||
async function readPersistentCacheEntry() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const row = await db.get('SELECT value FROM user_prefs WHERE key = ? LIMIT 1', [MAKEMKV_BETA_KEY_CACHE_PREF_KEY]);
|
||||
if (!row?.value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(String(row.value));
|
||||
return normalizeCacheEntry(parsed);
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:cache-db-read-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCacheEntry(entry) {
|
||||
const normalized = normalizeCacheEntry(entry);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
key: normalized.key,
|
||||
sourceUrl: normalized.sourceUrl,
|
||||
validUntil: normalized.validUntil,
|
||||
fetchedAt: normalized.fetchedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
[MAKEMKV_BETA_KEY_CACHE_PREF_KEY, payload]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:cache-db-write-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryUsePersistentCache(nowMs = Date.now()) {
|
||||
const persistent = await readPersistentCacheEntry();
|
||||
if (!isCacheEntryValid(persistent, nowMs)) {
|
||||
return null;
|
||||
}
|
||||
setInMemoryCache(persistent);
|
||||
return buildPublicResult(persistent, { cached: true, stale: false });
|
||||
}
|
||||
|
||||
async function getCachedBetaKey(options = {}) {
|
||||
const nowMs = Date.now();
|
||||
const allowExpired = options?.allowExpired !== false;
|
||||
|
||||
if (cachedBetaKeyEntry) {
|
||||
const stale = !isCacheEntryValid(cachedBetaKeyEntry, nowMs);
|
||||
if (!stale || allowExpired) {
|
||||
return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale });
|
||||
}
|
||||
}
|
||||
|
||||
const persistent = await readPersistentCacheEntry();
|
||||
if (!persistent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setInMemoryCache(persistent);
|
||||
const stale = !isCacheEntryValid(persistent, nowMs);
|
||||
if (stale && !allowExpired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildPublicResult(persistent, { cached: true, stale });
|
||||
}
|
||||
|
||||
function createRateLimitError(retryAfterMs = 0) {
|
||||
const parsedRetryMs = Number(retryAfterMs || 0);
|
||||
const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS;
|
||||
blockedUntilMs = Math.max(blockedUntilMs, Date.now() + waitMs);
|
||||
|
||||
const retryAtIso = new Date(blockedUntilMs).toISOString();
|
||||
const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`);
|
||||
error.statusCode = 429;
|
||||
error.retryAt = retryAtIso;
|
||||
error.retryAfterMs = Math.max(1000, blockedUntilMs - Date.now());
|
||||
return error;
|
||||
}
|
||||
|
||||
function buildExistingBackoffError() {
|
||||
const retryAtMs = Math.max(Date.now() + 1000, Number(blockedUntilMs || 0));
|
||||
const retryAtIso = new Date(retryAtMs).toISOString();
|
||||
const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`);
|
||||
error.statusCode = 429;
|
||||
error.retryAt = retryAtIso;
|
||||
error.retryAfterMs = Math.max(1000, retryAtMs - Date.now());
|
||||
return error;
|
||||
}
|
||||
|
||||
function resolveValidityOrFallback(validUntilIso, sourceUrl) {
|
||||
const parsedMs = Date.parse(String(validUntilIso || '').trim());
|
||||
if (Number.isFinite(parsedMs)) {
|
||||
return new Date(parsedMs).toISOString();
|
||||
}
|
||||
|
||||
const fallbackIso = new Date(Date.now() + MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS * 60 * 60 * 1000).toISOString();
|
||||
logger.warn('beta-key:validity-parse-fallback', {
|
||||
sourceUrl: sourceUrl || null,
|
||||
fallbackIso
|
||||
});
|
||||
return fallbackIso;
|
||||
}
|
||||
|
||||
function normalizeRegistrationKey(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function getMakeMKVConfigDir(homeDir = os.homedir()) {
|
||||
return path.join(String(homeDir || '').trim() || os.homedir(), '.MakeMKV');
|
||||
}
|
||||
|
||||
function getMakeMKVSettingsFilePath(homeDir = os.homedir()) {
|
||||
return path.join(getMakeMKVConfigDir(homeDir), 'settings.conf');
|
||||
}
|
||||
|
||||
function escapeKeyForSettingsFile(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildUpdatedSettingsContent(currentContent, registrationKey) {
|
||||
const existingLines = String(currentContent || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !/^\s*app_Key\s*=/.test(line));
|
||||
const normalizedKey = normalizeRegistrationKey(registrationKey);
|
||||
|
||||
while (existingLines.length > 0 && existingLines[existingLines.length - 1] === '') {
|
||||
existingLines.pop();
|
||||
}
|
||||
|
||||
if (normalizedKey) {
|
||||
existingLines.push(`app_Key = "${escapeKeyForSettingsFile(normalizedKey)}"`);
|
||||
}
|
||||
|
||||
return existingLines.length > 0 ? `${existingLines.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
async function syncRegistrationKeyToConfig(rawValue, options = {}) {
|
||||
const normalizedKey = normalizeRegistrationKey(rawValue);
|
||||
const homeDir = String(options?.homeDir || '').trim() || os.homedir();
|
||||
const configDir = getMakeMKVConfigDir(homeDir);
|
||||
const settingsFilePath = getMakeMKVSettingsFilePath(homeDir);
|
||||
const fileExists = fs.existsSync(settingsFilePath);
|
||||
const currentContent = fileExists
|
||||
? await fs.promises.readFile(settingsFilePath, 'utf8')
|
||||
: '';
|
||||
const nextContent = buildUpdatedSettingsContent(currentContent, normalizedKey);
|
||||
|
||||
if (!normalizedKey && !fileExists) {
|
||||
return {
|
||||
changed: false,
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
};
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
if (nextContent) {
|
||||
await fs.promises.writeFile(settingsFilePath, nextContent, 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-synced', {
|
||||
path: settingsFilePath,
|
||||
hasKey: true
|
||||
});
|
||||
} else {
|
||||
await fs.promises.writeFile(settingsFilePath, '', 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-cleared', {
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
changed: currentContent !== nextContent,
|
||||
path: settingsFilePath,
|
||||
hasKey: Boolean(normalizedKey)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCurrentBetaKey(options = {}) {
|
||||
const forceRefresh = options?.forceRefresh === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (!forceRefresh && isCacheEntryValid(cachedBetaKeyEntry, now)) {
|
||||
return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale: false });
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const persistentCacheHit = await tryUsePersistentCache(now);
|
||||
if (persistentCacheHit) {
|
||||
return persistentCacheHit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceRefresh && blockedUntilMs > now) {
|
||||
throw buildExistingBackoffError();
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResult = await fetchCurrentBetaKeyViaCurl();
|
||||
const normalized = normalizeCacheEntry({
|
||||
...apiResult,
|
||||
fetchedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!normalized) {
|
||||
const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
blockedUntilMs = 0;
|
||||
setInMemoryCache(normalized);
|
||||
await persistCacheEntry(normalized);
|
||||
return buildPublicResult(normalized, { cached: false, stale: false });
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:fetch-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
if (Number(error?.httpStatus || error?.statusCode || error?.status || 0) === 429) {
|
||||
throw createRateLimitError(Number(error?.retryAfterMs || 0));
|
||||
}
|
||||
const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
|
||||
resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502;
|
||||
throw resolved;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBetaKeyResponse(rawBody) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawBody || '{}'));
|
||||
} catch (_error) {
|
||||
const error = new Error('Betakey-Antwort ist kein gültiges JSON.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const betaKey = parseBetaKeyCandidate(String(payload?.key || '').trim(), 'Betakey-Antwort ist ungültig.');
|
||||
|
||||
let validUntil = null;
|
||||
const unixExpiry = Number(payload?.keydate || payload?.expires || payload?.valid_until_unix || 0);
|
||||
if (Number.isFinite(unixExpiry) && unixExpiry > 0) {
|
||||
validUntil = new Date(unixExpiry * 1000).toISOString();
|
||||
}
|
||||
|
||||
const resolvedValidUntil = resolveValidityOrFallback(validUntil, MAKEMKV_BETA_KEY_API_URL);
|
||||
|
||||
return {
|
||||
key: betaKey,
|
||||
sourceUrl: MAKEMKV_BETA_KEY_API_URL,
|
||||
validUntil: resolvedValidUntil
|
||||
};
|
||||
}
|
||||
|
||||
function fetchCurrentBetaKeyViaCurl() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const curlArgs = [
|
||||
'-fsSL',
|
||||
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
||||
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||
MAKEMKV_BETA_KEY_API_URL
|
||||
];
|
||||
|
||||
execFile(
|
||||
'curl',
|
||||
curlArgs,
|
||||
{
|
||||
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const resolvedError = new Error(
|
||||
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
||||
);
|
||||
resolvedError.statusCode = 502;
|
||||
if (/\b429\b/.test(String(stderr || ''))) {
|
||||
resolvedError.httpStatus = 429;
|
||||
}
|
||||
if (/\b403\b/.test(String(stderr || ''))) {
|
||||
resolvedError.httpStatus = 403;
|
||||
}
|
||||
reject(resolvedError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseBetaKeyResponse(stdout));
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAKEMKV_BETA_KEY_API_URL,
|
||||
fetchCurrentBetaKey,
|
||||
getCachedBetaKey,
|
||||
getMakeMKVConfigDir,
|
||||
getMakeMKVSettingsFilePath,
|
||||
normalizeRegistrationKey,
|
||||
syncRegistrationKeyToConfig
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('MUSICBRAINZ');
|
||||
|
||||
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||
@@ -74,6 +73,17 @@ function normalizeRelease(release) {
|
||||
artist: artistFromTrack || artistFromRecording || artistCredit || null
|
||||
};
|
||||
});
|
||||
const rawReleaseTrackCount = Number(release['track-count']);
|
||||
const mediaTrackCount = media.reduce((sum, medium) => {
|
||||
const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount);
|
||||
if (!Number.isFinite(mediumCount) || mediumCount <= 0) {
|
||||
return sum;
|
||||
}
|
||||
return sum + Math.trunc(mediumCount);
|
||||
}, 0);
|
||||
const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0
|
||||
? Math.trunc(rawReleaseTrackCount)
|
||||
: (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length);
|
||||
|
||||
// Always generate the CAA URL when an id is present; the browser/onError
|
||||
// handles 404s for releases that have no front cover.
|
||||
@@ -92,27 +102,21 @@ function normalizeRelease(release) {
|
||||
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
|
||||
: null,
|
||||
coverArtUrl,
|
||||
tracks: normalizedTracks
|
||||
tracks: normalizedTracks,
|
||||
trackCount
|
||||
};
|
||||
}
|
||||
|
||||
class MusicBrainzService {
|
||||
async isEnabled() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return settings.musicbrainz_enabled !== 'false';
|
||||
}
|
||||
|
||||
async searchByTitle(query) {
|
||||
async searchByTitle(query, options = {}) {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const expectedTrackCountRaw = Number(options?.trackCount);
|
||||
const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0
|
||||
? Math.trunc(expectedTrackCountRaw)
|
||||
: null;
|
||||
logger.info('search:start', { query: q });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release`);
|
||||
@@ -124,11 +128,23 @@ class MusicBrainzService {
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const releases = Array.isArray(data.releases) ? data.releases : [];
|
||||
const results = releases.map(normalizeRelease).filter(Boolean);
|
||||
logger.info('search:done', { query: q, count: results.length });
|
||||
const normalizedReleases = releases.map(normalizeRelease).filter(Boolean);
|
||||
const results = expectedTrackCount
|
||||
? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount)
|
||||
: normalizedReleases;
|
||||
logger.info('search:done', {
|
||||
query: q,
|
||||
expectedTrackCount,
|
||||
sourceCount: normalizedReleases.length,
|
||||
count: results.length
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
|
||||
logger.warn('search:failed', {
|
||||
query: q,
|
||||
expectedTrackCount,
|
||||
error: String(error?.message || error)
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -143,11 +159,6 @@ class MusicBrainzService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('getById:start', { mbId: id });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release/${id}`);
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('OMDB');
|
||||
|
||||
class OmdbService {
|
||||
async search(query) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
logger.info('search:start', { query });
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const apiKey = settings.omdb_api_key;
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const type = settings.omdb_default_type || 'movie';
|
||||
const url = new URL('https://www.omdbapi.com/');
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('s', query.trim());
|
||||
url.searchParams.set('type', type);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
logger.error('search:http-failed', { query, status: response.status });
|
||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
||||
return [];
|
||||
}
|
||||
const results = data.Search.map((item) => ({
|
||||
title: item.Title,
|
||||
year: item.Year,
|
||||
imdbId: item.imdbID,
|
||||
type: item.Type,
|
||||
poster: item.Poster
|
||||
}));
|
||||
logger.info('search:done', { query, count: results.length });
|
||||
return results;
|
||||
}
|
||||
|
||||
async fetchByImdbId(imdbId) {
|
||||
const normalizedId = String(imdbId || '').trim().toLowerCase();
|
||||
if (!/^tt\d{6,12}$/.test(normalizedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const apiKey = settings.omdb_api_key;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL('https://www.omdbapi.com/');
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('i', normalizedId);
|
||||
url.searchParams.set('plot', 'full');
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
|
||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.Response === 'False') {
|
||||
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
||||
return null;
|
||||
}
|
||||
|
||||
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
|
||||
const year = yearMatch ? Number(yearMatch[0]) : null;
|
||||
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
|
||||
|
||||
const result = {
|
||||
title: data.Title || null,
|
||||
year: Number.isFinite(year) ? year : null,
|
||||
imdbId: String(data.imdbID || normalizedId),
|
||||
type: data.Type || null,
|
||||
poster,
|
||||
raw: data
|
||||
};
|
||||
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OmdbService();
|
||||
+22045
-1488
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||
const CHAIN_DESCRIPTION_MAX_LENGTH = 50;
|
||||
const STEP_TYPE_SCRIPT = 'script';
|
||||
const STEP_TYPE_WAIT = 'wait';
|
||||
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
|
||||
@@ -26,6 +27,31 @@ function createValidationError(message, details = null) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeFavoriteFlag(rawValue, fallback = 0) {
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue ? 1 : 0;
|
||||
}
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed !== 0 ? 1 : 0;
|
||||
}
|
||||
const normalized = String(rawValue || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return 1;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return 0;
|
||||
}
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
|
||||
function normalizeChainDescription(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function mapChainRow(row, steps = []) {
|
||||
if (!row) {
|
||||
return null;
|
||||
@@ -33,6 +59,8 @@ function mapChainRow(row, steps = []) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
description: normalizeChainDescription(row.description),
|
||||
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
|
||||
orderIndex: Number(row.order_index || 0),
|
||||
steps: steps.map(mapStepRow),
|
||||
createdAt: row.created_at,
|
||||
@@ -56,24 +84,27 @@ function mapStepRow(row) {
|
||||
|
||||
function terminateChildProcess(child, { immediate = false } = {}) {
|
||||
if (!child) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
|
||||
const pid = Number(child.pid);
|
||||
let signaled = false;
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
// For detached children this targets the full process group.
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
signaled = true;
|
||||
} catch (_error) {
|
||||
// Fall through to direct child signal.
|
||||
// ignore and continue with direct child signal
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
signaled = true;
|
||||
} catch (_error) {
|
||||
return;
|
||||
// ignore
|
||||
}
|
||||
return signaled;
|
||||
}
|
||||
|
||||
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
|
||||
@@ -161,7 +192,7 @@ class ScriptChainService {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, order_index, created_at, updated_at
|
||||
SELECT id, name, description, is_favorite, order_index, created_at, updated_at
|
||||
FROM script_chains
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
@@ -210,7 +241,7 @@ class ScriptChainService {
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
|
||||
`SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
|
||||
[normalizedId]
|
||||
);
|
||||
if (!row) {
|
||||
@@ -232,7 +263,7 @@ class ScriptChainService {
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
|
||||
`SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
|
||||
ids
|
||||
);
|
||||
const stepRows = await db.all(
|
||||
@@ -265,12 +296,16 @@ class ScriptChainService {
|
||||
async createChain(payload = {}) {
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const name = String(body.name || '').trim();
|
||||
const description = normalizeChainDescription(body.description);
|
||||
if (!name) {
|
||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||
}
|
||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) {
|
||||
throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
const steps = validateSteps(body.steps);
|
||||
|
||||
const db = await getDb();
|
||||
@@ -278,10 +313,10 @@ class ScriptChainService {
|
||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO script_chains (name, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
INSERT INTO script_chains (name, description, is_favorite, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[name, nextOrderIndex]
|
||||
[name, description || null, nextOrderIndex]
|
||||
);
|
||||
const chainId = result.lastID;
|
||||
await this._saveSteps(db, chainId, steps);
|
||||
@@ -301,12 +336,16 @@ class ScriptChainService {
|
||||
}
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const name = String(body.name || '').trim();
|
||||
const description = normalizeChainDescription(body.description);
|
||||
if (!name) {
|
||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||
}
|
||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) {
|
||||
throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
const steps = validateSteps(body.steps);
|
||||
|
||||
await this.getChainById(normalizedId);
|
||||
@@ -314,8 +353,8 @@ class ScriptChainService {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.run(
|
||||
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[name, normalizedId]
|
||||
`UPDATE script_chains SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[name, description || null, normalizedId]
|
||||
);
|
||||
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
|
||||
await this._saveSteps(db, normalizedId, steps);
|
||||
@@ -339,6 +378,25 @@ class ScriptChainService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
async setChainFavorite(chainId, isFavorite) {
|
||||
const normalizedId = normalizeChainId(chainId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige chainId.');
|
||||
}
|
||||
const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0);
|
||||
const db = await getDb();
|
||||
await this.getChainById(normalizedId);
|
||||
await db.run(
|
||||
`
|
||||
UPDATE script_chains
|
||||
SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[favoriteFlag, normalizedId]
|
||||
);
|
||||
return this.getChainById(normalizedId);
|
||||
}
|
||||
|
||||
async reorderChains(orderedIds = []) {
|
||||
const providedIds = Array.isArray(orderedIds)
|
||||
? orderedIds.map(normalizeChainId).filter(Boolean)
|
||||
@@ -443,6 +501,7 @@ class ScriptChainService {
|
||||
currentStepType: null,
|
||||
activeWaitResolve: null,
|
||||
activeChild: null,
|
||||
activeProcessHandle: null,
|
||||
activeChildTermination: null
|
||||
};
|
||||
const emitRuntimeStep = (payload = {}) => {
|
||||
@@ -469,6 +528,23 @@ class ScriptChainService {
|
||||
message: 'Abbruch angefordert',
|
||||
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
|
||||
});
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('cancel');
|
||||
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'cancel';
|
||||
let handleCancelled = false;
|
||||
if (typeof controlState.activeProcessHandle?.cancel === 'function') {
|
||||
controlState.activeProcessHandle.cancel();
|
||||
handleCancelled = true;
|
||||
}
|
||||
const signaled = terminateChildProcess(controlState.activeChild, { immediate: true });
|
||||
if (!handleCancelled && !signaled) {
|
||||
logger.warn('chain:cancel:no-signal-sent', {
|
||||
chainId,
|
||||
scriptPid: Number(controlState.activeChild?.pid || 0) || null
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
|
||||
@@ -476,12 +552,6 @@ class ScriptChainService {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('cancel');
|
||||
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'cancel';
|
||||
terminateChildProcess(controlState.activeChild, { immediate: true });
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
};
|
||||
const requestNextStep = async () => {
|
||||
@@ -662,6 +732,7 @@ class ScriptChainService {
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
controlState.activeProcessHandle = processHandle;
|
||||
let runError = null;
|
||||
let exitCode = 0;
|
||||
let signal = null;
|
||||
@@ -676,6 +747,7 @@ class ScriptChainService {
|
||||
}
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
controlState.activeProcessHandle = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (runError && exitCode === null && !termination) {
|
||||
throw runError;
|
||||
@@ -829,6 +901,7 @@ class ScriptChainService {
|
||||
break;
|
||||
} finally {
|
||||
controlState.activeChild = null;
|
||||
controlState.activeProcessHandle = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
@@ -8,6 +7,7 @@ const settingsService = require('./settingsService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { streamLines } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const { tempDir } = require('../config');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||
@@ -72,6 +72,27 @@ function normalizeScriptBody(rawValue) {
|
||||
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function normalizeFavoriteFlag(rawValue, fallback = 0) {
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue ? 1 : 0;
|
||||
}
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed !== 0 ? 1 : 0;
|
||||
}
|
||||
const normalized = String(rawValue || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return 1;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return 0;
|
||||
}
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
|
||||
function createValidationError(message, details = null) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
@@ -125,6 +146,7 @@ function mapScriptRow(row) {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
scriptBody: String(row.script_body || ''),
|
||||
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
|
||||
orderIndex: Number(row.order_index || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
@@ -325,7 +347,7 @@ class ScriptService {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
@@ -341,7 +363,7 @@ class ScriptService {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id = ?
|
||||
`,
|
||||
@@ -362,8 +384,8 @@ class ScriptService {
|
||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
||||
);
|
||||
@@ -420,6 +442,25 @@ class ScriptService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
async setScriptFavorite(scriptId, isFavorite) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0);
|
||||
const db = await getDb();
|
||||
await this.getScriptById(normalizedId);
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[favoriteFlag, normalizedId]
|
||||
);
|
||||
return this.getScriptById(normalizedId);
|
||||
}
|
||||
|
||||
async getScriptsByIds(rawIds = []) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
@@ -429,7 +470,7 @@ class ScriptService {
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id IN (${placeholders})
|
||||
`,
|
||||
@@ -532,8 +573,9 @@ class ScriptService {
|
||||
async createExecutableScriptFile(script, context = {}) {
|
||||
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
|
||||
const scriptPath = path.join(tempDir, 'script.sh');
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-'));
|
||||
const scriptPath = path.join(scriptTempDir, 'script.sh');
|
||||
const wrapped = buildScriptWrapper(scriptBody, {
|
||||
...context,
|
||||
scriptId: script?.id ?? context?.scriptId ?? '',
|
||||
@@ -548,19 +590,19 @@ class ScriptService {
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
await fs.promises.rm(scriptTempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('script:temp-cleanup-failed', {
|
||||
scriptId: script?.id ?? null,
|
||||
scriptName: name,
|
||||
tempDir,
|
||||
tempDir: scriptTempDir,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
tempDir: scriptTempDir,
|
||||
scriptPath,
|
||||
cmd: '/usr/bin/env',
|
||||
args: ['bash', scriptPath],
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SETTINGS');
|
||||
const loggerService = require('./logger');
|
||||
const logger = loggerService.child('SETTINGS');
|
||||
const {
|
||||
parseJson,
|
||||
normalizeValueByType,
|
||||
serializeValueByType,
|
||||
validateSetting
|
||||
validateSetting,
|
||||
toBoolean
|
||||
} = require('../utils/validators');
|
||||
const { splitArgs } = require('../utils/commandLine');
|
||||
const { setLogRootDir } = require('./logPathService');
|
||||
const {
|
||||
syncRegistrationKeyToConfig,
|
||||
normalizeRegistrationKey
|
||||
} = require('./makemkvKeyService');
|
||||
|
||||
const {
|
||||
defaultRawDir: DEFAULT_RAW_DIR,
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultSeriesDir: DEFAULT_SERIES_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
|
||||
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
|
||||
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR,
|
||||
tempDir: DEFAULT_TEMP_DIR
|
||||
} = require('../config');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
@@ -34,10 +44,24 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
|
||||
]);
|
||||
const SENSITIVE_SETTING_KEYS = new Set([
|
||||
'makemkv_registration_key',
|
||||
'omdb_api_key',
|
||||
'tmdb_api_read_access_token',
|
||||
'pushover_token',
|
||||
'pushover_user'
|
||||
]);
|
||||
const LEGACY_HIDDEN_SETTING_KEYS = new Set([
|
||||
'omdb_api_key',
|
||||
'omdb_default_type',
|
||||
'omdb_timeout_ms'
|
||||
]);
|
||||
const IMMUTABLE_SETTING_KEYS = new Set([
|
||||
'makemkv_command',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'ffmpeg_command',
|
||||
'ffprobe_command',
|
||||
'cdparanoia_command',
|
||||
'mkvmerge_command'
|
||||
]);
|
||||
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
|
||||
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
|
||||
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
|
||||
@@ -45,6 +69,21 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||
const SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY = 'server_console_log_output_enabled';
|
||||
const SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY = 'server_console_log_http_enabled';
|
||||
const SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY = 'server_console_log_debug_enabled';
|
||||
const SERVER_CONSOLE_LOG_INFO_ENABLED_KEY = 'server_console_log_info_enabled';
|
||||
const SERVER_CONSOLE_LOG_WARN_ENABLED_KEY = 'server_console_log_warn_enabled';
|
||||
const SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY = 'server_console_log_error_enabled';
|
||||
const SERVER_CONSOLE_LOG_SETTING_KEYS = new Set([
|
||||
SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY,
|
||||
SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY,
|
||||
SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY,
|
||||
SERVER_CONSOLE_LOG_INFO_ENABLED_KEY,
|
||||
SERVER_CONSOLE_LOG_WARN_ENABLED_KEY,
|
||||
SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY
|
||||
]);
|
||||
const MAKEMKV_REGISTRATION_KEY_SETTING_KEY = 'makemkv_registration_key';
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||
const PROFILED_SETTINGS = {
|
||||
raw_dir: {
|
||||
@@ -59,18 +98,34 @@ const PROFILED_SETTINGS = {
|
||||
cd: 'raw_dir_cd_owner',
|
||||
audiobook: 'raw_dir_audiobook_owner'
|
||||
},
|
||||
series_raw_dir: {
|
||||
bluray: 'raw_dir_bluray_series',
|
||||
dvd: 'raw_dir_dvd_series'
|
||||
},
|
||||
series_raw_dir_owner: {
|
||||
bluray: 'raw_dir_bluray_series_owner',
|
||||
dvd: 'raw_dir_dvd_series_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
dvd: 'movie_dir_dvd',
|
||||
cd: 'movie_dir_cd',
|
||||
audiobook: 'movie_dir_audiobook'
|
||||
},
|
||||
series_dir: {
|
||||
bluray: 'series_dir_bluray',
|
||||
dvd: 'series_dir_dvd'
|
||||
},
|
||||
movie_dir_owner: {
|
||||
bluray: 'movie_dir_bluray_owner',
|
||||
dvd: 'movie_dir_dvd_owner',
|
||||
cd: 'movie_dir_cd_owner',
|
||||
audiobook: 'movie_dir_audiobook_owner'
|
||||
},
|
||||
series_dir_owner: {
|
||||
bluray: 'series_dir_bluray_owner',
|
||||
dvd: 'series_dir_dvd_owner'
|
||||
},
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
dvd: 'mediainfo_extra_args_dvd'
|
||||
@@ -95,6 +150,14 @@ const PROFILED_SETTINGS = {
|
||||
bluray: 'handbrake_extra_args_bluray',
|
||||
dvd: 'handbrake_extra_args_dvd'
|
||||
},
|
||||
handbrake_review_audio_languages: {
|
||||
bluray: 'handbrake_review_audio_languages_bluray',
|
||||
dvd: 'handbrake_review_audio_languages_dvd'
|
||||
},
|
||||
handbrake_review_subtitle_languages: {
|
||||
bluray: 'handbrake_review_subtitle_languages_bluray',
|
||||
dvd: 'handbrake_review_subtitle_languages_dvd'
|
||||
},
|
||||
output_extension: {
|
||||
bluray: 'output_extension_bluray',
|
||||
dvd: 'output_extension_dvd'
|
||||
@@ -108,8 +171,12 @@ const PROFILED_SETTINGS = {
|
||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||
'raw_dir',
|
||||
'raw_dir_owner',
|
||||
'series_raw_dir',
|
||||
'series_raw_dir_owner',
|
||||
'movie_dir',
|
||||
'movie_dir_owner'
|
||||
'movie_dir_owner',
|
||||
'series_dir',
|
||||
'series_dir_owner'
|
||||
]);
|
||||
|
||||
function applyRuntimeLogDirSetting(rawValue) {
|
||||
@@ -134,6 +201,44 @@ function applyRuntimeLogDirSetting(rawValue) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBooleanSetting(rawValue, fallback = true) {
|
||||
const normalizedRaw = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
|
||||
if (normalizedRaw === null || normalizedRaw === undefined || normalizedRaw === '') {
|
||||
return fallback;
|
||||
}
|
||||
return toBoolean(normalizedRaw);
|
||||
}
|
||||
|
||||
function isHiddenSettingKey(key) {
|
||||
const normalized = String(key || '').trim().toLowerCase();
|
||||
return LEGACY_HIDDEN_SETTING_KEYS.has(normalized);
|
||||
}
|
||||
|
||||
function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) {
|
||||
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||
const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true);
|
||||
const http = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY], true);
|
||||
const debug = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY], true);
|
||||
const info = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_INFO_ENABLED_KEY], true);
|
||||
const warn = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_WARN_ENABLED_KEY], true);
|
||||
const error = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY], true);
|
||||
const applied = loggerService.configureConsoleOutput({
|
||||
enabled,
|
||||
http,
|
||||
levels: {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error
|
||||
}
|
||||
});
|
||||
return applied;
|
||||
}
|
||||
|
||||
function isImmutableSettingKey(key) {
|
||||
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeTrackIds(rawList) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const seen = new Set();
|
||||
@@ -153,6 +258,40 @@ function normalizeTrackIds(rawList) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeTrackIdSequence(rawList, options = {}) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const dedupe = options?.dedupe !== false;
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalized = String(Math.trunc(value));
|
||||
if (dedupe && seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (dedupe) {
|
||||
seen.add(normalized);
|
||||
}
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizePositiveIndexes(rawList, maxValue = null) {
|
||||
const values = normalizeTrackIds(rawList)
|
||||
.map((item) => Number(item))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0) {
|
||||
return values;
|
||||
}
|
||||
const limit = Math.trunc(maxValue);
|
||||
return values.filter((value) => value <= limit);
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(rawValue) {
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return null;
|
||||
@@ -177,12 +316,38 @@ function removeSelectionArgs(extraArgs) {
|
||||
|
||||
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|
||||
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
|
||||
const isSubtitleSelectionWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||
const isSubtitleFlagWithValue = SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
|
||||
const isSubtitleWithValue = isSubtitleSelectionWithValue || isSubtitleFlagWithValue;
|
||||
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
|
||||
|
||||
if (isSubtitleFlagWithValue) {
|
||||
const inlineValue = token.includes('=')
|
||||
? token.slice(token.indexOf('=') + 1)
|
||||
: '';
|
||||
const hasAttachedValue = token.includes('=');
|
||||
const nextToken = String(args[i + 1] || '');
|
||||
const hasSeparateValue = !hasAttachedValue && nextToken && !nextToken.startsWith('-');
|
||||
const candidateValue = String(
|
||||
hasAttachedValue
|
||||
? inlineValue
|
||||
: (hasSeparateValue ? nextToken : '')
|
||||
).trim().toLowerCase();
|
||||
|
||||
// Keep explicit "none" subtitle flags from extra args.
|
||||
// This allows users to intentionally clear subtitle burn/default/forced behavior.
|
||||
if (candidateValue === 'none') {
|
||||
filtered.push(token);
|
||||
if (hasSeparateValue) {
|
||||
filtered.push(nextToken);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip) {
|
||||
filtered.push(token);
|
||||
continue;
|
||||
@@ -576,7 +741,8 @@ function mapPresetEntriesToOptions(entries) {
|
||||
* Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}.
|
||||
* Supports both the legacy string format ["/dev/sr0"] and the object format
|
||||
* [{"path": "/dev/sr0", "makemkvIndex": 0}].
|
||||
* When makemkvIndex is missing, it is auto-derived from the device name (sr0→0, sr1→1).
|
||||
* When makemkvIndex is missing, it is assigned by list order (0,1,2,...) so
|
||||
* sparse kernel names like /dev/sr2 still map to contiguous MakeMKV indices.
|
||||
*/
|
||||
function parseDriveDeviceEntries(raw) {
|
||||
try {
|
||||
@@ -584,28 +750,49 @@ function parseDriveDeviceEntries(raw) {
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
return arr.map((entry) => {
|
||||
const normalized = arr.map((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
const p = entry.trim();
|
||||
if (!p) {
|
||||
return null;
|
||||
}
|
||||
const m = p.match(/sr(\d+)$/);
|
||||
return { path: p, makemkvIndex: m ? Number(m[1]) : 0 };
|
||||
return { path: p, makemkvIndex: null };
|
||||
}
|
||||
if (entry && typeof entry === 'object' && entry.path) {
|
||||
const p = String(entry.path || '').trim();
|
||||
if (!p) {
|
||||
return null;
|
||||
}
|
||||
const idxRaw = entry.makemkvIndex != null ? Number(entry.makemkvIndex) : null;
|
||||
const m = p.match(/sr(\d+)$/);
|
||||
const derived = m ? Number(m[1]) : 0;
|
||||
const idx = Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : derived;
|
||||
return { path: p, makemkvIndex: idx };
|
||||
const idxRaw = Number(entry.makemkvIndex);
|
||||
return {
|
||||
path: p,
|
||||
makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const used = new Set();
|
||||
let nextAutoIndex = 0;
|
||||
return normalized.map((entry) => {
|
||||
if (entry.makemkvIndex != null) {
|
||||
used.add(entry.makemkvIndex);
|
||||
if (entry.makemkvIndex >= nextAutoIndex) {
|
||||
nextAutoIndex = entry.makemkvIndex + 1;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
while (used.has(nextAutoIndex)) {
|
||||
nextAutoIndex += 1;
|
||||
}
|
||||
const resolved = {
|
||||
path: entry.path,
|
||||
makemkvIndex: nextAutoIndex
|
||||
};
|
||||
used.add(nextAutoIndex);
|
||||
nextAutoIndex += 1;
|
||||
return resolved;
|
||||
});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -712,7 +899,8 @@ class SettingsService {
|
||||
|
||||
async getSchemaRows() {
|
||||
const db = await getDb();
|
||||
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
||||
const rows = await db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
||||
return rows.filter((row) => !isHiddenSettingKey(row?.key));
|
||||
}
|
||||
|
||||
async getSettingsMap(options = {}) {
|
||||
@@ -720,6 +908,21 @@ class SettingsService {
|
||||
return { ...(snapshot?.map || {}) };
|
||||
}
|
||||
|
||||
applyRuntimeSettingsFromMap(settingsMap = {}) {
|
||||
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||
const logDir = applyRuntimeLogDirSetting(source[LOG_DIR_SETTING_KEY]);
|
||||
const consoleLogConfig = applyRuntimeServerConsoleLoggingSettingsFromMap(source);
|
||||
return {
|
||||
logDir,
|
||||
serverConsoleLogConfig: consoleLogConfig
|
||||
};
|
||||
}
|
||||
|
||||
async applyRuntimeSettings() {
|
||||
const map = await this.getSettingsMap();
|
||||
return this.applyRuntimeSettingsFromMap(map);
|
||||
}
|
||||
|
||||
normalizeMediaProfile(value) {
|
||||
return normalizeMediaProfileValue(value);
|
||||
}
|
||||
@@ -754,6 +957,8 @@ class SettingsService {
|
||||
} else {
|
||||
resolvedValue = DEFAULT_RAW_DIR;
|
||||
}
|
||||
} else if (legacyKey === 'series_dir') {
|
||||
resolvedValue = DEFAULT_SERIES_DIR;
|
||||
} else if (legacyKey === 'movie_dir') {
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
@@ -798,18 +1003,32 @@ class SettingsService {
|
||||
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||
return {
|
||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
bluray: {
|
||||
raw: bluray.raw_dir,
|
||||
seriesRaw: bluray.series_raw_dir || bluray.raw_dir,
|
||||
movies: bluray.movie_dir,
|
||||
series: bluray.series_dir
|
||||
},
|
||||
dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
converter: {
|
||||
raw: String(map.converter_raw_dir || '').trim() || DEFAULT_CONVERTER_RAW_DIR,
|
||||
movies: String(map.converter_movie_dir || '').trim() || DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
audio: String(map.converter_audio_dir || '').trim() || DEFAULT_CONVERTER_AUDIO_DIR
|
||||
},
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
series: DEFAULT_SERIES_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
downloads: DEFAULT_DOWNLOAD_DIR
|
||||
downloads: DEFAULT_DOWNLOAD_DIR,
|
||||
converterRaw: DEFAULT_CONVERTER_RAW_DIR,
|
||||
converterMovies: DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
converterAudio: DEFAULT_CONVERTER_AUDIO_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -829,6 +1048,7 @@ class SettingsService {
|
||||
s.options_json,
|
||||
s.validation_json,
|
||||
s.order_index,
|
||||
s.depends_on,
|
||||
v.value as current_value
|
||||
FROM settings_schema s
|
||||
LEFT JOIN settings_values v ON v.key = s.key
|
||||
@@ -836,7 +1056,9 @@ class SettingsService {
|
||||
`
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
return rows
|
||||
.filter((row) => !isHiddenSettingKey(row?.key))
|
||||
.map((row) => ({
|
||||
key: row.key,
|
||||
category: row.category,
|
||||
label: row.label,
|
||||
@@ -847,8 +1069,9 @@ class SettingsService {
|
||||
options: parseJson(row.options_json, []),
|
||||
validation: parseJson(row.validation_json, {}),
|
||||
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
|
||||
orderIndex: row.order_index
|
||||
}));
|
||||
orderIndex: row.order_index,
|
||||
depends_on: row.depends_on ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
async getFlatSettings(options = {}) {
|
||||
@@ -862,6 +1085,12 @@ class SettingsService {
|
||||
}
|
||||
|
||||
async setSettingValue(key, rawValue) {
|
||||
if (isImmutableSettingKey(key)) {
|
||||
const error = new Error(`Setting ${key} ist schreibgeschützt und kann nicht geändert werden.`);
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
|
||||
if (!schema) {
|
||||
@@ -878,24 +1107,39 @@ class SettingsService {
|
||||
}
|
||||
|
||||
const serializedValue = serializeValueByType(schema.type, result.normalized);
|
||||
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO settings_values (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`,
|
||||
[key, serializedValue]
|
||||
);
|
||||
try {
|
||||
await db.exec('BEGIN');
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO settings_values (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`,
|
||||
[key, serializedValue]
|
||||
);
|
||||
if (normalizedKey === MAKEMKV_REGISTRATION_KEY_SETTING_KEY) {
|
||||
await syncRegistrationKeyToConfig(result.normalized);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
logger.info('setting:updated', {
|
||||
key,
|
||||
value: SENSITIVE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()) ? '[redacted]' : result.normalized
|
||||
});
|
||||
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
||||
if (normalizedKey === LOG_DIR_SETTING_KEY) {
|
||||
applyRuntimeLogDirSetting(result.normalized);
|
||||
}
|
||||
if (SERVER_CONSOLE_LOG_SETTING_KEYS.has(normalizedKey)) {
|
||||
const map = await this.getSettingsMap({ forceRefresh: true });
|
||||
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
|
||||
}
|
||||
this.invalidateSettingsCache([key]);
|
||||
|
||||
return {
|
||||
@@ -923,6 +1167,14 @@ class SettingsService {
|
||||
const validationErrors = [];
|
||||
|
||||
for (const [key, rawValue] of entries) {
|
||||
if (isImmutableSettingKey(key)) {
|
||||
validationErrors.push({
|
||||
key,
|
||||
message: 'Dieses Setting ist schreibgeschützt und kann nicht geändert werden.'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const schema = schemaByKey.get(key);
|
||||
if (!schema) {
|
||||
const error = new Error(`Setting ${key} existiert nicht.`);
|
||||
@@ -948,7 +1200,9 @@ class SettingsService {
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const error = new Error('Mindestens ein Setting ist ungültig.');
|
||||
error.statusCode = 400;
|
||||
error.statusCode = validationErrors.some((item) => String(item?.message || '').includes('schreibgeschützt'))
|
||||
? 403
|
||||
: 400;
|
||||
error.details = validationErrors;
|
||||
throw error;
|
||||
}
|
||||
@@ -967,6 +1221,12 @@ class SettingsService {
|
||||
[item.key, item.serializedValue]
|
||||
);
|
||||
}
|
||||
const makemkvKeyChange = normalizedEntries.find(
|
||||
(item) => String(item?.key || '').trim().toLowerCase() === MAKEMKV_REGISTRATION_KEY_SETTING_KEY
|
||||
);
|
||||
if (makemkvKeyChange) {
|
||||
await syncRegistrationKeyToConfig(makemkvKeyChange.value);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
@@ -979,6 +1239,13 @@ class SettingsService {
|
||||
if (logDirChange) {
|
||||
applyRuntimeLogDirSetting(logDirChange.value);
|
||||
}
|
||||
const consoleOutputChange = normalizedEntries.find(
|
||||
(item) => SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(item?.key || '').trim().toLowerCase())
|
||||
);
|
||||
if (consoleOutputChange) {
|
||||
const map = await this.getSettingsMap({ forceRefresh: true });
|
||||
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
|
||||
}
|
||||
|
||||
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
|
||||
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
||||
@@ -996,16 +1263,17 @@ class SettingsService {
|
||||
);
|
||||
const cmd = map.makemkv_command;
|
||||
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
|
||||
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
|
||||
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
|
||||
? Math.round(minLengthMinutes * 60)
|
||||
: 0;
|
||||
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
|
||||
const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)];
|
||||
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
||||
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo, disableMinLengthFilter });
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
@@ -1015,12 +1283,13 @@ class SettingsService {
|
||||
const cmd = map.makemkv_command;
|
||||
const sourceArg = `file:${sourcePath}`;
|
||||
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
|
||||
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
|
||||
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
|
||||
? Math.round(minLengthMinutes * 60)
|
||||
: 0;
|
||||
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
|
||||
const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg];
|
||||
@@ -1030,6 +1299,7 @@ class SettingsService {
|
||||
cmd,
|
||||
args,
|
||||
sourcePath,
|
||||
disableMinLengthFilter,
|
||||
requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null
|
||||
});
|
||||
return { cmd, args, sourceArg };
|
||||
@@ -1045,8 +1315,10 @@ class SettingsService {
|
||||
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||||
? 'backup'
|
||||
: 'mkv';
|
||||
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||
const sourceArgOverride = String(options?.sourceArgOverride || '').trim();
|
||||
const sourceArg = sourceArgOverride || this.resolveSourceArg(map, deviceInfo);
|
||||
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||
let extra = [];
|
||||
let baseArgs = [];
|
||||
@@ -1072,18 +1344,26 @@ class SettingsService {
|
||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||
const hasExplicitTitle = rawSelectedTitleId !== null;
|
||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||
const minLengthSeconds = Number.isFinite(minLength) && minLength > 0
|
||||
? Math.round(minLength * 60)
|
||||
: 0;
|
||||
if (hasExplicitTitle) {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--decrypt',
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
rawJobDir
|
||||
];
|
||||
} else {
|
||||
const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--minlength=' + Math.round(minLength * 60),
|
||||
'--decrypt',
|
||||
...minLengthArgs,
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
@@ -1097,6 +1377,7 @@ class SettingsService {
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
deviceInfo,
|
||||
disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false,
|
||||
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
||||
? Math.trunc(rawSelectedTitleId)
|
||||
: null
|
||||
@@ -1104,20 +1385,15 @@ class SettingsService {
|
||||
return { cmd, args: [...baseArgs, ...extra] };
|
||||
}
|
||||
|
||||
async buildMakeMKVRegisterConfig() {
|
||||
const map = await this.getSettingsMap();
|
||||
const registrationKey = String(map.makemkv_registration_key || '').trim();
|
||||
if (!registrationKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cmd = map.makemkv_command || 'makemkvcon';
|
||||
const args = ['reg', registrationKey];
|
||||
logger.debug('cli:makemkv:register', { cmd, args: ['reg', '<redacted>'] });
|
||||
async syncMakeMKVRegistrationKeyFromSettings(options = {}) {
|
||||
const map = options?.settingsMap || await this.getSettingsMap();
|
||||
const registrationKey = normalizeRegistrationKey(map?.makemkv_registration_key);
|
||||
const fileInfo = await syncRegistrationKeyToConfig(registrationKey, options);
|
||||
return {
|
||||
cmd,
|
||||
args,
|
||||
argsForLog: ['reg', '<redacted>']
|
||||
applied: Boolean(registrationKey),
|
||||
key: registrationKey || null,
|
||||
path: fileInfo?.path || null,
|
||||
changed: Boolean(fileInfo?.changed)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1174,10 +1450,14 @@ class SettingsService {
|
||||
}
|
||||
|
||||
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
|
||||
const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds);
|
||||
const subtitleTrackIds = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false });
|
||||
const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
|
||||
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
|
||||
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
|
||||
const subtitleForcedTrackIndexes = normalizePositiveIndexes(
|
||||
rawSelection.subtitleForcedTrackIndexes,
|
||||
subtitleTrackIds.length
|
||||
);
|
||||
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
|
||||
const filteredExtra = removeSelectionArgs(extra);
|
||||
const overrideArgs = [
|
||||
@@ -1192,7 +1472,9 @@ class SettingsService {
|
||||
if (subtitleDefaultTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||
}
|
||||
if (subtitleForcedTrackId !== null) {
|
||||
if (subtitleForcedTrackIndexes.length > 0) {
|
||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackIndexes.join(',')}`);
|
||||
} else if (subtitleForcedTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||
} else if (subtitleForcedOnly) {
|
||||
overrideArgs.push('--subtitle-forced');
|
||||
@@ -1210,6 +1492,7 @@ class SettingsService {
|
||||
subtitleTrackIds,
|
||||
subtitleBurnTrackId,
|
||||
subtitleDefaultTrackId,
|
||||
subtitleForcedTrackIndexes,
|
||||
subtitleForcedTrackId,
|
||||
subtitleForcedOnly
|
||||
}
|
||||
@@ -1223,6 +1506,7 @@ class SettingsService {
|
||||
subtitleTrackIds,
|
||||
subtitleBurnTrackId,
|
||||
subtitleDefaultTrackId,
|
||||
subtitleForcedTrackIndexes,
|
||||
subtitleForcedTrackId,
|
||||
subtitleForcedOnly
|
||||
}
|
||||
@@ -1266,7 +1550,7 @@ class SettingsService {
|
||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
|
||||
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
|
||||
const args = ['--scan', '--json', '-i', sourceArg, '-t', '0'];
|
||||
const args = ['--scan', '--json', '-i', sourceArg];
|
||||
logger.debug('cli:handbrake:scan', {
|
||||
cmd,
|
||||
args,
|
||||
@@ -1316,7 +1600,8 @@ class SettingsService {
|
||||
}
|
||||
|
||||
const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const exportFile = path.join(os.tmpdir(), `${exportName}.json`);
|
||||
fs.mkdirSync(DEFAULT_TEMP_DIR, { recursive: true });
|
||||
const exportFile = path.join(DEFAULT_TEMP_DIR, `${exportName}.json`);
|
||||
const args = [
|
||||
'--scan',
|
||||
'-i',
|
||||
@@ -1411,17 +1696,31 @@ class SettingsService {
|
||||
|
||||
resolveSourceArg(map, deviceInfo = null) {
|
||||
const devicePath = String(deviceInfo?.path || '').trim();
|
||||
const deviceIndex = Number(deviceInfo?.makemkvIndex ?? deviceInfo?.index);
|
||||
const configuredEntries = parseDriveDeviceEntries(map.drive_devices);
|
||||
|
||||
// In explicit mode: look up per-drive makemkvIndex from drive_devices
|
||||
if (devicePath && map.drive_mode === 'explicit') {
|
||||
const entries = parseDriveDeviceEntries(map.drive_devices);
|
||||
const entry = entries.find((e) => e.path === devicePath);
|
||||
const entry = configuredEntries.find((e) => e.path === devicePath);
|
||||
if (entry) {
|
||||
return `disc:${entry.makemkvIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive from device name (/dev/sr0 → disc:0, /dev/sr1 → disc:1)
|
||||
// Prefer configured per-drive index when a path match exists (also in auto mode).
|
||||
if (devicePath) {
|
||||
const configured = configuredEntries.find((e) => e.path === devicePath);
|
||||
if (configured) {
|
||||
return `disc:${configured.makemkvIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer device-provided MakeMKV index from disk detection service.
|
||||
if (Number.isFinite(deviceIndex) && deviceIndex >= 0) {
|
||||
return `disc:${Math.trunc(deviceIndex)}`;
|
||||
}
|
||||
|
||||
// Last automatic fallback: derive from device name (/dev/sr0 → disc:0).
|
||||
if (devicePath) {
|
||||
const match = devicePath.match(/sr(\d+)$/);
|
||||
if (match) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger').child('TEMP_CLEANUP');
|
||||
const { tempDir } = require('../config');
|
||||
|
||||
const TMP_ROOT = tempDir;
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
const TOP_LEVEL_PREFIXES = [
|
||||
'ripster-merge-',
|
||||
'ripster-script-',
|
||||
'ripster-export-'
|
||||
];
|
||||
|
||||
const MANAGED_UPLOAD_DIRS = [
|
||||
'ripster-converter-uploads',
|
||||
'ripster-audiobook-uploads'
|
||||
];
|
||||
|
||||
function getEntryAgeMs(stats) {
|
||||
const referenceTime = Math.max(
|
||||
Number(stats?.mtimeMs) || 0,
|
||||
Number(stats?.ctimeMs) || 0,
|
||||
Number(stats?.birthtimeMs) || 0
|
||||
);
|
||||
return Date.now() - referenceTime;
|
||||
}
|
||||
|
||||
function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) {
|
||||
return getEntryAgeMs(stats) >= minAgeMs;
|
||||
}
|
||||
|
||||
function removeEntry(targetPath, stats) {
|
||||
const isDirectory = stats?.isDirectory?.() || false;
|
||||
fs.rmSync(targetPath, {
|
||||
recursive: isDirectory,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
|
||||
class TempCleanupService {
|
||||
constructor() {
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.runSweep('startup');
|
||||
|
||||
if (!this.interval) {
|
||||
this.interval = setInterval(() => {
|
||||
this.runSweep('interval').catch((error) => {
|
||||
logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) });
|
||||
});
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
this.interval.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runSweep(reason = 'manual') {
|
||||
fs.mkdirSync(TMP_ROOT, { recursive: true });
|
||||
const deleted = [];
|
||||
const skipped = [];
|
||||
const failed = [];
|
||||
|
||||
let topLevelEntries = [];
|
||||
try {
|
||||
topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
logger.warn('temp:sweep:readdir-failed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
|
||||
for (const entry of topLevelEntries) {
|
||||
const entryName = String(entry?.name || '').trim();
|
||||
if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(TMP_ROOT, entryName);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dirName of MANAGED_UPLOAD_DIRS) {
|
||||
const uploadDir = path.join(TMP_ROOT, dirName);
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uploadEntries = [];
|
||||
try {
|
||||
uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: uploadDir,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of uploadEntries) {
|
||||
const targetPath = path.join(uploadDir, entry.name);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-upload-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('temp:sweep:completed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
deletedCount: deleted.length,
|
||||
skippedCount: skipped.length,
|
||||
failedCount: failed.length,
|
||||
deleted: deleted.slice(0, 50),
|
||||
failed: failed.slice(0, 20)
|
||||
});
|
||||
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TempCleanupService();
|
||||
@@ -33,6 +33,31 @@ function isLocalUrl(url) {
|
||||
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||
}
|
||||
|
||||
function resolveLocalThumbnailPath(url) {
|
||||
const raw = String(url || '').trim();
|
||||
const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const jobId = Number(match[1]);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return null;
|
||||
}
|
||||
return persistentFilePath(Math.trunc(jobId));
|
||||
}
|
||||
|
||||
function localThumbnailUrlExists(url) {
|
||||
const targetPath = resolveLocalThumbnailPath(url);
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(targetPath);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirectsLeft <= 0) {
|
||||
@@ -82,21 +107,48 @@ function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||
*/
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
||||
async function cacheJobThumbnailDetailed(jobId, posterUrl) {
|
||||
const normalizedPosterUrl = String(posterUrl || '').trim();
|
||||
if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl || null,
|
||||
cachedPath: null,
|
||||
error: 'Kein externer Poster-Link vorhanden.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDirs();
|
||||
const dest = cacheFilePath(jobId);
|
||||
await downloadImage(posterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
||||
return dest;
|
||||
await downloadImage(normalizedPosterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: dest,
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
||||
return null;
|
||||
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message });
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: null,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
const result = await cacheJobThumbnailDetailed(jobId, posterUrl);
|
||||
return result?.ok ? result.cachedPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||
@@ -251,11 +303,14 @@ async function migrateExistingThumbnails() {
|
||||
|
||||
module.exports = {
|
||||
cacheJobThumbnail,
|
||||
cacheJobThumbnailDetailed,
|
||||
promoteJobThumbnail,
|
||||
copyThumbnail,
|
||||
storeLocalThumbnail,
|
||||
deleteThumbnail,
|
||||
getThumbnailsDir,
|
||||
migrateExistingThumbnails,
|
||||
isLocalUrl
|
||||
isLocalUrl,
|
||||
resolveLocalThumbnailPath,
|
||||
localThumbnailUrlExists
|
||||
};
|
||||
|
||||
@@ -0,0 +1,871 @@
|
||||
'use strict';
|
||||
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 15000;
|
||||
|
||||
class TmdbService {
|
||||
hasCreditsPayload(details = null) {
|
||||
const credits = details?.credits && typeof details.credits === 'object'
|
||||
? details.credits
|
||||
: null;
|
||||
if (!credits) {
|
||||
return false;
|
||||
}
|
||||
const crew = Array.isArray(credits.crew) ? credits.crew : [];
|
||||
const cast = Array.isArray(credits.cast) ? credits.cast : [];
|
||||
return crew.length > 0 || cast.length > 0;
|
||||
}
|
||||
|
||||
mergeCreditsIntoDetails(details = null, credits = null) {
|
||||
const sourceDetails = details && typeof details === 'object' ? details : {};
|
||||
const sourceCredits = credits && typeof credits === 'object' ? credits : null;
|
||||
if (!sourceCredits) {
|
||||
return sourceDetails;
|
||||
}
|
||||
return {
|
||||
...sourceDetails,
|
||||
credits: {
|
||||
...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}),
|
||||
...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extractDirectorNames(crewValues = []) {
|
||||
const crew = Array.isArray(crewValues) ? crewValues : [];
|
||||
const byJob = this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
if (byJob.length > 0) {
|
||||
return byJob;
|
||||
}
|
||||
// Fallback for edge cases where "Director" job is missing in localized/partial payloads.
|
||||
return this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
}
|
||||
|
||||
isAbortError(error) {
|
||||
const name = String(error?.name || '').trim().toLowerCase();
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
return name === 'aborterror' || message.includes('aborted');
|
||||
}
|
||||
|
||||
classifyRequestError(error) {
|
||||
if (this.isAbortError(error)) {
|
||||
return 'timeout';
|
||||
}
|
||||
const statusCode = Number(error?.statusCode || 0) || null;
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'auth';
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'upstream';
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return 'request_failed';
|
||||
}
|
||||
return 'network';
|
||||
}
|
||||
|
||||
readFailureCode(rows) {
|
||||
const value = rows && typeof rows.tmdbFailureCode === 'string'
|
||||
? rows.tmdbFailureCode
|
||||
: '';
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
attachFailureCode(rows, failureCode = null) {
|
||||
const output = Array.isArray(rows) ? rows : [];
|
||||
const normalized = String(failureCode || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return output;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(output, 'tmdbFailureCode', {
|
||||
value: normalized,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
} catch (_error) {
|
||||
output.tmdbFailureCode = normalized;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages);
|
||||
return {
|
||||
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
|
||||
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE',
|
||||
fallbackLanguages
|
||||
};
|
||||
}
|
||||
|
||||
async isConfigured() {
|
||||
const config = await this.getConfig();
|
||||
return Boolean(config.readAccessToken);
|
||||
}
|
||||
|
||||
async resolveLanguage(explicitLanguage = null) {
|
||||
const config = await this.getConfig();
|
||||
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
|
||||
}
|
||||
|
||||
parseLanguageList(value) {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '').split(',');
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const lang = String(item || '').trim();
|
||||
if (!lang) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = lang.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
normalized.push(lang);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) {
|
||||
const preferred = String(primaryLanguage || '').trim() || 'de-DE';
|
||||
const fallback = this.parseLanguageList(fallbackLanguages);
|
||||
const combined = [preferred, ...fallback];
|
||||
return this.parseLanguageList(combined);
|
||||
}
|
||||
|
||||
async resolveLanguageCandidates(options = {}) {
|
||||
const config = await this.getConfig();
|
||||
const explicitLanguage = String(options.language || '').trim() || null;
|
||||
const explicitFallbackLanguages = options.fallbackLanguages !== undefined
|
||||
? this.parseLanguageList(options.fallbackLanguages)
|
||||
: null;
|
||||
const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE';
|
||||
const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages;
|
||||
return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages);
|
||||
}
|
||||
|
||||
async request(pathName, options = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.readAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
|
||||
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${config.readAccessToken}`,
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`TMDb request failed (${response.status})`);
|
||||
error.statusCode = response.status;
|
||||
error.url = url.toString();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
buildImageUrl(imagePath, size = 'w342') {
|
||||
const normalizedPath = String(imagePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let lastFailureCode = null;
|
||||
let lastFailureStatusCode = null;
|
||||
let lastFailureMessage = null;
|
||||
|
||||
for (const language of languageCandidates) {
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
lastFailureCode = failureCode;
|
||||
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
|
||||
lastFailureMessage = error?.message || String(error);
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
language,
|
||||
error: lastFailureMessage,
|
||||
failureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
originalTitle: String(row?.original_name || '').trim() || null,
|
||||
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
if (normalizedRows.length > 0) {
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
}
|
||||
if (lastFailureCode) {
|
||||
logger.warn('search:failed:all-languages', {
|
||||
query: normalizedQuery,
|
||||
error: lastFailureMessage,
|
||||
failureCode: lastFailureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
}
|
||||
return this.attachFailureCode([], lastFailureCode);
|
||||
}
|
||||
|
||||
async searchMovies(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let lastFailureCode = null;
|
||||
let lastFailureStatusCode = null;
|
||||
let lastFailureMessage = null;
|
||||
|
||||
for (const language of languageCandidates) {
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/movie', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
lastFailureCode = failureCode;
|
||||
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
|
||||
lastFailureMessage = error?.message || String(error);
|
||||
logger.warn('movie:search:failed', {
|
||||
query: normalizedQuery,
|
||||
language,
|
||||
error: lastFailureMessage,
|
||||
failureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.title || row?.original_title || '').trim() || null,
|
||||
originalTitle: String(row?.original_title || '').trim() || null,
|
||||
year: Number(String(row?.release_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
if (normalizedRows.length > 0) {
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
}
|
||||
if (lastFailureCode) {
|
||||
logger.warn('movie:search:failed:all-languages', {
|
||||
query: normalizedQuery,
|
||||
error: lastFailureMessage,
|
||||
failureCode: lastFailureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
}
|
||||
return this.attachFailureCode([], lastFailureCode);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('series:details:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.name || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getMovieDetails(movieId, options = {}) {
|
||||
const id = Number(movieId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/movie/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('movie:details:failed', {
|
||||
movieId: Math.trunc(id),
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.title || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getMovieCredits(movieId, options = {}) {
|
||||
const id = Number(movieId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/movie/${Math.trunc(id)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('movie:credits:failed', {
|
||||
movieId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getMovieDetailsWithCredits(movieId, options = {}) {
|
||||
const details = await this.getMovieDetails(movieId, options);
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
const ensureCredits = options?.ensureCredits === true;
|
||||
if (!ensureCredits || this.hasCreditsPayload(details)) {
|
||||
return details;
|
||||
}
|
||||
const credits = await this.getMovieCredits(movieId, options);
|
||||
return this.mergeCreditsIntoDetails(details, credits);
|
||||
}
|
||||
|
||||
async getEpisodeGroups(seriesId) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
|
||||
logger.warn('series:episode-groups:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
}
|
||||
|
||||
async getEpisodeGroupDetails(groupId, options = {}) {
|
||||
const normalizedId = String(groupId || '').trim();
|
||||
if (!normalizedId || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/episode_group/${normalizedId}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('episode-group:details:failed', {
|
||||
groupId: normalizedId,
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim());
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch(() => null);
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.name || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
|| (Array.isArray(details?.episodes) && details.episodes.length > 0)
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('season:credits:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
seasonNumber: Math.trunc(season),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
buildSeasonSummary(seasonDetails = null) {
|
||||
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
|
||||
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
|
||||
return {
|
||||
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
|
||||
name: String(details.name || '').trim() || null,
|
||||
overview: String(details.overview || '').trim() || null,
|
||||
posterPath: String(details.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(details.poster_path, 'w342'),
|
||||
episodeCount: episodes.length,
|
||||
episodes: episodes.map((episode) => ({
|
||||
id: Number(episode?.id || 0) || null,
|
||||
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
|
||||
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
|
||||
name: String(episode?.name || '').trim() || null,
|
||||
overview: String(episode?.overview || '').trim() || null,
|
||||
runtime: Number(episode?.runtime || 0) || null,
|
||||
airDate: String(episode?.air_date || '').trim() || null,
|
||||
stillPath: String(episode?.still_path || '').trim() || null,
|
||||
still: this.buildImageUrl(episode?.still_path, 'w300')
|
||||
})).filter((episode) => episode.id && episode.number)
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesDetailsSummary(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
|
||||
const directorNames = this.extractDirectorNames(crew);
|
||||
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0
|
||||
? directorNames.join(', ')
|
||||
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime: runtimeLabel,
|
||||
runtimeLabel,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
firstAirDate: String(details?.first_air_date || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
buildMovieDetailsSummary(movieDetails = null) {
|
||||
const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const directorNames = this.extractDirectorNames(crew);
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || details?.imdb_id || '').trim() || null;
|
||||
const releaseDate = String(details?.release_date || '').trim() || null;
|
||||
const runtime = this.formatRuntimeLabel(details?.runtime);
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0 ? directorNames.join(', ') : null,
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime,
|
||||
runtimeLabel: runtime,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
releaseDate
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesMetadataCandidate(series = {}, options = {}) {
|
||||
const season = series?.season && typeof series.season === 'object'
|
||||
? series.season
|
||||
: null;
|
||||
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
|
||||
const providerId = seasonNumber
|
||||
? `tmdb:${series.id}:season:${seasonNumber}`
|
||||
: `tmdb:${series.id}`;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: seasonNumber ? 'season' : 'series',
|
||||
tmdbId: Number(series?.id || 0) || null,
|
||||
title: String(series?.title || '').trim() || null,
|
||||
originalTitle: String(series?.originalTitle || '').trim() || null,
|
||||
year: Number(series?.year || 0) || null,
|
||||
overview: String(series?.overview || '').trim() || null,
|
||||
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
|
||||
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
|
||||
seasonNumber,
|
||||
seasonName: String(season?.name || '').trim() || null,
|
||||
seasonOverview: String(season?.overview || '').trim() || null,
|
||||
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
|
||||
episodeCount: Number(season?.episodeCount || 0) || 0,
|
||||
episodes: Array.isArray(season?.episodes) ? season.episodes : []
|
||||
};
|
||||
}
|
||||
|
||||
buildMovieMetadataCandidate(movie = {}, options = {}) {
|
||||
const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object'
|
||||
? options.detailsSummary
|
||||
: null;
|
||||
const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null;
|
||||
const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null;
|
||||
const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null;
|
||||
const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null;
|
||||
const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: 'movie',
|
||||
workflowKind: 'film',
|
||||
tmdbId,
|
||||
imdbId,
|
||||
title: String(movie?.title || movie?.originalTitle || '').trim() || null,
|
||||
originalTitle: String(movie?.originalTitle || '').trim() || null,
|
||||
year,
|
||||
overview: String(movie?.overview || '').trim() || null,
|
||||
poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'),
|
||||
backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'),
|
||||
runtime: detailsSummary?.runtime || null,
|
||||
genre: detailsSummary?.genre || null,
|
||||
voteAverage: detailsSummary?.voteAverage ?? null
|
||||
};
|
||||
}
|
||||
|
||||
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
|
||||
return seasons
|
||||
.map((season) => ({
|
||||
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
|
||||
name: String(season?.name || '').trim() || null,
|
||||
overview: String(season?.overview || '').trim() || null,
|
||||
posterPath: String(season?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(season?.poster_path, 'w342'),
|
||||
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
|
||||
episodes: []
|
||||
}))
|
||||
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getSeriesDetails(candidate.id, options);
|
||||
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
|
||||
if (seasons.length === 0) {
|
||||
return [this.buildSeriesMetadataCandidate(candidate)];
|
||||
}
|
||||
return seasons.map((season) => this.buildSeriesMetadataCandidate({
|
||||
...candidate,
|
||||
season
|
||||
}, {
|
||||
seasonNumber: season.seasonNumber
|
||||
}));
|
||||
}));
|
||||
|
||||
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
const normalizedSeason = Number(seasonNumber);
|
||||
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
|
||||
if (!seasonDetails) {
|
||||
return {
|
||||
...candidate,
|
||||
season: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
season: this.buildSeasonSummary(seasonDetails)
|
||||
};
|
||||
}));
|
||||
|
||||
const normalized = withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchMoviesWithDetails(query, options = {}) {
|
||||
const candidates = await this.searchMovies(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(15, Number(options.limit || 8)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getMovieDetailsWithCredits(candidate.id, {
|
||||
language,
|
||||
appendToResponse: ['credits', 'external_ids'],
|
||||
ensureCredits: true
|
||||
});
|
||||
const summary = this.buildMovieDetailsSummary(details);
|
||||
const normalized = this.buildMovieMetadataCandidate({
|
||||
...candidate,
|
||||
releaseDate: details?.release_date || null,
|
||||
imdbId: summary?.imdbId || null,
|
||||
posterPath: details?.poster_path || candidate?.posterPath || null
|
||||
}, {
|
||||
detailsSummary: summary
|
||||
});
|
||||
return {
|
||||
...normalized,
|
||||
tmdbDetails: summary && typeof summary === 'object' ? summary : null
|
||||
};
|
||||
}));
|
||||
|
||||
const normalized = expanded.filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TmdbService();
|
||||
@@ -0,0 +1,134 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('USER_PRESET_DEFAULTS');
|
||||
|
||||
const SLOT_KEYS = Object.freeze([
|
||||
'bluray_movie',
|
||||
'bluray_series',
|
||||
'dvd_movie',
|
||||
'dvd_series'
|
||||
]);
|
||||
const SLOT_KEY_SET = new Set(SLOT_KEYS);
|
||||
const SLOT_ALLOWED_MEDIA_TYPES = Object.freeze({
|
||||
bluray_movie: new Set(['bluray', 'all']),
|
||||
bluray_series: new Set(['bluray', 'all']),
|
||||
dvd_movie: new Set(['dvd', 'all']),
|
||||
dvd_series: new Set(['dvd', 'all'])
|
||||
});
|
||||
|
||||
function normalizePresetId(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function buildDefaultsMap(rows = []) {
|
||||
const map = Object.fromEntries(SLOT_KEYS.map((key) => [key, null]));
|
||||
for (const row of rows) {
|
||||
const slotKey = String(row?.slot_key || '').trim();
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
continue;
|
||||
}
|
||||
const presetId = normalizePresetId(row?.preset_id);
|
||||
map[slotKey] = presetId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listDefaults() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT slot_key, preset_id
|
||||
FROM user_preset_defaults
|
||||
WHERE slot_key IN (${SLOT_KEYS.map(() => '?').join(', ')})
|
||||
ORDER BY slot_key ASC
|
||||
`,
|
||||
SLOT_KEYS
|
||||
);
|
||||
return buildDefaultsMap(rows);
|
||||
}
|
||||
|
||||
async function updateDefaults(payload = {}) {
|
||||
const defaults = payload && typeof payload === 'object'
|
||||
? (payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : payload)
|
||||
: {};
|
||||
const updates = [];
|
||||
|
||||
for (const [slotKey, rawPresetId] of Object.entries(defaults)) {
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
const error = new Error(`Ungültiger Slot: ${slotKey}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
updates.push({ slotKey, presetId: normalizePresetId(rawPresetId) });
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
for (const update of updates) {
|
||||
if (update.presetId === null) {
|
||||
continue;
|
||||
}
|
||||
const exists = await db.get(
|
||||
`SELECT id, media_type FROM user_presets WHERE id = ? LIMIT 1`,
|
||||
[update.presetId]
|
||||
);
|
||||
if (!exists) {
|
||||
const error = new Error(`Preset ${update.presetId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const mediaType = String(exists.media_type || '').trim().toLowerCase();
|
||||
const allowedMediaTypes = SLOT_ALLOWED_MEDIA_TYPES[update.slotKey] || new Set(['all']);
|
||||
if (!allowedMediaTypes.has(mediaType)) {
|
||||
const error = new Error(
|
||||
`Preset ${update.presetId} (${mediaType || 'unbekannt'}) ist für ${update.slotKey} nicht zulässig.`
|
||||
);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (const update of updates) {
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO user_preset_defaults (slot_key, preset_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(slot_key) DO UPDATE SET preset_id = excluded.preset_id
|
||||
`,
|
||||
[update.slotKey, update.presetId]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('update', {
|
||||
updates: updates.map((entry) => ({
|
||||
slotKey: entry.slotKey,
|
||||
presetId: entry.presetId
|
||||
}))
|
||||
});
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SLOT_KEYS,
|
||||
listDefaults,
|
||||
updateDefaults
|
||||
};
|
||||
@@ -7,6 +7,17 @@ class WebSocketService {
|
||||
this.clients = new Set();
|
||||
}
|
||||
|
||||
_removeClient(socket, logLevel = 'info', event = 'client:removed') {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
const deleted = this.clients.delete(socket);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
logger[logLevel](event, { clients: this.clients.size });
|
||||
}
|
||||
|
||||
init(httpServer) {
|
||||
if (this.wss) {
|
||||
return;
|
||||
@@ -18,21 +29,32 @@ class WebSocketService {
|
||||
this.clients.add(socket);
|
||||
logger.info('client:connected', { clients: this.clients.size });
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'WS_CONNECTED',
|
||||
payload: { connectedAt: new Date().toISOString() }
|
||||
})
|
||||
);
|
||||
try {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'WS_CONNECTED',
|
||||
payload: { connectedAt: new Date().toISOString() }
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('client:connected:initial-send-failed', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on('close', () => {
|
||||
this.clients.delete(socket);
|
||||
logger.info('client:closed', { clients: this.clients.size });
|
||||
this._removeClient(socket, 'info', 'client:closed');
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
this.clients.delete(socket);
|
||||
logger.warn('client:error', { clients: this.clients.size });
|
||||
socket.on('error', (error) => {
|
||||
logger.warn('client:error', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:error:removed');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -55,8 +77,42 @@ class WebSocketService {
|
||||
});
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(message);
|
||||
if (!client || client.readyState !== client.OPEN) {
|
||||
if (client && client.readyState === client.CLOSED) {
|
||||
this._removeClient(client, 'info', 'client:pruned-closed');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
client.send(message, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
logger.warn('broadcast:send-failed', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-failure');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_error) {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('broadcast:send-threw', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-throw');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_terminateError) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+633
-24
@@ -3,6 +3,16 @@ const { splitArgs } = require('./commandLine');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac'];
|
||||
const DEFAULT_AUDIO_FALLBACK = 'av_aac';
|
||||
const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3
|
||||
});
|
||||
const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
|
||||
const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
|
||||
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
|
||||
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
|
||||
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
|
||||
const ISO2_TO_3_LANGUAGE = {
|
||||
de: 'deu',
|
||||
en: 'eng',
|
||||
@@ -23,6 +33,7 @@ const ISO2_TO_3_LANGUAGE = {
|
||||
ro: 'ron',
|
||||
uk: 'ukr',
|
||||
ja: 'jpn',
|
||||
jp: 'jpn',
|
||||
ko: 'kor',
|
||||
zh: 'zho',
|
||||
ar: 'ara'
|
||||
@@ -271,7 +282,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
|
||||
channels: item?.Channels || item?.Channel_s_ || null
|
||||
}));
|
||||
|
||||
const subtitleTracks = tracks
|
||||
const subtitleTracksRaw = tracks
|
||||
.filter((item) => {
|
||||
const type = String(item?.['@type'] || '').toLowerCase();
|
||||
return type === 'text' || type === 'subtitle';
|
||||
@@ -282,8 +293,14 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
|
||||
language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'),
|
||||
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
|
||||
title: item?.Title || null,
|
||||
format: item?.Format || null
|
||||
format: item?.Format || null,
|
||||
defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault),
|
||||
forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced),
|
||||
sdhFlag: parseSubtitleSdhFlag(item),
|
||||
eventCount: parseSubtitleEventCount(item),
|
||||
streamSizeBytes: parseSubtitleStreamSizeBytes(item)
|
||||
}));
|
||||
const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw);
|
||||
|
||||
const videoTracks = tracks
|
||||
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
|
||||
@@ -350,6 +367,467 @@ function parseTrackIdList(raw) {
|
||||
.filter((item) => Number.isFinite(item));
|
||||
}
|
||||
|
||||
function parseBooleanFlag(value) {
|
||||
return parseBooleanFlagNullable(value) === true;
|
||||
}
|
||||
|
||||
function parseBooleanFlagNullable(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'yes' || raw === 'true' || raw === '1') {
|
||||
return true;
|
||||
}
|
||||
if (raw === 'no' || raw === 'false' || raw === '0') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleForcedFlag(track) {
|
||||
if (!track || typeof track !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const candidates = [
|
||||
track?.forcedFlag,
|
||||
track?.forced,
|
||||
track?.Forced,
|
||||
track?.isForced,
|
||||
track?.IsForced,
|
||||
track?.forced_only,
|
||||
track?.forcedOnly,
|
||||
track?.Attributes?.Forced
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseBooleanFlagNullable(candidate);
|
||||
if (parsed === true || parsed === false) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleSdhFlag(track) {
|
||||
if (!track || typeof track !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const candidates = [
|
||||
track?.sdhFlag,
|
||||
track?.hearingImpaired,
|
||||
track?.HearingImpaired,
|
||||
track?.isHearingImpaired,
|
||||
track?.IsHearingImpaired,
|
||||
track?.Hearing_Impaired,
|
||||
track?.closedCaptions,
|
||||
track?.ClosedCaptions,
|
||||
track?.Attributes?.HearingImpaired,
|
||||
track?.Attributes?.ClosedCaptions
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseBooleanFlagNullable(candidate);
|
||||
if (parsed === true || parsed === false) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase();
|
||||
if (!serviceKind) {
|
||||
return null;
|
||||
}
|
||||
if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleEventCount(track) {
|
||||
const candidates = [
|
||||
track?.CountOfEvents,
|
||||
track?.countOfEvents,
|
||||
track?.EventCount,
|
||||
track?.eventCount,
|
||||
track?.ElementCount,
|
||||
track?.elementCount
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const numeric = Number(candidate);
|
||||
if (Number.isFinite(numeric) && numeric >= 0) {
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleStreamSizeBytes(track) {
|
||||
const numericCandidates = [
|
||||
track?.StreamSize,
|
||||
track?.streamSize,
|
||||
track?.StreamSize_Original,
|
||||
track?.streamSizeOriginal,
|
||||
track?.Bytes,
|
||||
track?.bytes
|
||||
];
|
||||
for (const candidate of numericCandidates) {
|
||||
const numeric = Number(candidate);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
}
|
||||
|
||||
const textCandidates = [
|
||||
track?.StreamSize_String,
|
||||
track?.streamSizeString,
|
||||
track?.StreamSize_Original_String,
|
||||
track?.streamSizeOriginalString,
|
||||
track?.Size_String,
|
||||
track?.sizeString
|
||||
];
|
||||
for (const candidate of textCandidates) {
|
||||
const text = String(candidate || '').trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const value = Number(String(match[1]).replace(',', '.'));
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const unit = String(match[2] || 'b').toLowerCase();
|
||||
const factorByUnit = {
|
||||
b: 1,
|
||||
kb: 1024,
|
||||
mb: 1024 ** 2,
|
||||
gb: 1024 ** 3,
|
||||
tb: 1024 ** 4
|
||||
};
|
||||
const factor = factorByUnit[unit] || 1;
|
||||
return Math.max(0, Math.trunc(value * factor));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSubtitleConfidence(raw) {
|
||||
const value = String(raw || '').trim().toLowerCase();
|
||||
if (value === 'high' || value === 'medium' || value === 'low') {
|
||||
return value;
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function subtitleConfidenceScore(raw) {
|
||||
return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0;
|
||||
}
|
||||
|
||||
function collectSubtitleText(track) {
|
||||
return [
|
||||
track?.title,
|
||||
track?.description,
|
||||
track?.name,
|
||||
track?.format,
|
||||
track?.label
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isLikelyBitmapSubtitleFormat(track) {
|
||||
const text = [
|
||||
track?.format,
|
||||
track?.codec,
|
||||
track?.codecName,
|
||||
track?.title,
|
||||
track?.description,
|
||||
track?.name,
|
||||
track?.label
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bpgs\b/.test(text)
|
||||
|| /\bhdmv\b/.test(text)
|
||||
|| /\bsup\b/.test(text)
|
||||
|| /\bvobsub\b/.test(text)
|
||||
|| /\bdvd[-_\s]?sub/.test(text)
|
||||
|| /\bdvb[-_\s]?sub/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelySdhSubtitleTrack(track) {
|
||||
const text = collectSubtitleText(track);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bsdh\b/.test(text)
|
||||
|| /\bcc\b/.test(text)
|
||||
|| /\bhoh\b/.test(text)
|
||||
|| /\bcaptions?\b/.test(text)
|
||||
|| /hard[-\s]?of[-\s]?hearing/.test(text)
|
||||
|| /hearing\s+impaired/.test(text)
|
||||
|| /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyForcedSubtitleTrack(track) {
|
||||
const text = collectSubtitleText(track);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bforced(?:\s+only)?\b/.test(text)
|
||||
|| /nur\s+erzwungen/.test(text)
|
||||
|| /\berzwungen\b/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function compareSubtitleTracksByForcedHeuristic(a, b) {
|
||||
const aSdh = Number(Boolean(a?.sdhLikely));
|
||||
const bSdh = Number(Boolean(b?.sdhLikely));
|
||||
if (aSdh !== bSdh) {
|
||||
return aSdh - bSdh;
|
||||
}
|
||||
|
||||
const aDefault = Number(Boolean(a?.defaultFlag));
|
||||
const bDefault = Number(Boolean(b?.defaultFlag));
|
||||
if (aDefault !== bDefault) {
|
||||
return aDefault - bDefault;
|
||||
}
|
||||
|
||||
const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
|
||||
const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
|
||||
if (aEventKnown !== bEventKnown) {
|
||||
return aEventKnown - bEventKnown;
|
||||
}
|
||||
if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
|
||||
return a.eventCount - b.eventCount;
|
||||
}
|
||||
|
||||
const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
|
||||
const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
|
||||
if (aSizeKnown !== bSizeKnown) {
|
||||
return aSizeKnown - bSizeKnown;
|
||||
}
|
||||
if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
|
||||
return a.streamSizeBytes - b.streamSizeBytes;
|
||||
}
|
||||
|
||||
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
|
||||
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
|
||||
if (aTrackId !== bTrackId) {
|
||||
return aTrackId - bTrackId;
|
||||
}
|
||||
return a.originalIndex - b.originalIndex;
|
||||
}
|
||||
|
||||
function compareSubtitleTracksForDedup(a, b) {
|
||||
const aSdh = Number(Boolean(a?.sdhLikely));
|
||||
const bSdh = Number(Boolean(b?.sdhLikely));
|
||||
if (aSdh !== bSdh) {
|
||||
return aSdh - bSdh;
|
||||
}
|
||||
|
||||
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
|
||||
if (defaultDiff !== 0) {
|
||||
return defaultDiff;
|
||||
}
|
||||
const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence);
|
||||
if (confidenceDiff !== 0) {
|
||||
return confidenceDiff;
|
||||
}
|
||||
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
|
||||
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
|
||||
if (aTrackId !== bTrackId) {
|
||||
return aTrackId - bTrackId;
|
||||
}
|
||||
return a.originalIndex - b.originalIndex;
|
||||
}
|
||||
|
||||
function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (!isLikelyBitmapSubtitleFormat(candidate)) {
|
||||
return false;
|
||||
}
|
||||
const comparable = (Array.isArray(languageEntries) ? languageEntries : [])
|
||||
.filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
|
||||
if (comparable.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateEventCount = Number(candidate?.eventCount);
|
||||
const comparableEventCounts = comparable
|
||||
.map((entry) => Number(entry?.eventCount))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
const maxComparableEventCount = comparableEventCounts.length > 0
|
||||
? Math.max(...comparableEventCounts)
|
||||
: null;
|
||||
const hasEventSignal = Number.isFinite(candidateEventCount)
|
||||
&& candidateEventCount >= 0
|
||||
&& Number.isFinite(maxComparableEventCount)
|
||||
&& maxComparableEventCount > 0
|
||||
&& candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
|
||||
&& (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
|
||||
&& (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;
|
||||
|
||||
const candidateStreamSize = Number(candidate?.streamSizeBytes);
|
||||
const comparableSizes = comparable
|
||||
.map((entry) => Number(entry?.streamSizeBytes))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
const maxComparableSize = comparableSizes.length > 0
|
||||
? Math.max(...comparableSizes)
|
||||
: null;
|
||||
const hasSizeSignal = Number.isFinite(candidateStreamSize)
|
||||
&& candidateStreamSize > 0
|
||||
&& Number.isFinite(maxComparableSize)
|
||||
&& maxComparableSize > 0
|
||||
&& (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
|
||||
&& (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;
|
||||
|
||||
const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
|
||||
if (signalCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.defaultFlag && signalCount < 2) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function annotateSubtitleTracks(subtitleTracks) {
|
||||
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
|
||||
if (tracks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = tracks.map((track, index) => ({
|
||||
...track,
|
||||
language: normalizeLanguage(track?.language || track?.languageLabel || 'und'),
|
||||
defaultFlag: Boolean(track?.defaultFlag),
|
||||
forcedFlag: parseSubtitleForcedFlag(track),
|
||||
forcedTrack: false,
|
||||
sourceConfidence: null,
|
||||
confidenceSource: 'heuristic',
|
||||
duplicate: false,
|
||||
selected: false,
|
||||
subtitleType: 'full',
|
||||
forcedAvailable: false,
|
||||
forcedSourceTrackIds: [],
|
||||
sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track),
|
||||
originalIndex: index
|
||||
}));
|
||||
|
||||
const byLanguage = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byLanguage.has(entry.language)) {
|
||||
byLanguage.set(entry.language, []);
|
||||
}
|
||||
byLanguage.get(entry.language).push(entry);
|
||||
}
|
||||
|
||||
for (const languageEntries of byLanguage.values()) {
|
||||
for (const entry of languageEntries) {
|
||||
const forcedByFlag = entry.forcedFlag === true;
|
||||
const forcedBlockedByFlag = entry.forcedFlag === false;
|
||||
const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry);
|
||||
|
||||
if (forcedByFlag) {
|
||||
entry.forcedTrack = true;
|
||||
entry.sourceConfidence = 'high';
|
||||
entry.confidenceSource = 'explicit_flag';
|
||||
} else if (forcedByTitle) {
|
||||
entry.forcedTrack = true;
|
||||
entry.sourceConfidence = 'medium';
|
||||
entry.confidenceSource = 'title';
|
||||
}
|
||||
}
|
||||
|
||||
if (!languageEntries.some((entry) => entry.forcedTrack)) {
|
||||
const candidates = languageEntries
|
||||
.filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
|
||||
.sort(compareSubtitleTracksByForcedHeuristic);
|
||||
const forcedCandidate = candidates[0] || null;
|
||||
if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) {
|
||||
forcedCandidate.forcedTrack = true;
|
||||
forcedCandidate.sourceConfidence = 'low';
|
||||
forcedCandidate.confidenceSource = 'heuristic';
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of languageEntries) {
|
||||
if (!entry.forcedTrack) {
|
||||
continue;
|
||||
}
|
||||
entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low');
|
||||
}
|
||||
|
||||
const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack);
|
||||
const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely);
|
||||
const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely);
|
||||
const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const forcedSourceTrackIds = forcedEntries
|
||||
.map((entry) => Number(entry?.sourceTrackId ?? entry?.id))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
const forcedAvailable = Boolean(forcedWinner);
|
||||
|
||||
for (const entry of languageEntries) {
|
||||
const typeWinner = entry.forcedTrack
|
||||
? forcedWinner
|
||||
: (entry.sdhLikely ? sdhWinner : fullWinner);
|
||||
entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
|
||||
if (entry.forcedTrack) {
|
||||
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
} else if (entry.sdhLikely) {
|
||||
entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
} else {
|
||||
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
}
|
||||
entry.subtitleType = entry.forcedTrack ? 'forced' : 'full';
|
||||
entry.forcedAvailable = forcedAvailable;
|
||||
entry.forcedSourceTrackIds = forcedSourceTrackIds;
|
||||
const fullHasForced = !entry.forcedTrack && Boolean(
|
||||
entry.fullHasForced
|
||||
?? entry.subtitleFullHasForced
|
||||
?? entry.hasForcedVariant
|
||||
);
|
||||
const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly;
|
||||
entry.isForcedOnly = typeof explicitForcedOnly === 'boolean'
|
||||
? explicitForcedOnly
|
||||
: (entry.forcedTrack && !fullHasForced);
|
||||
entry.fullHasForced = fullHasForced;
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
.sort((a, b) => a.originalIndex - b.originalIndex)
|
||||
.map((entry) => {
|
||||
const { originalIndex, ...rest } = entry;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
function parseEncoderList(raw) {
|
||||
return String(raw || '')
|
||||
.split(',')
|
||||
@@ -396,6 +874,29 @@ function normalizeBurnBehavior(raw) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function hasConfiguredLanguageSelection(rawValue) {
|
||||
return parseList(rawValue, normalizeSelectionLanguage)
|
||||
.filter((item) => item !== 'none' && item !== 'any')
|
||||
.length > 0;
|
||||
}
|
||||
|
||||
function hasExplicitPresetTrackSelection(profile = {}, trackType = 'audio') {
|
||||
const source = String(profile?.source || '').trim().toLowerCase();
|
||||
if (source !== 'preset-export') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trackType === 'audio') {
|
||||
const behavior = String(profile?.audioTrackSelectionBehavior || '').trim().toLowerCase();
|
||||
const languages = Array.isArray(profile?.audioLanguages) ? profile.audioLanguages : [];
|
||||
return behavior === 'all' || behavior === 'language' || behavior === 'none' || languages.length > 0;
|
||||
}
|
||||
|
||||
const behavior = String(profile?.subtitleTrackSelectionBehavior || '').trim().toLowerCase();
|
||||
const languages = Array.isArray(profile?.subtitleLanguages) ? profile.subtitleLanguages : [];
|
||||
return behavior === 'all' || behavior === 'language' || behavior === 'first' || languages.length > 0;
|
||||
}
|
||||
|
||||
function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {};
|
||||
const audioLanguages = Array.isArray(profile.audioLanguages)
|
||||
@@ -404,6 +905,14 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
const subtitleLanguages = Array.isArray(profile.subtitleLanguages)
|
||||
? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean)
|
||||
: [];
|
||||
const configuredAudioLanguages = parseList(settings?.handbrake_review_audio_languages, normalizeSelectionLanguage)
|
||||
.filter((item) => item !== 'none' && item !== 'any');
|
||||
const configuredSubtitleLanguages = parseList(settings?.handbrake_review_subtitle_languages, normalizeSelectionLanguage)
|
||||
.filter((item) => item !== 'none' && item !== 'any');
|
||||
const useConfiguredAudioLanguages = configuredAudioLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'audio');
|
||||
const useConfiguredSubtitleLanguages = configuredSubtitleLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'subtitle');
|
||||
const effectiveAudioLanguages = useConfiguredAudioLanguages ? configuredAudioLanguages : audioLanguages;
|
||||
const effectiveSubtitleLanguages = useConfiguredSubtitleLanguages ? configuredSubtitleLanguages : subtitleLanguages;
|
||||
const audioEncoders = Array.isArray(profile.audioEncoders)
|
||||
? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
@@ -419,6 +928,18 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
|
||||
const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio');
|
||||
const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle');
|
||||
const effectiveAudioMode = useConfiguredAudioLanguages && !hasConfiguredLanguageSelection(profile?.audioLanguages)
|
||||
? 'language'
|
||||
: baseAudioMode;
|
||||
const effectiveSubtitleMode = useConfiguredSubtitleLanguages && !hasConfiguredLanguageSelection(profile?.subtitleLanguages)
|
||||
? 'language'
|
||||
: baseSubtitleMode;
|
||||
const audioSelectionSource = useConfiguredAudioLanguages
|
||||
? 'settings'
|
||||
: (profile.source === 'preset-export' ? 'preset' : 'default');
|
||||
const subtitleSelectionSource = useConfiguredSubtitleLanguages
|
||||
? 'settings'
|
||||
: (profile.source === 'preset-export' ? 'preset' : 'default');
|
||||
|
||||
return {
|
||||
preset: settings?.handbrake_preset || null,
|
||||
@@ -426,11 +947,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
presetProfileSource: profile.source || 'fallback',
|
||||
presetProfileMessage: profile.message || null,
|
||||
audio: {
|
||||
mode: baseAudioMode,
|
||||
languages: audioLanguages.filter((item) => item !== 'none'),
|
||||
mode: effectiveAudioMode,
|
||||
languages: effectiveAudioLanguages.filter((item) => item !== 'none'),
|
||||
explicitIds: [],
|
||||
firstOnly: baseAudioMode === 'first',
|
||||
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
|
||||
firstOnly: effectiveAudioMode === 'first',
|
||||
selectionSource: audioSelectionSource,
|
||||
encoders: audioEncoders,
|
||||
encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default',
|
||||
copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK],
|
||||
@@ -439,11 +960,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||
fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default'
|
||||
},
|
||||
subtitle: {
|
||||
mode: baseSubtitleMode,
|
||||
languages: subtitleLanguages.filter((item) => item !== 'none'),
|
||||
mode: effectiveSubtitleMode,
|
||||
languages: effectiveSubtitleLanguages.filter((item) => item !== 'none'),
|
||||
explicitIds: [],
|
||||
firstOnly: baseSubtitleMode === 'first',
|
||||
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
|
||||
firstOnly: effectiveSubtitleMode === 'first',
|
||||
selectionSource: subtitleSelectionSource,
|
||||
// Do not auto-burn subtitle tracks from exported preset metadata.
|
||||
// Burn-in should only be activated via explicit CLI args/selection.
|
||||
burnBehavior: 'none',
|
||||
@@ -638,7 +1159,10 @@ function buildTrackSelectors(settings, presetProfile) {
|
||||
|
||||
function selectTrackIds(tracks, selector, trackType) {
|
||||
const available = Array.isArray(tracks) ? tracks : [];
|
||||
if (available.length === 0) {
|
||||
const selectable = trackType === 'subtitle'
|
||||
? available.filter((track) => !Boolean(track?.duplicate))
|
||||
: available;
|
||||
if (selectable.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -648,13 +1172,13 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
|
||||
if (selector.mode === 'all') {
|
||||
if (selector.firstOnly) {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
return available.map((track) => track.id);
|
||||
return selectable.map((track) => track.id);
|
||||
}
|
||||
|
||||
if (selector.mode === 'explicit') {
|
||||
const explicit = available
|
||||
const explicit = selectable
|
||||
.filter((track) => selector.explicitIds.includes(track.id))
|
||||
.map((track) => track.id);
|
||||
if (selector.firstOnly) {
|
||||
@@ -664,7 +1188,7 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
}
|
||||
|
||||
if (selector.mode === 'language') {
|
||||
const matches = available.filter((track) => selector.languages.includes(track.language));
|
||||
const matches = selectable.filter((track) => selector.languages.includes(track.language));
|
||||
if (selector.firstOnly) {
|
||||
return matches.length > 0 ? [matches[0].id] : [];
|
||||
}
|
||||
@@ -672,11 +1196,11 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
}
|
||||
|
||||
if (selector.mode === 'first') {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
|
||||
if (trackType === 'audio') {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -763,6 +1287,66 @@ function computeAudioTrackActions(track, selectedIndex, selector) {
|
||||
};
|
||||
}
|
||||
|
||||
function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) {
|
||||
const sourceTitles = Array.isArray(titles) ? titles : [];
|
||||
if (sourceTitles.length === 0) {
|
||||
return sourceTitles;
|
||||
}
|
||||
|
||||
const selectors = buildTrackSelectors(settings || {}, presetProfile || null);
|
||||
const audioSelector = selectors?.audio && typeof selectors.audio === 'object'
|
||||
? selectors.audio
|
||||
: {};
|
||||
|
||||
return sourceTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
||||
if (audioTracks.length === 0) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode));
|
||||
const selectedIndexById = new Map(
|
||||
selectedTracks
|
||||
.map((track, index) => {
|
||||
const trackId = Number(track?.id);
|
||||
if (!Number.isFinite(trackId)) {
|
||||
return null;
|
||||
}
|
||||
return [Math.trunc(trackId), index];
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const nextAudioTracks = audioTracks.map((track) => {
|
||||
if (!track?.selectedForEncode) {
|
||||
return {
|
||||
...track,
|
||||
encodeActions: [],
|
||||
encodeActionSummary: 'Nicht übernommen'
|
||||
};
|
||||
}
|
||||
const numericTrackId = Number(track?.id);
|
||||
const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null;
|
||||
const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId))
|
||||
? selectedIndexById.get(normalizedTrackId)
|
||||
: 0;
|
||||
const actions = computeAudioTrackActions(track, selectedIndex, audioSelector);
|
||||
return {
|
||||
...track,
|
||||
encodePreviewActions: actions.actions,
|
||||
encodePreviewSummary: actions.summary,
|
||||
encodeActions: actions.actions,
|
||||
encodeActionSummary: actions.summary
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...title,
|
||||
audioTracks: nextAudioTracks
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function computeSubtitleFlags(trackId, selectedTrackIds, selector) {
|
||||
const selected = selectedTrackIds.includes(trackId);
|
||||
if (!selected) {
|
||||
@@ -916,21 +1500,45 @@ function buildMediainfoReview({
|
||||
const normalizedSubtitle = title.subtitleTracks.map((track) => {
|
||||
const selectedByRule = selectedSubtitleIds.includes(track.id);
|
||||
const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle);
|
||||
const inferredForced = Boolean(
|
||||
track?.forcedTrack
|
||||
|| String(track?.subtitleType || '').trim().toLowerCase() === 'forced'
|
||||
);
|
||||
const inferredForcedOnly = Boolean(
|
||||
track?.isForcedOnly
|
||||
?? track?.forcedOnly
|
||||
?? track?.subtitlePreviewForcedOnly
|
||||
?? inferredForced
|
||||
);
|
||||
const inferredDefault = Boolean(track?.defaultFlag);
|
||||
const subtitlePreviewFlags = [];
|
||||
if (subtitleFlags.burned) {
|
||||
subtitlePreviewFlags.push('burned');
|
||||
}
|
||||
if (subtitleFlags.forced || inferredForced) {
|
||||
subtitlePreviewFlags.push('forced');
|
||||
}
|
||||
if (subtitleFlags.forcedOnly || inferredForcedOnly) {
|
||||
subtitlePreviewFlags.push('forced-only');
|
||||
}
|
||||
if (subtitleFlags.default || inferredDefault) {
|
||||
subtitlePreviewFlags.push('default');
|
||||
}
|
||||
const subtitlePreviewSummary = !selectedByRule
|
||||
? 'Nicht übernommen'
|
||||
: (subtitleFlags.flags.length > 0
|
||||
? `Übernehmen (${subtitleFlags.flags.join(', ')})`
|
||||
: (subtitlePreviewFlags.length > 0
|
||||
? `Übernehmen (${subtitlePreviewFlags.join(', ')})`
|
||||
: 'Übernehmen');
|
||||
|
||||
return {
|
||||
...track,
|
||||
selectedByRule,
|
||||
subtitlePreviewSummary,
|
||||
subtitlePreviewFlags: subtitleFlags.flags,
|
||||
subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [],
|
||||
subtitlePreviewBurnIn: subtitleFlags.burned,
|
||||
subtitlePreviewForced: subtitleFlags.forced,
|
||||
subtitlePreviewForcedOnly: subtitleFlags.forcedOnly,
|
||||
subtitlePreviewDefaultTrack: subtitleFlags.default
|
||||
subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false,
|
||||
subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false,
|
||||
subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1022,5 +1630,6 @@ function buildMediainfoReview({
|
||||
|
||||
module.exports = {
|
||||
parseDurationSeconds,
|
||||
buildMediainfoReview
|
||||
buildMediainfoReview,
|
||||
refreshAudioTrackActionsForPlanTitles
|
||||
};
|
||||
|
||||
@@ -28,20 +28,73 @@ function transliterateForFilename(input) {
|
||||
|
||||
function sanitizeFileName(input) {
|
||||
return transliterateForFilename(String(input || 'untitled'))
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function sanitizeFileNameWithExtension(input) {
|
||||
const str = String(input || 'untitled');
|
||||
const ext = path.extname(str);
|
||||
const base = ext ? str.slice(0, -ext.length) : str;
|
||||
const cleanBase = sanitizeFileName(base);
|
||||
const cleanExt = ext.toLowerCase().replace(/[^a-z0-9.]/g, '');
|
||||
return (cleanBase || 'untitled') + cleanExt;
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||
const val = values[key.trim()];
|
||||
const rawSource = String(template || '${title} (${year})');
|
||||
const parseToken = (rawToken) => {
|
||||
const token = String(rawToken || '').trim();
|
||||
if (!token) {
|
||||
return { format: '', key: '' };
|
||||
}
|
||||
const separatorIndex = token.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return { format: '', key: token };
|
||||
}
|
||||
return {
|
||||
format: token.slice(0, separatorIndex).trim(),
|
||||
key: token.slice(separatorIndex + 1).trim()
|
||||
};
|
||||
};
|
||||
const applyFormat = (value, format) => {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
if (!normalizedFormat) {
|
||||
return String(value);
|
||||
}
|
||||
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
|
||||
if (normalizedFormat === '0') {
|
||||
const raw = String(value);
|
||||
const match = raw.match(/^(-?)(\d+)$/);
|
||||
if (match) {
|
||||
const sign = match[1] || '';
|
||||
const digits = String(match[2] || '');
|
||||
return `${sign}${digits.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const resolveToken = (rawKey) => {
|
||||
const { format, key } = parseToken(rawKey);
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
return applyFormat(val, format);
|
||||
};
|
||||
|
||||
const source = (
|
||||
/[{}]/.test(rawSource)
|
||||
? rawSource
|
||||
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
|
||||
);
|
||||
|
||||
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
|
||||
return source
|
||||
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
|
||||
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
|
||||
}
|
||||
|
||||
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||
@@ -86,6 +139,7 @@ module.exports = {
|
||||
ensureDir,
|
||||
transliterateForFilename,
|
||||
sanitizeFileName,
|
||||
sanitizeFileNameWithExtension,
|
||||
renderTemplate,
|
||||
findLargestMediaFile,
|
||||
findMediaFiles
|
||||
|
||||
@@ -2,6 +2,8 @@ const LARGE_JUMP_THRESHOLD = 20;
|
||||
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
||||
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
|
||||
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
|
||||
const BRANCHING_OVERLAP_THRESHOLD = 0.6;
|
||||
const BRANCHING_AUDIO_SIMILARITY_THRESHOLD = 0.75;
|
||||
|
||||
function parseDurationSeconds(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
@@ -135,6 +137,83 @@ function extractPlaylistMapping(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPlaylistEqualityMapping(line) {
|
||||
const raw = String(line || '');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Robot output often ends with explicit args:
|
||||
// ..., "00100.mpls","00091.mpls"
|
||||
if (/^MSG:3309,/i.test(raw)) {
|
||||
const quoted = [];
|
||||
const regex = /"([^"]*)"/g;
|
||||
let match = regex.exec(raw);
|
||||
while (match) {
|
||||
quoted.push(String(match[1] || '').trim());
|
||||
match = regex.exec(raw);
|
||||
}
|
||||
if (quoted.length >= 2) {
|
||||
const aliasId = normalizePlaylistId(quoted[quoted.length - 2]);
|
||||
const canonicalId = normalizePlaylistId(quoted[quoted.length - 1]);
|
||||
if (aliasId && canonicalId && aliasId !== canonicalId) {
|
||||
return {
|
||||
aliasId,
|
||||
canonicalId
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text fallback:
|
||||
// "Title 00100.mpls is equal to title 00091.mpls and was skipped"
|
||||
const textMatch = raw.match(/title\s+(\d{5}\.mpls)\s+is equal to title\s+(\d{5}\.mpls)/i);
|
||||
if (textMatch) {
|
||||
const aliasId = normalizePlaylistId(textMatch[1]);
|
||||
const canonicalId = normalizePlaylistId(textMatch[2]);
|
||||
if (aliasId && canonicalId && aliasId !== canonicalId) {
|
||||
return {
|
||||
aliasId,
|
||||
canonicalId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPlaylistAliasMap(lines) {
|
||||
const aliasMap = new Map();
|
||||
for (const line of lines || []) {
|
||||
const mapping = extractPlaylistEqualityMapping(line);
|
||||
if (!mapping) {
|
||||
continue;
|
||||
}
|
||||
const canonicalId = normalizePlaylistId(mapping.canonicalId);
|
||||
const aliasId = normalizePlaylistId(mapping.aliasId);
|
||||
if (!canonicalId || !aliasId || canonicalId === aliasId) {
|
||||
continue;
|
||||
}
|
||||
if (!aliasMap.has(canonicalId)) {
|
||||
aliasMap.set(canonicalId, new Set());
|
||||
}
|
||||
aliasMap.get(canonicalId).add(aliasId);
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
for (const [canonicalId, aliasSet] of aliasMap.entries()) {
|
||||
const aliases = Array.from(aliasSet)
|
||||
.map((value) => normalizePlaylistId(value))
|
||||
.filter(Boolean)
|
||||
.filter((value) => value !== canonicalId)
|
||||
.sort();
|
||||
if (aliases.length > 0) {
|
||||
normalized[canonicalId] = aliases;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseAnalyzeTitles(lines) {
|
||||
const titleMap = new Map();
|
||||
|
||||
@@ -471,7 +550,330 @@ function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
|
||||
);
|
||||
}
|
||||
|
||||
function computeSegmentMetrics(segmentNumbers) {
|
||||
function clamp01(value) {
|
||||
const numeric = Number(value || 0);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, numeric));
|
||||
}
|
||||
|
||||
function normalizeTrackSignaturePart(value, fallback = 'na') {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function buildAudioSignatureTokens(title) {
|
||||
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
||||
if (audioTracks.length === 0) {
|
||||
const count = Number(title?.audioTrackCount || 0);
|
||||
return count > 0 ? [`count:${count}`] : [];
|
||||
}
|
||||
return uniqueOrdered(audioTracks.map((track) => (
|
||||
`${normalizeTrackSignaturePart(track?.language || track?.languageLabel || 'und', 'und')}`
|
||||
+ `|${normalizeTrackSignaturePart(track?.format)}`
|
||||
+ `|${normalizeTrackSignaturePart(track?.channels)}`
|
||||
)));
|
||||
}
|
||||
|
||||
function computeTokenJaccardSimilarity(leftTokens, rightTokens) {
|
||||
const left = new Set((Array.isArray(leftTokens) ? leftTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
|
||||
const right = new Set((Array.isArray(rightTokens) ? rightTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
|
||||
if (left.size === 0 && right.size === 0) {
|
||||
return 1;
|
||||
}
|
||||
if (left.size === 0 || right.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
let shared = 0;
|
||||
for (const token of left) {
|
||||
if (right.has(token)) {
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
const unionSize = new Set([...left, ...right]).size;
|
||||
return unionSize > 0 ? Number((shared / unionSize).toFixed(4)) : 0;
|
||||
}
|
||||
|
||||
function computeSegmentOverlapInfo(leftSegments, rightSegments) {
|
||||
const leftNumbers = Array.isArray(leftSegments)
|
||||
? leftSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
const rightNumbers = Array.isArray(rightSegments)
|
||||
? rightSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
const leftSet = new Set(leftNumbers);
|
||||
const rightSet = new Set(rightNumbers);
|
||||
if (leftSet.size === 0 || rightSet.size === 0) {
|
||||
return {
|
||||
sharedCount: 0,
|
||||
ratioToSmaller: 0,
|
||||
ratioToLarger: 0,
|
||||
jaccard: 0
|
||||
};
|
||||
}
|
||||
let sharedCount = 0;
|
||||
for (const value of leftSet) {
|
||||
if (rightSet.has(value)) {
|
||||
sharedCount += 1;
|
||||
}
|
||||
}
|
||||
const smaller = Math.max(1, Math.min(leftSet.size, rightSet.size));
|
||||
const larger = Math.max(1, Math.max(leftSet.size, rightSet.size));
|
||||
const unionSize = new Set([...leftSet, ...rightSet]).size;
|
||||
return {
|
||||
sharedCount,
|
||||
ratioToSmaller: Number((sharedCount / smaller).toFixed(4)),
|
||||
ratioToLarger: Number((sharedCount / larger).toFixed(4)),
|
||||
jaccard: unionSize > 0 ? Number((sharedCount / unionSize).toFixed(4)) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function buildDisjointSet(size) {
|
||||
const parent = Array.from({ length: Math.max(0, Number(size || 0)) }, (_, index) => index);
|
||||
const rank = Array.from({ length: parent.length }, () => 0);
|
||||
const find = (value) => {
|
||||
if (parent[value] !== value) {
|
||||
parent[value] = find(parent[value]);
|
||||
}
|
||||
return parent[value];
|
||||
};
|
||||
const union = (left, right) => {
|
||||
const rootLeft = find(left);
|
||||
const rootRight = find(right);
|
||||
if (rootLeft === rootRight) {
|
||||
return;
|
||||
}
|
||||
if (rank[rootLeft] < rank[rootRight]) {
|
||||
parent[rootLeft] = rootRight;
|
||||
return;
|
||||
}
|
||||
if (rank[rootLeft] > rank[rootRight]) {
|
||||
parent[rootRight] = rootLeft;
|
||||
return;
|
||||
}
|
||||
parent[rootRight] = rootLeft;
|
||||
rank[rootLeft] += 1;
|
||||
};
|
||||
return { find, union };
|
||||
}
|
||||
|
||||
function longestCommonSubsequence(leftValues, rightValues) {
|
||||
const left = Array.isArray(leftValues) ? leftValues : [];
|
||||
const right = Array.isArray(rightValues) ? rightValues : [];
|
||||
if (left.length === 0 || right.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
|
||||
for (let i = left.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = right.length - 1; j >= 0; j -= 1) {
|
||||
if (left[i] === right[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const output = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < left.length && j < right.length) {
|
||||
if (left[i] === right[j]) {
|
||||
output.push(left[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildFallbackCoreSequence(clusterTitles) {
|
||||
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
|
||||
const minimumFrequency = Math.max(2, Math.ceil(titles.length * 0.75));
|
||||
const frequencyMap = new Map();
|
||||
const positionMap = new Map();
|
||||
for (const title of titles) {
|
||||
const numbers = Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [];
|
||||
const seenInTitle = new Set();
|
||||
numbers.forEach((segment, index) => {
|
||||
if (!Number.isFinite(segment)) {
|
||||
return;
|
||||
}
|
||||
const normalized = Math.trunc(segment);
|
||||
if (!frequencyMap.has(normalized)) {
|
||||
frequencyMap.set(normalized, 0);
|
||||
positionMap.set(normalized, []);
|
||||
}
|
||||
if (!seenInTitle.has(normalized)) {
|
||||
frequencyMap.set(normalized, Number(frequencyMap.get(normalized) || 0) + 1);
|
||||
seenInTitle.add(normalized);
|
||||
}
|
||||
positionMap.get(normalized).push(index);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(frequencyMap.entries())
|
||||
.filter(([, frequency]) => Number(frequency || 0) >= minimumFrequency)
|
||||
.map(([segment]) => {
|
||||
const positions = positionMap.get(segment) || [];
|
||||
const averagePosition = positions.length > 0
|
||||
? positions.reduce((sum, value) => sum + Number(value || 0), 0) / positions.length
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return {
|
||||
segment,
|
||||
averagePosition
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.averagePosition - b.averagePosition || a.segment - b.segment)
|
||||
.map((entry) => entry.segment);
|
||||
}
|
||||
|
||||
function buildClusterCoreSequence(clusterTitles) {
|
||||
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
|
||||
const sequences = titles
|
||||
.map((title) => Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [])
|
||||
.filter((sequence) => sequence.length > 0);
|
||||
if (sequences.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let coreSequence = sequences[0].slice();
|
||||
for (let index = 1; index < sequences.length; index += 1) {
|
||||
coreSequence = longestCommonSubsequence(coreSequence, sequences[index]);
|
||||
if (coreSequence.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (coreSequence.length >= Math.max(2, Math.floor(sequences[0].length / 3))) {
|
||||
return coreSequence;
|
||||
}
|
||||
return buildFallbackCoreSequence(clusterTitles);
|
||||
}
|
||||
|
||||
function makeVariantBoundaryKey(beforeSegment, afterSegment) {
|
||||
const before = Number.isFinite(Number(beforeSegment)) ? String(Math.trunc(Number(beforeSegment))).padStart(5, '0') : 'START';
|
||||
const after = Number.isFinite(Number(afterSegment)) ? String(Math.trunc(Number(afterSegment))).padStart(5, '0') : 'END';
|
||||
return `${before}->${after}`;
|
||||
}
|
||||
|
||||
function formatVariantSpanLabel(span) {
|
||||
if (!span || !Array.isArray(span.segments) || span.segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const before = Number.isFinite(Number(span.beforeSegment))
|
||||
? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0')
|
||||
: 'START';
|
||||
const after = Number.isFinite(Number(span.afterSegment))
|
||||
? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0')
|
||||
: 'END';
|
||||
const segmentList = span.segments
|
||||
.map((segment) => String(Math.trunc(Number(segment))).padStart(5, '0'))
|
||||
.join(', ');
|
||||
return `${before}->${after}: [${segmentList}]`;
|
||||
}
|
||||
|
||||
function extractVariantSpansForTitle(segmentNumbers, coreSequence) {
|
||||
const numbers = Array.isArray(segmentNumbers)
|
||||
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
const core = Array.isArray(coreSequence)
|
||||
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
if (numbers.length === 0 || core.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const coreIndexBySegment = new Map(core.map((segment, index) => [segment, index]));
|
||||
const spans = [];
|
||||
let matchedCoreIndex = -1;
|
||||
let currentSpan = [];
|
||||
|
||||
const flushCurrentSpan = (afterSegment = null) => {
|
||||
if (currentSpan.length === 0) {
|
||||
return;
|
||||
}
|
||||
spans.push({
|
||||
beforeSegment: matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
|
||||
afterSegment,
|
||||
segments: currentSpan.slice(),
|
||||
boundaryKey: makeVariantBoundaryKey(
|
||||
matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
|
||||
afterSegment
|
||||
)
|
||||
});
|
||||
currentSpan = [];
|
||||
};
|
||||
|
||||
for (const segment of numbers) {
|
||||
const coreIndex = coreIndexBySegment.has(segment) ? coreIndexBySegment.get(segment) : -1;
|
||||
const expectedCoreIndex = matchedCoreIndex + 1;
|
||||
if (coreIndex === expectedCoreIndex) {
|
||||
flushCurrentSpan(segment);
|
||||
matchedCoreIndex = coreIndex;
|
||||
continue;
|
||||
}
|
||||
if (coreIndex > expectedCoreIndex) {
|
||||
flushCurrentSpan(core[expectedCoreIndex] ?? segment);
|
||||
matchedCoreIndex = coreIndex;
|
||||
continue;
|
||||
}
|
||||
currentSpan.push(segment);
|
||||
}
|
||||
|
||||
flushCurrentSpan(matchedCoreIndex + 1 < core.length ? core[matchedCoreIndex + 1] : null);
|
||||
return spans;
|
||||
}
|
||||
|
||||
function buildClusterCommonRuns(coreSequence, titleSpanRows) {
|
||||
const core = Array.isArray(coreSequence)
|
||||
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
if (core.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const boundaries = new Set();
|
||||
for (const row of titleSpanRows || []) {
|
||||
for (const span of row?.variantSpans || []) {
|
||||
if (!Number.isFinite(Number(span?.beforeSegment)) || !Number.isFinite(Number(span?.afterSegment))) {
|
||||
continue;
|
||||
}
|
||||
boundaries.add(makeVariantBoundaryKey(span.beforeSegment, span.afterSegment));
|
||||
}
|
||||
}
|
||||
const runs = [];
|
||||
let startIndex = 0;
|
||||
for (let index = 0; index < core.length - 1; index += 1) {
|
||||
const boundaryKey = makeVariantBoundaryKey(core[index], core[index + 1]);
|
||||
if (!boundaries.has(boundaryKey)) {
|
||||
continue;
|
||||
}
|
||||
const runSegments = core.slice(startIndex, index + 1);
|
||||
if (runSegments.length > 0) {
|
||||
runs.push({
|
||||
startSegment: runSegments[0],
|
||||
endSegment: runSegments[runSegments.length - 1],
|
||||
length: runSegments.length,
|
||||
segments: runSegments
|
||||
});
|
||||
}
|
||||
startIndex = index + 1;
|
||||
}
|
||||
const tailSegments = core.slice(startIndex);
|
||||
if (tailSegments.length > 0) {
|
||||
runs.push({
|
||||
startSegment: tailSegments[0],
|
||||
endSegment: tailSegments[tailSegments.length - 1],
|
||||
length: tailSegments.length,
|
||||
segments: tailSegments
|
||||
});
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function computeLegacySegmentMetrics(segmentNumbers) {
|
||||
const numbers = Array.isArray(segmentNumbers)
|
||||
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||
: [];
|
||||
@@ -550,29 +952,368 @@ function computeSegmentMetrics(segmentNumbers) {
|
||||
};
|
||||
}
|
||||
|
||||
function computeStandaloneSegmentMetrics(title) {
|
||||
const legacyMetrics = computeLegacySegmentMetrics(title?.segmentNumbers);
|
||||
const transitions = Math.max(1, legacyMetrics.segmentCount - 1);
|
||||
const directRatio = legacyMetrics.directSequenceSteps / transitions;
|
||||
const nonBackwardRatio = 1 - (legacyMetrics.backwardJumps / transitions);
|
||||
const boundedJumpRatio = 1 - (legacyMetrics.largeJumps / transitions);
|
||||
const antiAlternatingRatio = 1 - legacyMetrics.alternatingRatio;
|
||||
const correctedCoherence = clamp01(
|
||||
(directRatio * 0.18)
|
||||
+ (clamp01(nonBackwardRatio) * 0.34)
|
||||
+ (clamp01(boundedJumpRatio) * 0.34)
|
||||
+ (clamp01(antiAlternatingRatio) * 0.14)
|
||||
);
|
||||
return {
|
||||
...legacyMetrics,
|
||||
clusterId: null,
|
||||
clusterSize: 1,
|
||||
averageSegmentOverlap: 0,
|
||||
commonRuns: [],
|
||||
commonRunCount: 0,
|
||||
sharedCoreCount: 0,
|
||||
sharedCoreRatio: 0,
|
||||
sharedAdjacencyRatio: 0,
|
||||
variantSpans: [],
|
||||
variantSpanCount: 0,
|
||||
variantSegmentCount: 0,
|
||||
singleSegmentVariantSpans: 0,
|
||||
multiSegmentVariantSpans: 0,
|
||||
variantComplexity: 0,
|
||||
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
|
||||
legacyStructureScore: legacyMetrics.score,
|
||||
correctedCoherence: Number(correctedCoherence.toFixed(4)),
|
||||
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
|
||||
score: Number((correctedCoherence * 35).toFixed(4)),
|
||||
coherenceMode: 'standalone',
|
||||
positionReason: 'Einzelkandidat oder kein Branching-Cluster erkannt.'
|
||||
};
|
||||
}
|
||||
|
||||
function buildClusterContexts(titles, options = {}) {
|
||||
const rows = (Array.isArray(titles) ? titles : []).map((title, index) => ({
|
||||
...title,
|
||||
_clusterIndex: index,
|
||||
_audioSignatureTokens: buildAudioSignatureTokens(title)
|
||||
}));
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const durationTolerance = Math.max(
|
||||
0,
|
||||
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
|
||||
);
|
||||
const dsu = buildDisjointSet(rows.length);
|
||||
for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) {
|
||||
const left = rows[leftIndex];
|
||||
const right = rows[rightIndex];
|
||||
const durationClose = Math.abs(Number(left?.durationSeconds || 0) - Number(right?.durationSeconds || 0)) <= durationTolerance;
|
||||
if (!durationClose) {
|
||||
continue;
|
||||
}
|
||||
const audioSimilarity = computeTokenJaccardSimilarity(left._audioSignatureTokens, right._audioSignatureTokens);
|
||||
if (audioSimilarity < BRANCHING_AUDIO_SIMILARITY_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
|
||||
if (overlapInfo.ratioToSmaller < BRANCHING_OVERLAP_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
dsu.union(leftIndex, rightIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const root = dsu.find(index);
|
||||
if (!grouped.has(root)) {
|
||||
grouped.set(root, []);
|
||||
}
|
||||
grouped.get(root).push(row);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.filter((clusterRows) => clusterRows.length > 1)
|
||||
.sort((left, right) => right.length - left.length)
|
||||
.map((clusterRows, clusterIndex) => {
|
||||
const clusterId = `branching-${clusterIndex + 1}`;
|
||||
const pairwiseOverlap = {};
|
||||
for (const row of clusterRows) {
|
||||
const playlistId = normalizePlaylistId(row?.playlistId);
|
||||
if (!playlistId) {
|
||||
continue;
|
||||
}
|
||||
pairwiseOverlap[playlistId] = {};
|
||||
}
|
||||
for (let i = 0; i < clusterRows.length; i += 1) {
|
||||
for (let j = i + 1; j < clusterRows.length; j += 1) {
|
||||
const left = clusterRows[i];
|
||||
const right = clusterRows[j];
|
||||
const leftPlaylistId = normalizePlaylistId(left?.playlistId);
|
||||
const rightPlaylistId = normalizePlaylistId(right?.playlistId);
|
||||
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
|
||||
if (leftPlaylistId && rightPlaylistId) {
|
||||
pairwiseOverlap[leftPlaylistId][rightPlaylistId] = overlapInfo;
|
||||
pairwiseOverlap[rightPlaylistId][leftPlaylistId] = overlapInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const coreSequence = buildClusterCoreSequence(clusterRows);
|
||||
const titleSpanRows = clusterRows.map((row) => {
|
||||
const variantSpans = extractVariantSpansForTitle(row?.segmentNumbers, coreSequence);
|
||||
const variantSegmentCount = variantSpans.reduce((sum, span) => sum + span.segments.length, 0);
|
||||
const singleSegmentVariantSpans = variantSpans.filter((span) => span.segments.length === 1).length;
|
||||
const multiSegmentVariantSpans = variantSpans.filter((span) => span.segments.length > 1).length;
|
||||
const variantComplexity = variantSpans.reduce((sum, span) => sum + (span.segments.length ** 2), 0);
|
||||
const playlistId = normalizePlaylistId(row?.playlistId);
|
||||
const overlapEntries = playlistId && pairwiseOverlap[playlistId]
|
||||
? Object.entries(pairwiseOverlap[playlistId]).map(([otherPlaylistId, info]) => ({
|
||||
otherPlaylistId,
|
||||
ratioToSmaller: Number(info?.ratioToSmaller || 0),
|
||||
sharedCount: Number(info?.sharedCount || 0)
|
||||
}))
|
||||
: [];
|
||||
const averageSegmentOverlap = overlapEntries.length > 0
|
||||
? overlapEntries.reduce((sum, entry) => sum + Number(entry.ratioToSmaller || 0), 0) / overlapEntries.length
|
||||
: 0;
|
||||
return {
|
||||
row,
|
||||
variantSpans,
|
||||
variantSegmentCount,
|
||||
singleSegmentVariantSpans,
|
||||
multiSegmentVariantSpans,
|
||||
variantComplexity,
|
||||
overlapEntries,
|
||||
averageSegmentOverlap: Number(averageSegmentOverlap.toFixed(4))
|
||||
};
|
||||
});
|
||||
|
||||
const commonRuns = buildClusterCommonRuns(coreSequence, titleSpanRows);
|
||||
const maxVariantComplexity = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantComplexity || 0)), 0);
|
||||
const maxVariantSegmentCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSegmentCount || 0)), 0);
|
||||
const maxVariantSpanCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSpans.length || 0)), 0);
|
||||
const legacyStructureScores = titleSpanRows.map((row) => Number(computeLegacySegmentMetrics(row.row?.segmentNumbers).score || 0));
|
||||
const minLegacyStructureScore = legacyStructureScores.length > 0 ? Math.min(...legacyStructureScores) : 0;
|
||||
const maxLegacyStructureScore = legacyStructureScores.length > 0 ? Math.max(...legacyStructureScores) : 0;
|
||||
|
||||
const titlesWithMetrics = titleSpanRows.map((spanRow, index) => {
|
||||
const legacyMetrics = computeLegacySegmentMetrics(spanRow.row?.segmentNumbers);
|
||||
const sharedCoreCount = coreSequence.length;
|
||||
const segmentCount = Math.max(1, Number(legacyMetrics.segmentCount || 0));
|
||||
const sharedCoreRatio = sharedCoreCount > 0 ? sharedCoreCount / segmentCount : 0;
|
||||
const variantSpanCount = spanRow.variantSpans.length;
|
||||
const variantComplexityNorm = maxVariantComplexity > 0
|
||||
? Number(spanRow.variantComplexity || 0) / maxVariantComplexity
|
||||
: 0;
|
||||
const variantSegmentNorm = maxVariantSegmentCount > 0
|
||||
? Number(spanRow.variantSegmentCount || 0) / maxVariantSegmentCount
|
||||
: 0;
|
||||
const variantSpanNorm = maxVariantSpanCount > 0
|
||||
? Number(variantSpanCount || 0) / maxVariantSpanCount
|
||||
: 0;
|
||||
const singleSegmentVariantRatio = variantSpanCount > 0
|
||||
? spanRow.singleSegmentVariantSpans / variantSpanCount
|
||||
: 1;
|
||||
const multiSegmentPenalty = variantSpanCount > 0
|
||||
? spanRow.multiSegmentVariantSpans / variantSpanCount
|
||||
: 0;
|
||||
const legacyRange = maxLegacyStructureScore - minLegacyStructureScore;
|
||||
const legacyTieHint = legacyRange > 0
|
||||
? clamp01((Number(legacyMetrics.score || 0) - minLegacyStructureScore) / legacyRange)
|
||||
: 0;
|
||||
const correctedCoherence = clamp01(
|
||||
(sharedCoreRatio * 0.38)
|
||||
+ (spanRow.averageSegmentOverlap * 0.12)
|
||||
+ (singleSegmentVariantRatio * 0.22)
|
||||
+ ((1 - variantComplexityNorm) * 0.16)
|
||||
+ ((1 - variantSegmentNorm) * 0.08)
|
||||
+ ((1 - variantSpanNorm) * 0.04)
|
||||
+ (legacyTieHint * 0.02)
|
||||
- (multiSegmentPenalty * 0.08)
|
||||
);
|
||||
|
||||
let positionReason = 'nahe Branching-Variante';
|
||||
if (spanRow.multiSegmentVariantSpans === 0 && spanRow.variantSegmentCount <= Math.max(1, variantSpanCount)) {
|
||||
positionReason = 'strukturell einfachster Basis-/Referenzpfad im Cluster';
|
||||
} else if (spanRow.multiSegmentVariantSpans > 0) {
|
||||
positionReason = 'Variante mit zusätzlichen Mehrsegment-Branches';
|
||||
}
|
||||
|
||||
return {
|
||||
...spanRow.row,
|
||||
...legacyMetrics,
|
||||
clusterId,
|
||||
clusterSize: clusterRows.length,
|
||||
averageSegmentOverlap: spanRow.averageSegmentOverlap,
|
||||
overlapEntries: spanRow.overlapEntries,
|
||||
commonRuns,
|
||||
commonRunCount: commonRuns.length,
|
||||
sharedCoreCount,
|
||||
sharedCoreRatio: Number(sharedCoreRatio.toFixed(4)),
|
||||
sharedAdjacencyRatio: coreSequence.length > 1 ? 1 : 0,
|
||||
variantSpans: spanRow.variantSpans,
|
||||
variantSpanCount,
|
||||
variantSegmentCount: spanRow.variantSegmentCount,
|
||||
singleSegmentVariantSpans: spanRow.singleSegmentVariantSpans,
|
||||
multiSegmentVariantSpans: spanRow.multiSegmentVariantSpans,
|
||||
variantComplexity: spanRow.variantComplexity,
|
||||
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
|
||||
legacyStructureScore: legacyMetrics.score,
|
||||
correctedCoherence: Number(correctedCoherence.toFixed(4)),
|
||||
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
|
||||
score: Number((((correctedCoherence * 60) + Math.min(18, (clusterRows.length - 1) * 9))).toFixed(4)),
|
||||
coherenceMode: 'cluster_relative',
|
||||
positionReason,
|
||||
_legacyTieHint: legacyTieHint,
|
||||
_clusterSortIndex: index
|
||||
};
|
||||
});
|
||||
|
||||
const variantBoundaries = uniqueOrdered(
|
||||
titleSpanRows
|
||||
.flatMap((row) => row.variantSpans.map((span) => formatVariantSpanLabel(span)))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
return {
|
||||
id: clusterId,
|
||||
size: clusterRows.length,
|
||||
coreSequence,
|
||||
commonRuns,
|
||||
variantBoundaries,
|
||||
pairwiseOverlap,
|
||||
titles: titlesWithMetrics
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function computeSegmentMetrics(title, clusterMetricsByPlaylist = null) {
|
||||
const playlistId = normalizePlaylistId(title?.playlistId);
|
||||
if (playlistId && clusterMetricsByPlaylist && clusterMetricsByPlaylist[playlistId]) {
|
||||
return clusterMetricsByPlaylist[playlistId];
|
||||
}
|
||||
return computeStandaloneSegmentMetrics(title);
|
||||
}
|
||||
|
||||
function buildEvaluationLabel(metrics) {
|
||||
if (!metrics || metrics.segmentCount === 0) {
|
||||
return 'Keine Segmentliste aus TINFO:26 verfügbar';
|
||||
}
|
||||
if (String(metrics?.coherenceMode || '') === 'cluster_relative' && Number(metrics?.clusterSize || 0) > 1) {
|
||||
if (metrics.multiSegmentVariantSpans === 0 && metrics.variantSegmentCount <= Math.max(1, Number(metrics.variantSpanCount || 0))) {
|
||||
return 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster';
|
||||
}
|
||||
if (metrics.correctedCoherence >= 0.6) {
|
||||
return 'nahe Branching-Variante mit plausibler Segmentstruktur';
|
||||
}
|
||||
return 'komplexere Branching-Variante - Sichtprüfung empfohlen';
|
||||
}
|
||||
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
|
||||
return 'Fake-Struktur (alternierendes Sprungmuster)';
|
||||
return 'Auffällige Segmentstruktur - Sichtprüfung empfohlen';
|
||||
}
|
||||
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
|
||||
return 'Auffällige Segmentreihenfolge';
|
||||
if (metrics.correctedCoherence < 0.45) {
|
||||
return 'unklare Segmentstruktur - Sichtprüfung empfohlen';
|
||||
}
|
||||
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
|
||||
return 'wahrscheinlich konsistente Segmentstruktur';
|
||||
}
|
||||
|
||||
function scoreCandidates(groupTitles) {
|
||||
function normalizeRelativeScore(value, maxValue) {
|
||||
const numericValue = Number(value || 0);
|
||||
const numericMax = Number(maxValue || 0);
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(numericMax) || numericMax <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, numericValue / numericMax));
|
||||
}
|
||||
|
||||
function scoreCandidates(groupTitles, options = {}) {
|
||||
const titles = Array.isArray(groupTitles) ? groupTitles : [];
|
||||
if (titles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxDurationSeconds = titles.reduce(
|
||||
(max, title) => Math.max(max, Number(title?.durationSeconds || 0)),
|
||||
0
|
||||
);
|
||||
const maxAudioTrackCount = titles.reduce(
|
||||
(max, title) => Math.max(max, Number(title?.audioTrackCount || 0)),
|
||||
0
|
||||
);
|
||||
const maxChapterCount = titles.reduce(
|
||||
(max, title) => Math.max(max, Number(title?.chapters || 0)),
|
||||
0
|
||||
);
|
||||
const clusterContexts = buildClusterContexts(titles, options);
|
||||
const clusterMetricsByPlaylist = {};
|
||||
const clusterSummaryRows = [];
|
||||
for (const clusterContext of clusterContexts) {
|
||||
const playlistIds = [];
|
||||
for (const metrics of clusterContext.titles || []) {
|
||||
const playlistId = normalizePlaylistId(metrics?.playlistId);
|
||||
if (playlistId) {
|
||||
clusterMetricsByPlaylist[playlistId] = metrics;
|
||||
playlistIds.push(playlistId);
|
||||
}
|
||||
}
|
||||
clusterSummaryRows.push({
|
||||
id: clusterContext.id,
|
||||
size: clusterContext.size,
|
||||
playlistIds: uniqueOrdered(playlistIds),
|
||||
commonRuns: clusterContext.commonRuns,
|
||||
variantBoundaries: clusterContext.variantBoundaries
|
||||
});
|
||||
}
|
||||
|
||||
return titles
|
||||
.map((title) => {
|
||||
const metrics = computeSegmentMetrics(title.segmentNumbers);
|
||||
const metrics = computeSegmentMetrics(title, clusterMetricsByPlaylist);
|
||||
const structureScore = Number(metrics.score || 0);
|
||||
const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20;
|
||||
const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4;
|
||||
const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6;
|
||||
const totalScore = structureScore + durationScore + audioScore + chapterScore;
|
||||
const overlapSummary = Array.isArray(metrics?.overlapEntries)
|
||||
? metrics.overlapEntries
|
||||
.map((entry) => `${entry.otherPlaylistId}:${Number(entry.ratioToSmaller || 0).toFixed(3)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
const commonRunsSummary = Array.isArray(metrics?.commonRuns)
|
||||
? metrics.commonRuns
|
||||
.map((run) => `${String(run.startSegment).padStart(5, '0')}-${String(run.endSegment).padStart(5, '0')}`)
|
||||
.join(' | ')
|
||||
: '';
|
||||
const variantSummary = Array.isArray(metrics?.variantSpans)
|
||||
? metrics.variantSpans
|
||||
.map((span) => formatVariantSpanLabel(span))
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
: '';
|
||||
const reasons = [
|
||||
`structure_score=${structureScore.toFixed(2)}`,
|
||||
`duration_score=${durationScore.toFixed(2)}`,
|
||||
`audio_score=${audioScore.toFixed(2)}`,
|
||||
`chapter_score=${chapterScore.toFixed(2)}`,
|
||||
`legacy_structure_score=${Number(metrics.legacyStructureScore || 0).toFixed(2)}`,
|
||||
`legacy_sequence_coherence=${Number(metrics.legacySequenceCoherence || 0).toFixed(3)}`,
|
||||
`corrected_coherence=${Number(metrics.correctedCoherence || 0).toFixed(3)}`,
|
||||
`cluster=${metrics.clusterId || 'standalone'}${metrics.clusterSize > 1 ? `(${metrics.clusterSize})` : ''}`,
|
||||
`avg_segment_overlap=${Number(metrics.averageSegmentOverlap || 0).toFixed(3)}`,
|
||||
`core_coverage=${Number(metrics.sharedCoreRatio || 0).toFixed(3)}`,
|
||||
`variant_spans=${Number(metrics.variantSpanCount || 0)}`,
|
||||
`variant_segments=${Number(metrics.variantSegmentCount || 0)}`,
|
||||
`single_segment_spans=${Number(metrics.singleSegmentVariantSpans || 0)}`,
|
||||
`multi_segment_spans=${Number(metrics.multiSegmentVariantSpans || 0)}`,
|
||||
`common_runs=${commonRunsSummary || '-'}`,
|
||||
`variant_boundaries=${variantSummary || '-'}`,
|
||||
`segment_overlap_map=${overlapSummary || '-'}`,
|
||||
`position_reason=${metrics.positionReason || '-'}`,
|
||||
`sequence_steps=${metrics.directSequenceSteps}`,
|
||||
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
|
||||
`backward_jumps=${metrics.backwardJumps}`,
|
||||
@@ -582,22 +1323,34 @@ function scoreCandidates(groupTitles) {
|
||||
|
||||
return {
|
||||
...title,
|
||||
score: Number(metrics.score || 0),
|
||||
score: Number(totalScore.toFixed(4)),
|
||||
reasons,
|
||||
structuralMetrics: metrics,
|
||||
evaluationLabel: buildEvaluationLabel(metrics)
|
||||
evaluationLabel: buildEvaluationLabel(metrics),
|
||||
correctedCoherence: Number(metrics.correctedCoherence || 0),
|
||||
legacySequenceCoherence: Number(metrics.legacySequenceCoherence || 0),
|
||||
legacyStructureScore: Number(metrics.legacyStructureScore || 0),
|
||||
clusterId: metrics.clusterId || null,
|
||||
clusterSize: Number(metrics.clusterSize || 1),
|
||||
averageSegmentOverlap: Number(metrics.averageSegmentOverlap || 0),
|
||||
commonRuns: Array.isArray(metrics.commonRuns) ? metrics.commonRuns : [],
|
||||
variantSpans: Array.isArray(metrics.variantSpans) ? metrics.variantSpans : [],
|
||||
overlapEntries: Array.isArray(metrics.overlapEntries) ? metrics.overlapEntries : [],
|
||||
positionReason: metrics.positionReason || null
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.score - a.score
|
||||
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|
||||
|| b.legacyStructureScore - a.legacyStructureScore
|
||||
|| b.durationSeconds - a.durationSeconds
|
||||
|| b.sizeBytes - a.sizeBytes
|
||||
|| a.titleId - b.titleId
|
||||
)
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
recommended: index === 0
|
||||
recommended: index === 0,
|
||||
clusterSummary: clusterSummaryRows.find((row) => row.id === item.clusterId) || null
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -662,6 +1415,18 @@ function extractPlaylistMismatchWarnings(titles) {
|
||||
|
||||
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
|
||||
const parsedTitles = parseAnalyzeTitles(lines);
|
||||
const playlistAliasMap = buildPlaylistAliasMap(lines);
|
||||
const parsedTitlesWithAliases = parsedTitles.map((item) => {
|
||||
const playlistId = normalizePlaylistId(item?.playlistId);
|
||||
const aliasesRaw = playlistId && Array.isArray(playlistAliasMap?.[playlistId])
|
||||
? playlistAliasMap[playlistId]
|
||||
: [];
|
||||
const playlistAliases = uniqueOrdered(aliasesRaw.map((value) => normalizePlaylistId(value)).filter(Boolean));
|
||||
return {
|
||||
...item,
|
||||
playlistAliases
|
||||
};
|
||||
});
|
||||
const reportedTitleCount = parseReportedTitleCount(lines);
|
||||
const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60));
|
||||
const durationSimilaritySeconds = Math.max(
|
||||
@@ -669,7 +1434,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
|
||||
);
|
||||
|
||||
const candidatesRaw = parsedTitles
|
||||
const candidatesRaw = parsedTitlesWithAliases
|
||||
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
|
||||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||
const candidates = suppressRawMirrorCandidates(candidatesRaw)
|
||||
@@ -686,11 +1451,24 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
|
||||
const manualDecisionRequired = multipleCandidatesDetected;
|
||||
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
|
||||
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
|
||||
const evaluatedCandidates = decisionPool.length > 0
|
||||
? scoreCandidates(decisionPool, { durationSimilaritySeconds })
|
||||
: [];
|
||||
const recommendation = evaluatedCandidates[0] || null;
|
||||
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
|
||||
const rankedCandidatePlaylists = uniqueOrdered(
|
||||
evaluatedCandidates.map((item) => normalizePlaylistId(item?.playlistId)).filter(Boolean)
|
||||
);
|
||||
const candidatePlaylists = manualDecisionRequired
|
||||
? uniqueOrdered([...rankedCandidatePlaylists, ...candidatePlaylistsAll])
|
||||
: [];
|
||||
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
|
||||
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
|
||||
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases);
|
||||
const branchingClusters = uniqueOrdered(
|
||||
evaluatedCandidates
|
||||
.map((item) => item?.clusterSummary)
|
||||
.filter((value) => value && typeof value === 'object')
|
||||
.map((value) => JSON.stringify(value))
|
||||
).map((value) => JSON.parse(value));
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -698,7 +1476,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
minLengthMinutes: Number(minLengthMinutes || 0),
|
||||
minLengthSeconds: minSeconds,
|
||||
durationSimilaritySeconds,
|
||||
titles: parsedTitles,
|
||||
titles: parsedTitlesWithAliases,
|
||||
candidates,
|
||||
duplicateDurationGroups: similarityGroups,
|
||||
obfuscationDetected,
|
||||
@@ -709,6 +1487,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
candidatePlaylists,
|
||||
candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`),
|
||||
playlistToTitleId,
|
||||
playlistAliasMap,
|
||||
recommendation: recommendation
|
||||
? {
|
||||
titleId: recommendation.titleId,
|
||||
@@ -721,6 +1500,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
: null,
|
||||
evaluatedCandidates,
|
||||
playlistSegments,
|
||||
branchingClusters,
|
||||
structuralAnalysis: {
|
||||
method: 'makemkv_tinfo_26',
|
||||
sourceCommand: 'makemkvcon -r info disc:0 --robot',
|
||||
@@ -731,7 +1511,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
||||
...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length
|
||||
? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`]
|
||||
: []),
|
||||
...extractPlaylistMismatchWarnings(parsedTitles)
|
||||
...extractPlaylistMismatchWarnings(parsedTitlesWithAliases)
|
||||
].slice(0, 60)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,8 +93,34 @@ function parseCdParanoiaProgress(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMkvmergeProgress(line) {
|
||||
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Common mkvmerge output examples:
|
||||
// "Progress: 42%"
|
||||
// "#GUI#progress 42%"
|
||||
const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i);
|
||||
if (explicitMatch) {
|
||||
return {
|
||||
percent: clampPercent(Number(explicitMatch[1])),
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
|
||||
const percent = parseGenericPercent(normalized);
|
||||
if (percent !== null) {
|
||||
return { percent, eta: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMakeMkvProgress,
|
||||
parseHandBrakeProgress,
|
||||
parseCdParanoiaProgress
|
||||
parseCdParanoiaProgress,
|
||||
parseMkvmergeProgress
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
function padNumber(value, length = 2) {
|
||||
return String(value).padStart(length, '0');
|
||||
}
|
||||
|
||||
function formatTimezoneOffset(offsetMinutes) {
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const absoluteMinutes = Math.abs(offsetMinutes);
|
||||
const hours = Math.floor(absoluteMinutes / 60);
|
||||
const minutes = absoluteMinutes % 60;
|
||||
return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
|
||||
}
|
||||
|
||||
function getServerTimestamp(input = new Date()) {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
|
||||
return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`
|
||||
+ `T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`
|
||||
+ `.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(-date.getTimezoneOffset())}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getServerTimestamp
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis');
|
||||
|
||||
function buildTitleLines({
|
||||
titleId,
|
||||
playlistId,
|
||||
duration = '01:34:41',
|
||||
chapters = 20,
|
||||
audioTrackCount = 3,
|
||||
subtitleTrackCount = 2,
|
||||
segmentNumbers = []
|
||||
}) {
|
||||
const lines = [
|
||||
`MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`,
|
||||
`TINFO:${titleId},16,0,"${playlistId}.mpls"`,
|
||||
`TINFO:${titleId},9,0,"${duration}"`,
|
||||
`TINFO:${titleId},8,0,"${chapters}"`,
|
||||
`TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"`
|
||||
];
|
||||
|
||||
for (let index = 0; index < audioTrackCount; index += 1) {
|
||||
lines.push(`SINFO:${titleId},${index},1,0,"Audio"`);
|
||||
lines.push(`SINFO:${titleId},${index},3,0,"eng"`);
|
||||
lines.push(`SINFO:${titleId},${index},4,0,"English"`);
|
||||
lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`);
|
||||
lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`);
|
||||
}
|
||||
|
||||
for (let index = 0; index < subtitleTrackCount; index += 1) {
|
||||
const streamIndex = audioTrackCount + index;
|
||||
lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
test('branching cluster prefers the structurally simplest reference playlist', () => {
|
||||
const lines = [
|
||||
...buildTitleLines({
|
||||
titleId: 0,
|
||||
playlistId: '00802',
|
||||
segmentNumbers: [
|
||||
866, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||
833, 852, 834, 853, 835, 854, 869, 870, 872, 855,
|
||||
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||
]
|
||||
}),
|
||||
...buildTitleLines({
|
||||
titleId: 1,
|
||||
playlistId: '00800',
|
||||
segmentNumbers: [
|
||||
847, 829, 848, 830, 849, 831, 850, 832, 851, 833,
|
||||
852, 834, 853, 835, 854, 836, 855, 837, 856, 838,
|
||||
857, 839, 858, 840, 859, 841, 860, 842, 861, 843,
|
||||
862, 844, 863, 845, 864, 846
|
||||
]
|
||||
}),
|
||||
...buildTitleLines({
|
||||
titleId: 3,
|
||||
playlistId: '00804',
|
||||
segmentNumbers: [
|
||||
867, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||
833, 852, 834, 853, 835, 854, 869, 871, 872, 855,
|
||||
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
const analysis = analyzePlaylistObfuscation(lines, 60, {
|
||||
durationSimilaritySeconds: 90
|
||||
});
|
||||
|
||||
assert.equal(analysis?.recommendation?.playlistId, '00800');
|
||||
assert.deepEqual(
|
||||
(analysis?.evaluatedCandidates || []).map((item) => item.playlistId),
|
||||
['00800', '00804', '00802']
|
||||
);
|
||||
assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']);
|
||||
|
||||
const preferred = (analysis?.evaluatedCandidates || [])[0];
|
||||
assert.equal(preferred?.recommended, true);
|
||||
assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster');
|
||||
|
||||
const variantSummary = (preferred?.variantSpans || []).map((span) => ({
|
||||
before: span.beforeSegment,
|
||||
after: span.afterSegment,
|
||||
segments: span.segments
|
||||
}));
|
||||
assert.deepEqual(variantSummary, [
|
||||
{ before: null, after: 829, segments: [847] },
|
||||
{ before: 854, after: 855, segments: [836] }
|
||||
]);
|
||||
});
|
||||
+369
-4
@@ -11,6 +11,7 @@ CREATE TABLE settings_schema (
|
||||
options_json TEXT,
|
||||
validation_json TEXT,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
depends_on TEXT DEFAULT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -31,6 +32,7 @@ CREATE TABLE jobs (
|
||||
poster_url TEXT,
|
||||
omdb_json TEXT,
|
||||
selected_from_omdb INTEGER DEFAULT 0,
|
||||
migrate_tmdb INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
status TEXT NOT NULL,
|
||||
@@ -48,6 +50,11 @@ CREATE TABLE jobs (
|
||||
encode_input_path TEXT,
|
||||
encode_review_confirmed INTEGER DEFAULT 0,
|
||||
aax_checksum TEXT,
|
||||
media_type TEXT,
|
||||
job_kind TEXT,
|
||||
is_multipart_movie INTEGER NOT NULL DEFAULT 0,
|
||||
disc_number INTEGER,
|
||||
metadata_fingerprint TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
@@ -56,6 +63,9 @@ CREATE TABLE jobs (
|
||||
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||
CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id);
|
||||
CREATE INDEX idx_jobs_job_kind ON jobs(job_kind);
|
||||
CREATE INDEX idx_jobs_disc_number ON jobs(disc_number);
|
||||
CREATE INDEX idx_jobs_metadata_fingerprint ON jobs(metadata_fingerprint);
|
||||
|
||||
CREATE TABLE job_lineage_artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -73,10 +83,23 @@ CREATE TABLE job_lineage_artifacts (
|
||||
CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id);
|
||||
CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id);
|
||||
|
||||
CREATE TABLE job_output_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_job_output_folders_job_id ON job_output_folders(job_id);
|
||||
CREATE UNIQUE INDEX idx_job_output_folders_unique ON job_output_folders(job_id, output_path);
|
||||
|
||||
CREATE TABLE scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
script_body TEXT NOT NULL,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -88,6 +111,8 @@ CREATE INDEX idx_scripts_order_index ON scripts(order_index, id);
|
||||
CREATE TABLE script_chains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -110,6 +135,25 @@ CREATE TABLE script_chain_steps (
|
||||
|
||||
CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position);
|
||||
|
||||
CREATE TABLE hardware_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at TEXT NOT NULL,
|
||||
day_key TEXT NOT NULL,
|
||||
cpu_usage_percent REAL,
|
||||
cpu_temperature_c REAL,
|
||||
ram_usage_percent REAL,
|
||||
ram_used_bytes INTEGER,
|
||||
ram_total_bytes INTEGER,
|
||||
gpu_usage_percent REAL,
|
||||
gpu_temperature_c REAL,
|
||||
vram_used_bytes INTEGER,
|
||||
vram_total_bytes INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_hardware_metrics_history_captured_at ON hardware_metrics_history(captured_at);
|
||||
CREATE INDEX idx_hardware_metrics_history_day_key ON hardware_metrics_history(day_key);
|
||||
|
||||
CREATE TABLE pipeline_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state TEXT NOT NULL,
|
||||
@@ -165,18 +209,91 @@ CREATE TABLE user_presets (
|
||||
|
||||
CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
||||
|
||||
CREATE TABLE user_preset_defaults (
|
||||
slot_key TEXT PRIMARY KEY,
|
||||
preset_id INTEGER,
|
||||
FOREIGN KEY (preset_id) REFERENCES user_presets(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_preset_defaults_preset_id ON user_preset_defaults(preset_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aax_activation_bytes (
|
||||
checksum TEXT PRIMARY KEY,
|
||||
activation_bytes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_series', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_series', NULL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE converter_scan_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rel_path TEXT NOT NULL UNIQUE,
|
||||
entry_type TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
detected_media_type TEXT,
|
||||
detected_format TEXT,
|
||||
job_id INTEGER,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path);
|
||||
CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id);
|
||||
|
||||
CREATE TABLE dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature);
|
||||
CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number);
|
||||
|
||||
CREATE TABLE dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index);
|
||||
CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number);
|
||||
|
||||
-- =============================================================================
|
||||
-- Default Settings Seed
|
||||
-- =============================================================================
|
||||
@@ -186,18 +303,34 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL);
|
||||
|
||||
-- Laufwerk
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
||||
@@ -224,22 +357,42 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]');
|
||||
|
||||
-- Monitoring
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130);
|
||||
@@ -249,13 +402,38 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000');
|
||||
|
||||
-- Logging
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true');
|
||||
|
||||
-- Tools
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_command', 'makemkvcon');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_registration_key', 'Tools', 'MakeMKV Key', 'string', 0, 'Optionaler Registrierungsschlüssel. Wird vor Analyze/Rip automatisch per "makemkvcon reg" gesetzt.', NULL, '[]', '{}', 202);
|
||||
VALUES ('makemkv_registration_key', 'Tools', 'MakeMKV Key', 'string', 0, 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.', NULL, '[]', '{}', 202);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_registration_key', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -266,6 +444,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI');
|
||||
@@ -324,14 +506,62 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_bluray_movie', 'Tools', 'Review Audio-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 326);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_bluray_movie', 'Tools', 'Review UT-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 327);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_bluray_series', 'Tools', 'Review Audio-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 328);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_bluray_series', 'Tools', 'Review UT-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 329);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_bluray')
|
||||
WHERE key IN ('handbrake_review_audio_languages_bluray_movie', 'handbrake_review_audio_languages_bluray_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_audio_languages_bluray'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray')
|
||||
WHERE key IN ('handbrake_review_subtitle_languages_bluray_movie', 'handbrake_review_subtitle_languages_bluray_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
|
||||
|
||||
-- Tools – DVD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500);
|
||||
@@ -359,14 +589,62 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_dvd_movie', 'Tools', 'Review Audio-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 526);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_dvd_movie', 'Tools', 'Review UT-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 527);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_dvd_series', 'Tools', 'Review Audio-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 528);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_dvd_series', 'Tools', 'Review UT-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 529);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_dvd')
|
||||
WHERE key IN ('handbrake_review_audio_languages_dvd_movie', 'handbrake_review_audio_languages_dvd_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_audio_languages_dvd'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd')
|
||||
WHERE key IN ('handbrake_review_subtitle_languages_dvd_movie', 'handbrake_review_subtitle_languages_dvd_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
|
||||
|
||||
-- Tools – CD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
|
||||
@@ -444,13 +722,38 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group');
|
||||
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
||||
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false');
|
||||
|
||||
-- Benachrichtigungen
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -486,7 +789,7 @@ VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'n
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', 'true', '[]', '{}', 570);
|
||||
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -516,3 +819,65 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reen
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_reencode_finished', 'Benachrichtigungen', 'Bei Re-Encode Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreichem RAW Re-Encode.', 'true', '[]', '{}', 640);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_finished', 'true');
|
||||
|
||||
-- =============================================================================
|
||||
-- Converter Settings
|
||||
-- =============================================================================
|
||||
|
||||
-- Converter – Pfade und Templates (Kategorie: Pfade)
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_raw_dir', 'Pfade', 'Converter Raw-Ordner', 'path', 0, 'Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw).', NULL, '[]', '{}', 800);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_movie_dir', 'Pfade', 'Converter Ausgabe (Video)', 'path', 0, 'Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies).', NULL, '[]', '{}', 801);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_audio_dir', 'Pfade', 'Converter Ausgabe (Audio)', 'path', 0, 'Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio).', NULL, '[]', '{}', 802);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_video', 'Pfade', 'Output-Template (Video)', 'string', 1, 'Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}.', '{title}', '[]', '{"minLength":1}', 810);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', '{title}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_audio', 'Pfade', 'Output-Template (Audio)', 'string', 1, 'Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}.', '{artist} - {title}', '[]', '{"minLength":1}', 811);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', '{artist} - {title}');
|
||||
|
||||
-- Migration: Converter-Pfad-Settings in Kategorie 'Pfade' verschieben (falls bereits in DB vorhanden)
|
||||
UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio') AND category = 'Converter';
|
||||
|
||||
-- Converter – Scan
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus');
|
||||
UPDATE settings_schema
|
||||
SET
|
||||
label = 'Erlaubte Datei-Endungen*',
|
||||
description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.',
|
||||
default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
|
||||
WHERE key = 'converter_scan_extensions';
|
||||
UPDATE settings_values
|
||||
SET value = TRIM(
|
||||
REPLACE(
|
||||
REPLACE(
|
||||
REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','),
|
||||
',,', ','
|
||||
),
|
||||
',,', ','
|
||||
),
|
||||
','
|
||||
)
|
||||
WHERE key = 'converter_scan_extensions';
|
||||
UPDATE settings_values
|
||||
SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
|
||||
WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = '');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_enabled', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_polling_interval', 'Converter', 'Polling-Intervall (Sekunden)', 'number', 1, 'Intervall für automatischen Scan des Converter-Ordners.', '300', '[]', '{"min":30,"max":86400}', 840);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_interval', '300');
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Converter API
|
||||
|
||||
Endpunkte für den Datei-Converter: Datei-Explorer, Upload, Job-Verwaltung.
|
||||
|
||||
Basis-Pfad: `/api/converter`
|
||||
|
||||
---
|
||||
|
||||
## Scan & Explorer
|
||||
|
||||
### `GET /api/converter/tree`
|
||||
|
||||
Vollständiger Verzeichnisbaum des `converter_raw_dir` (FS-basiert, keine DB).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tree": [
|
||||
{
|
||||
"name": "filme",
|
||||
"relPath": "filme",
|
||||
"isDir": true,
|
||||
"children": [
|
||||
{
|
||||
"name": "film.mkv",
|
||||
"relPath": "filme/film.mkv",
|
||||
"isDir": false,
|
||||
"size": 10485760
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/converter/browse?parent=relPath`
|
||||
|
||||
DB-basierter Datei-Explorer. Gibt Einträge für einen Unterordner zurück.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `parent` | string | Relativer Pfad zum Unterordner (leer = Root) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": 1,
|
||||
"rel_path": "filme/film.mkv",
|
||||
"is_dir": false,
|
||||
"size": 10485760,
|
||||
"job_id": null
|
||||
}
|
||||
],
|
||||
"rawDir": "/data/output/converter-raw"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/scan`
|
||||
|
||||
Manuellen Scan des `converter_raw_dir` auslösen. Aktualisiert `converter_scan_entries` in der DB.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "result": { "added": 3, "removed": 1 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jobs erstellen
|
||||
|
||||
### `POST /api/converter/jobs/from-selection`
|
||||
|
||||
Jobs aus im Datei-Explorer ausgewählten Dateien erstellen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"relPaths": ["filme/film.mkv", "musik/track.flac"],
|
||||
"audioMode": "individual"
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `relPaths` | string[] | Relative Pfade der ausgewählten Dateien |
|
||||
| `audioMode` | string | `individual` (ein Job pro Datei) oder `shared` (ein gemeinsamer Job) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "jobs": [{ "id": 42, "status": "READY_TO_START" }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/create-jobs`
|
||||
|
||||
Jobs aus DB-Scan-Einträgen erstellen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{ "relPath": "filme/film.mkv", "converterMediaType": "video" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/upload`
|
||||
|
||||
Dateien hochladen (Multipart, max. 50 Dateien).
|
||||
|
||||
**Form-Felder:**
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `files` | File[] | Hochzuladende Dateien |
|
||||
| `folderName` | string | (Optional) Ziel-Unterordner |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"folders": [{ "folderRelPath": "upload-2026-03-30", "fileCount": 2 }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Job-Verwaltung
|
||||
|
||||
### `GET /api/converter/jobs`
|
||||
|
||||
Alle Converter-Jobs zurückgeben.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "jobs": [{ "id": 42, "status": "READY_TO_START", "title": "film.mkv" }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/converter/jobs/:jobId`
|
||||
|
||||
Einzelnen Converter-Job abrufen.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/config`
|
||||
|
||||
Konfigurationsentwurf für einen Job speichern (persistiert in `encode_plan_json`).
|
||||
|
||||
**Body:** Partielles Konfig-Objekt (Ausgabeformat, Presets, Metadaten, Track-Auswahl).
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/assign-files`
|
||||
|
||||
Dateien einem bestehenden (noch nicht gestarteten) Job hinzufügen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPaths": ["musik/track2.flac"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/remove-file`
|
||||
|
||||
Datei aus einem Job entfernen (per relativer Pfad).
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "musik/track2.flac" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/remove-input`
|
||||
|
||||
Datei aus einem Job entfernen (per absolutem Eingabepfad).
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "inputPath": "/data/output/converter-raw/musik/track2.flac" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/start`
|
||||
|
||||
Job mit finaler Konfiguration starten.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"converterMediaType": "video",
|
||||
"outputFormat": "mkv",
|
||||
"userPreset": "H.264 MKV 1080p30",
|
||||
"trackSelection": {},
|
||||
"handBrakeTitleId": 1,
|
||||
"audioFormatOptions": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/cancel`
|
||||
|
||||
Laufenden Job abbrechen.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/converter/jobs/:jobId`
|
||||
|
||||
Job aus der DB löschen.
|
||||
|
||||
---
|
||||
|
||||
## Datei-Operationen
|
||||
|
||||
Alle Datei-Operationen arbeiten direkt auf dem Dateisystem (ohne DB-Aktualisierung). Ein anschließender Scan-Aufruf synchronisiert die DB.
|
||||
|
||||
### `DELETE /api/converter/files`
|
||||
|
||||
Datei oder Ordner löschen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/rename`
|
||||
|
||||
Datei oder Ordner umbenennen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv", "newName": "film-neu.mkv" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/move`
|
||||
|
||||
Datei oder Ordner verschieben.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv", "targetParentRelPath": "archiv" }
|
||||
```
|
||||
|
||||
`targetParentRelPath = ""` verschiebt in das Root-Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/folder`
|
||||
|
||||
Neuen Ordner anlegen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "parentRelPath": "filme", "name": "neu" }
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
# Downloads API
|
||||
|
||||
Endpunkte für die Download-Queue. Ausgabedateien aus der Job-Historie können als ZIP heruntergeladen werden.
|
||||
|
||||
Basis-Pfad: `/api/downloads`
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads`
|
||||
|
||||
Liste aller Download-Einträge plus Zusammenfassung.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "dl-42-raw",
|
||||
"jobId": 42,
|
||||
"target": "raw",
|
||||
"status": "ready",
|
||||
"archiveName": "Job_42_raw.zip",
|
||||
"outputPath": null,
|
||||
"createdAt": "2026-03-30T10:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T10:01:00.000Z",
|
||||
"errorMessage": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status-Werte:**
|
||||
|
||||
| Status | Beschreibung |
|
||||
|---|---|
|
||||
| `pending` | In der Queue, noch nicht gestartet |
|
||||
| `processing` | ZIP wird gerade erstellt |
|
||||
| `ready` | ZIP fertig, Download verfügbar |
|
||||
| `failed` | Fehler bei der Erstellung |
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads/summary`
|
||||
|
||||
Nur die Zusammenfassung der Download-Queue (ohne Item-Liste).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/downloads/history/:jobId`
|
||||
|
||||
Job-Ausgabe in die Download-Queue einreihen.
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `jobId` | number | ID des Jobs in der Historie |
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "raw",
|
||||
"outputPath": null
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Typ | Default | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `target` | string | `raw` | `raw` (RAW-Dateien) oder `movie` (Ausgabedateien) |
|
||||
| `outputPath` | string | `null` | Optionaler expliziter Ausgabepfad |
|
||||
|
||||
**Response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"id": "dl-42-raw",
|
||||
"status": "pending",
|
||||
"summary": { "total": 1, "pending": 1, "processing": 0, "ready": 0, "failed": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
Ist der Eintrag bereits vorhanden, wird `201` durch `200` ersetzt und `created: false` zurückgegeben.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads/:id/file`
|
||||
|
||||
Fertige ZIP-Datei herunterladen.
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `id` | string | Download-ID (z. B. `dl-42-raw`) |
|
||||
|
||||
**Response:** Datei-Download (`Content-Disposition: attachment`).
|
||||
|
||||
Schlägt fehl mit `404`, wenn kein Download-Eintrag mit dieser ID existiert oder die Datei noch nicht bereit ist.
|
||||
|
||||
---
|
||||
|
||||
## `DELETE /api/downloads/:id`
|
||||
|
||||
Download-Eintrag löschen (auch fertige ZIP-Dateien werden vom Dateisystem entfernt).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"summary": { "total": 2, "pending": 0, "processing": 0, "ready": 2, "failed": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
Statusänderungen werden über `DOWNLOADS_UPDATED` in Echtzeit gemeldet. Vollständige Dokumentation: [WebSocket Events](websocket.md#downloads_updated).
|
||||
+76
-68
@@ -1,27 +1,31 @@
|
||||
# History API
|
||||
|
||||
Endpunkte für Job-Historie, Orphan-Import und Löschoperationen.
|
||||
Endpunkte für Job-Historie, Metadaten-Nachpflege, Orphan-Import und Löschoperationen.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history
|
||||
|
||||
Liefert Jobs (optionale Filter).
|
||||
Liefert Jobs mit optionalen Filtern.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|----------|-----|-------------|
|
||||
| `status` | string | Filter nach Job-Status |
|
||||
| `search` | string | Suche in Titel-Feldern |
|
||||
| `status` | string | Einzelstatus-Filter (legacy-kompatibel). |
|
||||
| `statuses` | string | Mehrfachstatus als CSV, z. B. `FINISHED,ERROR`. |
|
||||
| `search` | string | Suche in Titel-/IMDb-Feldern. |
|
||||
| `limit` | number | Maximale Anzahl Ergebnisse. |
|
||||
| `lite` | bool | Ohne Dateisystem-Checks (schneller). |
|
||||
| `includeChildren` | bool | Child-Jobs (z. B. Serien-/Multipart-Kinder) explizit einbeziehen. |
|
||||
|
||||
**Beispiel:**
|
||||
|
||||
```text
|
||||
GET /api/history?status=FINISHED&search=Inception
|
||||
GET /api/history?statuses=FINISHED,ERROR&search=Inception&limit=50&lite=1
|
||||
```
|
||||
|
||||
**Response:**
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -30,6 +34,7 @@ GET /api/history?status=FINISHED&search=Inception
|
||||
"id": 42,
|
||||
"status": "FINISHED",
|
||||
"title": "Inception",
|
||||
"job_kind": "bluray",
|
||||
"raw_path": "/mnt/raw/Inception - RAW - job-42",
|
||||
"output_path": "/mnt/movies/Inception (2010)/Inception (2010).mkv",
|
||||
"mediaType": "bluray",
|
||||
@@ -52,12 +57,13 @@ Liefert Job-Detail.
|
||||
|
||||
| Parameter | Typ | Standard | Beschreibung |
|
||||
|----------|-----|---------|-------------|
|
||||
| `includeLogs` | bool | `false` | Prozesslog laden |
|
||||
| `includeLiveLog` | bool | `false` | alias-artig ebenfalls Prozesslog laden |
|
||||
| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail |
|
||||
| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs` |
|
||||
| `includeLogs` | bool | `false` | Prozesslog laden. |
|
||||
| `includeLiveLog` | bool | `false` | Live-/Tail-Log laden. |
|
||||
| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail. |
|
||||
| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs`. |
|
||||
| `lite` | bool | `false` | Detailantwort ohne FS-Checks. |
|
||||
|
||||
**Response:**
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -86,33 +92,9 @@ Liefert Job-Detail.
|
||||
|
||||
Sucht RAW-Ordner ohne zugehörigen Job.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"rawDir": "/mnt/raw",
|
||||
"rawDirs": ["/mnt/raw", "/mnt/raw-bluray"],
|
||||
"rows": [
|
||||
{
|
||||
"rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99",
|
||||
"folderName": "Inception (2010) [tt1375666] - RAW - job-99",
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"imdbId": "tt1375666",
|
||||
"folderJobId": 99,
|
||||
"entryCount": 4,
|
||||
"hasBlurayStructure": true,
|
||||
"lastModifiedAt": "2026-03-10T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/orphan-raw/import
|
||||
|
||||
Importiert RAW-Ordner als FINISHED-Job.
|
||||
Importiert einen RAW-Ordner als Job und triggert optional die Analyse des Imports.
|
||||
|
||||
**Request:**
|
||||
|
||||
@@ -120,39 +102,57 @@ Importiert RAW-Ordner als FINISHED-Job.
|
||||
{ "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"job": { "id": 77, "status": "FINISHED" },
|
||||
"uiReset": { "reset": true, "state": "IDLE" }
|
||||
"activation": { "started": true },
|
||||
"activationError": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/:id/omdb/assign
|
||||
## POST /api/history/:id/metadata/assign
|
||||
|
||||
Weist OMDb-/Metadaten nachträglich zu.
|
||||
Weist TMDb-Metadaten nachträglich zu oder aktualisiert bestehende Film-/Serien-Metadaten eines Jobs.
|
||||
|
||||
**Request:**
|
||||
## POST /api/history/:id/cd/assign
|
||||
|
||||
Weist CD-Metadaten (z. B. MusicBrainz) nachträglich zu.
|
||||
|
||||
## POST /api/history/:id/error/ack
|
||||
|
||||
Quittiert einen Fehlerzustand im Historieneintrag.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history/:id/delete-preview
|
||||
|
||||
Liefert eine Vorschau der löschbaren Pfade (inkl. Scope auf verknüpfte Jobs).
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Standard | Beschreibung |
|
||||
|----------|-----|---------|-------------|
|
||||
| `includeRelated` | bool | `true` | Verknüpfte Jobs in die Vorschau einbeziehen. |
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"imdbId": "tt1375666",
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://...",
|
||||
"fromOmdb": true
|
||||
"preview": {
|
||||
"jobId": 42,
|
||||
"relatedJobs": [{ "id": 42 }, { "id": 73 }],
|
||||
"pathCandidates": {
|
||||
"raw": [{ "path": "/mnt/raw/...", "exists": true, "isDirectory": true }],
|
||||
"movie": [{ "path": "/mnt/movies/...", "exists": true, "isDirectory": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "job": { "id": 42, "imdb_id": "tt1375666" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/:id/delete-files
|
||||
@@ -162,23 +162,17 @@ Löscht Dateien eines Jobs, behält DB-Eintrag.
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "target": "both" }
|
||||
{
|
||||
"target": "both",
|
||||
"includeRelated": true,
|
||||
"selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"],
|
||||
"selectedMoviePaths": ["/mnt/movies/Inception (2010)"]
|
||||
}
|
||||
```
|
||||
|
||||
`target`: `raw` | `movie` | `both`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"target": "both",
|
||||
"raw": { "attempted": true, "deleted": true, "filesDeleted": 12, "dirsRemoved": 3, "reason": null },
|
||||
"movie": { "attempted": true, "deleted": false, "filesDeleted": 0, "dirsRemoved": 0, "reason": "Movie-Datei/Pfad existiert nicht." }
|
||||
},
|
||||
"job": { "id": 42 }
|
||||
}
|
||||
```
|
||||
`selectedRawPaths` / `selectedMoviePaths` sind optional und begrenzen die Löschung auf ausgewählte Pfade aus der Vorschau.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,12 +183,20 @@ Löscht Job aus DB; optional auch Dateien.
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "target": "none" }
|
||||
{
|
||||
"target": "both",
|
||||
"includeRelated": true,
|
||||
"resetDriveState": false,
|
||||
"keepDetectedDevice": true,
|
||||
"preserveRawForImportJobs": false,
|
||||
"selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"],
|
||||
"selectedMoviePaths": ["/mnt/movies/Inception (2010)"]
|
||||
}
|
||||
```
|
||||
|
||||
`target`: `none` | `raw` | `movie` | `both`
|
||||
|
||||
**Response:**
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -209,6 +211,11 @@ Löscht Job aus DB; optional auch Dateien.
|
||||
"uiReset": {
|
||||
"reset": true,
|
||||
"state": "IDLE"
|
||||
},
|
||||
"safeguards": {
|
||||
"containsOrphanRawImportJob": false,
|
||||
"resetDriveStateApplied": false,
|
||||
"keepDetectedDeviceApplied": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -218,4 +225,5 @@ Löscht Job aus DB; optional auch Dateien.
|
||||
## Hinweise
|
||||
|
||||
- Ein aktiver Pipeline-Job kann nicht gelöscht werden (`409`).
|
||||
- Alle Löschoperationen sind irreversibel.
|
||||
- Löschoperationen sind irreversibel.
|
||||
- Bei Serien-/Multipart-Containern steuern `includeRelated` und Pfadselektionen den genauen Scope.
|
||||
|
||||
+26
-2
@@ -54,11 +54,35 @@ API-Prefix: `/api`
|
||||
|
||||
[:octicons-arrow-right-24: Cron API](crons.md)
|
||||
|
||||
- :material-lightning-bolt: **WebSocket Events**
|
||||
- :material-swap-horizontal: **Converter API**
|
||||
|
||||
---
|
||||
|
||||
Pipeline-, Queue-, Disk-, Settings-, Cron- und Monitoring-Events.
|
||||
Datei-Explorer, Upload, Job-Verwaltung für den Converter.
|
||||
|
||||
[:octicons-arrow-right-24: Converter API](converter.md)
|
||||
|
||||
- :material-download: **Downloads API**
|
||||
|
||||
---
|
||||
|
||||
Download-Queue für Ausgabedateien aus der Job-Historie.
|
||||
|
||||
[:octicons-arrow-right-24: Downloads API](downloads.md)
|
||||
|
||||
- :material-lightning-bolt: **Runtime Activities API**
|
||||
|
||||
---
|
||||
|
||||
Laufende und abgeschlossene Aktivitäten (Skripte, Ketten, Cron, Tasks).
|
||||
|
||||
[:octicons-arrow-right-24: Runtime Activities](runtime-activities.md)
|
||||
|
||||
- :material-websocket: **WebSocket Events**
|
||||
|
||||
---
|
||||
|
||||
Pipeline-, Queue-, Disk-, Settings-, Cron-, Converter- und Download-Events.
|
||||
|
||||
[:octicons-arrow-right-24: WebSocket](websocket.md)
|
||||
|
||||
|
||||
+168
-3
@@ -95,9 +95,21 @@ Erzwingt erneute Laufwerksprüfung.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/omdb/search?q=<query>
|
||||
## POST /api/pipeline/rescan-drive
|
||||
|
||||
OMDb-Titelsuche.
|
||||
Erzwingt Rescan für ein konkretes Laufwerk.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "devicePath": "/dev/sr0" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/tmdb/movie/search?q=<query>
|
||||
|
||||
TMDb-Filmsuche.
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -117,6 +129,78 @@ OMDb-Titelsuche.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/tmdb/series/search?q=<query>&season=<n>
|
||||
|
||||
TMDb-Seriensuche (für Serien-Workflow bei DVD/Blu-ray).
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/cd/drives
|
||||
|
||||
Liefert Snapshot der aktuell bekannten CD-Laufwerke.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/cd/musicbrainz/search?q=<query>
|
||||
|
||||
MusicBrainz-Suche für CD-Metadaten.
|
||||
|
||||
## GET /api/pipeline/cd/musicbrainz/release/:mbId
|
||||
|
||||
Lädt Release-Details zu einer MusicBrainz-ID.
|
||||
|
||||
## POST /api/pipeline/cd/select-metadata
|
||||
|
||||
Übernimmt CD-Metadaten für einen Job.
|
||||
|
||||
**Request (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": 91,
|
||||
"title": "Album",
|
||||
"artist": "Artist",
|
||||
"year": 2020,
|
||||
"mbId": "f5093c06-23e3-404f-aeaa-40f72885ee3a",
|
||||
"coverUrl": "https://...",
|
||||
"tracks": []
|
||||
}
|
||||
```
|
||||
|
||||
## POST /api/pipeline/cd/start/:jobId
|
||||
|
||||
Startet/queued CD-Rip mit Format-/Script-/Chain-Konfiguration.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/audiobook/upload
|
||||
|
||||
Upload von AAX-Datei (Multipart/FormData).
|
||||
|
||||
**FormData-Felder:**
|
||||
|
||||
- `file` (Pflicht)
|
||||
- `format` (optional)
|
||||
- `startImmediately` (optional)
|
||||
|
||||
## GET /api/pipeline/audiobook/pending-activation
|
||||
|
||||
Zeigt AAX-Jobs mit fehlenden Activation-Bytes.
|
||||
|
||||
## POST /api/pipeline/audiobook/start/:jobId
|
||||
|
||||
Startet Audiobook-Job mit optionaler Konfiguration.
|
||||
|
||||
## GET /api/pipeline/audiobook/jobs
|
||||
|
||||
Liefert Audiobook-Jobliste.
|
||||
|
||||
## GET /api/pipeline/audiobook/output-tree
|
||||
|
||||
Read-only Baumansicht des Audiobook-Ausgabeordners.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/select-metadata
|
||||
|
||||
Setzt Metadaten (und optional Playlist) für einen Job.
|
||||
@@ -135,12 +219,69 @@ Setzt Metadaten (und optional Playlist) für einen Job.
|
||||
}
|
||||
```
|
||||
|
||||
Wichtige optionale Felder:
|
||||
|
||||
- `selectedHandBrakeTitleId` / `selectedHandBrakeTitleIds`: Titel-Auswahl für Review/MediaInfo.
|
||||
- `metadataProvider`: `omdb` oder `tmdb`.
|
||||
- `workflowKind`: bei Disc-Jobs typischerweise `film` oder `series`.
|
||||
- `discNumber`: Pflicht für Serien-Disc-Zuordnung (`workflowKind=series`).
|
||||
- `duplicateAction`: Duplikatverhalten bei Film-Metadaten (`allow_new` oder `multipart_movie`).
|
||||
- `existingJobId`: optionaler Referenzjob für `multipart_movie`.
|
||||
- `existingDiscNumber`: Disc-Nummer des bestehenden Jobs für `multipart_movie`.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "job": { "id": 42, "status": "READY_TO_START" } }
|
||||
```
|
||||
|
||||
**Konflikt-Response (Beispiel, HTTP 409):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.",
|
||||
"details": [
|
||||
{
|
||||
"code": "METADATA_DUPLICATE_FOUND",
|
||||
"mediaProfile": "bluray",
|
||||
"existingJob": {
|
||||
"id": 17,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"status": "FINISHED",
|
||||
"jobKind": "bluray",
|
||||
"discNumber": 1,
|
||||
"isMultipartMovie": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Relevante Fehlercodes (`POST /api/pipeline/select-metadata`):**
|
||||
|
||||
| Code | Bedeutung |
|
||||
|---|---|
|
||||
| `METADATA_DUPLICATE_FOUND` | Film-Metadaten existieren bereits in der Historie (Konfliktdialog im UI). |
|
||||
| `MULTIPART_DISC_REQUIRED` | Für Multipart fehlen Disc-Nummern (`discNumber`/`existingDiscNumber`). |
|
||||
| `MULTIPART_DISC_ALREADY_EXISTS` | Disc-Nummer im Multipart-Container bereits vergeben oder doppelt. |
|
||||
| `MULTIPART_MEDIA_MISMATCH` | Multipart nur bei gleichem Medientyp (DVD oder Blu-ray). |
|
||||
| `MULTIPART_SERIES_NOT_ALLOWED` | Multipart ist nur für Film-Jobs erlaubt, nicht für Serien-Workflow. |
|
||||
| `MULTIPART_METADATA_MISMATCH` | Ausgewählter bestehender Job passt nicht zu den Film-Metadaten. |
|
||||
| `SERIES_DISC_ALREADY_EXISTS` | Serien-Disc-Nummer innerhalb einer Staffel bereits vorhanden. |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/jobs/:jobId/raw-decision
|
||||
|
||||
Trifft Entscheidung bei vorhandenem RAW (`continue` oder `restart` je nach Dialogfluss).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "decision": "continue" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/start/:jobId
|
||||
@@ -233,10 +374,28 @@ Berechnet Review aus RAW neu.
|
||||
|
||||
Startet Encoding mit letzter bestätigter Review neu.
|
||||
|
||||
## POST /api/pipeline/restart-cd-review/:jobId
|
||||
|
||||
Berechnet CD-Review aus vorhandenem RAW neu.
|
||||
|
||||
## POST /api/pipeline/resume-ready/:jobId
|
||||
|
||||
Lädt `READY_TO_ENCODE`-Job nach Neustart wieder in aktive Session.
|
||||
|
||||
## GET /api/pipeline/output-folders/:jobId
|
||||
|
||||
Liefert bekannte Output-Ordner entlang der Job-Lineage.
|
||||
|
||||
## POST /api/pipeline/delete-output-folders/:jobId
|
||||
|
||||
Löscht ausgewählte Output-Ordner.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "folderPaths": ["/mnt/movies/Inception (2010)"] }
|
||||
```
|
||||
|
||||
Alle Endpunkte liefern `{ result: ... }` bzw. `{ job: ... }`.
|
||||
|
||||
---
|
||||
@@ -358,12 +517,18 @@ Entfernt Queue-Eintrag.
|
||||
| `DISC_DETECTED` | Medium erkannt |
|
||||
| `ANALYZING` | MakeMKV-Analyse läuft |
|
||||
| `METADATA_SELECTION` | Metadaten-Auswahl |
|
||||
| `WAITING_FOR_USER_DECISION` | Playlist-Entscheidung erforderlich |
|
||||
| `WAITING_FOR_USER_DECISION` | Nutzerentscheidung erforderlich (Playlist-Auswahl oder RAW-Entscheidung) |
|
||||
| `READY_TO_START` | Übergang vor Start |
|
||||
| `RIPPING` | MakeMKV-Rip läuft |
|
||||
| `MEDIAINFO_CHECK` | Titel-/Track-Auswertung |
|
||||
| `READY_TO_ENCODE` | Review bereit |
|
||||
| `ENCODING` | HandBrake-Encoding läuft |
|
||||
| `CD_METADATA_SELECTION` | CD-Metadatenauswahl aktiv |
|
||||
| `CD_READY_TO_RIP` | CD-Job ist startbereit |
|
||||
| `CD_ANALYZING` | CD-Struktur/Tracks werden vorbereitet |
|
||||
| `CD_RIPPING` | CD-Ripping läuft |
|
||||
| `CD_ENCODING` | CD-Encode läuft |
|
||||
| `FINISHED` | Abgeschlossen |
|
||||
| `DONE` | Abgeschlossen (v. a. Audiobook/Converter/CD-Varianten) |
|
||||
| `CANCELLED` | Abgebrochen |
|
||||
| `ERROR` | Fehler |
|
||||
|
||||
@@ -105,7 +105,7 @@ Alle Aktivitäts-Endpunkte geben einen Snapshot zurück:
|
||||
|
||||
## Endpunkte
|
||||
|
||||
### GET `/api/activities`
|
||||
### GET `/api/runtime/activities`
|
||||
|
||||
Aktuellen Aktivitäts-Snapshot abrufen.
|
||||
|
||||
@@ -136,7 +136,7 @@ Aktuellen Aktivitäts-Snapshot abrufen.
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/activities/:id/cancel`
|
||||
### POST `/api/runtime/activities/:id/cancel`
|
||||
|
||||
Aktive Aktivität abbrechen (nur wenn `canCancel: true`).
|
||||
|
||||
@@ -172,7 +172,7 @@ Aktive Aktivität abbrechen (nur wenn `canCancel: true`).
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/activities/:id/next-step`
|
||||
### POST `/api/runtime/activities/:id/next-step`
|
||||
|
||||
Nächsten Schritt einer Aktivität auslösen (nur wenn `canNextStep: true`).
|
||||
|
||||
@@ -201,7 +201,7 @@ Nächsten Schritt einer Aktivität auslösen (nur wenn `canNextStep: true`).
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/activities/clear-recent`
|
||||
### POST `/api/runtime/activities/clear-recent`
|
||||
|
||||
Alle abgeschlossenen Aktivitäten aus `recent` löschen.
|
||||
|
||||
@@ -232,4 +232,4 @@ Gekürzte Ausgaben erhalten den Suffix ` ...[gekürzt]` (bei Inline-Text) bzw. `
|
||||
|
||||
## Echtzeit-Updates
|
||||
|
||||
Änderungen werden automatisch als [`RUNTIME_ACTIVITY_CHANGED`](websocket.md#runtime_activity_changed) WebSocket-Event gesendet. Die Frontend-Komponente braucht `GET /api/activities` nur beim initialen Laden aufzurufen.
|
||||
Änderungen werden automatisch als [`RUNTIME_ACTIVITY_CHANGED`](websocket.md#runtime_activity_changed) WebSocket-Event gesendet. Die Frontend-Komponente braucht `GET /api/runtime/activities` nur beim initialen Laden aufzurufen.
|
||||
|
||||
@@ -336,3 +336,89 @@ Body mit beliebigen Feldern aus `POST`, Response `{ "preset": { ... } }`.
|
||||
### DELETE /api/settings/user-presets/:id
|
||||
|
||||
Response `{ "removed": { ... } }`.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Endpunkte
|
||||
|
||||
### GET /api/settings/effective-paths
|
||||
|
||||
Liefert aufgelöste, effektiv genutzte Pfade aus den Settings (für UI- und Diagnosezwecke).
|
||||
|
||||
### POST /api/settings/coverart/recover
|
||||
|
||||
Startet eine manuelle Coverart-Recovery.
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { "started": true },
|
||||
"scheduler": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Activation Bytes (AAX)
|
||||
|
||||
### GET /api/settings/activation-bytes
|
||||
|
||||
Liest gecachte AAX-Activation-Bytes.
|
||||
|
||||
### POST /api/settings/activation-bytes
|
||||
|
||||
Speichert Activation-Bytes.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"checksum": "abc123...",
|
||||
"activationBytes": "1a2b3c4d"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Laufwerke
|
||||
|
||||
### GET /api/settings/drives
|
||||
|
||||
Liefert erkannte optische Laufwerke (`/dev/...`) inkl. MakeMKV-Disc-Index.
|
||||
|
||||
### POST /api/settings/drives/force-unlock
|
||||
|
||||
Erzwingt Entsperren aktiver Laufwerkslocks.
|
||||
|
||||
Hinweis: Expertenmodus (`ui_expert_mode`) ist erforderlich, sonst `403`.
|
||||
|
||||
**Request (ein Laufwerk):**
|
||||
|
||||
```json
|
||||
{ "devicePath": "/dev/sr0" }
|
||||
```
|
||||
|
||||
**Request (alle):**
|
||||
|
||||
```json
|
||||
{ "all": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Preferences
|
||||
|
||||
### GET /api/settings/prefs/:key
|
||||
|
||||
Liest einen persistierten UI-Preference-Wert.
|
||||
|
||||
### PUT /api/settings/prefs/:key
|
||||
|
||||
Speichert einen persistierten UI-Preference-Wert.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "value": "{\"columns\":[\"id\",\"status\"]}" }
|
||||
```
|
||||
|
||||
@@ -242,6 +242,48 @@ Wird ausgelöst, wenn eine Aktivität gestartet, aktualisiert oder abgeschlossen
|
||||
|
||||
Vollständige Feldbeschreibung: [Runtime Activities API](runtime-activities.md).
|
||||
|
||||
### CONVERTER_SCAN_UPDATE
|
||||
|
||||
Wird nach einem Scan des Converter-Eingangsordners gesendet (manuell oder per Polling).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CONVERTER_SCAN_UPDATE",
|
||||
"payload": {
|
||||
"entryCount": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DOWNLOADS_UPDATED
|
||||
|
||||
Wird bei jeder Statusänderung in der Download-Queue gesendet.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DOWNLOADS_UPDATED",
|
||||
"payload": {
|
||||
"reason": "ready",
|
||||
"item": {
|
||||
"id": "dl-1",
|
||||
"jobId": 42,
|
||||
"status": "ready",
|
||||
"archiveName": "Job_42_movie.zip",
|
||||
"createdAt": "2026-03-30T10:00:00.000Z"
|
||||
},
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mögliche `reason`-Werte: `queued`, `processing`, `ready`, `failed`, `deleted`.
|
||||
|
||||
---
|
||||
|
||||
## Reconnect-Verhalten
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Lizenzierung und Drittanbieter-Software
|
||||
|
||||
## Ripster
|
||||
|
||||
Der von diesem Repository entwickelte Ripster-Quellcode steht unter der GNU
|
||||
General Public License Version 2 oder jeder späteren Version
|
||||
(`GPL-2.0-or-later`).
|
||||
|
||||
## Mitgelieferte HandBrakeCLI
|
||||
|
||||
Ripster enthält eine separat ausführbare HandBrakeCLI-Datei, weil Systempakete
|
||||
je nach Distribution nicht alle für Ripster benötigten Hardwarefunktionen
|
||||
bereitstellen.
|
||||
|
||||
Die mitgelieferte HandBrakeCLI bleibt ein eigenständiges Drittanbieterprogramm
|
||||
unter `GPL-2.0-only`. Ripster startet sie als externen Prozess; der
|
||||
HandBrake-Code stammt nicht vom Ripster-Projekt.
|
||||
|
||||
Buildinformationen, Prüfsummen, Patches und Hinweise zum korrespondierenden
|
||||
Quellcode liegen im Repository unter:
|
||||
|
||||
`third_party/handbrake/`
|
||||
|
||||
Der aktuell mitgelieferte Binary-Build enthält FDK-AAC-Hinweise und ist für die
|
||||
Weiterverteilung als ungeklärt markiert, bis eine rechtliche Prüfung erfolgt
|
||||
oder ein sauberer Neuaufbau ohne FDK-AAC bereitgestellt wird.
|
||||
|
||||
## Weitere Drittanbieter-Software
|
||||
|
||||
Ripster kann zusätzliche externe Medientools aufrufen und sich mit externen
|
||||
Metadaten- und Benachrichtigungsdiensten verbinden.
|
||||
|
||||
Siehe `THIRD_PARTY_NOTICES.md` im Repository für die Übersicht.
|
||||
|
||||
## KI-unterstützte Entwicklung
|
||||
|
||||
Teile von Ripster wurden mit Unterstützung generativer KI-Werkzeuge erstellt
|
||||
oder überarbeitet. Alle generierten Ergebnisse wurden unter menschlicher
|
||||
Verantwortung ausgewählt, geprüft, angepasst und integriert.
|
||||
@@ -1,6 +1,42 @@
|
||||
# Backend-Services
|
||||
|
||||
Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden.
|
||||
Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden. Seit v0.12.0 erfolgt die Medienverarbeitung über ein **Plugin-System**.
|
||||
|
||||
---
|
||||
|
||||
## Plugin-System (`src/plugins/`)
|
||||
|
||||
Ab v0.12.0 ist die Pipeline modular aufgebaut. Jeder Medientyp wird von einem eigenen Plugin verwaltet.
|
||||
|
||||
### Basisklassen
|
||||
|
||||
- **`PluginBase.js`** — Abstrakte Basisklasse `SourcePlugin` mit Lifecycle-Methoden
|
||||
- **`PluginRegistry.js`** — Dynamische Plugin-Erkennung, prioritätsbasierte Auswahl, Settings-Schema-Aggregation
|
||||
- **`PluginContext.js`** — Ausführungskontext (Logger, Callbacks, Signals) für Plugin-Aufrufe
|
||||
|
||||
### Plugin-Lifecycle
|
||||
|
||||
Jedes Plugin implementiert die folgenden Methoden:
|
||||
|
||||
| Methode | Beschreibung |
|
||||
|---|---|
|
||||
| `detect(discInfo)` | Medientyp-Erkennung |
|
||||
| `analyze(devicePath, job, ctx)` | Analyse / Metadaten-Extraktion |
|
||||
| `rip(job, ctx)` | Rohdaten-Extraktion |
|
||||
| `review(job, ctx)` | Optionale Vorschau-Vorbereitung |
|
||||
| `encode(job, ctx)` | Finales Encoding/Konvertierung |
|
||||
| `finalize(job, ctx)` | Nachbearbeitung |
|
||||
| `onCancel(job, ctx)` | Bereinigung bei Abbruch |
|
||||
| `onRetry(job, ctx)` | Zustand-Reset vor Retry |
|
||||
|
||||
### Implementierte Plugins
|
||||
|
||||
- **`BluRayPlugin.js`** — Blu-ray via MakeMKV (Backup-Modus)
|
||||
- **`DVDPlugin.js`** — DVD via MakeMKV (MKV-Modus)
|
||||
- **`CdPlugin.js`** — Audio-CD via cdparanoia + FFmpeg (experimentell)
|
||||
- **`AudiobookPlugin.js`** — AAX/M4B via FFmpeg mit Kapitel-Splitting
|
||||
- **`ConverterPlugin.js`** — Generische Audio/Video-Konvertierung via FFmpeg
|
||||
- **`VideoDiscPlugin.js`** — Gemeinsame Basis für Blu-ray/DVD (MediaInfo-Parsing)
|
||||
|
||||
---
|
||||
|
||||
@@ -11,10 +47,11 @@ Zentrale Workflow-Orchestrierung.
|
||||
Aufgaben:
|
||||
|
||||
- Pipeline-State-Machine + Persistenz (`pipeline_state`)
|
||||
- Disc-Analyse/Rip/Review/Encode
|
||||
- Disc-Analyse/Rip/Review/Encode via Plugin-Delegation
|
||||
- Queue-Management (Jobs + `script|chain|wait` Einträge)
|
||||
- Retry/Re-Encode/Restart-Flows
|
||||
- WebSocket-Broadcasts für State/Progress/Queue
|
||||
- Converter-Job-Verwaltung (`createConverterJobFromEntry`, `uploadConverterFiles`, `startConverterJob`)
|
||||
|
||||
Wichtige Methoden:
|
||||
|
||||
@@ -29,6 +66,8 @@ Wichtige Methoden:
|
||||
- `restartEncodeWithLastSettings()`
|
||||
- `resumeReadyToEncodeJob()`
|
||||
- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()`
|
||||
- `createConverterJobFromEntry()`, `createConverterJobsFromSelection()`
|
||||
- `uploadConverterFiles()`, `startConverterJob()`
|
||||
|
||||
---
|
||||
|
||||
@@ -57,9 +96,10 @@ Features:
|
||||
- `getCategorizedSettings()` für UI-Form
|
||||
- `setSettingValue()` / `setSettingsBulk()`
|
||||
- profilspezifische Auflösung (`resolveEffectiveToolSettings`)
|
||||
- CLI-Config-Building für MakeMKV/HandBrake/MediaInfo
|
||||
- CLI-Config-Building für MakeMKV/HandBrake/MediaInfo/FFmpeg
|
||||
- HandBrake-Preset-Liste via `HandBrakeCLI -z`
|
||||
- MakeMKV-Registration-Command aus `makemkv_registration_key`
|
||||
- MakeMKV-Key-Synchronisation aus `makemkv_registration_key` nach `~/.MakeMKV/settings.conf`
|
||||
- Pfadauflösung für alle Medienprofile inkl. Audiobook und Converter
|
||||
|
||||
---
|
||||
|
||||
@@ -71,12 +111,65 @@ Features:
|
||||
|
||||
- Job-Liste/Detail inkl. Log-Tail
|
||||
- Orphan-RAW-Erkennung und Import
|
||||
- OMDb-Nachzuweisung
|
||||
- TMDb-Neuzuordnung
|
||||
- Dateilöschung (`raw|movie|both`)
|
||||
- Job-Löschung (`none|raw|movie|both`)
|
||||
|
||||
---
|
||||
|
||||
## `audiobookService.js`
|
||||
|
||||
Audiobook-Verarbeitung (AAX/M4B).
|
||||
|
||||
Features:
|
||||
|
||||
- FFprobe-basierte Analyse (Kapitel, Metadaten)
|
||||
- FFmpeg-basiertes Encoding mit Kapitel-Splitting
|
||||
- Ausgabeformate: M4B, MP3, FLAC
|
||||
- Template-basierte Pfade (`{author}`, `{title}`, `{year}`, `{narrator}`, `{series}`, `{part}`)
|
||||
|
||||
---
|
||||
|
||||
## `activationBytesService.js`
|
||||
|
||||
Verwaltung von Audible-Activation-Bytes für AAX-DRM-Handling.
|
||||
|
||||
Features:
|
||||
|
||||
- SHA-1-Prüfsumme der AAX-Datei berechnen
|
||||
- Activation Bytes in `aax_activation_bytes`-Tabelle cachen
|
||||
- Lookup via `audnexService` (wenn konfiguriert)
|
||||
|
||||
---
|
||||
|
||||
## `converterScanService.js`
|
||||
|
||||
Dateisystem-Überwachung des Converter-Eingangsordners.
|
||||
|
||||
Features:
|
||||
|
||||
- Vollständiger FS-Baum (`getTree()`)
|
||||
- DB-basierter Datei-Explorer (`getEntries()`)
|
||||
- Manueller und automatischer Scan (Polling)
|
||||
- Pfad-Normalisierung mit Traversal-Schutz
|
||||
- WebSocket-Broadcast: `CONVERTER_SCAN_UPDATE`
|
||||
|
||||
---
|
||||
|
||||
## `downloadService.js`
|
||||
|
||||
Download-Queue für Ausgabedateien aus der Historie.
|
||||
|
||||
Features:
|
||||
|
||||
- Job in Download-Queue einreihen (`enqueueHistoryJob`)
|
||||
- ZIP-Archiv aus Ausgabedateien erstellen (`archiver`)
|
||||
- Datei-Streaming via `res.download()`
|
||||
- WebSocket-Broadcast: `DOWNLOADS_UPDATED` bei Status-Änderungen
|
||||
- Download-Item löschen
|
||||
|
||||
---
|
||||
|
||||
## `cronService.js`
|
||||
|
||||
Integriertes Cron-System ohne externe Parser-Library.
|
||||
@@ -124,6 +217,12 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
|
||||
- `hardwareMonitorService.js` (CPU/RAM/GPU/Storage)
|
||||
- `websocketService.js` (Client-Registry + Broadcast)
|
||||
- `notificationService.js` (PushOver)
|
||||
- `cdRipService.js` (CD-Ripping mit cdparanoia)
|
||||
- `musicBrainzService.js` (MusicBrainz-Metadaten-Lookup)
|
||||
- `audnexService.js` (Audnex-API für Audiobook-Metadaten)
|
||||
- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung)
|
||||
- `thumbnailService.js` (Vorschaubild-Generierung)
|
||||
- `tmdbService.js` (Film-/Serien-Metadaten-Suche)
|
||||
- `logger.js` (rotierende Datei-Logs)
|
||||
|
||||
---
|
||||
@@ -133,9 +232,10 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
|
||||
Beim Start:
|
||||
|
||||
1. DB init/migrate
|
||||
2. Pipeline-Init
|
||||
2. Pipeline-Init (inkl. Plugin-Registry)
|
||||
3. Cron-Init
|
||||
4. Express-Routes + Error-Handler
|
||||
5. WebSocket-Server auf `/ws`
|
||||
6. Hardware-Monitoring-Init
|
||||
7. Disk-Detection-Start
|
||||
8. Converter-Scan-Service-Init (Polling falls aktiviert)
|
||||
|
||||
@@ -10,6 +10,8 @@ Ripster verwendet SQLite (`backend/data/ripster.db`).
|
||||
settings_schema
|
||||
settings_values
|
||||
jobs
|
||||
job_lineage_artifacts
|
||||
job_output_folders
|
||||
pipeline_state
|
||||
scripts
|
||||
script_chains
|
||||
@@ -17,6 +19,9 @@ script_chain_steps
|
||||
user_presets
|
||||
cron_jobs
|
||||
cron_run_logs
|
||||
aax_activation_bytes
|
||||
user_prefs
|
||||
converter_scan_entries
|
||||
```
|
||||
|
||||
---
|
||||
@@ -32,8 +37,52 @@ Zentrale Felder:
|
||||
- Pfade: `raw_path`, `output_path`, `encode_input_path`
|
||||
- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json`
|
||||
- Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message`
|
||||
- Medientyp: `media_type` (`bluray|dvd|cd|audiobook|converter`)
|
||||
- Job-Typ/Beziehung: `job_kind`, `parent_job_id`
|
||||
- Multipart-/Disc-Merkmale: `is_multipart_movie`, `disc_number`, `metadata_fingerprint`
|
||||
- Prüfsumme: `aax_checksum` (für AAX-Dateien)
|
||||
- Audit: `created_at`, `updated_at`
|
||||
|
||||
Typische `job_kind`-Werte:
|
||||
|
||||
- Standard: `bluray`, `dvd`, `cd`, `audiobook`, `converter_audio`, `converter_video`, `converter_iso`
|
||||
- Serien: `dvd_series_container`, `dvd_series_child`
|
||||
- Multipart Film: `multipart_movie_container`, `multipart_movie_child`
|
||||
|
||||
Wichtige Indizes:
|
||||
|
||||
- `idx_jobs_status`
|
||||
- `idx_jobs_parent_job_id`
|
||||
- `idx_jobs_job_kind`
|
||||
- `idx_jobs_disc_number`
|
||||
- `idx_jobs_metadata_fingerprint`
|
||||
|
||||
---
|
||||
|
||||
## `job_lineage_artifacts`
|
||||
|
||||
Verknüpft Jobs mit ihren Quell-Jobs (z. B. Re-Encode aus einem vorherigen Rip).
|
||||
|
||||
Felder:
|
||||
|
||||
- `job_id` — der abgeleitete Job
|
||||
- `source_job_id` — der Quell-Job
|
||||
- `artifact_type` — Art der Verknüpfung (z. B. `reencode`, `restart_review`)
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `job_output_folders`
|
||||
|
||||
Verfolgt Ausgabepfade pro Job (mehrere Ausgaben möglich, z. B. bei Kapitel-Splitting).
|
||||
|
||||
Felder:
|
||||
|
||||
- `job_id`
|
||||
- `folder_path`
|
||||
- `label` — optionaler Bezeichner
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `pipeline_state`
|
||||
@@ -89,6 +138,45 @@ Benannte HandBrake-Preset-Sets:
|
||||
|
||||
---
|
||||
|
||||
## `aax_activation_bytes`
|
||||
|
||||
Cache für Audible-Activation-Bytes (AAX-DRM).
|
||||
|
||||
Felder:
|
||||
|
||||
- `checksum` — SHA-1-Prüfsumme der AAX-Datei (Primärschlüssel)
|
||||
- `activation_bytes` — Hex-String der Activation Bytes
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `user_prefs`
|
||||
|
||||
Benutzer-spezifische Einstellungen (Key-Value).
|
||||
|
||||
Felder:
|
||||
|
||||
- `key`
|
||||
- `value`
|
||||
- `updated_at`
|
||||
|
||||
---
|
||||
|
||||
## `converter_scan_entries`
|
||||
|
||||
DB-seitige Erfassung gescannter Dateien im Converter-Eingangsordner.
|
||||
|
||||
Felder:
|
||||
|
||||
- `rel_path` — relativer Pfad zum `converter_raw_dir`
|
||||
- `is_dir` — Verzeichnis-Flag
|
||||
- `size` — Dateigröße in Bytes
|
||||
- `modified_at` — Datei-Änderungsdatum
|
||||
- `job_id` — zugewiesener Converter-Job (falls vorhanden)
|
||||
- `scanned_at`
|
||||
|
||||
---
|
||||
|
||||
## Migration/Recovery
|
||||
|
||||
Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen.
|
||||
@@ -107,6 +195,9 @@ Bei korruptem SQLite-File:
|
||||
sqlite3 backend/data/ripster.db
|
||||
|
||||
.mode table
|
||||
SELECT id, status, title, created_at FROM jobs ORDER BY created_at DESC;
|
||||
SELECT id, status, title, media_type, created_at FROM jobs ORDER BY created_at DESC;
|
||||
SELECT id, parent_job_id, job_kind, is_multipart_movie, disc_number FROM jobs ORDER BY created_at DESC;
|
||||
SELECT key, value FROM settings_values ORDER BY key;
|
||||
SELECT checksum, activation_bytes FROM aax_activation_bytes;
|
||||
SELECT rel_path, job_id FROM converter_scan_entries;
|
||||
```
|
||||
|
||||
@@ -6,17 +6,18 @@ Frontend: React + PrimeReact + Vite.
|
||||
|
||||
## Hauptseiten
|
||||
|
||||
### `DashboardPage.jsx`
|
||||
### `RipperPage.jsx`
|
||||
|
||||
Pipeline-Steuerung:
|
||||
|
||||
- Status/Progress/ETA
|
||||
- Metadaten-Dialog
|
||||
- Playlist-Entscheidung
|
||||
- Review-Panel
|
||||
- Review-Panel (Track-Auswahl)
|
||||
- Queue-Interaktion (reorder/add/remove)
|
||||
- Job-Aktionen (Start/Cancel/Retry/Re-Encode)
|
||||
- Hardware-Monitoring-Anzeige
|
||||
- Aktivitäts-Tracking (Skripte, Ketten, Cron)
|
||||
|
||||
### `SettingsPage.jsx`
|
||||
|
||||
@@ -33,18 +34,57 @@ Historie:
|
||||
|
||||
- Job-Liste/Filter
|
||||
- Job-Details + Logs
|
||||
- OMDb-Nachzuweisung
|
||||
- TMDb-Neuzuordnung
|
||||
- Re-Encode/Restart-Workflows
|
||||
|
||||
### `ConverterPage.jsx`
|
||||
|
||||
Datei-Converter:
|
||||
|
||||
- Datei-Explorer des Converter-Eingangsordners (Baum-Ansicht)
|
||||
- Upload von Audio/Video-Dateien (bis zu 50 Dateien gleichzeitig)
|
||||
- Dateiverwaltung: Umbenennen, Verschieben, Löschen, Ordner erstellen
|
||||
- Jobs aus Dateiauswahl erstellen
|
||||
- Converter-Job-Konfiguration (Ausgabeformat, Preset, Tracks)
|
||||
- Job-Start, Abbruch, Löschung
|
||||
- Automatischer Scan-Status
|
||||
|
||||
### `AudiobooksPage.jsx`
|
||||
|
||||
Dedizierter AAX-/Audiobook-Flow:
|
||||
|
||||
- Upload-Panel für AAX-Dateien
|
||||
- aktive Audiobook-Jobkarten mit Start/Cancel/Retry/Delete
|
||||
- Audiobook-Konfigurationspanel und Output-Explorer
|
||||
|
||||
### `DownloadsPage.jsx`
|
||||
|
||||
Download-Queue:
|
||||
|
||||
- Ausgabedateien aus der Job-Historie in die Queue einreihen
|
||||
- Download-Status verfolgen (ausstehend, wird verarbeitet, bereit, fehlgeschlagen)
|
||||
- Dateien als ZIP herunterladen
|
||||
- Download-Einträge löschen
|
||||
|
||||
### `DatabasePage.jsx`
|
||||
|
||||
Expert-Modus:
|
||||
|
||||
- Tabellarische Rohsicht der Job-Datenbank
|
||||
- Orphan-RAW-Import
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Komponenten
|
||||
|
||||
- `PipelineStatusCard.jsx`
|
||||
- `MetadataSelectionDialog.jsx`
|
||||
- `MediaInfoReviewPanel.jsx`
|
||||
- `JobDetailDialog.jsx`
|
||||
- `CronJobsTab.jsx`
|
||||
- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress
|
||||
- `MetadataSelectionDialog.jsx` — TMDb-Metadaten-Auswahl
|
||||
- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel)
|
||||
- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs
|
||||
- `CronJobsTab.jsx` — Cron-Job-Verwaltung
|
||||
- `DynamicSettingsForm.jsx` — Schema-gesteuertes Einstellungsformular
|
||||
- `ConverterJobCard.jsx` — Converter-Job-Darstellung
|
||||
- `CdRipConfigPanel.jsx` — Konfiguration für Audio-CD-Ripping
|
||||
|
||||
---
|
||||
|
||||
@@ -68,6 +108,9 @@ In `App.jsx` werden u. a. verarbeitet:
|
||||
- `PIPELINE_QUEUE_CHANGED`
|
||||
- Disk erkannt / Disk entfernt
|
||||
- `HARDWARE_MONITOR_UPDATE`
|
||||
- `DOWNLOADS_UPDATED`
|
||||
- `CONVERTER_SCAN_UPDATE`
|
||||
- `RUNTIME_ACTIVITY_CHANGED`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-T
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Browser["Browser (React)"]
|
||||
Dashboard[Dashboard]
|
||||
Ripper[Ripper]
|
||||
Settings[Einstellungen]
|
||||
History[Historie]
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@ wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.s
|
||||
| `--no-makemkv` | – | MakeMKV-Installation überspringen |
|
||||
| `--no-handbrake` | – | HandBrake-Installation überspringen |
|
||||
| `--no-nginx` | – | nginx-Einrichtung überspringen |
|
||||
| `--no-system-deps` | – | Systemabhängigkeiten nicht nachinstallieren |
|
||||
| `--accept-makemkv-eula` | – | MakeMKV-EULA ausdrücklich nichtinteraktiv akzeptieren |
|
||||
| `--force-license-prompts` | – | Lizenzabfragen auch bei vorhandenen Tools erneut anzeigen |
|
||||
| `--reinstall` | – | Bestehende Installation aktualisieren (Daten bleiben erhalten) |
|
||||
| `-h`, `--help` | – | Hilfe anzeigen |
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Release-Prozess
|
||||
|
||||
## Lizenz- und Drittanbieter-Checkliste
|
||||
|
||||
- [ ] `LICENSE` enthält den vollständigen GPLv2-Text.
|
||||
- [ ] Ripster-Paketmetadaten verwenden `GPL-2.0-or-later`.
|
||||
- [ ] Die mitgelieferte HandBrakeCLI-Version ist dokumentiert.
|
||||
- [ ] Die HandBrakeCLI-SHA-256-Prüfsumme ist aktuell.
|
||||
- [ ] Der exakte HandBrake-Upstream-Commit ist dokumentiert.
|
||||
- [ ] Alle HandBrake-Patches sind enthalten.
|
||||
- [ ] Das Buildskript passt zum veröffentlichten Binary.
|
||||
- [ ] Das vollständige korrespondierende Source-Archiv ist verfügbar.
|
||||
- [ ] Binary und Source-Archiv sind demselben Release zugeordnet.
|
||||
- [ ] FDK-AAC ist nicht in einem weiterverteilten Build enthalten.
|
||||
- [ ] `THIRD_PARTY_NOTICES.md` ist aktuell.
|
||||
- [ ] README- und Dokumentationslinks sind gültig.
|
||||
- [ ] Der Hinweis zur KI-unterstützten Entwicklung ist weiterhin vorhanden.
|
||||
@@ -1,71 +1,151 @@
|
||||
# Ersteinrichtung
|
||||
|
||||
Nach der Installation erfolgt die tägliche Konfiguration fast vollständig in der GUI unter `Settings`.
|
||||
Diese Seite ist die Betriebs-Checkliste vor dem ersten produktiven Lauf.
|
||||
|
||||
## Ziel
|
||||
## Ziel der Ersteinrichtung
|
||||
|
||||
Vor dem ersten echten Job müssen Pfade, Tools und Metadatenzugriff sauber gesetzt sein.
|
||||
Vor dem ersten Job sollten fünf Bereiche sauber gesetzt sein:
|
||||
|
||||
## Reihenfolge (empfohlen)
|
||||
1. Pfade und Templates
|
||||
2. Laufwerk und Disc-Erkennung
|
||||
3. Metadatenzugang über TMDb
|
||||
4. Encode-Standards und Queue-Regeln
|
||||
5. optionale Automatisierung und Benachrichtigungen
|
||||
|
||||
### 1. `Settings` -> Tab `Konfiguration`
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
Setze zuerst diese Pflichtwerte:
|
||||
### 1. Basis in `Settings -> Konfiguration`
|
||||
|
||||
| Bereich | Wichtige Felder |
|
||||
|---|---|
|
||||
| Pfade | `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` |
|
||||
| Tools | `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando` |
|
||||
| Metadaten | `OMDb API Key`, optional `OMDb Typ` |
|
||||
Prüfe zuerst diese Kernfelder:
|
||||
|
||||
Danach `Änderungen speichern`.
|
||||
- RAW- und Zielordner für Blu-ray, DVD, CD, Audiobooks und Converter
|
||||
- `Log Ordner`
|
||||
- `TMDb Read Access Token`
|
||||
- `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
|
||||
- `FFmpeg Kommando`, `FFprobe Kommando`, `cdparanoia Kommando`, `mkvmerge Kommando`, falls du die jeweiligen Workflows nutzt
|
||||
|
||||
### 2. Medienprofile prüfen
|
||||
Danach speichern.
|
||||
|
||||
Wenn du Blu-ray und DVD unterschiedlich behandeln willst, pflege die profilbezogenen Felder:
|
||||
### 2. Pfade und Templates bewusst festlegen
|
||||
|
||||
- `*_bluray`
|
||||
- `*_dvd`
|
||||
- optional `*_other`
|
||||
Wichtig für den Alltag:
|
||||
|
||||
Typische Beispiele:
|
||||
- Film-Workflows nutzen die RAW- und Film-Ausgabeordner sowie die Film-Output-Templates
|
||||
- Serien-Workflows nutzen zusätzlich die Serien-RAW-Ordner, die Serien-Zielordner und die Episode-/Multi-Episode-Templates
|
||||
- Audiobooks nutzen `Audiobook RAW-Ordner`, `Audiobook Output-Ordner`, `Audiobook RAW Template`, `Output Template (Audiobook)` und `Kapitel Template (Audiobook)`
|
||||
- Converter nutzt `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)` sowie `Output-Template (Video)` und `Output-Template (Audio)`
|
||||
- Downloads nutzen `Download ZIP-Ordner`
|
||||
|
||||
- `HandBrake Preset` (Blu-ray und DVD)
|
||||
- `Raw Ausgabeordner` (Blu-ray und DVD)
|
||||
- `Dateiname Template` (Blu-ray und DVD)
|
||||
Wenn du auf NAS, USB oder mit anderen Benutzern/Gruppen schreiben willst, prüfe auch die jeweils zugehörigen `Eigentümer ...`-Felder.
|
||||
|
||||
### 3. Queue und Monitoring festlegen
|
||||
### 3. Laufwerk und automatische Erkennung prüfen
|
||||
|
||||
- `Parallele Jobs` für den gleichzeitigen Durchsatz
|
||||
- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Dashboard
|
||||
Relevant:
|
||||
|
||||
### 4. Optional: Push-Benachrichtigungen
|
||||
- `Laufwerksmodus`
|
||||
- `Explizite Laufwerke`, falls du nicht mit Auto-Discovery arbeiten willst
|
||||
- `Automatische Disk-Erkennung`
|
||||
- `Polling Intervall (ms)` im Expertenmodus
|
||||
- `Laufwerk nach Rip auswerfen`
|
||||
|
||||
In den Benachrichtigungsfeldern setzen:
|
||||
Empfehlung:
|
||||
|
||||
- Einzelnes Standardlaufwerk: `auto`
|
||||
- mehrere stabile Laufwerke oder ungewöhnliche Device-Mappings: `Explizite Laufwerke`
|
||||
|
||||
### 4. TMDb und Metadatenverhalten festlegen
|
||||
|
||||
Relevant:
|
||||
|
||||
- `TMDb Read Access Token`
|
||||
- `Film-Sprache`
|
||||
- `Fallback Sprache`
|
||||
- `Coverart-Nachladen aktiv`
|
||||
- `Coverart-Prüfintervall (Stunden)`
|
||||
- `NFO nach Encode erzeugen`
|
||||
|
||||
Diese Einstellungen wirken direkt in Metadatensuche, Serienzuordnung, Nachpflege von Covern und im finalen Output.
|
||||
|
||||
### 5. Encode- und Review-Standards festlegen
|
||||
|
||||
Für Video-Workflows besonders wichtig:
|
||||
|
||||
- `Minimale Titellänge (Minuten)`
|
||||
- `TMDb Runtime-Abzug für Playlistfilter (%)`
|
||||
- `HandBrake Preset` und `HandBrake Extra Args` je Medium
|
||||
- `Review Audio-Sprachen (...)`
|
||||
- `Review UT-Sprachen (...)`
|
||||
- `Ausgabeformat`
|
||||
|
||||
Praxis:
|
||||
|
||||
- `Minimale Titellänge` filtert Bonusmaterial früh weg
|
||||
- `TMDb Runtime-Abzug ...` lockert bei problematischen Blu-rays die Laufzeitprüfung der Playlist-Kandidaten
|
||||
- Review-Sprachen bestimmen, welche Audio-/Untertitelspuren in der Review schon vorausgewählt sind
|
||||
|
||||
### 6. Queue-Verhalten definieren
|
||||
|
||||
Stelle diese vier Felder bewusst ein:
|
||||
|
||||
- `Parallele Jobs`
|
||||
- `Max. parallele Audio CD Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
- `Audio CDs: Queue-Reihenfolge überspringen`
|
||||
|
||||
Damit steuerst du, wie aggressiv Ripster mehrere Jobs gleichzeitig startet.
|
||||
|
||||
### 7. Optional: User-Presets und Standard-Zuordnungen
|
||||
|
||||
Unter `Settings -> Encode-Presets` kannst du:
|
||||
|
||||
- eigene HandBrake-User-Presets anlegen
|
||||
- Standard-Zuordnungen für `Blu-ray Film`, `Blu-ray Serie`, `DVD Film`, `DVD Serie` setzen
|
||||
|
||||
Diese Defaults wirken später in der Review automatisch vorbefüllend.
|
||||
|
||||
### 8. Optional: Benachrichtigungen
|
||||
|
||||
Prüfe:
|
||||
|
||||
- `PushOver aktiviert`
|
||||
- `PushOver Token`
|
||||
- `PushOver User`
|
||||
- Token/User/optional Device
|
||||
- Event-Toggles wie `Bei manueller Auswahl senden`, `Bei Rip-Start senden`, `Bei Erfolg senden`
|
||||
|
||||
Dann über `PushOver Test` direkt prüfen.
|
||||
Danach in `Settings` den Button `PushOver Test` ausführen.
|
||||
|
||||
## 2-Minuten-Funktionstest
|
||||
### 9. Optional: Automatisierung
|
||||
|
||||
1. `Dashboard` öffnen
|
||||
2. Disc einlegen
|
||||
3. `Analyse starten`
|
||||
4. Metadaten übernehmen
|
||||
5. Bis `Bereit zum Encodieren` laufen lassen
|
||||
Erst wenn die Grundkonfiguration stabil ist:
|
||||
|
||||
Wenn diese Schritte funktionieren, ist die Grundkonfiguration korrekt.
|
||||
1. Skripte anlegen und testen
|
||||
2. Skriptketten aufbauen
|
||||
3. Cronjobs aktivieren
|
||||
|
||||
## Wenn Werte nicht gespeichert werden
|
||||
## Funktionsprüfung
|
||||
|
||||
- Feld mit Fehler markieren lassen (rote Validierung im Formular)
|
||||
- Pfadangaben und numerische Werte prüfen
|
||||
- bei Tool-Pfaden direkt CLI-Aufruf im Terminal testen
|
||||
Ein sinnvoller Kurztest:
|
||||
|
||||
1. Disc in `Ripper` erkennen lassen
|
||||
2. `Analyse starten`
|
||||
3. TMDb-Metadaten übernehmen
|
||||
4. ggf. Playlist-/RAW-Entscheidung durchlaufen
|
||||
5. bis `Bereit zum Encodieren` kommen
|
||||
6. Encode starten
|
||||
7. Ergebnis in `Historie` prüfen
|
||||
|
||||
## Typische Konfigurationsfehler
|
||||
|
||||
| Symptom | Häufige Ursache |
|
||||
|---|---|
|
||||
| Disc wird nicht erkannt | Laufwerksmodus oder Auto-Erkennung passt nicht |
|
||||
| Playlist-Auswahl wirkt unplausibel | `Minimale Titellänge` oder `TMDb Runtime-Abzug ...` ist zu streng/zu locker |
|
||||
| falsche Spuren sind in der Review vorausgewählt | Review-Sprachfilter oder HandBrake-Args greifen unerwartet |
|
||||
| Ausgabe landet am falschen Ort | Profilpfade oder Templates greifen über Fallback |
|
||||
| Queue verhält sich unerwartet | `Parallele Jobs`, CD-Limit und Gesamtlimit widersprechen sich |
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Erster Lauf](quickstart.md)
|
||||
- [GUI-Seiten im Detail](../gui/index.md)
|
||||
- [GUI-Seiten](../gui/index.md)
|
||||
- [Workflows aus Nutzersicht](../workflows/index.md)
|
||||
- [Alle Einstellungen](../configuration/settings-reference.md)
|
||||
|
||||
@@ -1,107 +1,119 @@
|
||||
# Installation
|
||||
|
||||
Die empfohlene Installation läuft über `install.sh` und richtet Ripster vollständig ein.
|
||||
Die empfohlene Installation startet immer über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und übergibt alle weiteren Parameter unverändert an den eigentlichen Installer.
|
||||
|
||||
Du musst dafür **keine Tools manuell vorinstallieren**. Das Skript installiert die benötigten Abhängigkeiten automatisch, sofern sie nicht explizit mit `--no-*` übersprungen werden.
|
||||
Wichtig:
|
||||
|
||||
## Unterstützte Systeme und Anforderungen
|
||||
- Standard-Einstieg: `setup.sh`
|
||||
- kein `curl | bash`
|
||||
- du musst die Tools nicht manuell vorinstallieren, sofern du sie nicht bewusst mit `--no-*` auslässt
|
||||
|
||||
- unterstützt laut Script: `debian`, `ubuntu`, `linuxmint`, `pop`
|
||||
- Ausführung als `root` (oder via `sudo`)
|
||||
## Voraussetzungen
|
||||
|
||||
- unterstütztes Linux-System
|
||||
- Root-Rechte oder `sudo`
|
||||
- Internetzugang während der Installation
|
||||
|
||||
## Schritt-für-Schritt
|
||||
Der Installer erkennt aktuell:
|
||||
|
||||
### 1. Installationsskript herunterladen
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Linux Mint
|
||||
- Pop!_OS
|
||||
|
||||
## Standardinstallation
|
||||
|
||||
```bash
|
||||
wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh
|
||||
wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
### 2. Installation ausführen
|
||||
Ohne `--branch` fragt `setup.sh` interaktiv nach dem gewünschten Branch.
|
||||
|
||||
Die typischen Branches sind:
|
||||
|
||||
- `main`: stabiles Release
|
||||
- `dev`: aktueller Entwicklungsstand
|
||||
|
||||
## Installation mit Branch oder Optionen
|
||||
|
||||
Beispiele:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
bash setup.sh --branch main
|
||||
bash setup.sh --branch dev
|
||||
bash setup.sh --branch dev --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||
```
|
||||
|
||||
Während der Installation wählst du den HandBrake-Modus:
|
||||
`setup.sh` wertet selbst nur `--branch` aus. Alles andere übernimmt `install.sh`.
|
||||
|
||||
- `1` Standard (Paketinstallation)
|
||||
- `2` GPU/NVDEC (gebündeltes Binary)
|
||||
## Wichtige Optionen von `install.sh`
|
||||
|
||||
### 3. Dienststatus prüfen
|
||||
| Option | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `--branch <branch>` | `dev` | installiert genau diesen Git-Branch |
|
||||
| `--dir <pfad>` | `/opt/ripster` | Installationsverzeichnis |
|
||||
| `--user <benutzer>` | `ripster` | Systembenutzer für den Dienst |
|
||||
| `--port <port>` | `3001` | Backend-Port |
|
||||
| `--host <hostname|ip>` | automatisch ermittelte Host-IP | Hostname/IP für Webzugriff und CORS |
|
||||
| `--no-makemkv` | aus | MakeMKV nicht installieren |
|
||||
| `--no-handbrake` | aus | HandBrake nicht installieren |
|
||||
| `--no-nginx` | aus | nginx-Setup überspringen |
|
||||
| `--no-system-deps` | aus | Systemabhängigkeiten nicht nachinstallieren |
|
||||
| `--accept-makemkv-eula` | aus | MakeMKV-EULA ausdrücklich nichtinteraktiv akzeptieren |
|
||||
| `--force-license-prompts` | aus | Lizenzabfragen auch bei vorhandenen Tools erneut anzeigen |
|
||||
| `--reinstall` | aus | bestehende Installation aktualisieren |
|
||||
|
||||
## Was der Installer einrichtet
|
||||
|
||||
Typischer Ablauf:
|
||||
|
||||
1. Betriebssystem, Root-Rechte und Host prüfen
|
||||
2. Systemabhängigkeiten installieren
|
||||
3. Node.js 20 sicherstellen
|
||||
4. optional MakeMKV installieren
|
||||
5. optional HandBrakeCLI installieren
|
||||
6. optional nginx konfigurieren
|
||||
7. Repository nach `--dir` klonen oder aktualisieren
|
||||
8. Backend-/Frontend-Abhängigkeiten installieren
|
||||
9. Frontend bauen
|
||||
10. `backend/.env` und Laufzeitverzeichnisse vorbereiten
|
||||
11. `ripster-backend.service` anlegen und starten
|
||||
|
||||
## Dienst prüfen
|
||||
|
||||
```bash
|
||||
sudo systemctl status ripster-backend
|
||||
```
|
||||
|
||||
### 4. Weboberfläche öffnen
|
||||
|
||||
- mit nginx (Standard): `http://<Server-IP>`
|
||||
- ohne nginx (`--no-nginx`): API auf `http://<Server-IP>:3001/api`
|
||||
|
||||
## Was `install.sh` konkret einrichtet
|
||||
|
||||
1. prüft Betriebssystem, Root-Rechte und ermittelt Host/IP
|
||||
2. aktualisiert Paketquellen und installiert Basispakete (`curl`, `wget`, `git`, `mediainfo`, `udev` ...)
|
||||
3. installiert Node.js 20 (falls nicht passend vorhanden)
|
||||
4. installiert optional MakeMKV (Build aus den offiziellen Quellen)
|
||||
5. installiert optional HandBrakeCLI (Standard oder GPU/NVDEC)
|
||||
6. installiert optional nginx
|
||||
7. legt den Systembenutzer `ripster` an (ohne Login-Shell, ohne Home) und ergänzt Gruppen (`cdrom`, `optical`, `disk`, `video`, `render`, falls vorhanden)
|
||||
8. klont das Repository nach `/opt/ripster` (oder aktualisiert bei `--reinstall`)
|
||||
9. legt Verzeichnisse an:
|
||||
- `/opt/ripster/backend/data`
|
||||
- `/opt/ripster/backend/logs`
|
||||
- `/opt/ripster/backend/data/output/raw`
|
||||
- `/opt/ripster/backend/data/output/movies`
|
||||
- `/opt/ripster/backend/data/logs`
|
||||
10. installiert npm-Abhängigkeiten (Root, Backend, Frontend) und baut das Frontend
|
||||
11. erzeugt `backend/.env` (bei `--reinstall` bleibt bestehende `.env` erhalten)
|
||||
12. setzt Rechte/Besitz und erstellt bei sudo-Installation zusätzlich `~/.MakeMKV` für den aufrufenden Benutzer
|
||||
13. erzeugt und startet `ripster-backend.service`
|
||||
14. konfiguriert und startet nginx (falls nicht übersprungen)
|
||||
|
||||
## Wichtige Optionen
|
||||
|
||||
| Option | Default laut Script | Zweck |
|
||||
|---|---|---|
|
||||
| `--branch <branch>` | `dev` | Branch für die Installation |
|
||||
| `--dir <pfad>` | `/opt/ripster` | Installationsverzeichnis |
|
||||
| `--user <benutzer>` | `ripster` | Service-Benutzer |
|
||||
| `--port <port>` | `3001` | Backend-Port |
|
||||
| `--host <hostname>` | automatisch ermittelte Host-IP | Hostname/IP für Webzugriff/CORS |
|
||||
| `--no-makemkv` | aus | MakeMKV-Installation überspringen |
|
||||
| `--no-handbrake` | aus | HandBrake-Installation überspringen |
|
||||
| `--no-nginx` | aus | nginx-Setup überspringen |
|
||||
| `--reinstall` | aus | bestehende Installation aktualisieren |
|
||||
|
||||
Beispiele:
|
||||
Typische Aufrufe im Betrieb:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --branch dev
|
||||
sudo bash install.sh --port 8080 --host ripster.local
|
||||
sudo bash install.sh --reinstall
|
||||
sudo journalctl -u ripster-backend -f
|
||||
sudo systemctl restart ripster-backend
|
||||
```
|
||||
|
||||
## Betrieb im Alltag
|
||||
## Update einer bestehenden Installation
|
||||
|
||||
Standard:
|
||||
|
||||
```bash
|
||||
# Logs live ansehen
|
||||
sudo journalctl -u ripster-backend -f
|
||||
|
||||
# Dienst neu starten
|
||||
sudo systemctl restart ripster-backend
|
||||
|
||||
# Update aus bestehender Installation
|
||||
sudo bash /opt/ripster/install.sh --reinstall
|
||||
```
|
||||
|
||||
## Häufige Stolperstellen
|
||||
Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, dieselben Parameter wieder mitgeben:
|
||||
|
||||
- Laufwerk nicht zugreifbar: Laufwerksrechte/Gruppen prüfen
|
||||
- CLI-Tool fehlt wegen `--no-*`: Tool nachinstallieren oder Befehl in Settings korrigieren
|
||||
- UI nicht erreichbar: nginx-Status und Firewall prüfen
|
||||
```bash
|
||||
sudo bash /opt/ripster/install.sh --reinstall --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||
```
|
||||
|
||||
Alternativ kannst du auch erneut über `setup.sh` gehen:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/ripster/setup.sh --reinstall
|
||||
```
|
||||
|
||||
`--reinstall` aktualisiert die Installation, behält aber die persistenten Daten der bestehenden Instanz.
|
||||
|
||||
## Danach weiter
|
||||
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
# Voraussetzungen
|
||||
|
||||
Die Voraussetzungen hängen davon ab, **wie** du Ripster betreibst.
|
||||
Die Voraussetzungen hängen davon ab, ob du Ripster produktiv installieren oder lokal entwickeln willst.
|
||||
|
||||
## Produktionsbetrieb mit `install.sh` (Standard)
|
||||
## Produktionsbetrieb mit `setup.sh`
|
||||
|
||||
Für den normalen Betrieb sind nur wenige Punkte vorab nötig.
|
||||
Für den normalen Betrieb brauchst du nur die Basisvoraussetzungen. Die eigentlichen Tools werden vom Installer eingerichtet, sofern du sie nicht bewusst mit `--no-*` überspringst.
|
||||
|
||||
### Pflicht
|
||||
|
||||
- unterstütztes Linux-System (laut Script: Debian, Ubuntu, Linux Mint, Pop!_OS)
|
||||
- `root`-Rechte
|
||||
- unterstütztes Linux-System
|
||||
- Root-Rechte oder `sudo`
|
||||
- Internetzugang während der Installation
|
||||
- optisches Laufwerk für Disc-Betrieb
|
||||
- optisches Laufwerk, wenn du Disc-Workflows nutzen willst
|
||||
|
||||
`install.sh` installiert die benötigten Tools selbst (u. a. Node.js, MakeMKV, HandBrakeCLI, MediaInfo), sofern sie nicht explizit per `--no-*` übersprungen werden.
|
||||
Der Installer unterstützt aktuell:
|
||||
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Linux Mint
|
||||
- Pop!_OS
|
||||
|
||||
Standard-Einstieg:
|
||||
|
||||
```bash
|
||||
wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
### Laufwerk kurz prüfen
|
||||
|
||||
@@ -22,27 +34,25 @@ ls /dev/sr*
|
||||
lsblk | grep rom
|
||||
```
|
||||
|
||||
Wenn nötig Rechte setzen (Beispiel):
|
||||
|
||||
```bash
|
||||
sudo chmod a+rw /dev/sr0
|
||||
```
|
||||
|
||||
### Optional vorab
|
||||
|
||||
- OMDb API-Key (kann auch nach Installation in den `Settings` gesetzt werden)
|
||||
- PushOver-Zugangsdaten (optional)
|
||||
- TMDb Read Access Token für Film-/Serien-Metadaten
|
||||
- PushOver-Zugangsdaten für Benachrichtigungen
|
||||
|
||||
## Entwicklungsmodus (nur für Dev)
|
||||
Beides kann auch erst nach der Installation in `Settings` gesetzt werden.
|
||||
|
||||
## Entwicklungsmodus
|
||||
|
||||
Wenn du lokal entwickelst (`./start.sh`, `npm run dev`), gelten zusätzliche Voraussetzungen:
|
||||
|
||||
- Node.js >= 20.19.0
|
||||
- Node.js `>= 20.19.0`
|
||||
- `makemkvcon`, `HandBrakeCLI`, `mediainfo` im `PATH`
|
||||
|
||||
Details: [Entwicklungsumgebung](../deployment/development.md)
|
||||
|
||||
## Abschluss-Checkliste
|
||||
|
||||
- [ ] Produktionsbetrieb: Linux + root + Internet + Laufwerk vorhanden
|
||||
- [ ] Dev-Modus (nur falls benötigt): Node.js und CLI-Tools verfügbar
|
||||
- [ ] Linux + Root/Sudo + Internet vorhanden
|
||||
- [ ] optisches Laufwerk vorhanden, falls Disc-Ripping genutzt werden soll
|
||||
- [ ] TMDb/PushOver-Zugangsdaten liegen bereit, falls diese Funktionen sofort genutzt werden sollen
|
||||
- [ ] im Dev-Modus zusätzlich Node.js und CLI-Tools lokal verfügbar
|
||||
|
||||
@@ -1,70 +1,151 @@
|
||||
# Erster Lauf
|
||||
|
||||
Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei.
|
||||
Dieser Ablauf führt einen typischen Disc-Job von Erkennung bis fertiger Datei durch.
|
||||
|
||||
## 1. Dashboard öffnen und Disc einlegen
|
||||
## 1. Disc erkennen
|
||||
|
||||
Erwartung:
|
||||
1. `Ripper` öffnen
|
||||
2. Disc einlegen
|
||||
3. auf `Medium erkannt` warten
|
||||
|
||||
- Status wechselt auf `Medium erkannt`
|
||||
- im Bereich `Disk-Information` sind Laufwerksdaten sichtbar
|
||||
Prüfen:
|
||||
|
||||
Wenn nichts passiert: `Laufwerk neu lesen`.
|
||||
- `Disk-Information` zeigt Device, Label und Laufwerksstatus
|
||||
- falls nicht: `Laufwerk neu lesen`
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Laufwerksmodus`
|
||||
- `Automatische Disk-Erkennung`
|
||||
- `Polling Intervall (ms)`
|
||||
|
||||
## 2. Analyse starten
|
||||
|
||||
Aktion im Dashboard:
|
||||
Aktion:
|
||||
|
||||
- `Analyse starten`
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Status `Analyse`
|
||||
- danach Metadaten-Dialog
|
||||
- danach der Metadaten-Dialog
|
||||
|
||||
## 3. Metadaten auswählen
|
||||
Relevantes Setting:
|
||||
|
||||
Im Dialog `Metadaten auswählen`:
|
||||
- `Minimale Titellänge (Minuten)` beeinflusst bereits, welche Titel/Playlists Ripster später als relevant betrachtet
|
||||
|
||||
1. OMDb-Suche nutzen oder manuell eintragen
|
||||
2. passenden Treffer markieren
|
||||
3. `Auswahl übernehmen`
|
||||
## 3. Metadaten in TMDb bestätigen
|
||||
|
||||
## 4. Auf den nächsten Zustand reagieren
|
||||
Im Dialog:
|
||||
|
||||
- Normalfall ohne vorhandenes RAW: `Rippen` -> `Mediainfo-Pruefung` -> `Bereit zum Encodieren`
|
||||
- bei vorhandenem RAW: direkt `Mediainfo-Pruefung` -> `Bereit zum Encodieren`
|
||||
- bei unklarer Blu-ray-Playlist: `Warte auf Auswahl` (Playlist auswählen und übernehmen)
|
||||
1. Treffer suchen oder filtern
|
||||
2. Film oder Serie auswählen
|
||||
3. bei Serien zusätzlich `Disc-Nummer` setzen
|
||||
4. `Auswahl übernehmen`
|
||||
|
||||
## 5. Review in `Bereit zum Encodieren`
|
||||
Mögliche Abzweigungen:
|
||||
|
||||
Im aufgeklappten Job (`Pipeline-Status`):
|
||||
- Duplikatfilm: `Übernahme erzwingen` oder `Multipart movie`
|
||||
- vorhandenes RAW: Entscheidungsdialog `weiterverwenden` oder `neu rippen`
|
||||
|
||||
- Encode-Titel wählen
|
||||
- Audio-/Subtitle-Spuren prüfen
|
||||
- optional User-Preset auswählen
|
||||
- optional Pre-/Post-Skripte bzw. Ketten hinzufügen
|
||||
Relevante Settings:
|
||||
|
||||
Dann `Encoding starten`.
|
||||
- `TMDb Read Access Token`
|
||||
- `Film-Sprache`
|
||||
- `Fallback Sprache`
|
||||
|
||||
## 4. RAW- und Playlist-Entscheidungen
|
||||
|
||||
Je nach Disc-Situation geht es jetzt unterschiedlich weiter.
|
||||
|
||||
### Normalfall
|
||||
|
||||
`Startbereit` -> `Rippen` -> `Mediainfo-Prüfung`
|
||||
|
||||
### Vorhandenes RAW
|
||||
|
||||
Kein neuer Rip, sondern erst eine Entscheidung:
|
||||
|
||||
- vorhandenes RAW weiterverwenden
|
||||
- RAW löschen und Rip neu starten
|
||||
- bei passenden Film-Konstellationen optional Multipart mit Orphan-RAW
|
||||
|
||||
### Playlist- oder Titelauswahl erforderlich
|
||||
|
||||
Bei bestimmten Blu-ray-/DVD-Fällen erscheint `Warte auf Auswahl`.
|
||||
|
||||
Dann bestätigst du:
|
||||
|
||||
- eine Playlist
|
||||
- oder einen/mehrere HandBrake-Titel
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Minimale Titellänge (Minuten)`
|
||||
- `TMDb Runtime-Abzug für Playlistfilter (%)`
|
||||
|
||||
## 5. Encode-Review abschließen
|
||||
|
||||
Bei `Bereit zum Encodieren` prüfst du:
|
||||
|
||||
- Titelwahl
|
||||
- Audio-Spuren
|
||||
- Untertitel
|
||||
- User-Preset oder HandBrake-Preset
|
||||
- Pre-/Post-Encode-Skripte und Ketten
|
||||
|
||||
Dann:
|
||||
|
||||
- `Encoding starten`
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `HandBrake Preset` und `HandBrake Extra Args` je Medium
|
||||
- `Review Audio-Sprachen (...)`
|
||||
- `Review UT-Sprachen (...)`
|
||||
- `Ausgabeformat`
|
||||
- Standard-Zuordnungen unter `Settings -> Encode-Presets`
|
||||
|
||||
## 6. Encoding überwachen
|
||||
|
||||
Während `Encodieren`:
|
||||
|
||||
- Fortschritt + ETA im Dashboard
|
||||
- Live-Log im `Pipeline-Status`
|
||||
- Queue- und Skript/Cron-Status parallel beobachtbar
|
||||
- Fortschritt/ETA im Ripper
|
||||
- Queue-Status beobachten
|
||||
- bei Bedarf `Hardware`-Ansicht öffnen
|
||||
|
||||
## 7. Ergebnis prüfen
|
||||
Relevante Settings:
|
||||
|
||||
Bei `Fertig`:
|
||||
- `Parallele Jobs`
|
||||
- `Max. parallele Audio CD Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
- `Hardware Monitoring aktiviert`
|
||||
- `Hardware Monitoring Intervall (ms)`
|
||||
|
||||
1. Seite `Historie` öffnen
|
||||
2. Job in Details öffnen
|
||||
3. Output-Pfad, Status und Log prüfen
|
||||
## 7. Ergebnis verifizieren
|
||||
|
||||
## Typische Folgeaktionen
|
||||
Nach `Fertig`:
|
||||
|
||||
- Falsches OMDb-Match: in `Historie` -> `OMDb neu zuordnen`
|
||||
- Neue Encodierung aus RAW: `RAW neu encodieren`
|
||||
- Prüfung komplett neu aufbauen: `Review neu starten`
|
||||
1. `Historie` öffnen
|
||||
2. Jobdetail prüfen
|
||||
3. Output-Pfad und Log kontrollieren
|
||||
|
||||
Kontrollpunkte:
|
||||
|
||||
- finale Datei liegt am erwarteten Template-Pfad
|
||||
- RAW-Ordner ist finalisiert
|
||||
- optional `.nfo` wurde erzeugt, falls aktiviert
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- Film-Output-Templates oder Serien-Templates
|
||||
- `Serien-Ordner (Blu-ray)` und `Serien-Ordner (DVD)` plus die Episode-/Multi-Episode-Templates
|
||||
- `NFO nach Encode erzeugen`
|
||||
|
||||
## 8. Typische Folgeaktionen
|
||||
|
||||
- falsche TMDb-Zuordnung: `TMDb neu zuweisen`
|
||||
- andere Trackauswahl: `Review neu starten`
|
||||
- erneuter Encode aus RAW: `RAW neu encodieren`
|
||||
- gleicher Plan erneut: `Encode neu starten`
|
||||
- Rip erneut versuchen: `Retry Rippen`
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Audiobooks
|
||||
|
||||
Die Seite `Audiobooks` ist der spezialisierte Workflow für Audible-`*.aax`-Dateien.
|
||||
|
||||
## Seitenstruktur
|
||||
|
||||
Die Oberfläche besteht aus zwei Hauptbereichen:
|
||||
|
||||
1. `Audiobooks Upload`
|
||||
2. `Audiobook Jobs`
|
||||
|
||||
## 1. Audiobooks Upload
|
||||
|
||||
Der Upload akzeptiert ausschließlich `.aax`.
|
||||
|
||||
### Bedienung
|
||||
|
||||
- Datei auswählen oder per Drag-and-Drop ablegen
|
||||
- Upload starten
|
||||
- Auswahl entfernen oder laufenden Upload abbrechen
|
||||
|
||||
Während des Uploads zeigt die UI:
|
||||
|
||||
- Prozentfortschritt
|
||||
- übertragene und gesamte Bytes
|
||||
- Statusmeldung
|
||||
|
||||
### Ablauf nach erfolgreichem Upload
|
||||
|
||||
- die AAX-Datei wird geprüft
|
||||
- Ripster versucht Metadata, Kapitel und Activation-Bytes-Kontext aufzubauen
|
||||
- ein Audiobook-Job wird angelegt
|
||||
- der Job startet direkt oder wird in die Queue eingereiht
|
||||
|
||||
## 2. Audiobook Jobs
|
||||
|
||||
Die Seite zeigt aktive Jobs. Abgeschlossene Jobs findest du in der `Historie`.
|
||||
|
||||
Ein Job kann eingeklappt oder ausgeklappt werden und zeigt:
|
||||
|
||||
- Titel, Autor, Sprecher, Jahr
|
||||
- Kapitelanzahl
|
||||
- Fortschritt und ETA
|
||||
- bei Split-Ausgabe den Kapitelstatus
|
||||
|
||||
## Konfigurierbare Job-Parameter
|
||||
|
||||
Im ausgeklappten Job kannst du setzen:
|
||||
|
||||
- Ausgabeformat `m4b`, `mp3` oder `flac`
|
||||
- formatspezifische Optionen
|
||||
- Kapitelnamen
|
||||
- Pre-Encode-Skripte und -Ketten
|
||||
- Post-Encode-Skripte und -Ketten
|
||||
|
||||
### Formatverhalten
|
||||
|
||||
- `m4b`: eine Datei mit Kapitelmarken
|
||||
- `mp3`: eine Datei pro Kapitel
|
||||
- `flac`: eine Datei pro Kapitel
|
||||
|
||||
Wenn du `mp3` oder `flac` wählst, zeigt die UI zusätzlich eine Kapitel-Status-Tabelle.
|
||||
|
||||
## Activation Bytes
|
||||
|
||||
Wenn Ripster für eine Datei keine Activation Bytes verwenden kann, wird im Workflow eine manuelle Eingabe nötig.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- die Bytes werden lokal gecacht
|
||||
- derselbe AAX-Hash muss dann später nicht erneut manuell eingegeben werden
|
||||
- der Expertenblock in `Settings` zeigt bekannte Cache-Einträge an
|
||||
|
||||
## Relevante Settings
|
||||
|
||||
- `Audiobook RAW-Ordner`
|
||||
- `Audiobook Output-Ordner`
|
||||
- `Eigentümer Audiobook RAW-Ordner`
|
||||
- `Eigentümer Audiobook Output-Ordner`
|
||||
- `Audiobook RAW Template`
|
||||
- `Output Template (Audiobook)`
|
||||
- `Kapitel Template (Audiobook)`
|
||||
- `FFprobe Kommando`
|
||||
- `FFmpeg Kommando`
|
||||
|
||||
### So wirken die Settings im Alltag
|
||||
|
||||
- `Audiobook RAW Template` bestimmt den Namen des AAX-RAW-Ordners
|
||||
- `Output Template (Audiobook)` bestimmt die Einzeldatei für `m4b`
|
||||
- `Kapitel Template (Audiobook)` bestimmt Zielstruktur und Dateinamen für `mp3`/`flac`
|
||||
- `FFprobe Kommando` liest Kapitel und Metadaten
|
||||
- `FFmpeg Kommando` erzeugt die finalen Dateien
|
||||
|
||||
## Queue und Laufzeit
|
||||
|
||||
Audiobook-Jobs nutzen dieselbe globale Pipeline wie die anderen Bereiche.
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Parallele Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
|
||||
## Typische Fehlerbilder
|
||||
|
||||
- Upload wird abgelehnt: Datei ist keine gültige `*.aax`
|
||||
- Analyse schlägt fehl: `ffprobe` ist nicht erreichbar
|
||||
- Encode schlägt fehl: `ffmpeg` ist nicht erreichbar
|
||||
- Ausgabe landet unerwartet: Templates oder Zielpfade passen nicht zur gewünschten Struktur
|
||||
@@ -0,0 +1,128 @@
|
||||
# Converter
|
||||
|
||||
Die Seite `Converter` verarbeitet vorhandene Dateien ohne Laufwerks-Rip. Sie ist für Video-, Audio- und ISO-Dateien gedacht, die bereits auf dem System liegen oder hochgeladen werden.
|
||||
|
||||
## Betriebsmodell
|
||||
|
||||
Der Converter arbeitet auf dem in `Settings` festgelegten `Converter Raw-Ordner`.
|
||||
|
||||
Dort landen:
|
||||
|
||||
- Uploads
|
||||
- neu angelegte Ordner
|
||||
- umbenannte oder verschobene Dateien
|
||||
- alle Dateien, aus denen später Converter-Jobs entstehen
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Upload erzeugt nicht automatisch einen Job
|
||||
- die Joberstellung erfolgt bewusst aus der Explorer-Auswahl
|
||||
|
||||
## Datei-Explorer
|
||||
|
||||
Der Explorer zeigt den kompletten Baum unter `Converter Raw-Ordner`.
|
||||
|
||||
Aktionen:
|
||||
|
||||
| Aktion | Wirkung |
|
||||
|---|---|
|
||||
| `Upload` | schreibt neue Dateien in den Converter-RAW-Baum |
|
||||
| `Ordner erstellen` | legt Unterordner an |
|
||||
| `Umbenennen` | benennt Datei oder Ordner um |
|
||||
| `Verschieben` | verschiebt Einträge innerhalb des Raw-Baums |
|
||||
| `Löschen` | entfernt Datei oder Ordner dauerhaft |
|
||||
| `Scannen` | synchronisiert Dateisystem und Converter-Datenbank |
|
||||
|
||||
### Upload- und Scan-Regeln
|
||||
|
||||
- Dateiendungen müssen in `Erlaubte Datei-Endungen` enthalten sein
|
||||
- neue Dateien erscheinen sofort nach manuellem Scan
|
||||
- mit Polling auch automatisch
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Erlaubte Datei-Endungen`
|
||||
- `Auto-Scan (Polling)`
|
||||
- `Polling-Intervall (Sekunden)`
|
||||
|
||||
## Joberstellung aus Explorer-Auswahl
|
||||
|
||||
Es gibt zwei Audio-Strategien:
|
||||
|
||||
1. `Ein Job pro Datei`
|
||||
2. `Gemeinsamer Job (Audio)`
|
||||
|
||||
Verhalten:
|
||||
|
||||
- Video und ISO werden immer als Einzeljobs angelegt
|
||||
- Audio kann als gemeinsamer Album-/Ordner-Job erzeugt werden
|
||||
|
||||
Shared-Audio-Job:
|
||||
|
||||
- hält mehrere `inputPaths`
|
||||
- kann vor dem Start erweitert oder reduziert werden
|
||||
|
||||
## Job-Konfiguration
|
||||
|
||||
Vor dem Start je Job einstellbar:
|
||||
|
||||
- `converterMediaType`
|
||||
- `outputFormat`
|
||||
- Metadaten
|
||||
- Track-/Titelwahl bei Video/ISO
|
||||
- User-Preset und HandBrake-Preset bei Video
|
||||
- Pre-/Post-Encode-Skripte und Ketten
|
||||
|
||||
Spezifika:
|
||||
|
||||
- Video/ISO durchlaufen eine Review ähnlich zum Disc-Flow
|
||||
- Audio wird direkt über Format und Audio-Optionen konfiguriert
|
||||
|
||||
## Ausgabe-Pfade
|
||||
|
||||
### Video / ISO
|
||||
|
||||
- Zielordner: `Converter Ausgabe (Video)`
|
||||
- Dateiname: `Output-Template (Video)`
|
||||
- Endung: aus dem gewählten Ausgabeformat
|
||||
|
||||
### Audio
|
||||
|
||||
- Zielordner: `Converter Ausgabe (Audio)`
|
||||
- Dateiname/Ordner: `Output-Template (Audio)`
|
||||
- bei Shared- oder Folder-Jobs entsteht eine Ordnerstruktur mit mehreren Track-Dateien
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Converter Ausgabe (Video)`
|
||||
- `Converter Ausgabe (Audio)`
|
||||
- `Output-Template (Video)`
|
||||
- `Output-Template (Audio)`
|
||||
- `Eigentümer Converter Video Output-Ordner`
|
||||
- `Eigentümer Converter Audio Output-Ordner`
|
||||
|
||||
## Queue und Start
|
||||
|
||||
Converter-Jobs laufen über dieselbe globale Pipeline-Queue wie Ripper und Audiobooks.
|
||||
|
||||
Das bedeutet:
|
||||
|
||||
- sofortiger Start, wenn Limits frei sind
|
||||
- sonst Einreihung in die Warteschlange
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Parallele Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
|
||||
## Typische Einsatzfälle
|
||||
|
||||
- vorhandene MKV/MP4-Dateien mit neuem Preset encodieren
|
||||
- ISO-Dateien in einen Video-Workflow überführen
|
||||
- Musikdateien gesammelt in einen Audio-Ordner-Job packen
|
||||
- Medien auf anderem Storage per Upload oder Dateibaum vorbereiten
|
||||
|
||||
## Abgrenzung zu Audiobooks
|
||||
|
||||
- `Converter`: beliebige Audio-, Video- und ISO-Dateien
|
||||
- `Audiobooks`: spezialisierter AAX-Workflow mit Activation Bytes, Kapitelmetadaten und Kapiteltemplates
|
||||
@@ -1,124 +0,0 @@
|
||||
# Dashboard
|
||||
|
||||
Das Dashboard ist die **Betriebszentrale** für laufende Jobs.
|
||||
|
||||
## Aufbau der Seite
|
||||
|
||||
Die Bereiche erscheinen in dieser Reihenfolge:
|
||||
|
||||
1. `Hardware Monitoring`
|
||||
2. `Job Queue`
|
||||
3. `Skript- / Cron-Status`
|
||||
4. `Job Übersicht`
|
||||
5. `Disk-Information`
|
||||
|
||||
---
|
||||
|
||||
## 1) Hardware Monitoring
|
||||
|
||||
Zeigt live:
|
||||
|
||||
- CPU (gesamt + optional pro Kern)
|
||||
- RAM
|
||||
- GPU-Auslastung/Temperatur/VRAM
|
||||
- freien Speicher in den konfigurierten Pfaden
|
||||
|
||||
Wichtig für den Betrieb:
|
||||
|
||||
- Hohe Speicherauslastung oder fast volle Zielpfade früh erkennen
|
||||
- über `Settings` aktivierbar/deaktivierbar (`Hardware Monitoring aktiviert`)
|
||||
|
||||
## 2) Job Queue
|
||||
|
||||
Zwei Spalten:
|
||||
|
||||
- `Laufende Jobs`
|
||||
- `Warteschlange`
|
||||
|
||||
Mögliche Aktionen:
|
||||
|
||||
- Queue per Drag-and-Drop umsortieren
|
||||
- Queue-Job entfernen (`X`)
|
||||
- zusätzliche Queue-Elemente einfügen (`+`):
|
||||
- Skript
|
||||
- Skriptkette
|
||||
- Wartezeit
|
||||
|
||||
Hinweis:
|
||||
|
||||
- `Parallel` zeigt den Wert aus `Settings` -> `Parallele Jobs`.
|
||||
|
||||
## 3) Skript- / Cron-Status
|
||||
|
||||
Zeigt:
|
||||
|
||||
- aktive Ausführungen (Skripte, Ketten, Cron)
|
||||
- zuletzt abgeschlossene Ausführungen
|
||||
|
||||
Mögliche Aktionen:
|
||||
|
||||
- laufende Ketten: `Nächster Schritt`
|
||||
- laufende Einträge: `Abbrechen`
|
||||
- Historie der Aktivitäten: `Liste leeren`
|
||||
|
||||
## 4) Job Übersicht
|
||||
|
||||
Kompakte Jobliste mit Status, Fortschritt, ETA. Klick auf einen Job klappt die Detailsteuerung auf.
|
||||
|
||||
Im aufgeklappten Zustand erscheint die Karte `Pipeline-Status` mit allen zustandsabhängigen Aktionen.
|
||||
|
||||
### Zustandsabhängige Hauptaktionen
|
||||
|
||||
| Zustand | Typische Aktion |
|
||||
|---|---|
|
||||
| `Medium erkannt` / `Bereit` | `Analyse starten` |
|
||||
| `Metadatenauswahl` | `Metadaten öffnen` |
|
||||
| `Warte auf Auswahl` | Playlist wählen und `Playlist übernehmen` |
|
||||
| `Startbereit` | `Job starten` |
|
||||
| `Bereit zum Encodieren` | Tracks/Skripte prüfen, dann `Encoding starten` |
|
||||
| laufend (`Analyse`/`Rippen`/`Encodieren`) | `Abbrechen` |
|
||||
| `Fehler` / `Abgebrochen` | `Retry Rippen`, `Disk-Analyse neu starten` |
|
||||
|
||||
Zusätzlich je nach Job:
|
||||
|
||||
- `Review neu starten`
|
||||
- `Encode neu starten`
|
||||
- `Aus Queue löschen`
|
||||
|
||||
### Titel-/Spurprüfung (`Bereit zum Encodieren`)
|
||||
|
||||
Im selben Block siehst du:
|
||||
|
||||
- Auswahl des Encode-Titels
|
||||
- Audio-/Subtitle-Trackauswahl
|
||||
- User-Preset-Auswahl
|
||||
- Pre-/Post-Encode-Skripte und Ketten
|
||||
- Preview des finalen HandBrakeCLI-Befehls
|
||||
|
||||
## 5) Disk-Information
|
||||
|
||||
Zeigt aktuelles Laufwerk und Disc-Metadaten (`Pfad`, `Modell`, `Disc-Label`, `Mount`).
|
||||
|
||||
Aktionen:
|
||||
|
||||
- `Laufwerk neu lesen`
|
||||
- `Disk neu analysieren`
|
||||
- `Metadaten-Modal öffnen`
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Dialoge im Dashboard
|
||||
|
||||
### Metadaten auswählen
|
||||
|
||||
- OMDb-Suche + Ergebnisliste
|
||||
- manuelle Eingabe als Fallback
|
||||
- `Auswahl übernehmen` startet den nächsten Pipeline-Schritt
|
||||
|
||||
### Abbruch-Bereinigung
|
||||
|
||||
Nach Abbruch kann Ripster optional fragen, ob erzeugte RAW- oder Movie-Dateien gelöscht werden sollen.
|
||||
|
||||
### Queue-Eintrag einfügen
|
||||
|
||||
Erstellt gezielt einen Skript-, Ketten- oder Warte-Eintrag an einer bestimmten Queue-Position.
|
||||
+21
-15
@@ -1,29 +1,35 @@
|
||||
# Database (Expert)
|
||||
|
||||
`/database` ist eine erweiterte Ansicht für Power-User und Recovery-Fälle.
|
||||
`/database` ist die Recovery- und Expertensicht für Fälle, in denen Dateisystem und Historie nicht mehr sauber zusammenpassen.
|
||||
|
||||
## Zugriff
|
||||
|
||||
- Route direkt aufrufen: `/database`
|
||||
- nicht Teil der Standard-Navigation
|
||||
- direkte Route: `/database`
|
||||
- nicht Teil der Standardnavigation
|
||||
|
||||
## Bereiche
|
||||
## Bereich `Gefundene RAW-Einträge`
|
||||
|
||||
### `Gefundene RAW-Einträge`
|
||||
|
||||
Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben.
|
||||
Dieser Bereich listet RAW-Verzeichnisse ohne zugeordneten Historie-Job.
|
||||
|
||||
Aktionen:
|
||||
|
||||
- `RAW prüfen` (Scan der konfigurierten RAW-Pfade)
|
||||
- `Job anlegen` (Orphan-RAW in Historie importieren)
|
||||
- `RAW prüfen`: scannt konfigurierte RAW-Wurzeln
|
||||
- `Job anlegen`: erstellt aus einem orphan RAW einen neuen Historie-Job
|
||||
|
||||
## Typischer Einsatz
|
||||
## Was bei `Job anlegen` passiert
|
||||
|
||||
- nach manuellen Dateioperationen
|
||||
- nach Migrationen oder Recovery
|
||||
- wenn RAW-Dateien vorhanden sind, aber kein Historieneintrag existiert
|
||||
- RAW-Pfad wird als Importquelle registriert
|
||||
- Job wird in die Historie geschrieben
|
||||
- anschließende Analyse läuft als RAW-basierter Einstieg (ohne klassischen Disc-Read)
|
||||
|
||||
## Vorsicht
|
||||
Typischer Einsatz:
|
||||
|
||||
Diese Seite erlaubt Eingriffe mit direkter Auswirkung auf Datenbestand und Historie. Vor Lösch- oder Importaktionen Pfade und Zieljob sorgfältig prüfen.
|
||||
- manuelle Dateioperationen außerhalb der UI
|
||||
- Migrationen
|
||||
- Wiederherstellung nach abgebrochenen Läufen
|
||||
|
||||
## Sicherheits- und Betriebsregeln
|
||||
|
||||
- Aktionen wirken direkt auf produktive Historie/Dateipfade.
|
||||
- Vor Import oder Löschung immer Pfad und Ziel prüfen.
|
||||
- Diese Seite nur für Recovery-Szenarien nutzen, nicht für normalen Tagesbetrieb.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Downloads
|
||||
|
||||
Auf `Downloads` werden ZIP-Archive für Historie-Artefakte erstellt und bereitgestellt.
|
||||
|
||||
## Was heruntergeladen werden kann
|
||||
|
||||
Ein Download-Eintrag referenziert immer einen Historie-Job und genau ein Ziel:
|
||||
|
||||
- `RAW`
|
||||
- `Output`
|
||||
|
||||
Erzeugt wird ein ZIP-Archiv im Download-Verzeichnis des Backends.
|
||||
|
||||
## Statusmodell
|
||||
|
||||
| Status | Bedeutung |
|
||||
|---|---|
|
||||
| `queued` | Eintrag erstellt, wartet auf Verarbeitung |
|
||||
| `processing` | ZIP wird gerade gebaut |
|
||||
| `ready` | Archiv fertig, Download-Link aktiv |
|
||||
| `failed` | Erzeugung oder Zugriff fehlgeschlagen |
|
||||
|
||||
## Eintrag anlegen
|
||||
|
||||
Startpunkt ist die `Historie`:
|
||||
|
||||
1. Job öffnen.
|
||||
2. Download einreihen.
|
||||
3. Ziel (`RAW` oder `Output`) wählen.
|
||||
4. Status in `Downloads` verfolgen.
|
||||
|
||||
## Wichtige Laufzeitdetails
|
||||
|
||||
- Wenn bereits ein passendes, aktuelles Archiv existiert, kann der Eintrag wiederverwendet werden.
|
||||
- Nach Backend-Neustart werden unterbrochene `queued`/`processing`-Einträge geprüft und fortgesetzt.
|
||||
- Fehlt eine erwartete ZIP-Datei bei `ready`, wird der Eintrag auf `failed` gesetzt.
|
||||
|
||||
## Download auslösen
|
||||
|
||||
Bei Status `ready`:
|
||||
|
||||
- Download-Button lädt die ZIP direkt aus `/api/downloads/:id/file`.
|
||||
|
||||
## Einträge entfernen
|
||||
|
||||
`Löschen` entfernt:
|
||||
|
||||
- Queue-Metadaten
|
||||
- zugehörige Archivdatei
|
||||
|
||||
Nicht erlaubt:
|
||||
|
||||
- Löschen während `queued`/`processing`
|
||||
|
||||
## Echtzeitaktualisierung
|
||||
|
||||
Die Seite reagiert auf `DOWNLOADS_UPDATED` per WebSocket.
|
||||
Dadurch sind Statuswechsel ohne Reload sichtbar.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Hardware
|
||||
|
||||
Die Seite `Hardware` ist die ausführliche Detailansicht für das Live-Monitoring aus dem `Ripper`.
|
||||
|
||||
## Was die Seite zeigt
|
||||
|
||||
- aktuelle CPU-Auslastung
|
||||
- RAM-Auslastung
|
||||
- GPU-Auslastung und VRAM, falls vorhanden
|
||||
- Temperaturen
|
||||
- Verlaufshistorie für Auslastung und Temperatur
|
||||
- Zeitfenster für `1h`, `6h`, `24h`, `4d`
|
||||
|
||||
## Live-Bereich
|
||||
|
||||
Oben zeigt die Seite:
|
||||
|
||||
- ob Monitoring aktiv ist
|
||||
- aktuelles Polling-Intervall
|
||||
- Zeitpunkt der letzten Messung
|
||||
|
||||
Wenn Monitoring deaktiviert ist, führt die Seite direkt zu `Settings`.
|
||||
|
||||
## Historie
|
||||
|
||||
Die Historie lädt Messpunkte aus dem Backend und zeigt:
|
||||
|
||||
- Auslastung als Linienchart
|
||||
- Temperatur als Linienchart
|
||||
- umschaltbare Serien für CPU, RAM und GPU
|
||||
|
||||
Das ist besonders nützlich bei:
|
||||
|
||||
- mehreren parallelen Encodes
|
||||
- Fehlersuche bei Hitzespitzen
|
||||
- Abschätzung, ob weitere Jobs sinnvoll gestartet werden können
|
||||
|
||||
## Relevante Settings
|
||||
|
||||
- `Hardware Monitoring aktiviert`
|
||||
- `Hardware Monitoring Intervall (ms)`
|
||||
|
||||
Außerdem sinnvoll:
|
||||
|
||||
- `Externe Speicher`, damit Speicherstände im `Ripper` sinnvoll angezeigt werden
|
||||
+67
-41
@@ -1,62 +1,88 @@
|
||||
# Historie
|
||||
|
||||
Die Seite `Historie` ist für Suche, Prüfung und Nachbearbeitung bestehender Jobs.
|
||||
`Historie` ist die Kontroll- und Nachbearbeitungsoberfläche für alle bereits angelegten Jobs.
|
||||
|
||||
## Hauptansicht
|
||||
|
||||
Filter und Werkzeuge:
|
||||
Filter und Arbeitsmittel:
|
||||
|
||||
- Suche (Titel/IMDb)
|
||||
- Status-Filter
|
||||
- Medium-Filter (`Blu-ray`, `DVD`, `Sonstiges`)
|
||||
- Suche
|
||||
- Statusfilter
|
||||
- Mediumfilter
|
||||
- Sortierung
|
||||
- Listen-/Grid-Layout
|
||||
|
||||
Jeder Eintrag zeigt:
|
||||
Jeder Eintrag zeigt auf einen Blick:
|
||||
|
||||
- Poster, Titel, Jahr, IMDb
|
||||
- Medium-Indikator
|
||||
- Status
|
||||
- Start/Ende
|
||||
- Verfügbarkeit von RAW/Movie
|
||||
- Ratings (wenn OMDb-Daten vorhanden)
|
||||
- Titel, Jahr, Poster oder Cover
|
||||
- Status und Zeitstempel
|
||||
- RAW- und Output-Verfügbarkeit
|
||||
- Medien- und Containerhinweise
|
||||
|
||||
Klick auf einen Eintrag öffnet die Detailansicht.
|
||||
## Detaildialog
|
||||
|
||||
---
|
||||
Der Detaildialog bündelt:
|
||||
|
||||
## Job-Detaildialog
|
||||
- Metadaten
|
||||
- Jobstatus und Fehlertexte
|
||||
- RAW- und Output-Pfade
|
||||
- Encode-Plan und Trackauswahl
|
||||
- ausgeführte Kommandos
|
||||
- Prozesslog
|
||||
- verfügbare Folgeaktionen
|
||||
|
||||
Bereiche:
|
||||
Bei Seriencontainern und Multipart-Jobs wird container- und child-orientiert dargestellt.
|
||||
|
||||
- Film-Infos + OMDb-Details
|
||||
- Job-Infos (Status, Pfade, Erfolgsflags, Fehler)
|
||||
- hinterlegte Encode-Auswahl
|
||||
- ausgeführter HandBrake-Befehl
|
||||
- strukturierte JSON-Blöcke (OMDb/MakeMKV/MediaInfo/EncodePlan/HandBrake)
|
||||
- Log-Ladefunktionen (`Tail`, `Vollständig`)
|
||||
## Nachbearbeitungsaktionen
|
||||
|
||||
## Typische Aktionen im Detaildialog
|
||||
| Aktion | Typischer Einsatz | Technische Wirkung |
|
||||
|---|---|---|
|
||||
| `TMDb neu zuweisen` | falsches Match, anderer Film, andere Staffel | Metadaten werden neu geschrieben; Poster, Jahr und ggf. Ordnername werden best effort aktualisiert |
|
||||
| `Review neu starten` | neue Track-/Titelbewertung nötig | Review wird aus vorhandenem RAW neu aufgebaut |
|
||||
| `RAW neu encodieren` | neuer Encode mit vorhandenem RAW | startet Encode ohne neuen Disc-Rip |
|
||||
| `Encode neu starten` | gleiche bestätigte Auswahl erneut fahren | letzter bestätigter Plan wird geladen |
|
||||
| `Retry Rippen` | Rip oder Analyse erneut anstoßen | neuer Rip-Lauf inklusive RAW-Phase |
|
||||
|
||||
- `OMDb neu zuordnen`
|
||||
- `Encode neu starten`
|
||||
- `Review neu starten`
|
||||
- `RAW neu encodieren`
|
||||
- `RAW löschen`, `Movie löschen`, `Beides löschen`
|
||||
- `Historieneintrag löschen`
|
||||
- bei Queue-Lock: `Aus Queue löschen`
|
||||
Relevante Settings:
|
||||
|
||||
## Wann welche Aktion?
|
||||
- `Encode-Neustart: unvollständige Ausgabe löschen`
|
||||
- `NFO nach Encode erzeugen`
|
||||
|
||||
| Ziel | Aktion |
|
||||
|---|---|
|
||||
| Metadaten korrigieren | `OMDb neu zuordnen` |
|
||||
| mit gleicher bestätigter Auswahl neu encodieren | `Encode neu starten` |
|
||||
| Titel-/Spurprüfung komplett neu berechnen | `Review neu starten` |
|
||||
| aus vorhandenem RAW erneut encodieren | `RAW neu encodieren` |
|
||||
| Speicher freigeben | Dateilöschaktionen |
|
||||
## Löschaktionen und Vorschau
|
||||
|
||||
## Logs
|
||||
Vor dem endgültigen Löschen zeigt Ripster eine Vorschau mit:
|
||||
|
||||
- `Tail laden (800)` für schnelle Fehleranalyse
|
||||
- `Vollständiges Log laden` für vollständige Nachverfolgung
|
||||
- RAW-Kandidaten
|
||||
- Movie-/Episode-Kandidaten
|
||||
- Related-Scope für Container, Kinder und verbundene Jobs
|
||||
|
||||
Wichtige Punkte:
|
||||
|
||||
- Pfade sind selektierbar
|
||||
- bei mehreren RAW-Kandidaten muss mindestens ein RAW-Pfad gewählt werden
|
||||
- Schutzlogik verhindert Löschungen außerhalb erlaubter Root-Verzeichnisse
|
||||
|
||||
## Serien- und Multipart-Sicht
|
||||
|
||||
### Serien
|
||||
|
||||
- Container zeigt den aggregierten Zustand der Child-Jobs
|
||||
- Disk- und Episodenaktionen können child-spezifisch ausgeführt werden
|
||||
- `Im Ripper öffnen` hilft bei offenen Reviews
|
||||
|
||||
### Multipart
|
||||
|
||||
- gemeinsamer Container mit Disc-Childs
|
||||
- Disc-Nummern bleiben pro Child nachvollziehbar
|
||||
- Delete-Preview kann Related-Pfade gemeinsam auflösen
|
||||
|
||||
## Logs sinnvoll nutzen
|
||||
|
||||
- `Tail laden` für schnellen Fehlerkontext
|
||||
- `Vollständiges Log laden` für tiefe Analyse
|
||||
|
||||
Empfehlung:
|
||||
|
||||
1. erst den Tail prüfen
|
||||
2. dann die Stelle im Log eingrenzen
|
||||
3. anschließend die passende Wiederanlauf-Aktion wählen
|
||||
|
||||
+33
-15
@@ -1,24 +1,42 @@
|
||||
# GUI-Seiten
|
||||
|
||||
Ripster hat drei Hauptseiten in der Navigation plus eine Expert-Seite.
|
||||
Dieses Kapitel ist das Bedienhandbuch für die Oberflächen im Alltag.
|
||||
|
||||
## Seitenüberblick
|
||||
|
||||
| Seite | Zweck |
|
||||
|---|---|
|
||||
| [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos |
|
||||
| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
|
||||
| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten |
|
||||
| [Database (Expert)](database.md) | tabellarische Rohsicht inkl. Orphan-RAW-Import |
|
||||
| Seite | Rolle im Betrieb | Typischer Einsatz |
|
||||
|---|---|---|
|
||||
| [Ripper](ripper.md) | Live-Steuerung der Disc-Pipeline | Disc erkennen, Analyse starten, Entscheidungen treffen, Queue steuern |
|
||||
| [Hardware](hardware.md) | Detailansicht für Live-Monitoring | CPU/RAM/GPU und Verlauf prüfen |
|
||||
| [Audiobooks](audiobooks.md) | AAX-Spezialflow | Upload, Kapitelprüfung und Encode von Audiobooks |
|
||||
| [Settings](settings.md) | Konfiguration und Automatisierung | Pfade, Tools, Queue, Presets, Skripte, Cron |
|
||||
| [Historie](history.md) | Nachbearbeitung und Kontrolle | Logs, Re-Encode, TMDb-Neuzuordnung, Löschvorschau |
|
||||
| [Converter](converter.md) | dateibasierte Konvertierung | Video/Audio/ISO ohne Disc-Rip verarbeiten |
|
||||
| [Downloads](downloads.md) | ZIP-Exports | RAW/Output aus der Historie bereitstellen |
|
||||
| [Database (Expert)](database.md) | Recovery-Sicht | orphan RAW finden, prüfen und importieren |
|
||||
| [TMDb Migration](tmdb-migration.md) | Sonderseite für Altbestände | alte Jobs gesammelt auf TMDb umstellen |
|
||||
|
||||
## Empfohlene Nutzung im Alltag
|
||||
## Empfohlener Tagesablauf
|
||||
|
||||
1. **Start eines neuen Jobs:** `Dashboard`
|
||||
2. **Regeln/Automatisierung anpassen:** `Settings`
|
||||
3. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
|
||||
4. **Sonderfälle/Recovery:** `Database`
|
||||
1. `Ripper`, `Converter` oder `Audiobooks`: neue Arbeit starten
|
||||
2. `Historie`: Ergebnisse kontrollieren und ggf. nachbearbeiten
|
||||
3. `Downloads`: Artefakte für externe Übergabe vorbereiten
|
||||
4. `Settings`: Regeln, Presets oder Limits nur kontrolliert anpassen
|
||||
5. `Hardware`: bei Lastspitzen oder Batch-Läufen den Verlauf prüfen
|
||||
|
||||
## Hinweise zur Navigation
|
||||
## Navigation
|
||||
|
||||
- `Dashboard`, `Settings`, `Historie` sind direkt in der Kopfnavigation.
|
||||
- `Database` ist als Expert-Route verfügbar: `/database`.
|
||||
Direkt in der Top-Navigation:
|
||||
|
||||
- `Ripper`
|
||||
- `Converter`
|
||||
- `Audiobooks`
|
||||
- `Settings`
|
||||
- `Historie`
|
||||
- `Downloads`
|
||||
|
||||
Zusatzansichten:
|
||||
|
||||
- `Hardware` unter `/hardware`
|
||||
- `Database` unter `/database` und im Expertenmodus zusätzlich in der Navigation
|
||||
- `TMDb Migration` unter `/tmdb-migration` als Direktlink
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Ripper
|
||||
|
||||
Die Seite `Ripper` ist die Live-Steuerung der Disc-Pipeline. Hier laufen Disc-Erkennung, Queue, manuelle Entscheidungen, Review und aktive Jobs zusammen.
|
||||
|
||||
## Seitenaufbau
|
||||
|
||||
Die wichtigsten Bereiche:
|
||||
|
||||
1. `Hardware`
|
||||
2. `Job Queue`
|
||||
3. `Skript- / Cron-Status`
|
||||
4. `Job Übersicht`
|
||||
5. `Disk-Information`
|
||||
|
||||
## 1. Hardware
|
||||
|
||||
Die Hardware-Karte zeigt live:
|
||||
|
||||
- CPU
|
||||
- RAM
|
||||
- GPU, falls vorhanden
|
||||
- freie Speicherstände der konfigurierten Pfade
|
||||
|
||||
Aktionen:
|
||||
|
||||
- Klick auf das Hardware-Symbol öffnet die Seite [Hardware](hardware.md)
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Hardware Monitoring aktiviert`
|
||||
- `Hardware Monitoring Intervall (ms)`
|
||||
- `Externe Speicher`
|
||||
|
||||
## 2. Job Queue
|
||||
|
||||
Es gibt zwei operative Zonen:
|
||||
|
||||
- laufende Jobs
|
||||
- Warteschlange
|
||||
|
||||
Mögliche Queue-Einträge:
|
||||
|
||||
- normale Job-Starts
|
||||
- `Retry Rippen`
|
||||
- `RAW neu encodieren`
|
||||
- `Encode neu starten`
|
||||
- `Review neu starten`
|
||||
- `Audio CD starten`
|
||||
- `Skript`
|
||||
- `Skriptkette`
|
||||
- `Warten`
|
||||
|
||||
Wichtige Regeln:
|
||||
|
||||
- Reihenfolge ist per Drag-and-Drop änderbar
|
||||
- `Warten` blockiert nachfolgende Starts, bis keine aktive Verarbeitung mehr läuft
|
||||
- Queue-Einträge können auch ohne Medienjob existieren
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Parallele Jobs`
|
||||
- `Max. parallele Audio CD Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
- `Audio CDs: Queue-Reihenfolge überspringen`
|
||||
|
||||
## 3. Skript- / Cron-Status
|
||||
|
||||
Zeigt Runtime-Aktivitäten:
|
||||
|
||||
- laufende Skripte
|
||||
- laufende Ketten
|
||||
- Cron-Ausführungen
|
||||
- kürzlich abgeschlossene Aktivitäten
|
||||
|
||||
Aktionen:
|
||||
|
||||
- `Nächster Schritt` bei Ketten
|
||||
- `Abbrechen`
|
||||
- `Liste leeren`
|
||||
|
||||
## 4. Job Übersicht
|
||||
|
||||
Die Jobliste ist der zentrale Arbeitsbereich. Ein Klick öffnet den jeweiligen `Pipeline-Status`.
|
||||
|
||||
### Zustandsabhängige Hauptaktionen
|
||||
|
||||
| Zustand | Typische Aktion | Ergebnis |
|
||||
|---|---|---|
|
||||
| `Medium erkannt` | `Analyse starten` | MakeMKV-Analyse beginnt |
|
||||
| `Metadatenauswahl` | `Metadaten öffnen` | TMDb-Dialog für Film/Serie |
|
||||
| `Warte auf Auswahl` | Entscheidung bestätigen | Flow geht an die passende Stelle weiter |
|
||||
| `Startbereit` | `Job starten` | Rip oder RAW-basierte Prüfung startet |
|
||||
| `Bereit zum Encodieren` | `Encoding starten` | HandBrake startet mit bestätigter Auswahl |
|
||||
| laufend | `Abbrechen` | Job wird gestoppt |
|
||||
| `Fehler` / `Abgebrochen` | `Retry Rippen` | neuer Rip-Anlauf |
|
||||
|
||||
### Was `Warte auf Auswahl` heute bedeuten kann
|
||||
|
||||
Der Status ist nicht nur für eine einzige Entscheidung da. Im aktuellen Stand gibt es drei typische Fälle:
|
||||
|
||||
#### Vorhandenes RAW
|
||||
|
||||
Ripster hat zu den Metadaten bereits ein passendes RAW gefunden. Dann entscheidest du:
|
||||
|
||||
- mit RAW weiterarbeiten
|
||||
- RAW löschen und neu rippen
|
||||
- in passenden Film-Fällen optional Multipart mit Orphan-RAW bilden
|
||||
|
||||
#### Playlist-Auswahl
|
||||
|
||||
Bei mehrdeutigen Blu-rays oder bestimmten DVD-Fällen musst du eine Playlist bestätigen. Die Liste zeigt unter anderem:
|
||||
|
||||
- Playlist-Datei
|
||||
- Titel-ID
|
||||
- Laufzeit
|
||||
- Score
|
||||
- Empfehlung
|
||||
- Segment- und Audio-Vorschau
|
||||
|
||||
Relevante Settings:
|
||||
|
||||
- `Minimale Titellänge (Minuten)`
|
||||
- `TMDb Runtime-Abzug für Playlistfilter (%)`
|
||||
- `Bei manueller Auswahl senden` für PushOver
|
||||
|
||||
#### Titelauswahl / PlayAll-Grenzfall
|
||||
|
||||
Wenn Ripster mehrere HandBrake-Titel als plausibel erkannt hat, musst du einen oder mehrere Titel bestätigen. Bei Serien kann das der Grenzfall `PlayAll oder Doppelfolge?` sein.
|
||||
|
||||
## 5. Review-Bereich (`Bereit zum Encodieren`)
|
||||
|
||||
Hier triffst du die eigentliche Encode-Entscheidung.
|
||||
|
||||
Du legst fest:
|
||||
|
||||
- welchen Titel Ripster encodieren soll
|
||||
- welche Audio-Spuren übernommen werden
|
||||
- welche Untertitel übernommen, erzwungen oder eingebrannt werden
|
||||
- welches User-Preset oder HandBrake-Preset verwendet wird
|
||||
- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen
|
||||
|
||||
Wichtig:
|
||||
|
||||
- ohne diese Bestätigung startet kein produktiver Encode
|
||||
- bei Multipart-Locks können Einstellungen von einer Referenz-Disc übernommen und gesperrt sein
|
||||
|
||||
### Welche Settings hier direkt sichtbar werden
|
||||
|
||||
- `HandBrake Preset`
|
||||
- `HandBrake Extra Args`
|
||||
- `Review Audio-Sprachen (...)`
|
||||
- `Review UT-Sprachen (...)`
|
||||
- `Ausgabeformat`
|
||||
- Standard-Zuordnungen aus `Settings -> Encode-Presets`
|
||||
|
||||
## RAW-Ordnerphasen
|
||||
|
||||
Der Ripper arbeitet mit drei RAW-Zuständen:
|
||||
|
||||
- `Incomplete_...` während eines unvollständigen Rips
|
||||
- `Rip_Complete_...` nach erfolgreichem Rip, vor dem finalen Encode
|
||||
- ohne Prefix nach erfolgreichem Encode
|
||||
|
||||
Das ist wichtig für:
|
||||
|
||||
- Recovery
|
||||
- RAW-Wiederverwendung
|
||||
- Delete-Preview in `Historie`
|
||||
|
||||
## Disk-Information
|
||||
|
||||
Zeigt aktuelle Laufwerksdaten:
|
||||
|
||||
- Device-Pfad
|
||||
- Modell
|
||||
- Disc-Label
|
||||
- Mount-Informationen
|
||||
|
||||
Aktionen:
|
||||
|
||||
- `Laufwerk neu lesen`
|
||||
- `Disk neu analysieren`
|
||||
- Metadaten-Dialog öffnen
|
||||
|
||||
Hinweis:
|
||||
|
||||
- `Laufwerk neu lesen` ist nicht mehr an einen festen Pipeline-State gebunden
|
||||
|
||||
## Wichtige Dialoge
|
||||
|
||||
### Metadaten auswählen
|
||||
|
||||
Im Dialog bestätigst du Film- oder Serienmetadaten aus TMDb.
|
||||
|
||||
Sonderfälle:
|
||||
|
||||
- Film-Duplikat: `Übernahme erzwingen` oder `Multipart movie`
|
||||
- Serie: `Disc-Nummer` ist Pflicht
|
||||
|
||||
### Vorhandenes RAW erkannt
|
||||
|
||||
Wenn zu den Metadaten bereits RAW existiert, fordert Ripster eine explizite Entscheidung:
|
||||
|
||||
- vorhandenes RAW verwenden
|
||||
- RAW verwerfen/löschen und neu rippen
|
||||
|
||||
### Queue-Eintrag einfügen
|
||||
|
||||
Du kannst gezielt ab Position einfügen:
|
||||
|
||||
- Skript
|
||||
- Skriptkette
|
||||
- Wartezeit
|
||||
|
||||
### Audible Activation Bytes eintragen
|
||||
|
||||
Wenn für eine AAX-Datei keine Activation Bytes verfügbar sind, öffnet Ripster einen Dialog zur manuellen Eingabe.
|
||||
|
||||
- Format: genau 8 Hex-Zeichen, z. B. `1a2b3c4d`
|
||||
- Speicherung: lokal im Activation-Bytes-Cache
|
||||
- Wirkung: der Audiobook-Flow kann anschließend fortgesetzt werden
|
||||
|
||||
## Siehe auch
|
||||
|
||||
- [Hardware](hardware.md)
|
||||
- [Settings](settings.md)
|
||||
- [Workflows aus Nutzersicht](../workflows/index.md)
|
||||
+240
-49
@@ -1,92 +1,283 @@
|
||||
# Settings
|
||||
|
||||
Die Seite `Settings` steuert Konfiguration und Automatisierung.
|
||||
Die Seite `Settings` ist das Kontrollzentrum für Laufzeitverhalten, Standardwerte und Automatisierung. Alles, was spätere Jobs automatisch tun oder vorschlagen, wird hier vorbereitet.
|
||||
|
||||
## Tabs im Überblick
|
||||
Für die detaillierte Feld-für-Feld-Erklärung jeder einzelnen Einstellung nutze die Seite [Einstellungsreferenz](../configuration/settings-reference.md). Dort ist jedes sichtbare Feld aus der GUI mit Wirkung, Workflow-Bezug und typischem Einsatz beschrieben.
|
||||
|
||||
## Tab-Überblick
|
||||
|
||||
| Tab | Zweck |
|
||||
|---|---|
|
||||
| `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) |
|
||||
| `Scripte` | einzelne Bash-Skripte verwalten und testen |
|
||||
| `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen |
|
||||
| `Encode-Presets` | benutzerdefinierte Presets für das Review im Dashboard |
|
||||
| `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung |
|
||||
|
||||
---
|
||||
| `Konfiguration` | alle fachlichen Einstellungen aus der GUI |
|
||||
| `Scripte` | einzelne Bash-Skripte anlegen, testen und sortieren |
|
||||
| `Skriptketten` | mehrstufige Abläufe aus Skript- und Warte-Schritten bauen |
|
||||
| `Encode-Presets` | eigene Video-Presets und Standard-Zuordnungen pflegen |
|
||||
| `Cronjobs` | zeitgesteuerte Ausführung von Skripten oder Ketten |
|
||||
|
||||
## Tab `Konfiguration`
|
||||
|
||||
Wichtiges Bedienmuster:
|
||||
### Bedienlogik
|
||||
|
||||
1. Werte ändern
|
||||
2. `Änderungen speichern`
|
||||
3. bei Bedarf `Änderungen verwerfen` oder `Neu laden`
|
||||
1. Felder ändern
|
||||
2. `Änderungen speichern` klicken
|
||||
3. erst dann gelten die neuen Werte für künftige Starts, Reviews oder Scheduler
|
||||
|
||||
Zusätzlich:
|
||||
Ausnahmen:
|
||||
|
||||
- `PushOver Test` sendet eine Testnachricht
|
||||
- Änderungen werden erst nach Speichern wirksam
|
||||
- Tool-Preset-Felder bieten HandBrake-Presetauswahl direkt im Formular
|
||||
- `Expertenmodus` wird sofort gespeichert
|
||||
- Buttons wie `PushOver Test`, `Coverarts prüfen` oder der MakeMKV-Betakey-Import lösen direkte Aktionen aus
|
||||
|
||||
### Toolbar-Aktionen
|
||||
|
||||
- `Änderungen speichern`: schreibt nur geänderte Felder
|
||||
- `Änderungen verwerfen`: setzt den Formularzustand zurück
|
||||
- `Neu laden`: lädt Schema und Werte neu
|
||||
- `PushOver Test`: sendet eine Testnachricht mit den aktuellen PushOver-Werten
|
||||
- `Coverarts prüfen`: startet den Coverart-Recovery-Lauf sofort
|
||||
|
||||
### Was die Kategorien im Alltag steuern
|
||||
|
||||
#### Benachrichtigungen
|
||||
|
||||
Hier steuerst du zwei Dinge:
|
||||
|
||||
- ob und wohin Ripster PushOver-Nachrichten sendet
|
||||
- bei welchen Ereignissen Ripster überhaupt benachrichtigt
|
||||
|
||||
Wichtig:
|
||||
|
||||
- `PushOver aktiviert` ist der Master-Schalter
|
||||
- `Bei manueller Auswahl senden` ist besonders relevant für Playlist-, RAW- oder Titelentscheidungen
|
||||
- der `Expertenmodus` blendet zusätzliche technische Felder in der gesamten Settings-Seite ein
|
||||
|
||||
#### Laufwerk
|
||||
|
||||
Diese Kategorie beeinflusst, wie Ripster optische Laufwerke findet und überwacht.
|
||||
|
||||
Typische Fragen:
|
||||
|
||||
- Soll Ripster Laufwerke automatisch erkennen?
|
||||
- Soll ein bestimmtes Laufwerk fest an einen MakeMKV-Index gebunden werden?
|
||||
- Soll das Laufwerk nach erfolgreichem Rip automatisch auswerfen?
|
||||
|
||||
Das ist vor allem wichtig bei:
|
||||
|
||||
- mehreren Laufwerken
|
||||
- USB-SATA-Adaptern
|
||||
- Hosts mit instabilen Device-Namen
|
||||
|
||||
#### Logging
|
||||
|
||||
Diese Felder ändern nicht die Job-Logs auf Platte, sondern die Ausgabe im Server-Terminal.
|
||||
|
||||
Damit steuerst du zum Beispiel:
|
||||
|
||||
- ob `start.sh` oder ein Terminal-Run sehr gesprächig sein darf
|
||||
- ob HTTP-Requests im Terminal sichtbar sein sollen
|
||||
- ob DEBUG/INFO/WARN/ERROR dort mitlaufen
|
||||
|
||||
#### Metadaten
|
||||
|
||||
Hier legst du fest, wie Ripster mit TMDb, Covern und NFO-Dateien umgeht.
|
||||
|
||||
Besonders wichtig:
|
||||
|
||||
- ohne `TMDb Read Access Token` funktionieren Film-/Seriensuche und Neu-Zuordnung nur eingeschränkt oder gar nicht
|
||||
- `Film-Sprache` und `Fallback Sprache` bestimmen, welche Titel, Beschreibungen und Staffelinformationen bevorzugt werden
|
||||
- `Coverart-Nachladen aktiv` hilft bei nachträglich fehlenden Postern/Covern
|
||||
|
||||
#### Monitoring
|
||||
|
||||
Steuert das Hardware-Monitoring für:
|
||||
|
||||
- CPU
|
||||
- RAM
|
||||
- GPU
|
||||
- Speicherstände
|
||||
|
||||
Diese Werte erscheinen:
|
||||
|
||||
- kompakt im `Ripper`
|
||||
- ausführlich in der Seite `Hardware`
|
||||
|
||||
#### Pfade
|
||||
|
||||
Hier legst du fest, wohin Ripster schreibt und wie Dateien benannt werden.
|
||||
|
||||
Die Felder steuern:
|
||||
|
||||
- RAW-Verzeichnisse pro Medium
|
||||
- Zielordner für Filme, Serien, CDs, Audiobooks, Converter und Downloads
|
||||
- Eigentümer per `user:gruppe`
|
||||
- Templates für Datei- und Ordnernamen
|
||||
|
||||
Praxisbeispiele:
|
||||
|
||||
- Serien getrennt von Filmen ablegen
|
||||
- RAW auf große HDD, Final-Output auf NAS
|
||||
- Audiobooks kapitelweise in Unterordnern strukturieren
|
||||
|
||||
#### Converter
|
||||
|
||||
Diese Kategorie wirkt nur auf die Seite `Converter`.
|
||||
|
||||
Sie steuert:
|
||||
|
||||
- welche Dateiendungen der Upload und der Verzeichnisscan akzeptieren
|
||||
- ob der Converter-RAW-Baum automatisch neu gescannt wird
|
||||
- wie häufig dieses Polling läuft
|
||||
|
||||
#### Tools
|
||||
|
||||
Diese Kategorie entscheidet über das technische Verhalten der eigentlichen Pipeline.
|
||||
|
||||
Dazu gehören:
|
||||
|
||||
- Pfade/Befehle für externe Tools
|
||||
- MakeMKV-Filter und Rip-Modi
|
||||
- HandBrake-Presets und Extra-Args
|
||||
- Vorauswahl von Audio-/Untertitelsprachen in der Review
|
||||
- Ausgabeformate
|
||||
- Queue- und Parallelitätsregeln
|
||||
- Timeout für Script-Tests
|
||||
|
||||
Für die Nutzer-Workflows besonders relevant:
|
||||
|
||||
- `Minimale Titellänge (Minuten)`
|
||||
- `TMDb Runtime-Abzug für Playlistfilter (%)`
|
||||
- `HandBrake Preset` und `HandBrake Extra Args`
|
||||
- `Review Audio-Sprachen (...)`
|
||||
- `Review UT-Sprachen (...)`
|
||||
- `Parallele Jobs`
|
||||
- `Max. parallele Audio CD Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
|
||||
### Kategorie `Pfade`: Besondere Interaktion
|
||||
|
||||
Die Pfad-Kategorie hat eine eigene Darstellung:
|
||||
|
||||
- Tabelle `Effektive Pfade`: zeigt die aktuell wirklich verwendeten Laufzeitpfade inklusive Fallbacks
|
||||
- Accordion pro Bereich wie `Blu-ray`, `DVD`, `CD / Audio`, `Audiobook`, `Converter`, `Downloads`, `Logs`
|
||||
- Owner-Felder (`*_owner`) erscheinen direkt beim zugehörigen Pfad
|
||||
- Owner-Felder sind deaktiviert, solange der zugehörige Pfad leer ist
|
||||
|
||||
### Spezial-Editoren
|
||||
|
||||
- `Explizite Laufwerke`: Editor für mehrere Laufwerke mit Pfad und MakeMKV-Index
|
||||
- `Externe Speicher`: Liste aus Anzeigename und Pfad für die Speicheranzeige
|
||||
- `Erlaubte Datei-Endungen`: Auswahl der erlaubten Upload-/Scan-Endungen
|
||||
- `MakeMKV Key`: inkl. Betakey-Hinweis und Übernahme-Button
|
||||
|
||||
### Wichtig für Reviews und Workflows
|
||||
|
||||
Die Seite `Settings` bestimmt direkt, was Nutzer später im Workflow sehen:
|
||||
|
||||
- Playlist-Kandidaten werden durch `Minimale Titellänge` und `TMDb Runtime-Abzug ...` beeinflusst
|
||||
- Audio-/Untertitel-Vorauswahlen in `Bereit zum Encodieren` hängen an den Review-Sprachfeldern
|
||||
- automatisch vorgeschlagene Presets hängen an den Standard-Zuordnungen unter `Encode-Presets`
|
||||
- Queue-Verhalten im `Ripper` hängt an den drei Parallelitätsfeldern plus `Audio CDs: Queue-Reihenfolge überspringen`
|
||||
|
||||
## Tab `Scripte`
|
||||
|
||||
Funktionen:
|
||||
|
||||
- Skript anlegen, bearbeiten, löschen
|
||||
- Skript testen (`Test`)
|
||||
- Reihenfolge per Drag-and-Drop
|
||||
- Skript anlegen
|
||||
- Skript bearbeiten
|
||||
- Skript löschen
|
||||
- Skript testen
|
||||
- Reihenfolge per Drag-and-Drop ändern
|
||||
- Favorit markieren
|
||||
|
||||
Praxis:
|
||||
Das Testergebnis zeigt:
|
||||
|
||||
- Reihenfolge ist wichtig, weil ausgewählte Skripte später sequentiell abgearbeitet werden.
|
||||
- Testresultate zeigen Exit-Code, Dauer und stdout/stderr.
|
||||
- Erfolg oder Fehler
|
||||
- Exit-Code
|
||||
- Dauer
|
||||
- stdout/stderr
|
||||
- Timeout, falls `Script-Test Timeout (ms)` greift
|
||||
|
||||
## Tab `Skriptketten`
|
||||
|
||||
Skriptketten bestehen aus Schritten:
|
||||
|
||||
- `Skript`
|
||||
- `Warten (Sekunden)`
|
||||
|
||||
Funktionen:
|
||||
|
||||
- Kette anlegen/bearbeiten/löschen
|
||||
- Kette testen
|
||||
- Reihenfolge der Ketten per Drag-and-Drop
|
||||
- Ketten anlegen, bearbeiten, löschen
|
||||
- Reihenfolge der Ketten ändern
|
||||
- einzelne Ketten testen
|
||||
- Schritte innerhalb der Kette per Drag-and-Drop sortieren
|
||||
|
||||
Im Ketten-Editor:
|
||||
Wichtig:
|
||||
|
||||
- Bausteine links (`Warten`, vorhandene Skripte)
|
||||
- Schritte rechts per Klick oder Drag-and-Drop hinzufügen
|
||||
- Schrittreihenfolge im Canvas ändern
|
||||
- dieselben Skripte können sowohl in Jobs als auch in Ketten und Cronjobs wiederverwendet werden
|
||||
|
||||
## Tab `Encode-Presets`
|
||||
|
||||
Ein Preset bündelt:
|
||||
Hier werden Video-Standards für Disc- und Converter-Workflows verwaltet.
|
||||
|
||||
- optional HandBrake-Preset (`-Z`)
|
||||
- optionale Extra-Args
|
||||
- Medientyp (`Universell`, `Blu-ray`, `DVD`, `Sonstiges`)
|
||||
### Standard-Zuordnungen
|
||||
|
||||
Verwendung:
|
||||
Es gibt vier feste Slots:
|
||||
|
||||
- Diese Presets erscheinen später im Dashboard im Review (`Bereit zum Encodieren`).
|
||||
- `Blu-ray Film`
|
||||
- `Blu-ray Serie`
|
||||
- `DVD Film`
|
||||
- `DVD Serie`
|
||||
|
||||
Wenn für einen Workflow hier ein Default gesetzt ist, erscheint dieses User-Preset später in der Review bereits vorausgewählt.
|
||||
|
||||
### Preset-Inhalt
|
||||
|
||||
Ein User-Preset besteht aus:
|
||||
|
||||
- Name
|
||||
- Medientyp (`all`, `bluray`, `dvd`)
|
||||
- HandBrake-Preset
|
||||
- Extra Args
|
||||
- optionaler Beschreibung
|
||||
|
||||
## Tab `Cronjobs`
|
||||
|
||||
Funktionen:
|
||||
|
||||
- Cronjob anlegen und bearbeiten
|
||||
- Quelle wählen: Skript oder Skriptkette
|
||||
- Cron-Ausdruck validieren
|
||||
- Cronjob anlegen, bearbeiten, löschen
|
||||
- Cron-Ausdruck live validieren
|
||||
- Quelle wählen: `Skript` oder `Skriptkette`
|
||||
- Aktiv- und PushOver-Status pro Job setzen
|
||||
- `Jetzt ausführen`
|
||||
- Logs je Cronjob anzeigen
|
||||
- `Aktiviert` und `Pushover` toggeln
|
||||
- Lauf-Logs öffnen
|
||||
|
||||
Hilfen:
|
||||
Die Runtime-Aktivitäten der laufenden Cronjobs werden zusätzlich live angezeigt.
|
||||
|
||||
- Beispiele für Cron-Ausdrücke direkt im Dialog
|
||||
- Link zu `crontab.guru` im Editor
|
||||
## Expertenbereich: Activation Bytes Cache
|
||||
|
||||
---
|
||||
Dieser Block wird nur im Expertenmodus angezeigt.
|
||||
|
||||
## Empfehlung für stabile Nutzung
|
||||
Er zeigt lokal bekannte AAX-Activation-Bytes mit:
|
||||
|
||||
1. Erst `Konfiguration` sauber setzen
|
||||
2. dann Skripte/Ketten testen
|
||||
3. danach Cronjobs aktivieren
|
||||
- Prüfsumme
|
||||
- Activation Bytes
|
||||
- Speicherzeitpunkt
|
||||
|
||||
Das hilft bei Audiobook-Workflows, wenn dieselbe Datei oder derselbe Hash später erneut auftaucht.
|
||||
|
||||
## Validierung von Template-Feldern
|
||||
|
||||
Template-Felder akzeptieren nur:
|
||||
|
||||
- Buchstaben und Ziffern
|
||||
- Leerzeichen
|
||||
- `_` und `-`
|
||||
- Platzhalter in `{...}` oder `${...}`
|
||||
- `/` für Unterordner
|
||||
|
||||
Damit verhindert Ripster fehlerhafte Dateinamen schon beim Speichern.
|
||||
|
||||
## Siehe auch
|
||||
|
||||
- [Alle Einstellungen](../configuration/settings-reference.md)
|
||||
- [Ripper](ripper.md)
|
||||
- [Workflows aus Nutzersicht](../workflows/index.md)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TMDb Migration
|
||||
|
||||
Die Seite `TMDb Migration` ist eine Sonderansicht für ältere Bestandsjobs, die noch nicht als auf TMDb migriert markiert sind.
|
||||
|
||||
## Zugriff
|
||||
|
||||
- Direktlink: `/tmdb-migration`
|
||||
- nicht in der normalen Top-Navigation verlinkt
|
||||
|
||||
## Wofür die Seite gedacht ist
|
||||
|
||||
Typische Einsatzfälle:
|
||||
|
||||
- Altbestand aus früheren Ständen bereinigen
|
||||
- mehrere Jobs nacheinander auf aktuelle TMDb-Metadaten umstellen
|
||||
- Historie konsistent auf den heutigen Metadaten-Workflow bringen
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
Die Seite lädt offene Migrationskandidaten und zeigt:
|
||||
|
||||
- Job-ID
|
||||
- Titel
|
||||
- Jahr
|
||||
- Medium
|
||||
- aktuellen Status
|
||||
|
||||
Aktionen:
|
||||
|
||||
- `Liste neu laden`
|
||||
- `Start`
|
||||
- `Stop`
|
||||
|
||||
## Ablauf einer Sequenz
|
||||
|
||||
1. Jobliste laden
|
||||
2. `Start`
|
||||
3. der bekannte TMDb-Metadaten-Dialog öffnet sich für den ersten Job
|
||||
4. nach erfolgreicher Übernahme plant Ripster den nächsten Job mit Cooldown
|
||||
|
||||
Wichtig:
|
||||
|
||||
- zwischen zwei Jobs liegt bewusst eine Wartezeit von mindestens 10 Sekunden
|
||||
- damit wird die TMDb-API bei Serienmigrationen nicht unnötig belastet
|
||||
|
||||
## Relevante Settings
|
||||
|
||||
- `TMDb Read Access Token`
|
||||
- `Film-Sprache`
|
||||
- `Fallback Sprache`
|
||||
|
||||
Ohne diese Einstellungen ist die Migration nicht sinnvoll nutzbar.
|
||||
+12
-21
@@ -1,33 +1,24 @@
|
||||
# Ripster Handbuch
|
||||
|
||||
Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung und Alltag, dann Technik im Anhang.
|
||||
Diese Dokumentation ist als vollständiges Benutzerhandbuch aufgebaut: tägliche Bedienung zuerst, technische Referenz danach.
|
||||
|
||||
---
|
||||
## Einstieg in 3 Schritten
|
||||
|
||||
## Schnellstart in 3 Schritten
|
||||
1. Installation: [Installation](getting-started/installation.md)
|
||||
2. Betriebskonfiguration: [Ersteinrichtung](getting-started/configuration.md)
|
||||
3. erster End-to-End-Lauf: [Erster Lauf](getting-started/quickstart.md)
|
||||
|
||||
1. Voraussetzungen prüfen und installieren: [Installation](getting-started/installation.md)
|
||||
2. Grundkonfiguration in der UI setzen: [Ersteinrichtung](getting-started/configuration.md)
|
||||
3. Ersten vollständigen Job durchlaufen: [Erster Lauf](getting-started/quickstart.md)
|
||||
## Was das Handbuch abdeckt
|
||||
|
||||
---
|
||||
|
||||
## Was du hier findest
|
||||
|
||||
- **Benutzerhandbuch**
|
||||
- Installation
|
||||
- GUI-Seiten im Detail (`Dashboard`, `Settings`, `Historie`, `Database`)
|
||||
- typische Arbeitsabläufe aus Anwendersicht
|
||||
- **Technischer Anhang**
|
||||
- vollständige Einstellungsreferenz
|
||||
- Pipeline-/API-/Architekturdetails
|
||||
- Deployment und Tool-Hintergründe
|
||||
|
||||
---
|
||||
- alle GUI-Seiten mit Aktionen und Wirkung
|
||||
- vollständige Workflows für Film, Serie und Multipart
|
||||
- Queue-, Job-, Container- und Recovery-Verhalten
|
||||
- detaillierte Settings-Wirkung inklusive Fallbacks
|
||||
|
||||
## Empfohlene Lesereihenfolge
|
||||
|
||||
1. [Benutzerhandbuch Überblick](getting-started/index.md)
|
||||
2. [GUI-Seiten](gui/index.md)
|
||||
3. [Workflows aus Nutzersicht](workflows/index.md)
|
||||
4. Bei Bedarf: [Technischer Anhang](appendix/index.md)
|
||||
4. [Einstellungsreferenz](configuration/settings-reference.md)
|
||||
5. bei Bedarf technischer Tiefgang im [Anhang](appendix/index.md)
|
||||
|
||||
@@ -44,7 +44,8 @@ Dann muss eine Playlist bestätigt werden, bevor der Workflow weiterläuft.
|
||||
|
||||
| Feld in `Settings` | Wirkung |
|
||||
|---|---|
|
||||
| `Minimale Titellaenge (Minuten)` | Mindestlaufzeit für Kandidaten |
|
||||
| `Minimale Titellänge (Minuten)` | Mindestlaufzeit für Kandidaten aus MakeMKV/Playlist-Analyse |
|
||||
| `TMDb Runtime-Abzug für Playlistfilter (%)` | lockert bei Bedarf die untere Laufzeitgrenze relativ zur TMDb-Laufzeit, damit leicht kürzere Hauptfassungen Kandidaten bleiben |
|
||||
|
||||
Default ist aktuell `60` Minuten.
|
||||
|
||||
@@ -52,7 +53,14 @@ Default ist aktuell `60` Minuten.
|
||||
|
||||
## UI-Verhalten
|
||||
|
||||
Bei manueller Entscheidung zeigt das Dashboard Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung.
|
||||
Bei manueller Entscheidung zeigt der `Ripper` Kandidaten mit:
|
||||
|
||||
- Playlist-Datei
|
||||
- Titel-ID
|
||||
- Laufzeit
|
||||
- Score
|
||||
- Empfehlung
|
||||
- Segment- und Audio-Vorschau
|
||||
|
||||
Nach Bestätigung:
|
||||
|
||||
|
||||
@@ -35,11 +35,17 @@ flowchart LR
|
||||
| `Analyse` | MakeMKV-Analyse läuft |
|
||||
| `Metadatenauswahl` | Metadaten müssen bestätigt werden |
|
||||
| `Warte auf Auswahl` | Playlist-Auswahl ist erforderlich |
|
||||
| `Warte auf Auswahl` | auch für RAW-Entscheidung bei vorhandenen Quelldaten genutzt |
|
||||
| `Startbereit` | kurzer Übergang vor Start |
|
||||
| `Rippen` | MakeMKV-Rip läuft |
|
||||
| `Mediainfo-Pruefung` | Titel/Spuren werden ausgewertet |
|
||||
| `Bereit zum Encodieren` | Review ist bereit |
|
||||
| `Encodieren` | HandBrake läuft |
|
||||
| `CD-Metadatenauswahl` | CD-Metadaten müssen bestätigt werden |
|
||||
| `CD-Startbereit` | CD-Job wartet auf Start |
|
||||
| `CD-Analyse` | CD-Track-/Strukturaufbereitung |
|
||||
| `CD-Rippen` | Audio-CD-Rip läuft |
|
||||
| `CD-Encodieren` | CD-Encode läuft |
|
||||
| `Fertig` | erfolgreich abgeschlossen |
|
||||
| `Abgebrochen` | manuell oder technisch abgebrochen |
|
||||
| `Fehler` | fehlgeschlagen |
|
||||
@@ -66,6 +72,14 @@ flowchart LR
|
||||
|
||||
`Mediainfo-Pruefung` -> `Warte auf Auswahl` bis Playlist bestätigt wurde.
|
||||
|
||||
### Vorhandenes RAW mit Nutzerentscheidung
|
||||
|
||||
`Metadatenauswahl` -> `Warte auf Auswahl` bis Entscheidung für Weiterverarbeitung (`continue`) oder Neustart getroffen wurde.
|
||||
|
||||
### CD-Flow
|
||||
|
||||
`CD-Metadatenauswahl` -> `CD-Startbereit` -> `CD-Rippen` -> `CD-Encodieren` -> `Fertig`
|
||||
|
||||
---
|
||||
|
||||
## Queue-Verhalten
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user