commit ed4e47e305c6e005ae64ef70394afe23d4467414 Author: tamakyi Date: Fri Sep 25 13:16:54 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2be49a8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d478d0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf1f4e4 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..607218a --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/ass2jsonl.go b/ass2jsonl.go new file mode 100644 index 0000000..6b0d041 --- /dev/null +++ b/ass2jsonl.go @@ -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 +} diff --git a/automerge.go b/automerge.go new file mode 100644 index 0000000..a9117f2 --- /dev/null +++ b/automerge.go @@ -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) + } + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..eddc396 --- /dev/null +++ b/config.go @@ -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) +} diff --git a/cycle.go b/cycle.go new file mode 100644 index 0000000..bdd885a --- /dev/null +++ b/cycle.go @@ -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) + } + } +} diff --git a/danmu.go b/danmu.go new file mode 100644 index 0000000..5271213 --- /dev/null +++ b/danmu.go @@ -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 +} diff --git a/danmuburn.go b/danmuburn.go new file mode 100644 index 0000000..8fa94f8 --- /dev/null +++ b/danmuburn.go @@ -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 +} diff --git a/disk_other.go b/disk_other.go new file mode 100644 index 0000000..146f929 --- /dev/null +++ b/disk_other.go @@ -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 +} diff --git a/disk_windows.go b/disk_windows.go new file mode 100644 index 0000000..74da026 --- /dev/null +++ b/disk_windows.go @@ -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 +} diff --git a/doc/cli.md b/doc/cli.md new file mode 100644 index 0000000..ffd59fb --- /dev/null +++ b/doc/cli.md @@ -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`查看详细设置说明 diff --git a/doc/momoko-acfunlive-notify.py b/doc/momoko-acfunlive-notify.py new file mode 100644 index 0000000..4ed2a3f --- /dev/null +++ b/doc/momoko-acfunlive-notify.py @@ -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 '未配置'}") diff --git a/doc/review-2026-09-25.md b/doc/review-2026-09-25.md new file mode 100644 index 0000000..b30c99a --- /dev/null +++ b/doc/review-2026-09-25.md @@ -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 可下载成片 | diff --git a/doc/webapi.md b/doc/webapi.md new file mode 100644 index 0000000..131acd7 --- /dev/null +++ b/doc/webapi.md @@ -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 拒绝删除。 diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..bfe32f4 --- /dev/null +++ b/fetch.go @@ -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() != "直播已结束" +} diff --git a/filename_other.go b/filename_other.go new file mode 100644 index 0000000..dfad9e6 --- /dev/null +++ b/filename_other.go @@ -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) +} diff --git a/filename_windows.go b/filename_windows.go new file mode 100644 index 0000000..f107ca9 --- /dev/null +++ b/filename_windows.go @@ -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 +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..ff4da4c --- /dev/null +++ b/files.go @@ -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")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5aa3c78 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8fc84fe --- /dev/null +++ b/go.sum @@ -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= diff --git a/handle.go b/handle.go new file mode 100644 index 0000000..03d5b43 --- /dev/null +++ b/handle.go @@ -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 + } +} diff --git a/input.go b/input.go new file mode 100644 index 0000000..0f0b8f8 --- /dev/null +++ b/input.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1fc6de0 --- /dev/null +++ b/main.go @@ -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("本程序结束运行") +} diff --git a/manualmerge.go b/manualmerge.go new file mode 100644 index 0000000..e9e5d10 --- /dev/null +++ b/manualmerge.go @@ -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) + `}`)) +} diff --git a/manualmerge_test.go b/manualmerge_test.go new file mode 100644 index 0000000..ca1919d --- /dev/null +++ b/manualmerge_test.go @@ -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) +} diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..ee93282 --- /dev/null +++ b/notify.go @@ -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 "" +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..8e8bf6e --- /dev/null +++ b/record.go @@ -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() + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..923bdc8 --- /dev/null +++ b/utils.go @@ -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 +} diff --git a/webui.go b/webui.go new file mode 100644 index 0000000..adb75ba --- /dev/null +++ b/webui.go @@ -0,0 +1,1343 @@ +// web UI 相关 +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// 储存日志 +var logString struct { + sync.Mutex + str strings.Builder + total int64 // 从启动到现在写入的总字节数,用于按偏移量增量读取日志 +} + +const webuiHTML = ` + + + + +AcFun 直播录制 + + + +
+

AcFun 直播录制

+
弹幕 / 视频录制,直播结束后自动合并
+
+

设置

+
+
+
+ + + +
+
三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并
+
+
+
+
+
+
+
+
+
+
录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录
+
录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。
+
同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。
+
+
+
+
+
+
+
+ + + + +
+
勾选的事件会以 JSON 推送到上面的地址(字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群);推送失败只记警告,不影响录制
+
+ + 用上面填写的地址直接测试,不需要先保存 +
+
+ +
+
开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连
+
+
+ +
+
勾选类设置修改后立即保存,文本框内容点「保存设置」生效
+
+
+
+ + +
+
+
+
+ + +
+
+
忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段
+
+
+ + +
+
+
+

主播列表(勾选 = 开启对应录制,修改立即生效)

+
下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始
+
+
+
+

文件管理

+
点击下面的按钮读取文件和磁盘信息
+
+ + +
+
+
+
+

手动合并

+
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + + + +
+
+
+ + + +
+
+
+
+

运行日志

+

+  
+
+
+
+

请输入管理口令

+

管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。

+ +
+
+
+
+ + +` + +// web UI 的主播状态 +type webStreamer struct { + UID int `json:"uid"` + Name string `json:"name"` + Live bool `json:"live"` + Danmu bool `json:"danmu"` // 弹幕录制开关 + Record bool `json:"record"` // 视频录制开关 + Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源 + DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 + RecordActive bool `json:"recordActive"` // 是否正在录制视频 + Title string `json:"title"` + URL string `json:"url"` +} + +// web UI 的状态数据 +type webStatus struct { + UIDs []uidSetting `json:"uids"` + Directory string `json:"directory"` + AutoMerge bool `json:"autoMerge"` + MergeDelVideo bool `json:"mergeDelVideo"` + MergeDelDanmu bool `json:"mergeDelDanmu"` + MergeOutputDir string `json:"mergeOutputDir"` + MergeBitrate int `json:"mergeBitrate"` + DefaultBitrate int `json:"defaultBitrate"` + WebExternal bool `json:"webExternal"` + RecordSubdir string `json:"recordSubdir"` + MergeSubdir string `json:"mergeSubdir"` + FFmpegPath string `json:"ffmpegPath"` + RecordFormat string `json:"recordFormat"` + NotifyURL string `json:"notifyUrl"` + NotifyToken string `json:"notifyToken"` + NotifyGroup string `json:"notifyGroup"` + NotifyLiveOn bool `json:"notifyLiveOn"` + NotifyLiveOff bool `json:"notifyLiveOff"` + NotifyRecordStart bool `json:"notifyRecordStart"` + NotifyMergeDone bool `json:"notifyMergeDone"` + MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行 + MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名 + MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误 + MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻 + Streamers []webStreamer `json:"streamers"` +} + +// 打印日志到 web UI +func lPrintln(msg ...any) { + logger.Println(msg...) + logString.Lock() + defer logString.Unlock() + // 防止内存泄漏 + if logString.str.Len() > 1000000 { + logString.str.Reset() + } + n, _ := fmt.Fprint(&logString.str, getLogTime()+" ") + n2, _ := fmt.Fprintln(&logString.str, msg...) + logString.total += int64(n + n2) +} + +// 显示 web UI 页面 +func webuiHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, webuiHTML) +} + +// 显示状态 +func statusHandler(w http.ResponseWriter, r *http.Request) { + // 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿 + mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot() + streamers.RLock() + status := webStatus{ + UIDs: append(uidList{}, config.UIDs...), + Directory: config.Directory, + AutoMerge: config.AutoMerge, + MergeDelVideo: config.MergeDelVideo, + MergeDelDanmu: config.MergeDelDanmu, + MergeOutputDir: config.MergeOutputDir, + MergeBitrate: config.MergeBitrate, + WebExternal: config.WebExternal, + RecordSubdir: config.RecordSubdir, + MergeSubdir: config.MergeSubdir, + FFmpegPath: config.FFmpegPath, + RecordFormat: normalizeRecordFormat(config.RecordFormat), + DefaultBitrate: config.DefaultBitrate, + NotifyURL: config.NotifyURL, + NotifyToken: config.NotifyToken, + NotifyGroup: config.NotifyGroup, + NotifyLiveOn: config.NotifyLiveOn, + NotifyLiveOff: config.NotifyLiveOff, + NotifyRecordStart: config.NotifyRecordStart, + NotifyMergeDone: config.NotifyMergeDone, + MergeBusy: mergeBusy, + MergeOutName: mergeOut, + MergeErr: mergeErr, + MergeDoneAt: mergeDoneAt, + Streamers: make([]webStreamer, 0, len(streamers.crt)), + } + for _, s := range streamers.crt { + live := s.isLiveOn() + var title string + if live { + title = s.getTitle() + } + ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record, Bitrate: s.Bitrate} + if infoList, ok := getLiveInfoByUID(s.UID); ok { + for _, info := range infoList { + if info.isDanmu { + ws.DanmuActive = true + } + if info.isRecording { + ws.RecordActive = true + } + } + } + status.Streamers = append(status.Streamers, ws) + } + streamers.RUnlock() + + sort.Slice(status.Streamers, func(i, j int) bool { + return status.Streamers[i].UID < status.Streamers[j].UID + }) + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(status) + checkErr(err) +} + +// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过 +func checkWebDir(path, name string) string { + if path == "" { + return "" + } + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return name + " 必须是存在的文件夹:" + path + } + return "" +} + +// 修改设置(保存目录和自动合并相关) +func webConfigHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Directory string `json:"directory"` + AutoMerge bool `json:"autoMerge"` + MergeDelVideo bool `json:"mergeDelVideo"` + MergeDelDanmu bool `json:"mergeDelDanmu"` + MergeOutputDir string `json:"mergeOutputDir"` + MergeBitrate int `json:"mergeBitrate"` + DefaultBitrate int `json:"defaultBitrate"` + WebExternal bool `json:"webExternal"` + RecordSubdir string `json:"recordSubdir"` + MergeSubdir string `json:"mergeSubdir"` + FFmpegPath string `json:"ffmpegPath"` + RecordFormat string `json:"recordFormat"` + NotifyURL string `json:"notifyUrl"` + NotifyToken string `json:"notifyToken"` + NotifyGroup string `json:"notifyGroup"` + NotifyLiveOn bool `json:"notifyLiveOn"` + NotifyLiveOff bool `json:"notifyLiveOff"` + NotifyRecordStart bool `json:"notifyRecordStart"` + NotifyMergeDone bool `json:"notifyMergeDone"` + } + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if msg := checkWebDir(req.Directory, "directory"); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + if msg := checkWebDir(req.MergeOutputDir, "mergeOutputDir"); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + if req.MergeBitrate < 0 { + http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest) + return + } + if req.DefaultBitrate < 0 { + http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest) + return + } + recordSubdir := strings.TrimSpace(req.RecordSubdir) + mergeSubdir := strings.TrimSpace(req.MergeSubdir) + if recordSubdir == "" { + recordSubdir = defaultRecordSubdir + } + if mergeSubdir == "" { + mergeSubdir = defaultMergeSubdir + } + if isBadSubdir(recordSubdir) { + http.Error(w, "录播子文件夹名不能包含路径分隔符或非法字符:"+recordSubdir, http.StatusBadRequest) + return + } + if isBadSubdir(mergeSubdir) { + http.Error(w, "成片子文件夹名不能包含路径分隔符或非法字符:"+mergeSubdir, http.StatusBadRequest) + return + } + if recordSubdir == mergeSubdir && req.MergeOutputDir == "" { + http.Error(w, "录播子文件夹和成片子文件夹不能相同", http.StatusBadRequest) + return + } + if msg := checkFFmpegPath(req.FFmpegPath); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + recordFormat := strings.ToLower(strings.TrimSpace(req.RecordFormat)) + if recordFormat == "" { + recordFormat = defaultRecordFormat + } + if _, ok := recordFormats[recordFormat]; !ok { + http.Error(w, "录播格式只能是 mp4、flv、ts 或 mkv:"+recordFormat, http.StatusBadRequest) + return + } + notifyURL := strings.TrimSpace(req.NotifyURL) + if msg := checkNotifyURL(notifyURL); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + + streamers.Lock() + config.Directory = req.Directory + config.AutoMerge = req.AutoMerge + config.MergeDelVideo = req.MergeDelVideo + config.MergeDelDanmu = req.MergeDelDanmu + config.MergeOutputDir = req.MergeOutputDir + config.MergeBitrate = req.MergeBitrate + config.DefaultBitrate = req.DefaultBitrate + restartWeb := config.WebExternal != req.WebExternal + config.WebExternal = req.WebExternal + config.RecordSubdir = recordSubdir + config.MergeSubdir = mergeSubdir + config.FFmpegPath = strings.TrimSpace(req.FFmpegPath) + config.RecordFormat = recordFormat + config.NotifyURL = notifyURL + config.NotifyToken = strings.TrimSpace(req.NotifyToken) + config.NotifyGroup = strings.TrimSpace(req.NotifyGroup) + config.NotifyLiveOn = req.NotifyLiveOn + config.NotifyLiveOff = req.NotifyLiveOff + config.NotifyRecordStart = req.NotifyRecordStart + config.NotifyMergeDone = req.NotifyMergeDone + // 修改了子文件夹名后确保新文件夹存在 + if err := os.MkdirAll(recordingDir(), 0755); err != nil { + lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err) + } + if err := os.MkdirAll(recordSaveDir(), 0755); err != nil { + lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err) + } + if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil { + lPrintWarnf("创建文件夹 %s 失败:%v", mergeSaveDir(), err) + } + streamers.Unlock() + + saveConfig() + + if restartWeb { + lPrintln("外网访问设置已改变,重启 web UI 服务器") + restartWebUI() + } + lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、默认下载码率 %d、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q", + req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir, + req.MergeBitrate, req.DefaultBitrate, req.WebExternal, recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat) + lPrintf("推送设置:地址 %q、群号 %q、开播 %v、下播 %v、开始录制 %v、合并完成 %v", + notifyURL, config.NotifyGroup, req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 新增主播或修改主播的弹幕/录播开关和码率 +func webStreamerHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + UID int `json:"uid"` + Danmu bool `json:"danmu"` + Record bool `json:"record"` + Bitrate *int `json:"bitrate"` // 不传时使用默认下载码率上限 + } + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if req.UID <= 0 { + http.Error(w, "uid 必须大于 0", http.StatusBadRequest) + return + } + bitrate := config.DefaultBitrate + if req.Bitrate != nil { + bitrate = *req.Bitrate + } + if bitrate < 0 { + http.Error(w, "bitrate 不能小于 0", http.StatusBadRequest) + return + } + + // 码率变化时先停掉正在进行的录播,setStreamerFlags 会用新码率重启 + if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate && cur.Record && req.Record { + stopRecord(req.UID) + } + if !setStreamerFlags(req.UID, req.Danmu, req.Record) { + http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest) + return + } + if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate { + setBitrate(req.UID, bitrate) + } + lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、码率上限 %d", req.UID, req.Danmu, req.Record, bitrate) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 停止或重新开始某个主播的录制 +func webStreamerControlHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + UID int `json:"uid"` + Target string `json:"target"` // danmu、record 或 all + Action string `json:"action"` // stop 或 restart + } + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if req.UID <= 0 { + http.Error(w, "uid 必须大于 0", http.StatusBadRequest) + return + } + if err := controlStreamer(req.UID, req.Target, req.Action); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + actionName := "停止" + if req.Action == "restart" { + actionName = "重新开始" + } + lPrintf("通过 web UI %s uid %d 的录制(%s)", actionName, req.UID, req.Target) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 删除主播 +func webStreamerRemoveHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + UID int `json:"uid"` + } + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + removeUID(req.UID) + lPrintf("通过 web UI 删除了 uid %d", req.UID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 发送一条测试通知,验证推送地址、群号和口令是否配置正确 +func notifyTestHandler(w http.ResponseWriter, r *http.Request) { + // 允许直接用设置区里还没保存的地址测试,没传就用已经保存的配置 + var req struct { + URL string `json:"url"` + Token string `json:"token"` + Group string `json:"group"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + addr := strings.TrimSpace(req.URL) + token := strings.TrimSpace(req.Token) + group := strings.TrimSpace(req.Group) + if addr == "" { + n := currentNotifySetting() + addr, token = n.url, n.token + } + if group == "" { + group = currentNotifySetting().group + } + if addr == "" { + http.Error(w, "还没有填写通知推送地址", http.StatusBadRequest) + return + } + if msg := checkNotifyURL(addr); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + + p := notifyPayload{ + Event: "test", + Name: "AcFun 直播录制", + Group: group, + Text: "这是一条测试通知,收到就说明推送地址和口令都配置正确", + Time: getLogTime(), + } + if err := postNotify(addr, token, p); err != nil { + lPrintWarnf("推送测试通知失败:%v", err) + http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway) + return + } + lPrintln("测试通知推送成功:" + addr) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 显示日志,支持 ?since=<偏移量> 只返回新增的部分,避免每 3 秒全量传输 +func logHandler(w http.ResponseWriter, r *http.Request) { + since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) + if err != nil { + since = 0 + } + + logString.Lock() + text := logString.str.String() + start := logString.total - int64(len(text)) // 缓冲区里现有内容对应的起始偏移 + data := text + if since > start && since <= logString.total { + data = text[since-start:] + } + total := logString.total + logString.Unlock() + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + // 用响应头返回新的偏移量:前端拿到的是解码后的字符串,长度不等于字节数 + w.Header().Set("X-Log-Offset", strconv.FormatInt(total, 10)) + fmt.Fprint(w, data) +} + +// 启动 web UI 服务器 +func startWebUI(ctx context.Context) { + defer func() { + if err := recover(); err != nil { + lPrintErr("Recovering from panic in startWebUI(), the error is:", err) + lPrintErr("web UI 服务器发生错误,尝试重启 web UI 服务器") + time.Sleep(2 * time.Second) + go startWebUI(ctx) + } + }() + + addr := webUIAddr() + if config.WebExternal { + lPrintln("启动 web UI 服务器,监听 " + addr + ",局域网或外网设备可以通过本机 IP 访问 web UI 界面") + } else { + lPrintln("启动 web UI 服务器,只监听本机 " + addr + ",现在可以通过 http://localhost:" + itoa(config.WebPort) + " 访问 web UI 界面") + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", webuiHandler) + mux.HandleFunc("GET /api/auth", authCheckHandler) + mux.HandleFunc("GET /api/auth/token", authTokenHandler) + mux.HandleFunc("POST /api/auth/token", authTokenSetHandler) + mux.HandleFunc("GET /api/status", statusHandler) + mux.HandleFunc("POST /api/config", webConfigHandler) + mux.HandleFunc("POST /api/streamer", webStreamerHandler) + mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler) + mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler) + mux.HandleFunc("GET /api/log", logHandler) + mux.HandleFunc("POST /api/notify/test", notifyTestHandler) + mux.HandleFunc("GET /api/files", filesHandler) + mux.HandleFunc("GET /api/file", fileDownloadHandler) + mux.HandleFunc("POST /api/file/remove", fileRemoveHandler) + mux.HandleFunc("POST /api/merge/info", mergeInfoHandler) + mux.HandleFunc("POST /api/merge", mergeHandler) + + srvCtx, srvCancel := context.WithCancel(ctx) + webSrvMu.Lock() + webSrvCancel = srvCancel + webSrvMu.Unlock() + + srv := &http.Server{ + Addr: addr, + IdleTimeout: 60 * time.Second, + // 没有这几个超时的话,慢速连接可以一直占着不放 + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + Handler: webMiddleware(webAuth(mux)), + } + + // 退出程序或主动重启时关闭 web UI 服务器 + go func() { + <-srvCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + lPrintErr("web UI 服务器关闭错误:", err) + } + }() + + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + // 主动重启导致的关闭不算错误 + if srvCtx.Err() != nil { + lPrintln("停止 web UI 服务器") + return + } + lPrintErr("web UI 服务器出现错误:", err) + panic(err) + } + lPrintln("停止 web UI 服务器") +} + +// web UI 监听地址,允许外网访问时监听所有网卡,否则只监听本机 +func webUIAddr() string { + if config.WebExternal { + return ":" + itoa(config.WebPort) + } + return "127.0.0.1:" + itoa(config.WebPort) +} + +// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token= +func authTokenFromRequest(r *http.Request) string { + if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" { + if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") { + return strings.TrimSpace(h[7:]) + } + } + if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" { + return t + } + return strings.TrimSpace(r.URL.Query().Get("token")) +} + +// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令 +func checkAuthToken(r *http.Request) bool { + want := currentAuthToken() + if want == "" { + return false + } + got := authTokenFromRequest(r) + if got == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// 口令错误次数的记录,给反复尝试的请求加一点延迟 +var authFail struct { + sync.Mutex + m map[string]authFailEntry +} + +type authFailEntry struct { + count int + last time.Time +} + +// 口令错误时最多延迟这么久,避免拖垮服务器 +const authMaxDelay = 2 * time.Second + +// 取请求的来源 IP +func clientIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// 记录一次口令错误并返回应该延迟的时长;口令正确时调用 authFailClear 清掉记录 +func authFailDelay(ip string) time.Duration { + authFail.Lock() + defer authFail.Unlock() + if authFail.m == nil { + authFail.m = make(map[string]authFailEntry) + } + // 记录太多时清掉过期的,避免大量不同来源的请求把内存撑满 + if len(authFail.m) > 1000 { + for k, v := range authFail.m { + if time.Since(v.last) > 10*time.Minute { + delete(authFail.m, k) + } + } + } + e := authFail.m[ip] + e.count++ + e.last = time.Now() + authFail.m[ip] = e + d := time.Duration(e.count) * 100 * time.Millisecond + if d > authMaxDelay { + d = authMaxDelay + } + return d +} + +// 口令正确时清掉错误记录 +func authFailClear(ip string) { + authFail.Lock() + defer authFail.Unlock() + delete(authFail.m, ip) +} + +// 管理接口的口令校验:页面本身可以匿名打开(前端拿到 401 后再提示输入口令), +// 所有 /api/ 接口都必须带上正确的口令 +func webAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + ip := clientIP(r) + if checkAuthToken(r) { + authFailClear(ip) + next.ServeHTTP(w, r) + return + } + if d := authFailDelay(ip); d > 0 { + time.Sleep(d) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"管理口令不正确,请在页面上重新输入"}`)) + }) +} + +// 口令校验通过后才会走到这里,直接返回成功供前端登录使用 +func authCheckHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 返回当前的管理口令,方便用户在设置区查看和复制 +func authTokenHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"token": currentAuthToken()}) +} + +// 更换管理口令 +func authTokenSetHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + token := strings.TrimSpace(req.Token) + if len([]rune(token)) < 8 { + http.Error(w, "管理口令至少需要 8 个字符", http.StatusBadRequest) + return + } + streamers.Lock() + config.AuthToken = token + streamers.Unlock() + saveConfig() + lPrintln("通过 web UI 更换了管理口令(authToken)") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("true")) +} + +// 统一的响应头和请求体限制 +func webMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + // 页面用的是内联脚本和样式,所以只放行内联 + h.Set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'") + // 写接口的请求体都很小,限制大小避免被慢速大请求拖着 + if r.Method == http.MethodPost { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + } + next.ServeHTTP(w, r) + }) +} + +var ( + webSrvMu sync.Mutex + webSrvCancel context.CancelFunc +) + +// 切换外网访问后监听地址会变,需要重启 web UI 服务器 +func restartWebUI() { + webSrvMu.Lock() + cancel := webSrvCancel + webSrvMu.Unlock() + if cancel != nil { + cancel() + } + go func() { + time.Sleep(500 * time.Millisecond) + startWebUI(mainCtx) + }() +}