Compare commits

...

30 Commits

Author SHA1 Message Date
michael 4aa14f9070 1.0.0-1 RepoFix
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-07-02 08:48:10 +02:00
michael 0fb0f0515a 1.0.0-1 release 2026-07-02 08:43:06 +02:00
michael a749188dc1 1.0.0-1 Licence 2026-07-02 08:41:58 +02:00
michael abe5a03b05 1.0.0-1 Script 2026-07-02 08:38:15 +02:00
michael ad5c3f328c Merge branch 'Licence-Update' into dev
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-07-02 08:32:51 +02:00
michael af7f73759f Merge branch 'dev' of github.com:Mboehmlaender/ripster into dev 2026-07-02 08:32:45 +02:00
michael 428cf951b2 1.0.0-1 Licence 2026-07-02 08:32:25 +02:00
michael e754f1f7c3 1.0.0 Licence 2026-06-30 08:01:02 +02:00
michael c9ad6b2f06 1.0.0 release snapshot 2026-06-12 11:17:44 +02:00
michael 41887e5465 1.0.0 docs
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-06-12 11:17:36 +02:00
michael c87508d190 1.0.0 Docs
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-06-12 11:11:01 +02:00
michael 5e24c6b9cc 1.0.0 docs
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-06-12 10:38:45 +02:00
michael b29ac935a9 1.0.0 Readme 2026-06-12 10:22:17 +02:00
michael 554cdbe749 1.0.0 Readme 2026-06-12 10:19:22 +02:00
michael c6d884dc3f 1.0.0 1.0.0 final
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-06-12 09:48:45 +02:00
michael 04acda6400 1.0.0-rc5 rc5 2026-06-08 16:12:38 +02:00
michael 6d3c6fed0f 1.0.0-rc4 rc4 2026-06-08 12:15:36 +02:00
michael 1502d4d9d0 1.0.0-rc3 rc3 2026-05-27 16:54:22 +02:00
michael 196ac2ee07 1.0.0-rc3 rc 2026-05-27 12:22:32 +02:00
michael 2359c03bc8 1.0.0-rc3 rc3 2026-05-27 09:49:12 +02:00
michael eeef1fdb73 1.0.0-rc2 RC2
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-05-21 10:19:00 +02:00
michael 9b38d78042 1.0.0-rc1 Docs
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-05-08 09:01:17 +02:00
michael 9b437fe5e5 0.16.1-7 Release-Bugfixes
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
2026-05-08 08:10:34 +02:00
michael 024a6305e2 0.16.1-6 Detection Fix 2026-05-02 19:31:01 +00:00
michael 6fbc5395a1 0.16.1-5 Ebcode Fix / presets
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
2026-04-30 12:37:32 +00:00
michael f9cf3cef09 0.16.1-4 Hardware Monitoring 2026-04-29 11:14:27 +00:00
michael 044ece0387 0.16.1-3 Playlist Fix 2026-04-29 10:54:31 +00:00
michael edbe291389 0.16.1-2 Setup Fix 2026-04-29 10:33:01 +00:00
michael 1509614d3a 0.16.1-2 depskip 2026-04-29 10:28:57 +00:00
michael a6ff18c8c3 0.16.1-2 Installer Fix 2026-04-29 10:25:41 +00:00
209 changed files with 18832 additions and 14341 deletions
+35
View File
@@ -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.
+9 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
/usr/bin/python3
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
lib
-5
View File
@@ -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
Submodule .worktrees/main deleted from 190f6fe1a5
+339
View File
@@ -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.
+339
View File
@@ -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.
+339
View File
@@ -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.
+194 -214
View File
@@ -1,257 +1,237 @@
# Ripster
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Plugin-basierte Architektur mit MakeMKV + HandBrake + FFmpeg, inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
![Ripster Logo](frontend/public/logo.png)
---
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.
## Was Ripster kann
## ⚠️ Rechtlicher Hinweis
### Disc-Ripping & Encoding
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
- Medienprofil-Erkennung (Blu-ray/DVD/CD/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
- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell)
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.
### Audiobook-Verarbeitung
- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling)
- FFmpeg-basiertes Kapitel-Splitting
- Ausgabeformate: M4B, MP3, FLAC mit Metadaten
- MusicBrainz-Metadaten-Lookup
## KI-unterstützte Entwicklung
### Datei-Converter
- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.)
- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen
- Automatischer Verzeichnis-Scan (Polling konfigurierbar)
Teile dieses Projekts, einschließlich Quellcode, Dokumentation, Tests und konzeptioneller Entwürfe, wurden mit Unterstützung generativer KI erstellt oder überarbeitet.
### Automatisierung & Verwaltung
- 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 in Echtzeit im Ripper
- Download-Queue: Ausgabedateien als ZIP herunterladen
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper
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.
## Tech-Stack
Trotz manueller Prüfung können Fehler oder unvollständige Annahmen enthalten sein. Entsprechende Hinweise, Issues und Pull Requests sind willkommen.
- Backend: Node.js, Express, SQLite, WebSocket (`ws`) **Plugin-Architektur**
- Frontend: React, Vite, PrimeReact
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia`
## 🎬 Funktionsumfang
## Dokumentation
### 🎞️ 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
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
### 📀 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
## Voraussetzungen
### 🎵 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
- Debian 11/12 oder Ubuntu 22.04/24.04
- root-Rechte + Internetzugang
- optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping
- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional
### 🎧 Audiobooks
## Schnellstart (`install.sh`)
Diese Funktion ist für eine Archivierung von gekauften Titeln. Das System ermittelt keine Activation-Bytes und verweist dafür auf eine externe Seite
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
- 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`, `ffmpeg`, `ffprobe`
- 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/Audiobook-Varianten)
- Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)`
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando`
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
- Audiobook: Output-Template, RAW-Template
- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates
- 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/
plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter)
routes/
services/
db/
utils/
frontend/
src/
pages/ # Ripper, Settings, History, Converter, Downloads, Database
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:
**Converter**
- `GET /api/converter/tree`
- `GET /api/converter/browse`
- `POST /api/converter/scan`
- `POST /api/converter/create-jobs`
- `POST /api/converter/upload`
- `POST /api/converter/jobs/from-selection`
- `GET /api/converter/jobs`
- `GET /api/converter/jobs/:jobId`
- `POST /api/converter/jobs/:jobId/start`
- `POST /api/converter/jobs/:jobId/cancel`
- `DELETE /api/converter/jobs/:jobId`
- `DELETE /api/converter/files`
- `POST /api/converter/files/rename`
- `POST /api/converter/files/move`
- `POST /api/converter/files/folder`
```bash
./start.sh
```
**Downloads**
- `GET /api/downloads`
- `GET /api/downloads/summary`
- `POST /api/downloads/history/:jobId`
- `GET /api/downloads/:id/file`
- `DELETE /api/downloads/:id`
`start.sh` prüft Node.js, installiert fehlende Abhängigkeiten und startet Backend und Frontend im Dev-Modus.
**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`
Wichtige npm-Skripte:
- `npm run dev`
- `npm run dev:backend`
- `npm run dev:frontend`
- `npm run build:frontend`
- `npm run start`
**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`
## ⚙️ Konfiguration
**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`
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
**Runtime-Aktivitäten**
- `GET /api/activities`
- `POST /api/activities/:id/cancel`
- `POST /api/activities/:id/next-step`
- `POST /api/activities/clear-recent`
Zusätzliche Bootstrap-/Override-Variablen:
## Troubleshooting
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`
- 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
Frontend:
- `VITE_API_BASE`
- `VITE_WS_URL`
## Sicherheit
## 🗂️ Daten, Logs und API
- Keine echten Tokens/Passwörter ins Repository committen.
- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren.
- 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).
+80
View File
@@ -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.
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "ripster-backend",
"version": "0.16.1-1",
"version": "1.0.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.16.1-1",
"version": "1.0.0-1",
"license": "GPL-2.0-or-later",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+10 -1
View File
@@ -1,7 +1,16 @@
{
"name": "ripster-backend",
"version": "0.16.1-1",
"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",
+3
View File
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-2.0-or-later
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
@@ -18,6 +20,7 @@ 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',
+123 -17
View File
@@ -528,6 +528,7 @@ async function openAndPrepareDatabase() {
await migrateLegacyProfiledToolSettings(dbInstance);
await migrateOutputTemplates(dbInstance);
await removeDeprecatedSettings(dbInstance);
await migrateTmdbMigrationFlags(dbInstance);
await migrateSettingsSchemaMetadata(dbInstance);
await ensurePipelineStateRow(dbInstance);
await backfillJobOutputFolders(dbInstance);
@@ -811,7 +812,9 @@ async function removeDeprecatedSettings(db) {
'use_plugin_architecture_audiobook_analyze',
'use_plugin_architecture_audiobook_encode',
'dvd_series_plugin_enabled',
'dvd_series_prescan_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]);
@@ -828,6 +831,64 @@ 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 = [
@@ -849,6 +910,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
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,
@@ -973,6 +1046,41 @@ async function migrateSettingsSchemaMetadata(db) {
});
}
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}'
@@ -1063,9 +1171,17 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)`
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 ('handbrake_pre_metadata_scan_enabled', 'true')`);
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)
@@ -1226,30 +1342,20 @@ async function migrateSettingsSchemaMetadata(db) {
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', '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)`
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', '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)`
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(
`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)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
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_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
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(
+39
View File
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-2.0-or-later
require('dotenv').config();
const http = require('http');
@@ -24,10 +26,20 @@ 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();
@@ -37,6 +49,32 @@ async function start() {
} 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();
@@ -105,6 +143,7 @@ async function start() {
hardwareMonitorService.stop();
cronService.stop();
tempCleanupService.stop();
clearTimeout(betaKeyRefreshTimer);
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);
+23
View File
@@ -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();
+14 -10
View File
@@ -2,7 +2,7 @@
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
const tmdbService = require('../services/tmdbService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');
@@ -11,7 +11,7 @@ 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() → OMDB-Suche + Disc-Metadaten vorbereiten
* analyze() → TMDb-Suche + Disc-Metadaten vorbereiten
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
* rip() → makemkvcon backup/mkv
* encode() → HandBrakeCLI
@@ -54,13 +54,13 @@ class VideoDiscPlugin extends SourcePlugin {
}
/**
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück.
* 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, omdbCandidates: Array}>}
* @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
@@ -76,8 +76,8 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, {
jobId: job?.id,
error: error?.message || String(error)
});
@@ -87,10 +87,10 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id,
detectedTitle,
omdbCandidates: omdbCandidates.length
metadataCandidates: metadataCandidates.length
});
return { detectedTitle, omdbCandidates };
return { detectedTitle, metadataCandidates };
}
/**
@@ -277,6 +277,9 @@ class VideoDiscPlugin extends SourcePlugin {
// 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({
@@ -285,7 +288,8 @@ class VideoDiscPlugin extends SourcePlugin {
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
parser: parseMakeMkvProgress,
lineFilter: makeMkvLineFilter
});
} else {
runInfo = await _spawnAndWait({
+4 -2
View File
@@ -2,7 +2,6 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
@@ -10,6 +9,7 @@ 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) {
@@ -20,9 +20,11 @@ function resolveTarget(rawDir, input) {
}
const router = express.Router();
const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads');
fs.mkdirSync(converterUploadDir, { recursive: true });
const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
dest: converterUploadDir
});
// ── Scan ──────────────────────────────────────────────────────────────────
+95 -9
View File
@@ -66,6 +66,42 @@ 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) => {
@@ -104,11 +140,21 @@ router.post(
);
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,
@@ -116,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 });
@@ -141,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) => {
@@ -250,9 +304,10 @@ router.post(
});
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 requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined;
const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !requestedResetDriveState;
: null;
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -284,12 +339,35 @@ router.post(
(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
: requestedResetDriveState;
: requestedResetDriveStateEffective;
const keepDetectedDevice = containsOrphanRawImportJob
? true
: requestedKeepDetectedDevice;
: (hasRequestedKeepDetectedDevice
? requestedKeepDetectedDevice
: !requestedResetDriveStateEffective);
if (containsOrphanRawImportJob && requestedResetDriveState) {
logger.info('post:delete-job:orphan-drive-reset-skipped', {
@@ -298,6 +376,14 @@ router.post(
});
}
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))
+135 -121
View File
@@ -1,6 +1,5 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
@@ -11,86 +10,16 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config');
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
});
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get(
'/state',
asyncHandler(async (req, res) => {
@@ -102,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) => {
@@ -146,11 +137,11 @@ 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 });
})
);
@@ -170,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 });
})
);
@@ -318,47 +313,19 @@ router.get(
})
);
router.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {
const {
jobId,
title,
artist,
year,
imdbId,
poster,
fromOmdb,
mbId,
coverUrl,
tracks,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
@@ -387,10 +354,13 @@ router.post(
reqId: req.reqId,
jobId,
title,
artist,
year,
imdbId,
poster,
fromOmdb,
mbId,
coverUrl,
trackCount: Array.isArray(tracks) ? tracks.length : null,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
@@ -405,13 +375,37 @@ router.post(
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,
selectedHandBrakeTitleIds,
@@ -532,6 +526,9 @@ 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;
@@ -541,8 +538,12 @@ router.post(
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
const selectedUserPresetId = hasSelectedUserPresetId
? body.selectedUserPresetId
: undefined;
const selectedHandBrakePreset = hasSelectedHandBrakePreset
? body.selectedHandBrakePreset
: undefined;
logger.info('post:confirm-encode', {
reqId: req.reqId,
jobId,
@@ -600,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 });
})
);
@@ -654,15 +656,24 @@ router.post(
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
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 });
const result = await pipelineService.restartReviewFromRaw(jobId, {
keepBoth,
deleteFolders,
reuseCurrentJob,
createNewJob
});
res.json({ result });
})
);
@@ -674,17 +685,20 @@ router.post(
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
restartMode,
createNewJob
});
res.json({ result });
})
+144 -71
View File
@@ -8,10 +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 } = require('../services/makemkvKeyService');
const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService');
const logger = require('../services/logger').child('SETTINGS_ROUTE');
const { getDb } = require('../db/database');
@@ -57,10 +58,35 @@ router.get(
'/makemkv/beta-key',
asyncHandler(async (req, res) => {
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
const result = await fetchCurrentBetaKey();
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
const cached = await getCachedBetaKey({ allowExpired: true });
res.json({
betaKey: result.key,
sourceUrl: result.sourceUrl
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
});
})
);
@@ -69,35 +95,26 @@ router.post(
'/makemkv/beta-key/apply',
asyncHandler(async (req, res) => {
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
const force = req.body?.force === true;
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
const betaKeyResult = await fetchCurrentBetaKey();
const requestedKey = String(req.body?.betaKey || '').trim();
const cachedResult = requestedKey
? { key: requestedKey }
: await getCachedBetaKey({ allowExpired: true });
const betaKey = String(cachedResult?.key || '').trim();
if (!force && existingKey && existingKey !== betaKeyResult.key) {
const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({
settingsMap: currentSettings
});
res.json({
applied: false,
preservedExistingKey: true,
reason: 'existing_key',
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl,
settingsFilePath: syncResult?.path || null
});
return;
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', betaKeyResult.key);
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey);
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({
applied: true,
preservedExistingKey: false,
setting: updated,
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl
betaKey,
sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null
});
})
);
@@ -157,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) => {
@@ -250,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) => {
@@ -277,6 +330,72 @@ router.post(
})
);
// ── 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) => {
@@ -408,52 +527,6 @@ router.put(
})
);
// ── 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) => {
@@ -130,12 +130,6 @@ function collectCoverCandidates(row) {
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
}
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
const omdbPoster = String(omdbInfo?.Poster || '').trim();
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
push(omdbPoster, 'omdb_json.Poster');
}
return candidates;
}
@@ -280,8 +274,7 @@ class CoverArtRecoveryService {
status,
poster_url,
imdb_id,
makemkv_info_json,
omdb_json
makemkv_info_json
FROM jobs
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
`
+208 -20
View File
@@ -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;
@@ -34,6 +36,27 @@ 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;
@@ -337,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;
@@ -347,6 +372,7 @@ class HardwareMonitorService {
sample: null,
error: null
};
this.lastHistoryCleanupDayKey = null;
}
async init() {
@@ -390,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 = {};
@@ -399,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);
@@ -418,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();
@@ -521,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;
@@ -553,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,
@@ -564,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,
@@ -574,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);
@@ -581,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 {
@@ -665,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])
);
@@ -708,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;
}
@@ -732,7 +884,7 @@ class HardwareMonitorService {
};
}
async collectTempsViaSensors() {
async collectTempsViaSensors(signal) {
if (this.sensorsCommandAvailable === false) {
return {
source: 'sensors',
@@ -745,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 || '{}'));
@@ -756,6 +909,9 @@ class HardwareMonitorService {
...mapTemperatureCandidates(preferred)
};
} catch (error) {
if (isAbortError(error)) {
throw error;
}
if (isCommandMissingError(error)) {
this.sensorsCommandAvailable = false;
}
@@ -863,7 +1019,7 @@ class HardwareMonitorService {
};
}
async collectGpuMetrics() {
async collectGpuMetrics(signal) {
if (this.nvidiaSmiAvailable === false) {
return {
source: 'nvidia-smi',
@@ -882,7 +1038,8 @@ class HardwareMonitorService {
],
{
timeout: NVIDIA_SMI_TIMEOUT_MS,
maxBuffer: 1024 * 1024
maxBuffer: 1024 * 1024,
signal
}
);
@@ -895,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,
@@ -910,26 +1071,47 @@ 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) {
const metric = await this.collectStorageForPath(entry);
const metric = await this.collectStorageForPath(entry, signal);
if (metric && !metric.hidden) {
list.push(metric);
}
@@ -959,7 +1141,7 @@ 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();
@@ -1028,7 +1210,8 @@ 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) {
@@ -1056,7 +1239,9 @@ class HardwareMonitorService {
}
const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint;
const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint);
const shouldShowExternal = exists || hasDedicatedMount;
// 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,
@@ -1077,6 +1262,9 @@ class HardwareMonitorService {
error: null
};
} catch (error) {
if (isAbortError(error)) {
throw error;
}
if (hideWhenUnavailable) {
return hiddenResult({
path: resolvedPath,
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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,
@@ -123,7 +124,7 @@ function emit(level, scope, message, meta = null) {
return;
}
const timestamp = new Date().toISOString();
const timestamp = getServerTimestamp();
const payload = {
timestamp,
level: normLevel,
+266 -58
View File
@@ -2,17 +2,218 @@ 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
|| 'Ripster/1.0 (MakeMKV beta key sync)';
const MAKEMKV_BETA_KEY_API_FROM = process.env.MAKEMKV_BETA_KEY_API_FROM
|| 'admin@example.invalid';
|| 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();
@@ -93,30 +294,53 @@ async function syncRegistrationKeyToConfig(rawValue, options = {}) {
};
}
async function fetchCurrentBetaKey() {
const errors = [];
async function fetchCurrentBetaKey(options = {}) {
const forceRefresh = options?.forceRefresh === true;
const now = Date.now();
try {
return await fetchCurrentBetaKeyViaCurl();
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:curl-failed', {
error: error?.message || String(error)
});
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 {
return await fetchCurrentBetaKeyViaFetch();
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) {
errors.push(error?.message || String(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;
}
const error = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
error.statusCode = 502;
throw error;
}
function parseBetaKeyResponse(rawBody) {
@@ -129,35 +353,35 @@ function parseBetaKeyResponse(rawBody) {
throw error;
}
const betaKey = String(payload?.key || '').trim();
const betaKey = parseBetaKeyCandidate(String(payload?.key || '').trim(), 'Betakey-Antwort ist ungültig.');
if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) {
const error = new Error('Betakey-Antwort ist ungültig.');
error.statusCode = 502;
throw error;
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
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',
[
'-fsSL',
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
'-H', `From: ${MAKEMKV_BETA_KEY_API_FROM}`,
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
'-H', 'Cache-Control: no-cache',
'-H', 'Pragma: no-cache',
MAKEMKV_BETA_KEY_API_URL
],
curlArgs,
{
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
maxBuffer: 1024 * 1024
@@ -168,6 +392,12 @@ function fetchCurrentBetaKeyViaCurl() {
`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;
}
@@ -182,32 +412,10 @@ function fetchCurrentBetaKeyViaCurl() {
});
}
async function fetchCurrentBetaKeyViaFetch() {
const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
headers: {
'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
'From': MAKEMKV_BETA_KEY_API_FROM,
'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8',
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
});
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.statusCode = 502;
throw error;
}
const rawBody = await response.text();
return parseBetaKeyResponse(rawBody);
}
module.exports = {
MAKEMKV_BETA_KEY_API_URL,
fetchCurrentBetaKey,
getCachedBetaKey,
getMakeMKVConfigDir,
getMakeMKVSettingsFilePath,
normalizeRegistrationKey,
+33 -22
View File
@@ -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}`);
-225
View File
@@ -1,225 +0,0 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
const OMDB_TIMEOUT_MS = 10000;
const OMDB_MAX_ATTEMPTS = 2;
const OMDB_RETRY_BASE_DELAY_MS = 300;
function normalizeOmdbTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return OMDB_TIMEOUT_MS;
}
return Math.max(1000, Math.trunc(parsed));
}
function isRetryableOmdbError(error, aborted = false) {
if (aborted) {
return true;
}
const code = String(error?.code || '').trim().toUpperCase();
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true;
}
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
return true;
}
const message = String(error?.message || '').toLowerCase();
return message.includes('timed out') || message.includes('timeout');
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
class OmdbService {
mapSearchResults(data) {
if (!data || typeof data !== 'object') {
return [];
}
if (data.Response === 'False' || !Array.isArray(data.Search)) {
return [];
}
return data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
}
async requestJson(url, meta = {}, options = {}) {
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
logger.warn('request:http-failed', {
...meta,
status: response.status,
attempt,
maxAttempts
});
return null;
}
return await response.json();
} catch (error) {
const aborted = error?.name === 'AbortError';
const retryable = isRetryableOmdbError(error, aborted);
const willRetry = retryable && attempt < maxAttempts;
logger[willRetry ? 'info' : 'warn']('request:failed', {
...meta,
timeoutMs,
aborted,
retryable,
attempt,
maxAttempts,
willRetry,
message: error?.message || String(error)
});
if (willRetry) {
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
continue;
}
return null;
} finally {
clearTimeout(timer);
}
}
return null;
}
async search(query) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return [];
}
logger.info('search:start', { query: normalizedQuery });
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
const imdbLike = normalizedQuery.toLowerCase();
if (/^tt\d{6,12}$/.test(imdbLike)) {
const byId = await this.fetchByImdbId(imdbLike);
return byId
? [{
title: byId.title,
year: byId.year != null ? String(byId.year) : null,
imdbId: byId.imdbId,
type: byId.type,
poster: byId.poster
}]
: [];
}
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return [];
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
const requestSearch = async (type = null) => {
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', normalizedQuery);
if (type) {
url.searchParams.set('type', type);
}
const data = await this.requestJson(
url,
{ query: normalizedQuery, action: 'search', type: type || null },
{ timeoutMs }
);
return {
data,
results: this.mapSearchResults(data)
};
};
let { data, results } = await requestSearch(configuredType);
if (results.length === 0 && configuredType) {
logger.info('search:fallback-no-type', {
query: normalizedQuery,
configuredType,
response: data?.Response || null,
error: data?.Error || null
});
({ data, results } = await requestSearch(null));
}
if (results.length === 0) {
logger.warn('search:no-results', {
query: normalizedQuery,
response: data?.Response || null,
error: data?.Error || null,
configuredType
});
return [];
}
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
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 timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
if (!data || typeof data !== 'object') {
return null;
}
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();
File diff suppressed because it is too large Load Diff
+91 -18
View File
@@ -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();
+53 -11
View File
@@ -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],
+56 -9
View File
@@ -1,5 +1,4 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { getDb } = require('../db/database');
@@ -29,7 +28,8 @@ const {
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_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'];
@@ -44,11 +44,15 @@ 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',
@@ -146,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'
@@ -197,6 +209,11 @@ function normalizeBooleanSetting(rawValue, fallback = true) {
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);
@@ -299,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;
@@ -856,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 = {}) {
@@ -1012,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,
@@ -1025,7 +1071,7 @@ class SettingsService {
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
orderIndex: row.order_index,
depends_on: row.depends_on ?? null
}));
}));
}
async getFlatSettings(options = {}) {
@@ -1554,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',
+3 -2
View File
@@ -1,9 +1,9 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const logger = require('./logger').child('TEMP_CLEANUP');
const { tempDir } = require('../config');
const TMP_ROOT = os.tmpdir();
const TMP_ROOT = tempDir;
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
@@ -65,6 +65,7 @@ class TempCleanupService {
}
async runSweep(reason = 'manual') {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const deleted = [];
const skipped = [];
const failed = [];
+447 -64
View File
@@ -8,6 +8,49 @@ 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();
@@ -103,9 +146,11 @@ class TmdbService {
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'
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE',
fallbackLanguages
};
}
@@ -119,6 +164,45 @@ class TmdbService {
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) {
@@ -177,47 +261,135 @@ class TmdbService {
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const language = await this.resolveLanguage(options.language);
const languageCandidates = await this.resolveLanguageCandidates(options);
let lastFailureCode = null;
let lastFailureStatusCode = null;
let lastFailureMessage = null;
let data = null;
try {
data = await this.request('/search/tv', {
query: {
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,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error),
failureCode,
statusCode: Number(error?.statusCode || 0) || null
});
return this.attachFailureCode([], failureCode);
}
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);
return this.attachFailureCode(normalizedRows, null);
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 = {}) {
@@ -225,24 +397,111 @@ class TmdbService {
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
const languageCandidates = await this.resolveLanguageCandidates(options);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return this.request(`/tv/${Math.trunc(id)}`, {
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,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
language
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
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())) {
@@ -263,18 +522,31 @@ class TmdbService {
if (!normalizedId || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
error: error?.message || String(error)
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;
});
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 = {}) {
@@ -283,12 +555,28 @@ class TmdbService {
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)}`, {
query: {
language
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;
}
}).catch(() => null);
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 = {}) {
@@ -343,10 +631,7 @@ class TmdbService {
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.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
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 });
@@ -375,6 +660,39 @@ class TmdbService {
};
}
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
@@ -404,6 +722,35 @@ class TmdbService {
};
}
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 : [];
@@ -483,6 +830,42 @@ class TmdbService {
}));
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
};
+52 -8
View File
@@ -33,6 +33,7 @@ const ISO2_TO_3_LANGUAGE = {
ro: 'ron',
uk: 'ukr',
ja: 'jpn',
jp: 'jpn',
ko: 'kor',
zh: 'zho',
ar: 'ara'
@@ -873,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)
@@ -881,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)
: [];
@@ -896,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,
@@ -903,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],
@@ -916,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',
+796 -16
View File
@@ -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)
};
}
+23
View File
@@ -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] }
]);
});
View File
+120 -11
View File
@@ -32,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,
@@ -98,6 +99,7 @@ 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
@@ -109,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
@@ -131,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,
@@ -186,12 +209,25 @@ 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,
@@ -408,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');
@@ -416,10 +456,6 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
@@ -470,10 +506,50 @@ 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})');
@@ -513,10 +589,50 @@ 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})');
@@ -622,18 +738,11 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
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 ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true');
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');
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true');
+2 -2
View File
@@ -114,9 +114,9 @@ Importiert einen RAW-Ordner als Job und triggert optional die Analyse des Import
---
## POST /api/history/:id/omdb/assign
## POST /api/history/:id/metadata/assign
Weist OMDb-/Filmdaten nachträglich zu.
Weist TMDb-Metadaten nachträglich zu oder aktualisiert bestehende Film-/Serien-Metadaten eines Jobs.
## POST /api/history/:id/cd/assign
+2 -2
View File
@@ -107,9 +107,9 @@ Erzwingt Rescan für ein konkretes Laufwerk.
---
## GET /api/pipeline/omdb/search?q=<query>
## GET /api/pipeline/tmdb/movie/search?q=<query>
OMDb-Titelsuche.
TMDb-Filmsuche.
**Response:**
+39
View File
@@ -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.
+2 -2
View File
@@ -111,7 +111,7 @@ 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`)
@@ -222,7 +222,7 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
- `audnexService.js` (Audnex-API für Audiobook-Metadaten)
- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung)
- `thumbnailService.js` (Vorschaubild-Generierung)
- `omdbService.js` (OMDb-Metadaten-Suche)
- `tmdbService.js` (Film-/Serien-Metadaten-Suche)
- `logger.js` (rotierende Datei-Logs)
---
+2 -10
View File
@@ -6,14 +6,6 @@ Frontend: React + PrimeReact + Vite.
## Hauptseiten
### `JobsInboxPage.jsx`
Zentrale Job-Inbox:
- einheitliche Liste über Ripper-, Converter- und Audiobook-Jobs
- View-/Status-Filter, Suche, Paginierung
- Schnellsprung in passende Detailseite (`Ripper`/`Converter`)
### `RipperPage.jsx`
Pipeline-Steuerung:
@@ -42,7 +34,7 @@ Historie:
- Job-Liste/Filter
- Job-Details + Logs
- OMDb-Nachzuweisung
- TMDb-Neuzuordnung
- Re-Encode/Restart-Workflows
### `ConverterPage.jsx`
@@ -86,7 +78,7 @@ Expert-Modus:
## Wichtige Komponenten
- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress
- `MetadataSelectionDialog.jsx`OMDb-Metadaten-Auswahl
- `MetadataSelectionDialog.jsx`TMDb-Metadaten-Auswahl
- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel)
- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs
- `CronJobsTab.jsx` — Cron-Job-Verwaltung
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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 |
+17
View File
@@ -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.
+101 -52
View File
@@ -4,99 +4,148 @@ Diese Seite ist die Betriebs-Checkliste vor dem ersten produktiven Lauf.
## Ziel der Ersteinrichtung
Vor dem ersten Job müssen drei Dinge stabil sein:
Vor dem ersten Job sollten fünf Bereiche sauber gesetzt sein:
1. Pfade stimmen (RAW, Output, Logs)
2. Tools sind aufrufbar (MakeMKV, HandBrake, MediaInfo)
3. Metadatenzugänge funktionieren (OMDb, optional TMDb/MusicBrainz)
1. Pfade und Templates
2. Laufwerk und Disc-Erkennung
3. Metadatenzugang über TMDb
4. Encode-Standards und Queue-Regeln
5. optionale Automatisierung und Benachrichtigungen
## Empfohlene Reihenfolge
### 1) Basis in `Settings -> Konfiguration`
### 1. Basis in `Settings -> Konfiguration`
Setze zuerst:
Prüfe zuerst diese Kernfelder:
- `Raw-Ordner` und `Film-/Serien-Ordner`
- RAW- und Zielordner für Blu-ray, DVD, CD, Audiobooks und Converter
- `Log Ordner`
- `TMDb Read Access Token`
- `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
- `OMDb API Key`
- `FFmpeg Kommando`, `FFprobe Kommando`, `cdparanoia Kommando`, `mkvmerge Kommando`, falls du die jeweiligen Workflows nutzt
Dann speichern.
Danach speichern.
### 2) Profile pro Medium festziehen
### 2. Pfade und Templates bewusst festlegen
Ripster löst viele Werte profilspezifisch auf (`bluray`, `dvd`, `cd`, `audiobook`).
Wichtig für den Alltag:
Pflichtprüfung:
- 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`
- RAW-Pfade je Profil
- Output-Pfade je Profil
- HandBrake-Preset je Profil
- Output-Templates je Profil
Wenn du auf NAS, USB oder mit anderen Benutzern/Gruppen schreiben willst, prüfe auch die jeweils zugehörigen `Eigentümer ...`-Felder.
Für Serien zusätzlich:
### 3. Laufwerk und automatische Erkennung prüfen
- `RAW-Ordner (Blu-ray/DVD Serie)`
- `Serien-Ordner (Blu-ray/DVD)`
- Serien-Templates (Episode/Multi-Episode)
Relevant:
### 3) Queue-Verhalten definieren
- `Laufwerksmodus`
- `Explizite Laufwerke`, falls du nicht mit Auto-Discovery arbeiten willst
- `Automatische Disk-Erkennung`
- `Polling Intervall (ms)` im Expertenmodus
- `Laufwerk nach Rip auswerfen`
Stelle ein:
Empfehlung:
- `Max. parallele Film/Video Encodes`
- 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`
- optional `Audio CDs: Queue-Reihenfolge überspringen`
- `Max. Encodes gesamt (medienunabhängig)`
- `Audio CDs: Queue-Reihenfolge überspringen`
Damit steuerst du Durchsatz, Priorität und Lastverteilung.
Damit steuerst du, wie aggressiv Ripster mehrere Jobs gleichzeitig startet.
### 4) Metadatenquellen absichern
### 7. Optional: User-Presets und Standard-Zuordnungen
Film:
Unter `Settings -> Encode-Presets` kannst du:
- OMDb-Key prüfen
- eigene HandBrake-User-Presets anlegen
- Standard-Zuordnungen für `Blu-ray Film`, `Blu-ray Serie`, `DVD Film`, `DVD Serie` setzen
Serien:
Diese Defaults wirken später in der Review automatisch vorbefüllend.
- TMDb Read Access Token setzen
- Sprache/Fallback-Sprachen festlegen
### 8. Optional: Benachrichtigungen
CD/Audiodaten:
Prüfe:
- `MusicBrainz aktiviert` nach Bedarf
- `PushOver aktiviert`
- Token/User/optional Device
- Event-Toggles wie `Bei manueller Auswahl senden`, `Bei Rip-Start senden`, `Bei Erfolg senden`
### 5) Optional: Benachrichtigungen
Danach in `Settings` den Button `PushOver Test` ausführen.
- PushOver-Zugangsdaten setzen
- `PushOver Test` ausführen
- Event-Toggles pro Phase prüfen
### 9. Optional: Automatisierung
### 6) Optional: Automatisierung
Erst wenn die Grundkonfiguration stabil ist:
1. Skripte testen
2. Skriptketten bauen/testen
3. Cronjobs erst danach aktivieren
1. Skripte anlegen und testen
2. Skriptketten aufbauen
3. Cronjobs aktivieren
## Funktionsprüfung (Kurztest)
## Funktionsprüfung
1. Disc in `Ripper` erkennen lassen.
2. `Analyse starten`.
3. Metadaten übernehmen.
4. Bis `Bereit zum Encodieren` laufen.
5. Encode starten und Abschluss in `Historie` prüfen.
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 |
|---|---|
| Job bleibt früh auf Fehler | Toolkommando falsch oder nicht im PATH |
| RAW/Output landet am falschen Ort | Profilpfade nicht gesetzt, Fallback greift |
| Serienausgabe landet bei Filmen | Serienpfade/Serientemplates fehlen |
| Queue verhält sich unerwartet | Gesamtlimit und Lane-Limits widersprechen sich |
| 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](../gui/index.md)
- [Workflows aus Nutzersicht](../workflows/index.md)
- [Alle Einstellungen](../configuration/settings-reference.md)
+83 -71
View File
@@ -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
+29 -19
View File
@@ -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
+99 -38
View File
@@ -1,19 +1,25 @@
# Erster Lauf
Dieser Ablauf führt einen vollständigen Disc-Job von Erkennung bis fertiger Datei durch.
Dieser Ablauf führt einen typischen Disc-Job von Erkennung bis fertiger Datei durch.
## 1) Disc erkennen
## 1. Disc erkennen
1. `Ripper` öffnen.
2. Disc einlegen.
3. Auf `Medium erkannt` warten.
1. `Ripper` öffnen
2. Disc einlegen
3. auf `Medium erkannt` warten
Prüfen:
- `Disk-Information` zeigt Device/Label.
- Falls nicht: `Laufwerk neu lesen`.
- `Disk-Information` zeigt Device, Label und Laufwerksstatus
- falls nicht: `Laufwerk neu lesen`
## 2) Analyse starten
Relevante Settings:
- `Laufwerksmodus`
- `Automatische Disk-Erkennung`
- `Polling Intervall (ms)`
## 2. Analyse starten
Aktion:
@@ -22,69 +28,124 @@ Aktion:
Erwartung:
- Status `Analyse`
- danach Metadaten-Dialog
- danach der Metadaten-Dialog
## 3) Metadaten bestätigen
Relevantes Setting:
- `Minimale Titellänge (Minuten)` beeinflusst bereits, welche Titel/Playlists Ripster später als relevant betrachtet
## 3. Metadaten in TMDb bestätigen
Im Dialog:
1. Treffer suchen/auswählen.
2. Bei Serie zusätzlich Disc-Nummer setzen.
3. `Auswahl übernehmen`.
1. Treffer suchen oder filtern
2. Film oder Serie auswählen
3. bei Serien zusätzlich `Disc-Nummer` setzen
4. `Auswahl übernehmen`
Mögliche Abzweigungen:
- Duplikatfilm -> `Übernahme erzwingen` oder `Multipart movie`
- vorhandenes RAW -> Entscheidungsdialog (weiterverwenden oder neu rippen)
- Duplikatfilm: `Übernahme erzwingen` oder `Multipart movie`
- vorhandenes RAW: Entscheidungsdialog `weiterverwenden` oder `neu rippen`
## 4) Rip/Review-Phase
Relevante Settings:
Normalfall:
- `TMDb Read Access Token`
- `Film-Sprache`
- `Fallback Sprache`
- `Startbereit` -> `Rippen` -> `Mediainfo-Pruefung`
## 4. RAW- und Playlist-Entscheidungen
Mögliche Alternativen:
Je nach Disc-Situation geht es jetzt unterschiedlich weiter.
- vorhandenes RAW: direkter Sprung in Prüfung
- Blu-ray/DVD-Entscheidung nötig: `Warte auf Auswahl` für Playlist/Titel
### Normalfall
## 5) Encode-Review abschließen
`Startbereit` -> `Rippen` -> `Mediainfo-Prüfung`
Bei `Bereit zum Encodieren`:
### Vorhandenes RAW
- Titel festlegen
- Audio-/Subtitle-Spuren prüfen
- optional Preset auswählen
- optional Pre-/Post-Skripte wählen
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`
## 6) Encoding überwachen
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 Ripper
- Queue- und Runtime-Status beobachten
- optional Logkontrolle in Historie
- Queue-Status beobachten
- bei Bedarf `Hardware`-Ansicht öffnen
## 7) Ergebnis verifizieren
Relevante Settings:
- `Parallele Jobs`
- `Max. parallele Audio CD Jobs`
- `Max. Encodes gesamt (medienunabhängig)`
- `Hardware Monitoring aktiviert`
- `Hardware Monitoring Intervall (ms)`
## 7. Ergebnis verifizieren
Nach `Fertig`:
1. `Historie` öffnen.
2. Jobdetail prüfen.
3. Output-Pfad und Log plausibilisieren.
1. `Historie` öffnen
2. Jobdetail prüfen
3. Output-Pfad und Log kontrollieren
Kontrollpunkte:
- finale Datei am erwarteten Template-Pfad
- RAW-Ordnerzustand finalisiert (kein `Incomplete_`)
- finale Datei liegt am erwarteten Template-Pfad
- RAW-Ordner ist finalisiert
- optional `.nfo` wurde erzeugt, falls aktiviert
## 8) Typische Folgeaktionen
Relevante Settings:
- falsche Metadaten: `OMDb neu zuordnen`
- 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`
+81 -41
View File
@@ -1,68 +1,108 @@
# Audiobooks
Die Seite `Audiobooks` ist der spezialisierte AAX-Workflow.
Die Seite `Audiobooks` ist der spezialisierte Workflow für Audible-`*.aax`-Dateien.
## Ablaufübersicht
## Seitenstruktur
Die Oberfläche ist in drei Bereiche gegliedert:
Die Oberfläche besteht aus zwei Hauptbereichen:
1. `Audiobooks Upload`
2. `Audiobook Jobs`
3. `Audiobook Output Explorer`
## 1) Audiobooks Upload
## 1. Audiobooks Upload
Der Upload verarbeitet ausschließlich AAX-Dateien.
Der Upload akzeptiert ausschließlich `.aax`.
Direkt nach Upload passieren im Hintergrund:
### Bedienung
- Probe/Metadatenanalyse (ffprobe)
- Anlage eines RAW-Ordners per `Audiobook RAW Template`
- Ablage der AAX-Datei im RAW-Pfad
- Initiale Jobkonfiguration (Format, Kapitel, Metadaten)
- Datei auswählen oder per Drag-and-Drop ablegen
- Upload starten
- Auswahl entfernen oder laufenden Upload abbrechen
Wichtige Parameter:
Während des Uploads zeigt die UI:
- Zielformat (`m4b`, `mp3`, `flac`)
- optionaler Sofortstart
- Prozentfortschritt
- übertragene und gesamte Bytes
- Statusmeldung
Toolabhängigkeit:
### Ablauf nach erfolgreichem Upload
- `ffprobe_command` für Metadaten/Kapitel
- `ffmpeg_command` für Encode/Cover-Extraktion
- 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
## 2. Audiobook Jobs
Jobkarten zeigen Zustand, Fortschritt und verfügbare Aktionen.
Die Seite zeigt aktive Jobs. Abgeschlossene Jobs findest du in der `Historie`.
Typische Aktionen:
Ein Job kann eingeklappt oder ausgeklappt werden und zeigt:
- `Starten`
- `Abbrechen`
- `Retry`
- `Löschen`
- `Im Ripper` (Kontextwechsel)
- Titel, Autor, Sprecher, Jahr
- Kapitelanzahl
- Fortschritt und ETA
- bei Split-Ausgabe den Kapitelstatus
Die Karten werden regelmäßig aktualisiert und durch WebSocket-Ereignisse ergänzt.
## Konfigurierbare Job-Parameter
## 3) Audiobook Output Explorer
Im ausgeklappten Job kannst du setzen:
Read-only Explorer des Audiobook-Outputpfads:
- Ausgabeformat `m4b`, `mp3` oder `flac`
- formatspezifische Optionen
- Kapitelnamen
- Pre-Encode-Skripte und -Ketten
- Post-Encode-Skripte und -Ketten
- Ordner-/Dateistruktur
- Zeitstempel/Größen
- schnelle Ergebnisprüfung ohne Dateisystemwechsel
### Formatverhalten
## Pfad- und Template-Logik
- `m4b`: eine Datei mit Kapitelmarken
- `mp3`: eine Datei pro Kapitel
- `flac`: eine Datei pro Kapitel
| Bereich | Gesteuert durch |
|---|---|
| RAW-Ablage | `raw_dir_audiobook` + `audiobook_raw_template` |
| Finale Ausgabe | `movie_dir_audiobook` + `output_template_audiobook` |
| Dateiformat | Jobkonfiguration (`m4b`, `mp3`, `flac`) |
Wenn du `mp3` oder `flac` wählst, zeigt die UI zusätzlich eine Kapitel-Status-Tabelle.
## Typische Probleme
## Activation Bytes
- Upload wird abgelehnt: Eingabedatei ist keine AAX-Datei.
- Start schlägt fehl: `ffmpeg`/`ffprobe` nicht aufrufbar.
- Unerwartete Pfade: Templates enthalten ungültige oder zu generische Platzhalter.
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
+70 -50
View File
@@ -1,39 +1,49 @@
# Converter
Die Seite `Converter` verarbeitet bereits vorhandene Dateien (Video, Audio, ISO) ohne Laufwerks-Rip.
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 Eingangspfad `converter_raw_dir`:
Der Converter arbeitet auf dem in `Settings` festgelegten `Converter Raw-Ordner`.
- Uploads landen dort
- Explorer-Aktionen arbeiten dort
- daraus werden Converter-Jobs erstellt
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.
- Joberstellung erfolgt explizit über Auswahl im Explorer.
- 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_dir`.
Der Explorer zeigt den kompletten Baum unter `Converter Raw-Ordner`.
Aktionen:
| Aktion | Wirkung |
|---|---|
| `Upload` | Dateien werden in den Raw-Baum geschrieben |
| `Ordner erstellen` | legt Unterordner im Raw-Baum an |
| `Umbenennen` | benennt Datei/Ordner im Raw-Baum um |
| `Verschieben` | verschiebt innerhalb des Raw-Baums |
| `Löschen` | entfernt Datei/Ordner dauerhaft |
| `Scannen` | synchronisiert Explorer und DB-Zuordnungen |
| `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-Regeln:
### Upload- und Scan-Regeln
- Dateiendung muss in `Erlaubte Datei-Endungen*` enthalten sein
- bei Verstoß wird der Upload abgelehnt
- 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
@@ -44,65 +54,75 @@ Es gibt zwei Audio-Strategien:
Verhalten:
- Video/ISO werden grundsätzlich als Einzeljobs angelegt.
- Audio kann als Shared-Job mit mehreren Eingabedateien erzeugt werden.
- 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 Start erweitert/reduziert werden (nur Audio-Dateien)
- kann vor dem Start erweitert oder reduziert werden
## Job-Konfiguration
Vor Start je Job einstellbar:
Vor dem Start je Job einstellbar:
- `converterMediaType` (`video`, `audio`, `iso`)
- `converterMediaType`
- `outputFormat`
- Metadaten
- Track-/Titelwahl (bei video/iso)
- Track-/Titelwahl bei Video/ISO
- User-Preset und HandBrake-Preset bei Video
- Pre-/Post-Encode-Skripte und Ketten
Spezifika:
- Video/ISO laufen durch Mediainfo/Review ähnlich zum Disc-Flow.
- Audio kann direkt mit Formatoptionen konfiguriert werden.
- Video/ISO durchlaufen eine Review ähnlich zum Disc-Flow
- Audio wird direkt über Format und Audio-Optionen konfiguriert
## Ausgabe-Pfade
### Video/ISO
### Video / ISO
- Root: `converter_movie_dir`
- Zielordner: `Converter Ausgabe (Video)`
- Dateiname: `Output-Template (Video)`
- Endung: aus gewähltem Output-Format
- Endung: aus dem gewählten Ausgabeformat
### Audio
- Root: `converter_audio_dir`
- Zielordner: `Converter Ausgabe (Audio)`
- Dateiname/Ordner: `Output-Template (Audio)`
- bei Shared-/Folder-Job: Ausgabe als Ordnerstruktur mit Trackdateien
## Queue und Start
Converter-Jobs laufen über dieselbe Pipeline-/Queue-Logik:
- sofortiger Start wenn Limits frei
- sonst Queue-Eintrag
Parallelität richtet sich nach den globalen Encode-Limits in `Settings`.
## Auto-Scan (Polling)
Mit aktiviertem Polling:
- `converter_raw_dir` wird zyklisch gescannt
- neue Dateien erscheinen automatisch im Explorer
- bei Shared- oder Folder-Jobs entsteht eine Ordnerstruktur mit mehreren Track-Dateien
Relevante Settings:
- `Auto-Scan (Polling)`
- `Polling-Intervall (Sekunden)`
- `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/ISO-Dateien
- `Audiobooks`: dedizierter AAX-Workflow mit Kapitel-/Audiobook-Metadaten
- `Converter`: beliebige Audio-, Video- und ISO-Dateien
- `Audiobooks`: spezialisierter AAX-Workflow mit Activation Bytes, Kapitelmetadaten und Kapiteltemplates
+45
View File
@@ -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
+37 -38
View File
@@ -6,7 +6,7 @@
Filter und Arbeitsmittel:
- Suche (Titel, IMDb, Kontextbegriffe)
- Suche
- Statusfilter
- Mediumfilter
- Sortierung
@@ -14,62 +14,61 @@ Filter und Arbeitsmittel:
Jeder Eintrag zeigt auf einen Blick:
- Titel, Jahr, Poster
- Titel, Jahr, Poster oder Cover
- Status und Zeitstempel
- RAW-/Output-Verfügbarkeit
- Medien- und ggf. Containerhinweise
- RAW- und Output-Verfügbarkeit
- Medien- und Containerhinweise
## Detaildialog: Was dort zusammenläuft
## Detaildialog
Der Detaildialog bündelt:
- Metadaten (Film oder Serie)
- Metadaten
- Jobstatus und Fehlertexte
- RAW-/Output-Pfade
- RAW- und Output-Pfade
- Encode-Plan und Trackauswahl
- ausgeführte Kommandodetails
- Prozesslog (`Tail` oder vollständig)
- ausgeführte Kommandos
- Prozesslog
- verfügbare Folgeaktionen
Bei Seriencontainern wird disk-/child-orientiert dargestellt, nicht nur als Einzeljob.
Bei Seriencontainern und Multipart-Jobs wird container- und child-orientiert dargestellt.
## Nachbearbeitungsaktionen und Wirkung
## Nachbearbeitungsaktionen
| Aktion | Typischer Einsatz | Technische Wirkung |
|---|---|---|
| `OMDb neu zuordnen` | falsches Match, Titel/Jahr korrigieren | Metadatenfelder werden neu geschrieben; Folder-Rename wird best effort angestoßen |
| `Review neu starten` | neue Track-/Titelbewertung nötig | Mediainfo/Review wird aus RAW neu aufgebaut |
| `RAW neu encodieren` | neuer Encode aus vorhandenem RAW | startet Encode ohne neuen Laufwerks-Rip |
| `Encode neu starten` | gleiche bestätigte Auswahl erneut fahren | letzter bestätigter Plan wird geladen, optional mit Bereinigung unvollständiger Ausgabe |
| `Retry Rippen` | Rip/Analyse neu anstoßen | neuer Rip-Lauf, inkl. erneuter RAW-Phase |
| `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 |
Löschaktionen im Dialog:
Relevante Settings:
- `RAW löschen`
- `Movie löschen`
- `Beides löschen`
- `Historieneintrag löschen`
- `Encode-Neustart: unvollständige Ausgabe löschen`
- `NFO nach Encode erzeugen`
## Löschvorschau und selektives Löschen
## Löschaktionen und Vorschau
Vor endgültigem Löschen zeigt Ripster eine Vorschau mit:
Vor dem endgültigen Löschen zeigt Ripster eine Vorschau mit:
- `RAW`-Kandidaten
- `Movie`-/Episoden-Kandidaten
- Related-Scope (Container/Kinder/verbundene Jobs)
- RAW-Kandidaten
- Movie-/Episode-Kandidaten
- Related-Scope für Container, Kinder und verbundene Jobs
Wichtige Punkte:
- Pfade sind selektierbar.
- Für RAW-Löschung muss bei Mehrfachkandidaten mindestens ein RAW-Pfad gewählt werden.
- Schutzlogik verhindert Löschungen außerhalb erlaubter Root-Verzeichnisse.
- 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 in der Historie
## Serien- und Multipart-Sicht
### Serien
- Container zeigt aggregierten Zustand aus Child-Jobs.
- Disk-/Episode-Aktionen können child-spezifisch ausgeführt werden.
- `Im Ripper öffnen` hilft bei `Bereit zum Encodieren`-Kindern.
- 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
@@ -79,11 +78,11 @@ Wichtige Punkte:
## Logs sinnvoll nutzen
- `Tail laden (800)` für schnellen Fehlerkontext
- `Vollständiges Log laden` für forensische Analyse
- `Tail laden` für schnellen Fehlerkontext
- `Vollständiges Log laden` für tiefe Analyse
Empfehlung:
1. Erst Tail prüfen.
2. Bei Pfad-/Toolproblemen vollständiges Log laden.
3. Danach passende Wiederanlauf-Aktion wählen.
1. erst den Tail prüfen
2. dann die Stelle im Log eingrenzen
3. anschließend die passende Wiederanlauf-Aktion wählen
+23 -15
View File
@@ -6,29 +6,37 @@ Dieses Kapitel ist das Bedienhandbuch für die Oberflächen im Alltag.
| Seite | Rolle im Betrieb | Typischer Einsatz |
|---|---|---|
| [Jobs](jobs.md) | zentrale Inbox | Gesamtlage prüfen und in Fachseite springen |
| [Ripper](ripper.md) | Live-Steuerung der Pipeline | Disc-Flow starten, Queue steuern, Review freigeben |
| [Audiobooks](audiobooks.md) | AAX-Spezialflow | Upload, Konfiguration und Encode von Audiobooks |
| [Settings](settings.md) | Konfiguration/Automatisierung | Pfade, Tools, Queue-Limits, Skripte, Cron |
| [Historie](history.md) | Nachbearbeitung und Kontrolle | Logs, Re-Encode, Löschvorschau, Recovery-Aktionen |
| [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 Historie bereitstellen |
| [Database (Expert)](database.md) | Recovery-Sicht | orphan RAW finden und importieren |
| [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 |
## Empfohlener Tagesablauf
1. `Jobs`: Statuslage und Auffälligkeiten prüfen.
2. `Ripper` oder `Converter`/`Audiobooks`: neue Arbeit starten.
3. `Historie`: Ergebnisse validieren und ggf. nachbearbeiten.
4. `Downloads`: Exporte für externe Übergabe erzeugen.
5. `Settings`: Regeln und Limits nur kontrolliert anpassen.
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
## Navigation
Direkt in der Top-Navigation:
- `Jobs`, `Ripper`, `Converter`, `Audiobooks`, `Settings`, `Historie`, `Downloads`
- `Ripper`
- `Converter`
- `Audiobooks`
- `Settings`
- `Historie`
- `Downloads`
Nur per URL:
Zusatzansichten:
- `Database` unter `/database`
- `Hardware` unter `/hardware`
- `Database` unter `/database` und im Expertenmodus zusätzlich in der Navigation
- `TMDb Migration` unter `/tmdb-migration` als Direktlink
-54
View File
@@ -1,54 +0,0 @@
# Jobs
`Jobs` ist die zentrale Inbox über alle Jobarten und dient als schneller Einstieg in den Tagesbetrieb.
## Zweck der Seite
Die Liste vereinheitlicht:
- Disc-/Ripper-Jobs
- Converter-Jobs
- Audiobook-Jobs
Dadurch kannst du unabhängig von der Fachseite zuerst den Gesamtzustand prüfen.
## Filterlogik
### View-Filter
- `Alle Jobs`
- `Ripper-View`
- `Converter-View`
- `Audiobooks`
### Status-Filter
- `Alle`
- `Aktiv`
- `Fehler/Abbruch`
Zusätzlich:
- Freitextsuche
- Paginierung
## Was ein Jobeintrag zeigt
- Job-ID und Titel
- Statusbadge
- Jobtypbadge (z. B. Blu-ray, DVD, Converter Audio, Audiobook)
- Aktualisierungszeit
Die Ansicht wird periodisch aktualisiert und ergänzt sich mit WebSocket-Events.
## Schnellaktion pro Zeile
- `Im Ripper öffnen` für Disc-/Audiobook-nahe Jobs
- `Im Converter öffnen` für Converter-Jobs
So kommst du ohne Umwege in die Oberfläche mit den passenden Detailaktionen.
## Empfohlener Einsatz
1. In `Jobs` Störungen und Auslastung identifizieren.
2. Danach gezielt in `Ripper`, `Converter` oder `Historie` springen.
+126 -67
View File
@@ -1,68 +1,76 @@
# Ripper
Die Seite `Ripper` ist die Live-Steuerung der Pipeline. Hier laufen Disc-Erkennung, Queue-Steuerung, Review und Encode-Start zusammen.
Die Seite `Ripper` ist die Live-Steuerung der Disc-Pipeline. Hier laufen Disc-Erkennung, Queue, manuelle Entscheidungen, Review und aktive Jobs zusammen.
## Seitenaufbau und Betriebslogik
## Seitenaufbau
Die Bereiche erscheinen in dieser Reihenfolge:
Die wichtigsten Bereiche:
1. `Hardware Monitoring`
1. `Hardware`
2. `Job Queue`
3. `Skript- / Cron-Status`
4. `Job Übersicht`
5. `Disk-Information`
## 1) Hardware Monitoring
## 1. Hardware
Zeigt kontinuierlich:
Die Hardware-Karte zeigt live:
- CPU / RAM
- GPU (wenn verfügbar)
- freie Speicherstände konfigurierter Pfade
- CPU
- RAM
- GPU, falls vorhanden
- freie Speicherstände der konfigurierten Pfade
Nutzen im Betrieb:
Aktionen:
- Engpässe vor Jobfehlern erkennen
- Pfadfüllstände vor Start großer Batchs prüfen
- Klick auf das Hardware-Symbol öffnet die Seite [Hardware](hardware.md)
Gesteuert über `Settings`:
Relevante Settings:
- `Hardware Monitoring aktiviert`
- `Hardware Monitoring Intervall (ms)`
- `Externe Speicher`
## 2) Job Queue
## 2. Job Queue
Es gibt zwei operative Zonen:
- `Laufende Jobs`
- `Warteschlange`
- laufende Jobs
- Warteschlange
Mögliche Queue-Einträge:
- Job-Startaktionen (`Start`, `Retry Rippen`, `RAW neu encodieren`, `Encode neu starten`, `Review neu berechnen`, `Audio CD starten`)
- Nicht-Job-Einträge (`Skript`, `Skriptkette`, `Warten`)
- normale Job-Starts
- `Retry Rippen`
- `RAW neu encodieren`
- `Encode neu starten`
- `Review neu starten`
- `Audio CD starten`
- `Skript`
- `Skriptkette`
- `Warten`
Wichtige Regeln:
- Queue-Reihenfolge ist per Drag-and-Drop änderbar.
- `Warten` blockiert nachfolgende Queue-Starts bis kein aktiver Prozess mehr läuft.
- `Skript` und `Skriptkette` werden als Queue-Einträge mitgeführt.
- 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
Parallelität kommt aus `Settings`:
Relevante Settings:
- `Max. parallele Film/Video Encodes`
- `Parallele Jobs`
- `Max. parallele Audio CD Jobs`
- `Max. Encodes gesamt`
- optional `Audio CDs: Queue-Reihenfolge überspringen`
- `Max. Encodes gesamt (medienunabhängig)`
- `Audio CDs: Queue-Reihenfolge überspringen`
## 3) Skript- / Cron-Status
## 3. Skript- / Cron-Status
Zeigt Runtime-Aktivitäten:
- laufende Skripte
- laufende Ketten
- Cron-Ausführungen
- letzte abgeschlossene Aktivitäten
- kürzlich abgeschlossene Aktivitäten
Aktionen:
@@ -70,66 +78,103 @@ Aktionen:
- `Abbrechen`
- `Liste leeren`
## 4) Job Übersicht
## 4. Job Übersicht
Die Jobliste ist der zentrale Arbeitsbereich. Ein Klick öffnet den jeweiligen `Pipeline-Status`.
### Badges und Kontext
### Zustandsabhängige Hauptaktionen
Neben Status/Fortschritt zeigt die Liste je nach Job:
- Serienkontext (`Serien-Container`, Staffel/Disk)
- Multipart-Kontext (`Multipart Movie`, Disc-Nr.)
- Queue-/Subjob-Kontext bei Serien-Episoden
### Zustandsabhängige Aktionen
| Zustand | Hauptaktion im Ripper | Ergebnis |
| Zustand | Typische Aktion | Ergebnis |
|---|---|---|
| `Medium erkannt`, `Bereit` | `Analyse starten` | startet MakeMKV-Analyse |
| `Metadatenauswahl` | `Metadaten öffnen` | Film/Serie auswählen, Provider festlegen |
| `Warte auf Auswahl` | Playlist/Titel/RAW-Entscheidung bestätigen | setzt Flow fort zur Prüfung |
| `Startbereit` | `Job starten` | startet Rip oder RAW-basierte Prüfung |
| `Bereit zum Encodieren` | `Encoding starten` | startet HandBrake mit bestätigter Auswahl |
| Laufend (`Analyse`, `Rippen`, `Encodieren`) | `Abbrechen` | setzt Job auf `Abgebrochen` |
| `Fehler`, `Abgebrochen` | `Retry Rippen` | neuer Rip-Anlauf |
| `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 |
Zusatzaktionen je nach Zustand und Jobtyp:
### Was `Warte auf Auswahl` heute bedeuten kann
- `Review neu starten`
- `Encode neu starten`
- `RAW neu encodieren`
- `Aus Queue löschen`
Der Status ist nicht nur für eine einzige Entscheidung da. Im aktuellen Stand gibt es drei typische Fälle:
### Review-Bereich (`Bereit zum Encodieren`)
#### Vorhandenes RAW
Hier wird die effektive Encode-Entscheidung festgelegt:
Ripster hat zu den Metadaten bereits ein passendes RAW gefunden. Dann entscheidest du:
- Titelwahl
- Audio-/Subtitle-Trackselektion
- User-Preset
- Pre-/Post-Encode-Skripte und Ketten
- HandBrake-Kommandovorschau
- mit RAW weiterarbeiten
- RAW löschen und neu rippen
- in passenden Film-Fällen optional Multipart mit Orphan-RAW bilden
Ohne Bestätigung dieses Blocks startet kein produktiver Encode.
#### Playlist-Auswahl
### RAW-Ordnerphasen (sichtbar über Logs/Details)
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 unvollständigem Rip
- `Rip_Complete_...` nach erfolgreich abgeschlossenem Rip, vor Encode
- `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 und Delete-Vorschau.
Das ist wichtig für:
## 5) Disk-Information
- Recovery
- RAW-Wiederverwendung
- Delete-Preview in `Historie`
## Disk-Information
Zeigt aktuelle Laufwerksdaten:
- Device-Pfad
- Modell
- Disc-Label / Mount
- Disc-Label
- Mount-Informationen
Aktionen:
@@ -139,13 +184,13 @@ Aktionen:
Hinweis:
- `Laufwerk neu lesen` ist nicht mehr an einen festen Pipeline-State gebunden.
- `Laufwerk neu lesen` ist nicht mehr an einen festen Pipeline-State gebunden
## Wichtige Dialoge
### Metadaten auswählen
Im Dialog werden Film-/Serienmetadaten bestätigt.
Im Dialog bestätigst du Film- oder Serienmetadaten aus TMDb.
Sonderfälle:
@@ -154,7 +199,7 @@ Sonderfälle:
### Vorhandenes RAW erkannt
Wenn passendes RAW existiert, fordert der Dialog eine explizite Entscheidung:
Wenn zu den Metadaten bereits RAW existiert, fordert Ripster eine explizite Entscheidung:
- vorhandenes RAW verwenden
- RAW verwerfen/löschen und neu rippen
@@ -166,3 +211,17 @@ 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)
+234 -58
View File
@@ -1,107 +1,283 @@
# Settings
Die Seite `Settings` steuert alle Betriebsparameter von Ripster: Pfade, Tools, Queue-Limits, Metadatenquellen, 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.
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 | Hauptzweck |
| Tab | Zweck |
|---|---|
| `Konfiguration` | globale und profilspezifische Laufzeitsettings |
| `Scripte` | einzelne Skripte verwalten und testen |
| `Skriptketten` | mehrstufige Abläufe aus Skript/Warten |
| `Encode-Presets` | wiederverwendbare HandBrake-Profile für Review |
| `Cronjobs` | zeitgesteuerte Ausführung von Skript/Kette |
| `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`
### Bedienprinzip
### Bedienlogik
1. Werte ändern.
2. `Änderungen speichern`.
3. erst danach sind Werte für neue Aktionen wirksam.
1. Felder ändern
2. `Änderungen speichern` klicken
3. erst dann gelten die neuen Werte für künftige Starts, Reviews oder Scheduler
Zusätzliche Bedienhilfen:
Ausnahmen:
- `Neu laden` verwirft lokale Formzustände
- `Änderungen verwerfen` setzt auf zuletzt gespeicherten Stand
- `PushOver Test` validiert Benachrichtigungszugang sofort
- `Expertenmodus` wird sofort gespeichert
- Buttons wie `PushOver Test`, `Coverarts prüfen` oder der MakeMKV-Betakey-Import lösen direkte Aktionen aus
### Was wirkt unmittelbar, was erst bei neuem Job?
### Toolbar-Aktionen
- Queue-/Parallelität wirkt auf neue Queue-Entscheidungen und Pump-Läufe.
- Tool-/Pfad-/Template-Änderungen wirken vollständig auf neu gestartete Schritte.
- Bereits laufende Prozesse übernehmen Änderungen in der Regel nicht rückwirkend.
- `Ä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
### Welche Bereiche im Alltag kritisch sind
### Was die Kategorien im Alltag steuern
| Bereich | Kritisch für |
|---|---|
| `Pfade` | wo RAW, finale Ausgaben, Converter/Audiobook-Dateien landen |
| `Tools` | ob Analyze/Rip/Encode ausführbar sind |
| `Metadaten` | Film-/Serienzuordnung und Episode-Mapping |
| `Queue-Limits` | Durchsatz und Reihenfolgeverhalten |
| `Monitoring` | Sichtbarkeit von Last und Speicherengpässen |
| `Benachrichtigungen` | PushOver-Ereignisse je Pipelinephase |
#### 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:
- anlegen, bearbeiten, löschen
- testen (`Test`)
- sortieren
- Skript anlegen
- Skript bearbeiten
- Skript löschen
- Skript testen
- Reihenfolge per Drag-and-Drop ändern
- Favorit markieren
Testausführung liefert:
Das Testergebnis zeigt:
- Erfolg oder Fehler
- Exit-Code
- Dauer
- stdout/stderr
Wichtig:
- Nur erfolgreiche Skripte sollten in produktive Pre-/Post-Listen übernommen werden.
- Timeout, falls `Script-Test Timeout (ms)` greift
## Tab `Skriptketten`
Ketten bestehen aus Schritten:
Skriptketten bestehen aus Schritten:
- `Skript`
- `Warten`
- `Warten (Sekunden)`
Typischer Einsatz:
Funktionen:
- externe Hooks vor/nach Encode
- gestaffelte Abläufe mit Pausen
- Ketten anlegen, bearbeiten, löschen
- Reihenfolge der Ketten ändern
- einzelne Ketten testen
- Schritte innerhalb der Kette per Drag-and-Drop sortieren
In der Queue erscheinen Ketten als eigene Einträge und werden dort ausgeführt.
Wichtig:
- 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.
- optionales HandBrake-Preset (`-Z`)
- optionale Extra-Args
- Medienziel (Universell/Blu-ray/DVD/Sonstiges)
### Standard-Zuordnungen
Im Ripper-Review kannst du Presets pro Job auswählen, statt Parameter jedes Mal neu zu setzen.
Es gibt vier feste Slots:
- `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 prüfen
- 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`
- Job-Logs ansehen
- Aktivierung/Pushover toggeln
- Lauf-Logs öffnen
Empfehlung:
Die Runtime-Aktivitäten der laufenden Cronjobs werden zusätzlich live angezeigt.
1. Skript/Kette manuell testen.
2. Dann Cron aktivieren.
3. Erste automatische Läufe aktiv im Runtime-Status kontrollieren.
## Expertenbereich: Activation Bytes Cache
## Verbindung zur Einstellungsreferenz
Dieser Block wird nur im Expertenmodus angezeigt.
Die vollständige Wirkung jeder einzelnen Einstellung steht in [Einstellungsreferenz](../configuration/settings-reference.md).
Er zeigt lokal bekannte AAX-Activation-Bytes mit:
- 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)
+52
View File
@@ -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.
+10 -2
View File
@@ -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 Ripper 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:
+7 -2
View File
@@ -1,6 +1,8 @@
# HandBrake
Ripster verwendet `HandBrakeCLI` für Scan und Encode.
Ripster verwendet `HandBrakeCLI` für Scan und Encode. Die mitgelieferte Datei
`bin/HandBrakeCLI` ist ein separat ausführbares Drittanbieterprogramm und steht
unter `GPL-2.0-only`; die Ripster-Lizenz ersetzt diese Lizenz nicht.
---
@@ -66,4 +68,7 @@ Ripster parst HandBrake-Stderr (Prozent/ETA/Detail) und sendet WebSocket-Progres
- Preset nicht gefunden: Preset-Namen mit `HandBrakeCLI -z` prüfen
- sehr langsames Encoding: Preset/Extra-Args prüfen (z. B. `--encoder-preset`)
Das Produktions-Installer-Script `install.sh` bietet eine Option zur Installation eines gebündelten HandBrakeCLI-Binaries mit NVDEC-Unterstützung (NVIDIA GPU-Dekodierung). Diese Option erscheint interaktiv während der Installation.
Das Produktions-Installer-Script `install.sh` bietet eine Option zur Installation
des gebündelten HandBrakeCLI-Binaries. Buildinformationen, Prüfsummen,
Lizenzhinweise und der Status des korrespondierenden Quellcodes liegen unter
`third_party/handbrake/`.
+4
View File
@@ -34,6 +34,10 @@ makemkvcon backup <source> <rawDir> --decrypt
## Registrierungsschlüssel (optional)
MakeMKV unterliegt eigenen Lizenz- und Registrierungsbedingungen. Ripster
lizenziert MakeMKV nicht um und darf diese Bedingungen nicht automatisch für den
Benutzer akzeptieren.
Wenn in `Settings` ein `MakeMKV Key` gesetzt ist, synchronisiert Ripster ihn nach:
```bash
+282 -123
View File
@@ -1,207 +1,366 @@
# Workflows aus Nutzersicht
Diese Seite beschreibt die produktiven Abläufe als Handbuch: welche Entscheidung an welcher Stelle fällt, welche Objekte dabei entstehen (Job, Container, Child-Job) und welche Dateien in welchen Ordnern landen.
Diese Seite beschreibt die produktiven Abläufe aus Sicht der Benutzer: welche Entscheidung wann anfällt, welche Jobs oder Container entstehen und welche Settings den Ablauf sichtbar beeinflussen.
## Gemeinsame Grundlagen
### Statuskette im Normalfall
### Typische Statuskette
`Medium erkannt` -> `Analyse` -> `Metadatenauswahl` -> `Startbereit` -> `Rippen` -> `Mediainfo-Pruefung` -> `Bereit zum Encodieren` -> `Encodieren` -> `Fertig`
`Medium erkannt` -> `Analyse` -> `Metadatenauswahl` -> `Startbereit` -> `Rippen` -> `Mediainfo-Prüfung` -> `Bereit zum Encodieren` -> `Encodieren` -> `Fertig`
Abweichungen:
Wichtige Abweichungen:
- bei Blu-ray/DVD mit nötiger Playlistwahl: `Mediainfo-Pruefung` -> `Warte auf Auswahl` -> zurück in `Mediainfo-Pruefung`
- bei vorhandenem RAW: `Startbereit` springt direkt in Review/Prüfung ohne neuen Laufwerks-Rip
- bei Fehler/Abbruch: `Fehler` oder `Abgebrochen` mit Wiederanlauf-Aktionen
- bei vorhandenem RAW: Sprung direkt in RAW-Entscheidung oder Review
- bei mehrdeutigen Blu-rays/DVDs: `Warte auf Auswahl` für Playlist- oder Titelauswahl
- bei Fehler oder Abbruch: `Fehler` oder `Abgebrochen` mit Folgeaktionen
- bei Audio-CDs und Audiobooks: eigener Spezialflow innerhalb derselben Gesamtpipeline
### Job- und Container-Typen
### Welche Settings fast überall hineinspielen
| Typ | Zweck | Was enthält der Typ? |
|---|---|---|
| Normaler Disc-Job | Einzel-Film oder einzelne Disc | RAW-Pfad, Output-Pfad, Logs, Review |
| `dvd_series_container` | logischer Serien-Container (pro Serie/Staffel) | Metadatenanker, aggregierter Status über Kinder |
| `dvd_series_child` | einzelne Serien-Disc oder Serien-Episode-Job | eigentliche RAW-/Encode-Arbeit |
| `multipart_movie_container` | logischer Multipart-Film-Container | Zusammenfassung für mehrere Film-Discs |
| `multipart_movie_child` | einzelne Disc innerhalb Multipart-Film | Disc-spezifische RAW-/Encode-Daten |
Die wichtigsten globalen Einflussfaktoren:
Wichtig:
- `Parallele Jobs`
- `Max. parallele Audio CD Jobs`
- `Max. Encodes gesamt (medienunabhängig)`
- `Audio CDs: Queue-Reihenfolge überspringen`
- `PushOver`-Ereignisse
- Pfade, Templates und Owner-Felder
- Container sind primär Ordnungs- und Statusobjekte.
- Dateien (RAW/Output) hängen an den Child-Jobs.
Für Disc-Video zusätzlich:
## Workflow A: Film (Blu-ray/DVD)
- `Minimale Titellänge (Minuten)`
- `TMDb Runtime-Abzug für Playlistfilter (%)`
- `HandBrake Preset`
- `HandBrake Extra Args`
- `Review Audio-Sprachen (...)`
- `Review UT-Sprachen (...)`
- Standard-Zuordnungen aus `Settings -> Encode-Presets`
### 1) Disc analysieren und Film-Metadaten wählen
## Workflow A: Film von Disc (Blu-ray / DVD)
1. Im `Ripper` `Analyse starten`.
2. Im Metadaten-Dialog Film auswählen (OMDb bzw. manuelle Film-Metadaten).
3. `Auswahl übernehmen`.
### 1. Disc analysieren und TMDb-Metadaten wählen
1. Im `Ripper` `Analyse starten`
2. im Metadaten-Dialog Film auswählen
3. `Auswahl übernehmen`
Wirkung:
- der Job wird als Film-Workflow geführt
- Film-Fingerprint für Duplikaterkennung wird gebildet (IMDb oder Titel/Jahr)
- Film-Metadaten, Poster und Fingerprint werden gesetzt
### 2) Duplikatfall entscheiden
Relevante Settings:
Wenn Film mit gleichem Fingerprint bereits existiert, gibt es zwei Wege:
- `TMDb Read Access Token`
- `Film-Sprache`
- `Fallback Sprache`
1. `Übernahme erzwingen` (`allow_new`): neuer unabhängiger Film-Job
2. `Multipart movie`: neuer Job wird mit bestehendem Job in einem Multipart-Container verknüpft
### 2. Duplikatfall entscheiden
Wenn ein Film mit gleichem Fingerprint bereits existiert:
1. `Übernahme erzwingen`: neuer unabhängiger Film-Job
2. `Multipart movie`: neuer Job wird mit bestehendem Film zu einem Multipart-Container verknüpft
Bei Multipart gilt:
- beide Discs brauchen unterschiedliche `Disc-Nummer`
- gleiche Disc-Nummer blockiert den Vorgang (`MULTIPART_DISC_ALREADY_EXISTS`)
- beide Discs brauchen unterschiedliche Disc-Nummern
- gleiche Disc-Nummern blockieren den Vorgang
### 3) RAW-Verzeichnis entsteht
### 3. RAW-Verzeichnis und RAW-Entscheidung
Ordnername basiert auf Metadaten plus Job-ID:
Wenn noch kein passendes RAW vorhanden ist:
- `Incomplete_<Titel> (<Jahr>) - RAW - job-<id>`
- Ripster legt ein neues RAW-Verzeichnis an
Zustandswechsel des RAW-Ordners:
Wenn bereits ein passendes RAW gefunden wird:
- beim Start: `Incomplete_...`
- nach erfolgreichem Rip: `Rip_Complete_...`
- nach erfolgreichem Encode: Prefix wird entfernt (final)
- der Job geht in `Warte auf Auswahl`
- du entscheidest, ob das vorhandene RAW weiterverwendet oder neu gerippt wird
### 4) Review und Encode
Relevante Settings:
In `Bereit zum Encodieren`:
- `Raw Ausgabeordner (Blu-ray)` und `Raw Ausgabeordner (DVD)`
- `Eigentümer Raw-Ordner (Blu-ray)` und `Eigentümer Raw-Ordner (DVD)`
- Titel wählen
- Audio-/Subtitle-Spuren festlegen
- optional User-Preset
- optional Pre-/Post-Encode-Skripte oder Ketten
### 4. Playlist- oder Titelauswahl
Beim Start:
Bei bestimmten Discs ist nach der Metadatenphase eine manuelle Auswahl nötig:
- temporäre Ausgabe in `Incomplete_job-<id>/<Datei>.<ext>`
- nach Erfolg Verschiebung in finalen Zielpfad aus Film-Template
- Playlist-Auswahl
- HandBrake-Titelauswahl
- bei Serien-Grenzfällen PlayAll- oder Doppelfolgen-Entscheidung
## Workflow B: Serie (Blu-ray/DVD)
Das ist der aktuelle `playlistflow`.
### 1) Serienmodus aktivieren
Relevante Settings:
1. Disc analysieren.
2. Im Metadaten-Dialog Serienmetadaten wählen (TMDb-basiert).
3. `Disc-Nummer` setzen (Pflicht, ab 1).
- `Minimale Titellänge (Minuten)`
- `TMDb Runtime-Abzug für Playlistfilter (%)`
- `Bei manueller Auswahl senden`
Praxis:
- zu hoher Mindestwert blendet legitime Alternativfassungen aus
- ein kleiner Runtime-Abzug hilft bei Discs, deren Hauptfilm minimal unter der TMDb-Laufzeit liegt
### 5. Review und Encode
Im Zustand `Bereit zum Encodieren` entscheidest du:
- welcher Titel encodiert wird
- welche Audio-Spuren übernommen werden
- welche Untertitel übernommen, erzwungen oder eingebrannt werden
- welches Preset gilt
- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen
Relevante Settings:
- `HandBrake Preset`
- `HandBrake Extra Args`
- `Review Audio-Sprachen (Blu-ray Film)` oder `Review Audio-Sprachen (DVD Film)`
- `Review UT-Sprachen (Blu-ray Film)` oder `Review UT-Sprachen (DVD Film)`
- `Ausgabeformat`
- Standard-Userpreset für Film unter `Settings -> Encode-Presets`
### 6. Finale Ausgabe
Finale Ausgabe:
- Zielordner: `Film Ausgabeordner (Blu-ray)` oder `Film Ausgabeordner (DVD)`
- Dateiname und Ordner: `Output Template (Blu-ray)` oder `Output Template (DVD)`
Optional:
- `.nfo` direkt nach Encode
Relevante Settings:
- `Film Ausgabeordner (Blu-ray)` und `Film Ausgabeordner (DVD)`
- `Eigentümer Film-Ordner (Blu-ray)` und `Eigentümer Film-Ordner (DVD)`
- `Output Template (Blu-ray)` und `Output Template (DVD)`
- `NFO nach Encode erzeugen`
## Workflow B: Serie von Disc (Blu-ray / DVD)
### 1. Serienmodus festlegen
1. Disc analysieren
2. im Metadaten-Dialog Serienmetadaten wählen
3. `Disc-Nummer` setzen
Wirkung:
- Workflow-Kind wird `series`
- Seriencontainer wird gesucht/angelegt
- aktueller Job wird als `dvd_series_child` darunter geführt
- Ripster arbeitet als Serien-Workflow
- Seriencontainer wird gesucht oder angelegt
- der Job wird als Child unter diesem Container geführt
### 2) Seriencontainer und Disc-Konflikte
### 2. Serien-RAW und Staffelkontext
Beim Speichern wird geprüft:
Für Serien bevorzugt Ripster eigene Serien-RAW-Verzeichnisse:
- existiert für Serie/Staffel bereits ein Container?
- existiert in diesem Container bereits dieselbe `Disc-Nummer`?
- `RAW-Ordner (Blu-ray Serie)`
- `RAW-Ordner (DVD Serie)`
Wenn ja, wird blockiert (`SERIES_DISC_ALREADY_EXISTS`).
Falls leer:
### 3) Serien-RAW-Pfad
- Fallback auf `Raw Ausgabeordner (Blu-ray)` bzw. `Raw Ausgabeordner (DVD)`
Für Serien wird ein eigener RAW-Pfad bevorzugt:
### 3. Episodenprüfung und Multi-Episode-Fälle
- Blu-ray: `raw_dir_bluray_series` (Fallback: `raw_dir_bluray`)
- DVD: `raw_dir_dvd_series` (Fallback: `raw_dir_dvd`)
In der Review werden Episoden oder Mehrfachfolgen aufgelöst.
RAW-Namensbasis enthält Staffel/Disc (`SxDy`) und wird ebenfalls mit den Prefix-Phasen geführt:
Ausgabevarianten:
- `Incomplete_...` -> `Rip_Complete_...` -> final
- Einzel-Episode
- Multi-Episode-Datei
### 4) Serien-Review und Episoden-Ausgabe
Relevante Settings:
Beim Review werden Episoden-/Titelzuordnungen genutzt. Je nach Auswahl:
- `Output Template (Blu-ray Serie, Episode)`
- `Output Template (Blu-ray Serie, Multi-Episode)`
- `Output Template (DVD Serie, Episode)`
- `Output Template (DVD Serie, Multi-Episode)`
- `Serien-Ordner (Blu-ray)`
- `Serien-Ordner (DVD)`
- Einzel-Episode: Episode-Template
- Multi-Episode-Titel: Multi-Episode-Template
### 4. Playlistflow auch bei Serien
Ausgabepfad:
Auch Serien können vor der Review in eine Playlist- oder Titelauswahl laufen.
- Root: `series_dir_*` (Fallback auf `movie_dir_*`)
- Dateiname/Unterordner aus Serien-Template
Zusätzlich wirken:
### 5) Multi-Episode-Disc als Batch
Wenn mehrere Episoden-Titel aus einer Disc encodiert werden:
- Ripster erzeugt Episode-Child-Jobs
- der ursprüngliche Disc-Job dient als Batch-Anker
- RAW-Finalisierung erfolgt erst, wenn alle Episode-Encodes abgeschlossen sind
- `Review Audio-Sprachen (Blu-ray Serie)` / `Review Audio-Sprachen (DVD Serie)`
- `Review UT-Sprachen (Blu-ray Serie)` / `Review UT-Sprachen (DVD Serie)`
- Serien-Default-Presets unter `Settings -> Encode-Presets`
## Workflow C: Multipart-Film
### Wann dieser Flow gedacht ist
### Wann der Flow gedacht ist
- gleicher Film auf mehreren Discs (z. B. Disc 1/Disc 2)
- Zusammengehörigkeit soll in Historie und Detailansichten sichtbar sein
- gleicher Film auf mehreren Discs
- die Discs sollen in Historie und Detailansichten zusammengehören
### Ablauf
1. Film-Metadaten wählen.
2. Duplikatdialog -> `Multipart movie`.
3. Disc-Nummern für bestehenden und neuen Job festlegen.
1. Film-Metadaten wählen
2. Duplikatdialog `Multipart movie`
3. Disc-Nummern für bestehenden und neuen Job festlegen
Wirkung:
- `multipart_movie_container` wird angelegt oder wiederverwendet
- beteiligte Jobs werden `multipart_movie_child`
- Container führt gemeinsame Metadaten, Kinder führen je Disc die eigentlichen Artefakte
- die Discs werden als Child-Jobs darunter geführt
- zusätzlich wird ein Merge-Job vorbereitet
## Workflow D: Vorhandenes RAW / RAW-Import
Relevante Settings:
### Fall 1: RAW bereits vorhanden bei Metadatenübernahme
- `mkvmerge_command`
- Film-Output-Templates
- Queue-Limits, falls mehrere Discs parallel weiterverarbeitet werden
Nach Metadaten-Übernahme erkennt Ripster passende RAW-Ordner und setzt den Job auf Entscheidungsstatus.
## Workflow D: Audio-CD
Möglichkeiten:
### 1. TOC lesen und Metadaten wählen
- vorhandenes RAW weiterverwenden (kein neuer Rip)
- RAW löschen/ignorieren und neu rippen
1. Audio-CD analysieren
2. Tracks prüfen
3. Metadaten aus MusicBrainz übernehmen oder manuell anpassen
### Fall 2: Orphan-RAW aus `/database`
### 2. Trackauswahl und Format
1. In `Database` `RAW prüfen`.
2. Bei orphan RAW `Job anlegen`.
3. Job wird in Historie erzeugt und wie "Disk analysieren aus RAW" weiterverarbeitet.
Vor dem Start legst du fest:
## Workflow E: Nachbearbeitung bestehender Jobs
- welche Tracks übernommen werden
- welches Ausgabeformat gilt
- welche Formatoptionen genutzt werden
- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen
In `Historie` (Detaildialog):
### 3. Rip und Encode
| Aktion | Wirkung |
|---|---|
| `OMDb neu zuordnen` | Metadaten neu schreiben, Folder-Rename wird best effort angestoßen |
| `Review neu starten` | Titel-/Spurprüfung aus aktuellem RAW neu aufbauen |
| `RAW neu encodieren` | Encode erneut aus vorhandenem RAW starten |
| `Encode neu starten` | letzte bestätigte Review-Einstellungen wiederverwenden |
| `Retry Rippen` | Rip/Analyse neu starten; alter Job-RAW kann bereinigt werden |
Die CD läuft anschließend als Track-für-Track-Flow:
## Wo landet was? (Artefakt-Matrix)
`CD-Metadatenauswahl` -> `CD-Startbereit` -> `CD-Rippen` -> `CD-Encodieren` -> `Fertig`
| Artefakt | Film | Serie | Multipart |
|---|---|---|---|
| RAW | `raw_dir_*` | bevorzugt `raw_dir_*_series` | pro Child-Job im normalen Film-RAW-Schema |
| Finale Ausgabe | `movie_dir_*` + Film-Template | `series_dir_*` (Fallback `movie_dir_*`) + Serien-Templates | pro Child-Job normaler Film-Output |
| Container-Datensatz | nein | ja (`dvd_series_container`) | ja (`multipart_movie_container`) |
| Löschvorschau in Historie | einzelner Job oder Related Scope | Container-/Child-bezogen mit Pfadauswahl | Container-/Child-bezogen mit Pfadauswahl |
Relevante Settings:
## Queue-Verhalten im Alltag
Die Startfreigabe hängt an vier Settings:
- `Max. parallele Film/Video Encodes`
- `CD RAW-Ordner`
- `CD Output-Ordner`
- `Eigentümer CD RAW-Ordner`
- `Eigentümer CD Output-Ordner`
- `CD Output Template`
- `cdparanoia Kommando`
- `Max. parallele Audio CD Jobs`
- `Max. Encodes gesamt`
- `Audio CDs: Queue-Reihenfolge überspringen`
Praxis:
## Workflow E: Converter
- Film/Video- und Audio/CD laufen in getrennten Lanes
- Gesamtlimit begrenzt beide gemeinsam
- Queue kann neben Jobs auch `Skript`, `Kette`, `Warten` enthalten
### 1. Dateien bereitstellen
Dateien gelangen in den Converter über:
- Upload
- bestehende Ordner im `Converter Raw-Ordner`
- manuelle Dateiverwaltung im Explorer
### 2. Jobs aus Auswahl erzeugen
- Video und ISO: immer ein Job pro Datei
- Audio: ein Job pro Datei oder gemeinsamer Audio-Job
### 3. Job konfigurieren und starten
Je nach Medium:
- Video/ISO: Review mit Track- und Preset-Auswahl
- Audio: direkte Format- und Zielkonfiguration
Relevante Settings:
- `Erlaubte Datei-Endungen`
- `Auto-Scan (Polling)`
- `Polling-Intervall (Sekunden)`
- `Converter Raw-Ordner`
- `Converter Ausgabe (Video)`
- `Converter Ausgabe (Audio)`
- `Output-Template (Video)`
- `Output-Template (Audio)`
- globale Queue-Limits
## Workflow F: Audiobooks
### 1. AAX hochladen
1. Datei hochladen
2. Metadaten und Kapitel aufbauen lassen
3. bei Bedarf Activation Bytes klären
### 2. Ausgabeformat wählen
- `m4b`: eine Datei
- `mp3`: eine Datei pro Kapitel
- `flac`: eine Datei pro Kapitel
### 3. Kapitel und Skripte prüfen
Vor dem Start anpassbar:
- Kapitelnamen
- Pre-Encode-Skripte und -Ketten
- Post-Encode-Skripte und -Ketten
Relevante Settings:
- `Audiobook RAW-Ordner`
- `Audiobook Output-Ordner`
- `Audiobook RAW Template`
- `Output Template (Audiobook)`
- `Kapitel Template (Audiobook)`
- `FFprobe Kommando`
- `FFmpeg Kommando`
- globale Queue-Limits
## Workflow G: Nachbearbeitung in der Historie
Typische Folgeaktionen:
- `TMDb neu zuweisen`
- `Review neu starten`
- `RAW neu encodieren`
- `Encode neu starten`
- `Retry Rippen`
- `Download` als ZIP einreihen
Besonders wichtig:
- `Encode neu starten` verwendet den letzten bestätigten Plan
- `Encode-Neustart: unvollständige Ausgabe löschen` entscheidet, ob unvollständige Ausgaben vorher gelöscht werden
## Workflow H: Downloads und Recovery
### Downloads
Aus `Historie` können ZIPs für:
- `RAW`
- `Output`
erstellt werden.
Relevante Settings:
- `Download ZIP-Ordner`
- `Eigentümer Download ZIP-Ordner`
### Database (Expert)
Die Recovery-Seite hilft bei orphan RAW:
- `RAW prüfen`
- `Job anlegen`
So lassen sich vorhandene RAW-Verzeichnisse wieder in einen normalen Workflow zurückführen.
### TMDb Migration
Für ältere Bestandsjobs gibt es die Sonderseite `/tmdb-migration`, um mehrere Jobs nacheinander auf den heutigen TMDb-Stand zu bringen.
+20 -2
View File
@@ -1,13 +1,15 @@
{
"name": "ripster-frontend",
"version": "0.16.1-1",
"version": "1.0.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.16.1-1",
"version": "1.0.0-1",
"license": "GPL-2.0-or-later",
"dependencies": {
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
"react": "^18.3.1",
@@ -703,6 +705,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1191,6 +1198,17 @@
}
]
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+11 -1
View File
@@ -1,7 +1,16 @@
{
"name": "ripster-frontend",
"version": "0.16.1-1",
"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": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +18,7 @@
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
"react": "^18.3.1",
+252 -154
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client';
@@ -13,8 +13,9 @@ import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
import JobsInboxPage from './pages/JobsInboxPage';
import AudiobooksPage from './pages/AudiobooksPage';
import TmdbMigrationPage from './pages/TmdbMigrationPage';
import HardwarePage from './pages/HardwarePage';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -24,6 +25,20 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
@@ -36,28 +51,57 @@ function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isRunningStage(value) {
const normalized = normalizeStage(value);
return normalized === 'ANALYZING'
|| normalized === 'RIPPING'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'ENCODING'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING';
}
function isTerminalStage(value) {
const normalized = normalizeStage(value);
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function formatBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 'n/a';
function isInteractiveReviewStage(value) {
const normalized = normalizeStage(value);
return normalized === 'METADATA_LOOKUP'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'WAITING_FOR_USER_DECISION'
|| normalized === 'READY_TO_ENCODE';
}
function parseTimestamp(value) {
if (!value) {
return 0;
}
if (parsed === 0) {
return '0 B';
const ts = Date.parse(String(value));
return Number.isFinite(ts) ? ts : 0;
}
function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) {
const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {};
const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {};
const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null;
const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null;
if (!prevQueue || !incomingQueue) {
return {
...prev,
...incoming
};
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
let current = parsed;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
const digits = unitIndex <= 1 ? 0 : 2;
return `${current.toFixed(digits)} ${units[unitIndex]}`;
const prevQueueTs = parseTimestamp(prevQueue.updatedAt);
const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt);
const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue;
return {
...prev,
...incoming,
queue
};
}
function createInitialAudiobookUploadState() {
@@ -70,28 +114,14 @@ function createInitialAudiobookUploadState() {
statusText: null,
errorMessage: null,
jobId: null,
uploadSessionId: null,
fileLastModified: null,
fileFingerprint: null,
startedAt: null,
finishedAt: null
};
}
function getAudiobookUploadTagMeta(phase) {
const normalized = String(phase || '').trim().toLowerCase();
if (normalized === 'uploading') {
return { label: 'Upload läuft', severity: 'warning' };
}
if (normalized === 'processing') {
return { label: 'Server verarbeitet', severity: 'info' };
}
if (normalized === 'completed') {
return { label: 'Bereit', severity: 'success' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Inaktiv', severity: 'secondary' };
}
function getDownloadIndicatorMeta(summary) {
const activeCount = Number(summary?.activeCount || 0);
const failedCount = Number(summary?.failedCount || 0);
@@ -118,6 +148,11 @@ function getDownloadIndicatorMeta(summary) {
};
}
const HARDWARE_MONITOR_SETTING_KEYS = new Set([
'hardware_monitoring_enabled',
'hardware_monitoring_interval_ms'
]);
function App() {
const appVersion = __APP_VERSION__;
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
@@ -129,11 +164,20 @@ function App() {
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = useState(null);
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({});
const prevJobProgressStatesRef = useRef({});
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
const audiobookUploadAbortRef = useRef(null);
const clearHardwareWarmupRetry = () => {
if (hardwareWarmupRef.current?.timer) {
clearTimeout(hardwareWarmupRef.current.timer);
hardwareWarmupRef.current.timer = null;
}
};
// When a virtual CD drive is removed (CD encode/rip finished or failed),
// or when a CD drive transitions away from an active job, force both
@@ -175,90 +219,148 @@ function App() {
prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]);
// Trigger a jobs refresh when a tracked job transitions into a terminal or
// interactive review state through PIPELINE_PROGRESS, so Ripper/History
// update immediately without a manual page reload.
useEffect(() => {
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
? pipeline.jobProgress
: {};
const previousStates = prevJobProgressStatesRef.current || {};
const nextStates = {};
let shouldRefresh = false;
for (const [jobId, entry] of Object.entries(current)) {
const currentState = normalizeStage(entry?.state);
const previousState = normalizeStage(previousStates[jobId]);
if (currentState) {
nextStates[jobId] = currentState;
}
if (
currentState
&& isTerminalStage(currentState)
&& currentState !== previousState
&& !isTerminalStage(previousState)
) {
shouldRefresh = true;
}
if (
currentState
&& isInteractiveReviewStage(currentState)
&& currentState !== previousState
) {
shouldRefresh = true;
}
}
prevJobProgressStatesRef.current = nextStates;
if (shouldRefresh) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}, [pipeline?.jobProgress]);
const refreshPipeline = async () => {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
setHardwareMonitoring(response?.hardwareMonitoring || null);
const monitoringPayload = response?.hardwareMonitoring || null;
setHardwareMonitoring(monitoringPayload);
const monitoringEnabled = Boolean(monitoringPayload?.enabled);
const hasSample = Boolean(monitoringPayload?.sample && typeof monitoringPayload.sample === 'object');
if (monitoringEnabled && !hasSample) {
const nextAttempts = Number(hardwareWarmupRef.current?.attempts || 0) + 1;
hardwareWarmupRef.current.attempts = nextAttempts;
if (nextAttempts <= 10 && !hardwareWarmupRef.current.timer) {
hardwareWarmupRef.current.timer = setTimeout(() => {
hardwareWarmupRef.current.timer = null;
refreshPipeline().catch(() => null);
}, 1000);
}
} else {
hardwareWarmupRef.current.attempts = 0;
clearHardwareWarmupRetry();
}
return response;
};
const clearAudiobookUpload = () => {
setAudiobookUpload(createInitialAudiobookUploadState());
};
const handleAudiobookUpload = async (file, payload = {}) => {
if (!file) {
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
}
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
? Number(file.size)
: 0;
const fileName = String(file.name || '').trim() || 'upload.aax';
const totalBytes = Number(file.size || 0);
const abortController = new AbortController();
audiobookUploadAbortRef.current = abortController;
setAudiobookUpload({
phase: 'uploading',
fileName: String(file.name || '').trim() || 'upload.aax',
fileName,
loadedBytes: 0,
totalBytes: fallbackTotalBytes,
totalBytes,
progressPercent: 0,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null,
jobId: null,
uploadSessionId: null,
fileLastModified: Number.isFinite(Number(file.lastModified))
? Math.trunc(Number(file.lastModified))
: null,
fileFingerprint: null,
startedAt: new Date().toISOString(),
finishedAt: null
});
try {
const response = await api.uploadAudiobook(file, payload, {
signal: abortController.signal,
onProgress: ({ loaded, total, percent }) => {
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
? Number(loaded)
: 0;
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
? Number(total)
: fallbackTotalBytes;
const nextPercent = Number.isFinite(Number(percent))
const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0;
const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes;
const progressPercent = Number.isFinite(Number(percent))
? clampPercent(Number(percent))
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
: (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0);
setAudiobookUpload((prev) => ({
...prev,
phase: transferComplete ? 'processing' : 'uploading',
loadedBytes: nextLoaded,
totalBytes: nextTotal,
progressPercent: nextPercent,
statusText: transferComplete
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
: 'AAX-Datei wird hochgeladen ...'
phase: 'uploading',
loadedBytes,
totalBytes: totalSize,
progressPercent,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null
}));
}
});
const uploadedJobId = normalizeJobId(response?.result?.jobId);
const uploadedJobId = extractUploadJobIdFromResponse(response);
const started = Boolean(response?.result?.started);
const queued = Boolean(response?.result?.queued);
await refreshPipeline().catch(() => null);
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingRipperJobId(uploadedJobId);
}
setAudiobookUpload((prev) => ({
...prev,
phase: 'completed',
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
totalBytes: prev.totalBytes || fallbackTotalBytes,
loadedBytes: prev.totalBytes || totalBytes,
totalBytes: prev.totalBytes || totalBytes,
progressPercent: 100,
statusText: uploadedJobId
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
? (
queued
? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.`
: (started
? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.`
: `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`)
)
: 'Upload abgeschlossen.',
errorMessage: null,
jobId: uploadedJobId,
finishedAt: new Date().toISOString()
}));
return response;
} catch (error) {
if (error?.name === 'AbortError') {
setAudiobookUpload(createInitialAudiobookUploadState());
throw error;
}
setAudiobookUpload((prev) => ({
...prev,
phase: 'error',
@@ -267,17 +369,20 @@ function App() {
finishedAt: new Date().toISOString()
}));
throw error;
} finally {
if (audiobookUploadAbortRef.current === abortController) {
audiobookUploadAbortRef.current = null;
}
}
};
const handleRipperJobFocusConsumed = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
const handleCancelAudiobookUpload = () => {
const activeAbortController = audiobookUploadAbortRef.current;
if (activeAbortController) {
activeAbortController.abort();
return;
}
setPendingRipperJobId((prev) => (
normalizeJobId(prev) === normalizedJobId ? null : prev
));
setAudiobookUpload(createInitialAudiobookUploadState());
};
useEffect(() => {
@@ -294,12 +399,24 @@ function App() {
setExpertMode(val === 'true' || val === true);
})
.catch(() => null);
return () => {
clearHardwareWarmupRetry();
};
}, []);
useWebSocket({
onMessage: (message) => {
if (message.type === 'WS_CONNECTED') {
refreshPipeline().catch(() => null);
}
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline(message.payload);
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
const nextState = normalizeStage(message?.payload?.state);
if (isTerminalStage(nextState) || isInteractiveReviewStage(nextState)) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}
if (message.type === 'PIPELINE_PROGRESS') {
@@ -315,17 +432,24 @@ function App() {
const isCdProgressStage = progressStage === 'CD_ANALYZING'
|| progressStage === 'CD_RIPPING'
|| progressStage === 'CD_ENCODING';
const incomingIsRunning = isRunningStage(progressStage);
const incomingIsTerminal = isTerminalStage(progressStage);
const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId);
const prevJobProgress = normalizedProgressJobId
? (prev?.jobProgress?.[normalizedProgressJobId] || null)
: null;
const prevJobProgressState = normalizeStage(prevJobProgress?.state);
const prevJobProgressIsRunning = isRunningStage(prevJobProgressState);
const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState);
const matchingCdDriveEntry = normalizedProgressJobId
? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId)
: null;
const matchingCdDriveState = normalizeStage(matchingCdDriveEntry?.state);
const matchingCdDriveIsRunning = isRunningStage(matchingCdDriveState);
const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state);
const previousGlobalStage = normalizeStage(prev?.state);
const previousGlobalIsRunning = isRunningStage(previousGlobalStage);
const previousGlobalIsTerminal = isTerminalStage(previousGlobalStage);
const hasKnownBinding = Boolean(
normalizedProgressJobId
&& (
@@ -334,16 +458,43 @@ function App() {
|| matchingCdDriveEntry
)
);
const regressesStableJobState = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& (
(prevJobProgressState && !prevJobProgressIsRunning && !prevJobProgressIsTerminal)
|| (matchingCdDriveState && !matchingCdDriveIsRunning && !matchingCdDriveIsTerminal)
|| (
prevActiveJobId === normalizedProgressJobId
&& previousGlobalStage
&& !previousGlobalIsRunning
&& !previousGlobalIsTerminal
)
)
);
const isUnknownRunningProgress = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& !hasKnownBinding
);
const allowRunningTransitionFromTerminal = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& prevActiveJobId === normalizedProgressJobId
);
// Ignore late/stale progress packets that arrive after a terminal state
// or for jobs that are no longer bound in pipeline state.
// or regress a job from an interactive/ready state back into an
// earlier running stage because an older progress packet arrived late.
if (
normalizedProgressJobId
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal
&& (
prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal
|| !hasKnownBinding
|| isUnknownRunningProgress
|| regressesStableJobState
)
) {
return prev;
@@ -352,7 +503,9 @@ function App() {
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const previousJobStage = normalizeStage(previousJobProgress?.state);
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
const keepPreviousJobStage = isTerminalStage(previousJobStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
const mergedJobContext = contextPatch
? {
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
@@ -376,8 +529,9 @@ function App() {
};
}
if (progressJobId === prev?.activeJobId || progressJobId == null) {
const previousGlobalStage = normalizeStage(prev?.state);
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
@@ -478,10 +632,15 @@ function App() {
const val = setting?.value;
setExpertMode(val === 'true' || val === true);
}
const normalizedKey = String(setting?.key || '').trim().toLowerCase();
if (HARDWARE_MONITOR_SETTING_KEYS.has(normalizedKey)) {
refreshPipeline().catch(() => null);
}
}
if (message.type === 'SETTINGS_BULK_UPDATED') {
const keys = message.payload?.keys || [];
const normalizedKeys = keys.map((key) => String(key || '').trim().toLowerCase());
if (keys.includes('ui_expert_mode')) {
api.getSettings({ forceRefresh: true })
.then((response) => {
@@ -491,6 +650,9 @@ function App() {
})
.catch(() => null);
}
if (normalizedKeys.some((key) => HARDWARE_MONITOR_SETTING_KEYS.has(key))) {
refreshPipeline().catch(() => null);
}
}
if (message.type === 'DOWNLOADS_UPDATED') {
@@ -529,7 +691,6 @@ function App() {
});
const nav = [
{ label: 'Jobs', path: '/jobs' },
{ label: 'Ripper', path: '/ripper' },
{ label: 'Converter', path: '/converter' },
{ label: 'Audiobooks', path: '/audiobooks' },
@@ -538,18 +699,6 @@ function App() {
{ label: 'Downloads', path: '/downloads' },
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
];
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const showAudiobookUploadBanner = uploadPhase !== 'idle';
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
const uploadBytesLabel = uploadTotalBytes > 0
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
@@ -589,57 +738,6 @@ function App() {
</div>
</header>
{showAudiobookUploadBanner ? (
<section className={`app-upload-banner phase-${uploadPhase}`}>
<div className="app-upload-banner-copy">
<div className="app-upload-banner-head">
<strong>Audiobook Upload</strong>
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
</div>
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
</div>
<div
className="app-upload-banner-progress"
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.trunc(uploadProgress)}%`}
</small>
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isAudiobooksRoute ? (
<Button
label="Zu Audiobooks"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
navigate('/audiobooks');
}}
/>
) : null}
{canDismissUploadBanner ? (
<Button
icon="pi pi-times"
rounded
text
severity="secondary"
aria-label="Upload-Hinweis schliessen"
onClick={clearAudiobookUpload}
/>
) : null}
</div>
</section>
) : null}
<main className="app-main">
<Routes>
<Route
@@ -651,28 +749,28 @@ function App() {
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
jobsRefreshToken={ripperJobsRefreshToken}
pendingExpandedJobId={pendingRipperJobId}
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
downloadSummary={downloadSummary}
>
<Outlet />
</RipperPage>
}
>
<Route index element={<Navigate to="jobs" replace />} />
<Route index element={<Navigate to="ripper" replace />} />
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="tmdb-migration" element={<TmdbMigrationPage />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="jobs" element={<JobsInboxPage />} />
<Route path="converter" element={<ConverterPage />} />
<Route path="hardware" element={<HardwarePage hardwareMonitoring={hardwareMonitoring} />} />
<Route
path="audiobooks"
element={
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
onCancelAudiobookUpload={handleCancelAudiobookUpload}
/>
}
/>
+106 -14
View File
@@ -267,6 +267,12 @@ async function requestWithXhr(path, options = {}) {
xhr.onerror = () => {
settle(() => {
if (Number(xhr.status || 0) === 0) {
const abortLikeError = new Error('Request unterbrochen.');
abortLikeError.name = 'AbortError';
reject(abortLikeError);
return;
}
reject(new Error('Netzwerkfehler'));
});
};
@@ -382,6 +388,13 @@ export const api = {
forceRefresh: options.forceRefresh
});
},
async checkMakeMKVBetaKey() {
const result = await request('/settings/makemkv/beta-key/check', {
method: 'POST'
});
afterMutationInvalidate(['/settings/makemkv/beta-key']);
return result;
},
async applyMakeMKVBetaKey(payload = {}) {
const result = await request('/settings/makemkv/beta-key/apply', {
method: 'POST',
@@ -422,6 +435,14 @@ export const api = {
afterMutationInvalidate(['/settings/scripts']);
return result;
},
async setScriptFavorite(scriptId, isFavorite) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}/favorite`, {
method: 'PUT',
body: JSON.stringify({ isFavorite: isFavorite === true })
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
async deleteScript(scriptId) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
method: 'DELETE'
@@ -466,6 +487,14 @@ export const api = {
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
async setScriptChainFavorite(chainId, isFavorite) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}/favorite`, {
method: 'PUT',
body: JSON.stringify({ isFavorite: isFavorite === true })
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
async deleteScriptChain(chainId) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
method: 'DELETE'
@@ -520,6 +549,21 @@ export const api = {
getPipelineState() {
return request('/pipeline/state');
},
getHardwareHistory(options = {}) {
const hoursRaw = Number(options?.hours);
const maxPointsRaw = Number(options?.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 query = new URLSearchParams({
hours: String(hours),
maxPoints: String(maxPoints)
});
const path = `/pipeline/hardware/history?${query.toString()}`;
return requestCachedGet(path, {
ttlMs: 10 * 1000,
forceRefresh: options.forceRefresh
});
},
getRuntimeActivities() {
return request('/runtime/activities');
},
@@ -564,8 +608,8 @@ export const api = {
});
return result;
},
searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
searchTmdbMovie(q) {
return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`);
},
searchTmdbSeries(q, seasonNumber = null) {
const params = new URLSearchParams();
@@ -575,8 +619,14 @@ export const api = {
}
return request(`/pipeline/tmdb/series/search?${params.toString()}`);
},
searchMusicBrainz(q) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
searchMusicBrainz(q, options = {}) {
const params = new URLSearchParams();
params.set('q', String(q || ''));
const expectedTrackCount = Number(options?.trackCount);
if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) {
params.set('trackCount', String(Math.trunc(expectedTrackCount)));
}
return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`);
},
getMusicBrainzRelease(mbId) {
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
@@ -628,9 +678,6 @@ export const api = {
getAudiobookJobs() {
return request('/pipeline/audiobook/jobs');
},
getAudiobookOutputTree() {
return request('/pipeline/audiobook/output-tree');
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',
@@ -700,9 +747,12 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async retryJob(jobId) {
async retryJob(jobId, options = {}) {
const body = {};
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/retry/${jobId}`, {
method: 'POST'
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
@@ -730,6 +780,7 @@ export const api = {
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.reuseCurrentJob) body.reuseCurrentJob = true;
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -742,6 +793,7 @@ export const api = {
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.restartMode) body.restartMode = options.restartMode;
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/restart-encode/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -816,6 +868,24 @@ export const api = {
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history${suffix}`);
},
getTmdbMigrationPendingJobs(params = {}) {
const query = new URLSearchParams();
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
query.set('limit', String(Math.trunc(Number(params.limit))));
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history/tmdb-migration/pending${suffix}`);
},
async setJobTmdbMigrationFlag(jobId, migrated = false) {
const result = await request(`/history/${jobId}/tmdb-migration`, {
method: 'POST',
body: JSON.stringify({
migrated: Boolean(migrated)
})
});
afterMutationInvalidate(['/history']);
return result;
},
getOrphanRawFolders() {
return request('/history/orphan-raw');
},
@@ -827,8 +897,16 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async assignJobOmdb(jobId, payload = {}) {
const result = await request(`/history/${jobId}/omdb/assign`, {
async deleteOrphanRawFolder(rawPath) {
const result = await request('/history/orphan-raw/delete', {
method: 'POST',
body: JSON.stringify({ rawPath })
});
afterMutationInvalidate(['/history']);
return result;
},
async assignJobMetadata(jobId, payload = {}) {
const result = await request(`/history/${jobId}/metadata/assign`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
@@ -1012,12 +1090,26 @@ export const api = {
forceRefresh: options.forceRefresh
});
},
getUserPresetDefaults(options = {}) {
return requestCachedGet('/settings/user-preset-defaults', {
ttlMs: 2 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
async updateUserPresetDefaults(defaults = {}) {
const result = await request('/settings/user-preset-defaults', {
method: 'PUT',
body: JSON.stringify({ defaults })
});
afterMutationInvalidate(['/settings/user-preset-defaults']);
return result;
},
async createUserPreset(payload = {}) {
const result = await request('/settings/user-presets', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
async updateUserPreset(id, payload = {}) {
@@ -1025,14 +1117,14 @@ export const api = {
method: 'PUT',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
async deleteUserPreset(id) {
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
+560 -89
View File
@@ -3,10 +3,10 @@ import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) {
@@ -84,6 +84,108 @@ function normalizeEditableChapters(chapters = []) {
});
}
function formatChapterDuration(startSecondsValue, endSecondsValue) {
const startSeconds = Number(startSecondsValue || 0);
const endSeconds = Number(endSecondsValue || 0);
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
return '-';
}
return formatChapterTime(endSeconds - startSeconds);
}
function normalizeTrackStageStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') {
return 'done';
}
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') {
return 'in_progress';
}
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') {
return 'error';
}
return 'pending';
}
function trackStatusTagMeta(value) {
const normalized = normalizeTrackStageStatus(value);
if (normalized === 'done') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'in_progress') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Offen', severity: 'secondary' };
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeIdList(values, kind = 'script') {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildEncodeItemsFromConfig(config, phase) {
const source = config && typeof config === 'object' ? config : {};
const prefix = phase === 'post' ? 'post' : 'pre';
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
const fromExplicit = explicitItems
.map((item) => {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'script' && type !== 'chain') {
return null;
}
const id = type === 'chain'
? normalizeChainId(item?.id ?? item?.chainId)
: normalizeScriptId(item?.id ?? item?.scriptId);
if (!id) {
return null;
}
return { type, id };
})
.filter(Boolean);
if (fromExplicit.length > 0) {
return fromExplicit;
}
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
return [
...scriptIds.map((id) => ({ type: 'script', id })),
...chainIds.map((id) => ({ type: 'chain', id }))
];
}
function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') {
return (
@@ -128,7 +230,6 @@ export default function AudiobookConfigPanel({
pipeline,
onStart,
onCancel,
onRetry,
onDeleteJob,
busy
}) {
@@ -149,6 +250,18 @@ export default function AudiobookConfigPanel({
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [preRipItems, setPreRipItems] = useState([]);
const [postRipItems, setPostRipItems] = useState([]);
const audiobookConfigKey = JSON.stringify({
preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'),
postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'),
preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'),
postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'),
preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [],
postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : []
});
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
@@ -160,13 +273,51 @@ export default function AudiobookConfigPanel({
setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]);
useEffect(() => {
let cancelled = false;
const loadCatalog = async () => {
try {
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
if (cancelled) {
return;
}
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
const chains = chainsResponse.status === 'fulfilled'
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
: [];
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
} catch (_error) {
if (!cancelled) {
setScriptCatalog([]);
setChainCatalog([]);
}
}
};
void loadCatalog();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre'));
setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post'));
}, [jobId, audiobookConfigKey]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
const canStart = Boolean(jobId) && (
state === 'READY_TO_START'
|| state === 'READY_TO_ENCODE'
|| state === 'ERROR'
|| state === 'CANCELLED'
);
const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const isSplitOutput = format === 'mp3' || format === 'flac';
const showEditableChapters = !(isSplitOutput && isRunning);
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
const completedChapterCount = Number(context?.completedChapterCount ?? -1);
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
@@ -182,6 +333,88 @@ export default function AudiobookConfigPanel({
[schema, formatOptions]
);
const moveEncodeItem = (phase, index, direction) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const list = Array.isArray(prev) ? [...prev] : [];
const from = Number(index);
const to = from + (direction === 'up' ? -1 : 1);
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) {
return list;
}
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
return list;
});
};
const addEncodeItem = (phase, type) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const selectedIds = new Set(
current
.filter((item) => item?.type === normalizedType)
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
const candidate = (Array.isArray(catalog) ? catalog : [])
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedIds.has(String(id)));
if (candidate === undefined || candidate === null) {
return current;
}
return [...current, { type: normalizedType, id: candidate }];
});
};
const changeEncodeItem = (phase, index, type, nextId) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
if (normalizedId === null) {
return;
}
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
const duplicate = current.some((item, itemIndex) => {
if (itemIndex === rowIndex) {
return false;
}
if (item?.type !== normalizedType) {
return false;
}
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
return existingId !== null && String(existingId) === String(normalizedId);
});
if (duplicate) {
return current;
}
const next = [...current];
next[rowIndex] = { type: normalizedType, id: normalizedId };
return next;
});
};
const removeEncodeItem = (phase, index) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
});
};
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
@@ -217,11 +450,8 @@ export default function AudiobookConfigPanel({
</div>
</div>
<div className="audiobook-config-tags">
<div className="ripper-job-badges">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</div>
</div>
@@ -263,78 +493,316 @@ export default function AudiobookConfigPanel({
</small>
</div>
<div className="audiobook-config-chapters">
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{editableChapters.map((chapter, index) => (
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<div className="audiobook-chapter-row-head">
<strong>#{chapter.index || index + 1}</strong>
<small>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
{showEditableChapters ? (
<div className="audiobook-config-chapters">
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{editableChapters.map((chapter, index) => (
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<div className="audiobook-chapter-row-head">
<strong>#{chapter.index || index + 1}</strong>
<small>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
) : null}
</div>
{isRunning ? (
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.trunc(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
<div className="audiobook-chapter-status">
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
<div className="audiobook-chapter-status-list">
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle';
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending';
return (
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}>
<i className={statusIcon} />
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span>
<span className="chapter-status-title">{chapter.title}</span>
</div>
);
})}
<div className="cd-track-selection">
<div className="cd-track-list">
<table className="cd-track-table">
<thead>
<tr>
<th className="check">Auswahl</th>
<th className="num">Nr</th>
<th className="title">Titel</th>
<th className="duration">Länge</th>
<th className="status">Rip</th>
<th className="status">Encode</th>
</tr>
</thead>
<tbody>
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const ripMeta = trackStatusTagMeta('done');
const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending'));
return (
<tr key={chIdx} className="selected">
<td className="check">Ja</td>
<td className="num">{String(chIdx).padStart(2, '0')}</td>
<td className="title">{chapter.title || '-'}</td>
<td className="duration">{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}</td>
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
) : null}
{outputPath ? (
<div className="audiobook-output-path">
<strong>Ausgabe:</strong> <code>{outputPath}</code>
<div className="encode-automation-grid">
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
disabled={busy || rowIndex >= preRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div>
) : null}
<div className="actions-row">
<div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
disabled={busy || rowIndex >= postRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
</div>
<div className="actions-row" style={{ marginTop: '1rem' }}>
{canStart ? (
<Button
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
label={(state === 'READY_TO_START' || state === 'READY_TO_ENCODE')
? 'Encode starten'
: 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({
@@ -347,7 +815,23 @@ export default function AudiobookConfigPanel({
endSeconds: chapter.endSeconds,
startMs: chapter.startMs,
endMs: chapter.endMs
}))
})),
selectedPreEncodeScriptIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPostEncodeScriptIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPreEncodeChainIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
),
selectedPostEncodeChainIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
)
})}
loading={busy}
disabled={!jobId}
@@ -365,28 +849,15 @@ export default function AudiobookConfigPanel({
/>
) : null}
{isRunning ? (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</div>
<Dialog
@@ -1,298 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
let current = n;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getNodeByPath(root, targetPath) {
if (!root) {
return null;
}
if ((root.path || '') === (targetPath || '')) {
return root;
}
for (const child of (root.children || [])) {
if (child.type !== 'folder') {
continue;
}
const found = getNodeByPath(child, targetPath);
if (found) {
return found;
}
}
return null;
}
function listChildren(node) {
if (!node || !Array.isArray(node.children)) {
return [];
}
return node.children;
}
function buildBreadcrumb(pathValue) {
if (!pathValue) {
return [];
}
const parts = String(pathValue).split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/')
}));
}
function filterFolderTree(node, query) {
if (!node || node.type !== 'folder') {
return null;
}
if (!query || !query.trim()) {
return node;
}
const normalized = query.toLowerCase();
const children = (node.children || [])
.filter((child) => child.type === 'folder')
.map((child) => filterFolderTree(child, query))
.filter(Boolean);
const nameMatches = String(node.name || '').toLowerCase().includes(normalized);
if (nameMatches || children.length > 0) {
return { ...node, children };
}
return null;
}
function defaultExpandedSet(tree) {
const next = new Set(['']);
const firstLevel = Array.isArray(tree?.children) ? tree.children : [];
for (const entry of firstLevel) {
if (entry?.type === 'folder' && entry?.path) {
next.add(entry.path);
}
}
return next;
}
export default function AudiobookOutputExplorer({ refreshToken = 0 }) {
const [tree, setTree] = useState(null);
const [outputDir, setOutputDir] = useState(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
const [sidebarQuery, setSidebarQuery] = useState('');
const loadTree = useCallback(async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await api.getAudiobookOutputTree();
const nextTree = response?.tree || null;
setTree(nextTree);
setOutputDir(response?.outputDir || null);
setExpandedFolders(defaultExpandedSet(nextTree));
setCurrentPath('');
} catch (error) {
setTree(null);
setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTree();
}, [loadTree, refreshToken]);
const navigateTo = (pathValue) => {
const normalized = String(pathValue || '').trim();
const node = getNodeByPath(tree, normalized);
if (node && node.type === 'folder') {
setCurrentPath(normalized);
}
};
const toggleFolder = (pathValue) => {
const normalized = String(pathValue || '').trim();
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(normalized)) {
next.delete(normalized);
} else {
next.add(normalized);
}
return next;
});
};
const renderTreeNode = (node, depth = 0) => {
if (!node || node.type !== 'folder') {
return null;
}
const key = node.path || '';
const isExpanded = expandedFolders.has(key);
const isCurrent = currentPath === key;
const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : [];
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<button
type="button"
className={`tree-row folder${isCurrent ? ' active' : ''}`}
onClick={() => navigateTo(key)}
>
{children.length > 0 ? (
<span
className="tree-caret"
onClick={(event) => {
event.stopPropagation();
toggleFolder(key);
}}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
</span>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'audiobooks'}</span>
</button>
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]);
const currentNode = getNodeByPath(tree, currentPath);
const currentChildren = listChildren(currentNode);
const breadcrumb = buildBreadcrumb(currentPath);
if (loading && !tree) {
return (
<div className="explorer-loading">
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
</div>
);
}
if (!tree) {
return (
<div className="explorer-empty">
{errorMessage ? (
<small className="error-text">{errorMessage}</small>
) : (
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
)}
<div style={{ marginTop: '0.6rem' }}>
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
</div>
</div>
);
}
return (
<div className="explorer audiobook-output-explorer">
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<input
type="text"
placeholder="Ordner filtern..."
value={sidebarQuery}
onChange={(event) => setSidebarQuery(event.target.value)}
className="sidebar-search"
/>
</div>
<div className="sidebar-tree">
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
</div>
</div>
<div className="explorer-main">
<div className="explorer-toolbar">
<Button
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
label="Aktualisieren"
outlined
size="small"
onClick={() => void loadTree()}
disabled={loading}
/>
<div className="explorer-path">
<Button text label="/" onClick={() => navigateTo('')} />
{breadcrumb.map((crumb) => (
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
))}
</div>
</div>
<div className="explorer-list">
<div className="explorer-row header audiobook-output-row">
<span>Name</span>
<span>Größe</span>
<span>Geändert</span>
</div>
{currentChildren.length === 0 ? (
<div className="explorer-row audiobook-output-row">
<span>Keine Einträge in diesem Ordner.</span>
<span />
<span />
</div>
) : (
currentChildren.map((entry) => (
<button
type="button"
key={entry.path || entry.name}
className="explorer-row audiobook-output-row"
onClick={() => {
if (entry.type === 'folder') {
navigateTo(entry.path);
}
}}
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
{entry.name}
</span>
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
<span>{formatDateTime(entry.mtime)}</span>
</button>
))
)}
</div>
<div className="explorer-footer">
<small>
Root: <code>{outputDir || '-'}</code>
</small>
</div>
</div>
</div>
);
}
+178 -89
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { FileUpload } from 'primereact/fileupload';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Toast } from 'primereact/toast';
@@ -32,15 +31,30 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
export default function AudiobookUploadPanel({
audiobookUpload,
onAudiobookUpload,
onCancelUpload = null,
onUploaded = null
}) {
const toastRef = useRef(null);
const fileUploadRef = useRef(null);
const fallbackFileInputRef = useRef(null);
const [uploadFile, setUploadFile] = useState(null);
const [statusVisible, setStatusVisible] = useState(false);
const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const uploadBusy = phase === 'uploading' || phase === 'processing';
@@ -49,40 +63,15 @@ export default function AudiobookUploadPanel({
: 0;
const loadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const totalBytes = Number(audiobookUpload?.totalBytes || 0);
const fileName = String(audiobookUpload?.fileName || '').trim()
|| String(uploadFile?.name || '').trim()
|| null;
const statusTone = phase === 'error'
? 'danger'
: phase === 'completed'
? 'success'
: phase === 'processing'
? 'info'
: phase === 'uploading'
? 'warning'
: 'secondary';
const statusLabel = phase === 'uploading'
? 'Upload laeuft'
: phase === 'processing'
? 'Server verarbeitet'
: phase === 'completed'
? 'Bereit'
: phase === 'error'
? 'Fehler'
: 'Inaktiv';
useEffect(() => {
if (phase === 'idle') {
setStatusVisible(false);
return;
}
setStatusVisible(true);
if (phase === 'completed') {
const timer = setTimeout(() => setStatusVisible(false), 5000);
return () => clearTimeout(timer);
}
return undefined;
}, [phase]);
const progressLabel = phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`;
const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile);
const canStartUpload = Boolean(uploadFile) && !uploadBusy;
const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function';
const canClearSelection = Boolean(uploadFile) && !uploadBusy;
const handleUpload = async () => {
if (!uploadFile) {
@@ -96,7 +85,7 @@ export default function AudiobookUploadPanel({
}
try {
const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false });
const uploadedJobId = normalizeJobId(response?.result?.jobId);
const uploadedJobId = extractUploadJobIdFromResponse(response);
if (uploadedJobId) {
toastRef.current?.show({
severity: 'success',
@@ -116,6 +105,15 @@ export default function AudiobookUploadPanel({
fileUploadRef.current?.clear?.();
onUploaded?.(uploadedJobId, response);
} catch (error) {
if (error?.name === 'AbortError') {
toastRef.current?.show({
severity: 'info',
summary: 'Upload abgebrochen',
detail: 'Der Audiobook-Upload wurde gestoppt.',
life: 2800
});
return;
}
toastRef.current?.show({
severity: 'error',
summary: 'Upload fehlgeschlagen',
@@ -125,6 +123,72 @@ export default function AudiobookUploadPanel({
}
};
const handleFileSelected = (file) => {
if (!file) {
setUploadFile(null);
return;
}
setUploadFile(file);
};
const handleFileCleared = () => {
setUploadFile(null);
};
const handleChooseFile = () => {
if (uploadBusy) {
return;
}
fallbackFileInputRef.current?.click();
};
const handleFileItemAction = (onRemove = null) => {
if (canCancelUpload) {
void onCancelUpload();
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
return;
}
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
};
const renderFileRow = (file, onRemove = null) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file?.name}>{file?.name || 'upload.aax'}</span>
<small>{formatBytes(Number(file?.size || 0))}</small>
{hasHeaderStatus ? (
<div className="aax-file-status">
<>
<small className="audiobook-upload-inline-text">{progressLabel}</small>
<ProgressBar value={progress} showValue={false} />
{audiobookUpload?.statusText ? (
<small className="audiobook-upload-inline-text">{audiobookUpload.statusText}</small>
) : null}
</>
</div>
) : null}
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={() => {
handleFileItemAction(onRemove);
}}
disabled={!canCancelUpload && !canClearSelection}
tooltip={canCancelUpload ? 'Upload abbrechen' : 'Auswahl entfernen'}
tooltipOptions={{ position: 'left' }}
/>
</div>
);
return (
<div className="audiobook-upload-panel">
<Toast ref={toastRef} position="top-right" />
@@ -135,66 +199,91 @@ export default function AudiobookUploadPanel({
customUpload
uploadHandler={() => void handleUpload()}
disabled={uploadBusy}
onSelect={(event) => setUploadFile(event.files[0] || null)}
onClear={() => setUploadFile(null)}
onRemove={() => setUploadFile(null)}
onSelect={(event) => {
handleFileSelected(event.files[0] || null);
}}
onClear={() => {
handleFileCleared();
}}
onRemove={() => {
handleFileCleared();
}}
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
itemTemplate={(file, options) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file.name}>{file.name}</span>
<small>{options.formatSize}</small>
headerTemplate={(options) => (
<div className={options.className}>
<div className="audiobook-upload-header">
<div className="audiobook-upload-header-actions">
<Button
icon="pi pi-images"
rounded
outlined
aria-label="Datei auswählen"
onClick={() => {
void handleChooseFile();
}}
disabled={uploadBusy}
/>
<Button
icon="pi pi-cloud-upload"
rounded
outlined
severity="success"
aria-label="Upload starten"
onClick={() => {
void handleUpload();
}}
disabled={!canStartUpload}
/>
<Button
icon="pi pi-times"
rounded
outlined
severity="secondary"
aria-label="Auswahl entfernen"
onClick={() => {
fileUploadRef.current?.clear?.();
handleFileCleared();
}}
disabled={!canClearSelection}
/>
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={options.onRemove}
disabled={uploadBusy}
/>
</div>
)}
itemTemplate={(file, options) => (
renderFileRow(file, () => {
options.onRemove?.();
})
)}
emptyTemplate={() => (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
uploadFile
? renderFileRow(uploadFile, () => {
// handled by handleFileItemAction
})
: (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
)
)}
/>
{statusVisible ? (
<div className={`audiobook-upload-status tone-${statusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{statusLabel}</strong>
<Tag value={statusLabel} severity={statusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{fileName ? (
<small className="audiobook-upload-file" title={fileName}>
Datei: {fileName}
</small>
) : null}
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.trunc(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`}
</small>
</div>
</div>
) : null}
<input
ref={fallbackFileInputRef}
type="file"
accept=".aax,audio/vnd.audible.aax"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target?.files?.[0] || null;
handleFileSelected(file);
if (event.target) {
event.target.value = '';
}
}}
/>
</div>
);
}
+5 -1
View File
@@ -142,7 +142,10 @@ export default function CdMetadataDialog({
const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId;
try {
const searchResults = await onSearch(trimmedQuery);
const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0;
const searchResults = await onSearch(trimmedQuery, {
trackCount: expectedTrackCount > 0 ? expectedTrackCount : null
});
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) {
@@ -264,6 +267,7 @@ export default function CdMetadataDialog({
emptyMessage="Keine Treffer"
>
<Column header="Album" body={mbTitleBody} />
<Column field="trackCount" header="Tracks" style={{ width: '6rem' }} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable>
+151 -88
View File
@@ -239,6 +239,7 @@ export default function CdRipConfigPanel({
const selectedMeta = context.selectedMetadata || {};
const state = String(pipeline?.state || '').trim().toUpperCase();
const jobId = normalizePosition(context?.jobId);
const isPostRipWorkflow = context?.skipRip === true;
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
const isFinished = state === 'FINISHED';
@@ -662,6 +663,7 @@ export default function CdRipConfigPanel({
|| ''
) || null;
const devicePath = normalizeTrackText(context?.devicePath) || '-';
const rawPath = normalizeTrackText(context?.rawPath) || '-';
const outputPath = normalizeTrackText(context?.outputPath) || '-';
const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase();
const formatLabel = (Array.isArray(CD_FORMATS)
@@ -795,7 +797,9 @@ export default function CdRipConfigPanel({
displayValueTemplate={(value) => formatProgressLabel(value)}
/>
<small>
{`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
{isPostRipWorkflow
? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`
: `Fortschritt: ${formatProgressLabel(wholeProgress)}`}
</small>
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
</div>
@@ -892,25 +896,30 @@ export default function CdRipConfigPanel({
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Format:</strong> {formatLabel}</div>
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
{!isRipping ? <div><strong>Format:</strong> {formatLabel}</div> : null}
{isPostRipWorkflow ? (
<>
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
</>
) : null}
{!isRipping ? <div><strong>Laufwerk:</strong> {devicePath}</div> : null}
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
{!isRipping ? <div><strong>Output-Pfad:</strong> {outputPath}</div> : null}
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
{!isRipping && jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div>
</div>
</div>
{displayTrackRows.length > 0 ? (
{isPostRipWorkflow && displayTrackRows.length > 0 ? (
<div className="cd-track-selection">
<strong>Zu rippende Tracks ({displayTrackRows.length})</strong>
<div className="cd-track-list">
@@ -951,6 +960,58 @@ export default function CdRipConfigPanel({
);
}
if (!isPostRipWorkflow) {
return (
<div className="cd-rip-config-panel">
<div className="status-row">
<Tag value={stateLabel} severity={stateSeverity} />
<span>{statusText || 'Bereit'}</span>
</div>
<div className="cd-meta-summary">
<strong>CD-Details</strong>
<div className="device-meta" style={{ marginTop: '0.55rem' }}>
<div><strong>Album:</strong> {albumTitle}</div>
<div><strong>Interpret:</strong> {albumArtist}</div>
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div>
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div>
</div>
<div className="actions-row">
<Button
label="Rip starten"
icon="pi pi-play"
severity="success"
onClick={() => handleStart({ forceSkipRip: false })}
loading={busy}
/>
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
onClick={() => onOpenMetadata && onOpenMetadata()}
loading={busy}
/>
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob && onDeleteJob(jobId)}
loading={busy}
/>
) : null}
</div>
</div>
);
}
return (
<div className="cd-rip-config-panel">
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
@@ -1082,15 +1143,16 @@ export default function CdRipConfigPanel({
</div>
) : null}
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
<div className="encode-automation-grid">
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
preRipItems
@@ -1116,8 +1178,8 @@ export default function CdRipConfigPanel({
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
return (
<div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
@@ -1167,43 +1229,43 @@ export default function CdRipConfigPanel({
onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
</div>
<div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
<div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
postRipItems
@@ -1229,8 +1291,8 @@ export default function CdRipConfigPanel({
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
return (
<div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
@@ -1280,32 +1342,33 @@ export default function CdRipConfigPanel({
onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
{/* Actions */}
+104 -29
View File
@@ -154,6 +154,59 @@ function deriveAudioTracks(plan) {
return [];
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') {
const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {};
const normalized = {
title: String(source?.title || fallbackTitle || '').trim() || null,
year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null,
imdbId: String(source?.imdbId || '').trim() || null,
poster: String(source?.poster || '').trim() || null
};
const metadataProvider = String(source?.metadataProvider || '').trim() || null;
const workflowKind = String(source?.workflowKind || '').trim() || null;
const providerId = String(source?.providerId || '').trim() || null;
const metadataKind = String(source?.metadataKind || '').trim() || null;
const seasonName = String(source?.seasonName || '').trim() || null;
const imdbRating = String(source?.imdbRating || '').trim() || null;
const tmdbId = normalizePositiveInt(source?.tmdbId);
const seasonNumber = normalizePositiveInt(source?.seasonNumber);
const discNumber = normalizePositiveInt(source?.discNumber);
const episodeCount = Number(source?.episodeCount || 0);
const voteAverageRaw = Number(source?.voteAverage || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails)
? source.tmdbDetails
: null;
if (metadataProvider) normalized.metadataProvider = metadataProvider;
if (workflowKind) normalized.workflowKind = workflowKind;
if (providerId) normalized.providerId = providerId;
if (metadataKind) normalized.metadataKind = metadataKind;
if (tmdbId) normalized.tmdbId = tmdbId;
if (seasonNumber) normalized.seasonNumber = seasonNumber;
if (seasonName) normalized.seasonName = seasonName;
if (discNumber) normalized.discNumber = discNumber;
if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount);
if (episodes.length > 0) normalized.episodes = episodes;
if (voteAverage !== null) normalized.voteAverage = voteAverage;
if (imdbRating) normalized.imdbRating = imdbRating;
if (tmdbDetails) normalized.tmdbDetails = tmdbDetails;
return normalized;
}
// Status helpers
function mediaTypeBadge(type) {
@@ -260,10 +313,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
// Video metadata
const [videoMetadata, setVideoMetadata] = useState(() => ({
title: String(planMetadata?.title || job.title || job.detected_title || '').trim(),
year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null,
imdbId: String(planMetadata?.imdbId || '').trim() || null,
poster: String(planMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
planMetadata,
String(job.title || job.detected_title || '').trim()
)
}));
// Script / Chain catalog
@@ -378,10 +431,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null
}
: {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
poster: String(videoMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
return {
@@ -436,12 +489,13 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
jobId: job.id,
detectedTitle: videoMetadata?.title || job.title || job.detected_title || '',
selectedMetadata: {
...videoMetadata,
title: videoMetadata?.title || job.title || job.detected_title || '',
year: videoMetadata?.year || null,
imdbId: videoMetadata?.imdbId || null,
poster: videoMetadata?.poster || null
},
omdbCandidates: []
metadataCandidates: []
}), [job.id, job.title, job.detected_title, videoMetadata]);
const cdMetadataDialogContext = useMemo(() => ({
@@ -454,31 +508,50 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
}), [job.id, audioTracks, trackFields]);
// Dialog handlers
const handleOmdbSearch = async (query) => {
const handleMetadataSearch = async (query, options = {}) => {
try {
const response = await api.searchOmdb(query);
return Array.isArray(response?.results) ? response.results : [];
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
const includeMovies = filterRaw !== 'series';
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
const [movieResponse, seriesResponse] = await Promise.all([
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] })
]);
const movieRows = Array.isArray(movieResponse?.results)
? movieResponse.results.map((row) => ({
...row,
workflowKind: 'film',
metadataKind: 'movie',
resultType: 'movie'
}))
: [];
const seriesRows = Array.isArray(seriesResponse?.results)
? seriesResponse.results.map((row) => ({
...row,
workflowKind: 'series',
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
resultType: 'series'
}))
: [];
return [...movieRows, ...seriesRows];
} catch { return []; }
};
const handleOmdbSubmit = async (payload) => {
const handleMetadataSubmit = async (payload) => {
setSearchDialogBusy(true);
try {
setVideoMetadata({
title: String(payload?.title || job.title || job.detected_title || '').trim(),
year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null,
imdbId: String(payload?.imdbId || '').trim() || null,
poster: String(payload?.poster || '').trim() || null
});
setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim()));
setMetadataDialogVisible(false);
} finally {
setSearchDialogBusy(false);
}
};
const handleMusicBrainzSearchDialog = async (query) => {
const handleMusicBrainzSearchDialog = async (query, options = {}) => {
try {
const response = await api.searchMusicBrainz(query);
const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return Array.isArray(response?.results) ? response.results : [];
} catch { return []; }
};
@@ -642,10 +715,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null
}
: {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
poster: String(videoMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
await api.startConverterJob(job.id, {
@@ -880,8 +953,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
)}
{/* Pre / Post Ausführungen */}
{renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
<div className="encode-automation-grid">
{renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
</div>
{/* Aktionen */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
@@ -1025,8 +1100,8 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={() => setMetadataDialogVisible(false)}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={searchDialogBusy}
/>
</div>
+35 -6
View File
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import { confirmModal } from '../utils/confirmModal';
// Hilfsfunktionen
@@ -61,7 +62,7 @@ const EMPTY_FORM = {
// Hauptkomponente
export default function CronJobsTab({ onWsMessage }) {
export default function CronJobsTab() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
@@ -89,6 +90,7 @@ export default function CronJobsTab({ onWsMessage }) {
// Aktionen Busy-State per Job-ID
const [busyId, setBusyId] = useState(null);
const wsReloadTimerRef = useRef(null);
// Daten laden
@@ -135,6 +137,16 @@ export default function CronJobsTab({ onWsMessage }) {
};
}, []);
const scheduleLoadAll = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void loadAll();
}, delayMs);
};
const activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
@@ -146,11 +158,28 @@ export default function CronJobsTab({ onWsMessage }) {
return map;
}, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen
useEffect(() => {
if (!onWsMessage) return;
// onWsMessage ist eine Funktion, die wir anmelden
}, [onWsMessage]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (type === 'RUNTIME_ACTIVITY_CHANGED') {
setActiveCronRuns(normalizeActiveCronRuns(message?.payload));
return;
}
if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') {
scheduleLoadAll(80);
}
}
});
// Cron-Ausdruck validieren (debounced)
+214 -41
View File
@@ -22,13 +22,13 @@ const GENERAL_TOOL_KEYS = new Set([
'makemkv_command',
'makemkv_registration_key',
'makemkv_min_length_minutes',
'playlist_tmdb_runtime_subtract_percent',
'mediainfo_command',
'handbrake_command',
'ffmpeg_command',
'mkvmerge_command',
'ffprobe_command',
'handbrake_restart_delete_incomplete_output',
'handbrake_pre_metadata_scan_enabled',
'script_test_timeout_ms'
]);
@@ -58,7 +58,11 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
'makemkv_rip_mode',
'makemkv_rip_mode_bluray',
'makemkv_rip_mode_dvd',
'makemkv_backup_mode'
'makemkv_backup_mode',
'handbrake_review_audio_languages_bluray',
'handbrake_review_subtitle_languages_bluray',
'handbrake_review_audio_languages_dvd',
'handbrake_review_subtitle_languages_dvd'
]);
const EXPERT_ONLY_SETTING_KEYS = new Set([
'pushover_device',
@@ -78,6 +82,9 @@ const EXPERT_ONLY_SETTING_KEYS = new Set([
'cdparanoia_command',
'script_test_timeout_ms'
]);
const EXPERT_ONLY_CATEGORY_NAMES = new Set([
'logging'
]);
function toBoolean(value) {
if (typeof value === 'boolean') {
@@ -436,6 +443,14 @@ function filterSettingsByVisibility(settings, expertModeEnabled) {
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
}
function shouldHideCategoryByExpertMode(categoryName, expertModeEnabled) {
const normalizedCategory = normalizeText(categoryName);
if (!normalizedCategory) {
return false;
}
return !expertModeEnabled && EXPERT_ONLY_CATEGORY_NAMES.has(normalizedCategory);
}
function buildToolSections(settings) {
const list = Array.isArray(settings) ? settings : [];
const generalBucket = {
@@ -655,60 +670,159 @@ function isReadonlySetting(setting) {
}
}
function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
function MakeMKVBetaKeyHint({ disabled = false, onNotify = null, onSettingApplied = null }) {
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [betaKey, setBetaKey] = useState('');
const [validUntil, setValidUntil] = useState('');
const [fetchedAt, setFetchedAt] = useState('');
const [appliedKey, setAppliedKey] = useState('');
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
useEffect(() => {
let active = true;
const loadCachedState = async () => {
setLoading(true);
setError('');
setWarning('');
try {
const response = await api.getMakeMKVBetaKey();
setBetaKey(String(response?.betaKey || '').trim());
setValidUntil(String(response?.validUntil || '').trim());
setFetchedAt(String(response?.fetchedAt || '').trim());
setAppliedKey(String(response?.appliedKey || '').trim());
if (response?.stale) {
setWarning('Zwischengespeicherter Betakey ist veraltet. Bitte bei Bedarf neu prüfen.');
} else if (!response?.betaKey) {
setWarning('Noch kein geprüfter Betakey im Cache. Prüfung startet nur per Button.');
} else if (response?.appliedMatchesCache) {
setWarning('Geprüfter Betakey ist bereits übernommen.');
} else {
setWarning('');
}
} catch (loadError) {
setBetaKey('');
setValidUntil('');
setFetchedAt('');
setAppliedKey('');
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
} finally {
setLoading(false);
}
};
api.getMakeMKVBetaKey({ forceRefresh: true })
.then((response) => {
if (!active) {
return;
}
setBetaKey(String(response?.betaKey || '').trim());
})
.catch((loadError) => {
if (!active) {
return;
}
setBetaKey('');
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
useEffect(() => {
void loadCachedState();
}, []);
const handleCheck = async () => {
setChecking(true);
setError('');
setWarning('');
try {
const response = await api.checkMakeMKVBetaKey();
const nextKey = String(response?.betaKey || '').trim();
setBetaKey(nextKey);
setValidUntil(String(response?.validUntil || '').trim());
setFetchedAt(String(response?.fetchedAt || '').trim());
setWarning('');
if (typeof onNotify === 'function') {
onNotify({
severity: 'success',
summary: 'MakeMKV Betakey',
detail: nextKey ? 'Betakey erfolgreich geprüft.' : 'Betakey-Prüfung abgeschlossen.'
});
}
} catch (checkError) {
setError(checkError?.message || 'Betakey konnte nicht geprüft werden.');
if (typeof onNotify === 'function') {
onNotify({
severity: 'error',
summary: 'MakeMKV Betakey',
detail: checkError?.message || 'Betakey konnte nicht geprüft werden.'
});
}
} finally {
setChecking(false);
}
};
const handleApply = async () => {
if (!betaKey) {
return;
}
setApplying(true);
setError('');
try {
const response = await api.applyMakeMKVBetaKey({ betaKey });
const nextAppliedKey = String(response?.betaKey || betaKey).trim();
setAppliedKey(nextAppliedKey);
setWarning('Geprüfter Betakey ist bereits übernommen.');
if (typeof onSettingApplied === 'function') {
onSettingApplied({
key: 'makemkv_registration_key',
value: nextAppliedKey
});
}
if (typeof onNotify === 'function') {
onNotify({
severity: 'success',
summary: 'MakeMKV Betakey',
detail: 'Betakey wurde übernommen und gespeichert.'
});
}
} catch (applyError) {
setError(applyError?.message || 'Betakey konnte nicht übernommen werden.');
if (typeof onNotify === 'function') {
onNotify({
severity: 'error',
summary: 'MakeMKV Betakey',
detail: applyError?.message || 'Betakey konnte nicht übernommen werden.'
});
}
} finally {
setApplying(false);
}
};
return (
<div className="makemkv-beta-key-box">
<div className="makemkv-beta-key-head">
<span className="makemkv-beta-key-title">Betakey</span>
<Button
type="button"
size="small"
text
label="übernehmen"
disabled={disabled || loading || !betaKey}
onClick={() => onApply?.(betaKey)}
/>
<div className="settings-inline-actions">
<Button
type="button"
size="small"
text
label="prüfen"
disabled={disabled || loading || checking || applying}
onClick={() => void handleCheck()}
/>
<Button
type="button"
size="small"
text
label="übernehmen"
disabled={disabled || loading || checking || applying || !betaKey || betaKey === appliedKey}
onClick={() => void handleApply()}
/>
</div>
</div>
{loading ? (
<small className="setting-description">Aktueller Betakey wird geladen</small>
<small className="setting-description">Zwischengespeicherter Betakey wird geladen</small>
) : null}
{!loading && error ? (
<small className="error-text">{error}</small>
) : null}
{!loading && !error && warning ? (
<small className="setting-description">{warning}</small>
) : null}
{!loading && !error && fetchedAt ? (
<small className="setting-description">Zuletzt geprüft: {new Date(fetchedAt).toLocaleString('de-DE')}</small>
) : null}
{!loading && !error && validUntil ? (
<small className="setting-description">Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}</small>
) : null}
{!loading && !error && betaKey ? (
<code className="makemkv-beta-key-value">{betaKey}</code>
) : null}
@@ -716,6 +830,42 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
);
}
function ActivationBytesCacheBox({ entries = [] }) {
const list = Array.isArray(entries) ? entries : [];
return (
<section className="settings-section grouped activation-bytes-cache-box">
<div className="settings-section-head">
<h4>Activation Bytes Cache</h4>
<small>Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt.</small>
</div>
{list.length === 0 ? (
<p>Keine Einträge vorhanden.</p>
) : (
<div className="activation-bytes-table-wrap">
<table className="activation-bytes-table">
<thead>
<tr>
<th>Checksum</th>
<th>Activation Bytes</th>
<th>Gespeichert am</th>
</tr>
</thead>
<tbody>
{list.map((entry, index) => (
<tr key={String(entry?.checksum || `activation-bytes-${index}`)}>
<td>{String(entry?.checksum || '-')}</td>
<td>{String(entry?.activation_bytes || '-')}</td>
<td>{entry?.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function SettingField({
setting,
value,
@@ -727,7 +877,9 @@ function SettingField({
ownerDirty,
onChange,
variant = 'default',
disabled = false
disabled = false,
onNotify = null,
onSettingApplied = null
}) {
const ownerKey = ownerSetting?.key;
const pathHasValue = Boolean(String(value ?? '').trim());
@@ -846,7 +998,8 @@ function SettingField({
{normalizeSettingKey(setting?.key) === 'makemkv_registration_key' ? (
<MakeMKVBetaKeyHint
disabled={isDisabled}
onApply={(nextKey) => onChange?.(setting.key, nextKey)}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
) : null}
{error ? (
@@ -887,7 +1040,16 @@ function SettingField({
}
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
function PathCategoryTab({
settings,
values,
errors,
dirtyKeys,
onChange,
effectivePaths,
onNotify,
onSettingApplied
}) {
const list = Array.isArray(settings) ? settings : [];
const settingsByKey = new Map(list.map((s) => [s.key, s]));
@@ -1094,6 +1256,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
ownerError={ownerError}
ownerDirty={ownerDirty}
onChange={onChange}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
);
})}
@@ -1113,11 +1277,14 @@ export default function DynamicSettingsForm({
dirtyKeys,
onChange,
effectivePaths,
onNotify
activationBytes = [],
onNotify,
onSettingApplied
}) {
const safeCategories = Array.isArray(categories) ? categories : [];
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
const visibleCategories = safeCategories
.filter((category) => !shouldHideCategoryByExpertMode(category?.category, expertModeEnabled))
.map((category) => ({
...category,
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
@@ -1197,6 +1364,8 @@ export default function DynamicSettingsForm({
dirtyKeys={dirtyKeys}
onChange={onChange}
effectivePaths={effectivePaths}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
) : (() => {
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
@@ -1205,6 +1374,7 @@ export default function DynamicSettingsForm({
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
const isToolsCategory = normalizeText(category?.category) === 'tools';
return (
<div className="settings-sections">
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
@@ -1277,6 +1447,8 @@ export default function DynamicSettingsForm({
onChange={onChangeFn}
variant={variant}
disabled={disabled}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
);
};
@@ -1333,6 +1505,7 @@ export default function DynamicSettingsForm({
})()}
</section>
))}
{isToolsCategory ? <ActivationBytesCacheBox entries={activationBytes} /> : null}
</div>
);
})()}
+177 -100
View File
@@ -641,24 +641,39 @@ function statusBadgeMeta(status, queued = false, options = {}) {
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'CD_READY_TO_RIP') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'WAITING_FOR_USER_DECISION') {
return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
}
if (normalized === 'METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'CD_METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'CD_ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'CD_RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'MEDIAINFO_CHECK') {
return { label, icon: 'pi-sliders-h', tone: 'warning' };
}
if (normalized === 'ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
if (normalized === 'CD_ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
if (normalized === 'MERGING') {
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
}
@@ -669,6 +684,25 @@ function normalizePathForCompare(value) {
return String(value || '').trim().replace(/[\\/]+$/, '');
}
function isIncompleteRawPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
const segments = normalized.split('/').filter(Boolean);
const baseName = segments[segments.length - 1] || '';
return /^incomplete_/i.test(baseName);
}
function isIncompleteOutputPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
}
function buildMergeToolLogFallback(job = null) {
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
? job.handbrakeInfo
@@ -695,19 +729,13 @@ function buildMergeToolLogFallback(job = null) {
return lines.join('\n');
}
function omdbField(value) {
function metadataField(value) {
const raw = String(value || '').trim();
return raw || '-';
}
function omdbRottenTomatoesScore(omdbInfo) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
return omdbField(entry?.Value);
}
function normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb';
return String(value || '').trim().toLowerCase() || 'tmdb';
}
function resolveJobMetadataContext(job) {
@@ -734,7 +762,7 @@ function resolveJobMetadataContext(job) {
const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider
|| 'omdb'
|| 'tmdb'
);
return {
analyzeContext,
@@ -744,7 +772,13 @@ function resolveJobMetadataContext(job) {
}
function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : [];
const source = Array.isArray(values)
? values
: (typeof values === 'string'
? values.split(',')
: (values && typeof values === 'object'
? [values]
: []));
const limit = Math.max(1, Number(maxItems || 10));
const output = [];
const seen = new Set();
@@ -787,53 +821,34 @@ function formatRuntimeLabel(value) {
return text || null;
}
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
function resolveMetadataDetailsForDisplay(job) {
const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
if (isTmdbProvider) {
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
? ratingNumber.toFixed(1)
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
? ratingNumber.toFixed(1)
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'omdb',
title: 'OMDb Details',
matchLabel: 'OMDb Match',
hasMatch: Boolean(job?.selected_from_omdb),
imdbId: String(job?.imdb_id || '').trim() || null,
director: omdbField(omdbInfo?.Director),
actors: omdbField(omdbInfo?.Actors),
runtime: omdbField(omdbInfo?.Runtime),
genre: omdbField(omdbInfo?.Genre),
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
imdbRating: omdbField(omdbInfo?.imdbRating)
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
@@ -1058,7 +1073,7 @@ export default function JobDetailDialog({
detailLoading = false,
onLoadLog,
logLoadingMode = null,
onAssignOmdb,
onAssignMetadata,
onAssignCdMetadata,
onGenerateNfo,
onAcknowledgeError,
@@ -1076,7 +1091,7 @@ export default function JobDetailDialog({
onRestoreMultipartMerge,
onCancel,
isQueued = false,
omdbAssignBusy = false,
metadataAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
generateNfoBusy = false,
@@ -1234,8 +1249,7 @@ export default function JobDetailDialog({
job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !running
&& mediaType !== 'audiobook'
&& !blockMetadataAndReviewUntilRetry
&& (isAudiobook || !blockMetadataAndReviewUntilRetry)
&& typeof onRestartReview === 'function'
);
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
@@ -1328,9 +1342,8 @@ export default function JobDetailDialog({
queueLocked,
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const metadataDetails = resolveMetadataDetailsForDisplay(job);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
@@ -1455,17 +1468,15 @@ export default function JobDetailDialog({
&& Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0)
);
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb'
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataJsonTitle = 'TMDb Info';
const metadataJsonValue = metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null;
const metadataAssignProvider = isDvdSeries
? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
: 'tmdb';
const metadataAssignButtonLabel = 'TMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
? 'Öffnet die TMDb-Suche, um Metadaten (Titel, Staffel/Episoden bzw. Film) neu zuzuweisen.'
: 'Öffnet die TMDb-Suche, um Metadaten neu zuzuweisen.';
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -1490,8 +1501,24 @@ export default function JobDetailDialog({
|| ''
).trim() || null;
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const jobMediaType = resolveMediaType(job);
const isDiscMediaJob = ['dvd', 'bluray', 'cd'].includes(jobMediaType);
const jobStatusUpper = String(job?.status || '').trim().toUpperCase();
const outputDownloadReady = Boolean(
job?.output_path
&& job?.outputStatus?.exists
&& !isIncompleteOutputPath(job.output_path)
&& (job?.encodeSuccess || jobStatusUpper === 'FINISHED')
);
const rawDownloadReady = Boolean(
resolvedRawPath
&& job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(resolvedRawPath)
&& (!isDiscMediaJob || Boolean(job?.ripSuccessful))
);
const canDownloadRaw = Boolean(rawDownloadReady && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(outputDownloadReady && typeof onDownloadArchive === 'function');
const outputFolders = (() => {
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
if (!isDiskContainer) {
@@ -1507,7 +1534,9 @@ export default function JobDetailDialog({
seen.add(outputPath);
merged.push({
id: folder?.id ?? fallbackId ?? outputPath,
output_path: outputPath
output_path: outputPath,
job_id: normalizePositiveInteger(folder?.job_id ?? folder?.jobId ?? null) || null,
exists: folder?.exists === undefined ? undefined : Boolean(folder?.exists)
});
};
for (const folder of baseFolders) {
@@ -1516,11 +1545,17 @@ export default function JobDetailDialog({
for (const child of childJobs) {
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
for (const childFolder of childOutputFolders) {
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
addFolder(
{
...childFolder,
job_id: childFolder?.job_id ?? child?.id ?? null
},
`child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`
);
}
if (child?.output_path) {
addFolder(
{ output_path: child.output_path },
{ output_path: child.output_path, job_id: child?.id ?? null },
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
);
}
@@ -1843,37 +1878,24 @@ export default function JobDetailDialog({
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(metadataDetails.director)}</span>
<span>{metadataField(metadataDetails.director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(metadataDetails.actors)}</span>
<span>{metadataField(metadataDetails.actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(metadataDetails.runtime)}</span>
<span>{metadataField(metadataDetails.runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(metadataDetails.genre)}</span>
<span>{metadataField(metadataDetails.genre)}</span>
</div>
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{metadataField(metadataDetails.tmdbRating)}</span>
</div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.imdbRating)}</span>
</div>
</>
)}
</div>
</section>
) : null}
@@ -1954,7 +1976,16 @@ export default function JobDetailDialog({
const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const childMediaType = resolveMediaType(child);
const childIsDiscMedia = ['dvd', 'bluray', 'cd'].includes(childMediaType);
const childRawDownloadReady = Boolean(
childRawPath
&& child?.rawStatus?.exists
&& child?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(childRawPath)
&& (!childIsDiscMedia || Boolean(child?.ripSuccessful))
);
const canDownloadChildRaw = Boolean(childRawDownloadReady && typeof onDownloadArchive === 'function');
return (
<PathField
key={`child-raw-${child.id}`}
@@ -1973,6 +2004,19 @@ export default function JobDetailDialog({
if (!folderPath) {
return null;
}
const folderOwnerJobId = normalizePositiveInteger(folder?.job_id) || normalizePositiveInteger(job?.id);
const folderOwner = folderOwnerJobId === normalizePositiveInteger(job?.id)
? job
: (childJobs.find((child) => normalizePositiveInteger(child?.id) === folderOwnerJobId) || job);
const folderOwnerStatusUpper = String(folderOwner?.status || '').trim().toUpperCase();
const folderExists = folder?.exists !== undefined
? Boolean(folder?.exists)
: Boolean(folderOwner?.outputStatus?.exists);
const folderDownloadReady = Boolean(
folderExists
&& !isIncompleteOutputPath(folderPath)
&& (folderOwner?.encodeSuccess || folderOwnerStatusUpper === 'FINISHED')
);
const normalizedFolderPath = normalizePathForCompare(folderPath);
const isMergeOutput = Boolean(
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
@@ -1980,13 +2024,13 @@ export default function JobDetailDialog({
);
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
const canDownloadFolder = Boolean(folderDownloadReady && typeof onDownloadOutputFolder === 'function');
return (
<PathField
key={folder.id || folderPath}
label={label}
value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath, folderOwnerJobId) : null}
downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath}
/>
@@ -2610,7 +2654,7 @@ export default function JobDetailDialog({
</div>
) : null}
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignMetadata === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -2620,8 +2664,8 @@ export default function JobDetailDialog({
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(child)}
loading={omdbAssignBusy}
onClick={() => onAssignMetadata?.(child)}
loading={metadataAssignBusy}
disabled={!childActionState.childCanAssignMetadata}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
@@ -2917,7 +2961,7 @@ export default function JobDetailDialog({
loading={actionBusy}
disabled={!canCdStartReview}
/>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow mit den zuletzt verwendeten CD-Metadaten: Trackauswahl und Ausgabeeinstellungen prüfen, dann Encode aus vorhandenen WAV-Daten starten.</span>
</div>
<div className="action-item">
<Button
@@ -2933,6 +2977,39 @@ export default function JobDetailDialog({
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
</div>
) : isAudiobook ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audiobook</div>
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<span className="action-desc">Startet FFmpeg direkt mit den zuletzt bestätigten Einstellungen neu. Es gibt dabei keinen Anpassungsschritt.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview}
/>
<span className="action-desc">Legt den Job neu aus der Quelldatei an und öffnet ihn im Ripper, damit Einstellungen vor dem Encode angepasst werden können.</span>
</div>
) : null}
</div>
) : (
canShowGeneralEncodeActions ? (
<div className="actions-group">
@@ -3025,7 +3102,7 @@ export default function JobDetailDialog({
</div>
) : null}
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignMetadata === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -3035,8 +3112,8 @@ export default function JobDetailDialog({
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
onClick={() => onAssignMetadata?.(job)}
loading={metadataAssignBusy}
disabled={running}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,49 @@ import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
function normalizeWorkflowKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
return 'series';
}
return null;
}
function normalizeResultType(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series') {
return 'series';
}
if (raw === 'movie' || raw === 'film' || raw === 'movies') {
return 'movie';
}
return null;
}
function normalizeMetadataRow(row = {}) {
const workflowKind = normalizeWorkflowKind(
row?.workflowKind
|| row?.kind
|| row?.metadataKind
|| null
);
const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase();
const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie');
const resultType = normalizeResultType(row?.resultType)
|| (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null))
|| ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie');
const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film');
return {
...row,
metadataKind,
resultType,
workflowKind: normalizedWorkflowKind
};
}
export default function MetadataSelectionDialog({
visible,
@@ -92,19 +134,6 @@ export default function MetadataSelectionDialog({
const preferred = stripped || separatorNormalized || raw;
return toSearchTitleCase(preferred);
};
const normalizeWorkflowKind = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
return 'series';
}
return null;
};
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
@@ -116,6 +145,7 @@ export default function MetadataSelectionDialog({
}
return Math.trunc(parsed);
};
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState('');
@@ -132,46 +162,35 @@ export default function MetadataSelectionDialog({
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [resultFilter, setResultFilter] = useState('all');
const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
const autoSearchDoneRef = useRef(false);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
|| null
);
const contextSelectedMetadataProvider = String(
context?.selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const contextMetadataProvider = (
contextWorkflowKind === 'series'
? 'tmdb'
: (contextWorkflowKind === 'film'
? 'omdb'
: (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase()))
) || 'omdb';
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
const wasVisibleRef = useRef(false);
const lastVisibleContextIdentityRef = useRef(null);
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb';
const providerOptions = mediaProfile === 'dvd'
? [
{ label: 'Film (OMDb)', value: 'omdb' },
{ label: 'Serie (TMDb)', value: 'tmdb' }
]
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }];
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const metadataProvider = 'tmdb';
const providerLabel = 'TMDb';
const supportsExternalIdInput = false;
const contextJobId = Number(context?.jobId || 0) || null;
const contextIdentity = contextJobId
? `job:${contextJobId}`
: `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`;
useEffect(() => {
if (!visible) {
wasVisibleRef.current = false;
lastVisibleContextIdentityRef.current = null;
return;
}
const openingNow = !wasVisibleRef.current;
const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity;
if (!openingNow && !contextChanged) {
return;
}
@@ -197,11 +216,88 @@ export default function MetadataSelectionDialog({
setConflictNewDiscNumber('');
setConflictError('');
setSeasonFilter('');
setResultFilter('all');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
autoSearchDoneRef.current = false;
activeSearchRequestRef.current += 1;
}, [visible, context]);
wasVisibleRef.current = true;
lastVisibleContextIdentityRef.current = contextIdentity;
}, [visible, contextIdentity, context]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: [];
const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
all.forEach((item) => {
const normalized = normalizeMetadataRow(item);
const rowType = normalizeResultType(normalized?.resultType) || 'movie';
const rowKey = String(
normalized?.providerId
|| `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...normalized,
_metadataKey: rowKey
});
});
return Array.from(map.values());
}, [context, extraResults, hasSearchOverride]);
const seriesRowsAvailable = useMemo(
() => rows.some((row) => normalizeResultType(row?.resultType) === 'series'),
[rows]
);
const selectedResultType = normalizeResultType(selected?.resultType)
|| (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie');
const selectedIsSeries = Boolean(selected) && selectedResultType === 'series';
const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable;
const filteredRows = useMemo(() => {
let output = [...rows];
if (resultFilter === 'movies') {
output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series');
} else if (resultFilter === 'series') {
output = output.filter((row) => normalizeResultType(row?.resultType) === 'series');
}
if (showSeasonFilter) {
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (normalizedFilter) {
output = output.filter((row) => {
if (normalizeResultType(row?.resultType) !== 'series') {
return false;
}
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}
}
return output;
}, [rows, resultFilter, seasonFilter, showSeasonFilter]);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& selectedIsSeries
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
useEffect(() => {
if (!discValidationError) {
@@ -212,56 +308,8 @@ export default function MetadataSelectionDialog({
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected, metadataProvider, conflictExistingDiscNumber, conflictNewDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
all.forEach((item) => {
const rowKey = String(
item?.providerId
|| item?.imdbId
|| item?.tmdbId
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...item,
_metadataKey: rowKey
});
});
return Array.from(map.values());
}, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
return rows;
}
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (!normalizedFilter) {
return rows;
}
return rows.filter((row) => {
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}, [metadataProvider, rows, seasonFilter]);
const titleWithPosterBody = (row) => (
<div className="omdb-row">
<div className="metadata-row">
{row.poster && row.poster !== 'N/A' ? (
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
) : (
@@ -323,7 +371,7 @@ export default function MetadataSelectionDialog({
};
useEffect(() => {
if (!visible || autoSearchDoneRef.current) {
if (!visible) {
return;
}
if (typeof onSearch !== 'function') {
@@ -334,9 +382,12 @@ export default function MetadataSelectionDialog({
if (!trimmedQuery) {
return;
}
if (autoSearchDoneRef.current) {
return;
}
autoSearchDoneRef.current = true;
void handleSearch();
}, [visible, query, metadataProvider, onSearch]);
}, [visible, query, onSearch]);
const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : [];
@@ -386,6 +437,9 @@ export default function MetadataSelectionDialog({
return;
}
const selectionWorkflowKind = selectedIsSeries ? 'series' : 'film';
const manualWorkflowKind = resultFilter === 'series' ? 'series' : 'film';
const payload = selected
? {
jobId: context.jobId,
@@ -393,27 +447,36 @@ export default function MetadataSelectionDialog({
year: selected.year,
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: metadataProvider === 'omdb',
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
workflowKind: selectionWorkflowKind,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0,
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
metadataKind: selected.metadataKind || (selectedIsSeries ? 'series' : 'movie'),
voteAverage: Number.isFinite(Number(selected?.voteAverage))
? Number(Number(selected.voteAverage).toFixed(1))
: null,
imdbRating: String(
selected?.imdbRating
|| selected?.tmdbDetails?.imdbRating
|| ''
).trim() || null,
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
? selected.tmdbDetails
: null,
seasonNumber: selectedIsSeries ? (selected.seasonNumber || null) : null,
seasonName: selectedIsSeries ? (selected.seasonName || null) : null,
episodeCount: selectedIsSeries ? (selected.episodeCount || 0) : 0,
episodes: selectedIsSeries && Array.isArray(selected.episodes) ? selected.episodes : [],
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}
: {
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: supportsExternalIdInput ? manualImdb : null,
imdbId: null,
poster: null,
fromOmdb: false,
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
workflowKind: manualWorkflowKind,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
@@ -566,69 +629,77 @@ export default function MetadataSelectionDialog({
header="Metadaten auswählen"
visible={visible}
onHide={handleHideRequest}
style={{ width: '52rem', maxWidth: '95vw' }}
style={{ width: '58.25rem', maxWidth: '95vw', maxHeight: '950px' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<div className="search-row">
{providerOptions.length > 1 ? (
<Dropdown
value={metadataProvider}
options={providerOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setHasSearchOverride(false);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
<div className="metadata-dialog-controls">
<div className="search-row">
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
disabled={searchBusy || busy}
style={{ minWidth: '12.5rem' }}
placeholder="Titel suchen"
/>
) : null}
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
placeholder="Titel suchen"
/>
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
{metadataProvider === 'tmdb' ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${showSeasonFilter ? '' : ' metadata-filter-grid-single'}`}>
{showSeasonFilter ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld für Serien-DVD/Blu-ray. Ohne Disc-Nummer können die Metadaten nicht übernommen werden.'
: 'Optional für Mapping und Output-Templates. Gespeichert werden nur Werte > 0.'}
</small>
</div>
</div>
<div className="metadata-result-filter-row">
<Button
label="Beides"
severity={resultFilter === 'all' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'all'}
onClick={() => setResultFilter('all')}
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
<Button
label="Filme"
severity={resultFilter === 'movies' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'movies'}
onClick={() => setResultFilter('movies')}
disabled={searchBusy || busy}
/>
<Button
label="Serien"
severity={resultFilter === 'series' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'series'}
onClick={() => setResultFilter('series')}
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small>
</div>
</div>
{searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
@@ -650,11 +721,16 @@ export default function MetadataSelectionDialog({
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
body={(row) => (metadataProvider === 'tmdb'
header="Typ"
body={(row) => (normalizeResultType(row?.resultType) === 'series' ? 'Serie' : 'Film')}
style={{ width: '7rem' }}
/>
<Column
header="Staffel"
body={(row) => (normalizeResultType(row?.resultType) === 'series'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))}
style={{ width: '10rem' }}
: '-')}
style={{ width: '8rem' }}
/>
</DataTable>
</div>
+304 -100
View File
@@ -316,6 +316,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) {
return output;
}
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'track_ids' || mode === 'variants') {
return mode;
}
return fallback;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
@@ -430,6 +438,15 @@ function buildDefaultTrackSelection(review) {
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: [];
const manualHasVariants = Object.keys(manualSubtitleVariantSelection).length > 0;
const subtitleSelectionMode = manualSelectionMatchesTitle
? normalizeSubtitleSelectionMode(
manualSelection?.subtitleSelectionMode,
manualHasVariants
? 'variants'
: (manualSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
)
: 'variants';
selection[titleId] = {
audioTrackIds: manualAudioTrackIds.length > 0
@@ -447,7 +464,8 @@ function buildDefaultTrackSelection(review) {
: defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder,
subtitleSelectionMode
};
}
@@ -460,7 +478,8 @@ function defaultTrackSelectionForTitle(review, titleId) {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
}
@@ -569,15 +588,22 @@ function hasSeriesMetadataShape(source = null) {
const metadata = source && typeof source === 'object' ? source : {};
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
const metadataKind = String(metadata?.metadataKind || '').trim().toLowerCase();
const episodeCount = Number(metadata?.episodeCount || 0) || 0;
if (['film', 'movie', 'feature'].includes(workflowKind) || ['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
return Boolean(
seasonNumber
|| episodeCount > 0
|| episodes.length > 0
|| metadataProvider === 'tmdb'
|| metadataProvider === 'themoviedb'
|| workflowKind === 'series'
|| workflowKind === 'season'
|| workflowKind === 'tv'
|| metadataKind === 'series'
|| metadataKind === 'season'
|| metadataKind === 'tv'
);
}
@@ -631,24 +657,14 @@ function resolveDiscWorkflowKind({
return normalized;
}
}
const providerCandidates = [
selected?.metadataProvider,
analyze?.metadataProvider,
pipelineCtx?.metadataProvider
];
for (const candidate of providerCandidates) {
const provider = String(candidate || '').trim().toLowerCase();
if (!provider) {
continue;
}
if (provider === 'tmdb' || provider === 'themoviedb') {
return 'series';
}
if (provider === 'omdb') {
return 'film';
}
if (
hasSeriesMetadataShape(selected)
|| hasSeriesMetadataShape(analyze)
|| hasSeriesMetadataShape(pipelineCtx)
) {
return 'series';
}
return fallbackIsSeries ? 'series' : 'film';
return null;
}
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
@@ -656,7 +672,10 @@ function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
return null;
}
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
const kind = normalizeDiscWorkflowKind(workflowKind);
if (!kind) {
return null;
}
if (normalizedMediaProfile === 'bluray') {
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
}
@@ -829,6 +848,20 @@ function formatPercentNoDecimals(value) {
return `${Math.trunc(Math.max(0, Math.min(100, parsed)))}%`;
}
function isPlaylistTrackPreparationStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
if (!normalized) {
return false;
}
return normalized.includes('PLAYLIST')
&& normalized.includes('TRACKDATEN')
&& normalized.includes('VORBEREIT');
}
function isMediainfoCheckStatus(value) {
return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK';
}
function getSeriesBatchTagSeverity(status) {
const normalized = normalizeSeriesBatchStatus(status);
if (normalized === 'FINISHED') {
@@ -1401,7 +1434,7 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
? ['dvd']
: mediaProfile === 'dvd'
? ['bluray']
: [];
: ['bluray', 'dvd', 'cd', 'audiobook'];
for (const fb of fallbackProfiles) {
const fbKey = `${key}_${fb}`;
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
@@ -1545,7 +1578,7 @@ export default function PipelineStatusCard({
onAnalyze,
onReanalyze,
onOpenMetadata,
onReassignOmdb,
onReassignMetadata,
onStart,
onRemoveFromQueue,
onRestartEncode,
@@ -1602,17 +1635,29 @@ export default function PipelineStatusCard({
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const hasReviewRecoverableRaw = Boolean(
!['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
&& ripCompleted
&& (
jobRow?.raw_path
|| pipeline?.context?.rawPath
|| jobRow?.makemkvInfo?.rawPath
)
);
const retryRipRecoveryRequired = Boolean(
!running
&& retryJobId
&& typeof onRetry === 'function'
&& !queueLocked
&& !isOrphanCancelled
&& !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
&& !hasReviewRecoverableRaw
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
&& (
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|| jobErrorMessageLower.includes('server-neustart')
|| (
jobErrorMessageLower.includes('server-neustart')
&& !hasReviewRecoverableRaw
)
|| jobErrorMessageLower.includes('rip ist unvollständig')
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
)
@@ -1626,6 +1671,7 @@ export default function PipelineStatusCard({
const [liveJobLog, setLiveJobLog] = useState('');
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]);
const [titleSelectionTouched, setTitleSelectionTouched] = useState(false);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
@@ -1645,6 +1691,7 @@ export default function PipelineStatusCard({
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]);
const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState('');
const [presetSelectionExplicitlyCleared, setPresetSelectionExplicitlyCleared] = useState(false);
const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv');
const [converterConfigSaving, setConverterConfigSaving] = useState(false);
const [converterConfigError, setConverterConfigError] = useState(null);
@@ -1803,6 +1850,7 @@ export default function PipelineStatusCard({
const effectiveSelected = defaultAllTitles
? normalizeTitleIdList(reviewTitles.map((title) => title?.id))
: selectedFromReview;
setTitleSelectionTouched(false);
setSelectedEncodeTitleIds(effectiveSelected);
setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
@@ -1850,12 +1898,18 @@ export default function PipelineStatusCard({
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
setSelectedOutputFormat(nextOutputFormat);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
const settingsPresetName = String(mediaInfoReview?.selectors?.preset || '').trim();
setPresetSelectionExplicitlyCleared(false);
if (Number.isFinite(userPresetId) && userPresetId > 0) {
setSelectedUserPresetId(Math.trunc(userPresetId));
setSelectedHandBrakePreset('');
} else {
setSelectedUserPresetId(null);
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
const hbPresetName = String(
mediaInfoReview?.userPreset?.handbrakePreset
|| settingsPresetName
|| ''
).trim();
setSelectedHandBrakePreset(hbPresetName);
}
}, [
@@ -1866,6 +1920,7 @@ export default function PipelineStatusCard({
mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset,
mediaInfoReview?.selectors?.preset,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
@@ -1877,6 +1932,7 @@ export default function PipelineStatusCard({
if (mediaInfoReview) {
return;
}
setTitleSelectionTouched(false);
setSelectedEncodeTitleIds([]);
setSelectedEncodeTitleId(null);
setTrackSelectionByTitle({});
@@ -1886,8 +1942,29 @@ export default function PipelineStatusCard({
setPostEncodeItems([]);
setSelectedUserPresetId(null);
setSelectedHandBrakePreset('');
setPresetSelectionExplicitlyCleared(false);
}, [mediaInfoReview, retryJobId]);
const resolveEffectiveSelectedTitleIds = (reviewTitles = []) => {
const normalizedReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : [];
const explicitSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
if (titleSelectionTouched) {
return explicitSelectedIds;
}
const fallbackSelectedIds = normalizeTitleIdList([
...explicitSelectedIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...normalizedReviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = normalizedReviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
return selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
};
useEffect(() => {
const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
const primaryTitleId = normalizeTitleId(selectedEncodeTitleId);
@@ -1927,11 +2004,7 @@ export default function PipelineStatusCard({
const resolveEpisodeFillContext = () => {
const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [];
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const selectedTitleIds = normalizeTitleIdList([
...normalizeTitleIdList(selectedEncodeTitleIds),
...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []),
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const orderedTitles = reviewTitles
.map((title) => ({
id: normalizeTitleId(title?.id),
@@ -2233,18 +2306,7 @@ export default function PipelineStatusCard({
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const hasEpisodeAssignmentPayload = (assignment) => {
if (!assignment || typeof assignment !== 'object') {
@@ -2294,10 +2356,16 @@ export default function PipelineStatusCard({
const hasSelectedEncodeTitle = normalizeTitleIdList(selectedEncodeTitleIds).length > 0;
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim();
const settingsExtraArgsFallback = String(mediaInfoReview?.selectors?.extraArgs || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
const hasSettingsPresetForValidation = !presetSelectionExplicitlyCleared && Boolean(settingsPresetFallback);
const hasEffectiveSelectedPreset = hasSelectedPreset
|| hasSettingsPresetForValidation
|| Boolean(settingsExtraArgsFallback);
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig;
const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray';
const multipartSettingsLock = mediaInfoReview?.multipartSettingsLock
&& typeof mediaInfoReview.multipartSettingsLock === 'object'
? mediaInfoReview.multipartSettingsLock
@@ -2318,6 +2386,15 @@ export default function PipelineStatusCard({
);
const allowTrackSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
const allowEncodeItemSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
const alternativeTitleSelection = mediaInfoReview?.alternativeTitleSelection
&& typeof mediaInfoReview.alternativeTitleSelection === 'object'
? mediaInfoReview.alternativeTitleSelection
: null;
const hasAlternativeTitleSelection = Boolean(
alternativeTitleSelection?.enabled
&& Array.isArray(mediaInfoReview?.titles)
&& mediaInfoReview.titles.length > 1
);
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
@@ -2335,7 +2412,7 @@ export default function PipelineStatusCard({
const canStartReadyJob = (isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath))
&& (!requiresPresetSelection || hasSelectedPreset);
&& (!requiresPresetSelection || hasEffectiveSelectedPreset);
const canRestartEncodeFromLastSettings = Boolean(
(state === 'ERROR' || state === 'CANCELLED')
&& retryJobId
@@ -2494,6 +2571,31 @@ export default function PipelineStatusCard({
}
return dedup;
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
const alternativeTitleOptions = useMemo(() => {
if (!hasAlternativeTitleSelection) {
return [];
}
const titles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
return titles
.map((title) => {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
return null;
}
const playlistFile = String(title?.playlistFile || '').trim();
const durationLabel = formatDurationClock(title?.durationSeconds) || null;
const parts = [
playlistFile || `Titel #${titleId}`,
durationLabel
].filter(Boolean);
return {
value: titleId,
label: parts.join(' | ')
};
})
.filter(Boolean)
.sort((left, right) => Number(left.value) - Number(right.value));
}, [hasAlternativeTitleSelection, mediaInfoReview?.titles]);
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
@@ -3076,6 +3178,12 @@ export default function PipelineStatusCard({
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const showMediainfoIndeterminatePrimaryProgress = !hasSeriesBatchProgress
&& isMediainfoCheckStatus(state);
const showIndeterminatePrimaryProgress = showMediainfoIndeterminatePrimaryProgress || (
!hasSeriesBatchProgress
&& isPlaylistTrackPreparationStatus(pipeline?.statusText)
);
const ripIssueEntries = useMemo(
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
[jobRow?.makemkvInfo]
@@ -3422,18 +3530,7 @@ export default function PipelineStatusCard({
.map((title) => [normalizeTitleId(title?.id), title])
.filter((entry) => entry[0] !== null)
);
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|| null;
@@ -3476,11 +3573,24 @@ export default function PipelineStatusCard({
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
const subtitleSelectionMode = normalizeSubtitleSelectionMode(
effectiveSelection?.subtitleSelectionMode,
hasExplicitVariantSelection
? 'variants'
: (filteredSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
);
selectedTrackSelection[titleId] = {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
subtitleSelectionMode,
subtitleTrackIds: subtitleSelectionMode === 'track_ids'
? filteredSubtitleTrackIds
: (hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds),
...(subtitleSelectionMode === 'variants'
? {
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
}
: {})
};
}
const episodeAssignments = {};
@@ -3594,13 +3704,19 @@ export default function PipelineStatusCard({
{running && (
<div className="progress-wrap">
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
/>
{showIndeterminatePrimaryProgress ? (
<ProgressBar mode="indeterminate" showValue={false} />
) : (
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
/>
)}
<small>
{primaryEta
{showIndeterminatePrimaryProgress
? (showMediainfoIndeterminatePrimaryProgress ? 'Mediainfo-Prüfung läuft …' : 'Vorbereitung läuft …')
: primaryEta
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
: 'ETA unbekannt'}
</small>
@@ -3695,7 +3811,7 @@ export default function PipelineStatusCard({
&& state !== 'IDLE'
&& state !== 'DISC_DETECTED'
&& retryJobId
&& typeof onReassignOmdb === 'function'
&& typeof onReassignMetadata === 'function'
&& !isOrphanCancelled
&& !blockMetadataAndReviewForRipRetry
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
@@ -3704,7 +3820,7 @@ export default function PipelineStatusCard({
icon="pi pi-search"
severity="info"
size="small"
onClick={() => onReassignOmdb?.(retryJobId)}
onClick={() => onReassignMetadata?.(retryJobId)}
loading={busy}
/>
) : null}
@@ -3791,10 +3907,14 @@ export default function PipelineStatusCard({
selectedPostEncodeScriptIds: selectedPostScriptIds,
selectedPreEncodeScriptIds: selectedPreScriptIds,
selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds,
selectedHandBrakePreset
selectedPreEncodeChainIds: selectedPreChainIds
};
startPayload.selectedUserPresetId = selectedUserPresetId || null;
startPayload.selectedUserPresetId = (
selectedUserPresetId !== null && selectedUserPresetId !== undefined
? selectedUserPresetId
: null
);
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
await onStart(retryJobId, startPayload);
}}
loading={busy}
@@ -4139,7 +4259,7 @@ export default function PipelineStatusCard({
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
{(mediaInfoReview || state === 'READY_TO_ENCODE') && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block">
{!isDiscTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? (
@@ -4150,16 +4270,6 @@ export default function PipelineStatusCard({
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
{multipartSettingsLocked ? (
<div className="playlist-decision-block">
<h3>Multipart-Lock aktiv</h3>
<small>
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
</small>
</div>
) : null}
{showConverterConfig ? (
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
@@ -4227,7 +4337,7 @@ export default function PipelineStatusCard({
) : null}
</div>
</div>
{allowConverterConfigEdit && !hasSelectedPreset ? (
{allowConverterConfigEdit && !hasEffectiveSelectedPreset ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann.
</small>
@@ -4245,29 +4355,91 @@ export default function PipelineStatusCard({
) : null}
</div>
) : null}
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small>
{episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small>
))}
</div>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
presetDisplayValue={presetDisplayValue}
commandOutputPath={commandOutputPath}
commandOutputPathByTitle={commandOutputPathByTitle}
topContent={(
<>
{hasAlternativeTitleSelection ? (
<div className="playlist-decision-block">
<h3>Alternative Titel gefunden</h3>
<small>
Es wurden weitere gleichlange oder längere Titel erkannt. Das kann auf alternative Schnittfassungen
oder Playlist-Varianten derselben Disc hindeuten. Bitte waehle hier den Titel aus, der fuer Tracks,
Untertitel, Infos und den HandBrakeCLI-Command verwendet werden soll.
</small>
<div style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>
Zu verwendender Titel
</label>
<Dropdown
value={normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || null}
options={alternativeTitleOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const nextTitleId = normalizeTitleId(e?.value);
if (!nextTitleId) {
return;
}
setSelectedEncodeTitleId(nextTitleId);
setSelectedEncodeTitleIds([nextTitleId]);
setTitleSelectionTouched(true);
}}
style={{ width: '100%' }}
disabled={!allowReviewSelectionEdit || alternativeTitleOptions.length <= 1}
/>
</div>
</div>
) : null}
{multipartSettingsLocked ? (
<div className="playlist-decision-block">
<h3>Multipart-Lock aktiv</h3>
<small>
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
</small>
</div>
) : null}
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small>
{episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small>
))}
</div>
) : null}
{!showConverterConfig
&& requiresPresetSelection
&& allowReviewSelectionEdit
&& !hasEffectiveSelectedPreset
&& state === 'READY_TO_ENCODE'
&& mediaInfoReview ? (
<div className="playlist-decision-block">
<h3>Preset-Auswahl erforderlich</h3>
<small>
Es ist kein gültiges Preset/CLI-Setup aktiv. Bitte Preset auswählen oder gültige Extra-Args hinterlegen.
</small>
</div>
) : null}
</>
)}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
displayOnlySelectedTitle={hasAlternativeTitleSelection}
titleSelectionTouched={titleSelectionTouched}
allowTitleSelection={allowTitleSelectionInReview}
compactTitleSelection={isDiscTitleSelectionRequired}
onSelectEncodeTitle={(titleId) => {
const normalizedTitleId = normalizeTitleId(titleId);
setSelectedEncodeTitleId(normalizedTitleId);
setTitleSelectionTouched(true);
if (!normalizedTitleId) {
return;
}
@@ -4278,6 +4450,7 @@ export default function PipelineStatusCard({
if (!normalizedTitleId) {
return;
}
setTitleSelectionTouched(true);
setSelectedEncodeTitleIds((prev) => {
const current = normalizeTitleIdList(prev);
if (checked) {
@@ -4310,7 +4483,8 @@ export default function PipelineStatusCard({
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
@@ -4322,7 +4496,14 @@ export default function PipelineStatusCard({
...prev,
[normalizedTitleId]: {
...current,
[key]: next
[key]: next,
...(trackType === 'subtitle'
? {
subtitleSelectionMode: 'track_ids',
subtitleVariantSelection: {},
subtitleLanguageOrder: []
}
: {})
}
};
});
@@ -4340,7 +4521,8 @@ export default function PipelineStatusCard({
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap };
@@ -4368,6 +4550,7 @@ export default function PipelineStatusCard({
...prev,
[normalizedTitleId]: {
...current,
subtitleSelectionMode: 'variants',
subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder
}
@@ -4451,8 +4634,29 @@ export default function PipelineStatusCard({
postEncodeItems={postEncodeItems}
userPresets={effectiveUserPresets}
selectedUserPresetId={selectedUserPresetId}
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => setSelectedUserPresetId(presetId)}
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => {
const nextPresetId = Number.isFinite(Number(presetId)) && Number(presetId) > 0
? Math.trunc(Number(presetId))
: null;
setSelectedUserPresetId(nextPresetId);
if (nextPresetId) {
setSelectedHandBrakePreset('');
}
}}
selectedHandBrakePreset={selectedHandBrakePreset}
onHandBrakePresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetName) => {
const nextPresetName = String(presetName || '').trim();
setSelectedHandBrakePreset(nextPresetName);
if (nextPresetName) {
setSelectedUserPresetId(null);
}
}}
presetSelectionExplicitlyCleared={presetSelectionExplicitlyCleared}
onPresetTokenChange={showConverterConfig || multipartSettingsLocked ? null : (token) => {
const normalizedToken = String(token || '').trim().toLowerCase();
setPresetSelectionExplicitlyCleared(normalizedToken === 'none');
}}
handBrakePresetOptions={handBrakePresetOptions}
allowEncodeItemSelection={allowEncodeItemSelectionInReview}
comparisonReviewItems={multipartComparisonReviews}
onAddPreEncodeItem={(itemType) => {
+35 -7
View File
@@ -9,6 +9,21 @@ function buildWsUrl() {
return `${protocol}//${window.location.host}/ws`;
}
function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isRunningStage(value) {
const normalized = normalizeStage(value);
return normalized === 'ANALYZING'
|| normalized === 'RIPPING'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'ENCODING'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING';
}
export function useWebSocket({ onMessage }) {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
@@ -18,7 +33,7 @@ export function useWebSocket({ onMessage }) {
let reconnectTimer;
let progressFlushTimer = null;
let progressFlushRaf = null;
let latestProgressMessage = null;
const latestProgressMessagesByJob = new Map();
let isUnmounted = false;
const clearProgressFlush = () => {
@@ -35,12 +50,14 @@ export function useWebSocket({ onMessage }) {
const flushProgressMessage = () => {
progressFlushRaf = null;
progressFlushTimer = null;
const message = latestProgressMessage;
latestProgressMessage = null;
if (!message) {
if (latestProgressMessagesByJob.size === 0) {
return;
}
onMessageRef.current?.(message);
const messages = Array.from(latestProgressMessagesByJob.values());
latestProgressMessagesByJob.clear();
for (const message of messages) {
onMessageRef.current?.(message);
}
};
const scheduleProgressFlush = () => {
@@ -65,10 +82,21 @@ export function useWebSocket({ onMessage }) {
try {
const message = JSON.parse(event.data);
if (message?.type === 'PIPELINE_PROGRESS') {
latestProgressMessage = message;
scheduleProgressFlush();
const progressJobId = message?.payload?.activeJobId;
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
const progressStage = normalizeStage(message?.payload?.state);
if (isRunningStage(progressStage)) {
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush();
} else {
latestProgressMessagesByJob.delete(jobKey);
clearProgressFlush();
flushProgressMessage();
onMessageRef.current?.(message);
}
return;
}
flushProgressMessage();
onMessageRef.current?.(message);
} catch (error) {
// ignore invalid json
+2
View File
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
+144 -81
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
@@ -10,7 +9,6 @@ import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { resolveMediaType } from '../utils/jobTaxonomy';
import { confirmModal } from '../utils/confirmModal';
@@ -25,6 +23,44 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function hasOwn(source, key) {
return source != null && Object.prototype.hasOwnProperty.call(source, key);
}
function normalizeLiveProgressForJob(job) {
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
if (!live) {
return null;
}
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
return {
progress: live?.progress ?? null,
eta: live?.eta ?? null,
statusText: live?.statusText ?? null,
state: live?.state ?? null,
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
? liveContext.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
? Number(liveContext.completedChapterCount)
: null
};
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function parseEncodePlan(job) {
if (job?.encodePlan && typeof job.encodePlan === 'object') {
return job.encodePlan;
@@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) {
};
}
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
const narrator = String(metadata?.narrator || '').trim() || null;
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
const year = Number.isFinite(Number(metadata?.year))
? Math.trunc(Number(metadata.year))
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
return (
`#${jobId}`
+ (author ? ` | ${author}` : '')
+ (narrator ? ` | ${narrator}` : '')
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
+ (year ? ` | ${year}` : '')
);
}
function buildAudiobookPipeline(job, progress = null) {
const encodePlan = parseEncodePlan(job);
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
@@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) {
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {}
: {},
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
},
mediaInfoReview: reviewData,
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
@@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) {
export default function AudiobooksPage({
audiobookUpload,
onAudiobookUpload
onAudiobookUpload,
onCancelAudiobookUpload = null
}) {
const toastRef = useRef(null);
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loadingJobs, setLoadingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [jobProgress, setJobProgress] = useState({});
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
const explorerRefreshToken = 0;
const setActionBusy = (jobId, busy) => {
const normalizedJobId = normalizeJobId(jobId);
@@ -146,21 +203,32 @@ export default function AudiobooksPage({
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
setJobs(audiobookRows);
setJobProgress((prev) => {
const next = { ...prev };
const next = {};
for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
continue;
}
const status = String(job?.status || '').trim().toUpperCase();
if (!['ANALYZING', 'ENCODING'].includes(status)) {
delete next[jobId];
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
if (!isLiveStatus) {
continue;
}
const serverProgress = normalizeLiveProgressForJob(job);
if (serverProgress) {
next[jobId] = serverProgress;
continue;
}
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
next[jobId] = prev[jobId];
}
}
return next;
});
return audiobookRows;
} catch (error) {
console.error('AudiobooksPage load jobs error:', error);
return [];
} finally {
setLoadingJobs(false);
}
@@ -183,17 +251,31 @@ export default function AudiobooksPage({
if (!jobId) {
return;
}
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null,
currentChapter: payload?.contextPatch?.currentChapter ?? null,
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
}
}));
setJobProgress((prev) => {
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
? payload.contextPatch
: null;
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
? (contextPatch?.currentChapter ?? null)
: (previousEntry?.currentChapter ?? null);
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
? Number(contextPatch.completedChapterCount)
: null)
: (previousEntry?.completedChapterCount ?? null);
return {
...prev,
[jobId]: {
progress: payload?.progress ?? previousEntry?.progress ?? null,
eta: payload?.eta ?? previousEntry?.eta ?? null,
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
state: payload?.state ?? previousEntry?.state ?? null,
currentChapter: nextCurrentChapter,
completedChapterCount: nextCompletedChapterCount
}
};
});
}
if (
message.type === 'PIPELINE_UPDATE'
@@ -262,7 +344,7 @@ export default function AudiobooksPage({
}
setActionBusy(normalizedJobId, true);
try {
await api.cancelJob(normalizedJobId);
await api.cancelPipeline(normalizedJobId);
toastRef.current?.show({
severity: 'info',
summary: 'Abbruch angefordert',
@@ -282,39 +364,6 @@ export default function AudiobooksPage({
}
};
const handleRetry = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.retryJob(normalizedJobId);
const retryJobId = normalizeJobId(response?.retryJob?.id);
toastRef.current?.show({
severity: 'success',
summary: 'Retry erstellt',
detail: retryJobId
? `Neuer Job #${retryJobId} wurde angelegt.`
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
life: 3200
});
await loadJobs();
if (retryJobId) {
setExpandedJobId(retryJobId);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Retry fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleDelete = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
@@ -357,11 +406,14 @@ export default function AudiobooksPage({
}
};
const handleUploaded = async (uploadedJobId) => {
const normalizedJobId = normalizeJobId(uploadedJobId);
await loadJobs();
if (normalizedJobId) {
setExpandedJobId(normalizedJobId);
const handleUploaded = async (uploadedJobId, response = null) => {
const normalizedJobId = normalizeJobId(uploadedJobId)
|| extractUploadJobIdFromResponse(response);
const rows = await loadJobs();
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
const targetJobId = normalizedJobId || fallbackJobId;
if (targetJobId) {
setExpandedJobId(targetJobId);
}
};
@@ -393,6 +445,7 @@ export default function AudiobooksPage({
<AudiobookUploadPanel
audiobookUpload={audiobookUpload}
onAudiobookUpload={onAudiobookUpload}
onCancelUpload={onCancelAudiobookUpload}
onUploaded={handleUploaded}
/>
</Card>
@@ -417,7 +470,17 @@ export default function AudiobooksPage({
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
: 0;
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
const showProgress = isRunning || progressValue > 0;
const progressLabel = `${Math.trunc(progressValue)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
? pipelineForJob.context.currentChapter
: null;
const chapterLabel = currentChapter?.index && currentChapter?.total
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
: '';
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
if (!isExpanded) {
return (
@@ -427,25 +490,30 @@ export default function AudiobooksPage({
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>#{jobId}</small>
<small>{metaLine}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
@@ -453,25 +521,22 @@ export default function AudiobooksPage({
return (
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span>
<span>{title}</span>
</strong>
<small className="ripper-job-subtitle">{metaLine}</small>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Im Ripper"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => navigate('/ripper')}
/>
<Button
label="Einklappen"
icon="pi pi-angle-up"
@@ -482,11 +547,16 @@ export default function AudiobooksPage({
/>
</div>
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)}
onRetry={() => void handleRetry(jobId)}
onDeleteJob={() => void handleDelete(jobId)}
busy={busy}
/>
@@ -496,13 +566,6 @@ export default function AudiobooksPage({
</div>
)}
</Card>
<Card
title="Audiobook Output Explorer"
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
>
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
</Card>
</div>
);
}
+14 -7
View File
@@ -507,7 +507,7 @@ export default function ConverterPage() {
imdbId: row?.imdb_id || metadata?.imdbId || null,
poster: row?.poster_url || metadata?.poster || null
},
omdbCandidates: []
metadataCandidates: []
});
setMetadataDialogVisible(true);
};
@@ -537,9 +537,9 @@ export default function ConverterPage() {
}
};
const handleOmdbSearch = async (query) => {
const handleMetadataSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : [];
} catch (_error) {
return [];
@@ -911,6 +911,13 @@ export default function ConverterPage() {
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
<div className="converter-beta-notice" role="note" aria-live="polite">
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
<span>
Hinweis: Der Converter befindet sich aktuell im Beta-Stadium und ist noch nicht vollständig geprüft.
</span>
</div>
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
@@ -978,7 +985,7 @@ export default function ConverterPage() {
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onOpenMetadata={handleOpenMetadataDialog}
onReassignOmdb={handleOpenMetadataDialog}
onReassignMetadata={handleOpenMetadataDialog}
onStart={handleStartPipelineJob}
onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist}
@@ -1027,7 +1034,7 @@ export default function ConverterPage() {
setMetadataDialogContext(null);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
onSearch={handleMetadataSearch}
busy={metadataDialogBusy}
/>
@@ -1196,7 +1203,7 @@ function ConverterVideoJobCard({
onExpand,
onCollapse,
onOpenMetadata,
onReassignOmdb,
onReassignMetadata,
onStart,
onConfirmReview,
onSelectPlaylist,
@@ -1282,7 +1289,7 @@ function ConverterVideoJobCard({
jobRow={job}
pipeline={pipeline}
onOpenMetadata={onOpenMetadata}
onReassignOmdb={onReassignOmdb}
onReassignMetadata={onReassignMetadata}
onStart={onStart}
onConfirmReview={onConfirmReview}
onSelectPlaylist={onSelectPlaylist}
+67 -10
View File
@@ -31,6 +31,7 @@ export default function DatabasePage() {
const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0);
@@ -130,6 +131,50 @@ export default function DatabasePage() {
setOrphanImportBusyPath(null);
}
};
const handleDeleteOrphanRaw = async (row) => {
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
const confirmed = await confirmModal({
header: 'RAW löschen',
message:
`RAW-Ordner "${target}" wirklich löschen?\n` +
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
acceptLabel: 'RAW löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setOrphanDeleteBusyPath(row.rawPath);
try {
const response = await api.deleteOrphanRawFolder(row.rawPath);
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
const deletedFiles = Number(response?.filesDeleted || 0);
const deletedDirs = Number(response?.dirsRemoved || 0);
if (deletedRawPath) {
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: 'RAW gelöscht',
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
life: 3500
});
await loadOrphans({ silent: true });
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanDeleteBusyPath(null);
}
};
const orphanTitleBody = (row) => (
<div>
<div><strong>{row.title || '-'}</strong></div>
@@ -145,14 +190,26 @@ export default function DatabasePage() {
</div>
);
const orphanActionBody = (row) => (
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath)}
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDeleteOrphanRaw(row)}
loading={orphanDeleteBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
</div>
);
return (
@@ -169,7 +226,7 @@ export default function DatabasePage() {
icon="pi pi-search"
onClick={loadOrphans}
loading={orphanLoading}
disabled={Boolean(orphanImportBusyPath)}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
</div>
@@ -190,7 +247,7 @@ export default function DatabasePage() {
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
</DataTable>
</div>
</Card>
+839
View File
@@ -0,0 +1,839 @@
import { useEffect, useMemo, useState } from 'react';
import 'chart.js/auto';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Chart } from 'primereact/chart';
import { Button } from 'primereact/button';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return `${Math.round(parsed)}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '-';
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalized = bytes / (1024 ** exponent);
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
}
function formatUpdatedAt(value) {
const raw = String(value || '').trim();
if (!raw) {
return '-';
}
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) {
return raw;
}
return new Date(parsed).toLocaleString('de-DE');
}
function normalizeHardwareMonitoringPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
return {
enabled: Boolean(payload.enabled),
intervalMs: Number(payload.intervalMs || 0),
updatedAt: payload.updatedAt || null,
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
error: payload.error ? String(payload.error) : null
};
}
function buildGaugeDataset(value, color) {
const safe = clampPercent(value);
return {
datasets: [
{
data: [safe, Math.max(0, 100 - safe)],
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
borderWidth: 0,
hoverOffset: 0
}
]
};
}
const gaugeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '82%',
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
animation: false
};
const HISTORY_WINDOWS = [
{ label: '1h', hours: 1 },
{ label: '6h', hours: 6 },
{ label: '24h', hours: 24 },
{ label: '4d', hours: 96 }
];
const HISTORY_STACK_BREAKPOINT = 1700;
const HISTORY_RESAMPLE_TARGET_POINTS = 720;
const HISTORY_INTERPOLATION_MIN_GAP_MS = 5 * 60 * 1000;
const HISTORY_INTERPOLATION_MAX_GAP_MS = 45 * 60 * 1000;
const HISTORY_INTERPOLATION_GAP_FACTOR = 6;
const CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a';
const RAM_SERIES_COLOR = '#2e7d4f';
const historyChartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (items) => {
const first = Array.isArray(items) ? items[0] : null;
const rawLabel = first?.label ?? first?.raw?.x ?? first?.parsed?.x ?? '';
return formatTickLabel(rawLabel);
}
}
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: '#6a4d38',
callback: function (value, index, ticks) {
const resolvedLabel = typeof this?.getLabelForValue === 'function'
? this.getLabelForValue(value)
: (
Array.isArray(ticks) && ticks[index]
? (ticks[index].label ?? ticks[index].value ?? value)
: value
);
return formatTickLabel(resolvedLabel);
}
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
}
}
};
function toNumberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatTickLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '-';
}
const date = new Date(parsed);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function formatDayMarkerLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '';
}
const date = new Date(parsed);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
}
function toDayKeyFromTimestamp(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return null;
}
const date = new Date(parsed);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function parseTimestampMs(value) {
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeHistoryPoints(points = []) {
return (Array.isArray(points) ? points : [])
.map((point) => {
const timestampMs = parseTimestampMs(point?.capturedAt);
if (!Number.isFinite(timestampMs)) {
return null;
}
return {
capturedAt: new Date(timestampMs).toISOString(),
timestampMs,
cpuUsagePercent: toNumberOrNull(point?.cpuUsagePercent),
ramUsagePercent: toNumberOrNull(point?.ramUsagePercent),
gpuUsagePercent: toNumberOrNull(point?.gpuUsagePercent),
cpuTemperatureC: toNumberOrNull(point?.cpuTemperatureC),
gpuTemperatureC: toNumberOrNull(point?.gpuTemperatureC)
};
})
.filter(Boolean)
.sort((left, right) => left.timestampMs - right.timestampMs);
}
function interpolateMetric(prevPoint, nextPoint, metricKey, targetMs, maxGapMs) {
if (!prevPoint || !nextPoint) {
return null;
}
if (prevPoint.timestampMs === nextPoint.timestampMs) {
return toNumberOrNull(prevPoint[metricKey]);
}
const gapMs = nextPoint.timestampMs - prevPoint.timestampMs;
if (!Number.isFinite(gapMs) || gapMs <= 0 || gapMs > maxGapMs) {
return null;
}
const prevValue = toNumberOrNull(prevPoint[metricKey]);
const nextValue = toNumberOrNull(nextPoint[metricKey]);
if (!Number.isFinite(prevValue) || !Number.isFinite(nextValue)) {
return null;
}
const ratio = (targetMs - prevPoint.timestampMs) / gapMs;
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
return null;
}
return Number((prevValue + ((nextValue - prevValue) * ratio)).toFixed(2));
}
function buildResampledHistoryPoints(points = [], historyHours = 24) {
const normalized = normalizeHistoryPoints(points);
if (normalized.length === 0) {
return [];
}
const historyWindowMs = Math.max(1, Number(historyHours || 24)) * 60 * 60 * 1000;
const stepMsRaw = Math.round(historyWindowMs / HISTORY_RESAMPLE_TARGET_POINTS);
const stepMs = Math.max(60 * 1000, stepMsRaw);
const maxInterpolationGapMs = Math.max(
HISTORY_INTERPOLATION_MIN_GAP_MS,
Math.min(HISTORY_INTERPOLATION_MAX_GAP_MS, stepMs * HISTORY_INTERPOLATION_GAP_FACTOR)
);
const latestSourceMs = normalized[normalized.length - 1].timestampMs;
const windowEndMs = Math.max(Date.now(), latestSourceMs);
const windowStartMs = windowEndMs - historyWindowMs;
const gridStartMs = Math.floor(windowStartMs / stepMs) * stepMs;
const gridEndMs = Math.ceil(windowEndMs / stepMs) * stepMs;
const source = normalized.filter((point) => point.timestampMs >= (gridStartMs - maxInterpolationGapMs));
const keys = [
'cpuUsagePercent',
'ramUsagePercent',
'gpuUsagePercent',
'cpuTemperatureC',
'gpuTemperatureC'
];
let sourceIndex = 0;
const output = [];
for (let ts = gridStartMs; ts <= gridEndMs; ts += stepMs) {
while (sourceIndex < source.length && source[sourceIndex].timestampMs < ts) {
sourceIndex += 1;
}
const nextPoint = sourceIndex < source.length ? source[sourceIndex] : null;
const prevPoint = sourceIndex > 0 ? source[sourceIndex - 1] : null;
const row = {
capturedAt: new Date(ts).toISOString(),
timestampMs: ts
};
for (const key of keys) {
const nextValue = nextPoint && nextPoint.timestampMs === ts
? toNumberOrNull(nextPoint[key])
: null;
const prevValue = prevPoint && prevPoint.timestampMs === ts
? toNumberOrNull(prevPoint[key])
: null;
row[key] = Number.isFinite(nextValue)
? nextValue
: (Number.isFinite(prevValue)
? prevValue
: interpolateMetric(prevPoint, nextPoint, key, ts, maxInterpolationGapMs));
}
output.push(row);
}
return output;
}
const historyDayBoundaryPlugin = {
id: 'historyDayBoundary',
afterDatasetsDraw(chart, _args, options) {
if (options === false) {
return;
}
const xScale = chart?.scales?.x;
const area = chart?.chartArea;
const labels = Array.isArray(chart?.data?.labels) ? chart.data.labels : [];
if (!xScale || !area || labels.length < 2) {
return;
}
const ctx = chart.ctx;
ctx.save();
ctx.strokeStyle = 'rgba(111,57,34,0.30)';
ctx.fillStyle = 'rgba(111,57,34,0.75)';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let index = 1; index < labels.length; index += 1) {
const prevDayKey = toDayKeyFromTimestamp(labels[index - 1]);
const nextDayKey = toDayKeyFromTimestamp(labels[index]);
if (!prevDayKey || !nextDayKey || prevDayKey === nextDayKey) {
continue;
}
const xPrev = xScale.getPixelForValue(index - 1);
const xNext = xScale.getPixelForValue(index);
const x = (xPrev + xNext) / 2;
ctx.beginPath();
ctx.moveTo(x, area.top);
ctx.lineTo(x, area.bottom);
ctx.stroke();
const markerLabel = formatDayMarkerLabel(labels[index]);
if (markerLabel) {
ctx.fillText(markerLabel, x + 4, area.top + 4);
}
}
ctx.restore();
}
};
function buildSeriesToggleButtonStyle(color, active) {
if (active) {
return {
background: color,
borderColor: color,
color: '#fffaf1'
};
}
return {
background: 'transparent',
borderColor: color,
color
};
}
function getHistoryViewportBucket() {
if (typeof window === 'undefined') {
return 'wide';
}
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
}
function ChartLegendRow({ items = [] }) {
return (
<div className="hardware-history-legend-row" aria-hidden="true">
{items.map((item) => (
<span key={item.label} className="hardware-history-legend-item">
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
<span>{item.label}</span>
</span>
))}
</div>
);
}
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
return (
<Card className="hardware-detail-card hardware-detail-card-top">
<div className="hardware-detail-card-head">
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
<div>
<h3>{title}</h3>
{subtitle ? <small>{subtitle}</small> : null}
</div>
</div>
<div className="hardware-detail-gauge-layout">
<div className="hardware-detail-gauge-wrap">
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
</div>
<div className="hardware-detail-bar-list">
{bars.map((bar) => (
<div key={bar.label} className="hardware-detail-bar-item">
<div className="hardware-detail-bar-head">
<span>{bar.label}</span>
<span>{bar.valueLabel}</span>
</div>
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
</div>
))}
</div>
</div>
{footer}
</Card>
);
}
export default function HardwarePage({ hardwareMonitoring }) {
const navigate = useNavigate();
const [historyHours, setHistoryHours] = useState(24);
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
cpu: true,
ram: true,
gpu: true
});
const [tempSeriesVisible, setTempSeriesVisible] = useState({
cpu: true,
gpu: true
});
const [historyState, setHistoryState] = useState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
const monitoringState = useMemo(
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
[hardwareMonitoring]
);
const sample = monitoringState.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 cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
const primaryGpu = gpuDevices[0] || null;
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
const resampledHistoryPoints = useMemo(
() => buildResampledHistoryPoints(historyPoints, historyHours),
[historyPoints, historyHours]
);
useEffect(() => {
if (!monitoringState.enabled) {
setHistoryState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
return undefined;
}
let cancelled = false;
const loadHistory = async (forceRefresh = false) => {
setHistoryState((prev) => ({
...prev,
loading: prev.points.length === 0
}));
try {
const response = await api.getHardwareHistory({
hours: historyHours,
maxPoints: 900,
forceRefresh
});
if (cancelled) {
return;
}
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
setHistoryState({
loading: false,
points: Array.isArray(payload.points) ? payload.points : [],
totalPoints: Number(payload.totalPoints || 0),
error: null
});
} catch (error) {
if (cancelled) {
return;
}
setHistoryState((prev) => ({
...prev,
loading: false,
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
}));
}
};
loadHistory(true);
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
const timer = setInterval(() => {
loadHistory(true);
}, refreshMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
useEffect(() => {
let frame = null;
const handleResize = () => {
if (frame) {
cancelAnimationFrame(frame);
}
frame = requestAnimationFrame(() => {
frame = null;
setHistoryViewportBucket((prev) => {
const next = getHistoryViewportBucket();
return prev === next ? prev : next;
});
});
};
window.addEventListener('resize', handleResize);
return () => {
if (frame) {
cancelAnimationFrame(frame);
}
window.removeEventListener('resize', handleResize);
};
}, []);
const historyUsageChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'RAM',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.ram,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [resampledHistoryPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [resampledHistoryPoints, tempSeriesVisible]);
return (
<div className="hardware-detail-page">
<Card className="hardware-detail-hero">
<div className="hardware-detail-hero-head">
<div>
<h2>Hardware Monitoring</h2>
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
</div>
<div className="hardware-detail-hero-tags">
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
</div>
</div>
</Card>
{!monitoringState.enabled ? (
<Card className="hardware-detail-empty">
<h3>Monitoring ist deaktiviert</h3>
<p>
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
um die Live-Ansicht auf dieser Seite zu nutzen.
</p>
<Button
label="Zu den Einstellungen"
icon="pi pi-cog"
onClick={() => navigate('/settings')}
/>
</Card>
) : null}
{monitoringState.enabled && !sample ? (
<Card className="hardware-detail-empty">
<h3>Warte auf erste Messung ...</h3>
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
</Card>
) : null}
{monitoringState.enabled ? (
<Card className="hardware-detail-card hardware-history-card">
<div className="hardware-detail-card-head hardware-history-card-head">
<div>
<h3>Historie</h3>
<small>
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
</small>
</div>
<div className="hardware-history-window-buttons">
{HISTORY_WINDOWS.map((window) => (
<Button
key={`history-window-${window.hours}`}
label={window.label}
size="small"
severity={historyHours === window.hours ? 'primary' : 'secondary'}
outlined={historyHours !== window.hours}
onClick={() => setHistoryHours(window.hours)}
/>
))}
</div>
</div>
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p>
) : resampledHistoryPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p>
) : (
<div className="hardware-history-grid">
<div className="hardware-history-chart-wrap">
<h4>Auslastung (%)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="RAM"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
/>
<Button
type="button"
size="small"
label="GPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`usage-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyUsageChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'RAM', color: RAM_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
<div className="hardware-history-chart-wrap">
<h4>Temperatur (°C)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="GPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`temp-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyTempChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
</div>
)}
</Card>
) : null}
{monitoringState.enabled && sample ? (
<div className="hardware-detail-grid">
<GaugeBlock
title="CPU"
subtitle={cpuTitle}
icon="pi-microchip"
valuePercent={cpu?.overallUsagePercent}
gaugeColor="#b07a24"
bars={[
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
]}
footer={(
<div className="hardware-detail-core-grid">
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
<div className="hardware-detail-bar-head">
<span>Core {core?.index ?? index}</span>
<span>{formatPercent(core?.usagePercent)}</span>
</div>
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
</div>
))}
</div>
)}
/>
<GaugeBlock
title="RAM"
subtitle={ramTitle}
icon="pi-server"
valuePercent={memory?.usagePercent}
gaugeColor="#9f6b1d"
bars={[
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>RAM Nutzung</span>
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
</div>
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
</div>
)}
/>
<GaugeBlock
title="GPU"
subtitle={gpuTitle}
icon="pi-desktop"
valuePercent={primaryGpu?.utilizationPercent}
gaugeColor="#996521"
bars={[
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>GPU Nutzung</span>
<span>
GPU {primaryGpu?.index ?? 0}
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
</span>
</div>
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
</div>
)}
/>
</div>
) : null}
</div>
);
}
+236 -190
View File
@@ -9,6 +9,7 @@ import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
@@ -58,6 +59,7 @@ const CD_FORMAT_LABELS = {
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
'ANALYZING',
'METADATA_LOOKUP',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
@@ -444,7 +446,7 @@ function getQueueActionResult(response) {
function isPipelineMetadataFlowStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
return ['METADATA_LOOKUP', 'METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
}
function normalizeSortText(value) {
@@ -459,33 +461,63 @@ function sanitizeRating(value) {
return raw;
}
function findOmdbRatingBySource(omdbInfo, sourceName) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const source = String(sourceName || '').trim().toLowerCase();
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source);
return sanitizeRating(entry?.Value);
function normalizeRatingValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue.toFixed(1);
}
return sanitizeRating(value);
}
function resolveRatings(row) {
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
if (!omdbInfo) {
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const selectedTmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: null;
const planTmdbDetails = planMetadata?.tmdbDetails && typeof planMetadata.tmdbDetails === 'object'
? planMetadata.tmdbDetails
: null;
const rowTmdbDetails = row?.tmdbDetails && typeof row.tmdbDetails === 'object'
? row.tmdbDetails
: null;
const tmdbDetails = selectedTmdbDetails || planTmdbDetails || rowTmdbDetails;
const tmdbRating = normalizeRatingValue(
tmdbDetails?.seasonVoteAverage
?? tmdbDetails?.voteAverage
?? tmdbDetails?.imdbRating
?? selectedMetadata?.seasonVoteAverage
?? selectedMetadata?.voteAverage
?? selectedMetadata?.imdbRating
?? planMetadata?.seasonVoteAverage
?? planMetadata?.voteAverage
?? planMetadata?.imdbRating
?? row?.voteAverage
?? row?.imdbRating
);
if (!tmdbRating) {
return [];
}
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
const metascore = sanitizeRating(omdbInfo?.Metascore);
const ratings = [];
if (imdb) {
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb });
}
if (rotten) {
ratings.push({ key: 'rt', label: 'RT', value: rotten });
}
if (metascore) {
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
if (tmdbRating) {
ratings.push({ key: 'tmdb', label: 'TMDb', value: tmdbRating });
}
return ratings;
}
@@ -553,7 +585,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
@@ -568,6 +600,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
const [conflictModalBusy, setConflictModalBusy] = useState(false);
const toastRef = useRef(null);
const wsReloadTimerRef = useRef(null);
const progressStateByJobRef = useRef(new Map());
const queuedJobIdSet = useMemo(() => {
const next = new Set();
@@ -638,6 +672,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const scheduleLiveReload = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void load();
}, delayMs);
};
useEffect(() => {
api.getPref('history_layout').then((res) => {
if (res?.value === 'list' || res?.value === 'grid') {
@@ -654,6 +698,55 @@ export default function HistoryPage({ refreshToken = 0 }) {
return () => clearTimeout(timer);
}, [search, status, refreshToken]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (
type === 'PIPELINE_STATE_CHANGED'
|| type === 'PIPELINE_QUEUE_CHANGED'
|| type === 'DISC_DETECTED'
|| type === 'DISC_REMOVED'
) {
scheduleLiveReload(80);
return;
}
if (type !== 'PIPELINE_PROGRESS') {
return;
}
const payload = message?.payload && typeof message.payload === 'object'
? message.payload
: {};
const normalizedJobId = normalizeJobId(payload?.activeJobId);
if (!normalizedJobId) {
return;
}
const nextState = String(payload?.state || '').trim().toUpperCase();
if (!nextState) {
return;
}
const key = String(normalizedJobId);
const prevState = progressStateByJobRef.current.get(key) || '';
if (nextState === prevState) {
return;
}
progressStateByJobRef.current.set(key, nextState);
scheduleLiveReload(120);
}
});
useEffect(() => {
const params = new URLSearchParams(location.search);
const openJobId = Number(params.get('open') || 0);
@@ -852,7 +945,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_encode') {
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const response = await api.restartEncodeWithLastSettings(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
if (result.queued) {
@@ -869,11 +965,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
} else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
const isAudiobookJob = resolveMediaType(job) === 'audiobook';
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'Review neu starten',
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: 'Review starten',
header: isAudiobookJob ? 'Vorprüfung starten' : 'Review neu starten',
message: isAudiobookJob
? `Vorprüfung für "${title}" starten?\nDer Job wird neu angelegt und kann danach im Ripper vor dem Encode angepasst werden.`
: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: isAudiobookJob ? 'Vorprüfung starten' : 'Review starten',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
@@ -882,19 +981,29 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setActionBusy(true);
try {
const response = await api.restartReviewFromRaw(job.id, options);
const response = await api.restartReviewFromRaw(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
summary: isAudiobookJob ? 'Vorprüfung gestartet' : 'Review-Neustart',
detail: isAudiobookJob
? 'Job neu angelegt. Im Ripper können die Encode-Einstellungen angepasst werden.'
: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
life: 3500
});
await load();
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
toastRef.current?.show({
severity: 'error',
summary: isAudiobookJob ? 'Vorprüfung fehlgeschlagen' : 'Review-Neustart fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
@@ -903,7 +1012,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'CD-Vorprüfung starten',
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
message: `CD-Vorprüfung für "${title}" starten?\nTrackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen'
});
@@ -917,7 +1026,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({
severity: 'success',
summary: 'CD-Vorprüfung gestartet',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Tracks und Einstellungen prüfen.',
life: 4000
});
await load();
@@ -1129,8 +1238,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleDownloadOutputFolder = async (row, folderPath) => {
const jobId = Number(row?.id || selectedJob?.id || 0);
const handleDownloadOutputFolder = async (row, folderPath, ownerJobId = null) => {
const jobId = Number(ownerJobId || row?.id || selectedJob?.id || 0);
if (!jobId || !folderPath) return;
setDownloadFolderBusyPath(folderPath);
try {
@@ -1205,7 +1314,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setActionBusy(true);
try {
const response = await api.retryJob(row.id);
const response = await api.retryJob(row.id, { createNewJob: true });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
@@ -1269,7 +1378,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAssignOmdb = (row) => {
const handleAssignMetadata = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
@@ -1291,11 +1400,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = workflowKind === 'series'
? 'tmdb'
: (workflowKind === 'film'
? 'omdb'
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
@@ -1326,47 +1431,78 @@ export default function HistoryPage({ refreshToken = 0 }) {
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
omdbCandidates: []
seriesDecision: analyzeContext?.seriesDecision || null
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query, options = {}) => {
const provider = String(
options?.metadataProvider
|| metadataDialogContext?.metadataProvider
|| 'omdb'
).trim().toLowerCase() || 'omdb';
const handleMetadataSearch = async (query, options = {}) => {
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = provider === 'tmdb'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchOmdb(query);
return response.results || [];
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
const includeMovies = filterRaw !== 'series';
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
const [movieResponse, seriesResponse] = await Promise.all([
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
includeSeries ? api.searchTmdbSeries(query, tmdbSeasonHint) : Promise.resolve({ results: [] })
]);
const movieRows = Array.isArray(movieResponse?.results)
? movieResponse.results.map((row) => ({
...row,
workflowKind: 'film',
metadataKind: 'movie',
resultType: 'movie'
}))
: [];
const seriesRows = Array.isArray(seriesResponse?.results)
? seriesResponse.results.map((row) => ({
...row,
workflowKind: 'series',
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
resultType: 'series'
}))
: [];
return [...movieRows, ...seriesRows];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error.message
});
return [];
}
};
const handleOmdbSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const handleMetadataSubmit = async (payload) => {
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
const isPipelineFlow = submitMode === 'pipeline';
setOmdbAssignBusy(true);
setMetadataAssignBusy(true);
try {
if (isPipelineFlow) {
await api.selectMetadata(payload);
const selectResponse = await api.selectMetadata(payload);
const selectedJobUpdate = selectResponse?.job && typeof selectResponse.job === 'object'
? selectResponse.job
: null;
if (selectedJobUpdate?.id) {
const normalizedSelectedId = Number(selectedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedSelectedId ? { ...row, ...selectedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedSelectedId
? { ...(prev || {}), ...selectedJobUpdate }
: prev
));
}
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
@@ -1408,11 +1544,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
return;
}
await api.assignJobOmdb(payload.jobId, payload);
const assignResponse = await api.assignJobMetadata(payload.jobId, payload);
const assignedJobUpdate = assignResponse?.job && typeof assignResponse.job === 'object'
? assignResponse.job
: null;
if (assignedJobUpdate?.id) {
const normalizedAssignedId = Number(assignedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedAssignedId ? { ...row, ...assignedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedAssignedId
? { ...(prev || {}), ...assignedJobUpdate }
: prev
));
}
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten zugewiesen',
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
detail: 'TMDb-Metadaten wurden aktualisiert.',
life: 3000
});
setMetadataDialogVisible(false);
@@ -1425,11 +1577,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
summary: 'TMDb-Zuweisung fehlgeschlagen',
detail: error.message
});
} finally {
setOmdbAssignBusy(false);
setMetadataAssignBusy(false);
}
};
@@ -1451,9 +1603,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
setCdMetadataDialogVisible(true);
};
const handleMusicBrainzSearch = async (query) => {
const handleMusicBrainzSearch = async (query, options = {}) => {
try {
const response = await api.searchMusicBrainz(query);
const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
@@ -1594,71 +1748,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
setDeleteEntryPreviewLoading(true);
try {
const response = await api.getJobDeletePreview(jobId, {
includeRelated: deleteEntryIncludeRelated,
selectedJobIds
});
const preview = response?.preview || null;
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
: relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
setDeleteEntryPreview(preview);
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
const defaultSelectedRawPaths = rawCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const defaultSelectedMoviePaths = movieCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschvorschau fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setDeleteEntryPreviewLoading(false);
}
};
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
if (!normalizedRelatedJobId || !dialogJobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const knownJobIds = relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const currentSelected = new Set(
deleteEntrySelectedJobIds
.map((item) => normalizeJobId(item))
.filter(Boolean)
);
if (checked) {
currentSelected.add(normalizedRelatedJobId);
} else {
currentSelected.delete(normalizedRelatedJobId);
}
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
setDeleteEntrySelectedJobIds(orderedSelected);
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
};
const confirmDeleteEntry = async (target) => {
const normalizedTarget = String(target || '').trim().toLowerCase();
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
@@ -1672,16 +1761,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if (
(normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled
@@ -1761,16 +1840,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if (
(normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled
@@ -2455,12 +2524,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
: previewRelatedJobs
.filter((item) => Boolean(item?.selected))
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
@@ -2474,17 +2537,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedRawPaths]
);
const selectedDeleteRelatedJobSet = useMemo(
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
[deleteEntrySelectedJobIds]
);
const selectedDeleteMoviePathSet = useMemo(
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths]
);
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
&& previewRelatedJobs.length > 0
&& deleteEntrySelectedJobIds.length === 0;
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
&& rawDeleteSelectionEnabled
&& deleteEntrySelectedRawPaths.length === 0;
@@ -2571,7 +2627,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignMetadata={handleAssignMetadata}
onAssignCdMetadata={handleAssignCdMetadata}
onGenerateNfo={handleGenerateNfo}
onAcknowledgeError={handleAcknowledgeError}
@@ -2586,7 +2642,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
actionBusy={actionBusy}
cancelBusy={actionBusy}
restoreMergeBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
metadataAssignBusy={metadataAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
generateNfoBusy={generateNfoBusy}
acknowledgeErrorBusy={acknowledgeErrorBusy}
@@ -2612,7 +2668,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
modal
>
<p>
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge im Lösch-Scope enthalten.`}
</p>
<small className="history-dv-subtle">
{deleteEntryIsMergeJob
@@ -2636,29 +2692,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
<ul className="history-delete-preview-list">
{previewRelatedJobs.map((item) => (
<li key={`delete-related-${item.id}`}>
<label className="history-delete-preview-checkbox-row">
<input
type="checkbox"
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
/>
<span>
<strong>#{item.id}</strong>
{' '}| {item.title || '-'}
{' '}| {item.roleLabel || 'Job'}
{' '}| {getStatusLabel(item.status)}
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
</span>
</label>
<span>
<strong>#{item.id}</strong>
{' '}| {item.title || '-'}
{' '}| {item.roleLabel || 'Job'}
{' '}| {getStatusLabel(item.status)}
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
</span>
</li>
))}
</ul>
) : (
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
)}
{relatedDeleteSelectionRequired ? (
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
) : null}
</div>
{!deleteEntryIsMergeJob ? (
@@ -2751,7 +2797,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
) : (
<>
@@ -2762,7 +2808,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/>
<Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
@@ -2771,7 +2817,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
@@ -2779,7 +2825,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
</>
)}
@@ -2791,7 +2837,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteFilesOnly('both')}
loading={deleteEntryTargetBusy === 'files-both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
) : null}
<Button
@@ -2801,7 +2847,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('none')}
loading={deleteEntryTargetBusy === 'none'}
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled}
/>
<Button
label="Abbrechen"
@@ -2820,9 +2866,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
busy={omdbAssignBusy}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy}
/>
<CdMetadataDialog
-357
View File
@@ -1,357 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Tag } from 'primereact/tag';
import { Paginator } from 'primereact/paginator';
import { api } from '../api/client';
import { classifyJob } from '../utils/jobTaxonomy';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const ACTIVE_STATUSES = [
'ANALYZING',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
'MEDIAINFO_CHECK',
'READY_TO_ENCODE',
'RIPPING',
'ENCODING',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
];
const STATUS_FILTERS = [
{ key: 'all', label: 'Alle' },
{ key: 'active', label: 'Aktiv' },
{ key: 'errors', label: 'Fehler/Abbruch' }
];
const VIEW_FILTERS = [
{ key: 'all', label: 'Alle Jobs' },
{ key: 'ripper', label: 'Ripper-View' },
{ key: 'converter', label: 'Converter-View' },
{ key: 'audiobook', label: 'Audiobooks' }
];
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function formatUpdatedAt(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getKindLabel(meta) {
if (meta.family === 'converter') {
if (meta.converterMediaType === 'audio') {
return 'Converter Audio';
}
if (meta.converterMediaType === 'iso') {
return 'Converter ISO';
}
return 'Converter Video';
}
if (meta.family === 'audiobook') {
return 'Audiobook';
}
if (meta.mediaType === 'bluray') {
return 'Blu-ray';
}
if (meta.mediaType === 'dvd') {
return 'DVD';
}
if (meta.mediaType === 'cd') {
return 'CD';
}
return 'Sonstiges';
}
function getKindSeverity(meta) {
if (meta.family === 'converter') {
return 'info';
}
if (meta.family === 'audiobook') {
return 'warning';
}
if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') {
return 'success';
}
return 'secondary';
}
export default function JobsInboxPage() {
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [viewFilter, setViewFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [queuedJobIds, setQueuedJobIds] = useState(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [first, setFirst] = useState(0);
const [rows, setRows] = useState(25);
const loadJobs = useCallback(async () => {
setLoading(true);
try {
const query = { limit: 500, lite: true };
if (statusFilter === 'active') {
query.statuses = ACTIVE_STATUSES;
} else if (statusFilter === 'errors') {
query.statuses = ['ERROR', 'CANCELLED'];
}
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs(query),
api.getPipelineQueue()
]);
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
? jobsResponse.value.jobs
: [];
setJobs(nextJobs);
if (queueResponse.status === 'fulfilled') {
const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
? queueResponse.value.queue.queuedJobs
: [];
setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean)));
} else {
setQueuedJobIds(new Set());
}
setLastUpdatedAt(new Date().toISOString());
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadJobs();
const interval = setInterval(() => {
loadJobs().catch(() => null);
}, 7000);
return () => clearInterval(interval);
}, [loadJobs]);
const jobsWithMeta = useMemo(() => (
jobs.map((job) => ({
job,
meta: classifyJob(job)
}))
), [jobs]);
const viewCounts = useMemo(() => {
let all = 0;
let ripper = 0;
let converter = 0;
let audiobook = 0;
for (const item of jobsWithMeta) {
all += 1;
if (item.meta.family === 'converter') {
converter += 1;
} else {
ripper += 1;
}
if (item.meta.family === 'audiobook') {
audiobook += 1;
}
}
return { all, ripper, converter, audiobook };
}, [jobsWithMeta]);
const filteredRows = useMemo(() => {
const normalizedSearch = String(search || '').trim().toLowerCase();
return jobsWithMeta.filter(({ job, meta }) => {
if (viewFilter === 'ripper' && meta.family === 'converter') {
return false;
}
if (viewFilter === 'converter' && meta.family !== 'converter') {
return false;
}
if (viewFilter === 'audiobook' && meta.family !== 'audiobook') {
return false;
}
if (!normalizedSearch) {
return true;
}
const haystack = [
job?.title,
job?.detected_title,
job?.imdb_id,
job?.status,
job?.job_kind,
job?.media_type
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
return haystack.includes(normalizedSearch);
});
}, [jobsWithMeta, search, viewFilter]);
useEffect(() => {
setFirst(0);
}, [viewFilter, statusFilter, search]);
useEffect(() => {
if (filteredRows.length === 0) {
if (first !== 0) {
setFirst(0);
}
return;
}
if (first >= filteredRows.length) {
const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows);
setFirst(pageStart);
}
}, [filteredRows.length, first, rows]);
const pagedRows = useMemo(
() => filteredRows.slice(first, first + rows),
[filteredRows, first, rows]
);
return (
<Card
title="Job Inbox"
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
>
<div className="job-inbox-toolbar">
<div className="job-inbox-filter-row">
{VIEW_FILTERS.map((filter) => {
const isActive = filter.key === viewFilter;
const count = viewCounts[filter.key] ?? 0;
return (
<Button
key={filter.key}
label={`${filter.label} (${count})`}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setViewFilter(filter.key)}
size="small"
/>
);
})}
</div>
<div className="job-inbox-filter-row">
{STATUS_FILTERS.map((filter) => {
const isActive = filter.key === statusFilter;
return (
<Button
key={filter.key}
label={filter.label}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setStatusFilter(filter.key)}
size="small"
/>
);
})}
<Button
label="Aktualisieren"
icon="pi pi-refresh"
outlined
size="small"
loading={loading}
onClick={() => {
loadJobs().catch(() => null);
}}
/>
</div>
</div>
<div className="job-inbox-search-row">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel, Status oder IMDB-ID"
className="job-inbox-search-input"
/>
<small>
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
</small>
</div>
<div className="job-inbox-list">
{loading && filteredRows.length === 0 ? (
<p>Inbox wird geladen ...</p>
) : filteredRows.length === 0 ? (
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
) : (
pagedRows.map(({ job, meta }) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
}
const isQueued = queuedJobIds.has(jobId);
const normalizedStatus = normalizeStatus(job?.status);
const targetPath = meta.family === 'converter' ? '/converter' : '/ripper';
const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`;
return (
<article key={jobId} className="job-inbox-row">
<div className="job-inbox-row-main">
<strong>#{jobId} | {jobTitle}</strong>
<small>
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
{' | '}
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
</small>
</div>
<div className="job-inbox-row-tags">
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
<Tag
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
<div className="job-inbox-row-actions">
<Button
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
outlined
size="small"
onClick={() => navigate(targetPath)}
/>
</div>
</article>
);
})
)}
</div>
{filteredRows.length > 0 ? (
<div className="job-inbox-pagination">
<Paginator
first={first}
rows={rows}
totalRecords={filteredRows.length}
rowsPerPageOptions={[25, 50, 100]}
onPageChange={(event) => {
setFirst(event.first);
setRows(event.rows);
}}
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="{first} - {last} von {totalRecords}"
/>
</div>
) : null}
</Card>
);
}
File diff suppressed because it is too large Load Diff
+457 -60
View File
@@ -14,6 +14,16 @@ import CronJobsTab from '../components/CronJobsTab';
import { confirmModal } from '../utils/confirmModal';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
const USER_PRESET_DEFAULT_SLOTS = Object.freeze([
{ key: 'bluray_movie', label: 'Standard-Userpreset für BluRay Film', mediaType: 'bluray' },
{ key: 'bluray_series', label: 'Standard-Userpreset für BluRay Serie', mediaType: 'bluray' },
{ key: 'dvd_movie', label: 'Standard-Userpreset für DVD Film', mediaType: 'dvd' },
{ key: 'dvd_series', label: 'Standard-Userpreset für DVD Serie', mediaType: 'dvd' }
]);
const EMPTY_USER_PRESET_DEFAULTS = Object.freeze(
USER_PRESET_DEFAULT_SLOTS.reduce((acc, slot) => ({ ...acc, [slot.key]: null }), {})
);
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
function validateSettingValue(key, value) {
@@ -45,6 +55,115 @@ function buildValuesMap(categories) {
return next;
}
function normalizePositiveId(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 normalizeUserPresetDefaultsMap(raw = {}) {
const source = raw && typeof raw === 'object' ? raw : {};
const normalized = { ...EMPTY_USER_PRESET_DEFAULTS };
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
normalized[slot.key] = normalizePositiveId(source[slot.key]);
}
return normalized;
}
function normalizeRunningChainIds(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
const ids = [];
const seen = new Set();
for (const item of active) {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'chain') {
continue;
}
const chainIdRaw = Number(item?.chainId || 0);
if (!Number.isFinite(chainIdRaw) || chainIdRaw <= 0) {
continue;
}
const chainId = Math.trunc(chainIdRaw);
if (seen.has(chainId)) {
continue;
}
seen.add(chainId);
ids.push(chainId);
}
return ids;
}
function isLikelyNetworkRequestError(error) {
if (!error) {
return false;
}
const status = Number(error?.status);
if (Number.isFinite(status) && status > 0) {
return false;
}
const message = String(error?.message || '').trim().toLowerCase();
if (!message) {
return true;
}
return (
message.includes('failed to fetch')
|| message.includes('networkerror')
|| message.includes('network error')
|| message.includes('load failed')
|| message.includes('timeout')
|| message.includes('aborted')
|| message.includes('socket hang up')
);
}
function isPresetCompatibleWithSlot(preset, slotMediaType) {
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
if (mediaType === 'all') {
return true;
}
return mediaType === slotMediaType;
}
function buildUserPresetDefaultOptions(userPresets, slotMediaType, selectedPresetId = null) {
const presets = Array.isArray(userPresets) ? userPresets : [];
const options = [{ label: '(keine Standardzuordnung)', value: null }];
const seen = new Set([null]);
for (const preset of presets) {
const presetId = normalizePositiveId(preset?.id);
if (!presetId || !isPresetCompatibleWithSlot(preset, slotMediaType)) {
continue;
}
seen.add(presetId);
options.push({
label: `#${presetId} - ${preset?.name || 'Preset'}`,
value: presetId
});
}
const normalizedSelectedId = normalizePositiveId(selectedPresetId);
if (normalizedSelectedId && !seen.has(normalizedSelectedId)) {
const existingPreset = presets.find((preset) => normalizePositiveId(preset?.id) === normalizedSelectedId);
options.push({
label: existingPreset
? `#${normalizedSelectedId} - ${existingPreset?.name || 'Preset'} (nicht kompatibel)`
: `#${normalizedSelectedId} - (nicht gefunden)`,
value: normalizedSelectedId
});
}
return options;
}
function isSameValue(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
return Number(a) === Number(b);
@@ -200,9 +319,10 @@ export default function SettingsPage() {
const [chainSaving, setChainSaving] = useState(false);
const [chainReordering, setChainReordering] = useState(false);
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
const [chainActionBusyId, setChainActionBusyId] = useState(null);
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
const [runningChainIds, setRunningChainIds] = useState([]);
const [lastChainTestResult, setLastChainTestResult] = useState(null);
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', description: '', steps: [] });
const [chainEditorErrors, setChainEditorErrors] = useState({});
const [chainDragSource, setChainDragSource] = useState(null);
@@ -213,6 +333,10 @@ export default function SettingsPage() {
const [userPresets, setUserPresets] = useState([]);
const [userPresetsLoading, setUserPresetsLoading] = useState(false);
const [userPresetSaving, setUserPresetSaving] = useState(false);
const [userPresetDefaults, setUserPresetDefaults] = useState(EMPTY_USER_PRESET_DEFAULTS);
const [userPresetDefaultsDraft, setUserPresetDefaultsDraft] = useState(EMPTY_USER_PRESET_DEFAULTS);
const [userPresetDefaultsLoading, setUserPresetDefaultsLoading] = useState(false);
const [userPresetDefaultsSaving, setUserPresetDefaultsSaving] = useState(false);
const [userPresetEditor, setUserPresetEditor] = useState({
open: false,
id: null,
@@ -235,6 +359,44 @@ export default function SettingsPage() {
),
[handBrakePresetSourceOptions, userPresetEditor.handbrakePreset]
);
const userPresetDefaultsDirty = useMemo(
() => USER_PRESET_DEFAULT_SLOTS.some((slot) => (
normalizePositiveId(userPresetDefaultsDraft?.[slot.key]) !== normalizePositiveId(userPresetDefaults?.[slot.key])
)),
[userPresetDefaultsDraft, userPresetDefaults]
);
const userPresetDefaultOptionsBySlot = useMemo(() => {
const next = {};
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
next[slot.key] = buildUserPresetDefaultOptions(
userPresets,
slot.mediaType,
userPresetDefaultsDraft?.[slot.key]
);
}
return next;
}, [userPresets, userPresetDefaultsDraft]);
const runningChainIdSet = useMemo(
() => new Set(Array.isArray(runningChainIds) ? runningChainIds : []),
[runningChainIds]
);
const setChainActionBusy = (chainId, busyFlag) => {
const normalizedId = Number(chainId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return;
}
const targetId = Math.trunc(normalizedId);
setChainActionBusyIds((prev) => {
const next = new Set(prev);
if (busyFlag) {
next.add(targetId);
} else {
next.delete(targetId);
}
return next;
});
};
const loadScripts = async ({ silent = false } = {}) => {
if (!silent) {
@@ -291,6 +453,35 @@ export default function SettingsPage() {
}
};
const loadUserPresetDefaults = async ({ silent = false } = {}) => {
if (!silent) {
setUserPresetDefaultsLoading(true);
}
try {
const response = await api.getUserPresetDefaults();
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || {});
setUserPresetDefaults(normalized);
setUserPresetDefaultsDraft(normalized);
} catch (error) {
if (!silent) {
toastRef.current?.show({ severity: 'error', summary: 'Preset-Standards', detail: error.message });
}
} finally {
if (!silent) {
setUserPresetDefaultsLoading(false);
}
}
};
const refreshRunningChains = async () => {
try {
const response = await api.getRuntimeActivities();
setRunningChainIds(normalizeRunningChainIds(response));
} catch (_error) {
// ignore polling errors
}
};
const openNewUserPreset = () => {
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
setUserPresetErrors({});
@@ -352,12 +543,59 @@ export default function SettingsPage() {
try {
await api.deleteUserPreset(presetId);
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' });
await loadUserPresets({ silent: true });
await Promise.all([
loadUserPresets({ silent: true }),
loadUserPresetDefaults({ silent: true })
]);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Preset löschen', detail: error.message });
}
};
const handleChangeUserPresetDefault = (slotKey, rawValue) => {
const normalized = normalizePositiveId(rawValue);
setUserPresetDefaultsDraft((prev) => ({
...prev,
[slotKey]: normalized
}));
};
const handleResetUserPresetDefaults = () => {
setUserPresetDefaultsDraft({ ...userPresetDefaults });
};
const handleSaveUserPresetDefaults = async () => {
if (!userPresetDefaultsDirty) {
toastRef.current?.show({
severity: 'info',
summary: 'Preset-Standards',
detail: 'Keine Änderungen zum Speichern.'
});
return;
}
setUserPresetDefaultsSaving(true);
try {
const payload = normalizeUserPresetDefaultsMap(userPresetDefaultsDraft);
const response = await api.updateUserPresetDefaults(payload);
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || payload);
setUserPresetDefaults(normalized);
setUserPresetDefaultsDraft(normalized);
toastRef.current?.show({
severity: 'success',
summary: 'Preset-Standards',
detail: 'Standard-Zuordnungen gespeichert.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Preset-Standards',
detail: error.message
});
} finally {
setUserPresetDefaultsSaving(false);
}
};
const loadEffectivePaths = async ({ silent = false } = {}) => {
try {
const paths = await api.getEffectivePaths({ forceRefresh: true });
@@ -386,7 +624,8 @@ export default function SettingsPage() {
const presetsPromise = api.getHandBrakePresets();
const scriptsPromise = api.getScripts();
const chainsPromise = api.getScriptChains();
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
const runtimePromise = api.getRuntimeActivities();
const [scriptsResponse, chainsResponse, runtimeResponse] = await Promise.allSettled([scriptsPromise, chainsPromise, runtimePromise]);
if (scriptsResponse.status === 'fulfilled') {
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
} else {
@@ -399,6 +638,9 @@ export default function SettingsPage() {
if (chainsResponse.status === 'fulfilled') {
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
}
if (runtimeResponse.status === 'fulfilled') {
setRunningChainIds(normalizeRunningChainIds(runtimeResponse.value));
}
presetsPromise
.then((presetPayload) => {
@@ -430,6 +672,26 @@ export default function SettingsPage() {
useEffect(() => {
load();
loadUserPresets();
loadUserPresetDefaults();
}, []);
useEffect(() => {
let cancelled = false;
const pollRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setRunningChainIds(normalizeRunningChainIds(response));
}
} catch (_error) {
// ignore polling errors
}
};
const timer = setInterval(pollRuntime, 2500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
const dirtyKeys = useMemo(() => {
@@ -757,6 +1019,32 @@ export default function SettingsPage() {
}
};
const handleToggleScriptFavorite = async (script) => {
const scriptId = Number(script?.id);
if (!Number.isFinite(scriptId) || scriptId <= 0) {
return;
}
setScriptActionBusyId(scriptId);
try {
const nextFavorite = !(script?.isFavorite === true);
await api.setScriptFavorite(scriptId, nextFavorite);
await loadScripts({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Scripte',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setScriptActionBusyId(null);
}
};
const handleScriptListDragStart = (event, scriptId) => {
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
event.preventDefault();
@@ -813,7 +1101,8 @@ export default function SettingsPage() {
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
setChainActionBusyId(chainId);
const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
setLastChainTestResult(null);
try {
const response = await api.testScriptChain(chainId);
@@ -833,24 +1122,75 @@ export default function SettingsPage() {
});
}
} catch (error) {
if (isLikelyNetworkRequestError(error)) {
try {
const runtimeResponse = await api.getRuntimeActivities();
const activeIds = normalizeRunningChainIds(runtimeResponse);
setRunningChainIds(activeIds);
if (activeIds.includes(normalizedChainId)) {
toastRef.current?.show({
severity: 'warn',
summary: 'Ketten-Test läuft weiter',
detail: `"${chain?.name || chainId}" läuft im Hintergrund weiter. Der Test-Request wurde vorzeitig beendet (z.B. Timeout/Verbindungsabbruch).`
});
return;
}
} catch (_runtimeError) {
// ignore follow-up runtime fetch errors and show default error toast below
}
}
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
} finally {
setChainActionBusyId(null);
setChainActionBusy(normalizedChainId, false);
refreshRunningChains();
}
};
const handleToggleChainFavorite = async (chain) => {
const chainId = Number(chain?.id);
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
try {
const nextFavorite = !(chain?.isFavorite === true);
await api.setScriptChainFavorite(normalizedChainId, nextFavorite);
await loadChains({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Skriptketten',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setChainActionBusy(normalizedChainId, false);
}
};
// Chain editor handlers
const openChainEditor = (chain = null) => {
if (chain) {
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
setChainEditor({
open: true,
id: chain.id,
name: chain.name,
description: chain.description || '',
steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` }))
});
} else {
setChainEditor({ open: true, id: null, name: '', steps: [] });
setChainEditor({ open: true, id: null, name: '', description: '', steps: [] });
}
setChainEditorErrors({});
};
const closeChainEditor = () => {
setChainEditor({ open: false, id: null, name: '', steps: [] });
setChainEditor({ open: false, id: null, name: '', description: '', steps: [] });
setChainEditorErrors({});
};
@@ -901,6 +1241,7 @@ export default function SettingsPage() {
}
const payload = {
name,
description: String(chainEditor.description || '').trim(),
steps: chainEditor.steps.map((s) => ({
stepType: s.stepType,
scriptId: s.stepType === 'script' ? s.scriptId : null,
@@ -960,7 +1301,7 @@ export default function SettingsPage() {
};
const handleChainListDragStart = (event, chainId) => {
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
if (chainSaving || chainsLoading || chainReordering) {
event.preventDefault();
return;
}
@@ -1060,8 +1401,7 @@ export default function SettingsPage() {
|| Boolean(scriptActionBusyId);
const chainListDnDDisabled = chainSaving
|| chainsLoading
|| chainReordering
|| Boolean(chainActionBusyId);
|| chainReordering;
return (
<div className="page-grid">
@@ -1134,7 +1474,17 @@ export default function SettingsPage() {
dirtyKeys={dirtyKeys}
onChange={handleFieldChange}
effectivePaths={effectivePaths}
activationBytes={activationBytes}
onNotify={(msg) => toastRef.current?.show(msg)}
onSettingApplied={(setting) => {
const key = String(setting?.key || '').trim();
if (!key) {
return;
}
setDraftValues((prev) => ({ ...prev, [key]: setting?.value }));
setInitialValues((prev) => ({ ...prev, [key]: setting?.value }));
setErrors((prev) => ({ ...prev, [key]: null }));
}}
/>
)}
</TabPanel>
@@ -1226,6 +1576,7 @@ export default function SettingsPage() {
<div className="script-order-list">
{scripts.map((script, index) => {
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
const isFavorite = script?.isFavorite === true;
return (
<div key={script.id} className="script-order-wrapper">
<div
@@ -1250,6 +1601,15 @@ export default function SettingsPage() {
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleScriptFavorite(script)}
loading={scriptActionBusyId === script.id}
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1394,6 +1754,11 @@ export default function SettingsPage() {
<div className="script-order-list">
{chains.map((chain, index) => {
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
const chainId = Number(chain?.id);
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
const isFavorite = chain?.isFavorite === true;
return (
<div key={chain.id} className="script-order-wrapper">
<div
@@ -1415,35 +1780,35 @@ export default function SettingsPage() {
</div>
<div className="script-list-main">
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
<small>
{chain.steps?.length ?? 0} Schritt(e):
{' '}
{(chain.steps || []).map((s, i) => (
<span key={i}>
{i > 0 ? ' → ' : ''}
{s.stepType === 'wait'
? `${s.waitSeconds}s`
: (s.scriptName || `Script #${s.scriptId}`)}
</span>
))}
<small className="chain-description-line" title={String(chain.description || '').trim()}>
{String(chain.description || '').trim() || '—'}
</small>
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleChainFavorite(chain)}
loading={isChainActionBusy}
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
severity="secondary"
outlined
onClick={() => openChainEditor(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)}
disabled={chainReordering}
/>
<Button
icon="pi pi-play"
label="Test"
severity="info"
onClick={() => handleTestChain(chain)}
loading={chainActionBusyId === chain.id}
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
loading={isChainActionBusy}
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/>
<Button
icon="pi pi-trash"
@@ -1451,7 +1816,7 @@ export default function SettingsPage() {
severity="danger"
outlined
onClick={() => handleDeleteChain(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)}
disabled={chainReordering}
/>
</div>
</div>
@@ -1517,6 +1882,20 @@ export default function SettingsPage() {
/>
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
</div>
<div className="chain-editor-name-row" style={{ marginTop: '0.7rem' }}>
<label htmlFor="chain-description">Kurzbeschreibung (max. 50 Zeichen)</label>
<InputText
id="chain-description"
value={chainEditor.description}
maxLength={50}
onChange={(e) => {
setChainEditor((prev) => ({ ...prev, description: e.target.value }));
setChainEditorErrors((prev) => ({ ...prev, description: null }));
}}
placeholder="Kurzer Hinweis zur Kette"
/>
{chainEditorErrors.description ? <small className="error-text">{chainEditorErrors.description}</small> : null}
</div>
<div className="chain-editor-body">
{/* Palette */}
@@ -1670,12 +2049,57 @@ export default function SettingsPage() {
label="Presets neu laden"
icon="pi pi-refresh"
severity="secondary"
onClick={() => loadUserPresets()}
loading={userPresetsLoading}
disabled={userPresetSaving}
onClick={() => Promise.all([loadUserPresets(), loadUserPresetDefaults()])}
loading={userPresetsLoading || userPresetDefaultsLoading}
disabled={userPresetSaving || userPresetDefaultsSaving}
/>
</div>
<div className="script-list-box" style={{ marginTop: '1rem' }}>
<h4>Standard-Zuordnungen</h4>
<small>
Weise für jeden Medientyp/Fall ein Standard-Userpreset zu. Diese Zuordnungen werden persistent gespeichert.
</small>
<div className="user-preset-default-grid" style={{ marginTop: '0.8rem' }}>
{USER_PRESET_DEFAULT_SLOTS.map((slot) => (
<div key={slot.key} className="user-preset-default-grid-item">
<label htmlFor={`user-preset-default-${slot.key}`} style={{ display: 'block', marginBottom: '0.3rem' }}>
{slot.label}
</label>
<Dropdown
id={`user-preset-default-${slot.key}`}
value={normalizePositiveId(userPresetDefaultsDraft?.[slot.key])}
options={userPresetDefaultOptionsBySlot?.[slot.key] || []}
optionLabel="label"
optionValue="value"
onChange={(event) => handleChangeUserPresetDefault(slot.key, event.value)}
placeholder="Preset auswählen"
showClear
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving}
style={{ width: '100%' }}
/>
</div>
))}
</div>
<div className="actions-row" style={{ marginTop: '0.8rem' }}>
<Button
label="Zuordnungen speichern"
icon="pi pi-save"
onClick={handleSaveUserPresetDefaults}
loading={userPresetDefaultsSaving}
disabled={userPresetDefaultsLoading || !userPresetDefaultsDirty}
/>
<Button
label="Änderungen verwerfen"
icon="pi pi-undo"
severity="secondary"
outlined
onClick={handleResetUserPresetDefaults}
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving || !userPresetDefaultsDirty}
/>
</div>
</div>
<small>
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
@@ -1687,9 +2111,9 @@ export default function SettingsPage() {
) : userPresets.length === 0 ? (
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
) : (
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
<div className="preset-list" style={{ marginTop: '1rem' }}>
{userPresets.map((preset) => (
<div key={preset.id} className="script-list-item">
<div key={preset.id} className="script-list-item preset-list-item">
<div className="script-list-main">
<div className="script-title-line">
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
@@ -1703,7 +2127,7 @@ export default function SettingsPage() {
{preset.description || '-'}
</small>
</div>
<div className="script-list-actions script-list-actions--two">
<div className="script-list-actions script-list-actions--two preset-list-actions">
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1824,33 +2248,6 @@ export default function SettingsPage() {
</TabView>
</Card>
{expertModeEnabled && activationBytes.length > 0 && (
<Card
title="Activation Bytes Cache"
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
style={{ marginTop: '1.5rem' }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
</tr>
</thead>
<tbody>
{activationBytes.map((entry) => (
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
const candidates = [
row?.mediaType,
row?.media_type,
row?.mediaProfile,
row?.media_profile,
encodePlan?.mediaProfile,
row?.makemkvInfo?.analyzeContext?.mediaProfile,
row?.makemkvInfo?.mediaProfile,
row?.mediainfoInfo?.mediaProfile
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (raw === 'converter') {
return 'converter';
}
}
return 'other';
}
function buildMetadataContextFromJob(row) {
const jobId = normalizeJobId(row?.id);
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const workflowKindRaw = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
return {
jobId,
status: row?.status || null,
lastState: row?.last_state || null,
submitMode: 'assign',
mediaProfile: rowMediaType,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
year: row?.year || selectedMetadata?.year || null,
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
poster: row?.poster_url || selectedMetadata?.poster || null,
metadataProvider,
workflowKind,
tmdbId: selectedMetadata?.tmdbId || null,
providerId: selectedMetadata?.providerId || null,
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
seasonNumber,
seasonName: selectedMetadata?.seasonName || null,
episodeCount: selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
seriesDecision: analyzeContext?.seriesDecision || null
};
}
export default function TmdbMigrationPage() {
const toastRef = useRef(null);
const countdownIntervalRef = useRef(null);
const nextJobTimeoutRef = useRef(null);
const jobsRef = useRef([]);
const runningRef = useRef(false);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
useEffect(() => {
jobsRef.current = Array.isArray(jobs) ? jobs : [];
}, [jobs]);
useEffect(() => {
runningRef.current = running;
}, [running]);
const clearTimers = useCallback(() => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
if (nextJobTimeoutRef.current) {
clearTimeout(nextJobTimeoutRef.current);
nextJobTimeoutRef.current = null;
}
}, []);
useEffect(() => () => {
clearTimers();
}, [clearTimers]);
const loadPendingJobs = useCallback(async () => {
setLoading(true);
try {
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
setJobs(rows);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Laden fehlgeschlagen',
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
});
setJobs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadPendingJobs();
}, [loadPendingJobs]);
const openJobInDialog = useCallback((job) => {
if (!job) {
return;
}
const context = buildMetadataContextFromJob(job);
if (!context?.jobId) {
return;
}
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
}, []);
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
clearTimers();
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
if (!nextJob) {
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
return;
}
setCooldownRemaining(normalizedSeconds);
if (normalizedSeconds > 0) {
countdownIntervalRef.current = setInterval(() => {
setCooldownRemaining((prev) => {
const next = Number(prev || 0) - 1;
return next > 0 ? next : 0;
});
}, 1000);
}
nextJobTimeoutRef.current = setTimeout(() => {
clearTimers();
setCooldownRemaining(0);
if (!runningRef.current) {
return;
}
openJobInDialog(nextJob);
}, normalizedSeconds * 1000);
}, [clearTimers, openJobInDialog]);
const startSequence = () => {
const pending = jobsRef.current;
if (!Array.isArray(pending) || pending.length === 0) {
return;
}
clearTimers();
setRunning(true);
setMetadataDialogVisible(true);
setCooldownRemaining(0);
openJobInDialog(pending[0]);
};
const stopSequence = useCallback(() => {
clearTimers();
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}, [clearTimers]);
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response?.results || [];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error?.message || 'Suche fehlgeschlagen.'
});
return [];
}
};
const handleMetadataSubmit = async (payload) => {
const normalizedJobId = normalizeJobId(payload?.jobId);
if (!normalizedJobId) {
return;
}
setMetadataAssignBusy(true);
clearTimers();
setCooldownRemaining(0);
try {
await api.assignJobMetadata(normalizedJobId, payload || {});
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
jobsRef.current = remaining;
setJobs(remaining);
toastRef.current?.show({
severity: 'success',
summary: 'Job migriert',
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
life: 2600
});
if (remaining.length === 0) {
setRunning(false);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
toastRef.current?.show({
severity: 'success',
summary: 'Migration abgeschlossen',
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
life: 3200
});
return;
}
if (!runningRef.current) {
return;
}
scheduleNextJob(remaining[0], 10);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Migration fehlgeschlagen',
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
});
throw error;
} finally {
setMetadataAssignBusy(false);
}
};
const mediumTagBody = (row) => {
const mediaType = resolveMediaType(row);
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
};
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
const countdownProgressValue = useMemo(() => {
if (cooldownRemaining <= 0) {
return 0;
}
const elapsed = 10 - Math.min(10, cooldownRemaining);
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
}, [cooldownRemaining]);
return (
<div className="page-grid tmdb-migration-page">
<Toast ref={toastRef} position="top-right" />
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
<div className="tmdb-migration-toolbar">
<div className="tmdb-migration-toolbar-left">
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
</div>
<div className="tmdb-migration-toolbar-actions">
<Button
label="Liste neu laden"
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
outlined
onClick={() => {
if (!metadataAssignBusy) {
void loadPendingJobs();
}
}}
disabled={metadataAssignBusy}
/>
{!running ? (
<Button
label="Start"
icon="pi pi-play"
onClick={startSequence}
disabled={jobs.length === 0 || loading || metadataAssignBusy}
/>
) : (
<Button
label="Stop"
icon="pi pi-stop"
severity="secondary"
onClick={stopSequence}
disabled={metadataAssignBusy}
/>
)}
</div>
</div>
{running && cooldownRemaining > 0 ? (
<div className="tmdb-migration-countdown">
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
<div className="tmdb-migration-countdown-copy">
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
</div>
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
</div>
</div>
) : null}
<DataTable
value={jobs}
loading={loading}
dataKey="id"
emptyMessage="Keine offenen Jobs für TMDb-Migration."
className="tmdb-migration-table"
rows={50}
paginator={jobs.length > 50}
>
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
<Column header="Titel" body={titleBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
<Column
header="Status"
style={{ width: '14rem' }}
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
/>
</DataTable>
</Card>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={stopSequence}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+4 -40
View File
@@ -254,16 +254,14 @@ export function isSeriesVideoJob(job) {
return true;
}
const metadataProvider = String(
analyzeContext?.metadataProvider
|| selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const metadataKind = String(
selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind
|| ''
).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
@@ -279,15 +277,7 @@ export function isSeriesVideoJob(job) {
return true;
}
const tmdbIdRaw = selectedMetadata?.tmdbId
?? analyzeContext?.tmdbId
?? selectedMetadata?.providerId
?? analyzeContext?.providerId
?? null;
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
return false;
}
export function isSeriesDvdJob(job) {
@@ -297,29 +287,3 @@ export function isSeriesDvdJob(job) {
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
export function classifyJob(job) {
const jobKind = resolveJobKind(job);
const mediaType = resolveMediaType(job);
const converterMediaType = normalizeConverterMediaType(
(jobKind && jobKind.startsWith('converter_'))
? jobKind.replace('converter_', '')
: (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
);
let family = 'other';
if (mediaType === 'converter') {
family = 'converter';
} else if (mediaType === 'audiobook') {
family = 'audiobook';
} else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
family = 'rip';
}
return {
jobKind,
mediaType,
converterMediaType,
family
};
}
+9 -5
View File
@@ -2,6 +2,7 @@ const STATUS_LABELS = {
IDLE: 'Bereit',
DISC_DETECTED: 'Medium erkannt',
ANALYZING: 'Analyse',
METADATA_LOOKUP: 'TMDb-Suche',
METADATA_SELECTION: 'Metadatenauswahl',
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
READY_TO_START: 'Startbereit',
@@ -13,11 +14,11 @@ const STATUS_LABELS = {
FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen',
ERROR: 'Fehler',
CD_ANALYZING: 'CD-Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
CD_RIPPING: 'CD-Rippen',
CD_ENCODING: 'CD-Encodieren'
CD_ANALYZING: 'Analyse',
CD_METADATA_SELECTION: 'Metadatenauswahl',
CD_READY_TO_RIP: 'Bereit zum Encodieren',
CD_RIPPING: 'Rippen',
CD_ENCODING: 'Encodieren'
};
const PROCESS_STATUS_LABELS = {
@@ -46,6 +47,7 @@ const FALLBACK_TOKEN_LABELS = {
ENCODE: 'encodieren',
ENCODING: 'encodieren',
ANALYZING: 'analyse',
LOOKUP: 'suche',
METADATA: 'metadaten',
SELECTION: 'auswahl',
WAITING: 'warte',
@@ -120,6 +122,7 @@ export function getStatusSeverity(status, options = {}) {
normalized === 'RIPPING'
|| normalized === 'ENCODING'
|| normalized === 'ANALYZING'
|| normalized === 'METADATA_LOOKUP'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'POST_ENCODE_SCRIPTS'
@@ -154,5 +157,6 @@ export const STATUS_FILTER_OPTIONS = [
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
{ label: getStatusLabel('METADATA_LOOKUP'), value: 'METADATA_LOOKUP' },
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
];
Regular → Executable
+303 -62
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# =============================================================================
# Ripster Installationsskript (Git)
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
@@ -21,6 +22,10 @@
# --no-makemkv MakeMKV-Installation überspringen
# --no-handbrake HandBrake-Installation überspringen
# --no-nginx Nginx-Einrichtung überspringen
# --no-system-deps Abschnitt "Systemabhängigkeiten installieren" überspringen
# --accept-makemkv-eula MakeMKV-Lizenz/EULA bewusst akzeptieren
# --force-license-prompts
# Lizenzabfragen auch bei vorhandenen Tools erneut anzeigen
# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten)
# -h, --help Diese Hilfe anzeigen
# =============================================================================
@@ -54,7 +59,17 @@ SKIP_MAKEMKV=false
SKIP_HANDBRAKE=false
HANDBRAKE_INSTALL_MODE=""
SKIP_NGINX=false
SKIP_SYSTEM_DEPS=false
ACCEPT_MAKEMKV_EULA=false
FORCE_LICENSE_PROMPTS=false
MAKEMKV_EULA_REVIEWED=false
HANDBRAKE_NOTICE_SHOWN=false
REINSTALL=false
GIT_COMMAND_TIMEOUT_SEC=180
# Keine interaktiven Git-Prompts im Installer (z.B. Credentials) - stattdessen
# sauber mit Fehler abbrechen.
export GIT_TERMINAL_PROMPT=0
# --- Container-Optionen -------------------------------------------------------
CONTAINER_TYPE="none"
@@ -72,6 +87,9 @@ while [[ $# -gt 0 ]]; do
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
--no-nginx) SKIP_NGINX=true; shift ;;
--no-system-deps) SKIP_SYSTEM_DEPS=true; shift ;;
--accept-makemkv-eula) ACCEPT_MAKEMKV_EULA=true; shift ;;
--force-license-prompts) FORCE_LICENSE_PROMPTS=true; shift ;;
--reinstall) REINSTALL=true; shift ;;
-h|--help)
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
@@ -135,6 +153,30 @@ detect_container_env() {
command_exists() { command -v "$1" &>/dev/null; }
run_git_command() {
local description="$1"
shift
local status=0
info "$description..."
if command_exists timeout; then
timeout --foreground "${GIT_COMMAND_TIMEOUT_SEC}" "$@"
status=$?
if [[ "$status" -ne 0 ]]; then
if [[ "$status" -eq 124 ]]; then
fatal "Git-Kommando lief in Timeout (${GIT_COMMAND_TIMEOUT_SEC}s): $*"
fi
fatal "Git-Kommando fehlgeschlagen (Exit $status): $*"
fi
else
"$@"
status=$?
if [[ "$status" -ne 0 ]]; then
fatal "Git-Kommando fehlgeschlagen (Exit $status): $*"
fi
fi
}
service_unit_exists() {
local unit="$1"
systemctl list-unit-files --type=service --all --no-legend 2>/dev/null \
@@ -188,6 +230,36 @@ backup_and_remove_backend_src() {
fi
}
backup_persistent_backend_data() {
local src_dir="$INSTALL_DIR/backend/data"
local backup_root="$INSTALL_DIR/backend/data/temp/ripster-data-backup"
local backup_dir="${backup_root}/data_bak_$(date +%Y%m%d%H%M%S)"
local copied_any=false
local candidate
if [[ ! -d "$src_dir" ]]; then
warn "Kein Backend-Datenverzeichnis gefunden: $src_dir"
return 0
fi
# Nur kleine, kritische Metadaten sichern. Medienordner bleiben im
# Installationspfad bestehen und werden hier bewusst nicht kopiert.
mkdir -p "$backup_dir"
for candidate in "ripster.db" "ripster.db-shm" "ripster.db-wal" "thumbnails"; do
if [[ -e "${src_dir}/${candidate}" ]]; then
cp -a "${src_dir}/${candidate}" "$backup_dir/"
copied_any=true
fi
done
if [[ "$copied_any" == true ]]; then
DATA_BACKUP="$backup_dir"
ok "Persistente Backend-Daten gesichert nach: $DATA_BACKUP"
else
warn "Keine zu sichernden Kern-Daten in $src_dir gefunden (ripster.db/thumbnails)."
fi
}
enforce_default_output_dirs_permissions() {
local default_dirs=(
"$INSTALL_DIR/backend/data/output/raw"
@@ -329,7 +401,7 @@ SupplementaryGroups=video render cdrom disk
# darf no_new_privileges nicht aktiv sein.
NoNewPrivileges=false
ProtectSystem=full
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true
EOF
)
@@ -385,6 +457,58 @@ install_node() {
ok "Node.js $(node --version) installiert"
}
confirm_makemkv_eula() {
if [[ "$MAKEMKV_EULA_REVIEWED" == true ]]; then
return 0
fi
if [[ "$ACCEPT_MAKEMKV_EULA" == true ]]; then
warn "MakeMKV-EULA wurde per --accept-makemkv-eula ausdrücklich akzeptiert."
MAKEMKV_EULA_REVIEWED=true
return 0
fi
echo ""
warn "MakeMKV unterliegt eigenen Lizenz- und Registrierungsbedingungen."
warn "Ripster kann diese Bedingungen nicht automatisch fuer dich akzeptieren."
echo "Bitte pruefe die MakeMKV-Lizenzbedingungen, bevor MakeMKV gebaut und installiert wird."
echo ""
local answer=""
if [[ -t 0 ]]; then
read -r -p "MakeMKV-EULA bewusst akzeptieren und MakeMKV installieren? Tippe 'yes': " answer
elif [[ -r /dev/tty ]]; then
read -r -p "MakeMKV-EULA bewusst akzeptieren und MakeMKV installieren? Tippe 'yes': " answer </dev/tty
else
fatal "MakeMKV-Installation benoetigt ausdrueckliche Zustimmung. Nutze --accept-makemkv-eula oder --no-makemkv."
fi
if [[ "$answer" != "yes" ]]; then
fatal "MakeMKV-Installation abgebrochen. Nutze --no-makemkv zum Ueberspringen."
fi
ACCEPT_MAKEMKV_EULA=true
MAKEMKV_EULA_REVIEWED=true
}
show_handbrake_license_notice() {
if [[ "$HANDBRAKE_NOTICE_SHOWN" == true ]]; then
return 0
fi
echo ""
warn "HandBrakeCLI ist ein eigenständiges Drittanbieterprogramm unter GPL-2.0-only."
warn "Die Ripster-Lizenz ersetzt oder verändert die HandBrake-Lizenz nicht."
if [[ "$HANDBRAKE_INSTALL_MODE" == "gpu" ]]; then
warn "Die gebündelte HandBrakeCLI enthält FDK-AAC-Hinweise."
warn "Ihre Weiterverteilung ist bis zur Klärung oder einem Neuaufbau ohne FDK-AAC ungeklärt."
else
info "Bei der Standardversion gelten die Lizenzhinweise des Distributionspakets."
fi
info "Weitere Details: THIRD_PARTY_NOTICES.md und third_party/handbrake/BUILDINFO.md"
HANDBRAKE_NOTICE_SHOWN=true
}
install_makemkv() {
header "MakeMKV installieren"
@@ -393,6 +517,8 @@ install_makemkv() {
return
fi
confirm_makemkv_eula
info "Installiere Build-Abhängigkeiten für MakeMKV..."
apt-get install -y \
build-essential pkg-config libc6-dev libssl-dev \
@@ -433,6 +559,7 @@ install_makemkv() {
cd "$tmp_dir"
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
cd "makemkv-bin-${makemkv_version}"
# MakeMKV expects this marker during its own build after explicit user consent.
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
make -j"$(nproc)"
make install
@@ -589,8 +716,59 @@ install_handbrake_standard() {
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
}
verify_bundled_handbrake_binary() {
local candidate="$1"
local checksum_file="${SCRIPT_DIR}/third_party/handbrake/SHA256SUMS"
local host_arch
local binary_desc=""
if [[ ! -f "$candidate" ]]; then
fatal "Gebündelte HandBrakeCLI fehlt: $candidate"
fi
if [[ ! -x "$candidate" ]]; then
fatal "Gebündelte HandBrakeCLI ist nicht ausführbar: $candidate"
fi
host_arch="$(uname -m 2>/dev/null || echo unknown)"
if command_exists file; then
binary_desc="$(file -b "$candidate" 2>/dev/null || true)"
info "Gebündelte HandBrakeCLI: ${binary_desc:-Architektur nicht ermittelbar}"
case "$binary_desc" in
*"x86-64"*)
case "$host_arch" in
x86_64|amd64) ;;
*) fatal "Gebündelte HandBrakeCLI ist x86-64, Host-Architektur ist '$host_arch'." ;;
esac
;;
*)
warn "Architektur der gebündelten HandBrakeCLI konnte nicht eindeutig geprüft werden."
;;
esac
else
warn "Befehl 'file' nicht gefunden - Architekturprüfung für HandBrakeCLI übersprungen."
fi
if [[ -f "$checksum_file" ]] && command_exists sha256sum; then
local expected
local actual
expected=$(awk '$2 == "bin/HandBrakeCLI" || $2 == "./bin/HandBrakeCLI" { print $1; exit }' "$checksum_file")
if [[ -n "$expected" ]]; then
actual=$(sha256sum "$candidate" | awk '{ print $1 }')
if [[ "$actual" != "$expected" ]]; then
fatal "Prüfsumme der gebündelten HandBrakeCLI stimmt nicht. Erwartet $expected, erhalten $actual."
fi
ok "Prüfsumme der gebündelten HandBrakeCLI verifiziert"
else
warn "Keine passende HandBrakeCLI-Prüfsumme in $checksum_file gefunden."
fi
else
warn "HandBrakeCLI-Prüfsumme konnte nicht geprüft werden (SHA256SUMS oder sha256sum fehlt)."
fi
}
install_handbrake_gpu_bundled() {
info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
info "Installiere gebündelte GPU-fähige HandBrakeCLI..."
local bundled_source="$BUNDLED_HANDBRAKE_CLI"
local downloaded_tmp=""
@@ -608,6 +786,8 @@ install_handbrake_gpu_bundled() {
fi
fi
verify_bundled_handbrake_binary "$bundled_source"
install -m 0755 "$bundled_source" /usr/local/bin/HandBrakeCLI
hash -r 2>/dev/null || true
if [[ -n "$downloaded_tmp" ]]; then
@@ -627,6 +807,8 @@ install_handbrake() {
HANDBRAKE_INSTALL_MODE="standard"
fi
show_handbrake_license_notice
case "$HANDBRAKE_INSTALL_MODE" in
standard) install_handbrake_standard ;;
gpu) install_handbrake_gpu_bundled ;;
@@ -725,59 +907,99 @@ info "Installationsverzeichnis: $INSTALL_DIR"
info "Systembenutzer: $SERVICE_USER"
info "Backend-Port: $BACKEND_PORT"
info "Frontend-Host: $FRONTEND_HOST"
info "System-Dependencies: $([[ "$SKIP_SYSTEM_DEPS" == true ]] && echo "überspringen" || echo "installieren")"
# --- HandBrake-Installmodus auswählen ----------------------------------------
select_handbrake_mode
if [[ "$SKIP_SYSTEM_DEPS" == false || "$FORCE_LICENSE_PROMPTS" == true ]]; then
select_handbrake_mode
fi
# --- Lizenzhinweise auf Wunsch unabhängig vom Installationsstatus zeigen -----
if [[ "$FORCE_LICENSE_PROMPTS" == true ]]; then
header "Lizenzabfragen erneut anzeigen"
if [[ "$SKIP_MAKEMKV" == false ]]; then
confirm_makemkv_eula
else
warn "MakeMKV-Lizenzabfrage übersprungen (--no-makemkv)"
fi
if [[ "$SKIP_HANDBRAKE" == false ]]; then
show_handbrake_license_notice
else
warn "HandBrake-Lizenzhinweis übersprungen (--no-handbrake)"
fi
fi
# --- Systemabhängigkeiten -----------------------------------------------------
header "Systemabhängigkeiten installieren"
info "Paketlisten aktualisieren..."
apt_update
info "Installiere Basispakete..."
apt-get install -y \
curl wget git jq \
ffmpeg \
mediainfo \
util-linux udev \
ca-certificates gnupg \
lsb-release \
mkvtoolnix \
coreutils
ok "Basispakete installiert"
info "Installiere CD-Ripping-Tools..."
apt-get install -y \
cdparanoia \
flac \
lame \
opus-tools \
vorbis-tools
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
install_node
if [[ "$SKIP_MAKEMKV" == false ]]; then
install_makemkv
if [[ "$SKIP_SYSTEM_DEPS" == true ]]; then
header "Systemabhängigkeiten installieren"
warn "Übersprungen (--no-system-deps)"
else
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
fi
header "Systemabhängigkeiten installieren"
if [[ "$SKIP_HANDBRAKE" == false ]]; then
install_handbrake
else
warn "HandBrake-Installation übersprungen (--no-handbrake)"
fi
info "Paketlisten aktualisieren..."
apt_update
if [[ "$SKIP_NGINX" == false ]]; then
if ! command_exists nginx; then
info "Installiere nginx..."
apt-get install -y nginx
info "Installiere Basispakete..."
apt-get install -y \
curl wget git jq \
ffmpeg \
mediainfo \
util-linux udev \
ca-certificates gnupg \
lsb-release \
mkvtoolnix \
coreutils
ok "Basispakete installiert"
info "Installiere CD-Ripping-Tools..."
apt-get install -y \
cdparanoia \
flac \
lame \
opus-tools \
vorbis-tools
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
install_node
if [[ "$SKIP_MAKEMKV" == false ]]; then
install_makemkv
else
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
fi
if [[ "$SKIP_HANDBRAKE" == false ]]; then
install_handbrake
else
warn "HandBrake-Installation übersprungen (--no-handbrake)"
fi
if [[ "$SKIP_NGINX" == false ]]; then
if ! command_exists nginx; then
info "Installiere nginx..."
apt-get install -y nginx
fi
ok "nginx installiert"
fi
fi
if [[ "$SKIP_SYSTEM_DEPS" == true ]]; then
missing_required=()
command_exists git || missing_required+=("git")
command_exists jq || missing_required+=("jq")
command_exists node || missing_required+=("node")
command_exists npm || missing_required+=("npm")
if [[ "$SKIP_NGINX" == false ]]; then
command_exists nginx || missing_required+=("nginx")
fi
if [[ ${#missing_required[@]} -gt 0 ]]; then
fatal "--no-system-deps gesetzt, aber folgende Pflicht-Tools fehlen: ${missing_required[*]}. Bitte vorab installieren oder ohne --no-system-deps ausführen."
fi
ok "nginx installiert"
fi
# --- Systembenutzer anlegen ---------------------------------------------------
@@ -823,10 +1045,24 @@ header "Repository holen (Git)"
info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
branch_check_output=""
branch_check_status=0
if ! branch_check_output=$(git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" 2>&1); then
branch_check_status=$?
if command_exists timeout; then
branch_check_output=$(timeout --foreground "${GIT_COMMAND_TIMEOUT_SEC}" \
git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" 2>&1) || branch_check_status=$?
else
branch_check_output=$(git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" 2>&1) || branch_check_status=$?
fi
if [[ "$branch_check_status" -ne 0 ]]; then
if [[ "$branch_check_status" -eq 124 ]]; then
fatal "Timeout beim Prüfen des Branches '${GIT_BRANCH}' (${GIT_COMMAND_TIMEOUT_SEC}s)."
fi
if [[ "$branch_check_status" -eq 2 ]]; then
available_branches=$(git ls-remote --heads "$REPO_URL" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')
if command_exists timeout; then
available_branches=$(timeout --foreground "${GIT_COMMAND_TIMEOUT_SEC}" \
git ls-remote --heads "$REPO_URL" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')
else
available_branches=$(git ls-remote --heads "$REPO_URL" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')
fi
fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: ${available_branches:-keine oder Remote nicht erreichbar}"
fi
fatal "Remote $REPO_URL ist nicht erreichbar.\nGit-Fehler: ${branch_check_output:-unbekannt}"
@@ -836,22 +1072,24 @@ ok "Branch '$GIT_BRANCH' gefunden"
if [[ -d "$INSTALL_DIR/.git" ]]; then
if [[ "$REINSTALL" == true ]]; then
info "Aktualisiere bestehendes Repository..."
# Daten sichern
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
info "Datenbank gesichert nach: $DATA_BACKUP"
fi
info "Sichere persistente Backend-Daten (ohne große Medienordner)..."
backup_persistent_backend_data
backup_and_remove_backend_src
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
# (z.B. ripster-Serviceuser nach erstem Install)
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
git -C "$INSTALL_DIR" remote set-branches origin '*'
git -C "$INSTALL_DIR" fetch --quiet origin
git -C "$INSTALL_DIR" reset --hard HEAD
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
run_git_command "Setze Git-Remote-URL" \
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
run_git_command "Aktualisiere Remote-Branch-Mapping" \
git -C "$INSTALL_DIR" remote set-branches origin '*'
run_git_command "Hole Änderungen vom Remote" \
git -C "$INSTALL_DIR" fetch --quiet origin
run_git_command "Setze Arbeitsverzeichnis auf aktuellen Stand (HEAD)" \
git -C "$INSTALL_DIR" reset --hard HEAD
run_git_command "Checkout Branch '$GIT_BRANCH'" \
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
run_git_command "Setze Branch auf origin/$GIT_BRANCH" \
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
else
fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren."
@@ -873,6 +1111,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/downloads"
mkdir -p "$INSTALL_DIR/backend/data/logs"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# Gesicherte Daten zurückspielen
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
@@ -886,6 +1125,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# --- npm-Abhängigkeiten installieren -----------------------------------------
header "npm-Abhängigkeiten installieren"
@@ -942,6 +1182,7 @@ DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
DEFAULT_TEMP_DIR=${INSTALL_DIR}/backend/data/temp
EOF
ok "Backend .env erstellt"
fi

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