init
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
|||||||
|
acfunlive
|
||||||
|
*.ico
|
||||||
|
*.json
|
||||||
|
*.mp4
|
||||||
|
*.flv
|
||||||
|
*.ass
|
||||||
|
*.7z
|
||||||
|
/webui
|
||||||
|
/bin
|
||||||
|
/merged
|
||||||
|
/record
|
||||||
|
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
.workbuddy
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:1-alpine AS go_build
|
||||||
|
LABEL stage=buildgo
|
||||||
|
|
||||||
|
ADD . /acfunlive-src
|
||||||
|
WORKDIR /acfunlive-src
|
||||||
|
|
||||||
|
ENV GO111MODULE=on \
|
||||||
|
GOPROXY="https://goproxy.cn" \
|
||||||
|
CGO_ENABLED=0
|
||||||
|
|
||||||
|
RUN go build
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
|
||||||
|
ENV BINFILE="/acfunlive/acfunlive" \
|
||||||
|
CONFIGDIR="/acfunlive/config" \
|
||||||
|
RECORDDIR="/acfunlive/record"
|
||||||
|
|
||||||
|
RUN mkdir -p $CONFIGDIR && \
|
||||||
|
mkdir -p $RECORDDIR && \
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && \
|
||||||
|
apk update && \
|
||||||
|
apk upgrade && \
|
||||||
|
apk --no-cache add ca-certificates tzdata ffmpeg && \
|
||||||
|
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||||
|
|
||||||
|
COPY --from=go_build /acfunlive-src/acfunlive $BINFILE
|
||||||
|
|
||||||
|
VOLUME $CONFIGDIR $RECORDDIR
|
||||||
|
|
||||||
|
ENTRYPOINT ["/acfunlive/acfunlive", "-config", "/acfunlive/config", "-record", "/acfunlive/record"]
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
GOCMD=go
|
||||||
|
GOBUILD=$(GOCMD) build
|
||||||
|
GOCLEAN=$(GOCMD) clean
|
||||||
|
GOGET=$(GOCMD) get
|
||||||
|
GITREPO=github.com/orzogc/acfunlive
|
||||||
|
STATIC=CGO_ENABLED=0
|
||||||
|
WINDOWSENV=GOOS=windows GOARCH=amd64
|
||||||
|
MKDIR=mkdir -p
|
||||||
|
RM=rm -rf
|
||||||
|
BINARY=bin
|
||||||
|
|
||||||
|
all: deps build
|
||||||
|
|
||||||
|
build: build-go-cli
|
||||||
|
|
||||||
|
build-go-cli:
|
||||||
|
$(MKDIR) $(BINARY)
|
||||||
|
$(STATIC) $(GOBUILD) -o $(BINARY)
|
||||||
|
|
||||||
|
deps:
|
||||||
|
$(GOGET) $(GITREPO)
|
||||||
|
|
||||||
|
build-windows-cli: deps build-go-windows-cli
|
||||||
|
|
||||||
|
build-go-windows-cli:
|
||||||
|
$(MKDIR) $(BINARY)
|
||||||
|
$(STATIC) $(WINDOWSENV) $(GOBUILD) -o $(BINARY)
|
||||||
|
|
||||||
|
build-linux-cli: deps build-go-linux-cli
|
||||||
|
|
||||||
|
build-go-linux-cli:
|
||||||
|
$(MKDIR) $(BINARY)
|
||||||
|
$(STATIC) $(GOBUILD) -o $(BINARY)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
$(GOCLEAN)
|
||||||
|
$(RM) $(BINARY)
|
||||||
|
|
||||||
|
.PHONY: all build build-go-cli deps build-windows-cli build-go-windows-cli build-linux-cli build-go-linux-cli clean
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# acfunlive
|
||||||
|
|
||||||
|
AcFun 直播弹幕和录播助手(免登录,只用 uid 列表指定要录制的主播)
|
||||||
|
|
||||||
|
- [acfunlive](#acfunlive)
|
||||||
|
- [依赖](#依赖)
|
||||||
|
- [运行依赖](#运行依赖)
|
||||||
|
- [编译依赖](#编译依赖)
|
||||||
|
- [编译](#编译)
|
||||||
|
- [使用 GNU Make](#使用-gnu-make)
|
||||||
|
- [不使用 GNU Make](#不使用-gnu-make)
|
||||||
|
- [配置文件详解](#配置文件详解)
|
||||||
|
- [使用方法](#使用方法)
|
||||||
|
- [弹幕合成](#弹幕合成)
|
||||||
|
- [自动合并](#自动合并)
|
||||||
|
- [Docker](#docker)
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
#### 运行依赖
|
||||||
|
|
||||||
|
- ffmpeg(录制直播视频和合并弹幕需要,可以放在本程序所在文件夹、加入 PATH,或在设置里用`ffmpegPath`指定位置)
|
||||||
|
|
||||||
|
#### 编译依赖
|
||||||
|
|
||||||
|
- go
|
||||||
|
- GNU Make(Linux 下可选)
|
||||||
|
|
||||||
|
### 编译
|
||||||
|
|
||||||
|
#### 使用 GNU Make
|
||||||
|
|
||||||
|
```
|
||||||
|
# 更新repo需另外运行 git submodule update --remote --merge
|
||||||
|
git clone --recursive https://github.com/orzogc/acfunlive.git
|
||||||
|
cd acfunlive
|
||||||
|
# 编译GUI版本运行 make build-gui ,编译Windows版本运行 make build-windows-gui 或 make build-windows-cli
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
编译好的文件在 bin 文件夹下
|
||||||
|
|
||||||
|
#### 不使用 GNU Make
|
||||||
|
|
||||||
|
```
|
||||||
|
# 更新repo需另外运行 git submodule update --remote --merge
|
||||||
|
git clone --recursive https://github.com/orzogc/acfunlive.git
|
||||||
|
cd acfunlive
|
||||||
|
go build
|
||||||
|
```
|
||||||
|
|
||||||
|
web UI 已经内嵌在程序里,不需要另外编译前端。
|
||||||
|
|
||||||
|
交叉编译 Windows 版本:
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置文件详解
|
||||||
|
|
||||||
|
可以先运行一次本程序以生成配置文件。
|
||||||
|
|
||||||
|
配置文件`config.json`默认保存在本程序所在文件夹内,运行时可用参数`-config`指定配置文件所在文件夹,大部分设置修改后会自动生效,无需重新启动本程序。
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"uids": [ // 要录制的主播列表
|
||||||
|
{
|
||||||
|
"uid": 23682490, // 主播的 uid
|
||||||
|
"danmu": true, // 是否录制弹幕
|
||||||
|
"record": true, // 是否录制直播视频
|
||||||
|
"bitrate": 0 // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹
|
||||||
|
"webPort": 51880, // web UI 的端口,为 0 时不启动 web UI
|
||||||
|
|
||||||
|
"autoMerge": false, // 直播结束后自动把弹幕层合并进录播
|
||||||
|
"mergeDelVideo": false, // 合并完成后删除源视频
|
||||||
|
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
||||||
|
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
||||||
|
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
|
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
|
||||||
|
|
||||||
|
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
||||||
|
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
|
|
||||||
|
"notifyUrl": "", // 开播/下播等事件推送到该地址(HTTP POST JSON),留空不推送
|
||||||
|
"notifyToken": "", // 推送时附带的 Bearer 口令,留空不带
|
||||||
|
"notifyGroup": "", // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
||||||
|
"notifyLiveOn": false, // 开播时推送
|
||||||
|
"notifyLiveOff": false, // 下播时推送
|
||||||
|
"notifyRecordStart": false, // 开始录制直播视频时推送
|
||||||
|
"notifyMergeDone": false, // 自动合并完成时推送
|
||||||
|
|
||||||
|
"ffmpegPath": "", // ffmpeg 可执行文件(或它所在文件夹)的路径,留空则自动查找
|
||||||
|
"recordFormat": "mp4", // 录播文件的封装格式:mp4、flv、ts、mkv
|
||||||
|
"recordSubdir": "record", // 录播原片和弹幕文件存放在保存目录下的子文件夹名
|
||||||
|
"mergeSubdir": "merged" // 合并成片存放在保存目录下的子文件夹名
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率取最高。
|
||||||
|
|
||||||
|
`bitrate`为 0 时录制码率最高的直播源,设置为其他数字时录制码率不超过该值的最高档(如 2000 大约是超清档),可以在 web UI 的主播列表里随时修改。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以`bitrate`只能在这几个档位里挑选。
|
||||||
|
|
||||||
|
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||||
|
|
||||||
|
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 设置区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
||||||
|
|
||||||
|
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
||||||
|
|
||||||
|
| 格式 | 兼容性 | 强杀后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mp4`(默认) | 最好,播放器、剪辑软件、手机都认 | **整个文件报废**(48 字节,`moov atom not found`) |
|
||||||
|
| `flv` | 一般,浏览器不能直接播 | **整个文件报废**(0 字节,输出还在缓冲区里) |
|
||||||
|
| `ts` | 较好,剪辑软件普遍支持 | **能读出已录的部分**(7.06 秒可播) |
|
||||||
|
| `mkv` | 一般,部分移动端不支持 | **整个文件报废**(0 字节) |
|
||||||
|
|
||||||
|
也就是说,如果这台机器可能被强制关机、或者你习惯直接结束进程,把`recordFormat`改成`ts`更保险(后续合并、播放都不受影响);追求兼容性就用默认的`mp4`。合并出来的成片始终是`mp4`(弹幕层需要重新编码)。
|
||||||
|
|
||||||
|
**同一主播下播后 20 秒内又上播会被判定为网络波动**:程序不会结束本场录制,而是接着录到同一对文件里,最终只合成一个成片(视频分段会先拼接,弹幕时间轴自动对齐)。超过 20 秒才算真的换场次,会重新开始一场录制,下播通知也是等过了这个窗口确认后才发,避免网络抖动导致误报。
|
||||||
|
|
||||||
|
`webExternal`为 false 时 web UI 只监听`127.0.0.1`,只有本机能打开;设为 true 后监听所有网卡,同一局域网的设备可以用本机 IP 加端口访问。这项设置改动后 web 服务会自动重启。
|
||||||
|
|
||||||
|
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 设置区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
||||||
|
|
||||||
|
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 设置区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
||||||
|
|
||||||
|
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
||||||
|
|
||||||
|
每个主播的`danmu`和`record`开关可以在 web UI 里随时修改,也可以用命令行命令修改,输入`help`查看全部命令。
|
||||||
|
|
||||||
|
### 使用方法
|
||||||
|
|
||||||
|
运行时可用参数`-record`指定录制文件保存的文件夹,用`-config`指定配置文件所在文件夹。
|
||||||
|
|
||||||
|
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。
|
||||||
|
|
||||||
|
主播列表每行右侧的`停止`和`重新开始`按钮控制该主播当前的录制:`停止`会结束这一场的弹幕和录播(同时把该主播的弹幕、录播开关关掉,避免被监控循环自动重启),`重新开始`会重新开启开关并立刻开始新的一组录制文件。这两个按钮同时作用于弹幕和录播,只控制其中一项可以用对应的勾选框。
|
||||||
|
|
||||||
|
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
||||||
|
|
||||||
|
录制直播视频时用`ffmpeg -c copy`直接保存直播源,不转码,分辨率和码率跟随直播源,默认取码率最高的源。
|
||||||
|
|
||||||
|
录制弹幕时会同时生成三个文件:
|
||||||
|
|
||||||
|
- `.ass`:原始弹幕字幕
|
||||||
|
- `.nick.ass`:带头像占位和`昵称(UID):`前缀的弹幕字幕
|
||||||
|
- `.jsonl`:弹幕记录(时间、uid、昵称、头像链接、内容、弹道),供弹幕合成使用
|
||||||
|
|
||||||
|
### 弹幕合成
|
||||||
|
|
||||||
|
录制生成的`jsonl`文件可以合成带头像的弹幕层,需要 ffmpeg:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 把弹幕压进录播视频,输出 xxx_弹幕合并.mp4
|
||||||
|
acfunlive -danmuburn xxx.jsonl -video xxx.flv
|
||||||
|
# 只输出带透明通道的头像弹幕层(ProRes 4444 的 mov),可叠到剪辑软件里
|
||||||
|
acfunlive -danmuburn xxx.jsonl
|
||||||
|
# 指定输出文件、时间偏移和目标码率(录播比弹幕晚开始 3 秒时用 -offset 对齐)
|
||||||
|
acfunlive -danmuburn xxx.jsonl -video xxx.flv -out out.mp4 -offset 3 -vbitrate 2000
|
||||||
|
# 弹幕文件也可以是上游原版的 .ass,会先自动还原成 jsonl 再合并
|
||||||
|
acfunlive -danmuburn "2025-04-09 20-52-05 AC娘本体 标题.ass" -video xxx.flv
|
||||||
|
# ffmpeg 既不在 PATH 也不在程序目录时,用 -ffmpeg 指定它的位置
|
||||||
|
acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
||||||
|
|
||||||
|
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
|
||||||
|
|
||||||
|
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
||||||
|
|
||||||
|
每条带头像的弹幕都要生成一个叠加滤镜,而它们全部塞在同一条 ffmpeg 命令里,命令总长在 Windows 上不能超过 32767 字符(Linux 是单个参数 128KB),弹幕一多就会超限失败。所以弹幕数量超过一定值时会自动**分块渲染**:按时段切成若干块,每块单独渲染成一个小片段,最后拼接成完整成片。断流空档以外的视频不会缺帧,音轨在分块时会重新编码以保证拼接后不跑偏。
|
||||||
|
|
||||||
|
### 自动合并
|
||||||
|
|
||||||
|
开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。`mergeBitrate`大于 0 时成片使用指定目标码率,可以明显减小成片体积。
|
||||||
|
|
||||||
|
直播流中途中断时录制会自动重连并产生新的视频分段,合并会把**所有分段拼成一个完整视频**,弹幕时间轴也按各段时长重新对齐,断流期间的弹幕挪到下一段开头,所以后半场不会丢弹幕。分段时长探测不到时会退化为只合并第一段并给出提示。
|
||||||
|
|
||||||
|
手动停止录制(web UI 的`停止`按钮或命令行里停止)不会触发自动合并,重新开始录制后新的一组文件仍会在直播结束时自动合并。这一场想补出成片的话,用下面的`手动合并`。合并需要 ffmpeg,且会占用 CPU 或显卡资源,长录播的合并耗时较长。
|
||||||
|
|
||||||
|
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
|
||||||
|
|
||||||
|
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
|
||||||
|
|
||||||
|
### 手动合并
|
||||||
|
|
||||||
|
web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||||
|
|
||||||
|
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
||||||
|
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
||||||
|
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
||||||
|
- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。
|
||||||
|
- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。
|
||||||
|
|
||||||
|
一次只允许跑一个手动合并任务,重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。
|
||||||
|
|
||||||
|
### 通知推送
|
||||||
|
|
||||||
|
填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址,可以用来接自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务。
|
||||||
|
|
||||||
|
推送的请求体:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"event": "liveOn", // liveOn 开播、liveOff 下播、recordStart 开始录制、mergeDone 合并完成
|
||||||
|
"uid": 280178, // 主播 uid
|
||||||
|
"name": "Xsphere", // 主播名字
|
||||||
|
"title": "今天画点啥", // 直播标题
|
||||||
|
"url": "https://…", // 直播间地址
|
||||||
|
"file": "…", // 相关文件路径(开始录制时的视频、合并完成时的成片)
|
||||||
|
"group": "777163254", // 通知群号(web UI 里配的),机器人可以直接用它决定发到哪个群
|
||||||
|
"text": "Xsphere 开播了…", // 已经拼好的文本,机器人可以直接发出去
|
||||||
|
"time": "2026/09/25 10:59:29"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。web UI 设置区的`发送测试通知`按钮可以用当前填写的地址直接测试(不需要先保存)。
|
||||||
|
|
||||||
|
`notifyGroup`是随推送下发的群号,留空则不带这个字段。它只是原样透传给机器人,程序自己不做任何判断——好处是群号、口令这些都能在 web UI 里改,不用去动机器人那边的配置。
|
||||||
|
|
||||||
|
接 NoneBot 的例子见`doc/momoko-acfunlive-notify.py`:把这个文件复制到 NoneBot 项目的插件目录即可,收到后直接把`text`发到群里。插件会优先使用推送里的`group`,只有推送没带时才回退到环境变量`ACFUNLIVE_NOTIFY_GROUP`,所以群号在 acfunlive 页面上改完立即生效,不用重启机器人。
|
||||||
|
|
||||||
|
### 文件管理
|
||||||
|
|
||||||
|
web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
|
||||||
|
|
||||||
|
- `下载`:以附件形式下载,中文文件名用 RFC 5987 编码,不会乱码
|
||||||
|
- `播放`:只对浏览器能直接播的格式(mp4、m4v、webm、ogv)显示,在新标签页里内联播放;`.flv` 浏览器放不了,只提供下载
|
||||||
|
- `删除`:会二次确认,删除后不可恢复
|
||||||
|
- 正在录制或正在合并的文件会标记`写入中`且不显示删除按钮,后端也会拒绝删除这类文件(返回 409),避免删掉还没写完的录播或成片。录制侧和接口侧的路径会先归一成绝对路径再比较,所以`-record .`这类相对路径的部署方式下判断同样有效
|
||||||
|
|
||||||
|
文件管理只暴露这两个文件夹,接口不接受带路径分隔符的文件名,也不能用相对路径跳出文件夹。下载链接带`?token=`,因为浏览器直接跳转无法携带请求头,页面响应已设置`Referrer-Policy: no-referrer`避免口令随来源页泄露。
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone --recursive https://github.com/orzogc/acfunlive.git
|
||||||
|
cd acfunlive
|
||||||
|
docker build -t acfunlive .
|
||||||
|
# configDir是配置文件所在文件夹,recordDir是录播和弹幕保存所在文件夹
|
||||||
|
docker run -i -v configDir:/acfunlive/config -v recordDir:/acfunlive/record -p 51880:51880 -u `id -u`:`id -g` acfunlive:latest
|
||||||
|
```
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
// 弹幕文件的读写与格式还原
|
||||||
|
// 上游原版只输出 .ass,这里把 .ass 还原成本程序的 jsonl,之后就能走同一套合成流程
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ASS 的时间格式 H:MM:SS.cc
|
||||||
|
var assClockRe = regexp.MustCompile(`^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$`)
|
||||||
|
|
||||||
|
// ASS 覆盖块里的 \move(x1,y1,x2,y2)
|
||||||
|
var assMoveRe = regexp.MustCompile(`\\move\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)`)
|
||||||
|
|
||||||
|
// Style / Dialogue 里的「昵称(uid)」
|
||||||
|
var assSpeakerRe = regexp.MustCompile(`^(.*)\((\d+)\)$`)
|
||||||
|
|
||||||
|
// 文件里没有 Format 行时按标准列序解析
|
||||||
|
// 样式必须是完整的 23 列:字号是第 3 列,后面还有颜色等字段,
|
||||||
|
// 列数给少了会把后面的内容全吞进字号那一列
|
||||||
|
var (
|
||||||
|
defaultStyleFields = []string{
|
||||||
|
"Name", "Fontname", "Fontsize", "PrimaryColour", "SecondaryColour", "OutlineColour",
|
||||||
|
"BackColour", "Bold", "Italic", "Underline", "StrikeOut", "ScaleX", "ScaleY",
|
||||||
|
"Spacing", "Angle", "BorderStyle", "Outline", "Shadow", "Alignment",
|
||||||
|
"MarginL", "MarginR", "MarginV", "Encoding",
|
||||||
|
}
|
||||||
|
defaultEventFields = []string{"Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect", "Text"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonl 的摘要,检查合并参数时用
|
||||||
|
type jsonlSummary struct {
|
||||||
|
meta danmuMeta
|
||||||
|
count int
|
||||||
|
first float64
|
||||||
|
last float64
|
||||||
|
hasAvatar bool // 记录里有没有头像链接
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 ASS 时间(H:MM:SS.cc),返回秒数
|
||||||
|
func parseAssClock(s string) (float64, bool) {
|
||||||
|
m := assClockRe.FindStringSubmatch(strings.TrimSpace(s))
|
||||||
|
if m == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
h, _ := strconv.Atoi(m[1])
|
||||||
|
mi, _ := strconv.Atoi(m[2])
|
||||||
|
sec, _ := strconv.Atoi(m[3])
|
||||||
|
frac := 0.0
|
||||||
|
if m[4] != "" {
|
||||||
|
frac, _ = strconv.ParseFloat("0."+m[4], 64)
|
||||||
|
}
|
||||||
|
return float64(h)*3600 + float64(mi)*60 + float64(sec) + frac, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 ASS 的格式拆一行:列数按 Format 行给出的数量截断,最后一列吸收剩余的逗号
|
||||||
|
// (弹幕内容里本来就可能有逗号,不能无脑 Split)
|
||||||
|
func assFields(line string, count int) []string {
|
||||||
|
body := line
|
||||||
|
if i := strings.Index(line, ":"); i >= 0 {
|
||||||
|
body = line[i+1:]
|
||||||
|
}
|
||||||
|
body = strings.TrimLeft(body, " \t")
|
||||||
|
if count > 0 {
|
||||||
|
return strings.SplitN(body, ",", count)
|
||||||
|
}
|
||||||
|
return strings.Split(body, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这一行是不是某个 ASS 关键字开头(形如「Style: xxx」)
|
||||||
|
func isAssKey(line, key string) bool {
|
||||||
|
i := strings.Index(line, ":")
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(line[:i]), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段名在 Format 行里的下标,找不到返回 -1
|
||||||
|
func assFieldIndex(format []string, name string) int {
|
||||||
|
for i, f := range format {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(f), name) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拆开 ASS 正文开头的 {...} 覆盖块和后面的文字
|
||||||
|
func splitAssOverride(text string) (string, string) {
|
||||||
|
if strings.HasPrefix(text, "{") {
|
||||||
|
if i := strings.Index(text, "}"); i >= 0 {
|
||||||
|
return text[1:i], text[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 \move 的 y 坐标反推弹道行号,解析不出来返回 0
|
||||||
|
func assLane(override string, fontSize int, nick bool) int {
|
||||||
|
if fontSize <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m := assMoveRe.FindStringSubmatch(override)
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
y, err := strconv.ParseFloat(m[2], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lane := int(math.Round(y / float64(fontSize)))
|
||||||
|
if !nick {
|
||||||
|
// 原格式的 y 是 fontSize*(lane+1),昵称版是 fontSize*lane
|
||||||
|
lane--
|
||||||
|
}
|
||||||
|
if lane < 0 {
|
||||||
|
lane = 0
|
||||||
|
}
|
||||||
|
if lane >= laneNum {
|
||||||
|
lane = laneNum - 1
|
||||||
|
}
|
||||||
|
return lane
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合成时实际的显示文字,与录制时写进 jsonl 的格式一致
|
||||||
|
func speakerText(name string, uid int64, content string) string {
|
||||||
|
return name + "(" + strconv.FormatInt(uid, 10) + "):" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move)
|
||||||
|
func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
||||||
|
var meta danmuMeta
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return meta, nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var styleFormat, eventFormat []string
|
||||||
|
records := make([]danmuRecord, 0, 1024)
|
||||||
|
bad := 0
|
||||||
|
section := ""
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimRight(scanner.Text(), "\r")
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if t == "" || strings.HasPrefix(t, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
||||||
|
section = strings.ToLower(strings.Trim(t, "[]"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch section {
|
||||||
|
case "script info":
|
||||||
|
i := strings.Index(t, ":")
|
||||||
|
if i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(t[i+1:])
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t[:i])) {
|
||||||
|
case "playresx":
|
||||||
|
meta.PlayResX, _ = strconv.Atoi(val)
|
||||||
|
case "playresy":
|
||||||
|
meta.PlayResY, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "v4+ styles", "v4 styles":
|
||||||
|
if isAssKey(t, "format") {
|
||||||
|
styleFormat = assFields(t, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isAssKey(t, "style") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
format := styleFormat
|
||||||
|
if len(format) == 0 {
|
||||||
|
format = defaultStyleFields
|
||||||
|
}
|
||||||
|
fields := assFields(t, len(format))
|
||||||
|
i := assFieldIndex(format, "fontsize")
|
||||||
|
if i < 0 || i >= len(fields) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(strings.TrimSpace(fields[i]))
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 优先用 Danmu 这个样式,其它的只在本文件还没有字号时兜底
|
||||||
|
name := ""
|
||||||
|
if j := assFieldIndex(format, "name"); j >= 0 && j < len(fields) {
|
||||||
|
name = strings.TrimSpace(fields[j])
|
||||||
|
}
|
||||||
|
if meta.FontSize == 0 || strings.EqualFold(name, "danmu") {
|
||||||
|
meta.FontSize = v
|
||||||
|
}
|
||||||
|
|
||||||
|
case "events":
|
||||||
|
if isAssKey(t, "format") {
|
||||||
|
eventFormat = assFields(t, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isAssKey(t, "dialogue") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
format := eventFormat
|
||||||
|
if len(format) == 0 {
|
||||||
|
format = defaultEventFields
|
||||||
|
}
|
||||||
|
fields := assFields(t, len(format))
|
||||||
|
get := func(name string) string {
|
||||||
|
i := assFieldIndex(format, name)
|
||||||
|
if i < 0 || i >= len(fields) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fields[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
start, ok := parseAssClock(get("Start"))
|
||||||
|
if !ok {
|
||||||
|
bad++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
override, body := splitAssOverride(get("Text"))
|
||||||
|
nick := strings.Contains(override, `\an7`)
|
||||||
|
|
||||||
|
speaker := strings.TrimSpace(get("Name"))
|
||||||
|
name, uid := speaker, int64(0)
|
||||||
|
if m := assSpeakerRe.FindStringSubmatch(speaker); m != nil {
|
||||||
|
name = m[1]
|
||||||
|
uid, _ = strconv.ParseInt(m[2], 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 昵称头像版的正文前面还有一段「昵称(uid):」,原格式的正文就是弹幕内容
|
||||||
|
content := body
|
||||||
|
if nick && speaker != "" {
|
||||||
|
if p := strings.Index(content, speaker+":"); p >= 0 {
|
||||||
|
content = content[p+len(speaker)+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
bad++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: start,
|
||||||
|
UID: uid,
|
||||||
|
Name: name,
|
||||||
|
Avatar: "", // .ass 里没有头像链接,合成时按 uid 反查
|
||||||
|
Content: content,
|
||||||
|
Lane: assLane(override, meta.FontSize, nick),
|
||||||
|
Width: textWidth(speakerText(name, uid, content), meta.FontSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return meta, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.PlayResX == 0 || meta.PlayResY == 0 {
|
||||||
|
return meta, nil, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件")
|
||||||
|
}
|
||||||
|
if meta.FontSize == 0 {
|
||||||
|
return meta, nil, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)")
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
if bad > 0 {
|
||||||
|
return meta, nil, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad)
|
||||||
|
}
|
||||||
|
return meta, nil, errNoDanmu
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分块渲染要求记录按时间递增,这里统一排一遍
|
||||||
|
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||||||
|
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
|
||||||
|
meta.StartTime = nameStartNs(path)
|
||||||
|
if bad > 0 {
|
||||||
|
lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad)
|
||||||
|
}
|
||||||
|
lPrintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)",
|
||||||
|
filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize,
|
||||||
|
formatSeconds(records[len(records)-1].Time))
|
||||||
|
return meta, records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按本程序的 jsonl 格式写文件(首行是元数据)
|
||||||
|
func writeJsonlFile(path string, meta danmuMeta, records []danmuRecord) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
b, err := json.Marshal(meta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Write(b)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
for _, r := range records {
|
||||||
|
if b, err := json.Marshal(r); err == nil {
|
||||||
|
w.Write(b)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 jsonl 的摘要
|
||||||
|
func scanJsonlSummary(path string) (jsonlSummary, error) {
|
||||||
|
var s jsonlSummary
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
first := true
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
first = false
|
||||||
|
if err := json.Unmarshal(line, &s.meta); err != nil {
|
||||||
|
return s, fmt.Errorf("jsonl 的首行不是元数据,文件可能已损坏:%v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r danmuRecord
|
||||||
|
if json.Unmarshal(line, &r) != nil || r.Content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.count == 0 {
|
||||||
|
s.first = r.Time
|
||||||
|
}
|
||||||
|
s.last = r.Time
|
||||||
|
s.count++
|
||||||
|
if r.Avatar != "" {
|
||||||
|
s.hasAvatar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
if s.meta.PlayResX == 0 || s.meta.FontSize == 0 {
|
||||||
|
return s, errors.New("jsonl 文件格式不对,首行必须包含 playResX、playResY、fontSize")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕文件对应的 jsonl 路径:xxx.ass 和 xxx.nick.ass 都对应 xxx.jsonl
|
||||||
|
func assSiblingJsonl(path string) string {
|
||||||
|
base, low := path, strings.ToLower(path)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(low, ".nick.ass"):
|
||||||
|
base = base[:len(base)-len(".nick.ass")]
|
||||||
|
case strings.HasSuffix(low, ".ass"):
|
||||||
|
base = base[:len(base)-len(".ass")]
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base + ".jsonl"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕文件名去掉扩展名(.nick.ass 也一起去掉),用来拼默认的输出名
|
||||||
|
func danmuBaseName(path string) string {
|
||||||
|
name := filepath.Base(path)
|
||||||
|
low := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(low, ".nick.ass"):
|
||||||
|
return name[:len(name)-len(".nick.ass")]
|
||||||
|
case strings.HasSuffix(low, ".ass"), strings.HasSuffix(low, ".jsonl"):
|
||||||
|
return name[:len(name)-len(filepath.Ext(name))]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
// 直播结束后自动合并录播和弹幕层
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 弹幕和视频录制结束时都会调用:标记对应部分已结束,两部分都自然结束后启动自动合并
|
||||||
|
func finishPart(liveID string, danmu bool) {
|
||||||
|
lInfoMap.Lock()
|
||||||
|
info, ok := lInfoMap.info[liveID]
|
||||||
|
if !ok {
|
||||||
|
lInfoMap.Unlock()
|
||||||
|
lPrintWarnf("找不到 liveID 为 %s 的直播信息,跳过录制结束的处理(记录可能已被清理)", liveID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info.doneAt = time.Now().UnixNano()
|
||||||
|
if danmu {
|
||||||
|
info.isDanmu = false
|
||||||
|
info.danmuDone = true
|
||||||
|
} else {
|
||||||
|
info.isRecording = false
|
||||||
|
info.recordDone = true
|
||||||
|
}
|
||||||
|
shouldMerge := config.AutoMerge && mainCtx.Err() == nil &&
|
||||||
|
!info.danmuStopped && !info.recordStopped &&
|
||||||
|
info.danmuDone && info.recordDone && !info.mergeStarted &&
|
||||||
|
info.assFile != "" && info.firstRecordFile != ""
|
||||||
|
var segs []recordSegment
|
||||||
|
if shouldMerge {
|
||||||
|
info.mergeStarted = true
|
||||||
|
// 复制一份分段列表:合并是在另一个 goroutine 里做的,不能让它读到会被后续修改的切片
|
||||||
|
segs = append([]recordSegment(nil), info.recordSegments...)
|
||||||
|
if len(segs) == 0 {
|
||||||
|
segs = []recordSegment{{file: info.firstRecordFile, startNs: info.recordStartNs}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lInfoMap.info[liveID] = info
|
||||||
|
lInfoMap.Unlock()
|
||||||
|
|
||||||
|
if shouldMerge {
|
||||||
|
_, jsonlFile := nickJsonlFile(info.assFile)
|
||||||
|
uid := info.uid
|
||||||
|
danmuStartNs := info.cfg.StartTime
|
||||||
|
// 合并是最重的任务,必须兜底 recover,否则一次 panic 会终止整个程序并中断其它主播的录制
|
||||||
|
safeGo("自动合并"+longID(uid), func() { autoMerge(uid, segs, jsonlFile, danmuStartNs) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕录制结束
|
||||||
|
func finishDanmuPart(liveID string) {
|
||||||
|
finishPart(liveID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频录制结束
|
||||||
|
func finishRecordPart(liveID string) {
|
||||||
|
finishPart(liveID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找文件实际位置:优先原路径,其次 directory(录制结束时文件可能已被移动过去)
|
||||||
|
func findMovedFile(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// 录制结束时文件可能已经被移动到保存目录下的子文件夹
|
||||||
|
p := filepath.Join(recordSaveDir(), filepath.Base(path))
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
|
||||||
|
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移,以及清理临时文件的函数。
|
||||||
|
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, func(), error) {
|
||||||
|
noop := func() {}
|
||||||
|
|
||||||
|
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
|
||||||
|
files := make([]string, 0, len(segs))
|
||||||
|
starts := make([]int64, 0, len(segs))
|
||||||
|
for _, s := range segs {
|
||||||
|
if p := findMovedFile(s.file); p != "" {
|
||||||
|
files = append(files, p)
|
||||||
|
starts = append(starts, s.startNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", "", 0, noop, errors.New("找不到录播视频文件")
|
||||||
|
}
|
||||||
|
if len(files) == 1 {
|
||||||
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
|
return files[0], jsonlFile, offset, noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintf("本场录播中途中断过,共 %d 个分段,先拼接成完整视频再合并", len(files))
|
||||||
|
|
||||||
|
// 每段的真实时长:拼接后的时间轴是按各段时长累加得到的,弹幕时间要照着映射过去
|
||||||
|
durs := make([]float64, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
durs[i] = probeDuration(ffmpegFile, f)
|
||||||
|
if durs[i] <= 0 {
|
||||||
|
lPrintWarnf("无法获取分段 %s 的时长,本场只合并第一个分段(后半场没有弹幕)", filepath.Base(f))
|
||||||
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
|
return files[0], jsonlFile, offset, noop, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(jsonlFile)
|
||||||
|
// 拼接出来的中间文件沿用分段的封装格式,concat 用 -c copy 时容器一致最稳
|
||||||
|
joined := filepath.Join(dir, chunkTempPrefix+"joined"+filepath.Ext(files[0]))
|
||||||
|
if err := concatChunks(ffmpegFile, files, joined, dir); err != nil {
|
||||||
|
lPrintWarnf("拼接录播分段失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
||||||
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
|
return files[0], jsonlFile, offset, noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newJsonl := filepath.Join(dir, chunkTempPrefix+"remapped.jsonl")
|
||||||
|
if err := remapJsonlTimes(jsonlFile, newJsonl, starts, durs, danmuStartNs); err != nil {
|
||||||
|
_ = os.Remove(joined)
|
||||||
|
lPrintWarnf("重映射弹幕时间失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
||||||
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
|
return files[0], jsonlFile, offset, noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Remove(joined)
|
||||||
|
_ = os.Remove(newJsonl)
|
||||||
|
}
|
||||||
|
return joined, newJsonl, 0, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把弹幕时间从「真实时间轴」映射到「分段拼接后的时间轴」
|
||||||
|
func remapJsonlTimes(srcFile, dstFile string, starts []int64, durs []float64, danmuStartNs int64) error {
|
||||||
|
// 每段的真实起点(相对弹幕开始录制)和它在拼接后时间轴上的起点
|
||||||
|
realStart := make([]float64, len(starts))
|
||||||
|
concatStart := make([]float64, len(starts))
|
||||||
|
sum := 0.0
|
||||||
|
for i := range starts {
|
||||||
|
realStart[i] = float64(starts[i]-danmuStartNs) / 1e9
|
||||||
|
concatStart[i] = sum
|
||||||
|
sum += durs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(srcFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
w := bufio.NewWriter(out)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(in)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
first := true
|
||||||
|
dropped, moved := 0, 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if first {
|
||||||
|
// 首行是元数据,原样保留
|
||||||
|
w.Write(line)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r danmuRecord
|
||||||
|
if json.Unmarshal(line, &r) != nil || r.Content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := r.Time
|
||||||
|
idx := -1
|
||||||
|
for i := range realStart {
|
||||||
|
if t >= realStart[i] && t < realStart[i]+durs[i] {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case idx >= 0:
|
||||||
|
r.Time = concatStart[idx] + (t - realStart[idx])
|
||||||
|
default:
|
||||||
|
// 落在断流空档里的弹幕,挪到下一段的开头
|
||||||
|
for i := range realStart {
|
||||||
|
if t < realStart[i] {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
// 晚于最后一段:视频里没有对应画面,丢弃
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Time = concatStart[idx]
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.Write(b)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if moved > 0 || dropped > 0 {
|
||||||
|
lPrintf("弹幕时间轴已对齐拼接后的视频:%d 条落在断流空档里(挪到下一段开头),%d 条超出视频(丢弃)", moved, dropped)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行自动合并及后续的删除/移动
|
||||||
|
func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) {
|
||||||
|
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
|
||||||
|
|
||||||
|
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
|
||||||
|
if len(segs) > 0 {
|
||||||
|
videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file)))
|
||||||
|
jsonlBase := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl"))
|
||||||
|
if videoBase != jsonlBase {
|
||||||
|
lPrintWarnf("视频文件(%s)和弹幕文件(%s)不是同一场录制,取消自动合并", segs[0].file, jsonlFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
lPrintErr("自动合并失败:找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
|
||||||
|
defer cleanup()
|
||||||
|
if err != nil {
|
||||||
|
lPrintErrf("自动合并失败:%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成片直接输出到成片文件夹,不再事后再移动一次
|
||||||
|
mergeDir := mergeSaveDir()
|
||||||
|
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
||||||
|
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
|
||||||
|
|
||||||
|
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
||||||
|
markFileActive(outFile, true)
|
||||||
|
burnErr := burnDanmu(jsonl, video, outFile, offset)
|
||||||
|
markFileActive(outFile, false)
|
||||||
|
if burnErr != nil {
|
||||||
|
if burnErr == errNoDanmu {
|
||||||
|
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintErrf("自动合并失败:%v", burnErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintln("自动合并完成:" + outFile)
|
||||||
|
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile,
|
||||||
|
longID(uid)+" 的录播和弹幕合并完成\n"+outFile)
|
||||||
|
|
||||||
|
if config.MergeDelVideo {
|
||||||
|
// 源视频可能有多个分段,全部删掉
|
||||||
|
for _, s := range segs {
|
||||||
|
p := findMovedFile(s.file)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(p); err == nil {
|
||||||
|
lPrintln("已删除源视频:" + p)
|
||||||
|
} else {
|
||||||
|
lPrintWarnf("删除源视频失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.MergeDelDanmu {
|
||||||
|
base := strings.TrimSuffix(jsonlFile, ".jsonl")
|
||||||
|
for _, p := range []string{base + ".ass", base + ".nick.ass", base + ".jsonl"} {
|
||||||
|
if err := os.Remove(p); err == nil {
|
||||||
|
lPrintln("已删除弹幕文件:" + p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
// 设置相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
const configFile = "config.json" // 设置文件名字
|
||||||
|
|
||||||
|
var configFileLocation string // 设置文件位置
|
||||||
|
|
||||||
|
// 主播的设置数据
|
||||||
|
type streamer struct {
|
||||||
|
UID int `json:"uid"` // 主播 uid
|
||||||
|
Name string `json:"name"` // 主播名字
|
||||||
|
Danmu bool `json:"-"` // 是否录制直播弹幕
|
||||||
|
Record bool `json:"-"` // 是否录制直播视频
|
||||||
|
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个主播的开关设置
|
||||||
|
type uidSetting struct {
|
||||||
|
UID int `json:"uid"`
|
||||||
|
Danmu bool `json:"danmu"`
|
||||||
|
Record bool `json:"record"`
|
||||||
|
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
||||||
|
}
|
||||||
|
|
||||||
|
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
|
||||||
|
type uidList []uidSetting
|
||||||
|
|
||||||
|
func (l *uidList) UnmarshalJSON(data []byte) error {
|
||||||
|
var ints []int
|
||||||
|
if err := json.Unmarshal(data, &ints); err == nil {
|
||||||
|
*l = (*l)[:0]
|
||||||
|
for _, uid := range ints {
|
||||||
|
*l = append(*l, uidSetting{UID: uid, Danmu: true, Record: true})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type rawList []uidSetting
|
||||||
|
var arr rawList
|
||||||
|
if err := json.Unmarshal(data, &arr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*l = uidList(arr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存放主播的设置数据
|
||||||
|
var streamers struct {
|
||||||
|
sync.RWMutex // crt 的锁
|
||||||
|
crt map[int]streamer // 现在的主播的设置数据
|
||||||
|
old map[int]streamer // 旧的主播的设置数据
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串行化 config.json 的写入,避免多个 goroutine 同时写坏设置文件
|
||||||
|
var configMu sync.Mutex
|
||||||
|
|
||||||
|
// 设置数据
|
||||||
|
type configData struct {
|
||||||
|
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
|
||||||
|
Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹
|
||||||
|
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
|
||||||
|
|
||||||
|
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
|
||||||
|
MergeDelVideo bool `json:"mergeDelVideo"` // 合并完成后删除源视频
|
||||||
|
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
|
||||||
|
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
|
||||||
|
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
|
|
||||||
|
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
|
||||||
|
|
||||||
|
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
|
||||||
|
|
||||||
|
AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
|
|
||||||
|
NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送
|
||||||
|
NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带
|
||||||
|
NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
||||||
|
NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送
|
||||||
|
NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送
|
||||||
|
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
|
||||||
|
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
|
||||||
|
|
||||||
|
FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找
|
||||||
|
|
||||||
|
RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv
|
||||||
|
|
||||||
|
RecordSubdir string `json:"recordSubdir"` // 录播和弹幕文件存放在保存目录下的子文件夹名,默认为 record
|
||||||
|
MergeSubdir string `json:"mergeSubdir"` // 合并成片存放在保存目录下的子文件夹名,默认为 merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尽量转成绝对路径,失败就原样返回
|
||||||
|
func absOrSelf(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(path); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存录制文件的根目录:设置了 directory 就用它,否则用 -record 指定的文件夹
|
||||||
|
// 一律归一成绝对路径:录制侧的文件名由这里拼出来,而 web UI 的文件管理接口拼的是绝对路径,
|
||||||
|
// 两边形式不统一会让「这个文件正在写入」的判断失效(删掉正在录制的文件)
|
||||||
|
func saveRootDir() string {
|
||||||
|
if config.Directory != "" {
|
||||||
|
return absOrSelf(config.Directory)
|
||||||
|
}
|
||||||
|
if recordDir == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return absOrSelf(*recordDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录播和弹幕文件保存的文件夹
|
||||||
|
func recordSaveDir() string {
|
||||||
|
return filepath.Join(saveRootDir(), config.RecordSubdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制过程中文件直接写入的文件夹
|
||||||
|
func recordingDir() string {
|
||||||
|
if recordDir == nil {
|
||||||
|
return config.RecordSubdir
|
||||||
|
}
|
||||||
|
return filepath.Join(absOrSelf(*recordDir), config.RecordSubdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并成片保存的文件夹
|
||||||
|
func mergeSaveDir() string {
|
||||||
|
if config.MergeOutputDir != "" {
|
||||||
|
return absOrSelf(config.MergeOutputDir)
|
||||||
|
}
|
||||||
|
return filepath.Join(saveRootDir(), config.MergeSubdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认的存放子文件夹名
|
||||||
|
const (
|
||||||
|
defaultRecordSubdir = "record"
|
||||||
|
defaultMergeSubdir = "merged"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4
|
||||||
|
const defaultRecordFormat = "mp4"
|
||||||
|
|
||||||
|
// 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别
|
||||||
|
var recordFormats = map[string]struct{}{
|
||||||
|
"mp4": {},
|
||||||
|
"flv": {},
|
||||||
|
"ts": {},
|
||||||
|
"mkv": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把设置里的录播格式规整成受支持的值,空或不认识的值回退到 mp4
|
||||||
|
func normalizeRecordFormat(format string) string {
|
||||||
|
f := strings.ToLower(strings.TrimSpace(format))
|
||||||
|
if f == "" {
|
||||||
|
return defaultRecordFormat
|
||||||
|
}
|
||||||
|
if _, ok := recordFormats[f]; !ok {
|
||||||
|
lPrintWarnf("不支持的录播格式 %q,改用 %s(可选:mp4、flv、ts、mkv)", format, defaultRecordFormat)
|
||||||
|
return defaultRecordFormat
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录播文件的扩展名(含点),供录制和合并拼文件名用
|
||||||
|
func recordFileExt() string {
|
||||||
|
f := strings.ToLower(strings.TrimSpace(config.RecordFormat))
|
||||||
|
if _, ok := recordFormats[f]; !ok {
|
||||||
|
f = defaultRecordFormat
|
||||||
|
}
|
||||||
|
return "." + f
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认设置
|
||||||
|
var config = configData{
|
||||||
|
UIDs: uidList{},
|
||||||
|
WebPort: 51880,
|
||||||
|
RecordSubdir: defaultRecordSubdir,
|
||||||
|
MergeSubdir: defaultMergeSubdir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 下不能用作文件(夹)名的保留字
|
||||||
|
var reservedNames = map[string]struct{}{
|
||||||
|
"CON": {}, "PRN": {}, "AUX": {}, "NUL": {},
|
||||||
|
"COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {},
|
||||||
|
"COM6": {}, "COM7": {}, "COM8": {}, "COM9": {},
|
||||||
|
"LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {},
|
||||||
|
"LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子文件夹名不能包含路径分隔符和文件名非法字符,也不能是系统保留名
|
||||||
|
func isBadSubdir(name string) bool {
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, `\/:*?"<>|`) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Windows 下结尾的空格和点会被忽略,容易和别的名字撞车
|
||||||
|
if strings.HasSuffix(name, " ") || strings.HasSuffix(name, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, bad := reservedNames[strings.ToUpper(name)]; bad {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 streamers.crt 获取 streamer
|
||||||
|
func getStreamer(uid int) (streamer, bool) {
|
||||||
|
streamers.RLock()
|
||||||
|
defer streamers.RUnlock()
|
||||||
|
s, ok := streamers.crt[uid]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看设置文件是否存在
|
||||||
|
func isConfigFileExist(filename string) bool {
|
||||||
|
fileLocation := filepath.Join(*configDir, filename)
|
||||||
|
info, err := os.Stat(fileLocation)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
checkErr(err)
|
||||||
|
if info.IsDir() {
|
||||||
|
lPrintErr(fileLocation + " 不能是目录")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 config.json,调用方需要自己处理 streamers 的锁
|
||||||
|
func loadConfig() {
|
||||||
|
if !isConfigFileExist(configFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(configFileLocation)
|
||||||
|
checkErr(err)
|
||||||
|
|
||||||
|
if json.Valid(data) {
|
||||||
|
err = json.Unmarshal(data, &config)
|
||||||
|
checkErr(err)
|
||||||
|
} else {
|
||||||
|
lPrintErr("设置文件" + configFile + "的内容不符合 json 格式,请检查其内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效
|
||||||
|
func loadConfigForDanmuburn() {
|
||||||
|
if *configDir == "" {
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
*configDir = filepath.Dir(exePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *configDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configFileLocation = filepath.Join(*configDir, configFile)
|
||||||
|
if !isConfigFileExist(configFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadConfig()
|
||||||
|
// 设置文件里没写到的字段保持默认值
|
||||||
|
if strings.TrimSpace(config.RecordSubdir) == "" {
|
||||||
|
config.RecordSubdir = defaultRecordSubdir
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.MergeSubdir) == "" {
|
||||||
|
config.MergeSubdir = defaultMergeSubdir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// web UI 管理接口的访问口令长度(字节),编码成十六进制字符串
|
||||||
|
const authTokenBytes = 12
|
||||||
|
|
||||||
|
// 生成 web UI 管理接口的访问口令,首次启动时自动生成并写入设置文件
|
||||||
|
func ensureAuthToken() {
|
||||||
|
if strings.TrimSpace(currentAuthToken()) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, authTokenBytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
lPrintErrf("生成 web UI 管理口令失败:%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(buf)
|
||||||
|
streamers.Lock()
|
||||||
|
config.AuthToken = token
|
||||||
|
streamers.Unlock()
|
||||||
|
saveConfig()
|
||||||
|
lPrintln("已生成 web UI 管理口令(authToken):" + token)
|
||||||
|
lPrintln("打开 web UI 后需要输入该口令才能管理录制,请妥善保存;忘记时可以在 config.json 的 authToken 里查看")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前的 web UI 管理口令
|
||||||
|
func currentAuthToken() string {
|
||||||
|
streamers.RLock()
|
||||||
|
defer streamers.RUnlock()
|
||||||
|
return config.AuthToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 config.json 里的配置
|
||||||
|
func checkConfig() {
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if u.UID <= 0 {
|
||||||
|
lPrintErr(configFile + "里的 uid 必须大于 0")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.WebPort != 0 && (config.WebPort < 1024 || config.WebPort > 65535) {
|
||||||
|
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if config.Directory != "" {
|
||||||
|
info, err := os.Stat(config.Directory)
|
||||||
|
checkErr(err)
|
||||||
|
if !info.IsDir() {
|
||||||
|
lPrintErrf("%s里的directory必须是存在的文件夹:%s", configFile, config.Directory)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.MergeOutputDir != "" {
|
||||||
|
info, err := os.Stat(config.MergeOutputDir)
|
||||||
|
checkErr(err)
|
||||||
|
if !info.IsDir() {
|
||||||
|
lPrintErrf("%s里的mergeOutputDir必须是存在的文件夹:%s", configFile, config.MergeOutputDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.MergeBitrate < 0 {
|
||||||
|
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if config.DefaultBitrate < 0 {
|
||||||
|
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.RecordSubdir) == "" {
|
||||||
|
config.RecordSubdir = defaultRecordSubdir
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.MergeSubdir) == "" {
|
||||||
|
config.MergeSubdir = defaultMergeSubdir
|
||||||
|
}
|
||||||
|
if isBadSubdir(config.RecordSubdir) {
|
||||||
|
lPrintErrf("%s里的recordSubdir不能包含路径分隔符或非法字符:%s", configFile, config.RecordSubdir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if isBadSubdir(config.MergeSubdir) {
|
||||||
|
lPrintErrf("%s里的mergeSubdir不能包含路径分隔符或非法字符:%s", configFile, config.MergeSubdir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if config.RecordSubdir == config.MergeSubdir && config.MergeOutputDir == "" {
|
||||||
|
lPrintErrf("%s里的recordSubdir和mergeSubdir不能相同", configFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// 录播格式写错不致命,回退到 mp4,免得整个程序起不来
|
||||||
|
config.RecordFormat = normalizeRecordFormat(config.RecordFormat)
|
||||||
|
// ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找
|
||||||
|
if msg := checkFFmpegPath(config.FFmpegPath); msg != "" {
|
||||||
|
lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg)
|
||||||
|
config.FFmpegPath = ""
|
||||||
|
}
|
||||||
|
// 确保保存文件夹存在
|
||||||
|
for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
lPrintErrf("创建文件夹 %s 失败:%v", d, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if u.Bitrate < 0 {
|
||||||
|
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 config.UIDs 构建 streamers.crt,调用方需要自己处理 streamers 的锁
|
||||||
|
func buildStreamers() {
|
||||||
|
news := make(map[int]streamer, len(config.UIDs))
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if u.UID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 已有的主播保留名字,更新开关设置
|
||||||
|
if s, ok := streamers.crt[u.UID]; ok {
|
||||||
|
s.Danmu = u.Danmu
|
||||||
|
s.Record = u.Record
|
||||||
|
s.Bitrate = u.Bitrate
|
||||||
|
news[u.UID] = s
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := getName(u.UID)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("uid%d", u.UID)
|
||||||
|
}
|
||||||
|
news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate}
|
||||||
|
}
|
||||||
|
streamers.crt = news
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
|
||||||
|
func saveConfig() {
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
|
||||||
|
// 在锁内取快照,避免与并发的设置修改(如 removeUID)竞态,顺便去掉重复的 uid
|
||||||
|
streamers.Lock()
|
||||||
|
seen := make(map[int]struct{}, len(config.UIDs))
|
||||||
|
uids := make(uidList, 0, len(config.UIDs))
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if _, ok := seen[u.UID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[u.UID] = struct{}{}
|
||||||
|
uids = append(uids, u)
|
||||||
|
}
|
||||||
|
config.UIDs = uids
|
||||||
|
snapshot := config
|
||||||
|
streamers.Unlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
lPrintErrf("保存%s失败:%v", configFile, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先写临时文件再改名,读取方不会看到不完整的设置文件
|
||||||
|
tmp := configFileLocation + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
lPrintErrf("保存%s失败:%v", configFile, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, configFileLocation); err != nil {
|
||||||
|
lPrintErrf("保存%s失败:%v", configFile, err)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 uid 到录制列表并保存(弹幕和录播默认都开启),启动监听由设置文件监控触发
|
||||||
|
func addUID(uid int) bool {
|
||||||
|
return setStreamerFlags(uid, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 uid 移出录制列表并保存,同时停止其正在进行的录制,停止监听由设置文件监控触发
|
||||||
|
func removeUID(uid int) bool {
|
||||||
|
streamers.Lock()
|
||||||
|
found := false
|
||||||
|
for i, u := range config.UIDs {
|
||||||
|
if u.UID == uid {
|
||||||
|
config.UIDs = append(config.UIDs[:i], config.UIDs[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
if !found {
|
||||||
|
lPrintWarnf("录制列表里没有 uid 为%d的主播", uid)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConfig()
|
||||||
|
stopDanmu(uid)
|
||||||
|
stopRecord(uid)
|
||||||
|
if s, ok := getStreamer(uid); ok {
|
||||||
|
lPrintln("成功将" + s.longID() + "移出录制列表")
|
||||||
|
} else {
|
||||||
|
lPrintf("成功将 uid 为%d的主播移出录制列表", uid)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只修改指定主播的录播开关,弹幕开关保持不变,uid 不在列表里时以弹幕关闭添加
|
||||||
|
func setRecordFlag(uid int, on bool) bool {
|
||||||
|
streamers.RLock()
|
||||||
|
var danmu bool
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if u.UID == uid {
|
||||||
|
danmu = u.Danmu
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.RUnlock()
|
||||||
|
return setStreamerFlags(uid, danmu, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置指定主播的弹幕和录播开关并保存;uid 不在列表里时先添加;运行中的录制立即启停
|
||||||
|
func setStreamerFlags(uid int, danmu, record bool) bool {
|
||||||
|
if uid <= 0 {
|
||||||
|
lPrintWarn("uid 必须大于 0")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
streamers.Lock()
|
||||||
|
found := false
|
||||||
|
for i, u := range config.UIDs {
|
||||||
|
if u.UID == uid {
|
||||||
|
config.UIDs[i].Danmu = danmu
|
||||||
|
config.UIDs[i].Record = record
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
name := getName(uid)
|
||||||
|
if name == "" {
|
||||||
|
streamers.Unlock()
|
||||||
|
lPrintWarnf("不存在 uid 为%d的用户", uid)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate})
|
||||||
|
}
|
||||||
|
// 同步 streamers.crt 里的开关
|
||||||
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
|
s.Danmu, s.Record = danmu, record
|
||||||
|
streamers.crt[uid] = s
|
||||||
|
} else {
|
||||||
|
streamers.crt[uid] = streamer{UID: uid, Name: getName(uid), Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate}
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
|
||||||
|
saveConfig()
|
||||||
|
|
||||||
|
// 运行中的录制按新开关立即启停
|
||||||
|
if danmu {
|
||||||
|
startDanmu(uid)
|
||||||
|
} else {
|
||||||
|
stopDanmu(uid)
|
||||||
|
}
|
||||||
|
if record {
|
||||||
|
startRecord(uid)
|
||||||
|
} else {
|
||||||
|
stopRecord(uid)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置指定主播的录播码率上限(Kbps,0 为码率最高的直播源)并保存,正在录制时自动重启录制让新码率生效
|
||||||
|
func setBitrate(uid, bitrate int) bool {
|
||||||
|
if uid <= 0 || bitrate < 0 {
|
||||||
|
lPrintWarn("uid 必须大于 0,bitrate 不能小于 0")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
wasRecording := false
|
||||||
|
if infoList, ok := getLiveInfoByUID(uid); ok {
|
||||||
|
for _, info := range infoList {
|
||||||
|
if info.isRecording {
|
||||||
|
wasRecording = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamers.Lock()
|
||||||
|
found := false
|
||||||
|
for i, u := range config.UIDs {
|
||||||
|
if u.UID == uid {
|
||||||
|
config.UIDs[i].Bitrate = bitrate
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
|
s.Bitrate = bitrate
|
||||||
|
streamers.crt[uid] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
if !found {
|
||||||
|
lPrintWarnf("录制列表里没有 uid 为%d的主播", uid)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConfig()
|
||||||
|
|
||||||
|
// 码率只对新分段生效,正在录制时重启录制
|
||||||
|
if wasRecording {
|
||||||
|
stopRecord(uid)
|
||||||
|
startRecord(uid)
|
||||||
|
lPrintf("已将 uid %d 的录播码率上限改为 %d Kbps 并重启录制", uid, bitrate)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart
|
||||||
|
func controlStreamer(uid int, target, action string) error {
|
||||||
|
if uid <= 0 {
|
||||||
|
return fmt.Errorf("uid 必须大于 0")
|
||||||
|
}
|
||||||
|
restart := action == "restart"
|
||||||
|
if !restart && action != "stop" {
|
||||||
|
return fmt.Errorf("未知的操作:%s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamers.Lock()
|
||||||
|
s, ok := streamers.crt[uid]
|
||||||
|
if !ok {
|
||||||
|
for _, u := range config.UIDs {
|
||||||
|
if u.UID == uid {
|
||||||
|
s = streamer{UID: uid, Name: getName(uid), Danmu: u.Danmu, Record: u.Record}
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("录制列表里没有 uid 为%d的主播", uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
danmu, record := s.Danmu, s.Record
|
||||||
|
switch target {
|
||||||
|
case "danmu":
|
||||||
|
danmu = restart
|
||||||
|
case "record":
|
||||||
|
record = restart
|
||||||
|
default:
|
||||||
|
// 重新开始只作用于原本就开启的录制,不会把用户关掉的录制也打开;停止则全部停止
|
||||||
|
if !restart {
|
||||||
|
danmu, record = false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restart && !danmu && !record {
|
||||||
|
lPrintWarnf("uid 为%d的主播没有开启任何录制,跳过重新开始", uid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新开始:先停止正在录的部分,才会产生新的分段
|
||||||
|
if restart {
|
||||||
|
if danmu {
|
||||||
|
stopDanmu(uid)
|
||||||
|
}
|
||||||
|
if record {
|
||||||
|
stopRecord(uid)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
resetMergeState(uid, danmu, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !setStreamerFlags(uid, danmu, record) {
|
||||||
|
return fmt.Errorf("设置 uid 为%d的主播失败", uid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新开始录制后重置自动合并用到的状态,让新的一对文件能被合并
|
||||||
|
func resetMergeState(uid int, danmu, record bool) {
|
||||||
|
infoList, ok := getLiveInfoByUID(uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, info := range infoList {
|
||||||
|
updateLiveInfo(info.LiveID, func(i *liveInfo) {
|
||||||
|
if danmu {
|
||||||
|
i.assFile = ""
|
||||||
|
i.danmuDone = false
|
||||||
|
i.danmuStopped = false
|
||||||
|
}
|
||||||
|
if record {
|
||||||
|
i.firstRecordFile = ""
|
||||||
|
i.recordStartNs = 0
|
||||||
|
i.recordSegments = nil
|
||||||
|
i.recordDone = false
|
||||||
|
i.recordStopped = false
|
||||||
|
}
|
||||||
|
i.mergeStarted = false
|
||||||
|
i.resumeUntil = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动指定主播的循环
|
||||||
|
func startStreamerCycle(s streamer) {
|
||||||
|
start := controlMsg{s: s, c: startCycle}
|
||||||
|
sInfoMap.Lock()
|
||||||
|
if m, ok := sInfoMap.info[s.UID]; ok {
|
||||||
|
lPrintErr("sInfoMap不应该有%s的key", s.longID())
|
||||||
|
m.modify = true
|
||||||
|
} else {
|
||||||
|
sInfoMap.info[s.UID] = &streamerInfo{modify: true}
|
||||||
|
}
|
||||||
|
sInfoMap.Unlock()
|
||||||
|
mainCh <- start
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止指定主播的循环
|
||||||
|
func stopStreamerCycle(s streamer) {
|
||||||
|
stop := controlMsg{s: s, c: stopCycle}
|
||||||
|
sInfoMap.Lock()
|
||||||
|
if m, ok := sInfoMap.info[s.UID]; ok {
|
||||||
|
m.modify = true
|
||||||
|
m.ch <- stop
|
||||||
|
} else {
|
||||||
|
lPrintErr("sInfoMap没有%s的key", s.longID())
|
||||||
|
}
|
||||||
|
sInfoMap.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监控 config.json 是否被修改,是的话重新设置
|
||||||
|
func cycleConfig(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in cycleConfig(), the error is:", err)
|
||||||
|
lPrintErr("监控设置文件" + configFile + "时出错,请重启本程序")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
lPrintln("开始监控设置文件" + configFile)
|
||||||
|
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
checkErr(err)
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 很多时候保存文件会分为数段写入,避免读取未完成写入的设置文件
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
changed := isConfigFileEvent(event)
|
||||||
|
Outer:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok = <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isConfigFileEvent(event) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
default:
|
||||||
|
break Outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
lPrintln("设置文件" + configFile + "被修改,重新读取设置")
|
||||||
|
loadNewConfig()
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintErr("监控设置文件"+configFile+"时出现错误:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = watcher.Add(*configDir)
|
||||||
|
checkErr(err)
|
||||||
|
wg.Wait()
|
||||||
|
lPrintln("停止监控设置文件" + configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断 fsnotify 事件是否作用在设置文件上(路径比较在 Windows 下大小写不敏感)
|
||||||
|
func isConfigFileEvent(event fsnotify.Event) bool {
|
||||||
|
if !strings.EqualFold(filepath.Clean(event.Name), filepath.Clean(configFileLocation)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 监听的是目录而不是文件本身,改名替换(原子保存)只产生 Create/Rename 事件
|
||||||
|
return event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取修改后的 config.json,对比新旧主播列表,启动和停止相应主播的循环
|
||||||
|
func loadNewConfig() {
|
||||||
|
var toStart []streamer
|
||||||
|
var toStop []streamer
|
||||||
|
|
||||||
|
streamers.Lock()
|
||||||
|
oldCrt := make(map[int]streamer, len(streamers.crt))
|
||||||
|
for uid, s := range streamers.crt {
|
||||||
|
oldCrt[uid] = s
|
||||||
|
}
|
||||||
|
loadConfig()
|
||||||
|
checkConfig()
|
||||||
|
buildStreamers()
|
||||||
|
var changedFlags []streamer
|
||||||
|
for uid, s := range streamers.crt {
|
||||||
|
old, ok := oldCrt[uid]
|
||||||
|
if !ok {
|
||||||
|
toStart = append(toStart, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 开关有变化的主播立即按新开关启停运行中的录制
|
||||||
|
if old.Danmu != s.Danmu || old.Record != s.Record {
|
||||||
|
changedFlags = append(changedFlags, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for uid, s := range oldCrt {
|
||||||
|
if _, ok := streamers.crt[uid]; !ok {
|
||||||
|
toStop = append(toStop, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
|
||||||
|
// 必须在释放 streamers 锁之后调用:ensureAuthToken 内部要重新加锁,互斥锁不可重入会死锁
|
||||||
|
// 设置文件里的 authToken 被清空时重新生成,否则所有管理接口都会因为口令为空而拒绝访问
|
||||||
|
ensureAuthToken()
|
||||||
|
|
||||||
|
for _, s := range toStart {
|
||||||
|
lPrintln("新增" + s.longID() + "的录制设置")
|
||||||
|
startStreamerCycle(s)
|
||||||
|
}
|
||||||
|
for _, s := range toStop {
|
||||||
|
lPrintln(s.longID() + "的录制设置被删除")
|
||||||
|
stopStreamerCycle(s)
|
||||||
|
stopDanmu(s.UID)
|
||||||
|
stopRecord(s.UID)
|
||||||
|
}
|
||||||
|
for _, s := range changedFlags {
|
||||||
|
if s.Danmu {
|
||||||
|
startDanmu(s.UID)
|
||||||
|
} else {
|
||||||
|
stopDanmu(s.UID)
|
||||||
|
}
|
||||||
|
if s.Record {
|
||||||
|
startRecord(s.UID)
|
||||||
|
} else {
|
||||||
|
stopRecord(s.UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动弹幕录制产出的三个文件(原格式 ASS、昵称头像版 ASS、jsonl)
|
||||||
|
func (s *streamer) moveDanmuFiles(assFile string) {
|
||||||
|
s.moveFile(assFile)
|
||||||
|
nickFile, jsonlFile := nickJsonlFile(assFile)
|
||||||
|
s.moveFile(nickFile)
|
||||||
|
s.moveFile(jsonlFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动文件
|
||||||
|
func (s *streamer) moveFile(oldFile string) {
|
||||||
|
if oldFile == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 录播和弹幕文件统一放到保存目录下的子文件夹里
|
||||||
|
directory := recordSaveDir()
|
||||||
|
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||||
|
lPrintErrf("创建文件夹 %s 失败:%v", directory, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newFile := filepath.Join(directory, filepath.Base(oldFile))
|
||||||
|
if newFile == oldFile {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(oldFile)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
lPrintErrf("文件 %s 不存在", oldFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkErr(err)
|
||||||
|
|
||||||
|
// https://github.com/cloudfoundry/bosh-utils/blob/master/fileutil/mover.go
|
||||||
|
err = os.Rename(oldFile, newFile)
|
||||||
|
if err != nil {
|
||||||
|
le, ok := err.(*os.LinkError)
|
||||||
|
if !ok {
|
||||||
|
lPrintErrf("将文件 %s 移动到 %s 失败:%v", oldFile, newFile, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if le.Err == syscall.EXDEV || (runtime.GOOS == "windows" && le.Err == syscall.Errno(0x11)) {
|
||||||
|
inputFile, err := os.Open(oldFile)
|
||||||
|
checkErr(err)
|
||||||
|
defer inputFile.Close()
|
||||||
|
outputFile, err := os.Create(newFile)
|
||||||
|
checkErr(err)
|
||||||
|
defer outputFile.Close()
|
||||||
|
_, err = io.Copy(outputFile, inputFile)
|
||||||
|
checkErr(err)
|
||||||
|
_ = inputFile.Close()
|
||||||
|
err = os.Remove(oldFile)
|
||||||
|
checkErr(err)
|
||||||
|
} else {
|
||||||
|
lPrintErrf("将文件 %s 移动到 %s 失败:%+v", oldFile, newFile, le)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintf("成功将文件 %s 移动到 %s", oldFile, newFile)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// 循环相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 处理管道信号
|
||||||
|
func (s *streamer) handleMsg(msg controlMsg) {
|
||||||
|
switch msg.c {
|
||||||
|
case startCycle:
|
||||||
|
lPrintln("重启监听" + s.longID() + "的直播状态")
|
||||||
|
mainCh <- msg
|
||||||
|
case stopCycle:
|
||||||
|
lPrintln("退出" + s.longID() + "的循环")
|
||||||
|
sInfoMap.Lock()
|
||||||
|
if _, ok := sInfoMap.info[s.UID]; ok {
|
||||||
|
delete(sInfoMap.info, s.UID)
|
||||||
|
} else {
|
||||||
|
lPrintErr("sInfoMap没有%s的key", s.longID())
|
||||||
|
}
|
||||||
|
sInfoMap.Unlock()
|
||||||
|
case quit:
|
||||||
|
default:
|
||||||
|
lPrintErrf("未知的 controlMsg:%+v", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 掉线后又上线的判定窗口。AcFun 的接口和直播源偶尔会抖动一下,几秒到十几秒内
|
||||||
|
// 「下播又上播」基本都是网络波动而不是真的换场次,窗口内恢复就当成同一场继续录,
|
||||||
|
// 这样最后是一个成片,而不是一场直播被切成两份文件
|
||||||
|
var liveResumeWindow = 20 * time.Second
|
||||||
|
|
||||||
|
// 判断主播是否还在直播(走 wap 页面)。抽成变量是为了让测试能替换掉这次网络请求
|
||||||
|
var liveOnPage = func(s *streamer) bool { return s.isLiveOnByPage() }
|
||||||
|
|
||||||
|
// 断流后等待直播恢复:窗口内恢复返回 true,超时或用户主动停止返回 false
|
||||||
|
func (s *streamer) waitResume(ctx context.Context) bool {
|
||||||
|
deadline := time.Now().Add(liveResumeWindow)
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 页面说还在直播就继续;liveID 变了也接受,短暂下播又上播大多只是网络波动
|
||||||
|
if liveOnPage(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !time.Now().Before(deadline) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记这条记录正在等直播恢复,这段时间里不要给同一个主播另起一场
|
||||||
|
func markResumeWait(liveID string) {
|
||||||
|
until := time.Now().Add(liveResumeWindow).UnixNano()
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) { i.resumeUntil = until })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消等待标记(恢复录制、或者本场真的结束了)
|
||||||
|
func clearResumeWait(liveID string) {
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) { i.resumeUntil = 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主播是否还有没真正结束的录制会话:本场还有部分在录,或者断流后录制循环还在重连窗口里等。
|
||||||
|
// cycle() 用它判断「下播又上播」是不是同一场的延续,避免同一场被重复录成两份文件
|
||||||
|
func sessionAliveForUID(uid int) bool {
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
lInfoMap.RLock()
|
||||||
|
defer lInfoMap.RUnlock()
|
||||||
|
for _, info := range lInfoMap.info {
|
||||||
|
if info.uid != uid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.resumeUntil > now {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 本场开始录过的部分还有没结束的,说明本场还在进行;
|
||||||
|
// 录制循环退出时一定会标记 Done,所以这里不会把已经死掉的录制当成还活着
|
||||||
|
if (info.recordStarted && !info.recordDone) || (info.danmuStarted && !info.danmuDone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环获取指定主播的直播状态,自动录制直播弹幕
|
||||||
|
func (s streamer) cycle(liveID string) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in cycle(), the error is:", err)
|
||||||
|
lPrintErr(s.longID() + "的循环处理发生错误,尝试重启循环")
|
||||||
|
|
||||||
|
restart := controlMsg{s: s, c: startCycle}
|
||||||
|
mainCh <- restart
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ch := make(chan controlMsg, 20)
|
||||||
|
var modify bool
|
||||||
|
sInfoMap.Lock()
|
||||||
|
if m, ok := sInfoMap.info[s.UID]; ok {
|
||||||
|
m.ch = ch
|
||||||
|
modify = m.modify
|
||||||
|
m.modify = false
|
||||||
|
} else {
|
||||||
|
sInfoMap.info[s.UID] = &streamerInfo{ch: ch}
|
||||||
|
}
|
||||||
|
sInfoMap.Unlock()
|
||||||
|
|
||||||
|
lPrintln("开始监听" + s.longID() + "的直播状态")
|
||||||
|
|
||||||
|
var isLive bool
|
||||||
|
var lastTitle string
|
||||||
|
var offlineAt time.Time // 首次读不到直播状态的时刻,用来判断是网络波动还是真下播
|
||||||
|
var resumeWait bool // 已经提示过「本场还在继续」,避免每秒重复打日志
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
msg.liveID = liveID
|
||||||
|
s.handleMsg(msg)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if s.isLiveOn() {
|
||||||
|
isLive = true
|
||||||
|
newLiveID := s.getLiveID()
|
||||||
|
if newLiveID == "" {
|
||||||
|
lPrintErrf("无法获取%s的liveID", s.longID())
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !offlineAt.IsZero() {
|
||||||
|
lPrintf("%s 的直播状态恢复(掉线约 %.0f 秒),判定为网络波动,继续本场录制",
|
||||||
|
s.longID(), time.Since(offlineAt).Seconds())
|
||||||
|
offlineAt = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if liveID != newLiveID || modify {
|
||||||
|
// 只有真正换了场次才算新开播:改设置导致的重新开始不重复推送开播通知
|
||||||
|
newLive := liveID != newLiveID
|
||||||
|
// 同一主播的上一场还没真正结束(断流后录制循环还在重连窗口里等),
|
||||||
|
// 说明这次「上播」只是网络波动:先不新开一场,避免重复录制和重复通知。
|
||||||
|
// 这里刻意不改 liveID,等本场真的结束后下一轮再按新 liveID 开一场,
|
||||||
|
// 否则本场要是 20 秒后才结束,这一场新直播就永远开始不了了
|
||||||
|
if newLive && !modify && sessionAliveForUID(s.UID) {
|
||||||
|
if !resumeWait {
|
||||||
|
resumeWait = true
|
||||||
|
lPrintln(s.longID() + " 本场录制还在继续(判定为网络波动),不重新开始录制")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resumeWait = false
|
||||||
|
liveID = newLiveID
|
||||||
|
modify = false
|
||||||
|
title := s.getTitle()
|
||||||
|
liveURL := s.getURL()
|
||||||
|
lastTitle = title
|
||||||
|
lPrintln(s.longID() + "正在直播:" + title)
|
||||||
|
lPrintln(s.Name + "的直播观看地址:" + liveURL)
|
||||||
|
|
||||||
|
// 刷新主播数据,使用最新的开关设置
|
||||||
|
if cur, ok := getStreamer(s.UID); ok {
|
||||||
|
s = cur
|
||||||
|
}
|
||||||
|
if newLive && notifiedLiveOnce(liveID) {
|
||||||
|
sendNotify(notifyEventLiveOn, s.UID, s.Name, title, liveURL, "",
|
||||||
|
s.Name+" 开播了\n"+title+"\n"+liveURL)
|
||||||
|
}
|
||||||
|
filename := getTime() + " " + s.Name + " " + title
|
||||||
|
if s.Record {
|
||||||
|
ss := s
|
||||||
|
safeGo(ss.longID()+"的视频录制", func() { ss.initRecord(mainCtx, liveID, filename) })
|
||||||
|
// 没有 ffmpeg 时录不了视频,避免发出假的「开始录制」通知
|
||||||
|
if newLive && getFFmpeg() != "" {
|
||||||
|
sendNotify(notifyEventRecordStart, s.UID, s.Name, title, liveURL, "",
|
||||||
|
s.Name+" 开始录制直播视频\n"+title+"\n"+liveURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Danmu {
|
||||||
|
ss := s
|
||||||
|
safeGo(ss.longID()+"的弹幕录制", func() { ss.initDanmu(mainCtx, liveID, filename) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 应付 AcFun API 可能出现的 bug:主播没下播但 API 显示下播
|
||||||
|
if isLive && !s.isLiveOnByPage() {
|
||||||
|
if offlineAt.IsZero() {
|
||||||
|
// 先不下结论:网络波动也会这样,等窗口过后再确认,免得发一条假的下播通知
|
||||||
|
offlineAt = time.Now()
|
||||||
|
lPrintWarn(s.longID() + " 读不到直播状态,等待 " + itoa(int(liveResumeWindow.Seconds())) +
|
||||||
|
" 秒确认(网络波动会继续本场录制)")
|
||||||
|
} else if time.Since(offlineAt) >= liveResumeWindow {
|
||||||
|
isLive = false
|
||||||
|
offlineAt = time.Time{}
|
||||||
|
resumeWait = false
|
||||||
|
lPrintln(s.longID() + "已经下播")
|
||||||
|
sendNotify(notifyEventLiveOff, s.UID, s.Name, lastTitle, s.getURL(), "",
|
||||||
|
s.Name+" 已经下播\n最后一场:"+lastTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制全部结束、且没有再产生合并任务后,liveInfo 的保留时长
|
||||||
|
const liveInfoKeepDuration = 30 * time.Minute
|
||||||
|
|
||||||
|
// 循环检测删除 lInfoMap.info 里没有录制弹幕的 key
|
||||||
|
func cycleDelKey(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
sweepLiveInfo()
|
||||||
|
|
||||||
|
// 每分钟循环一次
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已经彻底结束的直播记录,返回删除的条数
|
||||||
|
func sweepLiveInfo() int {
|
||||||
|
now := time.Now()
|
||||||
|
nowNs := now.UnixNano()
|
||||||
|
removed := 0
|
||||||
|
lInfoMap.Lock()
|
||||||
|
defer lInfoMap.Unlock()
|
||||||
|
for liveID, info := range lInfoMap.info {
|
||||||
|
// 正在录制或正在合并的记录不能删
|
||||||
|
if info.isDanmu || info.isRecording || info.mergeStarted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 断流后录制循环还在重连窗口里等着,这条记录后面还要接着用
|
||||||
|
if info.resumeUntil > nowNs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 刚结束录制的记录要留住:弹幕和录播的结束时间可能相差较久(录播分段之间还有间隔),
|
||||||
|
// 删掉 key 会让后结束的那部分在 finishPart 里找不到记录,自动合并被静默跳过
|
||||||
|
if info.danmuDone || info.recordDone {
|
||||||
|
if info.doneAt == 0 || now.Sub(time.Unix(0, info.doneAt)) < liveInfoKeepDuration {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(lInfoMap.info, liveID)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环获取 AcFun 直播间数据
|
||||||
|
func cycleFetch(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if ok := fetchAllRooms(); ok {
|
||||||
|
if len(liveRooms.newRooms) == 0 {
|
||||||
|
lPrintWarn("没有人在直播")
|
||||||
|
}
|
||||||
|
|
||||||
|
liveRooms.Lock()
|
||||||
|
for uid, room := range liveRooms.rooms {
|
||||||
|
delete(liveRooms.rooms, uid)
|
||||||
|
liveRoomPool.Put(room)
|
||||||
|
}
|
||||||
|
liveRooms.rooms = liveRooms.newRooms
|
||||||
|
liveRooms.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每 10 秒循环一次
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
// 弹幕录制相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/orzogc/acfundanmu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 不同的视频分辨率对应的弹幕字幕设置
|
||||||
|
var subConfigs = map[int]acfundanmu.SubConfig{
|
||||||
|
0: {PlayResX: 720, PlayResY: 1280, FontSize: 60}, // 这是手机直播,有一些是 540X960
|
||||||
|
540: {PlayResX: 960, PlayResY: 540, FontSize: 30},
|
||||||
|
720: {PlayResX: 1280, PlayResY: 720, FontSize: 40},
|
||||||
|
1080: {PlayResX: 1920, PlayResY: 1080, FontSize: 60},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取弹幕字幕设置,根据直播源的码率选择
|
||||||
|
func (s *streamer) getSubConfig() (cfg acfundanmu.SubConfig, e error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
e = fmt.Errorf("getSubConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var ac *acfundanmu.AcFunLive
|
||||||
|
var err error
|
||||||
|
err = runThrice(func() error {
|
||||||
|
ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID)))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
checkErr(err)
|
||||||
|
sInfo := ac.GetStreamInfo()
|
||||||
|
|
||||||
|
// 选择码率最高的直播源
|
||||||
|
index := len(sInfo.StreamList) - 1
|
||||||
|
bitrate := sInfo.StreamList[index].Bitrate
|
||||||
|
switch {
|
||||||
|
case bitrate >= 4000:
|
||||||
|
cfg = subConfigs[1080]
|
||||||
|
case len(sInfo.StreamList) >= 2 && bitrate >= 2000:
|
||||||
|
cfg = subConfigs[720]
|
||||||
|
case bitrate == 0:
|
||||||
|
cfg = subConfigs[0]
|
||||||
|
default:
|
||||||
|
cfg = subConfigs[540]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 弹幕数据双路输出 ====================
|
||||||
|
|
||||||
|
// 弹幕持续时间(纳秒),与 acfundanmu 保持一致
|
||||||
|
const danmuShowDur = int64(10 * time.Second)
|
||||||
|
|
||||||
|
// 弹道数量上限,与 acfundanmu 的 queueLen 保持一致
|
||||||
|
const laneNum = 100
|
||||||
|
|
||||||
|
// ass 文件的 Script Info,与 acfundanmu 保持一致
|
||||||
|
const scriptInfoTmpl = `[Script Info]
|
||||||
|
; LiveID: %s
|
||||||
|
; StreamName: %s
|
||||||
|
Title: %s
|
||||||
|
ScriptType: v4.00+
|
||||||
|
Collisions: Normal
|
||||||
|
PlayResX: %d
|
||||||
|
PlayResY: %d
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
// ass 文件的 V4+ Styles,与 acfundanmu 保持一致
|
||||||
|
const stylesTmpl = `[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Danmu,Microsoft YaHei,%d,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
// ass 文件的 Events
|
||||||
|
const eventsTmpl = `[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
`
|
||||||
|
|
||||||
|
// 原格式弹幕行,与 acfundanmu 的 dialogue 保持一致
|
||||||
|
const dialogueTmpl = "Dialogue: 0,%s,%s,Danmu,%s(%d),20,20,2,,{\\move(%d,%d,%d,%d)}%s\n"
|
||||||
|
|
||||||
|
// 昵称头像版弹幕行,\an7 让锚点固定在文本左上角,前导全角空格是头像占位
|
||||||
|
const dialogueNickTmpl = "Dialogue: 0,%s,%s,Danmu,%s(%d),20,20,2,,{\\an7\\move(%d,%d,%d,%d)}%s\n"
|
||||||
|
|
||||||
|
// jsonl 首行的元数据
|
||||||
|
type danmuMeta struct {
|
||||||
|
PlayResX int `json:"playResX"`
|
||||||
|
PlayResY int `json:"playResY"`
|
||||||
|
FontSize int `json:"fontSize"`
|
||||||
|
StartTime int64 `json:"startTime"` // 录制开始的时间,纳秒 Unix
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonl 的弹幕记录
|
||||||
|
type danmuRecord struct {
|
||||||
|
Time float64 `json:"t"` // 相对录制开始的秒数
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"` // 头像 URL,弹幕协议自带
|
||||||
|
Content string `json:"content"`
|
||||||
|
Lane int `json:"lane"` // 在昵称头像版 ASS 里的弹道行
|
||||||
|
Width int `json:"width"` // 昵称头像版文本的估算像素宽度
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹道的占用状态(纳秒)
|
||||||
|
type laneState struct {
|
||||||
|
appear int64
|
||||||
|
emerge int64
|
||||||
|
disappear int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ass 文件里弹幕出现或消失的时间格式,与 acfundanmu 保持一致
|
||||||
|
type assTime int64
|
||||||
|
|
||||||
|
func (t assTime) String() string {
|
||||||
|
if t < 0 {
|
||||||
|
return "0:00:00.00"
|
||||||
|
}
|
||||||
|
tt := time.Unix(0, int64(t)).UTC()
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d.%02d", tt.Hour(), tt.Minute(), tt.Second(), tt.Nanosecond()/1e7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 估算文本的渲染宽度,CJK 及全角字符按全宽、其余按半宽计算
|
||||||
|
func textWidth(text string, fontSize int) int {
|
||||||
|
w := 0
|
||||||
|
for _, r := range text {
|
||||||
|
if r >= 0x2E80 {
|
||||||
|
w += fontSize
|
||||||
|
} else {
|
||||||
|
w += fontSize / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在弹道数组里寻找空闲弹道,返回弹道行号,没有空闲返回 -1,公式与 acfundanmu 保持一致
|
||||||
|
func placeLane(last []laneState, dt laneState, leftTime int64) int {
|
||||||
|
for i, t := range last {
|
||||||
|
if dt.appear > t.emerge && leftTime > t.disappear {
|
||||||
|
last[i] = dt
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕数据双路输出:原格式 ASS + 昵称头像版 ASS + jsonl
|
||||||
|
type danmuWriter struct {
|
||||||
|
cfg acfundanmu.SubConfig
|
||||||
|
ass *os.File // 原格式
|
||||||
|
nick *os.File // 昵称头像版
|
||||||
|
jsonl *os.File
|
||||||
|
lastO []laneState // 原格式的弹道状态
|
||||||
|
lastN []laneState // 昵称头像版的弹道状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回昵称头像版 ASS 和 jsonl 的文件路径
|
||||||
|
func nickJsonlFile(assFile string) (string, string) {
|
||||||
|
base := strings.TrimSuffix(assFile, ".ass")
|
||||||
|
return base + ".nick.ass", base + ".jsonl"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建弹幕写入器,覆盖写文件头,重启续录时沿用同一个写入器即可
|
||||||
|
func newDanmuWriter(liveID, streamName string, cfg acfundanmu.SubConfig, assFile string) *danmuWriter {
|
||||||
|
nickFile, jsonlFile := nickJsonlFile(assFile)
|
||||||
|
w := &danmuWriter{
|
||||||
|
cfg: cfg,
|
||||||
|
lastO: make([]laneState, laneNum),
|
||||||
|
lastN: make([]laneState, laneNum),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
w.ass, err = os.OpenFile(assFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
checkErr(err)
|
||||||
|
w.nick, err = os.OpenFile(nickFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
checkErr(err)
|
||||||
|
w.jsonl, err = os.OpenFile(jsonlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
checkErr(err)
|
||||||
|
|
||||||
|
fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY)
|
||||||
|
fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize)
|
||||||
|
fmt.Fprint(w.ass, eventsTmpl)
|
||||||
|
fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY)
|
||||||
|
fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize)
|
||||||
|
fmt.Fprint(w.nick, eventsTmpl)
|
||||||
|
|
||||||
|
meta, err := json.Marshal(danmuMeta{PlayResX: cfg.PlayResX, PlayResY: cfg.PlayResY, FontSize: cfg.FontSize, StartTime: cfg.StartTime})
|
||||||
|
checkErr(err)
|
||||||
|
w.jsonl.Write(meta)
|
||||||
|
w.jsonl.Write([]byte("\n"))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入一条弹幕
|
||||||
|
func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
||||||
|
s := w.cfg
|
||||||
|
sendTime := c.SendTime * 1e6
|
||||||
|
rel := sendTime - s.StartTime
|
||||||
|
|
||||||
|
// 原格式 ASS,碰撞避让和坐标公式与 acfundanmu 完全一致
|
||||||
|
length := utf8.RuneCountInString(c.Content) * s.FontSize
|
||||||
|
dtO := laneState{
|
||||||
|
appear: rel,
|
||||||
|
emerge: rel + (int64(length)*danmuShowDur)/int64(s.PlayResX+length),
|
||||||
|
disappear: rel + danmuShowDur,
|
||||||
|
}
|
||||||
|
leftO := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+length)
|
||||||
|
if i := placeLane(w.lastO, dtO, leftO); i >= 0 {
|
||||||
|
fmt.Fprintf(w.ass, dialogueTmpl,
|
||||||
|
assTime(dtO.appear), assTime(dtO.disappear),
|
||||||
|
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
||||||
|
s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 昵称头像版 ASS,\an7 锚点在文本左上角,头像由 ffmpeg 叠在文字左侧,不依赖字体渲染宽度
|
||||||
|
text := c.Nickname + "(" + strconv.FormatInt(c.UserID, 10) + "):" + c.Content
|
||||||
|
width := textWidth(text, s.FontSize)
|
||||||
|
avW, avGap := avatarSize(s.FontSize), avatarGap(s.FontSize)
|
||||||
|
total := avW + avGap + width // 头像、间隔和文字的总宽度
|
||||||
|
dtN := laneState{
|
||||||
|
appear: rel,
|
||||||
|
emerge: rel + (int64(total)*danmuShowDur)/int64(s.PlayResX+total),
|
||||||
|
disappear: rel + danmuShowDur,
|
||||||
|
}
|
||||||
|
leftN := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+total)
|
||||||
|
if i := placeLane(w.lastN, dtN, leftN); i >= 0 {
|
||||||
|
fmt.Fprintf(w.nick, dialogueNickTmpl,
|
||||||
|
assTime(dtN.appear), assTime(dtN.disappear),
|
||||||
|
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
||||||
|
s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text)
|
||||||
|
rec := danmuRecord{
|
||||||
|
Time: float64(rel) / 1e9,
|
||||||
|
UID: c.UserID,
|
||||||
|
Name: c.Nickname,
|
||||||
|
Avatar: c.Avatar,
|
||||||
|
Content: c.Content,
|
||||||
|
Lane: i,
|
||||||
|
Width: width,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(rec)
|
||||||
|
checkErr(err)
|
||||||
|
w.jsonl.Write(b)
|
||||||
|
w.jsonl.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费弹幕队列直到结束,返回说明队列已结束(出错或主播下播)
|
||||||
|
func (w *danmuWriter) consume(ctx context.Context, ac *acfundanmu.AcFunLive) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
danmu := ac.GetDanmu()
|
||||||
|
if danmu == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, d := range danmu {
|
||||||
|
if c, ok := d.(*acfundanmu.Comment); ok {
|
||||||
|
w.write(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭所有文件
|
||||||
|
func (w *danmuWriter) close() {
|
||||||
|
w.ass.Close()
|
||||||
|
w.nick.Close()
|
||||||
|
w.jsonl.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 录制流程 ====================
|
||||||
|
|
||||||
|
// 录制直播弹幕
|
||||||
|
func (s streamer) getDanmu(ctx context.Context, info liveInfo) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in getDanmu(), the error is:", err)
|
||||||
|
lPrintErrf("录制%s的直播弹幕发生错误,如要重启录制,请运行 startdanmu %d", s.longID(), s.UID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
lPrintln("开始录制" + s.longID() + "的直播弹幕")
|
||||||
|
lPrintln("本次录制的 ass 文件保存在" + info.assFile)
|
||||||
|
lPrintf("如果想提前结束录制%s的直播弹幕,运行 stopdanmu %d", s.longID(), s.UID)
|
||||||
|
|
||||||
|
defer s.moveDanmuFiles(info.assFile)
|
||||||
|
|
||||||
|
ac, err := acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID)))
|
||||||
|
checkErr(err)
|
||||||
|
_ = ac.StartDanmu(ctx, false)
|
||||||
|
streamInfo := ac.GetStreamInfo()
|
||||||
|
w := newDanmuWriter(streamInfo.LiveID, streamInfo.StreamName, info.cfg, info.assFile)
|
||||||
|
defer w.close()
|
||||||
|
w.consume(ctx, ac)
|
||||||
|
|
||||||
|
// 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时沿用同一个写入器接着写,
|
||||||
|
// 弹幕时间轴和视频分段保持一致,最终和视频一起合成同一个成片
|
||||||
|
fails := 0
|
||||||
|
for {
|
||||||
|
markResumeWait(info.LiveID)
|
||||||
|
resumed := s.waitResume(ctx)
|
||||||
|
clearResumeWait(info.LiveID)
|
||||||
|
if !resumed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fails >= maxResumeRetry {
|
||||||
|
lPrintWarnf("重连%s的直播弹幕连续失败 %d 次,结束本场弹幕录制", s.longID(), fails)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lPrintWarn("因意外结束录制" + s.longID() + "的直播弹幕,尝试重启录制")
|
||||||
|
begin := time.Now()
|
||||||
|
ac, err := acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID)))
|
||||||
|
if err != nil {
|
||||||
|
lPrintErrf("重连%s的直播弹幕失败:%v", s.longID(), err)
|
||||||
|
fails++
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = ac.StartDanmu(ctx, false)
|
||||||
|
w.consume(ctx, ac)
|
||||||
|
// 连上几秒就断开说明这次重连没成功,计数避免一直高频重连
|
||||||
|
if time.Since(begin) < 5*time.Second {
|
||||||
|
fails++
|
||||||
|
} else {
|
||||||
|
fails = 0
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintln(s.longID() + "的直播弹幕录制已经结束")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出直播弹幕录制相关操作
|
||||||
|
func (s streamer) quitDanmu(liveID string) {
|
||||||
|
finishDanmuPart(liveID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化弹幕录制
|
||||||
|
func (s streamer) initDanmu(ctx context.Context, liveID, filename string) {
|
||||||
|
dctx, dcancel := context.WithCancel(ctx)
|
||||||
|
defer dcancel()
|
||||||
|
if info, ok := getLiveInfo(liveID); ok && info.isDanmu {
|
||||||
|
lPrintWarnf("已经在录制%s的直播弹幕,如要重启录制,请先运行 stopdanmu %d", s.longID(), s.UID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取弹幕字幕设置
|
||||||
|
cfg, err := s.getSubConfig()
|
||||||
|
if err != nil {
|
||||||
|
lPrintErr(err)
|
||||||
|
lPrintErrf("无法获取%s的直播源,退出录制直播弹幕,请确定主播正在直播,如要重启录制直播弹幕,请运行 startdanmu %d", s.longID(), s.UID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := liveInfo{LiveID: liveID, uid: s.UID, cfg: cfg}
|
||||||
|
|
||||||
|
assFile := transFilename(filename)
|
||||||
|
if assFile == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info.assFile = assFile + ".ass"
|
||||||
|
info.cfg.Title = filepath.Base(assFile)
|
||||||
|
info.cfg.StartTime = time.Now().UnixNano()
|
||||||
|
info.isDanmu = true
|
||||||
|
info.danmuCancel = dcancel
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) {
|
||||||
|
i.uid = info.uid
|
||||||
|
i.isDanmu = true
|
||||||
|
i.danmuStarted = true
|
||||||
|
i.danmuCancel = info.danmuCancel
|
||||||
|
i.assFile = info.assFile
|
||||||
|
i.cfg = info.cfg
|
||||||
|
})
|
||||||
|
defer s.quitDanmu(info.LiveID)
|
||||||
|
|
||||||
|
s.getDanmu(dctx, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时录制指定主播的直播弹幕
|
||||||
|
func startDanmu(uid int) bool {
|
||||||
|
s, ok := getStreamer(uid)
|
||||||
|
if !ok {
|
||||||
|
name := getName(uid)
|
||||||
|
if name == "" {
|
||||||
|
lPrintWarnf("不存在 uid 为%d的用户", uid)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s = streamer{UID: uid, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
liveID := getLiveID(uid)
|
||||||
|
if liveID == "" {
|
||||||
|
lPrintErr(s.longID() + "不在直播,取消录制直播弹幕")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isDanmu(liveID) {
|
||||||
|
lPrintWarnf("已经在录制%s的直播弹幕,如要重启录制,请先运行 stopdanmu %d", s.longID(), s.UID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := getTime() + " " + s.Name + " " + s.getTitle()
|
||||||
|
|
||||||
|
// goroutine 是为了快速返回
|
||||||
|
safeGo(s.longID()+"的弹幕录制", func() { s.initDanmu(mainCtx, liveID, filename) })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止录制指定主播的直播弹幕
|
||||||
|
func stopDanmu(uid int) bool {
|
||||||
|
infoList, ok := getLiveInfoByUID(uid)
|
||||||
|
if !ok {
|
||||||
|
lPrintWarnf("没有在录制 uid 为%d的主播的直播弹幕", uid)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, info := range infoList {
|
||||||
|
if info.isDanmu {
|
||||||
|
lPrintf("开始停止录制%s的liveID为%s直播弹幕", longID(uid), info.LiveID)
|
||||||
|
updateLiveInfo(info.LiveID, func(i *liveInfo) { i.danmuStopped = true })
|
||||||
|
info.danmuCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
+836
@@ -0,0 +1,836 @@
|
|||||||
|
// 弹幕合成相关:读取 jsonl,下载头像并圆形裁切,调用 ffmpeg 合成带头像的弹幕层
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 头像缓存目录名
|
||||||
|
const avatarCacheDir = "avatar_cache"
|
||||||
|
|
||||||
|
// 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用
|
||||||
|
var burnBitrate int
|
||||||
|
|
||||||
|
// 缓存头像的边长
|
||||||
|
const avatarCacheSize = 256
|
||||||
|
|
||||||
|
// jsonl 里没有弹幕记录
|
||||||
|
var errNoDanmu = errors.New("jsonl 里没有弹幕")
|
||||||
|
|
||||||
|
// 弹幕在屏幕上的停留时长(秒),与 danmu.go 里的 danmuShowDur 一致
|
||||||
|
const danmuShowSeconds = 10.0
|
||||||
|
|
||||||
|
// 每块最多包含的弹幕条数
|
||||||
|
// 每条弹幕都要生成一段 overlay 并作为 -filter_complex 的单个参数传给 ffmpeg,
|
||||||
|
// 而 Windows 的命令行总长上限是 32767 字符(Linux 是单个参数 128KB),
|
||||||
|
// 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。
|
||||||
|
const maxDanmuPerChunk = 80
|
||||||
|
|
||||||
|
// 分块渲染产生的临时文件前缀
|
||||||
|
const chunkTempPrefix = "acfunlive_chunk_"
|
||||||
|
|
||||||
|
// 一个待渲染的弹幕块
|
||||||
|
type danmuChunk struct {
|
||||||
|
start float64 // 在视频时间轴上的起点(秒)
|
||||||
|
end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾
|
||||||
|
records []danmuRecord // 属于这一块的弹幕
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染分块时共用的参数
|
||||||
|
type burnContext struct {
|
||||||
|
ffmpegFile string
|
||||||
|
videoFile string // 为空表示输出透明头像层
|
||||||
|
outFile string
|
||||||
|
offset float64
|
||||||
|
meta danmuMeta
|
||||||
|
avatars map[int64]string
|
||||||
|
bitrate int
|
||||||
|
dir string
|
||||||
|
encoder string // 已经验证可用的编码器,分块时避免每块都重新回退
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像直径,与文字的实际墨迹高度一致(字号是 em 框,墨迹约为 0.8 倍)
|
||||||
|
func avatarSize(fontSize int) int {
|
||||||
|
return fontSize * 4 / 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像和弹幕文字之间的间隔
|
||||||
|
func avatarGap(fontSize int) int {
|
||||||
|
return fontSize / 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕合成入口(命令行)
|
||||||
|
// 弹幕文件可以给 .jsonl,也可以给 .ass(先还原成 jsonl 再合成)
|
||||||
|
func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||||
|
src, err := prepareDanmuSource(danmuFile, true)
|
||||||
|
if err != nil {
|
||||||
|
if err == errNoDanmu {
|
||||||
|
lPrintWarn(err.Error())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
lPrintErr(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
|
||||||
|
if err == errNoDanmu {
|
||||||
|
lPrintWarn(err.Error())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
lPrintErr(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
||||||
|
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频
|
||||||
|
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||||
|
jsonlFile, err := filepath.Abs(jsonlFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(jsonlFile); err != nil {
|
||||||
|
return fmt.Errorf("jsonl 文件不存在:%s", jsonlFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 jsonl,首行是元数据
|
||||||
|
f, err := os.Open(jsonlFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
var meta danmuMeta
|
||||||
|
var records []danmuRecord
|
||||||
|
first := true
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if first {
|
||||||
|
if err := json.Unmarshal(line, &meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r danmuRecord
|
||||||
|
if json.Unmarshal(line, &r) == nil && r.Content != "" {
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if meta.PlayResX == 0 || meta.FontSize == 0 {
|
||||||
|
return errors.New("jsonl 文件格式不对,首行必须包含 playResX、playResY、fontSize")
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return errNoDanmu
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定输出文件和模式:有视频就把弹幕压进视频,没有视频就输出透明头像层
|
||||||
|
burnMode := videoFile != ""
|
||||||
|
if burnMode {
|
||||||
|
videoFile, err = filepath.Abs(videoFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(videoFile); err != nil {
|
||||||
|
return fmt.Errorf("录播视频不存在:%s", videoFile)
|
||||||
|
}
|
||||||
|
if outFile == "" {
|
||||||
|
// 成片默认放到单独的文件夹里,不再和录播、弹幕混在一起
|
||||||
|
outFile = filepath.Join(mergeSaveDir(), filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if outFile == "" {
|
||||||
|
outFile = strings.TrimSuffix(jsonlFile, ".jsonl") + "_avatar.mov"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outFile, err = filepath.Abs(outFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉
|
||||||
|
videoDuration := 0.0
|
||||||
|
if burnMode {
|
||||||
|
videoDuration = probeDuration(ffmpegFile, videoFile)
|
||||||
|
if videoDuration > 0 {
|
||||||
|
kept := records[:0]
|
||||||
|
for _, r := range records {
|
||||||
|
if r.Time+offset < videoDuration {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dropped := len(records) - len(kept); dropped > 0 {
|
||||||
|
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
|
||||||
|
}
|
||||||
|
records = kept
|
||||||
|
if len(records) == 0 {
|
||||||
|
return errNoDanmu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载并圆形裁切头像
|
||||||
|
avatarPaths := make(map[int64]string)
|
||||||
|
dir := filepath.Dir(jsonlFile)
|
||||||
|
cacheDir := filepath.Join(dir, avatarCacheDir)
|
||||||
|
// 先去重,顺便记下每个用户自带的头像链接
|
||||||
|
urlByUID := make(map[int64]string)
|
||||||
|
uids := make([]int64, 0, 64)
|
||||||
|
for _, r := range records {
|
||||||
|
if r.UID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if old, ok := urlByUID[r.UID]; !ok {
|
||||||
|
uids = append(uids, r.UID)
|
||||||
|
urlByUID[r.UID] = r.Avatar
|
||||||
|
} else if old == "" && r.Avatar != "" {
|
||||||
|
// 同一个用户的前几条可能没有头像链接,后面带上就补上
|
||||||
|
urlByUID[r.UID] = r.Avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
looked := 0
|
||||||
|
for i, uid := range uids {
|
||||||
|
url := urlByUID[uid]
|
||||||
|
if url == "" {
|
||||||
|
// 从 .ass 还原出来的弹幕没有头像链接,按 uid 反查一次
|
||||||
|
url = avatarURLByUID(uid)
|
||||||
|
looked++
|
||||||
|
// 反查比较慢,定期报一下进度,避免看起来像卡住了
|
||||||
|
if looked%50 == 0 || i+1 == len(uids) {
|
||||||
|
lPrintf("正在获取头像:已处理 %d/%d 个用户", i+1, len(uids))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path := ensureAvatar(uid, url, cacheDir); path != "" {
|
||||||
|
avatarPaths[uid] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if looked > 0 {
|
||||||
|
lPrintf("有 %d 个用户没有自带头像链接(多半是从 .ass 还原的弹幕),已按 uid 向 AcFun 反查", looked)
|
||||||
|
}
|
||||||
|
if len(avatarPaths) == 0 {
|
||||||
|
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &burnContext{
|
||||||
|
ffmpegFile: ffmpegFile,
|
||||||
|
videoFile: videoFile,
|
||||||
|
outFile: outFile,
|
||||||
|
offset: offset,
|
||||||
|
meta: meta,
|
||||||
|
avatars: avatarPaths,
|
||||||
|
bitrate: config.MergeBitrate + burnBitrate,
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||||
|
if len(chunks) <= 1 {
|
||||||
|
lPrintf("共 %d 条弹幕,%d 个用户的头像可用,开始合成", len(records), len(avatarPaths))
|
||||||
|
return ctx.burnSingle(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单次渲染上限(%d 条),分 %d 块渲染后拼接",
|
||||||
|
len(records), len(avatarPaths), maxDanmuPerChunk, len(chunks))
|
||||||
|
return ctx.burnChunked(chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||||
|
func probeDuration(ffmpegFile, path string) float64 {
|
||||||
|
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stderr = &buf
|
||||||
|
// 没指定输出文件时 ffmpeg 会返回非 0,但时长信息已经打印出来了
|
||||||
|
_ = cmd.Run()
|
||||||
|
|
||||||
|
m := durationRe.FindStringSubmatch(buf.String())
|
||||||
|
if len(m) != 4 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
h, _ := strconv.Atoi(m[1])
|
||||||
|
mi, _ := strconv.Atoi(m[2])
|
||||||
|
s, _ := strconv.ParseFloat(m[3], 64)
|
||||||
|
return float64(h)*3600 + float64(mi)*60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`)
|
||||||
|
|
||||||
|
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
|
||||||
|
func (c *burnContext) burnSingle(records []danmuRecord) error {
|
||||||
|
chunk := danmuChunk{start: 0, end: math.Inf(1), records: records}
|
||||||
|
// 临时字幕一律用纯 ASCII 文件名:libass 在部分环境下打不开非 ASCII 的文件名
|
||||||
|
assName := chunkTempPrefix + "single.ass"
|
||||||
|
assPath := filepath.Join(c.dir, assName)
|
||||||
|
if err := writeChunkAss(assPath, c.meta, chunk, c.offset,
|
||||||
|
avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)); err != nil {
|
||||||
|
return fmt.Errorf("生成字幕文件失败:%v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(assPath)
|
||||||
|
|
||||||
|
return c.renderChunk(chunk, assName, c.outFile, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分块渲染:每块单独渲染成一个小片,最后用 concat 拼成完整成片
|
||||||
|
func (c *burnContext) burnChunked(chunks []danmuChunk) error {
|
||||||
|
ext := ".mov"
|
||||||
|
if c.videoFile != "" {
|
||||||
|
ext = ".mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
defer func() {
|
||||||
|
for _, p := range parts {
|
||||||
|
_ = os.Remove(p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ad, gap := avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)
|
||||||
|
for i, ch := range chunks {
|
||||||
|
// 每块的字幕单独生成,只包含这一块时间范围内的弹幕
|
||||||
|
assName := fmt.Sprintf("%schunk_%04d.ass", chunkTempPrefix, i)
|
||||||
|
assPath := filepath.Join(c.dir, assName)
|
||||||
|
if err := writeChunkAss(assPath, c.meta, ch, c.offset, ad, gap); err != nil {
|
||||||
|
return fmt.Errorf("生成第 %d 块的字幕失败:%v", i+1, err)
|
||||||
|
}
|
||||||
|
defer os.Remove(assPath)
|
||||||
|
|
||||||
|
part := filepath.Join(c.dir, fmt.Sprintf("%spart_%04d%s", chunkTempPrefix, i, ext))
|
||||||
|
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,%d 条弹幕)",
|
||||||
|
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records))
|
||||||
|
if err := c.renderChunk(ch, assName, part, true); err != nil {
|
||||||
|
return fmt.Errorf("渲染第 %d/%d 块失败:%v", i+1, len(chunks), err)
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintf("正在把 %d 个分块拼接成完整成片", len(parts))
|
||||||
|
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧
|
||||||
|
// maxEnd 大于 0 时表示视频总时长,块尾不会超过它
|
||||||
|
func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChunk {
|
||||||
|
n := len(records)
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n <= maxDanmuPerChunk {
|
||||||
|
return []danmuChunk{{start: 0, end: math.Inf(1), records: records}}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := make([]danmuChunk, 0, n/maxDanmuPerChunk+1)
|
||||||
|
i := 0
|
||||||
|
start := 0.0
|
||||||
|
for i < n {
|
||||||
|
limit := i + maxDanmuPerChunk
|
||||||
|
if limit >= n {
|
||||||
|
chunks = append(chunks, danmuChunk{start: start, end: math.Inf(1), records: records[i:]})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 块尾取第 limit 条弹幕显示结束的时刻
|
||||||
|
end := records[limit-1].Time + offset + danmuShowSeconds
|
||||||
|
if maxEnd > 0 && end > maxEnd {
|
||||||
|
end = maxEnd
|
||||||
|
}
|
||||||
|
if end <= start {
|
||||||
|
end = start + danmuShowSeconds
|
||||||
|
}
|
||||||
|
// 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块
|
||||||
|
j := limit
|
||||||
|
for j < n && records[j].Time+offset < end {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
chunks = append(chunks, danmuChunk{start: start, end: end, records: records[i:j]})
|
||||||
|
i = j
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为分块渲染生成只包含该块弹幕的字幕文件(相对块起点计时)
|
||||||
|
func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64, avW, avGap int) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// ASS 必须带 [Script Info] 开头,否则 libass 探测格式会失败
|
||||||
|
fmt.Fprintf(f, scriptInfoTmpl, "", "", filepath.Base(path), meta.PlayResX, meta.PlayResY)
|
||||||
|
fmt.Fprintf(f, stylesTmpl, meta.FontSize)
|
||||||
|
fmt.Fprint(f, eventsTmpl)
|
||||||
|
|
||||||
|
for _, r := range chunk.records {
|
||||||
|
t0 := r.Time + offset
|
||||||
|
visStart := math.Max(t0, chunk.start)
|
||||||
|
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
|
||||||
|
if visEnd <= visStart {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := r.Name + "(" + strconv.FormatInt(r.UID, 10) + "):" + r.Content
|
||||||
|
y := meta.FontSize * r.Lane
|
||||||
|
fmt.Fprintf(f, dialogueNickTmpl,
|
||||||
|
assTime(int64((visStart-chunk.start)*1e9)), assTime(int64((visEnd-chunk.start)*1e9)),
|
||||||
|
strings.ReplaceAll(r.Name, ",", " "), r.UID,
|
||||||
|
meta.PlayResX+avW+avGap, y, -r.Width, y, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
|
||||||
|
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
|
||||||
|
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool) error {
|
||||||
|
burnMode := c.videoFile != ""
|
||||||
|
|
||||||
|
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)
|
||||||
|
uidOrder := []int64{}
|
||||||
|
uidCount := make(map[int64]int)
|
||||||
|
uidInput := make(map[int64]int)
|
||||||
|
for _, r := range chunk.records {
|
||||||
|
if _, ok := c.avatars[r.UID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := uidCount[r.UID]; !seen {
|
||||||
|
uidOrder = append(uidOrder, r.UID)
|
||||||
|
uidInput[r.UID] = len(uidOrder)
|
||||||
|
}
|
||||||
|
uidCount[r.UID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-y"}
|
||||||
|
if burnMode {
|
||||||
|
if cut {
|
||||||
|
// -ss 放在 -i 之前是快速定位,配合重编码能精确切到指定时间点
|
||||||
|
args = append(args, "-ss", formatSeconds(chunk.start))
|
||||||
|
}
|
||||||
|
args = append(args, "-i", c.videoFile)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-f", "lavfi", "-i",
|
||||||
|
fmt.Sprintf("color=c=black@0:s=%dx%d:d=%.2f:r=30,format=rgba",
|
||||||
|
c.meta.PlayResX, c.meta.PlayResY, c.chunkDuration(chunk)+0.5))
|
||||||
|
}
|
||||||
|
for _, uid := range uidOrder {
|
||||||
|
args = append(args, "-i", c.avatars[uid])
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := &strings.Builder{}
|
||||||
|
prev := "0:v"
|
||||||
|
if burnMode {
|
||||||
|
fmt.Fprintf(fc, "[%s]subtitles=filename='%s'[b0];\n", prev, assBase)
|
||||||
|
prev = "b0"
|
||||||
|
}
|
||||||
|
// 把每个头像缩放到弹幕字号对应的直径,同一个头像被多条弹幕使用时用 split 复制
|
||||||
|
ad := avatarSize(c.meta.FontSize)
|
||||||
|
gap := avatarGap(c.meta.FontSize)
|
||||||
|
for _, uid := range uidOrder {
|
||||||
|
idx := uidInput[uid]
|
||||||
|
if uidCount[uid] == 1 {
|
||||||
|
fmt.Fprintf(fc, "[%d:v]scale=%d:%d[av%d];\n", idx, ad, ad, idx)
|
||||||
|
} else {
|
||||||
|
outs := &strings.Builder{}
|
||||||
|
for j := 0; j < uidCount[uid]; j++ {
|
||||||
|
fmt.Fprintf(outs, "[av%d_%d]", idx, j)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(fc, "[%d:v]scale=%d:%d,split=%d%s;\n", idx, ad, ad, uidCount[uid], outs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uidCopy := make(map[int64]int) // 每个头像当前用掉的份数
|
||||||
|
for n, r := range chunk.records {
|
||||||
|
idx, ok := uidInput[r.UID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
avLabel := strconv.Itoa(idx)
|
||||||
|
if uidCount[r.UID] > 1 {
|
||||||
|
avLabel = strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
|
||||||
|
uidCopy[r.UID]++
|
||||||
|
}
|
||||||
|
t0 := r.Time + c.offset
|
||||||
|
visStart := math.Max(t0, chunk.start)
|
||||||
|
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
|
||||||
|
if visEnd <= visStart {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
|
||||||
|
// 位置公式 x = 画面宽 - (画面宽+总宽)*(t-t0)/10,这里 t 是块内时间,把 t0 也换算到块内坐标
|
||||||
|
total := ad + gap + r.Width
|
||||||
|
shift := chunk.start - t0
|
||||||
|
x := fmt.Sprintf("%d-(%d+%d)*(t%+.3f)/10", c.meta.PlayResX, c.meta.PlayResX, total, shift)
|
||||||
|
y := c.meta.FontSize*r.Lane + (c.meta.FontSize-ad)/2 // 头像与文字墨迹垂直居中
|
||||||
|
outLabel := fmt.Sprintf("s%d", n)
|
||||||
|
fmt.Fprintf(fc, "[%s][av%s]overlay=x='%s':y=%d:enable='between(t,%.3f,%.3f)'[%s];\n",
|
||||||
|
prev, avLabel, x, y, visStart-chunk.start, visEnd-chunk.start, outLabel)
|
||||||
|
prev = outLabel
|
||||||
|
}
|
||||||
|
if burnMode {
|
||||||
|
fmt.Fprintf(fc, "[%s]format=yuv420p[v]", prev)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(fc, "[%s]format=yuva444p10le[v]", prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-filter_complex", fc.String(), "-map", "[v]")
|
||||||
|
if burnMode {
|
||||||
|
args = append(args, "-map", "0:a?")
|
||||||
|
if cut {
|
||||||
|
// 分块切割点的音频不在帧边界上,必须重编码,否则拼接后会累积音画错位
|
||||||
|
args = append(args, "-c:a", "aac", "-b:a", "192k")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:a", "copy")
|
||||||
|
}
|
||||||
|
args = append(args, "-movflags", "+faststart")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le")
|
||||||
|
}
|
||||||
|
if cut && !math.IsInf(chunk.end, 1) {
|
||||||
|
args = append(args, "-t", formatSeconds(chunk.end-chunk.start))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !burnMode {
|
||||||
|
// 透明头像层模式固定用 ProRes 4444,不能再追加编码器覆盖
|
||||||
|
full := append(append([]string{}, args...), out)
|
||||||
|
lPrintln("开始运行 ffmpeg 合成透明头像层,输出到 " + out)
|
||||||
|
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 烧录模式:优先 Intel QSV 硬件编码,运行失败自动回退 libx264
|
||||||
|
encoders := buildEncoders(c.bitrate, c.ffmpegFile)
|
||||||
|
// 已经在这条流水线里验证过可用的编码器直接复用,分块时不用每块都回退一次
|
||||||
|
begin := 0
|
||||||
|
if c.encoder != "" {
|
||||||
|
for i, enc := range encoders {
|
||||||
|
if enc.name == c.encoder {
|
||||||
|
begin = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for i := begin; i < len(encoders); i++ {
|
||||||
|
enc := encoders[i]
|
||||||
|
full := append(append([]string{}, args...), "-c:v", enc.name)
|
||||||
|
full = append(full, enc.opts...)
|
||||||
|
full = append(full, out)
|
||||||
|
if i == begin {
|
||||||
|
lPrintf("开始运行 ffmpeg 合成(编码器 %s),输出到 %s", enc.name, out)
|
||||||
|
}
|
||||||
|
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if i+1 < len(encoders) {
|
||||||
|
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ffmpeg 运行失败:%v", lastErr)
|
||||||
|
}
|
||||||
|
c.encoder = enc.name
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ffmpeg 运行失败:%v", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这一块需要渲染的时长(用于透明头像层模式生成等长的黑底)
|
||||||
|
func (c *burnContext) chunkDuration(chunk danmuChunk) float64 {
|
||||||
|
if !math.IsInf(chunk.end, 1) {
|
||||||
|
return chunk.end - chunk.start
|
||||||
|
}
|
||||||
|
maxT := chunk.start
|
||||||
|
for _, r := range chunk.records {
|
||||||
|
if t := r.Time + c.offset + danmuShowSeconds; t > maxT {
|
||||||
|
maxT = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxT - chunk.start
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合成用的视频编码器配置
|
||||||
|
type videoEncoder struct {
|
||||||
|
name string
|
||||||
|
opts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按目标码率构造编码器候选列表,QSV 可用时优先
|
||||||
|
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
|
||||||
|
var qOpts, xOpts []string
|
||||||
|
if bitrate > 0 {
|
||||||
|
br := strconv.Itoa(bitrate) + "k"
|
||||||
|
qOpts = []string{"-b:v", br}
|
||||||
|
xOpts = []string{"-b:v", br, "-preset", "medium"}
|
||||||
|
} else {
|
||||||
|
qOpts = []string{"-global_quality", "22", "-preset", "medium"}
|
||||||
|
xOpts = []string{"-crf", "18", "-preset", "medium"}
|
||||||
|
}
|
||||||
|
encoders := []videoEncoder{{"libx264", xOpts}}
|
||||||
|
if qsvAvailable(ffmpegFile) {
|
||||||
|
encoders = append([]videoEncoder{{"h264_qsv", qOpts}}, encoders...)
|
||||||
|
}
|
||||||
|
return encoders
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行 ffmpeg,输出直接透传到控制台
|
||||||
|
func runFFmpeg(ffmpegFile string, args []string, dir string) error {
|
||||||
|
cmd := exec.Command(ffmpegFile, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 concat demuxer 把各分块的成片拼接起来
|
||||||
|
func concatChunks(ffmpegFile string, files []string, out, dir string) error {
|
||||||
|
listFile := filepath.Join(dir, chunkTempPrefix+"list.txt")
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range files {
|
||||||
|
// concat demuxer 里单引号要写成 '\''
|
||||||
|
p = filepath.ToSlash(p)
|
||||||
|
b.WriteString("file '" + strings.ReplaceAll(p, "'", `'\''`) + "'\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(listFile, []byte(b.String()), 0644); err != nil {
|
||||||
|
return fmt.Errorf("写入拼接清单失败:%v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(listFile)
|
||||||
|
|
||||||
|
if err := runFFmpeg(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir); err != nil {
|
||||||
|
return fmt.Errorf("拼接各分块失败:%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把秒格式化成 ffmpeg 参数,保留毫秒
|
||||||
|
func formatSeconds(sec float64) string {
|
||||||
|
return strconv.FormatFloat(sec, 'f', 3, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串
|
||||||
|
func ensureAvatar(uid int64, url, cacheDir string) string {
|
||||||
|
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
|
||||||
|
sum := sha1.Sum([]byte(url))
|
||||||
|
path := filepath.Join(cacheDir, fmt.Sprintf("%d_%x.png", uid, sum[:6]))
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||||
|
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 清掉这个用户已经失效的旧头像
|
||||||
|
if olds, err := filepath.Glob(filepath.Join(cacheDir, strconv.FormatInt(uid, 10)+"_*.png")); err == nil {
|
||||||
|
for _, p := range olds {
|
||||||
|
if p != path {
|
||||||
|
_ = os.Remove(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runThrice(func() error {
|
||||||
|
return downloadAvatar(uid, url, path)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAvatar(uid int64, url, path string) error {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析失败(可能是 webp 格式):%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
return png.Encode(out, circleAvatar(src, avatarCacheSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问
|
||||||
|
var avatarURLMemo struct {
|
||||||
|
sync.Mutex
|
||||||
|
m map[int64]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实际去查头像的动作,单独拎出来方便测试替换掉
|
||||||
|
var lookupAvatarURL = fetchAvatarURL
|
||||||
|
|
||||||
|
// 按 uid 反查头像链接。原版 .ass 里没有头像链接,只能这样补回来
|
||||||
|
func avatarURLByUID(uid int64) string {
|
||||||
|
if uid <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
avatarURLMemo.Lock()
|
||||||
|
if avatarURLMemo.m == nil {
|
||||||
|
avatarURLMemo.m = make(map[int64]string)
|
||||||
|
}
|
||||||
|
if url, ok := avatarURLMemo.m[uid]; ok {
|
||||||
|
avatarURLMemo.Unlock()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
avatarURLMemo.Unlock()
|
||||||
|
|
||||||
|
url := lookupAvatarURL(uid)
|
||||||
|
|
||||||
|
avatarURLMemo.Lock()
|
||||||
|
avatarURLMemo.m[uid] = url
|
||||||
|
avatarURLMemo.Unlock()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查 AcFun 的用户信息接口取头像地址
|
||||||
|
func fetchAvatarURL(uid int64) string {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
addr := fmt.Sprintf("https://www.acfun.cn/rest/pc-direct/user/userInfo?userId=%d", uid)
|
||||||
|
req, err := http.NewRequest("GET", addr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Result int `json:"result"`
|
||||||
|
Profile struct {
|
||||||
|
HeadURL string `json:"headUrl"`
|
||||||
|
} `json:"profile"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &out) != nil || out.Result != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.Profile.HeadURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSV 可用性的探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
|
||||||
|
var qsvCache struct {
|
||||||
|
sync.Mutex
|
||||||
|
path string
|
||||||
|
ok bool
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测 Intel QSV(h264_qsv)硬件编码是否可用
|
||||||
|
func qsvAvailable(ffmpegFile string) bool {
|
||||||
|
qsvCache.Lock()
|
||||||
|
if qsvCache.done && qsvCache.path == ffmpegFile {
|
||||||
|
ok := qsvCache.ok
|
||||||
|
qsvCache.Unlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
qsvCache.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegFile, "-hide_banner", "-loglevel", "error",
|
||||||
|
"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.5:r=30",
|
||||||
|
"-c:v", "h264_qsv", "-frames:v", "5", "-f", "null", "-")
|
||||||
|
ok := cmd.Run() == nil
|
||||||
|
|
||||||
|
qsvCache.Lock()
|
||||||
|
qsvCache.path, qsvCache.ok, qsvCache.done = ffmpegFile, ok, true
|
||||||
|
qsvCache.Unlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把头像居中裁方、缩放并圆形裁切,输出带透明通道的 PNG
|
||||||
|
func circleAvatar(src image.Image, size int) image.Image {
|
||||||
|
sb := src.Bounds()
|
||||||
|
side := sb.Dx()
|
||||||
|
if sb.Dy() < side {
|
||||||
|
side = sb.Dy()
|
||||||
|
}
|
||||||
|
cx, cy := sb.Min.X+sb.Dx()/2, sb.Min.Y+sb.Dy()/2
|
||||||
|
half := side / 2
|
||||||
|
sr := image.Rect(cx-half, cy-half, cx+half, cy+half)
|
||||||
|
|
||||||
|
scaled := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, sr, xdraw.Over, nil)
|
||||||
|
|
||||||
|
// 圆形遮罩,边缘 2px 抗锯齿过渡
|
||||||
|
mask := image.NewAlpha(image.Rect(0, 0, size, size))
|
||||||
|
radius := float64(size) / 2
|
||||||
|
edge := 2.0
|
||||||
|
start := 1 - edge/radius
|
||||||
|
for y := 0; y < size; y++ {
|
||||||
|
for x := 0; x < size; x++ {
|
||||||
|
dx := float64(x-size/2) + 0.5
|
||||||
|
dy := float64(y-size/2) + 0.5
|
||||||
|
d := math.Sqrt(dx*dx+dy*dy) / radius
|
||||||
|
switch {
|
||||||
|
case d >= 1:
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 0})
|
||||||
|
case d > start:
|
||||||
|
a := uint8(255 * (1 - (d-start)/(edge/radius)))
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: a})
|
||||||
|
default:
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
draw.DrawMask(dst, dst.Bounds(), scaled, image.Point{}, mask, image.Point{}, draw.Over)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
// 返回指定路径所在磁盘的剩余和总容量(字节)
|
||||||
|
func diskSpace(path string) (free, total uint64, err error) {
|
||||||
|
var st syscall.Statfs_t
|
||||||
|
if err = syscall.Statfs(path, &st); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
bsize := uint64(st.Bsize)
|
||||||
|
return st.Bavail * bsize, st.Blocks * bsize, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回指定路径所在磁盘的剩余和总容量(字节)
|
||||||
|
func diskSpace(path string) (free, total uint64, err error) {
|
||||||
|
// GetDiskFreeSpaceEx 需要绝对路径
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
p, err := windows.UTF16PtrFromString(abs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
var freeToCaller, totalBytes, totalFree uint64
|
||||||
|
if err = windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return freeToCaller, totalBytes, nil
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# 命令行使用方法
|
||||||
|
命令行模式需要运行`acfunlive -listen`,下载的视频和弹幕默认保存在本程序所在文件夹内
|
||||||
|
|
||||||
|
`acfunlive -listen` 运行程序监听,监听过程中可以输入命令修改设置(运行`help`查看详细命令说明)
|
||||||
|
|
||||||
|
`acfunlive -listen -config configDir -record recordDir` 运行程序监听,读取`configDir`里的配置文件,并将录播和弹幕文件保存在`recordDir`
|
||||||
|
|
||||||
|
`acfunlive -webui` 启动 web UI 服务器,可以通过`http://localhost:51890`访问 web UI 界面
|
||||||
|
|
||||||
|
`acfunlive -webapi` 运行监听程序并启动 web API 服务器,可以通过`http://localhost:51880`来查看状态和发送命令
|
||||||
|
|
||||||
|
`acfunlive -mirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群
|
||||||
|
|
||||||
|
`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置
|
||||||
|
|
||||||
|
`acfunlive -listlive` 列出正在直播的主播
|
||||||
|
|
||||||
|
`acfunlive -addnotify 23682490` 通知 uid 为 23682490 的主播的开播
|
||||||
|
|
||||||
|
`acfunlive -delnotify 23682490` 取消通知 uid 为 23682490 的主播的开播
|
||||||
|
|
||||||
|
`acfunlive -addrecord 23682490` uid 为 23682490 的主播直播时自动下载其直播视频
|
||||||
|
|
||||||
|
`acfunlive -delrecord 23682490` 取消自动下载 uid 为 23682490 的主播的直播视频
|
||||||
|
|
||||||
|
`acfunlive -adddanmu 23682490` uid 为 23682490 的主播直播时自动下载其直播弹幕
|
||||||
|
|
||||||
|
`acfunlive -deldanmu 23682490` 取消自动下载 uid 为 23682490 的主播的直播弹幕
|
||||||
|
|
||||||
|
`acfunlive -getdlurl 23682490` 查看 uid 为 23682490 的主播是否在直播,输出其直播源
|
||||||
|
|
||||||
|
`acfunlive -startrecord 23682490` 临时下载 uid 为 23682490 的主播的直播视频
|
||||||
|
|
||||||
|
`acfunlive -startdanmu 23682490` 临时下载 uid 为 23682490 的主播的直播弹幕
|
||||||
|
|
||||||
|
`acfunlive -startrecdan 23682490` 临时下载 uid 为 23682490 的主播的直播视频和弹幕
|
||||||
|
|
||||||
|
运行`acfunlive -h`查看详细设置说明
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""AcFun 直播录制通知接收插件(供 acfunlive 的「通知推送」功能使用)
|
||||||
|
|
||||||
|
acfunlive 会把开播 / 下播 / 开始录制 / 合并完成等事件以 JSON POST 过来,
|
||||||
|
本插件把它们转成群消息发出去。
|
||||||
|
|
||||||
|
安装:
|
||||||
|
把本文件复制到 Momoko 的 plugins 目录(和 upload_notify.py 放一起),重启 bot。
|
||||||
|
|
||||||
|
acfunlive 侧填写(web UI 的「通知推送地址」):
|
||||||
|
http://127.0.0.1:3000/acfunlive/notify
|
||||||
|
(端口换成你 bot.py 里 nonebot.run(port=...) 的实际端口)
|
||||||
|
|
||||||
|
可选环境变量(写在 bot 的 .env 里):
|
||||||
|
ACFUNLIVE_NOTIFY_GROUP=777163254 # 默认通知群,多个用逗号分隔;补底用,见下
|
||||||
|
ACFUNLIVE_NOTIFY_TOKEN=某个口令 # 填了之后会校验 acfunlive 推送时带的 Bearer 口令
|
||||||
|
|
||||||
|
通知发到哪个群:优先用 acfunlive 推送里带的 group 字段(在 acfunlive 的 web UI
|
||||||
|
「通知群号」里配,改完立即生效,不用重启 bot);没配时才回退到环境变量
|
||||||
|
ACFUNLIVE_NOTIFY_GROUP;两边都没配就只记日志不发送。
|
||||||
|
|
||||||
|
acfunlive 推送的 JSON 字段:
|
||||||
|
event liveOn / liveOff / recordStart / mergeDone / test
|
||||||
|
uid 主播 uid
|
||||||
|
name 主播名字
|
||||||
|
title 直播标题
|
||||||
|
url 直播间地址
|
||||||
|
file 相关文件路径(录制开始时的视频、合并完成时的成片)
|
||||||
|
group 通知群号(acfunlive 页面里配的)
|
||||||
|
text 已经拼好的文本,可以直接发出去
|
||||||
|
time 事件发生的时刻
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from nonebot import get_bot, get_driver
|
||||||
|
from nonebot.log import logger
|
||||||
|
|
||||||
|
driver = get_driver()
|
||||||
|
app: FastAPI = driver.server_app
|
||||||
|
|
||||||
|
# 兜底的通知群,多个用逗号分隔;acfunlive 推送里带 group 时以推送的为准
|
||||||
|
GROUPS = [g.strip() for g in os.getenv("ACFUNLIVE_NOTIFY_GROUP", "").split(",") if g.strip()]
|
||||||
|
# 推送口令,留空表示不校验
|
||||||
|
TOKEN = os.getenv("ACFUNLIVE_NOTIFY_TOKEN", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_groups(data: dict) -> list[str]:
|
||||||
|
"""决定这条通知发到哪些群:优先用推送里下发的群号,其次用环境变量"""
|
||||||
|
raw = str(data.get("group") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return GROUPS
|
||||||
|
return [g.strip() for g in raw.split(",") if g.strip()]
|
||||||
|
|
||||||
|
# 每种事件的前缀,让群里一眼能看出是什么事
|
||||||
|
EVENT_PREFIX = {
|
||||||
|
"liveOn": "🔴 开播",
|
||||||
|
"liveOff": "⚪ 下播",
|
||||||
|
"recordStart": "⏺ 开始录制",
|
||||||
|
"mergeDone": "✅ 合并完成",
|
||||||
|
"test": "🧪 测试通知",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(data: dict) -> str:
|
||||||
|
"""把推送内容拼成一条群消息"""
|
||||||
|
event = str(data.get("event", ""))
|
||||||
|
prefix = EVENT_PREFIX.get(event, "📢 " + (event or "通知"))
|
||||||
|
|
||||||
|
# text 是 acfunlive 拼好的正文,优先用它
|
||||||
|
body = str(data.get("text") or "").strip()
|
||||||
|
if not body:
|
||||||
|
parts = []
|
||||||
|
if data.get("name"):
|
||||||
|
parts.append(str(data["name"]))
|
||||||
|
if data.get("title"):
|
||||||
|
parts.append(str(data["title"]))
|
||||||
|
if data.get("url"):
|
||||||
|
parts.append(str(data["url"]))
|
||||||
|
body = "\n".join(parts)
|
||||||
|
|
||||||
|
lines = [prefix]
|
||||||
|
if body:
|
||||||
|
lines.append(body)
|
||||||
|
if data.get("time"):
|
||||||
|
lines.append(str(data["time"]))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/acfunlive/notify")
|
||||||
|
async def acfunlive_notify(data: dict, request: Request):
|
||||||
|
"""接收 acfunlive 推送的直播事件"""
|
||||||
|
if TOKEN:
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
got = auth[7:].strip() if auth.lower().startswith("bearer ") else auth.strip()
|
||||||
|
if got != TOKEN:
|
||||||
|
logger.warning("AcFun 通知的口令不正确,已忽略")
|
||||||
|
return {"status": "error", "message": "口令不正确"}
|
||||||
|
|
||||||
|
message = build_message(data)
|
||||||
|
groups = resolve_groups(data)
|
||||||
|
logger.info(f"收到 AcFun 通知:{data.get('event')} {data.get('name', '')}")
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
logger.warning("推送里没有 group,也没有设置 ACFUNLIVE_NOTIFY_GROUP,只记录日志不发送")
|
||||||
|
return {"status": "success", "message": "未配置群号,已忽略"}
|
||||||
|
|
||||||
|
ok, failed = 0, []
|
||||||
|
for group_id in groups:
|
||||||
|
try:
|
||||||
|
bot = get_bot()
|
||||||
|
await bot.send_group_msg(group_id=int(group_id), message=message)
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed.append(f"{group_id}: {e}")
|
||||||
|
logger.error(f"向群 {group_id} 发送 AcFun 通知失败:{e}")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
return {"status": "error", "message": ";".join(failed)}
|
||||||
|
return {"status": "success", "message": f"已发送到 {ok} 个群"}
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("AcFun 直播通知插件加载成功")
|
||||||
|
logger.info(f"HTTP 端点:POST /acfunlive/notify,默认群(推送里带 group 时以推送为准):{GROUPS or '未配置'}")
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# acfunlive 源码审阅报告
|
||||||
|
|
||||||
|
- 日期:2026-09-25
|
||||||
|
- 范围:13 个 Go 文件共 3735 行(config/webui/danmu/danmuburn/record/automerge/cycle/fetch/main/utils/handle/input/filename),外加 config.json、Dockerfile、README
|
||||||
|
- 方法:逐文件通读 + 行级取证 + `go vet`(无输出)+ 关键假设实测(Windows 命令行长度上限、进程上限)
|
||||||
|
- 结论:**本次未修改任何代码**。P0 3 项、P1 9 项、P2 16 项,其中 P0-3 已用实测数据证明"长直播的自动合并必然失败"
|
||||||
|
|
||||||
|
### 运行环境发现(非代码问题,但优先级很高)
|
||||||
|
|
||||||
|
- 本机同时存在**两个录制程序实例**:
|
||||||
|
- `acfunlive-danmu.exe`(PID 14616,本改造版),监听 `0.0.0.0:51881`;
|
||||||
|
- `acfunlive`(PID 10708,**上游原版**,有 `/listlive` `/startmirai` 等接口),监听 `0.0.0.0:51880`。
|
||||||
|
- 判定依据:`netstat -ano` 显示 51880 → PID 10708、51881 → PID 14616;`GET http://127.0.0.1:51880/` 返回上游帮助文本(`/listlive`、`/startwebui`…),而 `/api/status` 返回 404。
|
||||||
|
- 风险:如果两个实例的 uid 列表重叠,会对同一主播**重复下载、重复占用带宽**,产物互相覆盖或重复;上游版的 web API 同样是**无鉴权的写接口**(`/addnotifyon/...`),且监听所有网卡。
|
||||||
|
- 建议:确认后关掉不需要的那一个;若上游版仍需保留,把它的 web 监听限制在本机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论速览
|
||||||
|
|
||||||
|
| 级别 | 数量 | 主题 | 最值得先做的一件事 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P0 安全/数据 | 3 | 无认证的 web 管理面、合并 goroutine 崩溃会杀掉整个录制进程、合并命令超命令行上限 | 给管理接口加鉴权 + 合并改分块渲染 |
|
||||||
|
| P1 正确性/竞态 | 9 | 数据竞态、硬杀 ffmpeg 导致文件损坏、合并静默丢失、并发合并字幕错配、停止操作卡 30 秒 | 修 `removeUID` 竞态、优雅停止、临时文件唯一化 |
|
||||||
|
| P2 健壮性/体验/工程 | 16 | 配置错误直接退出、无信号处理、锁内 IO、无超时、日志不落盘等 | 统一 goroutine 兜底 + 配置降级 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P0:安全与数据完整性
|
||||||
|
|
||||||
|
### P0-1 web UI 无任何认证,且当前配置已对外开放 🔴
|
||||||
|
|
||||||
|
- 证据:`webui.go:546-553` 注册的路由(`POST /api/config`、`/api/streamer`、`/api/streamer/control`、`/api/streamer/remove`)全部无鉴权、无 token、无来源校验;`config.json` 中 `webExternal: true` 意味着监听 `0.0.0.0`(`webui.go:589-594`)。
|
||||||
|
- 影响:同一局域网(或做了端口映射的机器上)任何人可以:
|
||||||
|
- 改 `directory` / `recordSubdir` / `mergeSubdir` / `mergeOutputDir`,把录制产物写到任意已存在目录;
|
||||||
|
- 打开 `mergeDelVideo` / `mergeDelDanmu`(这两个开关的实际效果是**删除源文件**);
|
||||||
|
- 增删主播、停止全部录制、把码率改成 1 导致画质损毁。
|
||||||
|
- 缓解事实:POST 接口要求 `application/json`,浏览器跨站表单会触发 CORS 预检且服务端不返回 CORS 头,因此 CSRF 难利用;但这不改变"无认证"这一根本问题。
|
||||||
|
- 建议:
|
||||||
|
1. 增加 `webToken`(首次启动随机生成并打印到控制台/日志),页面 `?token=` 或 `Authorization: Bearer`;
|
||||||
|
2. 更贴合个人工具的方案:管理类接口(config/streamer/remove/control)**默认只接受来自 127.0.0.1 的请求**,`webExternal` 只用于查看状态;
|
||||||
|
3. `mergeDelVideo`/`mergeDelDanmu` 这类破坏性开关再加一层"仅在本地控制台/带 token 时可改"。
|
||||||
|
|
||||||
|
### P0-2 自动合并 goroutine 没有 recover,一次 panic 会杀掉整个录制进程 🔴
|
||||||
|
|
||||||
|
- 证据链:`automerge.go:42` `go autoMerge(...)`(无 `defer recover`)→ `automerge.go:116` 调 `burnDanmu` → `danmuburn.go:328` `checkErr(os.MkdirAll(cacheDir, 0755))`、`danmuburn.go:369` `checkErr(png.Encode(...))`;`checkErr` 的实现是 `panic`(`utils.go:87-91`)。`runThrice`(`utils.go:94-105`)只重试**返回 error** 的调用,捕不住 panic。
|
||||||
|
- 影响:头像缓存目录创建失败(权限/被占用/磁盘满)或 PNG 编码失败 → panic 逃出 goroutine → **Go 运行时终止整个进程**,其它主播正在进行的录制全部中断,flv 文件被留下且未移动。
|
||||||
|
- 对比:程序里绝大多数 goroutine 都有 recover(`cycle`/`handleInput`/`getDanmu`/`initRecord`/`startWebUI`),只有 `autoMerge` 这条最"重"的路径没有。
|
||||||
|
- 建议:① 所有 `go` 出去的入口加 `safeGo(func())` 包装(统一 recover + 日志);② `danmuburn.go` 里的 `checkErr` 换成返回 error 或就地判空返回。
|
||||||
|
|
||||||
|
### P0-3 弹幕条数 ≳200 时,合并命令超出 Windows 命令行上限,自动合并必然失败 🔴
|
||||||
|
|
||||||
|
- 证据:`danmuburn.go:221-240` 为**每一条弹幕**生成一个 overlay 片段,`danmuburn.go:247` 把它们作为**单个** `-filter_complex` 参数传给 ffmpeg。
|
||||||
|
- 实测(本机复刻格式串):单条片段 118 字符,实测样例
|
||||||
|
`[s1234][av56789012_3]overlay=x='1920-(1920+1258)*(t-1234.567)/10':y=726:enable='between(t,1234.567,1244.567)'[s1235];`
|
||||||
|
- 实测(Windows CreateProcess 上限):30000 / 32000 字符 → 成功;**40000 字符 → `WinError 206 文件名或扩展名太长`**。
|
||||||
|
|
||||||
|
| 弹幕条数 | filter_complex 长度 | 整条命令估算 | Windows (≤32767) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 100 | 11.8 KB | 16.4 KB | ok |
|
||||||
|
| 200 | 23.6 KB | 32.2 KB | 已到临界 |
|
||||||
|
| 290 | 34.2 KB | 46.4 KB | **必然失败** |
|
||||||
|
| 1000 | 118 KB | 130 KB | **必然失败** |
|
||||||
|
| 10000 | 1.2 MB | 1.3 MB | **必然失败** |
|
||||||
|
|
||||||
|
- 影响:一场正常直播(几千到几万条弹幕)的**自动合并/命令行合成必定报错**,且报错是 `WinError 206`,完全看不出真实原因。之前实测能成功,只是因为测试用的直播间弹幕量都不到 200 条。Linux 端单参数上限 `MAX_ARG_STRLEN` 为 128 KB,约 1100 条也会踩到。
|
||||||
|
- 附带性能问题:O(N) 条 overlay 链,每条都对整帧做一次重编码,即使不超长度,几千条时耗时与内存也不可行。
|
||||||
|
- 建议(推荐第 2 项):
|
||||||
|
1. **分块渲染**:按时间窗(如每 60~120 秒)切分成多个 ffmpeg 任务,每块只包含该时段出现的弹幕与用户头像,编成 mp4 分片后用 concat demuxer 合并;音频直接从原视频 `-c:a copy`。每块弹幕数可控,链长下降两个数量级。
|
||||||
|
2. **头像预合成**:把"头像+昵称"先渲染成不透明小图(或按用户合成一张 sprite),再按用户而非按弹幕做 overlay,配合 `enable` 表达式按用户合并时间段 —— 数量从"弹幕数"降到"用户数",可再降一个数量级。
|
||||||
|
3. 至少在超限前**主动报错并降级**(如提示用户改用分块或关闭头像),而不是把 ffmpeg 的错误原样抛出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P1:正确性 / 竞态 / 数据
|
||||||
|
|
||||||
|
| 编号 | 问题 | 证据 | 影响 | 建议 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| P1-1 | `removeUID` 在**无锁**状态下改 `config.UIDs` | `config.go:296-319` 直接 `append`;而 `setStreamerFlags`(`config.go:342-368`)持 `streamers.Lock` 改同一切片 | 数据竞态;并发增删时可能丢 uid 或读到脏切片;`saveConfig` 又在锁外遍历它(`config.go:270-288`),并发写同一文件会互相覆盖 | 统一用 `streamers.Lock`;`saveConfig` 用独立 `configMu` 串行化 + 临时文件原子替换 |
|
||||||
|
| P1-2 | 停止录制是**硬杀** ffmpeg,`q` 写在进程已死之后 | `record.go:176` 用 `exec.CommandContext`;`record.go:200-204` 先 `cmd.Run()` 返回,再 `io.WriteString(stdin, "q")` | 取消 ctx 时 Go 直接 `TerminateProcess`,flv 尾部无正常收尾 → 合并时报 corrupt packet(已实测到该现象);`q` 写入是无效代码 | 用 `cmd.Cancel`(写 `q`,不做 Kill)+ `cmd.WaitDelay = 5s`,超时再杀 |
|
||||||
|
| P1-3 | 只有首段录播参与合并,断流后的分段被忽略 | `record.go:186-197` `firstRecordFile/recordStartNs` 只在为空时赋值;`record.go:221` 续段生成新文件名;`automerge.go` 只处理 `firstRecordFile` | 直播中途断一次流,**后半场视频没有弹幕**,且合并只输出首段(弹幕时间轴也对不上)→ 内容丢失 | 方案 A(小改):续段复用同一 flv 文件名,让 ffmpeg 追加/重连;方案 B(推荐):记录所有分段与各自起始偏移,合并时先 concat 所有分段再叠弹幕 |
|
||||||
|
| P1-4 | `cycleDelKey` 60 秒清理会**静默吞掉**自动合并 | `cycle.go:106-124` 删除"既无弹幕也无录播"的 liveInfo;`record.go:205` 分段之间会把 `isRecording` 置 false 并 `time.Sleep(5s)`(`record.go:217`);`automerge.go:16-20` 找不到 key 时直接 return 且**无日志** | 录播收尾晚于弹幕超过 60 秒(或分段间隙撞上清理周期)时,自动合并永远不触发,用户只看到"没生成成片" | 保留条件放宽:有 `assFile`/`firstRecordFile`/`mergeStarted` 的项不删;清理周期改 10 分钟;`finishPart` 找不到 key 时打日志 |
|
||||||
|
| P1-5 | `controlStreamer` 的"重新开始"会**打开原本关闭的录制** | `config.go:461-468`,前端固定传 `target:"all"`(`webui.go:196`)→ `danmu, record = restart, restart`(都置 true) | 只开弹幕的主播点一次"重新开始",录播也被打开,产生用户没要的视频文件 | restart 只对"该项原本已开启"的部分重启:`danmu = s.Danmu && restart`,或让前端按当前勾选传 target |
|
||||||
|
| P1-6 | 并发合并时临时字幕文件**同名冲突**,可能张冠李戴 | `danmuburn.go:170` 硬编码 `acfunlive_nick_tmp.ass`,且开头 `_ = os.Remove(tmpNick)`、结尾 `defer os.Remove(tmpNick)` | 两个主播同时下播 → 两个 autoMerge 并发 → 一方删掉另一方正在用的字幕,最坏情况把 A 的弹幕烧进 B 的视频 | 临时名加唯一后缀(liveID/uid/时间戳),或改到各自独立临时目录 |
|
||||||
|
| P1-7 | 停止操作可能卡 4~30 秒 | `record.go:102`、`danmu.go:424` 的日志里调 `longID(uid)`(`utils.go:157-159`)→ `getName` → 缓存未命中时 `tryFetchLiveInfo` 走 3 次重试(每次 10 秒超时 + 2 秒 sleep,`utils.go:94-105`) | 主播下播后点"停止",HTTP 请求要等到日志打印完才返回,web 页面像卡死;`setStreamerFlags` 里 `getName` 还会调两次(`config.go:353,366`) | 日志走缓存名字,取不到就只打 uid;`getName` 加一层短期缓存 |
|
||||||
|
| P1-8 | web UI 每 3 秒重建主播列表 DOM | `webui.go:144` `innerHTML = rows`;`webui.go:224` `setInterval(refresh, 3000)` | 正在输入"下载码率上限"时会被重建清空(`setVal` 只保护设置区表单,不保护列表行) | 数据无变化时不重建(比较 JSON 摘要);或在 `:focus` 元素所在行跳过重建 |
|
||||||
|
| P1-9 | 每 3 秒全量拉取日志 | `webui.go:523-526` 返回最多 1 MB 日志,`webui.go:124` 每 3 秒取一次 | 每分钟最多 20 MB 流量,手机浏览器明显吃力,日志越长越严重 | 支持增量(`since` 偏移/时间戳)+ 前端只追加 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P2:健壮性、体验与工程
|
||||||
|
|
||||||
|
| 编号 | 问题 | 证据 / 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P2-1 | 配置错误直接 `os.Exit(1)`,会中断正在进行的录制 | `config.go:177-243` 大量 `os.Exit`;`config.go:610` 在 `loadNewConfig` 里也会调它。用户手改 config.json 写错一个字符 → 整个录制进程退出。建议:保留旧配置 + 告警(只有启动阶段才致命) |
|
||||||
|
| P2-2 | 无 SIGINT/SIGTERM 处理 | 没有 `signal.Notify`;Ctrl+C 或杀进程时 ffmpeg 变孤儿继续录(上次已实测),且退出流程是 `main.go:164` 硬 `sleep 20s` |
|
||||||
|
| P2-3 | `mainCh` 的发送可能与退出竞争 | `main.go:116` 容量 20;`config.go:527-537` `stopStreamerCycle` 持有 `sInfoMap` 锁做 `m.ch <- stop`,若无人接收会持锁阻塞(潜在死锁);main 退出后任何 `mainCh <-` 永久阻塞。建议 `select + default` 并显式从 map 删除 |
|
||||||
|
| P2-4 | http.Server 只设了 `IdleTimeout` | `webui.go:560-564` 缺 `ReadHeaderTimeout/ReadTimeout/WriteTimeout`;请求体也没有 `http.MaxBytesReader` 与速率限制 → 慢连接可长期占用连接 |
|
||||||
|
| P2-5 | 页面缺少安全响应头 | 可加 `X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`、CSP;`/api/log` 会泄露主播名、直播标题、本机路径 |
|
||||||
|
| P2-6 | 目录名校验不够严 | `config.go:130-135` 只拒分隔符与 `".."`;未处理 Windows 保留名(CON/NUL/PRN)、结尾空格或点;`mergeOutputDir` 可为任意已存在目录 |
|
||||||
|
| P2-7 | Linux 文件名截断可能切碎 UTF-8 | `filename_other.go:19-21` 按**字节** `filename[:250]`,多字节汉字会被截半 → 非法文件名。应按 rune 截断 |
|
||||||
|
| P2-8 | 头像缓存无失效、无上限、位置混放 | `danmuburn.go:323-338` 缓存名是 `uid.png`,主播换头像后永远用旧图;缓存在 `record/` 目录内与成对文件混在一起;建议按 URL 摘要命名 + 放 `cache/` 子目录 + 定期清理 |
|
||||||
|
| P2-9 | 时间轴对齐存在 1~3 秒系统误差 | `record.go:195` 在 ffmpeg **启动前**记录 `recordStartNs`;ffmpeg 建立连接与缓冲有延迟 → 弹幕整体偏早。可考虑用 flv 首个时间戳校正 |
|
||||||
|
| P2-10 | 弹幕坐标系可能与实际分辨率不符 | `danmu.go:43-55` 用最高档**码率**猜分辨率(≥4000→1080)。竖屏直播配上≥4000 档会选到横屏坐标系 1920×1080 → libass 拉伸变形 |
|
||||||
|
| P2-11 | `assTime` 超过 24 小时会回绕 | `danmu.go:127-133` 用 `time.Unix(0, t).UTC()` 取 `Hour()`;超长直播(>24h)时间轴错乱 |
|
||||||
|
| P2-12 | QSV 能力每次合并都探测 | `danmuburn.go:374-379` 每次跑一次 0.5 秒的试编码;建议进程内缓存结果 |
|
||||||
|
| P2-13 | 日志不落盘、无轮转 | 只有内存 + stdout(`webui.go:260-270`);用计划任务/nssm 后台运行时排障困难。建议同时写 `log/` 并按大小轮转 |
|
||||||
|
| P2-14 | ffmpeg 只在 PATH 与 exeDir 查找 | `record.go:18-31`,且不校验版本/编码器能力,出错提示不够可操作 |
|
||||||
|
| P2-15 | `initRecord`/`initDanmu` 无 recover | 由 `main.go:88-89` 与 `cycle.go:85-90` 直接 `go` 出;当前实现无 panic 点,但一旦后续改动引入 `checkErr` 就是整机崩溃。建议统一 `safeGo` 包装 |
|
||||||
|
| P2-16 | 大量 `time.Sleep` 兜底、缺少状态同步 | `cycle.go:101` 1s、`record.go:217` 5s、`danmu.go:310/327` 5s/10s、`config.go:478` 2s、`webui.go:610` 500ms;`restartWebUI` 依赖 panic-recover 重试(`webui.go:529-537`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 优化建议(不改行为)
|
||||||
|
|
||||||
|
1. **配置写入串行化 + 原子替换**:`saveConfig` 是目前唯一的多写者共享资源,改为「写 `config.json.tmp` → `os.Rename`」可同时解决半截文件与并发覆盖。
|
||||||
|
2. **锁顺序固化**:现有顺序是 `streamers → liveRooms`(如 `buildStreamers` 持前者调 `getName` 取后者)。建议在注释中固化,并把"锁内不做 IO"写成硬约束:`getTitle/getName/longID` 一律先取值后打印。
|
||||||
|
3. **`safeGo` 统一兜底**:所有后台任务走同一入口(recover + 日志 + 可选重启),把"是否 recover"从人工记忆变成机制。
|
||||||
|
4. **合并任务串行化**:加一个 `mergeCh`(容量 1 的 worker),避免并发合并抢 CPU/内存与临时文件冲突(同时解掉 P1-6)。
|
||||||
|
5. **前端轮询改造**:状态接口返回 `version`(配置/列表变更摘要),前端只在变更时重建;日志改增量拉取。
|
||||||
|
6. **`/api/status` 瘦身**:`UIDs` 字段已冗余(前端未使用),可去掉。
|
||||||
|
7. **CI**:加 `gofmt -l`、`go vet`、`go build`(windows/linux 双平台)与 `govulncheck`;依赖里有 `acfundanmu` 等较旧的包,建议定期看 CVE。
|
||||||
|
8. **Docker 场景目录嵌套**:`recordDir=/acfunlive/record` + `recordSubdir=record` 会变成 `/acfunlive/record/record`;建议允许 `recordSubdir` 为空表示"不建子目录",或文档说明。
|
||||||
|
9. **录制前检查磁盘水位**:新增剩余空间检查,低水位告警/停止录制,避免写满盘导致所有录制同时失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 下一步值得做的功能
|
||||||
|
|
||||||
|
### 第一批:补齐稳定性(建议先做,和上面的 P0/P1 是同一批工作)
|
||||||
|
1. 合并改为**分块渲染 + concat**,彻底解决 P0-3,顺带把 3 小时的直播合并从"不可行"变成"可接受"。
|
||||||
|
2. **优雅停止 + 断流续录拼接**(P1-2 / P1-3):停止时 ffmpeg 正常收尾(flv 不再损坏);断流后的分段自动拼接,弹幕覆盖全场。
|
||||||
|
3. 管理接口鉴权 + 敏感操作本地限定(P0-1)。
|
||||||
|
|
||||||
|
### 第二批:无人值守能力(投入小、体感提升最大)
|
||||||
|
4. **通知推送**:开播 / 下播 / 合并完成 / 合并失败 / 磁盘不足,推送到 **NoneBot(你已有 Momoko)/ Bark / 企业微信 / Telegram**。这是长时间挂机录制最缺的一环,实现成本约百行。
|
||||||
|
- 佐证:**上游原版本来就有开播/下播通知**(`/addnotifyon`、`/startmirai`,走 Mirai 推 QQ),本改造版在剥离 Mirai 时把这套能力一起删掉了。现在本机还跑着那个上游实例(见上文"运行环境发现")。用 NoneBot 的 OneBot HTTP API 接回来,既恢复能力又不引入 Mirai 依赖。
|
||||||
|
5. **文件管理**:Web UI 列出 `record/`、`merged/` 中的文件(大小、时长、是否已合并),成片在线播放(Range)与下载、一键删除;显示磁盘剩余空间。
|
||||||
|
6. **日志落盘 + 轮转**,配合计划任务/nssm 后台运行。
|
||||||
|
|
||||||
|
### 第三批:进阶功能
|
||||||
|
7. **录制后处理流水线**:合并完成后自动转 H.265(省 40%~60% 空间,可与现有 `mergeBitrate` 联动)、生成缩略图、按 `{日期}/{主播}` 归档。
|
||||||
|
8. **弹幕数据再加工**:导出 B 站 XML / lrc;生成"弹幕热度曲线",方便后续剪辑挑精彩片段。
|
||||||
|
9. **定时/条件录制**:只在指定时段录制、按标题关键词过滤、按清晰度门槛过滤。
|
||||||
|
10. **Web UI 实时预览**:录制中定期截图或低码率 MJPEG 预览,配合显示当前文件大小/已录时长。
|
||||||
|
11. **多站点支持**:抽象 `LiveProvider` 接口(AcFun/B站/虎牙/抖音),是最有价值也最费力的方向。
|
||||||
|
12. **工程化**:systemd unit / Windows 服务 + 崩溃自愈;发布构建脚本与 Docker compose 示例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 建议的落地顺序
|
||||||
|
|
||||||
|
| 批次 | 内容 | 验收要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | P0-2 goroutine 兜底、P1-1 竞态、P1-5 restart 语义、P1-6 临时文件唯一化、P1-8/P1-9 前端 | 并发增删主播 `-race` 无告警;两个主播同时下播合并互不干扰 |
|
||||||
|
| 2 | P1-2 优雅停止、P1-3 断流拼接、P1-4 清理条件 | 手动停止后 flv 无损坏 packet;断流一次后成片覆盖全场 |
|
||||||
|
| 3 | P0-3 分块渲染 | 用 3000+ 条弹幕的 jsonl 实测合并成功,耗时可接受 |
|
||||||
|
| 4 | P0-1 鉴权 | 局域网设备无 token 时打不开管理接口 |
|
||||||
|
| 5 | 通知 + 文件管理 | 下播合并完成后 NoneBot 收到消息;Web UI 可下载成片 |
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# acfunlive web API
|
||||||
|
|
||||||
|
web API 默认本地端口为 51880
|
||||||
|
|
||||||
|
`http://localhost:51880/listlive` 列出正在直播的主播
|
||||||
|
|
||||||
|
`http://localhost:51880/listrecord` 列出正在下载的直播视频
|
||||||
|
|
||||||
|
`http://localhost:51880/listdanmu` 列出正在下载的直播弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/liststreamer` 列出设置了开播提醒或自动下载直播的主播
|
||||||
|
|
||||||
|
`http://localhost:51880/startmirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群
|
||||||
|
|
||||||
|
`http://localhost:51880/addnotifyon/23682490` 通知 uid 为 23682490 的主播的开播
|
||||||
|
|
||||||
|
`http://localhost:51880/delnotifyon/23682490` 取消通知 uid 为 23682490 的主播的开播
|
||||||
|
|
||||||
|
`http://localhost:51880/addnotifyoff/23682490` 通知 uid 为 23682490 的主播的下播
|
||||||
|
|
||||||
|
`http://localhost:51880/delnotifyoff/23682490` 取消通知 uid 为 23682490 的主播的下播
|
||||||
|
|
||||||
|
`http://localhost:51880/addnotifyrecord/23682490` 通知 uid 为 23682490 的主播的直播视频下载
|
||||||
|
|
||||||
|
`http://localhost:51880/delnotifyrecord/23682490` 取消通知 uid 为 23682490 的主播的直播视频下载
|
||||||
|
|
||||||
|
`http://localhost:51880/addnotifydanmu/23682490` 通知 uid 为 23682490 的主播的直播弹幕下载
|
||||||
|
|
||||||
|
`http://localhost:51880/delnotifydanmu/23682490` 取消通知 uid 为 23682490 的主播的直播弹幕下载
|
||||||
|
|
||||||
|
`http://localhost:51880/addrecord/23682490` uid 为 23682490 的主播直播时自动下载其直播视频
|
||||||
|
|
||||||
|
`http://localhost:51880/delrecord/23682490` 取消自动下载 uid 为 23682490 的主播的直播视频
|
||||||
|
|
||||||
|
`http://localhost:51880/adddanmu/23682490` uid 为 23682490 的主播直播时自动下载其直播弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/deldanmu/23682490` 取消自动下载 uid 为 23682490 的主播的直播弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/addkeeponline/23682490` uid 为 23682490 的主播直播时在其直播间里挂机
|
||||||
|
|
||||||
|
`http://localhost:51880/delkeeponline/23682490` 取消设置在 uid 为 23682490 的主播直播时在其直播间里挂机
|
||||||
|
|
||||||
|
`http://localhost:51880/delconfig/23682490` 删除 uid 为 23682490 的主播的所有设置
|
||||||
|
|
||||||
|
`http://localhost:51880/getdlurl/23682490` 查看 uid 为 23682490 的主播是否在直播,并输出其直播源
|
||||||
|
|
||||||
|
`http://localhost:51880/addqq/23682490/12345` 将 uid 为 23682490 的主播的开播提醒发送到 QQ12345,需要 QQ 机器人已经添加该 QQ 为好友
|
||||||
|
|
||||||
|
`http://localhost:51880/delqq/23682490/12345` 取消将 uid 为 23682490 的主播的开播提醒发送到 QQ12345
|
||||||
|
|
||||||
|
`http://localhost:51880/cancelqq/23682490` 取消将 uid 为 23682490 的主播的开播提醒发送到任何 QQ
|
||||||
|
|
||||||
|
`http://localhost:51880/addqqgroup/23682490/12345` 将 uid 为 23682490 的主播的开播提醒发送到 QQ 群 12345,需要 QQ 机器人已经加入该 QQ 群,最好是管理员,会@全体成员
|
||||||
|
|
||||||
|
`http://localhost:51880/delqqgroup/23682490/12345` 取消将 uid 为 23682490 的主播的开播提醒发送到 QQ 群 12345
|
||||||
|
|
||||||
|
`http://localhost:51880/cancelqqgroup/23682490` 取消将 uid 为 23682490 的主播的开播提醒发送到任何 QQ 群
|
||||||
|
|
||||||
|
`http://localhost:51880/startrecord/23682490` 临时下载 uid 为 23682490 的主播的直播视频
|
||||||
|
|
||||||
|
`http://localhost:51880/stoprecord/23682490` 取消下载 uid 为 23682490 的主播的直播视频
|
||||||
|
|
||||||
|
`http://localhost:51880/startdanmu/23682490` 临时下载 uid 为 23682490 的主播的直播弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/stopdanmu/23682490` 取消下载 uid 为 23682490 的主播的直播弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/startrecdan/23682490` 临时下载 uid 为 23682490 的主播的直播视频和弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/stoprecdan/23682490` 取消下载 uid 为 23682490 的主播的直播视频和弹幕
|
||||||
|
|
||||||
|
`http://localhost:51880/log` 查看 log
|
||||||
|
|
||||||
|
`http://localhost:51880/quit` 退出 acfunlive 运行
|
||||||
|
|
||||||
|
`http://localhost:51880/help` 显示帮助信息
|
||||||
|
|
||||||
|
## 新版 web UI 的管理接口
|
||||||
|
|
||||||
|
web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全部需要带管理口令:请求头`X-Auth-Token: <口令>`,或者在 URL 上带`?token=<口令>`(下载链接用后者,因为浏览器跳转带不了请求头)。跟手动合并有关的是下面两个:
|
||||||
|
|
||||||
|
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"videos": ["2026-09-25 01-00-00 主播 标题.mp4", "D:/别处/随手放的视频.mp4"],
|
||||||
|
"danmu": "2026-09-25 01-00-00 主播 标题.ass",
|
||||||
|
"offset": 3 // 可选,不填按文件名里的录制时间推算
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`jsonl 弹幕数据` / `同名的 jsonl 弹幕数据` / `由 xxx.ass 还原`)、推算出的偏移和原因、最终输出文件名、以及提示(`warnings`)。参数不合法一律 400,并在响应体里给中文原因。
|
||||||
|
|
||||||
|
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
|
||||||
|
|
||||||
|
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// 爬虫相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"github.com/valyala/fastjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type httpClient struct {
|
||||||
|
client *fasthttp.Client
|
||||||
|
url string
|
||||||
|
body []byte
|
||||||
|
method string
|
||||||
|
cookies []*fasthttp.Cookie
|
||||||
|
userAgent string
|
||||||
|
contentType string
|
||||||
|
referer string
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultClient = &fasthttp.Client{
|
||||||
|
MaxIdleConnDuration: 90 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
var fetchRoomPool fastjson.ParserPool
|
||||||
|
|
||||||
|
// 直播间的数据结构
|
||||||
|
type liveRoom struct {
|
||||||
|
name string // 主播名字
|
||||||
|
title string // 直播间标题
|
||||||
|
liveID string // 直播 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveRoom 的 map
|
||||||
|
var liveRooms struct {
|
||||||
|
sync.RWMutex // rooms 的锁
|
||||||
|
rooms map[int]*liveRoom // 现在的 liveRoom
|
||||||
|
newRooms map[int]*liveRoom // 新的 liveRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveRoom 的 pool
|
||||||
|
var liveRoomPool = &sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return new(liveRoom)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取主播的直播链接
|
||||||
|
func getURL(uid int) string {
|
||||||
|
const livePage = "https://live.acfun.cn/live/%d"
|
||||||
|
return fmt.Sprintf(livePage, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取主播的直播链接
|
||||||
|
func (s *streamer) getURL() string {
|
||||||
|
return getURL(s.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// http 请求,调用后需要 defer fasthttp.ReleaseResponse(resp)
|
||||||
|
func (c *httpClient) doRequest() (resp *fasthttp.Response, e error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErrf("Recovering from panic in doRequest(), the error is: %v", err)
|
||||||
|
e = fmt.Errorf("请求 %s 时出错,错误为 %v", c.url, err)
|
||||||
|
fasthttp.ReleaseResponse(resp)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
c.client = defaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
req := fasthttp.AcquireRequest()
|
||||||
|
defer fasthttp.ReleaseRequest(req)
|
||||||
|
resp = fasthttp.AcquireResponse()
|
||||||
|
|
||||||
|
if c.url != "" {
|
||||||
|
req.SetRequestURI(c.url)
|
||||||
|
} else {
|
||||||
|
fasthttp.ReleaseResponse(resp)
|
||||||
|
return nil, fmt.Errorf("请求的 url 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.body) != 0 {
|
||||||
|
req.SetBody(c.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.method != "" {
|
||||||
|
req.Header.SetMethod(c.method)
|
||||||
|
} else {
|
||||||
|
// 默认为 GET
|
||||||
|
req.Header.SetMethod(fasthttp.MethodGet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.cookies) != 0 {
|
||||||
|
for _, cookie := range c.cookies {
|
||||||
|
req.Header.SetCookieBytesKV(cookie.Key(), cookie.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
if c.userAgent != "" {
|
||||||
|
req.Header.SetUserAgent(c.userAgent)
|
||||||
|
} else {
|
||||||
|
req.Header.SetUserAgent(userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.contentType != "" {
|
||||||
|
req.Header.SetContentType(c.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.referer != "" {
|
||||||
|
req.Header.SetReferer(c.referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.SetCookie("_did", deviceID)
|
||||||
|
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
|
||||||
|
err := c.client.Do(req, resp)
|
||||||
|
checkErr(err)
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取响应 body
|
||||||
|
func getBody(resp *fasthttp.Response) []byte {
|
||||||
|
if string(resp.Header.Peek("content-encoding")) == "gzip" || string(resp.Header.Peek("Content-Encoding")) == "gzip" {
|
||||||
|
body, err := resp.BodyGunzip()
|
||||||
|
if err == nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Body()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全部 AcFun 直播间
|
||||||
|
func fetchAllRooms() bool {
|
||||||
|
for count := 10000; count < 1e9; count *= 10 {
|
||||||
|
rooms, all, err := fetchLiveRoom(count)
|
||||||
|
if err != nil {
|
||||||
|
lPrintErr(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
liveRooms.newRooms = rooms
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if count == 1e8 {
|
||||||
|
lPrintErr("获取正在直播的直播间列表失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定数量的 AcFun 直播间列表
|
||||||
|
func fetchLiveRoom(count int) (rooms map[int]*liveRoom, all bool, e error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
e = fmt.Errorf("fetchLiveRoom() error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const liveListURL = "https://live.acfun.cn/api/channel/list?count=%d&pcursor=0"
|
||||||
|
|
||||||
|
client := &httpClient{
|
||||||
|
url: fmt.Sprintf(liveListURL, count),
|
||||||
|
method: fasthttp.MethodGet,
|
||||||
|
}
|
||||||
|
resp, err := client.doRequest()
|
||||||
|
checkErr(err)
|
||||||
|
defer fasthttp.ReleaseResponse(resp)
|
||||||
|
body := getBody(resp)
|
||||||
|
|
||||||
|
p := fetchRoomPool.Get()
|
||||||
|
defer fetchRoomPool.Put(p)
|
||||||
|
v, err := p.ParseBytes(body)
|
||||||
|
checkErr(err)
|
||||||
|
v = v.Get("channelListData")
|
||||||
|
if !v.Exists("result") || v.GetInt("result") != 0 {
|
||||||
|
panic(fmt.Errorf("无法获取AcFun直播间列表,响应为:%s", string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(v.GetStringBytes("pcursor")) != "no_more" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
liveList := v.GetArray("liveList")
|
||||||
|
rooms = make(map[int]*liveRoom, len(liveList))
|
||||||
|
for _, live := range liveList {
|
||||||
|
uid := live.GetInt("authorId")
|
||||||
|
room := liveRoomPool.Get().(*liveRoom)
|
||||||
|
room.name = string(live.GetStringBytes("user", "name"))
|
||||||
|
room.title = string(live.GetStringBytes("title"))
|
||||||
|
room.liveID = string(live.GetStringBytes("liveId"))
|
||||||
|
rooms[uid] = room
|
||||||
|
}
|
||||||
|
|
||||||
|
return rooms, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 uid 获取主播的名字,可能需要检查返回是否为空
|
||||||
|
func getName(uid int) string {
|
||||||
|
liveRooms.RLock()
|
||||||
|
room, ok := liveRooms.rooms[uid]
|
||||||
|
if ok {
|
||||||
|
name := room.name
|
||||||
|
liveRooms.RUnlock()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
liveRooms.RUnlock()
|
||||||
|
|
||||||
|
_, room, err := tryFetchLiveInfo(uid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer liveRoomPool.Put(room)
|
||||||
|
return room.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 uid 获取主播直播间的标题
|
||||||
|
func getTitle(uid int) string {
|
||||||
|
liveRooms.RLock()
|
||||||
|
room, ok := liveRooms.rooms[uid]
|
||||||
|
if ok {
|
||||||
|
title := room.title
|
||||||
|
liveRooms.RUnlock()
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
liveRooms.RUnlock()
|
||||||
|
|
||||||
|
if isLive, room, err := tryFetchLiveInfo(uid); err == nil {
|
||||||
|
defer liveRoomPool.Put(room)
|
||||||
|
if isLive {
|
||||||
|
return room.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 uid 获取 liveID,结果准确,可能需要检查返回是否为空
|
||||||
|
func getLiveID(uid int) string {
|
||||||
|
if isLive, room, err := tryFetchLiveInfo(uid); err == nil {
|
||||||
|
defer liveRoomPool.Put(room)
|
||||||
|
if isLive {
|
||||||
|
return room.liveID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 uid 查看主播是否正在直播
|
||||||
|
func isLiveOn(uid int) bool {
|
||||||
|
liveRooms.RLock()
|
||||||
|
_, ok := liveRooms.rooms[uid]
|
||||||
|
liveRooms.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLive, room, err := tryFetchLiveInfo(uid); err == nil {
|
||||||
|
defer liveRoomPool.Put(room)
|
||||||
|
return isLive
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取主播直播间的标题
|
||||||
|
func (s *streamer) getTitle() string {
|
||||||
|
return getTitle(s.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 liveID,由于 AcFun 的 bug,结果不一定准确,可能需要检查返回是否为空
|
||||||
|
func (s *streamer) getLiveID() string {
|
||||||
|
liveRooms.RLock()
|
||||||
|
defer liveRooms.RUnlock()
|
||||||
|
room, ok := liveRooms.rooms[s.UID]
|
||||||
|
if ok {
|
||||||
|
return room.liveID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看主播是否在直播,由于 AcFun 的 bug,结果不一定准确
|
||||||
|
func (s *streamer) isLiveOn() bool {
|
||||||
|
liveRooms.RLock()
|
||||||
|
defer liveRooms.RUnlock()
|
||||||
|
_, ok := liveRooms.rooms[s.UID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户直播相关信息,可能要将 room 放回 liveRoomPool
|
||||||
|
func fetchLiveInfo(uid int) (isLive bool, room *liveRoom, e error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
e = fmt.Errorf("fetchLiveInfo() error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const acLiveInfo = "https://live.acfun.cn/api/live/info?authorId=%d"
|
||||||
|
|
||||||
|
client := &httpClient{
|
||||||
|
url: fmt.Sprintf(acLiveInfo, uid),
|
||||||
|
method: fasthttp.MethodGet,
|
||||||
|
}
|
||||||
|
resp, err := client.doRequest()
|
||||||
|
checkErr(err)
|
||||||
|
defer fasthttp.ReleaseResponse(resp)
|
||||||
|
body := getBody(resp)
|
||||||
|
|
||||||
|
p := fetchRoomPool.Get()
|
||||||
|
defer fetchRoomPool.Put(p)
|
||||||
|
v, err := p.ParseBytes(body)
|
||||||
|
checkErr(err)
|
||||||
|
if !v.Exists("result") || v.GetInt("result") != 0 {
|
||||||
|
return false, nil, fmt.Errorf("无法获取uid为%d的主播的直播信息,响应为:%s", uid, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
room = liveRoomPool.Get().(*liveRoom)
|
||||||
|
if v.Exists("liveId") {
|
||||||
|
isLive = true
|
||||||
|
room.title = string(v.GetStringBytes("title"))
|
||||||
|
room.liveID = string(v.GetStringBytes("liveId"))
|
||||||
|
} else {
|
||||||
|
isLive = false
|
||||||
|
room.title = ""
|
||||||
|
room.liveID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
room.name = string(v.GetStringBytes("user", "name"))
|
||||||
|
|
||||||
|
return isLive, room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户直播相关信息,可能要将 room 放回 liveRoomPool
|
||||||
|
func tryFetchLiveInfo(uid int) (isLive bool, room *liveRoom, err error) {
|
||||||
|
err = runThrice(func() (err error) {
|
||||||
|
isLive, room, err = fetchLiveInfo(uid)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return isLive, room, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 wap 版网页查看主播是否在直播
|
||||||
|
func (s *streamer) isLiveOnByPage() (isLive bool) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in isLiveOnByPage(), the error is:", err)
|
||||||
|
lPrintErr("获取" + s.longID() + "的直播页面时出错")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const acLivePage = "https://m.acfun.cn/live/detail/%d"
|
||||||
|
const mobileUserAgent = "Mozilla/5.0 (iPad; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
|
||||||
|
|
||||||
|
client := &httpClient{
|
||||||
|
url: fmt.Sprintf(acLivePage, s.UID),
|
||||||
|
method: fasthttp.MethodGet,
|
||||||
|
userAgent: mobileUserAgent,
|
||||||
|
}
|
||||||
|
resp, err := client.doRequest()
|
||||||
|
checkErr(err)
|
||||||
|
defer fasthttp.ReleaseResponse(resp)
|
||||||
|
body := getBody(resp)
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
|
||||||
|
checkErr(err)
|
||||||
|
return doc.Find("p.closed-tip").Text() != "直播已结束"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文件名按字节的长度上限,留几个字节给后面追加的扩展名
|
||||||
|
const maxFilenameBytes = 250
|
||||||
|
|
||||||
|
// 转换文件名和限制文件名长度,添加保存文件夹的路径
|
||||||
|
func transFilename(filename string) string {
|
||||||
|
// 转换文件名不允许的特殊字符
|
||||||
|
re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`)
|
||||||
|
filename = re.ReplaceAllString(filename, "-")
|
||||||
|
// linux 和 macOS 下文件名有字节数上限,按字节截断时不能把一个多字节字符切成两半
|
||||||
|
if len(filename) > maxFilenameBytes {
|
||||||
|
cut := maxFilenameBytes
|
||||||
|
for cut > 0 && !utf8.RuneStart(filename[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
filename = filename[:cut]
|
||||||
|
}
|
||||||
|
return filepath.Join(recordingDir(), filename)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// windows 下全路径的字符数上限,留几个字符给后面追加的扩展名
|
||||||
|
const maxPathRunes = 250
|
||||||
|
|
||||||
|
// 转换文件名和限制文件名长度,添加保存文件夹的路径
|
||||||
|
func transFilename(filename string) string {
|
||||||
|
// 转换文件名不允许的特殊字符
|
||||||
|
re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`)
|
||||||
|
filename = re.ReplaceAllString(filename, "-")
|
||||||
|
dir := recordingDir()
|
||||||
|
outFilename := filepath.Join(dir, filename)
|
||||||
|
if utf8.RuneCountInString(outFilename) <= maxPathRunes {
|
||||||
|
return outFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径过长时截断文件名而不是直接放弃录制
|
||||||
|
keep := maxPathRunes - utf8.RuneCountInString(dir) - 2
|
||||||
|
if keep < 10 {
|
||||||
|
lPrintErrf("保存文件夹的路径太长,无法生成文件名:%s", dir)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(filename)
|
||||||
|
if len(runes) > keep {
|
||||||
|
runes = runes[:keep]
|
||||||
|
}
|
||||||
|
outFilename = filepath.Join(dir, string(runes))
|
||||||
|
lPrintWarnf("文件名过长,已截断为 %s", filepath.Base(outFilename))
|
||||||
|
return outFilename
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// web UI 的文件管理和磁盘空间
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文件管理里的一个文件
|
||||||
|
type fileEntry struct {
|
||||||
|
Name string `json:"name"` // 文件名
|
||||||
|
Dir string `json:"dir"` // record 录播,merge 成片
|
||||||
|
Size int64 `json:"size"` // 字节
|
||||||
|
ModTime int64 `json:"modTime"` // 修改时间,Unix 秒
|
||||||
|
InUse bool `json:"inUse"` // 正在写入(录制分段或合并成片),不允许删除
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在被写入的文件集合,避免用户在 web UI 里删掉还在录制的文件
|
||||||
|
var activeFiles struct {
|
||||||
|
sync.Mutex
|
||||||
|
m map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径比较用的统一形式:先转绝对路径,Windows 下再统一大小写
|
||||||
|
// 录制侧拼出来的路径可能是相对的(-record .),文件管理接口拼的是绝对路径,
|
||||||
|
// 不先归一的话「正在写入」的判断会对不上
|
||||||
|
func normalizePath(path string) string {
|
||||||
|
if abs, err := filepath.Abs(path); err == nil {
|
||||||
|
path = abs
|
||||||
|
}
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
path = strings.ToLower(path)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记文件开始/结束写入
|
||||||
|
func markFileActive(path string, active bool) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := normalizePath(path)
|
||||||
|
activeFiles.Lock()
|
||||||
|
defer activeFiles.Unlock()
|
||||||
|
if activeFiles.m == nil {
|
||||||
|
activeFiles.m = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
activeFiles.m[key] = struct{}{}
|
||||||
|
} else {
|
||||||
|
delete(activeFiles.m, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件是否正在被写入
|
||||||
|
func isFileActive(path string) bool {
|
||||||
|
key := normalizePath(path)
|
||||||
|
activeFiles.Lock()
|
||||||
|
defer activeFiles.Unlock()
|
||||||
|
_, ok := activeFiles.m[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件管理里 dir 参数对应的文件夹
|
||||||
|
func fileDir(kind string) string {
|
||||||
|
if kind == "merge" {
|
||||||
|
return mergeSaveDir()
|
||||||
|
}
|
||||||
|
return recordSaveDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许文件名本身,防止用相对路径跳出录音文件夹
|
||||||
|
func resolveFile(kind, name string) (string, error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", errors.New("缺少文件名")
|
||||||
|
}
|
||||||
|
// 不管哪个平台都拒绝路径分隔符,Windows 下 Base 会认反斜杠,Linux 下不会
|
||||||
|
if name == "." || name == ".." || name != filepath.Base(filepath.FromSlash(name)) ||
|
||||||
|
strings.ContainsAny(name, `/\`) {
|
||||||
|
return "", errors.New("文件名不合法")
|
||||||
|
}
|
||||||
|
dir, err := filepath.Abs(fileDir(kind))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
full := filepath.Join(dir, name)
|
||||||
|
rel, err := filepath.Rel(dir, full)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return "", errors.New("文件名不合法")
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出文件夹里的文件(不含子文件夹)
|
||||||
|
func listFiles(dir string) []fileEntry {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]fileEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fileEntry{
|
||||||
|
Name: e.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime().Unix(),
|
||||||
|
InUse: isFileActive(filepath.Join(dir, e.Name())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览器能直接播放的格式
|
||||||
|
func playableFile(name string) bool {
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".mp4", ".m4v", ".webm", ".ogv":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示用的绝对路径,取不到就返回原值
|
||||||
|
func absPath(path string) string {
|
||||||
|
if abs, err := filepath.Abs(path); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出录播和成片文件夹里的文件、目录位置和磁盘剩余空间
|
||||||
|
func filesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
recordDir, mergeDir := recordSaveDir(), mergeSaveDir()
|
||||||
|
|
||||||
|
files := make([]fileEntry, 0, 64)
|
||||||
|
for _, f := range listFiles(recordDir) {
|
||||||
|
f.Dir = "record"
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
if normalizePath(mergeDir) != normalizePath(recordDir) {
|
||||||
|
for _, f := range listFiles(mergeDir) {
|
||||||
|
f.Dir = "merge"
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 最近修改的排前面
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
if files[i].ModTime != files[j].ModTime {
|
||||||
|
return files[i].ModTime > files[j].ModTime
|
||||||
|
}
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
free, total, err := diskSpace(saveRootDir())
|
||||||
|
if err != nil {
|
||||||
|
lPrintWarnf("获取磁盘剩余空间失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := struct {
|
||||||
|
RecordDir string `json:"recordDir"`
|
||||||
|
MergeDir string `json:"mergeDir"`
|
||||||
|
Free uint64 `json:"free"`
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Files []fileEntry `json:"files"`
|
||||||
|
}{absPath(recordDir), absPath(mergeDir), free, total, files}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
lPrintErrf("输出文件列表失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件,?inline=1 时让浏览器直接播放(只对浏览器支持的格式生效)
|
||||||
|
func fileDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind := r.URL.Query().Get("dir")
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
full, err := resolveFile(kind, name)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.Error(w, "文件不存在", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("inline") == "1" && playableFile(name) {
|
||||||
|
// ServeFile 会用文件扩展名推测 Content-Type,这里不额外设置
|
||||||
|
} else {
|
||||||
|
// 中文文件名要用 RFC 5987 的写法,否则部分浏览器会乱码
|
||||||
|
w.Header().Set("Content-Disposition",
|
||||||
|
"attachment; filename*=UTF-8''"+url.PathEscape(filepath.Base(full)))
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
func fileRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full, err := resolveFile(req.Dir, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isFileActive(full) {
|
||||||
|
http.Error(w, "这个文件正在录制或合并中,不能删除", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.Error(w, "文件不存在", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Remove(full); err != nil {
|
||||||
|
http.Error(w, "删除失败:"+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintf("通过 web UI 删除了文件 %s(%d 字节)", full, info.Size())
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte("true"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module github.com/orzogc/acfunlive
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.13.0
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
|
github.com/orzogc/acfundanmu v0.0.0-20240910132643-93679007a97d
|
||||||
|
github.com/valyala/fasthttp v1.73.0
|
||||||
|
github.com/valyala/fastjson v1.6.10
|
||||||
|
golang.org/x/image v0.46.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
facette.io/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
|
||||||
|
github.com/Workiva/go-datastructures v1.1.7 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.3 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
github.com/orzogc/fastws v1.0.5-0.20230809182400-6c9094d8c52e // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/stretchr/testify v1.12.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
facette.io/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:1pSweJFeR3Pqx7uoelppkzeegfUBXL6I2FFAbfXw570=
|
||||||
|
facette.io/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:npRYmtaITVom7rcSo+pRURltHSG2r4TQM1cdqJ2dUB0=
|
||||||
|
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
|
||||||
|
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
|
||||||
|
github.com/Workiva/go-datastructures v1.1.7 h1:q5RXlAeKm3zDpZTbYXwdMb1gN9RtGSvOCtPXGJJL6Cs=
|
||||||
|
github.com/Workiva/go-datastructures v1.1.7/go.mod h1:1yZL+zfsztete+ePzZz/Zb1/t5BnDuE2Ya2MMGhzP6A=
|
||||||
|
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
|
||||||
|
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
|
github.com/orzogc/acfundanmu v0.0.0-20240910132643-93679007a97d h1:6fOdERp9RiugE/wvWD7tFuoM69nZwp4wmqA6T5APOTs=
|
||||||
|
github.com/orzogc/acfundanmu v0.0.0-20240910132643-93679007a97d/go.mod h1:IX7NLxpewGhcxj1GM/T6uHqy2p3wODZ60ajgzEuxYtg=
|
||||||
|
github.com/orzogc/fastws v1.0.5-0.20230809182400-6c9094d8c52e h1:Y9G+uvJg6lVYmIo37SIQu3uiRdELT1fikmYcM1ifK8g=
|
||||||
|
github.com/orzogc/fastws v1.0.5-0.20230809182400-6c9094d8c52e/go.mod h1:t7IMS/l1UPNzSM/ZgZRfplA8cBi0KyqFIEflap5VOOU=
|
||||||
|
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg=
|
||||||
|
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||||
|
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||||
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 命令处理相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 错误命令信息
|
||||||
|
const handleErrMsg = "请输入正确的命令,输入 help 查看全部命令的解释"
|
||||||
|
|
||||||
|
// 帮助信息
|
||||||
|
const helpMsg = `listlive:列出正在直播的主播
|
||||||
|
listdanmu:列出正在录制直播弹幕的主播
|
||||||
|
listrecord:列出正在录制直播视频的主播
|
||||||
|
adddanmu uid:添加指定主播到录制列表(弹幕和录播都开启),uid 在主播的网页版个人主页查看
|
||||||
|
deldanmu uid:将指定主播移出录制列表
|
||||||
|
addrecord uid:开启指定主播的直播视频录制
|
||||||
|
delrecord uid:关闭指定主播的直播视频录制
|
||||||
|
startdanmu uid:临时录制指定主播的直播弹幕
|
||||||
|
stopdanmu uid:停止录制指定主播的直播弹幕
|
||||||
|
startrecord uid:临时录制指定主播的直播视频
|
||||||
|
stoprecord uid:停止录制指定主播的直播视频
|
||||||
|
quit:退出本程序,退出需要等待半分钟左右
|
||||||
|
help:输出本帮助信息`
|
||||||
|
|
||||||
|
var uidBoolDispatch = map[string]func(int) bool{
|
||||||
|
"adddanmu": addUID,
|
||||||
|
"deldanmu": removeUID,
|
||||||
|
"startdanmu": startDanmu,
|
||||||
|
"stopdanmu": stopDanmu,
|
||||||
|
"startrecord": startRecord,
|
||||||
|
"stoprecord": stopRecord,
|
||||||
|
"addrecord": func(uid int) bool { return setRecordFlag(uid, true) },
|
||||||
|
"delrecord": func(uid int) bool { return setRecordFlag(uid, false) },
|
||||||
|
}
|
||||||
|
|
||||||
|
var listDispatch = map[string]func(){
|
||||||
|
"listlive": listLive,
|
||||||
|
"listdanmu": listDanmu,
|
||||||
|
"listrecord": listRecord,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理单个命令
|
||||||
|
func handleCmd(cmd string) bool {
|
||||||
|
if d, ok := listDispatch[cmd]; ok {
|
||||||
|
d()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "quit":
|
||||||
|
quitRun()
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
lPrintErr("错误的命令:" + cmd)
|
||||||
|
printErr()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 "命令 UID"
|
||||||
|
func handleCmdUID(cmd string, uid int) bool {
|
||||||
|
if d, ok := uidBoolDispatch[cmd]; ok {
|
||||||
|
return d(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintErr("错误的命令:"+cmd, uid)
|
||||||
|
printErr()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印错误命令信息
|
||||||
|
func printErr() {
|
||||||
|
lPrintWarn(handleErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理所有命令
|
||||||
|
func handleAllCmd(text string) bool {
|
||||||
|
cmd := strings.Fields(text)
|
||||||
|
switch len(cmd) {
|
||||||
|
case 1:
|
||||||
|
switch cmd[0] {
|
||||||
|
case "help":
|
||||||
|
fmt.Println(helpMsg)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return handleCmd(cmd[0])
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if uid, err := strconv.ParseUint(cmd[1], 10, 64); err == nil {
|
||||||
|
return handleCmdUID(cmd[0], int(uid))
|
||||||
|
}
|
||||||
|
printErr()
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
printErr()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 命令输入相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 列出正在直播的主播
|
||||||
|
func listLive() {
|
||||||
|
streamers.RLock()
|
||||||
|
lives := make([]streamer, 0, len(streamers.crt))
|
||||||
|
for _, s := range streamers.crt {
|
||||||
|
if s.isLiveOn() {
|
||||||
|
lives = append(lives, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamers.RUnlock()
|
||||||
|
|
||||||
|
sort.Slice(lives, func(i, j int) bool {
|
||||||
|
return lives[i].UID < lives[j].UID
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("正在直播的主播:")
|
||||||
|
for _, s := range lives {
|
||||||
|
log.Println(s.longID() + ":" + s.getTitle() + " " + s.getURL())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出正在录制直播弹幕的主播
|
||||||
|
func listDanmu() {
|
||||||
|
lInfoMap.RLock()
|
||||||
|
danmu := make([]liveInfo, 0, len(lInfoMap.info))
|
||||||
|
for _, info := range lInfoMap.info {
|
||||||
|
if info.isDanmu {
|
||||||
|
danmu = append(danmu, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lInfoMap.RUnlock()
|
||||||
|
|
||||||
|
sort.Slice(danmu, func(i, j int) bool {
|
||||||
|
return danmu[i].uid < danmu[j].uid
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("正在录制直播弹幕的主播:")
|
||||||
|
for _, d := range danmu {
|
||||||
|
s := streamer{UID: d.uid, Name: getName(d.uid)}
|
||||||
|
log.Println(s.longID() + ":" + s.getTitle() + " " + s.getURL())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出正在录制直播视频的主播
|
||||||
|
func listRecord() {
|
||||||
|
lInfoMap.RLock()
|
||||||
|
recs := make([]liveInfo, 0, len(lInfoMap.info))
|
||||||
|
for _, info := range lInfoMap.info {
|
||||||
|
if info.isRecording {
|
||||||
|
recs = append(recs, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lInfoMap.RUnlock()
|
||||||
|
|
||||||
|
sort.Slice(recs, func(i, j int) bool {
|
||||||
|
return recs[i].uid < recs[j].uid
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("正在录制直播视频的主播:")
|
||||||
|
for _, r := range recs {
|
||||||
|
s := streamer{UID: r.uid, Name: getName(r.uid)}
|
||||||
|
log.Println(s.longID() + ":" + r.recordFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知 main() 退出程序
|
||||||
|
func quitRun() {
|
||||||
|
lPrintln("正在准备退出,请等待...")
|
||||||
|
q := controlMsg{c: quit}
|
||||||
|
mainCh <- q
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理输入
|
||||||
|
func handleInput() {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in handleInput(), the error is:", err)
|
||||||
|
lPrintErr("输入处理发生错误,尝试重启输入处理")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
go handleInput()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for scanner.Scan() {
|
||||||
|
handleAllCmd(scanner.Text())
|
||||||
|
}
|
||||||
|
err := scanner.Err()
|
||||||
|
checkErr(err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// AcFun 直播弹幕录制工具
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orzogc/acfundanmu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 命令行参数处理
|
||||||
|
func argsHandle() {
|
||||||
|
const usageStr = "本程序用于录制 AcFun 主播的直播弹幕,在 " + configFile + " 的 uids 里填入要录制弹幕的主播 uid 列表\n" +
|
||||||
|
"指定 -danmuburn 时进入弹幕合成模式:读取录制弹幕时生成的 jsonl 文件,合成带头像的弹幕层\n" +
|
||||||
|
"弹幕文件也可以直接给 .ass(原版录制的就是这种),会先还原成 jsonl 再合成"
|
||||||
|
|
||||||
|
isHelp := flag.Bool("h", false, "输出本帮助信息")
|
||||||
|
configDir = flag.String("config", "", "设置文件所在文件夹,默认是本程序所在文件夹")
|
||||||
|
recordDir = flag.String("record", "", "弹幕文件保存的文件夹,默认是本程序所在文件夹")
|
||||||
|
danmuburnFile := flag.String("danmuburn", "", "要合成的弹幕文件(.jsonl 或 .ass)路径,指定后进行弹幕合成然后退出,不启动录制")
|
||||||
|
burnVideo := flag.String("video", "", "录播视频路径,配合 -danmuburn 把弹幕压进视频;不指定则输出透明头像层 mov")
|
||||||
|
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
|
||||||
|
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
|
||||||
|
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
|
||||||
|
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为默认质量,配合 -danmuburn 使用")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *isHelp {
|
||||||
|
fmt.Println(usageStr)
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
if flag.NArg() != 0 {
|
||||||
|
fmt.Println(usageStr)
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *danmuburnFile != "" {
|
||||||
|
loadConfigForDanmuburn()
|
||||||
|
// 命令行显式给的优先于设置文件里的
|
||||||
|
if *burnFFmpeg != "" {
|
||||||
|
config.FFmpegPath = *burnFFmpeg
|
||||||
|
}
|
||||||
|
runDanmuburn(*danmuburnFile, *burnVideo, *burnOut, *burnOffset)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 程序初始化
|
||||||
|
func initialize() {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
checkErr(err)
|
||||||
|
exeDir = filepath.Dir(exePath)
|
||||||
|
if *configDir == "" {
|
||||||
|
*configDir = exeDir
|
||||||
|
} else {
|
||||||
|
info, err := os.Stat(*configDir)
|
||||||
|
checkErr(err)
|
||||||
|
if !info.IsDir() {
|
||||||
|
lPrintErrf("指定的设置文件夹 %s 并不是真正的文件夹", *configDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *recordDir == "" {
|
||||||
|
*recordDir = exeDir
|
||||||
|
} else {
|
||||||
|
info, err := os.Stat(*recordDir)
|
||||||
|
checkErr(err)
|
||||||
|
if !info.IsDir() {
|
||||||
|
lPrintErrf("指定的弹幕保存文件夹 %s 并不是真正的文件夹", *recordDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configFileLocation = filepath.Join(*configDir, configFile)
|
||||||
|
|
||||||
|
if !isConfigFileExist(configFile) {
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
checkErr(err)
|
||||||
|
err = os.WriteFile(configFileLocation, data, 0644)
|
||||||
|
checkErr(err)
|
||||||
|
lPrintln("创建设置文件" + configFile + ",请在 uids 里填入要录制弹幕的主播 uid 列表")
|
||||||
|
}
|
||||||
|
|
||||||
|
sInfoMap.info = make(map[int]*streamerInfo)
|
||||||
|
lInfoMap.info = make(map[string]liveInfo)
|
||||||
|
streamers.crt = make(map[int]streamer)
|
||||||
|
streamers.old = make(map[int]streamer)
|
||||||
|
|
||||||
|
deviceID, err = acfundanmu.GetDeviceID()
|
||||||
|
checkErr(err)
|
||||||
|
|
||||||
|
if ok := fetchAllRooms(); !ok {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
liveRooms.rooms = liveRooms.newRooms
|
||||||
|
|
||||||
|
loadConfig()
|
||||||
|
checkConfig()
|
||||||
|
ensureAuthToken()
|
||||||
|
buildStreamers()
|
||||||
|
|
||||||
|
for uid, s := range streamers.crt {
|
||||||
|
streamers.old[uid] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
argsHandle()
|
||||||
|
|
||||||
|
if len(streamers.crt) == 0 {
|
||||||
|
lPrintWarn("请在" + configFile + "的 uids 里填入要录制弹幕的主播 uid 列表")
|
||||||
|
} else {
|
||||||
|
lPrintln("本程序开始监听主播的直播状态,自动录制直播弹幕")
|
||||||
|
}
|
||||||
|
|
||||||
|
if getFFmpeg() == "" {
|
||||||
|
lPrintWarn("没有找到 ffmpeg,无法录制直播视频,弹幕合并也无法进行;请安装 ffmpeg 并加入 PATH," +
|
||||||
|
"或把 ffmpeg 放在本程序所在文件夹,或在 web UI 设置区填写「ffmpeg 路径」")
|
||||||
|
}
|
||||||
|
lPrintf("录播文件格式:%s(可在 web UI 或 %s 的 recordFormat 里改成 flv / ts / mkv)",
|
||||||
|
normalizeRecordFormat(config.RecordFormat), configFile)
|
||||||
|
|
||||||
|
mainCh = make(chan controlMsg, 20)
|
||||||
|
|
||||||
|
for _, s := range streamers.crt {
|
||||||
|
st := s
|
||||||
|
safeGo(st.longID()+"的监听循环", func() { st.cycle("") })
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
mainCtx = ctx
|
||||||
|
|
||||||
|
safeGo("监控设置文件", func() { cycleConfig(ctx) })
|
||||||
|
safeGo("获取直播间数据", func() { cycleFetch(ctx) })
|
||||||
|
safeGo("清理无用的直播信息", func() { cycleDelKey(ctx) })
|
||||||
|
|
||||||
|
if config.WebPort > 0 {
|
||||||
|
safeGo("web UI 服务器", func() { startWebUI(ctx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintln("现在可以输入命令控制本程序,输入 help 查看全部命令的解释")
|
||||||
|
safeGo("命令行输入", handleInput)
|
||||||
|
|
||||||
|
// 收到 Ctrl+C(或 Linux 下的终止信号)时走和 quit 命令一样的退出流程,
|
||||||
|
// 否则进程会直接消失,ffmpeg 变成孤儿进程继续录制
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
safeGo("退出信号监听", func() {
|
||||||
|
sig := <-sigCh
|
||||||
|
lPrintln("收到 " + sig.String() + " 信号,正在退出(再按一次可强制退出)")
|
||||||
|
select {
|
||||||
|
case mainCh <- controlMsg{c: quit}:
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-sigCh
|
||||||
|
lPrintln("再次收到退出信号,强制退出")
|
||||||
|
os.Exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
Outer:
|
||||||
|
for msg := range mainCh {
|
||||||
|
switch msg.c {
|
||||||
|
case startCycle:
|
||||||
|
m := msg
|
||||||
|
safeGo(m.s.longID()+"的监听循环", func() { m.s.cycle(m.liveID) })
|
||||||
|
case quit:
|
||||||
|
// 结束所有 mainCtx 的子 ctx
|
||||||
|
cancel()
|
||||||
|
// 结束 cycle()
|
||||||
|
lPrintln("正在退出各主播的循环")
|
||||||
|
sInfoMap.Lock()
|
||||||
|
for _, info := range sInfoMap.info {
|
||||||
|
// 退出各主播的循环
|
||||||
|
if info.ch != nil {
|
||||||
|
info.ch <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sInfoMap.Unlock()
|
||||||
|
lInfoMap.RLock()
|
||||||
|
for _, info := range lInfoMap.info {
|
||||||
|
// 结束录制弹幕
|
||||||
|
if info.isDanmu {
|
||||||
|
info.danmuCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lInfoMap.RUnlock()
|
||||||
|
// 等待 20 秒,等待弹幕文件写入完成
|
||||||
|
time.Sleep(20 * time.Second)
|
||||||
|
break Outer
|
||||||
|
default:
|
||||||
|
lPrintErrf("未知 controlMsg:%+v", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintln("本程序结束运行")
|
||||||
|
}
|
||||||
+555
@@ -0,0 +1,555 @@
|
|||||||
|
// web UI 的手动合并:自己挑视频和弹幕合成一个成片
|
||||||
|
// 自动合并只在录制自然结束时触发(手动停止过、或者弹幕和视频对不上号的都不会合并),
|
||||||
|
// 这里补上那条路:选文件 → 检查 → 合成
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 允许作为合并输入的视频扩展名
|
||||||
|
var mergeVideoExts = map[string]struct{}{
|
||||||
|
".mp4": {}, ".m4v": {}, ".flv": {}, ".ts": {}, ".mkv": {}, ".mov": {}, ".webm": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许作为合并输入的弹幕扩展名
|
||||||
|
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
||||||
|
|
||||||
|
// 手动合并的请求体
|
||||||
|
type mergeRequest struct {
|
||||||
|
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
||||||
|
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
||||||
|
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
||||||
|
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查结果里每个视频的信息
|
||||||
|
type mergeVideoInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
InUse bool `json:"inUse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动合并的检查结果,页面直接展示
|
||||||
|
type mergeInfo struct {
|
||||||
|
Videos []mergeVideoInfo `json:"videos"`
|
||||||
|
DanmuName string `json:"danmuName"`
|
||||||
|
DanmuCount int `json:"danmuCount"`
|
||||||
|
DanmuFirst float64 `json:"danmuFirst"`
|
||||||
|
DanmuLast float64 `json:"danmuLast"`
|
||||||
|
DanmuHasAvatar bool `json:"danmuHasAvatar"`
|
||||||
|
Source string `json:"source"` // 弹幕数据的来源说明(jsonl 还是从 ass 还原)
|
||||||
|
Offset float64 `json:"offset"`
|
||||||
|
OffsetNote string `json:"offsetNote"`
|
||||||
|
OutName string `json:"outName"`
|
||||||
|
Duration float64 `json:"duration"` // 视频总时长(秒)
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查通过后解析好的合并方案,避免检查一次、合成时又重新解析一遍
|
||||||
|
type mergePlan struct {
|
||||||
|
videos []string // 视频的绝对路径,已按录制时间排好序
|
||||||
|
danmu string // 弹幕文件的绝对路径
|
||||||
|
out string // 成片的绝对路径
|
||||||
|
meta danmuMeta
|
||||||
|
offset *float64
|
||||||
|
info mergeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动合并的运行状态,页面轮询它来决定按钮是否可用
|
||||||
|
var mergeRun struct {
|
||||||
|
sync.Mutex
|
||||||
|
busy bool
|
||||||
|
outName string
|
||||||
|
lastErr string
|
||||||
|
doneAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动合并是否正在进行
|
||||||
|
func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
|
||||||
|
mergeRun.Lock()
|
||||||
|
defer mergeRun.Unlock()
|
||||||
|
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
|
||||||
|
func resolveMergeInput(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
// 去掉从资源管理器复制路径时带上的引号
|
||||||
|
value = strings.Trim(value, `"'`)
|
||||||
|
if value == "" {
|
||||||
|
return "", errors.New("文件路径为空")
|
||||||
|
}
|
||||||
|
if value == "." || value == ".." || strings.ContainsAny(value, "\x00\r\n") {
|
||||||
|
return "", errors.New("文件路径不合法")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cands []string
|
||||||
|
if filepath.IsAbs(value) {
|
||||||
|
cands = append(cands, value)
|
||||||
|
} else {
|
||||||
|
// 不是绝对路径时优先当成录播目录里的文件名,这样页面下拉里选出来的名字能直接填
|
||||||
|
cands = append(cands, filepath.Join(recordSaveDir(), value))
|
||||||
|
if mergeDir := mergeSaveDir(); normalizePath(mergeDir) != normalizePath(recordSaveDir()) {
|
||||||
|
cands = append(cands, filepath.Join(mergeDir, value))
|
||||||
|
}
|
||||||
|
cands = append(cands, value) // 相对当前工作目录
|
||||||
|
}
|
||||||
|
for _, c := range cands {
|
||||||
|
if info, err := os.Stat(c); err == nil && !info.IsDir() {
|
||||||
|
full, err := filepath.Abs(c)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("找不到文件:%s", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展名白名单的说明文字
|
||||||
|
func extListText(m map[string]struct{}) string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件名前缀推出录制开始的时刻
|
||||||
|
// 本程序(以及上游原版)的文件名都是「2006-01-02 15-04-05 主播名 标题」
|
||||||
|
func nameStartNs(path string) int64 {
|
||||||
|
name := filepath.Base(path)
|
||||||
|
if len(name) < 19 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("2006-01-02 15-04-05", name[:19], time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.UnixNano()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名里没有时间戳时,用「修改时间 - 时长」估算视频的起点
|
||||||
|
func estimateStartNs(ffmpegFile, path string) int64 {
|
||||||
|
d := probeDuration(ffmpegFile, path)
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.ModTime().UnixNano() - int64(d*1e9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推测弹幕相对视频起点的时间偏移:视频时间 = 弹幕时间 + 偏移
|
||||||
|
func guessOffset(videos []string, danmuPath string, meta danmuMeta) (float64, string) {
|
||||||
|
if len(videos) == 1 {
|
||||||
|
videoBase := strings.TrimSuffix(filepath.Base(videos[0]), filepath.Ext(videos[0]))
|
||||||
|
if strings.EqualFold(danmuBaseName(danmuPath), videoBase) {
|
||||||
|
return 0, "视频和弹幕文件名一致,按同一场录制处理"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
danmuStart, fromMeta := nameStartNs(danmuPath), false
|
||||||
|
if danmuStart == 0 && meta.StartTime > 0 {
|
||||||
|
danmuStart, fromMeta = meta.StartTime, true
|
||||||
|
}
|
||||||
|
if danmuStart == 0 {
|
||||||
|
return 0, "弹幕文件里没有录制开始时间,偏移按 0 处理"
|
||||||
|
}
|
||||||
|
|
||||||
|
videoStart := int64(0)
|
||||||
|
if len(videos) > 0 {
|
||||||
|
videoStart = nameStartNs(videos[0])
|
||||||
|
if videoStart == 0 {
|
||||||
|
videoStart = estimateStartNs(getFFmpeg(), videos[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if videoStart == 0 {
|
||||||
|
return 0, "无法确定视频的开始时间,偏移按 0 处理"
|
||||||
|
}
|
||||||
|
|
||||||
|
off := float64(danmuStart-videoStart) / 1e9
|
||||||
|
note := "按文件名里的录制时间推算"
|
||||||
|
if fromMeta {
|
||||||
|
note = "视频按文件名时间、弹幕按 jsonl 里记录的开始时间推算"
|
||||||
|
}
|
||||||
|
if math.Abs(off) > 60 {
|
||||||
|
note += ";偏移偏大,请确认选的是同一场录制"
|
||||||
|
}
|
||||||
|
return off, note
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备弹幕源:.jsonl 直接用;.ass / .nick.ass 还原成 jsonl
|
||||||
|
// write 为 false 时只解析不落盘(页面上的「检查」用)
|
||||||
|
type danmuSource struct {
|
||||||
|
jsonlPath string
|
||||||
|
meta danmuMeta
|
||||||
|
count int
|
||||||
|
first float64
|
||||||
|
last float64
|
||||||
|
hasAvatar bool
|
||||||
|
source string // 来源说明
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||||
|
if strings.EqualFold(filepath.Ext(path), ".jsonl") {
|
||||||
|
sum, err := scanJsonlSummary(path)
|
||||||
|
if err != nil {
|
||||||
|
return danmuSource{}, err
|
||||||
|
}
|
||||||
|
return danmuSource{jsonlPath: path, meta: sum.meta, count: sum.count,
|
||||||
|
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
|
||||||
|
source: "jsonl 弹幕数据"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旁边已经有同名的 jsonl(本程序录制的就有),它带着头像链接,比从 .ass 还原更好
|
||||||
|
jsonlPath := assSiblingJsonl(path)
|
||||||
|
if info, err := os.Stat(jsonlPath); err == nil && info.Size() > 0 {
|
||||||
|
if sum, err := scanJsonlSummary(jsonlPath); err == nil && sum.count > 0 {
|
||||||
|
lPrintf("发现同名的弹幕数据文件 %s,直接用(比 .ass 多了头像链接)", filepath.Base(jsonlPath))
|
||||||
|
return danmuSource{jsonlPath: jsonlPath, meta: sum.meta, count: sum.count,
|
||||||
|
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
|
||||||
|
source: "同名的 jsonl 弹幕数据"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, records, err := parseAssFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return danmuSource{}, err
|
||||||
|
}
|
||||||
|
src := danmuSource{jsonlPath: jsonlPath, meta: meta, count: len(records),
|
||||||
|
first: records[0].Time, last: records[len(records)-1].Time,
|
||||||
|
source: "由 " + filepath.Base(path) + " 还原"}
|
||||||
|
if !write {
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeJsonlFile(jsonlPath, meta, records); err != nil {
|
||||||
|
// 目录只读等情况退到临时目录,不影响这次合并
|
||||||
|
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
|
||||||
|
if err2 := writeJsonlFile(tmp, meta, records); err2 != nil {
|
||||||
|
return src, fmt.Errorf("还原弹幕数据失败:%v", err)
|
||||||
|
}
|
||||||
|
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
|
||||||
|
src.jsonlPath = tmp
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出文件名只能是文件名本身,且固定为 mp4
|
||||||
|
func sanitizeOutName(name string) (string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", errors.New("输出文件名不能为空")
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." || strings.ContainsAny(name, "\x00\r\n") ||
|
||||||
|
name != filepath.Base(filepath.FromSlash(name)) || strings.ContainsAny(name, `/\`) {
|
||||||
|
return "", errors.New("输出文件名不合法,只能填文件名本身")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(filepath.Ext(name), ".mp4") {
|
||||||
|
name += ".mp4"
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找一个不冲突的输出路径,不默默覆盖上一次的成片
|
||||||
|
func freeOutPath(dir, base string) string {
|
||||||
|
cand := filepath.Join(dir, base+".mp4")
|
||||||
|
if _, err := os.Stat(cand); err != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
for i := 2; i < 1000; i++ {
|
||||||
|
p := filepath.Join(dir, fmt.Sprintf("%s(%d).mp4", base, i))
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求、检查文件并算出合并参数
|
||||||
|
func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||||
|
var plan mergePlan
|
||||||
|
|
||||||
|
// 视频:一行一个,允许夹带空行
|
||||||
|
var values []string
|
||||||
|
for _, v := range req.Videos {
|
||||||
|
for _, p := range strings.FieldsFunc(v, func(r rune) bool { return r == '\n' || r == '\r' }) {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
values = append(values, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return plan, errors.New("请先选择要合并的视频文件")
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
full, err := resolveMergeInput(v)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
if _, ok := mergeVideoExts[strings.ToLower(filepath.Ext(full))]; !ok {
|
||||||
|
return plan, fmt.Errorf("不支持的视频格式 %s,支持 %s", filepath.Ext(full), extListText(mergeVideoExts))
|
||||||
|
}
|
||||||
|
if isFileActive(full) {
|
||||||
|
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(full))
|
||||||
|
}
|
||||||
|
plan.videos = append(plan.videos, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Danmu) == "" {
|
||||||
|
return plan, errors.New("请先选择弹幕文件")
|
||||||
|
}
|
||||||
|
danmuPath, err := resolveMergeInput(req.Danmu)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
if _, ok := mergeDanmuExts[strings.ToLower(filepath.Ext(danmuPath))]; !ok {
|
||||||
|
return plan, fmt.Errorf("不支持的弹幕格式 %s,支持 %s", filepath.Ext(danmuPath), extListText(mergeDanmuExts))
|
||||||
|
}
|
||||||
|
if isFileActive(danmuPath) {
|
||||||
|
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(danmuPath))
|
||||||
|
}
|
||||||
|
plan.danmu = danmuPath
|
||||||
|
|
||||||
|
// 先只解析不落盘,检查阶段不应该产生文件
|
||||||
|
src, err := prepareDanmuSource(danmuPath, false)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
if src.count == 0 {
|
||||||
|
return plan, errNoDanmu
|
||||||
|
}
|
||||||
|
plan.meta = src.meta
|
||||||
|
|
||||||
|
// 按录制的开始时间排序,用户填反了也能自动纠正
|
||||||
|
sort.SliceStable(plan.videos, func(i, j int) bool {
|
||||||
|
si, sj := nameStartNs(plan.videos[i]), nameStartNs(plan.videos[j])
|
||||||
|
if si == 0 || sj == 0 {
|
||||||
|
return plan.videos[i] < plan.videos[j]
|
||||||
|
}
|
||||||
|
return si < sj
|
||||||
|
})
|
||||||
|
|
||||||
|
info := mergeInfo{
|
||||||
|
DanmuName: filepath.Base(danmuPath),
|
||||||
|
DanmuCount: src.count,
|
||||||
|
DanmuFirst: src.first,
|
||||||
|
DanmuLast: src.last,
|
||||||
|
DanmuHasAvatar: src.hasAvatar,
|
||||||
|
Source: src.source,
|
||||||
|
}
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
for _, f := range plan.videos {
|
||||||
|
fi, err := os.Stat(f)
|
||||||
|
if err != nil {
|
||||||
|
return plan, fmt.Errorf("读不到文件信息:%s", filepath.Base(f))
|
||||||
|
}
|
||||||
|
d := 0.0
|
||||||
|
if ffmpegFile != "" {
|
||||||
|
d = probeDuration(ffmpegFile, f)
|
||||||
|
}
|
||||||
|
info.Duration += d
|
||||||
|
info.Videos = append(info.Videos, mergeVideoInfo{
|
||||||
|
Name: filepath.Base(f), Size: fi.Size(), Duration: d, InUse: isFileActive(f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
return plan, errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Offset, info.OffsetNote = guessOffset(plan.videos, danmuPath, src.meta)
|
||||||
|
if req.Offset != nil {
|
||||||
|
plan.offset = req.Offset
|
||||||
|
info.Offset = *req.Offset
|
||||||
|
info.OffsetNote = "按你填写的偏移"
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeDir := mergeSaveDir()
|
||||||
|
if strings.TrimSpace(req.OutName) == "" {
|
||||||
|
plan.out = freeOutPath(mergeDir, danmuBaseName(danmuPath)+"_弹幕合并")
|
||||||
|
} else {
|
||||||
|
name, err := sanitizeOutName(req.OutName)
|
||||||
|
if err != nil {
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
plan.out = filepath.Join(mergeDir, name)
|
||||||
|
}
|
||||||
|
info.OutName = filepath.Base(plan.out)
|
||||||
|
|
||||||
|
if !src.hasAvatar {
|
||||||
|
info.Warnings = append(info.Warnings,
|
||||||
|
"这个弹幕文件里没有头像链接,合成时会按 uid 向 AcFun 反查头像;个别查不到的只烧文字")
|
||||||
|
}
|
||||||
|
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
|
||||||
|
info.Warnings = append(info.Warnings, fmt.Sprintf(
|
||||||
|
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
|
||||||
|
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来",
|
||||||
|
src.last+info.Offset, info.Duration))
|
||||||
|
}
|
||||||
|
if info.Duration == 0 {
|
||||||
|
info.Warnings = append(info.Warnings, "读不出视频时长,合成时无法裁掉超出视频的弹幕")
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.info = info
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真正执行合并,进度直接打到运行日志里
|
||||||
|
func runManualMerge(plan mergePlan) error {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := prepareDanmuSource(plan.danmu, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
|
||||||
|
segs := make([]recordSegment, 0, len(plan.videos))
|
||||||
|
for _, f := range plan.videos {
|
||||||
|
start := nameStartNs(f)
|
||||||
|
if start == 0 {
|
||||||
|
start = estimateStartNs(ffmpegFile, f)
|
||||||
|
}
|
||||||
|
if start == 0 {
|
||||||
|
start = time.Now().UnixNano()
|
||||||
|
}
|
||||||
|
segs = append(segs, recordSegment{file: f, startNs: start})
|
||||||
|
}
|
||||||
|
if len(segs) > 1 {
|
||||||
|
lPrintf("手动合并:%d 个分段按录制时间顺序拼成一个视频后再合成", len(segs))
|
||||||
|
}
|
||||||
|
|
||||||
|
danmuStartNs := nameStartNs(plan.danmu)
|
||||||
|
if danmuStartNs == 0 {
|
||||||
|
danmuStartNs = src.meta.StartTime
|
||||||
|
}
|
||||||
|
if danmuStartNs == 0 {
|
||||||
|
danmuStartNs = segs[0].startNs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴
|
||||||
|
video, jsonl, autoOffset, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs)
|
||||||
|
defer cleanup()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, note := autoOffset, "按文件名里的录制时间自动推算"
|
||||||
|
if plan.offset != nil {
|
||||||
|
offset, note = *plan.offset, "按你填写的偏移"
|
||||||
|
}
|
||||||
|
if math.Abs(offset) > 60 {
|
||||||
|
lPrintWarnf("偏移 %.1f 秒偏大,如果弹幕整体对不上画面,调整偏移后重新合并", offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(plan.out), 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建成片文件夹失败:%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(plan.out); err == nil {
|
||||||
|
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
||||||
|
filepath.Base(video), filepath.Base(jsonl), offset, note)
|
||||||
|
|
||||||
|
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
||||||
|
markFileActive(plan.out, true)
|
||||||
|
burnErr := burnDanmu(jsonl, video, plan.out, offset)
|
||||||
|
markFileActive(plan.out, false)
|
||||||
|
if burnErr != nil {
|
||||||
|
return burnErr
|
||||||
|
}
|
||||||
|
|
||||||
|
lPrintln("手动合并完成:" + plan.out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
|
||||||
|
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req mergeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := buildMergePlan(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(plan.info); err != nil {
|
||||||
|
lPrintErrf("输出合并检查结果失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始手动合并:立刻返回,合成在后台进行
|
||||||
|
func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req mergeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := buildMergePlan(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeRun.Lock()
|
||||||
|
if mergeRun.busy {
|
||||||
|
busyOut := mergeRun.outName
|
||||||
|
mergeRun.Unlock()
|
||||||
|
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeRun.busy = true
|
||||||
|
mergeRun.outName = plan.info.OutName
|
||||||
|
mergeRun.lastErr = ""
|
||||||
|
mergeRun.Unlock()
|
||||||
|
|
||||||
|
safeGo("手动合并", func() {
|
||||||
|
defer func() {
|
||||||
|
mergeRun.Lock()
|
||||||
|
mergeRun.busy = false
|
||||||
|
mergeRun.doneAt = time.Now().Unix()
|
||||||
|
mergeRun.Unlock()
|
||||||
|
}()
|
||||||
|
if err := runManualMerge(plan); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
if err == errNoDanmu {
|
||||||
|
msg = "弹幕文件里没有弹幕"
|
||||||
|
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
|
||||||
|
} else {
|
||||||
|
lPrintErrf("手动合并失败:%v", err)
|
||||||
|
}
|
||||||
|
mergeRun.Lock()
|
||||||
|
mergeRun.lastErr = msg
|
||||||
|
mergeRun.Unlock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 原格式 ASS(上游原版用 \move,弹幕正文是裸的)
|
||||||
|
const plainAssFixture = `[Script Info]
|
||||||
|
; LiveID: 123456
|
||||||
|
; StreamName: 测试主播
|
||||||
|
Title: 2026-09-25 01-00-00 测试主播 标题
|
||||||
|
ScriptType: v4.00+
|
||||||
|
Collisions: Normal
|
||||||
|
PlayResX: 1920
|
||||||
|
PlayResY: 1080
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Danmu,Microsoft YaHei,48,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:01.00,0:00:11.00,Danmu,甲(101),20,20,2,,{\move(1020,48,-60,48)}你好, 世界
|
||||||
|
Dialogue: 0,0:00:04.00,0:00:14.00,Danmu,乙(202),20,20,2,,{\move(1068,96,-120,96)}第二条弹幕
|
||||||
|
Comment: 0,0:00:05.00,0:00:15.00,Danmu,丙(303),20,20,2,,{\move(1068,96,-120,96)}这行是注释不该被读进来
|
||||||
|
`
|
||||||
|
|
||||||
|
// 昵称头像版 ASS(\an7\move,正文前面带「昵称(uid):」)
|
||||||
|
const nickAssFixture = `[Script Info]
|
||||||
|
Title: 2026-09-25 01-00-00 测试主播 标题 昵称头像版
|
||||||
|
ScriptType: v4.00+
|
||||||
|
Collisions: Normal
|
||||||
|
PlayResX: 1920
|
||||||
|
PlayResY: 1080
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Danmu,Microsoft YaHei,48,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:02.00,0:00:12.00,Danmu,甲(101),20,20,2,,{\an7\move(1966,48,-200,48)}甲(101):昵称版弹幕
|
||||||
|
`
|
||||||
|
|
||||||
|
// 没有 Format 行时按标准列序兜底
|
||||||
|
const noFormatAssFixture = `[Script Info]
|
||||||
|
Title: 无 Format 行
|
||||||
|
PlayResX: 640
|
||||||
|
PlayResY: 360
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Style: Danmu,Microsoft YaHei,24,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)}兜底解析
|
||||||
|
`
|
||||||
|
|
||||||
|
// 测试用的 ffmpeg,找不到就跳过
|
||||||
|
// 依次找 PATH 和源码目录(以及上一级目录)里的 ffmpeg,不写死某台机器上的路径
|
||||||
|
func testFFmpeg(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
cands := []string{"ffmpeg"}
|
||||||
|
for _, name := range []string{"ffmpeg.exe", filepath.Join("..", "ffmpeg.exe")} {
|
||||||
|
if abs, err := filepath.Abs(name); err == nil {
|
||||||
|
cands = append(cands, abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.FFmpegPath != "" {
|
||||||
|
cands = append([]string{config.FFmpegPath}, cands...)
|
||||||
|
}
|
||||||
|
for _, p := range cands {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := exec.Command(p, "-version").Run(); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("机器上没有可用的 ffmpeg,跳过")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备一个干净的保存目录
|
||||||
|
func setupMergeTest(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
config.Directory = dir
|
||||||
|
config.RecordSubdir = defaultRecordSubdir
|
||||||
|
config.MergeSubdir = defaultMergeSubdir
|
||||||
|
config.MergeOutputDir = ""
|
||||||
|
config.MergeBitrate = 0
|
||||||
|
config.FFmpegPath = testFFmpeg(t)
|
||||||
|
// recordDir 是命令行参数,测试里没跑 argsHandle 时还是 nil;
|
||||||
|
// 这里兜底分配,让需要模拟 -record 相对路径的用例能改到它
|
||||||
|
if recordDir == nil {
|
||||||
|
recordDir = new(string)
|
||||||
|
}
|
||||||
|
*recordDir = dir
|
||||||
|
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 测试统一不发网络请求,头像反查直接返回空
|
||||||
|
old := lookupAvatarURL
|
||||||
|
lookupAvatarURL = func(int64) string { return "" }
|
||||||
|
t.Cleanup(func() { lookupAvatarURL = old })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(t *testing.T, path, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 ASS 时间
|
||||||
|
func TestParseAssClock(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want float64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"0:00:01.00", 1, true},
|
||||||
|
{"1:02:03.45", 3723.45, true},
|
||||||
|
{"0:00:00.10", 0.1, true},
|
||||||
|
{" 0:10:00.00 ", 600, true},
|
||||||
|
{"", 0, false},
|
||||||
|
{"abc", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseAssClock(c.in)
|
||||||
|
if ok != c.ok {
|
||||||
|
t.Fatalf("parseAssClock(%q) ok=%v,期望 %v", c.in, ok, c.ok)
|
||||||
|
}
|
||||||
|
if ok && got != c.want {
|
||||||
|
t.Fatalf("parseAssClock(%q)=%v,期望 %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原格式 ASS:正文是裸的,弹道要用 y 减 1
|
||||||
|
func TestParseAssFilePlain(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.ass"), plainAssFixture)
|
||||||
|
|
||||||
|
meta, records, err := parseAssFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析失败:%v", err)
|
||||||
|
}
|
||||||
|
if meta.PlayResX != 1920 || meta.PlayResY != 1080 || meta.FontSize != 48 {
|
||||||
|
t.Fatalf("元数据不对:%+v", meta)
|
||||||
|
}
|
||||||
|
// Comment 行不该被读进来
|
||||||
|
if len(records) != 2 {
|
||||||
|
t.Fatalf("应该解析出 2 条弹幕,实际 %d 条:%+v", len(records), records)
|
||||||
|
}
|
||||||
|
r0 := records[0]
|
||||||
|
if r0.Time != 1 || r0.UID != 101 || r0.Name != "甲" {
|
||||||
|
t.Fatalf("第一条的时间/uid/昵称不对:%+v", r0)
|
||||||
|
}
|
||||||
|
// 正文里的 ASCII 逗号必须保留(Text 是最后一列,不能被拆开)
|
||||||
|
if r0.Content != "你好, 世界" {
|
||||||
|
t.Fatalf("第一条正文不对:%q", r0.Content)
|
||||||
|
}
|
||||||
|
if r0.Lane != 0 {
|
||||||
|
t.Fatalf("y=48、字号=48,原格式弹道应为 0,实际 %d", r0.Lane)
|
||||||
|
}
|
||||||
|
if r0.Avatar != "" {
|
||||||
|
t.Fatalf("ASS 里没有头像链接,应为空,实际 %q", r0.Avatar)
|
||||||
|
}
|
||||||
|
if records[1].Lane != 1 {
|
||||||
|
t.Fatalf("y=96 应为弹道 1,实际 %d", records[1].Lane)
|
||||||
|
}
|
||||||
|
// 文件名里的录制时间要能被解析出来
|
||||||
|
want := time.Date(2026, 9, 25, 1, 0, 0, 0, time.Local).UnixNano()
|
||||||
|
if meta.StartTime != want {
|
||||||
|
t.Fatalf("StartTime=%d,期望 %d", meta.StartTime, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 昵称头像版 ASS:正文要剥掉「昵称(uid):」,弹道直接用 y 除字号
|
||||||
|
func TestParseAssFileNick(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.nick.ass"), nickAssFixture)
|
||||||
|
|
||||||
|
meta, records, err := parseAssFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析失败:%v", err)
|
||||||
|
}
|
||||||
|
if meta.FontSize != 48 {
|
||||||
|
t.Fatalf("字号不对:%d", meta.FontSize)
|
||||||
|
}
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("应该解析出 1 条弹幕,实际 %d 条", len(records))
|
||||||
|
}
|
||||||
|
r := records[0]
|
||||||
|
if r.Content != "昵称版弹幕" {
|
||||||
|
t.Fatalf("正文没剥掉昵称前缀:%q", r.Content)
|
||||||
|
}
|
||||||
|
if r.Name != "甲" || r.UID != 101 {
|
||||||
|
t.Fatalf("昵称/uid 不对:%+v", r)
|
||||||
|
}
|
||||||
|
// 昵称版的 y 是 字号*lane,y=48、字号=48 → 弹道 1
|
||||||
|
if r.Lane != 1 {
|
||||||
|
t.Fatalf("昵称版 y=48 应为弹道 1,实际 %d", r.Lane)
|
||||||
|
}
|
||||||
|
// 宽度按合成时真正的显示文字算:甲(101):昵称版弹幕
|
||||||
|
if want := textWidth("甲(101):昵称版弹幕", 48); r.Width != want {
|
||||||
|
t.Fatalf("宽度=%d,期望 %d", r.Width, want)
|
||||||
|
}
|
||||||
|
if want := 432; r.Width != want {
|
||||||
|
t.Fatalf("宽度期望 432(甲 48 + ( 24 + 101 72 + ) 24 + : 24 + 5 个汉字 240),实际 %d", r.Width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件里没有 Format 行时按标准列序解析
|
||||||
|
func TestParseAssFileWithoutFormatLine(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture)
|
||||||
|
|
||||||
|
meta, records, err := parseAssFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析失败:%v", err)
|
||||||
|
}
|
||||||
|
if meta.FontSize != 24 {
|
||||||
|
t.Fatalf("没有 Format 行时应按「Name, Fontname, Fontsize」取到 24,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || records[0].Content != "兜底解析" {
|
||||||
|
t.Fatalf("记录不对:%+v", records)
|
||||||
|
}
|
||||||
|
if records[0].Time != 3.5 {
|
||||||
|
t.Fatalf("时间应为 3.5,实际 %v", records[0].Time)
|
||||||
|
}
|
||||||
|
// 文件名没有时间戳,StartTime 应该是 0
|
||||||
|
if records[0].Time == 0 {
|
||||||
|
t.Fatal("时间不该是 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不是弹幕文件的 ASS 要报错,而不是解析出空结果
|
||||||
|
func TestParseAssFileRejectsBadFile(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
cases := map[string]string{
|
||||||
|
"没有分辨率.ass": "[Script Info]\nTitle: x\n\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Danmu,甲(1),0,0,0,,{\\move(1,1,-1,1)}x\n",
|
||||||
|
"没有字号.ass": "[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Danmu,甲(1),0,0,0,,{\\move(1,1,-1,1)}x\n",
|
||||||
|
"没有弹幕.ass": "[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Danmu,Microsoft YaHei,24\n",
|
||||||
|
}
|
||||||
|
for name, content := range cases {
|
||||||
|
path := writeTestFile(t, filepath.Join(dir, name), content)
|
||||||
|
if _, _, err := parseAssFile(path); err == nil {
|
||||||
|
t.Fatalf("%s 应该报错", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .ass / .nick.ass 对应的 jsonl 路径
|
||||||
|
func TestAssSiblingJsonl(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"/a/b/2026-09-25 01-00-00 主播 标题.ass": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl",
|
||||||
|
"/a/b/2026-09-25 01-00-00 主播 标题.nick.ass": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl",
|
||||||
|
// 扩展名大小写不敏感,上游在 Linux 上录的可能就是大写
|
||||||
|
"/a/b/2026-09-25 01-00-00 主播 标题.ASS": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl",
|
||||||
|
"/a/b/x.mp4": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := assSiblingJsonl(in); got != want {
|
||||||
|
t.Fatalf("assSiblingJsonl(%q)=%q,期望 %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出文件名的默认值
|
||||||
|
func TestDanmuBaseName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"2026-09-25 01-00-00 主播 标题.jsonl": "2026-09-25 01-00-00 主播 标题",
|
||||||
|
"2026-09-25 01-00-00 主播 标题.ass": "2026-09-25 01-00-00 主播 标题",
|
||||||
|
"2026-09-25 01-00-00 主播 标题.nick.ass": "2026-09-25 01-00-00 主播 标题",
|
||||||
|
"D:/x/2026-09-25 01-00-00 主播 标题.ass": "2026-09-25 01-00-00 主播 标题",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := danmuBaseName(in); got != want {
|
||||||
|
t.Fatalf("danmuBaseName(%q)=%q,期望 %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名时间戳解析
|
||||||
|
func TestNameStartNs(t *testing.T) {
|
||||||
|
want := time.Date(2026, 9, 25, 1, 44, 57, 0, time.Local).UnixNano()
|
||||||
|
if got := nameStartNs("/a/b/2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"); got != want {
|
||||||
|
t.Fatalf("nameStartNs=%d,期望 %d", got, want)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"abc.mp4", "2026-09-25.mp4", ""} {
|
||||||
|
if got := nameStartNs(bad); got != 0 {
|
||||||
|
t.Fatalf("%q 不该解析出时间,得到 %d", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移推算:同名就是同一场,直接 0
|
||||||
|
func TestGuessOffsetSameName(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
name := "2026-09-25 01-44-57 Xsphere 电脑自己画画"
|
||||||
|
videos := []string{filepath.Join(dir, name+".mp4")}
|
||||||
|
off, note := guessOffset(videos, filepath.Join(dir, name+".jsonl"), danmuMeta{})
|
||||||
|
if off != 0 {
|
||||||
|
t.Fatalf("同名时偏移应为 0,实际 %v", off)
|
||||||
|
}
|
||||||
|
if !strings.Contains(note, "文件名一致") {
|
||||||
|
t.Fatalf("说明文字不对:%q", note)
|
||||||
|
}
|
||||||
|
// .ass 也要能认出来同名
|
||||||
|
if off, _ := guessOffset(videos, filepath.Join(dir, name+".nick.ass"), danmuMeta{}); off != 0 {
|
||||||
|
t.Fatalf("同名 .nick.ass 时偏移应为 0,实际 %v", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移推算:文件名时间不同就按时间差算
|
||||||
|
func TestGuessOffsetDiffName(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
videos := []string{filepath.Join(dir, "2026-09-25 02-00-00 主播 标题.mp4")}
|
||||||
|
off, note := guessOffset(videos, filepath.Join(dir, "2026-09-25 01-44-57 主播 标题.jsonl"), danmuMeta{})
|
||||||
|
// 弹幕比视频早 15 分 3 秒开始
|
||||||
|
if off != -903 {
|
||||||
|
t.Fatalf("偏移应为 -903,实际 %v", off)
|
||||||
|
}
|
||||||
|
if !strings.Contains(note, "偏移偏大") {
|
||||||
|
t.Fatalf("偏移过大时应有提醒,实际 %q", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径解析:文件名、引号、绝对路径、目录、不存在
|
||||||
|
func TestResolveMergeInput(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
name := "2026-09-25 01-44-57 主播 标题.mp4"
|
||||||
|
target := writeTestFile(t, filepath.Join(recordSaveDir(), name), "x")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name, target}, // 录播目录里的文件名
|
||||||
|
{`"` + name + `"`, target}, // 从资源管理器复制来会带引号
|
||||||
|
{" " + name + " ", target}, // 前后空格
|
||||||
|
{target, target}, // 绝对路径
|
||||||
|
{filepath.ToSlash(target), target}, // 正斜杠的绝对路径
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := resolveMergeInput(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析 %q 失败:%v", c.in, err)
|
||||||
|
}
|
||||||
|
if normalizePath(got) != normalizePath(c.want) {
|
||||||
|
t.Fatalf("解析 %q 得到 %q,期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []string{"", " ", ".", "..", "不存在的文件.mp4", "../外面.mp4", filepath.Join(recordSaveDir(), "没有这个.mp4")}
|
||||||
|
for _, b := range bad {
|
||||||
|
if got, err := resolveMergeInput(b); err == nil {
|
||||||
|
t.Fatalf("%q 应该报错,却得到 %q", b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 目录不能被当成文件
|
||||||
|
if _, err := resolveMergeInput(recordSaveDir()); err == nil {
|
||||||
|
t.Fatal("目录应该被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出文件名校验
|
||||||
|
func TestSanitizeOutName(t *testing.T) {
|
||||||
|
good := map[string]string{"a.mp4": "a.mp4", "a": "a.mp4", "标题.mp4": "标题.mp4", "x.MP4": "x.MP4"}
|
||||||
|
for in, want := range good {
|
||||||
|
got, err := sanitizeOutName(in)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("sanitizeOutName(%q)=(%q,%v),期望 %q", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []string{"", " ", ".", "..", "a/b.mp4", `a\b.mp4`, "a\nb.mp4", "../x.mp4"}
|
||||||
|
for _, b := range bad {
|
||||||
|
if got, err := sanitizeOutName(b); err == nil {
|
||||||
|
t.Fatalf("%q 应该被拒绝,却得到 %q", b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出不覆盖已有成片
|
||||||
|
func TestFreeOutPath(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
base := "标题_弹幕合并"
|
||||||
|
if got := filepath.Base(freeOutPath(mergeSaveDir(), base)); got != base+".mp4" {
|
||||||
|
t.Fatalf("没有冲突时应返回原名,实际 %q", got)
|
||||||
|
}
|
||||||
|
writeTestFile(t, filepath.Join(mergeSaveDir(), base+".mp4"), "x")
|
||||||
|
if got := filepath.Base(freeOutPath(mergeSaveDir(), base)); got != base+"(2).mp4" {
|
||||||
|
t.Fatalf("有冲突时应加序号,实际 %q", got)
|
||||||
|
}
|
||||||
|
_ = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展名白名单:不是视频/弹幕的文件不能被当输入
|
||||||
|
func TestBuildMergePlanRejectsBadExt(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 主播 标题.jsonl"),
|
||||||
|
`{"playResX":640,"playResY":360,"fontSize":24,"startTime":0}`+"\n")
|
||||||
|
|
||||||
|
// 视频给个 .txt
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "note.txt"), "x")
|
||||||
|
_, err := buildMergePlan(mergeRequest{
|
||||||
|
Videos: []string{"note.txt"},
|
||||||
|
Danmu: "2026-09-25 01-00-00 主播 标题.jsonl",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "不支持的视频格式") {
|
||||||
|
t.Fatalf("应该拒绝 .txt 视频,实际 err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕给个 .mp4
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 主播 标题.mp4"), "x")
|
||||||
|
_, err = buildMergePlan(mergeRequest{
|
||||||
|
Videos: []string{"2026-09-25 01-00-00 主播 标题.mp4"},
|
||||||
|
Danmu: "2026-09-25 01-00-00 主播 标题.mp4",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "不支持的弹幕格式") {
|
||||||
|
t.Fatalf("应该拒绝用 mp4 当弹幕,实际 err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 什么都不填
|
||||||
|
if _, err := buildMergePlan(mergeRequest{}); err == nil {
|
||||||
|
t.Fatal("空请求应该报错")
|
||||||
|
}
|
||||||
|
_ = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在写入的文件不能被当输入,也不能被覆盖
|
||||||
|
func TestBuildMergePlanRejectsActiveFile(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
jsonl := "2026-09-25 01-00-00 主播 标题.jsonl"
|
||||||
|
video := "2026-09-25 01-00-00 主播 标题.mp4"
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), jsonl),
|
||||||
|
`{"playResX":640,"playResY":360,"fontSize":24,"startTime":0}`+"\n")
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), video), "x")
|
||||||
|
|
||||||
|
markFileActive(filepath.Join(recordSaveDir(), video), true)
|
||||||
|
defer markFileActive(filepath.Join(recordSaveDir(), video), false)
|
||||||
|
_, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: jsonl})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "正在录制或写入中") {
|
||||||
|
t.Fatalf("正在写入的视频应该被拒绝,实际 err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制侧和管理接口侧必须用同一种路径形式,否则「正在写入」的判断会失效,
|
||||||
|
// 用户就能在 web UI 里删掉正在录制的文件(删不掉时会抛系统错误而不是给友好提示)
|
||||||
|
func TestMarkAndCheckUseSamePathForm(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := func(tag string) {
|
||||||
|
t.Helper()
|
||||||
|
if !filepath.IsAbs(recordSaveDir()) || !filepath.IsAbs(mergeSaveDir()) {
|
||||||
|
t.Fatalf("%s:保存目录没归一成绝对路径,record=%q merge=%q",
|
||||||
|
tag, recordSaveDir(), mergeSaveDir())
|
||||||
|
}
|
||||||
|
full, err := resolveFile("merge", "x.mp4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
markFileActive(filepath.Join(mergeSaveDir(), "x.mp4"), true)
|
||||||
|
defer markFileActive(filepath.Join(mergeSaveDir(), "x.mp4"), false)
|
||||||
|
if !isFileActive(full) {
|
||||||
|
t.Fatalf("%s:录制侧标记的文件,管理接口侧查不到(%q)", tag, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一个文件的相对写法与绝对写法必须被判成同一个
|
||||||
|
// (相对路径按 cwd 解析,所以要从绝对路径反推才能指向同一个文件)
|
||||||
|
abs := filepath.Join(mergeSaveDir(), "y.mp4")
|
||||||
|
rel, err := filepath.Rel(cwd, abs)
|
||||||
|
if err == nil && !filepath.IsAbs(rel) {
|
||||||
|
markFileActive(rel, true)
|
||||||
|
defer markFileActive(rel, false)
|
||||||
|
if !isFileActive(abs) {
|
||||||
|
t.Fatalf("%s:相对路径 %q 与绝对路径 %q 归一后对不上", tag, rel, abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// directory 是绝对路径的情况
|
||||||
|
check("directory 为绝对路径")
|
||||||
|
|
||||||
|
// 模拟 -record . 这类相对路径的部署方式:用 cwd 到临时目录的相对路径,
|
||||||
|
// 既能触发相对路径分支,又不会碰到仓库自己的 record/merged 目录
|
||||||
|
savedDir := config.Directory
|
||||||
|
defer func() { config.Directory = savedDir }()
|
||||||
|
oldRecord := *recordDir
|
||||||
|
defer func() { *recordDir = oldRecord }()
|
||||||
|
config.Directory = ""
|
||||||
|
|
||||||
|
relDir, err := filepath.Rel(cwd, dir)
|
||||||
|
if err != nil {
|
||||||
|
// 跨盘符取不到相对路径时退回最朴素的 ".",一样是相对路径
|
||||||
|
relDir = "."
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(relDir) {
|
||||||
|
t.Fatalf("准备相对路径失败,得到的是绝对路径 %q", relDir)
|
||||||
|
}
|
||||||
|
*recordDir = relDir
|
||||||
|
check("保存根目录为相对路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpeg 的路径必须归一成绝对路径:合并时 ffmpeg 的工作目录会被改成输出目录,
|
||||||
|
// 相对路径留着的话它会去输出目录里找,最后报一个跟真实原因对不上的「找不到文件」
|
||||||
|
func TestGetFFmpegAbsolutizesRelativePath(t *testing.T) {
|
||||||
|
ff := testFFmpeg(t)
|
||||||
|
|
||||||
|
savedPath, savedExe := config.FFmpegPath, exeDir
|
||||||
|
defer func() { config.FFmpegPath, exeDir = savedPath, savedExe }()
|
||||||
|
|
||||||
|
same := func(tag, want, got string) {
|
||||||
|
t.Helper()
|
||||||
|
if !filepath.IsAbs(got) {
|
||||||
|
t.Fatalf("%s:没归一成绝对路径,得到 %q", tag, got)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(filepath.Clean(got), filepath.Clean(want)) {
|
||||||
|
t.Fatalf("%s:归一后指向的不是同一个文件,得到 %q,期望 %q", tag, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常见写法一:只写可执行文件名,靠程序所在目录找到它
|
||||||
|
exeDir = filepath.Dir(ff)
|
||||||
|
config.FFmpegPath = filepath.Base(ff)
|
||||||
|
same("只写文件名", ff, getFFmpeg())
|
||||||
|
|
||||||
|
// 常见写法二:相对当前工作目录的路径
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(cwd, ff)
|
||||||
|
if err != nil || filepath.IsAbs(rel) {
|
||||||
|
t.Skipf("取不到相对当前目录的路径:%v", err)
|
||||||
|
}
|
||||||
|
exeDir = ""
|
||||||
|
config.FFmpegPath = rel
|
||||||
|
same("相对当前目录", ff, getFFmpeg())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:原版 .ass → 还原成 jsonl → 指定视频合并 → 出片
|
||||||
|
func TestManualMergeFromAssEndToEnd(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
ffmpeg := getFFmpeg()
|
||||||
|
if ffmpeg == "" {
|
||||||
|
t.Skip("没有 ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := "2026-09-25 01-00-00 测试主播 标题"
|
||||||
|
// 6 秒黑底视频,弹幕在 1 / 3 / 5 秒出现
|
||||||
|
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||||||
|
cmd := exec.Command(ffmpeg, "-v", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", "6",
|
||||||
|
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕文件用原格式 .ass(模拟上游原版录出来的东西),字号 24
|
||||||
|
ass := fmt.Sprintf(`[Script Info]
|
||||||
|
Title: %s
|
||||||
|
ScriptType: v4.00+
|
||||||
|
Collisions: Normal
|
||||||
|
PlayResX: 640
|
||||||
|
PlayResY: 360
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Danmu,Microsoft YaHei,24,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:01.00,0:00:11.00,Danmu,甲(101),20,20,2,,{\move(400,24,-40,24)}第一条弹幕
|
||||||
|
Dialogue: 0,0:00:03.00,0:00:13.00,Danmu,乙(202),20,20,2,,{\move(400,48,-40,48)}第二条弹幕
|
||||||
|
Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,72,-40,72)}第三条弹幕
|
||||||
|
`, name)
|
||||||
|
assPath := writeTestFile(t, filepath.Join(recordSaveDir(), name+".ass"), ass)
|
||||||
|
|
||||||
|
// 还原成 jsonl
|
||||||
|
src, err := prepareDanmuSource(assPath, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("还原弹幕失败:%v", err)
|
||||||
|
}
|
||||||
|
if src.count != 3 {
|
||||||
|
t.Fatalf("应还原出 3 条弹幕,实际 %d", src.count)
|
||||||
|
}
|
||||||
|
wantJsonl := filepath.Join(recordSaveDir(), name+".jsonl")
|
||||||
|
if normalizePath(src.jsonlPath) != normalizePath(wantJsonl) {
|
||||||
|
t.Fatalf("jsonl 应写在 %q,实际 %q", wantJsonl, src.jsonlPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wantJsonl); err != nil {
|
||||||
|
t.Fatalf(".ass 还原出的 jsonl 没有落盘:%v", err)
|
||||||
|
}
|
||||||
|
sum, err := scanJsonlSummary(wantJsonl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读还原出来的 jsonl 失败:%v", err)
|
||||||
|
}
|
||||||
|
if sum.count != 3 || sum.meta.PlayResX != 640 || sum.meta.FontSize != 24 {
|
||||||
|
t.Fatalf("还原结果不对:count=%d meta=%+v", sum.count, sum.meta)
|
||||||
|
}
|
||||||
|
// 还原出来的弹幕没有头像链接,合成时会按 uid 反查(测试里已替换成返回空)
|
||||||
|
if sum.hasAvatar {
|
||||||
|
t.Fatal("从 .ass 还原不该有头像链接")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走一遍检查
|
||||||
|
plan, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: assPath})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("检查失败:%v", err)
|
||||||
|
}
|
||||||
|
if plan.info.Offset != 0 {
|
||||||
|
t.Fatalf("同名文件偏移应为 0,实际 %v(%s)", plan.info.Offset, plan.info.OffsetNote)
|
||||||
|
}
|
||||||
|
if plan.info.DanmuCount != 3 {
|
||||||
|
t.Fatalf("检查结果里的弹幕条数应为 3,实际 %d", plan.info.DanmuCount)
|
||||||
|
}
|
||||||
|
if plan.info.Duration < 5.9 || plan.info.Duration > 6.1 {
|
||||||
|
t.Fatalf("视频时长应约 6 秒,实际 %v", plan.info.Duration)
|
||||||
|
}
|
||||||
|
if !strings.Contains(plan.info.OutName, "_弹幕合并.mp4") {
|
||||||
|
t.Fatalf("默认输出名不对:%q", plan.info.OutName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真正合并
|
||||||
|
if err := runManualMerge(plan); err != nil {
|
||||||
|
t.Fatalf("手动合并失败:%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(plan.out); err != nil {
|
||||||
|
t.Fatalf("没有生成成片:%v", err)
|
||||||
|
}
|
||||||
|
d := probeDuration(ffmpeg, plan.out)
|
||||||
|
if d < 5.9 || d > 6.3 {
|
||||||
|
t.Fatalf("成片时长应约 6 秒,实际 %v", d)
|
||||||
|
}
|
||||||
|
// 合并完不能再被标记成"写入中"
|
||||||
|
if isFileActive(plan.out) {
|
||||||
|
t.Fatal("合并结束后不该还被标记为写入中")
|
||||||
|
}
|
||||||
|
t.Logf("成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
|
||||||
|
_ = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多段:两个分段要能拼成一个再合成
|
||||||
|
func TestManualMergeMultiSegment(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
ffmpeg := getFFmpeg()
|
||||||
|
if ffmpeg == "" {
|
||||||
|
t.Skip("没有 ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
mk := func(name string, sec float64) string {
|
||||||
|
p := filepath.Join(recordSaveDir(), name)
|
||||||
|
cmd := exec.Command(ffmpeg, "-v", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25",
|
||||||
|
"-t", fmt.Sprintf("%.0f", sec), "-c:v", "libx264", "-preset", "ultrafast",
|
||||||
|
"-pix_fmt", "yuv420p", p)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一段 01:00:00 开始,第二段 01:00:04 开始(模拟断流后重连)
|
||||||
|
v1 := mk("2026-09-25 01-00-00 测试主播 标题.mp4", 4)
|
||||||
|
v2 := mk("2026-09-25 01-00-04 测试主播 标题.mp4", 4)
|
||||||
|
|
||||||
|
// 弹幕文件按第一段命名,弹幕时间覆盖两段
|
||||||
|
ass := `[Script Info]
|
||||||
|
Title: 2026-09-25 01-00-00 测试主播 标题
|
||||||
|
PlayResX: 640
|
||||||
|
PlayResY: 360
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize
|
||||||
|
Style: Danmu,Microsoft YaHei,24
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:01.00,0:00:11.00,Danmu,甲(101),0,0,0,,{\move(400,24,-40,24)}第一段里的弹幕
|
||||||
|
Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}第二段里的弹幕
|
||||||
|
`
|
||||||
|
assPath := writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 测试主播 标题.ass"), ass)
|
||||||
|
|
||||||
|
plan, err := buildMergePlan(mergeRequest{Videos: []string{v2, v1}, Danmu: assPath}) // 故意填反顺序
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("检查失败:%v", err)
|
||||||
|
}
|
||||||
|
// 填反了也要按录制时间纠正过来
|
||||||
|
if filepath.Base(plan.videos[0]) != filepath.Base(v1) {
|
||||||
|
t.Fatalf("分段顺序应纠正为第一段在前,实际 %q", filepath.Base(plan.videos[0]))
|
||||||
|
}
|
||||||
|
if err := runManualMerge(plan); err != nil {
|
||||||
|
t.Fatalf("多段手动合并失败:%v", err)
|
||||||
|
}
|
||||||
|
d := probeDuration(ffmpeg, plan.out)
|
||||||
|
// 两段各 4 秒,拼起来应该约 8 秒
|
||||||
|
if d < 7.5 || d > 8.5 {
|
||||||
|
t.Fatalf("拼接后的成片应约 8 秒,实际 %.2f", d)
|
||||||
|
}
|
||||||
|
t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// 开播、下播、开始录制、合并完成等事件的推送
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 推送的事件名,外部(如 NoneBot 机器人)按这个字段区分事件
|
||||||
|
const (
|
||||||
|
notifyEventLiveOn = "liveOn"
|
||||||
|
notifyEventLiveOff = "liveOff"
|
||||||
|
notifyEventRecordStart = "recordStart"
|
||||||
|
notifyEventMergeDone = "mergeDone"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 推送到 webhook 的请求体
|
||||||
|
type notifyPayload struct {
|
||||||
|
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone
|
||||||
|
UID int `json:"uid"` // 主播 uid
|
||||||
|
Name string `json:"name"` // 主播名字
|
||||||
|
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
|
||||||
|
URL string `json:"url"` // 直播间地址
|
||||||
|
File string `json:"file"` // 相关文件路径(录制开始时的视频文件、合并完成时的成片)
|
||||||
|
Group string `json:"group"` // web UI 里配置的通知群号,机器人可以直接用它决定发到哪个群
|
||||||
|
Text string `json:"text"` // 可以直接发出去的文本,机器人拿到后原样发送即可
|
||||||
|
Time string `json:"time"` // 事件发生的时刻
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送用的 http 客户端,超时短一点,避免推送把任务拖住
|
||||||
|
var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
// 已经推送过开播通知的 liveID,避免重连或改设置时重复推送
|
||||||
|
var notifiedLive struct {
|
||||||
|
sync.Mutex
|
||||||
|
ids map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次见到这个 liveID 时返回 true
|
||||||
|
func notifiedLiveOnce(liveID string) bool {
|
||||||
|
if liveID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
notifiedLive.Lock()
|
||||||
|
defer notifiedLive.Unlock()
|
||||||
|
if notifiedLive.ids == nil {
|
||||||
|
notifiedLive.ids = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if _, ok := notifiedLive.ids[liveID]; ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
notifiedLive.ids[liveID] = struct{}{}
|
||||||
|
// 长时间运行后简单地清一次,避免无限增长
|
||||||
|
if len(notifiedLive.ids) > 4096 {
|
||||||
|
notifiedLive.ids = map[string]struct{}{liveID: {}}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
|
||||||
|
type notifySetting struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
group string
|
||||||
|
liveOn bool
|
||||||
|
liveOff bool
|
||||||
|
recordStart bool
|
||||||
|
mergeDone bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentNotifySetting() notifySetting {
|
||||||
|
streamers.RLock()
|
||||||
|
defer streamers.RUnlock()
|
||||||
|
return notifySetting{
|
||||||
|
url: strings.TrimSpace(config.NotifyURL),
|
||||||
|
token: strings.TrimSpace(config.NotifyToken),
|
||||||
|
group: strings.TrimSpace(config.NotifyGroup),
|
||||||
|
liveOn: config.NotifyLiveOn,
|
||||||
|
liveOff: config.NotifyLiveOff,
|
||||||
|
recordStart: config.NotifyRecordStart,
|
||||||
|
mergeDone: config.NotifyMergeDone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该事件是否开启了推送
|
||||||
|
func (n notifySetting) enabled(event string) bool {
|
||||||
|
switch event {
|
||||||
|
case notifyEventLiveOn:
|
||||||
|
return n.liveOn
|
||||||
|
case notifyEventLiveOff:
|
||||||
|
return n.liveOff
|
||||||
|
case notifyEventRecordStart:
|
||||||
|
return n.recordStart
|
||||||
|
case notifyEventMergeDone:
|
||||||
|
return n.mergeDone
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送一个事件。没配置推送地址或没开启该事件时什么都不做,
|
||||||
|
// 推送在后台 goroutine 里进行,失败只打警告,不会影响录制
|
||||||
|
func sendNotify(event string, uid int, name, title, url, file, text string) {
|
||||||
|
n := currentNotifySetting()
|
||||||
|
if n.url == "" || !n.enabled(event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := notifyPayload{
|
||||||
|
Event: event,
|
||||||
|
UID: uid,
|
||||||
|
Name: name,
|
||||||
|
Title: title,
|
||||||
|
URL: url,
|
||||||
|
File: file,
|
||||||
|
Group: n.group,
|
||||||
|
Text: text,
|
||||||
|
Time: getLogTime(),
|
||||||
|
}
|
||||||
|
// 推送地址可能在推送过程中被改掉,这里把地址和口令带进闭包,保证用的是同一次配置
|
||||||
|
addr, token := n.url, n.token
|
||||||
|
safeGo("推送"+event+"通知", func() {
|
||||||
|
if err := postNotify(addr, token, p); err != nil {
|
||||||
|
lPrintWarnf("推送%s通知失败:%v", event, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintf("已推送%s通知:%s", event, p.Text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把事件 POST 到 webhook
|
||||||
|
func postNotify(addr, token string, p notifyPayload) error {
|
||||||
|
data, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("X-Auth-Token", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := notifyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
|
||||||
|
func checkNotifyURL(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") {
|
||||||
|
return "推送地址必须以 http:// 或 https:// 开头:" + addr
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// 直播视频录制相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orzogc/acfundanmu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 查找 ffmpeg:优先设置里指定的路径,其次 PATH,最后程序所在目录及其常见子目录
|
||||||
|
func getFFmpeg() string {
|
||||||
|
name := "ffmpeg"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "ffmpeg.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置里显式指定的路径优先,可以填可执行文件本身或它所在的文件夹
|
||||||
|
if p := strings.TrimSpace(config.FFmpegPath); p != "" {
|
||||||
|
// 相对路径先落在程序所在目录上。合并时 ffmpeg 的工作目录会被改成输出目录,
|
||||||
|
// 留着相对路径的话它会去那里找,报一个跟真实原因完全对不上的「找不到文件」
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
cand := p
|
||||||
|
if joined := filepath.Join(exeDir, p); filepath.IsAbs(joined) {
|
||||||
|
if _, err := os.Stat(joined); err == nil {
|
||||||
|
cand = joined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(cand); err == nil {
|
||||||
|
p = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(p); err == nil && info.IsDir() {
|
||||||
|
p = filepath.Join(p, name)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(p); err == nil && !info.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range []string{
|
||||||
|
exeDir,
|
||||||
|
filepath.Join(exeDir, "bin"),
|
||||||
|
filepath.Join(exeDir, "ffmpeg"),
|
||||||
|
filepath.Join(exeDir, "ffmpeg", "bin"),
|
||||||
|
} {
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if info, err := os.Stat(p); err == nil && !info.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设置里指定的 ffmpeg 路径,返回错误信息,空字符串表示可用(或没填)
|
||||||
|
func checkFFmpegPath(path string) string {
|
||||||
|
p := strings.TrimSpace(path)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return "ffmpeg 路径不存在:" + p
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
name := "ffmpeg"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "ffmpeg.exe"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(p, name)); err != nil {
|
||||||
|
return "文件夹里没有找到 " + name + ":" + p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档
|
||||||
|
func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
|
||||||
|
var ac *acfundanmu.AcFunLive
|
||||||
|
err = runThrice(func() error {
|
||||||
|
ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(uid)))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
sInfo := ac.GetStreamInfo()
|
||||||
|
if len(sInfo.StreamList) == 0 {
|
||||||
|
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
|
||||||
|
}
|
||||||
|
// StreamList 按码率升序排列
|
||||||
|
st := sInfo.StreamList[len(sInfo.StreamList)-1]
|
||||||
|
if bitrate > 0 {
|
||||||
|
st = sInfo.StreamList[0]
|
||||||
|
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
|
||||||
|
if sInfo.StreamList[i].Bitrate <= bitrate {
|
||||||
|
st = sInfo.StreamList[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st.URL, st.QualityName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时录制指定主播的直播视频
|
||||||
|
func startRecord(uid int) bool {
|
||||||
|
if getFFmpeg() == "" {
|
||||||
|
lPrintErr("没有找到 ffmpeg,无法录制直播视频,请安装 ffmpeg 并加入 PATH,或把 ffmpeg 放在本程序所在文件夹")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s, ok := getStreamer(uid)
|
||||||
|
if !ok {
|
||||||
|
name := getName(uid)
|
||||||
|
if name == "" {
|
||||||
|
lPrintWarnf("不存在 uid 为%d的用户", uid)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s = streamer{UID: uid, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
liveID := getLiveID(uid)
|
||||||
|
if liveID == "" {
|
||||||
|
lPrintErr(s.longID() + "不在直播,取消录制直播视频")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if info, ok := getLiveInfo(liveID); ok && info.isRecording {
|
||||||
|
lPrintWarnf("已经在录制%s的直播视频,如要重启录制,请先运行 stoprecord %d", s.longID(), uid)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := getTime() + " " + s.Name + " " + s.getTitle()
|
||||||
|
safeGo(s.longID()+"的视频录制", func() { s.initRecord(mainCtx, liveID, filename) })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止录制指定主播的直播视频
|
||||||
|
func stopRecord(uid int) bool {
|
||||||
|
infoList, ok := getLiveInfoByUID(uid)
|
||||||
|
if !ok {
|
||||||
|
lPrintWarnf("没有在录制 uid 为%d的主播的直播视频", uid)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, info := range infoList {
|
||||||
|
if info.isRecording {
|
||||||
|
lPrintf("开始停止录制%s的liveID为%s的直播视频", longID(uid), info.LiveID)
|
||||||
|
updateLiveInfo(info.LiveID, func(i *liveInfo) { i.recordStopped = true })
|
||||||
|
// 先结束整个录制循环,否则单段中断会被当成意外中断而重启录制
|
||||||
|
if info.recordStop != nil {
|
||||||
|
info.recordStop()
|
||||||
|
}
|
||||||
|
if info.recordCancel != nil {
|
||||||
|
info.recordCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出直播视频录制相关操作
|
||||||
|
func quitRecord(liveID string) {
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) { i.isRecording = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化直播视频录制
|
||||||
|
func (s streamer) initRecord(ctx context.Context, liveID, filename string) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in initRecord(), the error is:", err)
|
||||||
|
lPrintErrf("录制%s的直播视频发生错误,如要重启录制,请运行 startrecord %d", s.longID(), s.UID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if info, ok := getLiveInfo(liveID); ok && info.isRecording {
|
||||||
|
lPrintWarnf("已经在录制%s的直播视频,如要重启录制,请先运行 stoprecord %d", s.longID(), s.UID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dctx, dcancel := context.WithCancel(ctx)
|
||||||
|
defer dcancel()
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) {
|
||||||
|
i.recordStop = dcancel
|
||||||
|
i.recordStarted = true
|
||||||
|
})
|
||||||
|
|
||||||
|
lPrintln("开始录制" + s.longID() + "的直播视频")
|
||||||
|
lPrintf("如果想提前结束录制%s的直播视频,运行 stoprecord %d", s.longID(), s.UID)
|
||||||
|
|
||||||
|
s.recordLive(dctx, liveID, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断流重连时取不到直播源的重试次数上限,避免直播源一直不可用时无限重试
|
||||||
|
const maxResumeRetry = 3
|
||||||
|
|
||||||
|
// 录制主播的直播视频,意外中断且主播还在播时重启录制新的分段
|
||||||
|
func (s streamer) recordLive(ctx context.Context, liveID, filename string) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErr("Recovering from panic in recordLive(), the error is:", err)
|
||||||
|
lPrintErrf("下载%s的直播视频发生错误,如要重启下载,请运行 startrecord %d", s.longID(), s.UID)
|
||||||
|
}
|
||||||
|
// 录制结束(含出错),检查是否满足自动合并条件
|
||||||
|
finishRecordPart(liveID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
lPrintErr("没有找到 ffmpeg,无法录制直播视频")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resumeFails int // 连续取不到直播源的次数
|
||||||
|
for {
|
||||||
|
url, quality, err := getStreamURL(s.UID, s.Bitrate)
|
||||||
|
if err != nil {
|
||||||
|
// 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段
|
||||||
|
if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) {
|
||||||
|
resumeFails++
|
||||||
|
lPrintWarnf("暂时取不到%s的直播源,稍后重试(%d/%d):%v", s.longID(), resumeFails, maxResumeRetry, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lPrintErrf("无法获取%s的直播源,退出录制直播视频:%v", s.longID(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resumeFails = 0
|
||||||
|
|
||||||
|
base := transFilename(filename)
|
||||||
|
if base == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordFile := base + recordFileExt()
|
||||||
|
|
||||||
|
// -c copy 直接封装原始流,不转码,分辨率就是直播源的实际分辨率
|
||||||
|
rctx, cancel := context.WithCancel(ctx)
|
||||||
|
cmd := exec.CommandContext(rctx, ffmpegFile,
|
||||||
|
"-y", "-rw_timeout", "20000000", "-timeout", "20000000",
|
||||||
|
"-i", url, "-c", "copy", recordFile)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
lPrintErrf("启动 ffmpeg 失败:%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 上下文取消时向 ffmpeg 发 q 让它正常收尾(写出索引、把缓冲刷盘),超过 WaitDelay 才强杀。
|
||||||
|
// 默认的取消行为是直接杀进程,缓冲区里的数据会丢,mp4 还会因为缺少 moov 而整个报废。
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
_, werr := io.WriteString(stdin, "q")
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 10 * time.Second
|
||||||
|
|
||||||
|
startNs := time.Now().UnixNano()
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) {
|
||||||
|
i.uid = s.UID
|
||||||
|
i.isRecording = true
|
||||||
|
i.recordCancel = cancel
|
||||||
|
i.ffmpegStdin = stdin
|
||||||
|
i.recordFile = recordFile
|
||||||
|
// 每一段都记下来:直播流中断后重连会产生新分段,合并时要把它们拼起来
|
||||||
|
i.recordSegments = append(i.recordSegments, recordSegment{file: recordFile, startNs: startNs})
|
||||||
|
if i.firstRecordFile == "" {
|
||||||
|
i.firstRecordFile = recordFile
|
||||||
|
i.recordStartNs = startNs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
lPrintln("本次录制的视频保存在" + recordFile + "(码率档位:" + quality + ")")
|
||||||
|
|
||||||
|
// 标记成正在写入,避免在 web UI 的文件管理里把它删掉
|
||||||
|
markFileActive(recordFile, true)
|
||||||
|
runErr := cmd.Run()
|
||||||
|
markFileActive(recordFile, false)
|
||||||
|
// 正常情况下上面设置的 Cancel 已经让 ffmpeg 收到 q 并正常退出,这里只做资源清理
|
||||||
|
_ = stdin.Close()
|
||||||
|
cancel()
|
||||||
|
updateLiveInfo(liveID, func(i *liveInfo) { i.isRecording = false })
|
||||||
|
s.moveFile(recordFile)
|
||||||
|
|
||||||
|
// 用户主动停止或程序退出
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
lPrintln(s.longID() + "的直播视频录制已经结束")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
lPrintWarnf("下载%s的直播视频出现错误:%v", s.longID(), runErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时接着录同一场,
|
||||||
|
// 这样视频和弹幕仍然是同一对文件,最终只会合成一个成片
|
||||||
|
markResumeWait(liveID)
|
||||||
|
resumed := s.waitResume(ctx)
|
||||||
|
clearResumeWait(liveID)
|
||||||
|
if !resumed {
|
||||||
|
lPrintln(s.longID() + "的直播视频录制已经结束")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintWarn("因意外结束录制" + s.longID() + "的直播视频,尝试重启录制")
|
||||||
|
// 新分段用新文件名,避免覆盖上一段;它和第一段一起参与后面的拼接
|
||||||
|
filename = getTime() + " " + s.Name + " " + s.getTitle()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// 部分数据和函数定义
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orzogc/acfundanmu"
|
||||||
|
)
|
||||||
|
|
||||||
|
type control int
|
||||||
|
|
||||||
|
// 控制信息
|
||||||
|
const (
|
||||||
|
startCycle control = iota
|
||||||
|
stopCycle
|
||||||
|
quit
|
||||||
|
)
|
||||||
|
|
||||||
|
// 主播的管道信息
|
||||||
|
type controlMsg struct {
|
||||||
|
s streamer
|
||||||
|
c control
|
||||||
|
liveID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主播信息
|
||||||
|
type streamerInfo struct {
|
||||||
|
ch chan controlMsg // 控制信息
|
||||||
|
modify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一段录播视频:直播流意外中断后会重新开一段
|
||||||
|
type recordSegment struct {
|
||||||
|
file string // 视频文件路径
|
||||||
|
startNs int64 // 这一段开始录制的时刻,纳秒 Unix
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直播信息
|
||||||
|
type liveInfo struct {
|
||||||
|
LiveID string // 直播 ID
|
||||||
|
uid int // 主播的 uid
|
||||||
|
isDanmu bool // 是否正在录制直播弹幕
|
||||||
|
danmuCancel context.CancelFunc // 用来停止录制弹幕
|
||||||
|
assFile string // 弹幕文件路径
|
||||||
|
cfg acfundanmu.SubConfig
|
||||||
|
isRecording bool // 是否正在录制直播视频
|
||||||
|
recordCancel context.CancelFunc // 用来停止录制视频(中断当前分段)
|
||||||
|
recordStop context.CancelFunc // 用来结束整个录制循环,避免停止后又被当成意外中断而重启
|
||||||
|
ffmpegStdin io.WriteCloser // ffmpeg 的 stdin
|
||||||
|
recordFile string // 视频文件路径
|
||||||
|
firstRecordFile string // 第一个分段的视频文件路径(与弹幕文件同名配对,用于自动合并)
|
||||||
|
recordStartNs int64 // 第一个分段开始录制的时刻,纳秒 Unix(用于计算弹幕与录播的时间偏移)
|
||||||
|
recordSegments []recordSegment // 本场录播的所有分段(中断重连会产生新的分段),合并时全部参与
|
||||||
|
danmuDone bool // 弹幕录制是否已经结束
|
||||||
|
recordDone bool // 视频录制是否已经结束
|
||||||
|
danmuStarted bool // 本场是否启动过弹幕录制
|
||||||
|
recordStarted bool // 本场是否启动过视频录制
|
||||||
|
danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并)
|
||||||
|
recordStopped bool // 视频是否被用户手动停止
|
||||||
|
mergeStarted bool // 自动合并是否已经启动
|
||||||
|
doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录)
|
||||||
|
resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamerInfo 的 map
|
||||||
|
var sInfoMap struct {
|
||||||
|
sync.Mutex
|
||||||
|
info map[int]*streamerInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveInfo 的 map
|
||||||
|
var lInfoMap struct {
|
||||||
|
sync.RWMutex
|
||||||
|
info map[string]liveInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备 ID
|
||||||
|
var deviceID string
|
||||||
|
|
||||||
|
var (
|
||||||
|
exeDir string // 运行程序所在文件夹
|
||||||
|
mainCh chan controlMsg // main() 的管道
|
||||||
|
mainCtx context.Context // main() 的 ctx
|
||||||
|
configDir *string // 设置文件所在文件夹
|
||||||
|
recordDir *string // 弹幕文件保存的文件夹
|
||||||
|
logger = log.New(os.Stdout, "", log.LstdFlags) // 可以同步输出的 logger
|
||||||
|
itoa = strconv.Itoa // 将 int 转换为字符串
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
func checkErr(err error) {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程
|
||||||
|
// name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover
|
||||||
|
func safeGo(name string, f func()) {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试运行,三次出错后结束运行
|
||||||
|
func runThrice(f func() error) error {
|
||||||
|
var err error
|
||||||
|
for retry := 0; retry < 3; retry++ {
|
||||||
|
if err = f(); err != nil {
|
||||||
|
log.Printf("%v", err)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("运行三次都出现错误:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取时间
|
||||||
|
func getTime() string {
|
||||||
|
t := time.Now()
|
||||||
|
return fmt.Sprintf("%d-%02d-%02d %02d-%02d-%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印带时间戳的 log 信息
|
||||||
|
// lPrintln 定义在 webui.go 里,同时输出到控制台和 web UI 的日志
|
||||||
|
|
||||||
|
// 获取时间,按照 log 的时间格式
|
||||||
|
func getLogTime() string {
|
||||||
|
t := time.Now()
|
||||||
|
return fmt.Sprintf("%d/%02d/%02d %02d:%02d:%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印警告信息
|
||||||
|
func lPrintWarn(msg ...any) {
|
||||||
|
w := []any{"[WARN]"}
|
||||||
|
msg = append(w, msg...)
|
||||||
|
lPrintln(msg...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印错误信息
|
||||||
|
func lPrintErr(msg ...any) {
|
||||||
|
e := []any{"[ERROR]"}
|
||||||
|
msg = append(e, msg...)
|
||||||
|
lPrintln(msg...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印带时间戳的 log 信息(格式化字符串)
|
||||||
|
func lPrintf(format string, a ...any) {
|
||||||
|
lPrintln(fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印警告信息(格式化字符串)
|
||||||
|
func lPrintWarnf(format string, a ...any) {
|
||||||
|
lPrintWarn(fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印错误信息(格式化字符串)
|
||||||
|
func lPrintErrf(format string, a ...any) {
|
||||||
|
lPrintErr(fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 ID(UID)形式的字符串
|
||||||
|
func (s *streamer) longID() string {
|
||||||
|
return fmt.Sprintf("%s(%d)", s.Name, s.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志里用的主播名:只使用内存里已经有的数据,取不到就不发网络请求。
|
||||||
|
// getName 缓存未命中时会重试 3 次 HTTP(最长 30 多秒),放在日志里会把界面卡住。
|
||||||
|
func nameForLog(uid int) string {
|
||||||
|
if s, ok := getStreamer(uid); ok && s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
liveRooms.RLock()
|
||||||
|
defer liveRooms.RUnlock()
|
||||||
|
if room, ok := liveRooms.rooms[uid]; ok {
|
||||||
|
return room.name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 ID(UID)形式的字符串,用于日志,取不到名字时只打 uid
|
||||||
|
func longID(uid int) string {
|
||||||
|
name := nameForLog(uid)
|
||||||
|
if name == "" {
|
||||||
|
return itoa(uid)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s(%d)", name, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 uid 获取 liveInfo
|
||||||
|
func getLiveInfoByUID(uid int) (infoList []liveInfo, ok bool) {
|
||||||
|
lInfoMap.RLock()
|
||||||
|
defer lInfoMap.RUnlock()
|
||||||
|
for _, info := range lInfoMap.info {
|
||||||
|
if info.uid == uid {
|
||||||
|
infoList = append(infoList, info)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return infoList, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 liveID 获取 liveInfo
|
||||||
|
func getLiveInfo(liveID string) (liveInfo, bool) {
|
||||||
|
lInfoMap.RLock()
|
||||||
|
defer lInfoMap.RUnlock()
|
||||||
|
if info, ok := lInfoMap.info[liveID]; ok {
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
return liveInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 info 放进 lInfoMap 里
|
||||||
|
func setLiveInfo(info liveInfo) {
|
||||||
|
lInfoMap.Lock()
|
||||||
|
defer lInfoMap.Unlock()
|
||||||
|
lInfoMap.info[info.LiveID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原地更新 liveInfo 的部分字段,不覆盖其他字段(弹幕和视频录制共用同一个 liveInfo)
|
||||||
|
func updateLiveInfo(liveID string, f func(*liveInfo)) {
|
||||||
|
lInfoMap.Lock()
|
||||||
|
defer lInfoMap.Unlock()
|
||||||
|
info, ok := lInfoMap.info[liveID]
|
||||||
|
if !ok {
|
||||||
|
info = liveInfo{LiveID: liveID}
|
||||||
|
}
|
||||||
|
f(&info)
|
||||||
|
lInfoMap.info[liveID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 liveID 查询是否正在录制直播弹幕
|
||||||
|
func isDanmu(liveID string) bool {
|
||||||
|
if info, ok := getLiveInfo(liveID); ok {
|
||||||
|
return info.isDanmu
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 liveID 查询是否正在录制直播视频
|
||||||
|
func isRecording(liveID string) bool {
|
||||||
|
if info, ok := getLiveInfo(liveID); ok {
|
||||||
|
return info.isRecording
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user