From ed4e47e305c6e005ae64ef70394afe23d4467414 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 13:16:54 +0800 Subject: [PATCH] init --- .gitignore | 29 + Dockerfile | 31 + LICENSE | 674 ++++++++++++++++ Makefile | 39 + README.md | 247 ++++++ ass2jsonl.go | 400 ++++++++++ automerge.go | 302 +++++++ config.go | 910 ++++++++++++++++++++++ cycle.go | 284 +++++++ danmu.go | 439 +++++++++++ danmuburn.go | 836 ++++++++++++++++++++ disk_other.go | 15 + disk_windows.go | 27 + doc/cli.md | 38 + doc/momoko-acfunlive-notify.py | 124 +++ doc/review-2026-09-25.md | 159 ++++ doc/webapi.md | 95 +++ fetch.go | 378 +++++++++ filename_other.go | 28 + filename_windows.go | 38 + files.go | 244 ++++++ go.mod | 29 + go.sum | 76 ++ handle.go | 101 +++ input.go | 101 +++ main.go | 208 +++++ manualmerge.go | 555 +++++++++++++ manualmerge_test.go | 720 +++++++++++++++++ notify.go | 173 ++++ record.go | 312 ++++++++ utils.go | 259 ++++++ webui.go | 1343 ++++++++++++++++++++++++++++++++ 32 files changed, 9214 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 ass2jsonl.go create mode 100644 automerge.go create mode 100644 config.go create mode 100644 cycle.go create mode 100644 danmu.go create mode 100644 danmuburn.go create mode 100644 disk_other.go create mode 100644 disk_windows.go create mode 100644 doc/cli.md create mode 100644 doc/momoko-acfunlive-notify.py create mode 100644 doc/review-2026-09-25.md create mode 100644 doc/webapi.md create mode 100644 fetch.go create mode 100644 filename_other.go create mode 100644 filename_windows.go create mode 100644 files.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handle.go create mode 100644 input.go create mode 100644 main.go create mode 100644 manualmerge.go create mode 100644 manualmerge_test.go create mode 100644 notify.go create mode 100644 record.go create mode 100644 utils.go create mode 100644 webui.go 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) + }() +}