commit 6f6e3161ec5e3c633a9ba03fe9f91857b3f2bd25 Author: root Date: Sat May 9 19:31:07 2026 +0000 Proyecto KiraTV diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e49113 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copiar a .env y ajustar +PORT=3000 +JWT_SECRET=cambia_esto_en_produccion + +# Backend Xtream (Dispatcharr en tu red) +# Ejemplo: http://10.10.10.101:9191 +# En la UI de KiraTV aΓ±ades una fuente Xtream con la misma URL y usuario/contraseΓ±a de Dispatcharr. + +# Opcional: si usas balanceador dedicado solo para /api/transcode, la app debe generar URLs absolutas +# con ese host (requiere cambios en front); por defecto mismo origen vΓ­a reverse proxy en 10.10.10.115 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a2ba80f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Before opening an issue, please confirm:** +- [ ] The stream works in VLC/another player +- [ ] I've read the [Browser Codec Support](https://github.com/technomancer702/nodecast-tv#browser-codec-support), [Supported Stream Types](https://github.com/technomancer702/nodecast-tv#supported-stream-types) and [Troubleshooting](https://github.com/technomancer702/nodecast-tv#troubleshooting) sections in the README +- [ ] I've tried enabling "Force Proxy" for CORS issues +- [ ] I've tried enabling "Force Audio Transcode" for audio codec issues +- [ ] I've tried enabling "Force Remux" for video playback issues + + +**Describe the bug** +A clear and concise description of what the bug is. +**Source Type** +- [ ] Xtream Codes +- [ ] M3U Playlist + + +**Stream Information (if applicable)** +- Audio Codec (if known): [e.g. AAC, AC3, EAC3/DD+] +- Video Codec (if known): [e.g. H264, HEVC] + +**To Reproduce** +1. Add source type: '...' +2. Select channel: '...' +3. See error: '...' + +**Expected behavior** +What you expected to happen. + +**Console/Server Logs** +Share your browser console and server logs here. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment** +- Deployment: [Docker / Node.js / Other] +- OS: [e.g. Ubuntu 22.04, Windows 11] +- Browser: [e.g. Chrome 120, Safari 17] + +**Additional context** +Any other relevant information. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d58b825 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,34 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEAT]" +labels: enhancement +assignees: '' + +--- + +**Feature Category** +- [ ] Playback / Streaming +- [ ] UI / Interface +- [ ] Source Management (Xtream/M3U) +- [ ] EPG / Program Guide +- [ ] Settings / Configuration +- [ ] Other + +**Is your feature request related to a problem?** +A clear description of the problem. Ex. I'm frustrated when [...] + +**Describe the feature you'd like** +A clear description of what you want to happen. + +**Use Case** +How would this feature improve your experience? Who would benefit from this? + +**Alternatives considered** +Any workarounds or alternative solutions you've thought of. + +**Mockup/Examples (optional)** +Screenshots, mockups, or examples from other apps that implement this feature. + +**Additional context** +Any other relevant information. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..9ba1fff --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,56 @@ +name: Build and Publish Container Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + release: + types: [ published ] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Container image + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/nodecast-tv + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Container image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c45c938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +data/*.db +.env +*.log +data/ +user_data/ +coverage/ +.DS_Store +dist/ +release/ +.vscode +transcode-cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73316e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# NodeCast TV Docker Image +# +# Hardware acceleration: +# - VAAPI (Intel/AMD): Mount /dev/dri and add video/render groups +# - NVIDIA NVENC: Requires nvidia-container-toolkit on host + --gpus flag +# - Intel QSV: Mount /dev/dri +# +# Build: docker compose build +# Run with VAAPI: docker run --device /dev/dri:/dev/dri --group-add video ... + +FROM ubuntu:24.04 + +# Install Node.js, FFmpeg, and hardware acceleration drivers +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && if [ "$TARGETARCH" = "amd64" ]; then \ + DRIVERS="mesa-va-drivers intel-media-va-driver vainfo"; \ + else \ + DRIVERS=""; \ + fi \ + && apt-get update && apt-get install -y --no-install-recommends \ + nodejs \ + ffmpeg \ + python3 \ + make \ + g++ \ + $DRIVERS \ + && rm -rf /var/lib/apt/lists/* + +# Verify FFmpeg installed +RUN ffmpeg -version && ffmpeg -encoders 2>/dev/null | grep -E "vaapi|nvenc|qsv|libx264" | head -10 + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies (better-sqlite3 will build from source using g++ installed above) +RUN npm ci --only=production + +# Copy application files +COPY . . + +# Create data and cache directories +RUN mkdir -p /app/data /app/transcode-cache && chmod 777 /app/transcode-cache + +# Expose port +EXPOSE 3000 + +# Start server +CMD ["node", "server/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8345712 --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + 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 December 20, 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/README.md b/README.md new file mode 100644 index 0000000..19dfcdf --- /dev/null +++ b/README.md @@ -0,0 +1,350 @@ +

+ nodecast-tv +

+ +# What is nodecast-tv? + +nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (VOD), and Series support. Built with performance and user experience in mind. + +## Features + +- **πŸ“Ί Live TV**: Fast channel zapping, category grouping, and search. +- **πŸ“… TV Guide (EPG)**: Interactive grid guide with 24h timeline, search, and dynamic resizing. +- **🎬 VOD Support**: Dedicated sections for Movies and TV Series with rich metadata, posters, and seasonal episode lists. +- **❀️ Favorites System**: Unified favorites for channels, movies, and series with instant synchronization. +- **πŸ” Authentication**: User login system with admin and viewer roles ([details](https://github.com/technomancer702/nodecast-tv/pull/23)). +- **πŸ†” OIDC SSO**: Support for Single Sign-On via OIDC providers (Authentik, Keycloak, etc.). +- **⚑ High Performance**: Optimized for large playlists (7000+ channels) using virtual scrolling and batch rendering. +- **βš™οΈ Management**: + - Support for Xtream Codes and M3U playlists. + - Manage hidden content categories. + - Playback preferences (volume memory, auto-play). +- **πŸŽ›οΈ Hardware Transcoding**: GPU-accelerated transcoding with NVIDIA NVENC, AMD AMF, Intel QuickSync, and VAAPI support. +- **πŸ”Š Smart Audio**: Configurable 5.1β†’Stereo downmix presets (ITU, Night Mode, Cinematic) with automatic passthrough for compatible sources. +- **πŸ“¦ Stream Processing**: Auto-detection of stream codecs with smart remux/transcode decisions. +- **🐳 Docker Ready**: Easy deployment containerization. + +## Screenshots + +
+ Dashboard + Live TV + TV Guide + Movies + Series + Settings +
+ +## Getting Started + +### Prerequisites + +- Node.js (v14 or higher) +- npm + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/technomancer702/nodecast-tv.git + cd nodecast-tv + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Start the development server: + ```bash + npm run dev + ``` + +4. Open your browser at `http://localhost:3000`. + +### Docker Deployment + +You can run nodecast-tv easily using Docker. + +1. Create a `docker-compose.yml` file (or copy the one from this repo): + + ```yaml + services: + nodecast-tv: + build: https://github.com/technomancer702/nodecast-tv.git#main + container_name: nodecast-tv + ports: + - "3000:3000" # Host:Container + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 # Optional: Internal container port + ``` + +2. Run the container: + ```bash + docker-compose up -d + ``` + +The application will be available at `http://localhost:3000`. + + +### Hardware Acceleration Setup + +To enable hardware transcoding (NVENC, QSV, VAAPI), you must expose your host's GPU to the container. + +**1. Intel (QSV) & AMD (VAAPI)** +Update your `docker-compose.yml` to map the DRI devices and add necessary groups (often required for permission): +```yaml + devices: + - /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux) + # group_add: # Optional: Needed mainly if you run as non-root + # - "video" # Run on host: getent group video + # - "render" # Run on host: getent group render +``` + +**2. NVIDIA (NVENC)** +Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) installed on your host, then update your `docker-compose.yml`: +```yaml + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu, utility, video, compute] +``` + +**Verify:** +After restarting the container, go to **Settings -> Transcoding**. The **Hardware Detection** status should list your GPU (e.g., "NVIDIA GPU Detected" or "VAAPI Available"). + +### SSO / OIDC Setup + +Enable Single Sign-On (SSO) with your preferred OIDC provider (Authentik, Keycloak, etc.) by configuring these variables in your `.env` file or Docker environment: + +```env +OIDC_ISSUER_URL=https://your-idp.com/application/o/nodecast/ +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret +OIDC_CALLBACK_URL=http://localhost:3000/api/auth/oidc/callback # Adjust for your domain +``` + +**Note:** New users signing in via SSO are automatically assigned the **Viewer** role. You must manually promote them to Admin if desired. + +### Usage + +1. Go to **Settings** -> **Content Sources**. +2. Add your IPTV provider details (Xtream Codes or M3U URL). +3. Click "Refresh Sources". +4. Navigate to **Live TV**, **Movies**, or **Series** to browse your content. + + +## Browser Codec Support & Transcoding + +nodecast-tv is a web-based application. By default, **video decoding is handled by your browser**. However, the built-in **smart transcoding system** automatically converts incompatible media (e.g., HEVC video, Dolby audio) into browser-friendly formats using FFmpeg. + +**Codec Compatibility Table:** + +| Codec | Chrome | Firefox | Safari | Edge | +|-------|--------|---------|--------|------| +| **H.264 (AVC)** | βœ… | βœ… | βœ… | βœ… | +| **H.265 (HEVC)** | Auto-Transcode | Auto-Transcode | βœ… | ⚠️ | +| **AV1** | βœ… | βœ… | Auto-Transcode | βœ… | +| **AAC Audio** | βœ… | βœ… | βœ… | βœ… | +| **AC3/EAC3 (Dolby)** | Auto-Transcode | Auto-Transcode | βœ… | Auto-Transcode | + +> **⚠️ Note:** Edge requires the [HEVC Video Extensions](https://apps.microsoft.com/store/detail/hevc-video-extensions/9NMZLZ57R3T7) from the Microsoft Store to play H.265 (HEVC) natively. +> **ℹ️ Note:** Safari plays AV1 natively on supported hardware (iPhone 15 Pro, M3 Macs). On older devices, Auto-Transcode handles it. + + + +## Supported Stream Types + +nodecast-tv is optimized for **HLS (HTTP Live Streaming)**. + +- **βœ… HLS (`.m3u8`)**: Fully supported and recommended. Best for adaptive bitrate and network resilience. +- **βœ… MPEG-TS (`.ts`)**: Supported via Force Remux in settings. +- **⚠️ High Latency/P2P**: For sources like Acestream, prefer HLS output (`.m3u8`) over raw TS streams to avoid timeouts during buffering. +- **❌ RTMP/RTSP**: Not supported natively by browsers. + +## Transcoding Settings + +All transcoding and stream processing settings are found in **Settings β†’ Transcoding**. + +### Hardware Encoder + +| Setting | Options | Description | +|---------|---------|-------------| +| **Hardware Encoder** | Auto, NVENC, AMF, QSV, VAAPI, Software | GPU-accelerated encoding. Auto detects best available. | +| **Max Resolution** | 4K, 1080p, 720p, 480p | Limit output resolution (lower = faster). | +| **Quality Preset** | High, Medium, Low | Encoding quality/speed tradeoff. | +| **Audio Mix Preset** | Auto, ITU, Night Mode, Cinematic, Passthrough | 5.1β†’Stereo downmix mode (see below). | + +### Audio Mix Presets + +| Preset | Description | +|--------|-------------| +| **Auto (Smart)** | Copies stereo AAC as-is, uses ITU downmix for 5.1+ | +| **ITU-R BS.775** | Industry-standard balanced downmix | +| **Night Mode** | Boosted dialogue, reduced bass for quiet viewing | +| **Cinematic** | Wide soundstage, immersive surround feel | +| **Passthrough** | No processing (may cause errors on 5.1/Dolby sources) | + +### Stream Processing + +| Setting | What It Does | When to Enable | +|---------|--------------|----------------| +| **Auto Transcode (Smart)** | Probes streams and only transcodes/remuxes when needed | Recommended for most users (default ON) | +| **Force Audio Transcode** | Transcodes audio to AAC (video passes through) | When you have video but no audio (Dolby/AC3/EAC3) | +| **Force Video Transcode** | Full transcode of both audio and video | For HEVC/VP9 sources on unsupported browsers | +| **Force Remux** | Remuxes MPEG-TS to MP4 (no re-encoding) | For raw `.ts` streams from middleware | +| **Stream Output Format** | HLS or TS for Xtream API requests | Try TS if HLS causes buffering | + +### Network + +| Setting | What It Does | When to Enable | +|---------|--------------|----------------| +| **Force Backend Proxy** | Routes streams through the server for CORS headers | When streams fail with CORS errors, or using middleware | + + +## Troubleshooting + +### Video Won't Play (Black Screen or Loading Forever) + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| Black screen, `Access-Control-Allow-Origin` error | CORS blocked | Enable **"Force Backend Proxy"** in Settings β†’ Transcoding | +| Black screen with `MEDIA_ERR_DECODE` | Unsupported codec (HEVC/VP9) | Ensure **"Auto Transcode"** is enabled | +| Loading forever (no error) | Browser decoder stuck | Enable **"Force Video Transcode"** (overrides Auto detection) | + +### No Audio (Video Plays Fine) + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| No audio at all | Dolby/AC3/EAC3 audio | Enable **"Force Audio Transcode"** (overrides Auto detection) | +| Audio out of sync | Stream encoding issue | Try changing stream format to TS in Settings | + +### Buffering Issues + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| Constant buffering | Slow network or weak GPU | 1. Lower **Max Resolution** (e.g. to 720p)
2. Try **TS** format instead of HLS | + +### HTTPS / Reverse Proxy Issues + +If you're running nodecast-tv behind a reverse proxy (Nginx, Caddy, Traefik) with HTTPS: + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| Streams fail with `fragLoadError` | Mixed content (HTTPS page loading HTTP streams) | Enable **"Force Backend Proxy"** in Settings β†’ Transcoding | +| Streams work on HTTP but not HTTPS | Reverse proxy not passing headers correctly | Ensure `X-Forwarded-Proto` header is set (see examples below) | + +**Caddy example:** +``` +tv.domain.com { + reverse_proxy nodecast:3000 { + flush_interval -1 + header_up X-Forwarded-Proto {scheme} + } +} +``` + +**Nginx example:** +```nginx +location / { + proxy_pass http://nodecast:3000; + proxy_http_version 1.1; # Required for chunked transfers and keep-alive + proxy_buffering off; # Don't buffer responses (required for streaming) + proxy_request_buffering off; # Don't buffer requests + proxy_read_timeout 300s; # VOD: 5 min timeout for large files + proxy_connect_timeout 60s; # VOD: Connection timeout + client_max_body_size 0; # No upload size limit + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; # Required for HTTPS detection + proxy_set_header Connection ""; # VOD: Enable keep-alive for Range requests +} +``` + +### IPTV Middleware (m3u-editor, dispatcharr, Threadfin, xTeVe) +If you manage your streams with middleware tools, you may encounter CORS issues or raw MPEG-TS streams that browsers can't play directly. + +**Recommended Setup:** +1. **Force Backend Proxy:** Enable this in **Settings β†’ Transcoding β†’ Network**. This routes middleware streams through NodeCast TV, bypassing CORS restrictions. +2. **Auto Transcode:** Keep this enabled (default). It will automatically detect if the middleware stream (e.g., MPEG-TS) needs to be remuxed or transcoded for the browser. + +There is rarely a need to configure specific "Force Remux" settings manually anymore; the system detects stream types automatically. + +### TVHeadend + +If you're using TVHeadend as your source, you may need to configure a few settings for streams to play correctly in nodecast-tv: + +**Option 1: Enable Force Backend Proxy (Easiest)** +- In nodecast-tv, go to **Settings β†’ Transcoding β†’ Network** +- Enable **"Force Backend Proxy"** +- This routes streams through the server, bypassing browser CORS restrictions + +**Option 2: Configure TVHeadend CORS** +- In TVHeadend, go to **Configuration β†’ General β†’ Base β†’ HTTP Server Settings** +- Add your nodecast-tv URL to **"CORS origin"** (e.g., `http://192.168.1.100:3000`) +- **Note:** You must include the protocol (`http://` or `https://`) + +**Additional Tips:** +- Enable **"digest+plain"** authentication in TVHeadend if using username/password in the M3U URL +- Try different stream profiles (`?profile=pass` or `?profile=matroska`) if playback issues persist + +### Acestream / P2P Streaming + +If you are using `acestream-docker-home` or similar tools, it is **recommended** to use the HLS output format to reduce server load, though NodeCast TV can remux raw streams if needed. + +- **Recommended:** `http://proxy:6878/ace/manifest.m3u8?id=...` (HLS Playlist - Direct Play) +- **Supported:** `http://proxy:6878/ace/getstream?id=...` (MPEG-TS - Requires Server Remuxing) + +## Technology Stack + +- **Backend**: Node.js, Express +- **Frontend**: Vanilla JavaScript (ES6+), CSS3 +- **Database**: SQLite (via better-sqlite3) for high-performance data storage +- **Streaming**: HLS.js for stream playback +- **Transcoding**: FFmpeg (integrated for hardware/software transcoding) + +## Project Structure + +``` +nodecast-tv/ +β”œβ”€β”€ public/ # Frontend assets +β”‚ β”œβ”€β”€ css/ # Stylesheets +β”‚ β”œβ”€β”€ js/ # Client-side logic +β”‚ β”‚ β”œβ”€β”€ components/ # UI Components (ChannelList, EpgGuide, etc.) +β”‚ β”‚ β”œβ”€β”€ pages/ # Page Controllers (Movies, Series, etc.) +β”‚ β”‚ └── api.js # API Client +β”‚ └── index.html # Main entry point +β”œβ”€β”€ server/ # Backend server +β”‚ β”œβ”€β”€ routes/ # API Endpoints (proxy, transcode) +β”‚ β”œβ”€β”€ services/ # Playlist parsers, SyncService, etc. +β”‚ β”œβ”€β”€ db/ # Database Logic +β”‚ β”‚ β”œβ”€β”€ index.js # Legacy DB Wrapper +β”‚ β”‚ └── sqlite.js # SQLite Connection & Schema +β”‚ └── index.js # Server Entry Point +└── data/ # Persistent storage (content.db, playlists) +``` + +## License + +This project is licensed under the **GNU General Public License v3.0 (GPL-3.0)**. + +You are free to: +- **Run** the program for any purpose +- **Study** how the program works and change it +- **Redistribute** copies +- **Distribute** copies of your modified versions + +Under the condition that: +- You typically must distinguish your modifications from the original work +- You provide the source code to recipients +- You license any derivative works under the same GPL-3.0 license + +See the [LICENSE](LICENSE) file for details. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6de58db --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + nodecast-tv: + image: ghcr.io/technomancer702/nodecast-tv:latest + build: https://github.com/technomancer702/nodecast-tv.git#main + container_name: nodecast-tv + ports: + - "3000:3000" + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 # Internal container port diff --git a/nodecast.patch b/nodecast.patch new file mode 100644 index 0000000..e2f2553 --- /dev/null +++ b/nodecast.patch @@ -0,0 +1,869 @@ +diff --git a/public/index.html b/public/index.html +index dd74211..9205ef7 100644 +--- a/public/index.html ++++ b/public/index.html +@@ -9,7 +9,7 @@ + + nodecast-tv | IPTV Player + +- ++ + + + +@@ -1095,20 +1095,20 @@ + + + +- +- +- +- +- +- +- +- +- +- +- +- +- +- ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +- +\ No newline at end of file ++ +diff --git a/public/js/api.js b/public/js/api.js +index 77b1306..ed4b354 100644 +--- a/public/js/api.js ++++ b/public/js/api.js +@@ -24,7 +24,7 @@ const API = { + options.body = JSON.stringify(data); + } + +- const response = await fetch(`/api${endpoint}`, options); ++ const response = await fetch(`api/${endpoint}`, options); + + let result; + const contentType = response.headers.get('content-type'); +@@ -39,7 +39,7 @@ const API = { + // If unauthorized, redirect to login + if (response.status === 401) { + localStorage.removeItem('authToken'); +- window.location.href = '/login.html'; ++ window.location.href = 'login.html'; + return; + } + throw new Error(result.error || `Server responded with ${response.status}`); +@@ -50,37 +50,37 @@ const API = { + + // Sources + sources: { +- getAll: () => API.request('GET', '/sources'), +- getByType: (type) => API.request('GET', `/sources/type/${type}`), +- getById: (id) => API.request('GET', `/sources/${id}`), +- create: (data) => API.request('POST', '/sources', data), +- update: (id, data) => API.request('PUT', `/sources/${id}`, data), +- delete: (id) => API.request('DELETE', `/sources/${id}`), +- toggle: (id) => API.request('POST', `/sources/${id}/toggle`), +- test: (id) => API.request('POST', `/sources/${id}/test`), +- sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync +- getStatus: () => API.request('GET', '/sources/status'), // Get all statuses +- estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size +- estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation) ++ getAll: () => API.request('GET', 'sources'), ++ getByType: (type) => API.request('GET', `sources/type/${type}`), ++ getById: (id) => API.request('GET', `sources/${id}`), ++ create: (data) => API.request('POST', 'sources', data), ++ update: (id, data) => API.request('PUT', `sources/${id}`, data), ++ delete: (id) => API.request('DELETE', `sources/${id}`), ++ toggle: (id) => API.request('POST', `sources/${id}/toggle`), ++ test: (id) => API.request('POST', `sources/${id}/test`), ++ sync: (id) => API.request('POST', `sources/${id}/sync`), // Manual sync ++ getStatus: () => API.request('GET', 'sources/status'), // Get all statuses ++ estimate: (id) => API.request('GET', `sources/${id}/estimate`), // Estimate M3U size ++ estimateByUrl: (url, type) => API.request('POST', 'sources/estimate', { url, type }), // Estimate by URL (before creation) + }, + + // Channels (hidden items) + channels: { +- getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`), +- hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }), +- show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }), +- isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`), +- bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }), +- bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }), ++ getHidden: (sourceId = null) => API.request('GET', `channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`), ++ hide: (sourceId, itemType, itemId) => API.request('POST', 'channels/hide', { sourceId, itemType, itemId }), ++ show: (sourceId, itemType, itemId) => API.request('POST', 'channels/show', { sourceId, itemType, itemId }), ++ isHidden: (sourceId, itemType, itemId) => API.request('GET', `channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`), ++ bulkHide: (items) => API.request('POST', 'channels/hide/bulk', { items }), ++ bulkShow: (items) => API.request('POST', 'channels/show/bulk', { items }), + // Fast bulk operations - single SQL statement +- showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }), +- hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType }) ++ showAll: (sourceId, contentType) => API.request('POST', 'channels/show/all', { sourceId, contentType }), ++ hideAll: (sourceId, contentType) => API.request('POST', 'channels/hide/all', { sourceId, contentType }) + }, + + // Favorites + favorites: { + getAll: (sourceId = null, itemType = null) => { +- let url = '/favorites'; ++ let url = 'favorites'; + const params = []; + if (sourceId) params.push(`sourceId=${sourceId}`); + if (itemType) params.push(`itemType=${itemType}`); +@@ -88,84 +88,84 @@ const API = { + return API.request('GET', url); + }, + add: (sourceId, itemId, itemType = 'channel') => +- API.request('POST', '/favorites', { sourceId, itemId, itemType }), ++ API.request('POST', 'favorites', { sourceId, itemId, itemType }), + remove: (sourceId, itemId, itemType = 'channel') => +- API.request('DELETE', '/favorites', { sourceId, itemId, itemType }), ++ API.request('DELETE', 'favorites', { sourceId, itemId, itemType }), + check: (sourceId, itemId, itemType = 'channel') => +- API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`) ++ API.request('GET', `favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`) + }, + + // Proxy + proxy: { + // Xtream + xtream: { +- auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`), ++ auth: (sourceId) => API.request('GET', `proxy/xtream/${sourceId}/auth`), + liveCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/live_categories${params}`); + }, + liveStreams: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/live_streams${query}`); + }, + vodCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/vod_categories${params}`); + }, + vodStreams: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/vod_streams${query}`); + }, + seriesCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/series_categories${params}`); + }, + series: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; +- return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`); ++ return API.request('GET', `proxy/xtream/${sourceId}/series${query}`); + }, + seriesInfo: (sourceId, seriesId) => +- API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`), +- shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`), ++ API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`), ++ shortEpg: (sourceId, streamId) => API.request('GET', `proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`), + getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') => +- API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`) ++ API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`) + }, + + // EPG + epg: { +- get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`), +- getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds }) ++ get: (sourceId) => API.request('GET', `proxy/epg/${sourceId}`), ++ getForChannels: (sourceId, channelIds) => API.request('POST', `proxy/epg/${sourceId}/channels`, { channelIds }) + }, + + // Cache management + cache: { +- clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`) ++ clear: (sourceId) => API.request('DELETE', `proxy/cache/${sourceId}`) + } + }, + + // Settings + settings: { +- get: () => API.request('GET', '/settings'), +- update: (data) => API.request('PUT', '/settings', data), +- reset: () => API.request('DELETE', '/settings'), +- getDefaults: () => API.request('GET', '/settings/defaults') ++ get: () => API.request('GET', 'settings'), ++ update: (data) => API.request('PUT', 'settings', data), ++ reset: () => API.request('DELETE', 'settings'), ++ getDefaults: () => API.request('GET', 'settings/defaults') + }, + + // Users (admin only) + users: { +- getAll: () => API.request('GET', '/auth/users'), +- create: (data) => API.request('POST', '/auth/users', data), +- update: (id, data) => API.request('PUT', `/auth/users/${id}`, data), +- delete: (id) => API.request('DELETE', `/auth/users/${id}`) ++ getAll: () => API.request('GET', 'auth/users'), ++ create: (data) => API.request('POST', 'auth/users', data), ++ update: (id, data) => API.request('PUT', `auth/users/${id}`, data), ++ delete: (id) => API.request('DELETE', `auth/users/${id}`) + } + }; + +diff --git a/public/js/app.js b/public/js/app.js +index c4d2f8c..f00dc38 100644 +--- a/public/js/app.js ++++ b/public/js/app.js +@@ -169,13 +169,13 @@ class App { + + if (!token) { + // No token, redirect to login (replace to avoid back button issues) +- window.location.replace('/login.html'); ++ window.location.replace('login.html'); + return; + } + + try { + // Verify token with server +- const response = await fetch('/api/auth/me', { ++ const response = await fetch('api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}` + } +@@ -201,7 +201,7 @@ class App { + } catch (err) { + console.error('Authentication error:', err); + localStorage.removeItem('authToken'); +- window.location.replace('/login.html'); ++ window.location.replace('login.html'); + } + } + +@@ -225,7 +225,7 @@ class App { + + const token = localStorage.getItem('authToken'); + if (token) { +- await fetch('/api/auth/logout', { ++ await fetch('api/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` +@@ -234,7 +234,7 @@ class App { + } + + localStorage.removeItem('authToken'); +- window.location.replace('/login.html'); ++ window.location.replace('login.html'); + }); + + navbar.appendChild(logoutLink); +@@ -283,7 +283,7 @@ document.addEventListener('DOMContentLoaded', () => { + window.app = new App(); + + // Fetch and display version badge +- fetch('/api/version') ++ fetch('api/version') + .then(res => res.json()) + .then(data => { + const badge = document.getElementById('version-badge'); +diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js +index e8da5ad..2047dbf 100644 +--- a/public/js/components/ChannelList.js ++++ b/public/js/components/ChannelList.js +@@ -33,10 +33,10 @@ class ChannelList { + * Only proxies HTTP URLs when on HTTPS page + */ + getProxiedImageUrl(url) { +- if (!url || url.length === 0) return '/img/placeholder.png'; ++ if (!url || url.length === 0) return 'img/placeholder.png'; + // Only proxy if we're on HTTPS and the image is HTTP + if (window.location.protocol === 'https:' && url.startsWith('http://')) { +- return `/api/proxy/image?url=${encodeURIComponent(url)}`; ++ return `api/proxy/image?url=${encodeURIComponent(url)}`; + } + return url; + } +@@ -510,7 +510,7 @@ class ChannelList { + data-render-id="${renderId}" + data-render-group="${renderGroup}"> + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+@@ -623,7 +623,7 @@ class ChannelList { + data-render-id="${renderId}" + data-render-group="${renderGroup}"> + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+@@ -1052,7 +1052,7 @@ class ChannelList { + + div.innerHTML = ` + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.getProgramInfo(channel) || ''}
+@@ -1305,7 +1305,7 @@ class ChannelList { +
+
+ ++ onerror="this.onerror=null;this.src='img/placeholder.png'" /> +
+

Group: ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}

+

Source: ${channel.sourceType}

+diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js +index ce4a01d..7b0da96 100644 +--- a/public/js/components/EpgGuide.js ++++ b/public/js/components/EpgGuide.js +@@ -43,10 +43,10 @@ class EpgGuide { + * Only proxies HTTP URLs when on HTTPS page + */ + getProxiedImageUrl(url) { +- if (!url || url.length === 0) return '/img/placeholder.png'; ++ if (!url || url.length === 0) return 'img/placeholder.png'; + // Only proxy if we're on HTTPS and the image is HTTP + if (window.location.protocol === 'https:' && url.startsWith('http://')) { +- return `/api/proxy/image?url=${encodeURIComponent(url)}`; ++ return `api/proxy/image?url=${encodeURIComponent(url)}`; + } + return url; + } +@@ -212,7 +212,7 @@ class EpgGuide { + // Load EPG from ALL sources in parallel + const fetchPromises = sources.map(async (source) => { + try { +- const response = await fetch(`/api/proxy/epg/${source.id}${queryParams}`); ++ const response = await fetch(`api/proxy/epg/${source.id}${queryParams}`); + if (!response.ok) throw new Error(`Status ${response.status}`); + return await response.json(); + } catch (e) { +@@ -596,7 +596,7 @@ class EpgGuide { + ${isFavorite ? Icons.favorite : Icons.favoriteOutline} + + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> + ${name} +
+
+diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js +index a089dab..ac3b71c 100644 +--- a/public/js/components/VideoPlayer.js ++++ b/public/js/components/VideoPlayer.js +@@ -805,7 +805,7 @@ class VideoPlayer { + async startTranscodeSession(url, options = {}) { + try { + console.log('[Player] Starting HLS transcode session...', options); +- const res = await fetch('/api/transcode/session', { ++ const res = await fetch('api/transcode/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, ...options }) +@@ -817,7 +817,7 @@ class VideoPlayer { + } catch (err) { + console.error('[Player] Session start failed:', err); + // Fallback to direct transcode if session fails +- return `/api/transcode?url=${encodeURIComponent(url)}`; ++ return `api/transcode?url=${encodeURIComponent(url)}`; + } + } + +@@ -829,7 +829,7 @@ class VideoPlayer { + console.log('[Player] Stopping transcode session:', this.currentSessionId); + try { + // Fire and forget cleanup +- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); ++ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); + } catch (err) { + console.error('Failed to stop session:', err); + } +@@ -865,7 +865,7 @@ class VideoPlayer { + if (this.settings.autoTranscode) { + console.log('[Player] Auto Transcode enabled. Probing stream...'); + try { +- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); ++ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`); + const info = await probeRes.json(); + console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`); + +@@ -885,7 +885,7 @@ class VideoPlayer { + track.kind = 'subtitles'; + track.label = sub.title; + track.srclang = sub.language; +- track.src = `/api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`; ++ track.src = `api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`; + this.video.appendChild(track); + }); + +@@ -925,7 +925,7 @@ class VideoPlayer { + // Raw .ts container - use remux + console.log('[Player] Auto: Using remux (.ts container)'); + this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); +- const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; ++ const remuxUrl = `api/remux?url=${encodeURIComponent(streamUrl)}`; + this.currentUrl = remuxUrl; + this.video.src = remuxUrl; + this.video.play().catch(e => { +@@ -997,7 +997,7 @@ class VideoPlayer { + // Probe to get video codec for HEVC tag handling + let videoCodec = 'unknown'; + try { +- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); ++ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`); + const info = await probeRes.json(); + videoCodec = info.video; + } catch (e) { console.warn('Probe failed for force audio, assuming h264'); } +@@ -1096,7 +1096,7 @@ class VideoPlayer { + (data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError'); + + // Don't proxy if it's already a local API URL +- const isLocalApi = this.currentUrl.startsWith('/api/'); ++ const isLocalApi = this.currentUrl.startsWith('api/'); + + if (isCorsLikely && !this.isUsingProxy && !isLocalApi) { + console.log('CORS/Network error detected, retrying via proxy...', data.details); +@@ -1344,14 +1344,14 @@ class VideoPlayer { + * Get proxied URL for a stream + */ + getProxiedUrl(url) { +- return `/api/proxy/stream?url=${encodeURIComponent(url)}`; ++ return `api/proxy/stream?url=${encodeURIComponent(url)}`; + } + + /** + * Get transcoded URL for a stream (audio transcoding for browser compatibility) + */ + getTranscodeUrl(url) { +- return `/api/transcode?url=${encodeURIComponent(url)}`; ++ return `api/transcode?url=${encodeURIComponent(url)}`; + } + + /** +@@ -1359,7 +1359,7 @@ class VideoPlayer { + * Used for raw .ts streams that browsers can't play directly + */ + getRemuxUrl(url) { +- return `/api/remux?url=${encodeURIComponent(url)}`; ++ return `api/remux?url=${encodeURIComponent(url)}`; + } + + /** +diff --git a/public/js/login.js b/public/js/login.js +index a246d10..e2339f3 100644 +--- a/public/js/login.js ++++ b/public/js/login.js +@@ -5,10 +5,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { + const password = document.getElementById('password').value; + + try { +- const response = await API.request('POST', '/auth/login', { username, password }); ++ const response = await API.request('POST', 'auth/login', { username, password }); + localStorage.setItem('sessionToken', response.token); +- window.location.href = '/'; ++ window.location.href = 'index.html'; + } catch (err) { + document.getElementById('login-error').textContent = 'Login failed: ' + err.message; + } +-}); +\ No newline at end of file ++}); +diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js +index 6ddadc1..c8a1805 100644 +--- a/public/js/pages/HomePage.js ++++ b/public/js/pages/HomePage.js +@@ -174,7 +174,7 @@ class HomePage { + await this.renderFavoriteChannels(); + + // 1. Load Watch History +- const history = await window.API.request('GET', '/history?limit=12'); ++ const history = await window.API.request('GET', 'history?limit=12'); + if (history && Array.isArray(history)) { + this.renderHistory(history); + } +@@ -197,7 +197,7 @@ class HomePage { + + try { + // Fetch favorite channels for current user +- const favorites = await window.API.request('GET', '/favorites?itemType=channel'); ++ const favorites = await window.API.request('GET', 'favorites?itemType=channel'); + + if (!favorites || favorites.length === 0) { + list.innerHTML = '
Add channels to favorites from Live TV
'; +@@ -251,14 +251,14 @@ class HomePage { + } + + createChannelTile(channel) { +- const logo = channel.tvgLogo || '/img/placeholder.png'; +- const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo; ++ const logo = channel.tvgLogo || 'img/placeholder.png'; ++ const logoUrl = logo.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(logo)}` : logo; + const name = channel.name || 'Unknown'; + + return ` +
+ +
${name}
+
+@@ -352,7 +352,7 @@ class HomePage { + if (!list) return; + + try { +- const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12'); ++ const movies = await window.API.request('GET', 'channels/recent?type=movie&limit=12'); + if (!movies || movies.length === 0) { + list.innerHTML = '
No recently added movies found
'; + return; +@@ -381,7 +381,7 @@ class HomePage { + if (!list) return; + + try { +- const series = await window.API.request('GET', '/channels/recent?type=series&limit=12'); ++ const series = await window.API.request('GET', 'channels/recent?type=series&limit=12'); + if (!series || series.length === 0) { + list.innerHTML = '
No recently added series found
'; + return; +@@ -411,13 +411,13 @@ class HomePage { + const percent = Math.min(100, Math.round((progress / duration) * 100)); + + // Proxy the poster if it's an external URL +- const poster = data.poster || '/img/poster-placeholder.jpg'; +- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster; ++ const poster = data.poster || 'img/poster-placeholder.jpg'; ++ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster; + + return ` +
+
+- ${data.title || item.name} ++ ${data.title || item.name} +
+
+
+@@ -436,13 +436,13 @@ class HomePage { + createRecentCard(item) { + const { data, item_id } = item; + const type = item.type || item.item_type; +- const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg'; +- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster; ++ const poster = item.stream_icon || data.poster || 'img/poster-placeholder.jpg'; ++ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster; + + return ` +
+
+- ${item.name} ++ ${item.name} +
+ +
+@@ -465,7 +465,7 @@ class HomePage { + const streamId = item.item_id; + const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4'; + +- const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`); ++ const result = await window.API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`); + + if (result && result.url) { + const content = { +@@ -488,7 +488,7 @@ class HomePage { + // Fetch seriesInfo if we have a seriesId + if (content.seriesId && sourceId) { + try { +- const seriesInfo = await window.API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`); ++ const seriesInfo = await window.API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`); + if (seriesInfo) { + content.seriesInfo = seriesInfo; + } +diff --git a/public/js/pages/MoviesPage.js b/public/js/pages/MoviesPage.js +index a5bb40f..c27fba6 100644 +--- a/public/js/pages/MoviesPage.js ++++ b/public/js/pages/MoviesPage.js +@@ -278,7 +278,7 @@ class MoviesPage { + card.dataset.movieId = movie.stream_id; + card.dataset.sourceId = movie.sourceId; + +- const poster = movie.stream_icon || movie.cover || '/img/placeholder.png'; ++ const poster = movie.stream_icon || movie.cover || 'img/placeholder.png'; + const year = movie.year || movie.releaseDate?.substring(0, 4) || ''; + const rating = movie.rating ? `${Icons.star} ${movie.rating}` : ''; + +@@ -287,7 +287,7 @@ class MoviesPage { + card.innerHTML = ` +
+ ${movie.name} ++ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy"> +
+ ${Icons.play} +
+diff --git a/public/js/pages/SeriesPage.js b/public/js/pages/SeriesPage.js +index 6b7a929..1a25f85 100644 +--- a/public/js/pages/SeriesPage.js ++++ b/public/js/pages/SeriesPage.js +@@ -288,7 +288,7 @@ class SeriesPage { + card.dataset.seriesId = series.series_id; + card.dataset.sourceId = series.sourceId; + +- const poster = series.cover || '/img/placeholder.png'; ++ const poster = series.cover || 'img/placeholder.png'; + const year = series.year || series.releaseDate?.substring(0, 4) || ''; + const rating = series.rating ? `${Icons.star} ${series.rating}` : ''; + +@@ -297,7 +297,7 @@ class SeriesPage { + card.innerHTML = ` +
+ ${series.name} ++ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy"> +
+ ${Icons.play} +
+@@ -350,7 +350,7 @@ class SeriesPage { + this.detailsPanel.classList.remove('hidden'); + + // Set header info +- document.getElementById('series-poster').src = series.cover || '/img/placeholder.png'; ++ document.getElementById('series-poster').src = series.cover || 'img/placeholder.png'; + document.getElementById('series-title').textContent = series.name; + document.getElementById('series-plot').textContent = series.plot || ''; + +diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js +index a47b5d7..437d7a5 100644 +--- a/public/js/pages/Settings.js ++++ b/public/js/pages/Settings.js +@@ -259,7 +259,7 @@ class SettingsPage { + if (!container) return; + + try { +- const response = await fetch('/api/settings/hw-info'); ++ const response = await fetch('api/settings/hw-info'); + if (!response.ok) throw new Error('Failed to fetch hardware info'); + const hwInfo = await response.json(); + +@@ -603,7 +603,7 @@ class SettingsPage { + + try { + // Fetch last sync time from server +- const response = await fetch('/api/settings/sync-status'); ++ const response = await fetch('api/settings/sync-status'); + if (!response.ok) throw new Error('Failed to fetch sync status'); + const data = await response.json(); + +diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js +index b85ca63..2aecdfd 100644 +--- a/public/js/pages/WatchPage.js ++++ b/public/js/pages/WatchPage.js +@@ -329,7 +329,7 @@ class WatchPage { + async startTranscodeSession(url, options = {}) { + try { + console.log('[WatchPage] Starting HLS transcode session...', options); +- const res = await fetch('/api/transcode/session', { ++ const res = await fetch('api/transcode/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ +@@ -345,7 +345,7 @@ class WatchPage { + } catch (err) { + console.error('[WatchPage] Session start failed:', err); + // Fallback to direct transcode if session fails +- return `/api/transcode?url=${encodeURIComponent(url)}`; ++ return `api/transcode?url=${encodeURIComponent(url)}`; + } + } + +@@ -357,7 +357,7 @@ class WatchPage { + console.log('[WatchPage] Stopping transcode session:', this.currentSessionId); + try { + // Fire and forget cleanup +- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); ++ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); + } catch (err) { + console.error('Failed to stop session:', err); + } +@@ -437,7 +437,7 @@ class WatchPage { + console.log('[WatchPage] Auto Transcode enabled. Probing stream...'); + try { + const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; +- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); ++ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); + const info = await probeRes.json(); + console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`); + +@@ -470,7 +470,7 @@ class WatchPage { + // TODO: Move remux to session logic if seeking is needed for TS files + console.log('[WatchPage] Auto: Using remux (.ts container)'); + this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); +- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`; ++ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`; + this.video.src = finalUrl; + this.video.play().catch(e => { + if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); +@@ -509,7 +509,7 @@ class WatchPage { + let videoCodec = 'unknown'; + try { + const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; +- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); ++ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); + const info = await probeRes.json(); + videoCodec = info.video; + } catch (e) { console.warn('Probe failed for force audio, assuming h264'); } +@@ -528,7 +528,7 @@ class WatchPage { + if (settings.forceRemux && isRawTs) { + console.log('[WatchPage] Force Remux enabled'); + this.updateTranscodeStatus('remuxing', 'Remux (Force)'); +- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`; ++ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`; + this.video.src = finalUrl; + this.video.play().catch(e => { + if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); +@@ -540,7 +540,7 @@ class WatchPage { + // Determine if proxy is needed + const proxyRequiredDomains = ['pluto.tv']; + const needsProxy = settings.forceProxy || proxyRequiredDomains.some(domain => url.includes(domain)); +- const finalUrl = needsProxy ? `/api/proxy/stream?url=${encodeURIComponent(url)}` : url; ++ const finalUrl = needsProxy ? `api/proxy/stream?url=${encodeURIComponent(url)}` : url; + + console.log('[WatchPage] Playing:', { url, needsProxy, looksLikeHls }); + +@@ -600,9 +600,9 @@ class WatchPage { + console.error('[WatchPage] HLS fatal error:', data); + // Try proxy on CORS error (only if not already proxied/transcoded) + // Note: Transcoded streams are local, so no CORS issues usually +- if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) { ++ if (!url.startsWith('api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) { + console.log('[WatchPage] Retrying via proxy...'); +- this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`); ++ this.playHls(`api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`); + } else { + this.hls.destroy(); + } +@@ -1030,7 +1030,7 @@ class WatchPage { + if (!this.content) return; + + const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown +- const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg'; ++ const fallback = isChannel ? 'img/placeholder.png' : 'img/poster-placeholder.jpg'; + + this.posterEl.onerror = () => { + this.posterEl.onerror = null; +@@ -1130,9 +1130,9 @@ class WatchPage { + + this.recommendedGrid.innerHTML = movies.map(movie => ` + + `).join(''); +@@ -1417,7 +1417,7 @@ class WatchPage { + currentEpisode: this.currentEpisode || null + }; + +- await window.API.request('POST', '/history', { ++ await window.API.request('POST', 'history', { + id: this.content.id, + type: this.content.type === 'movie' ? 'movie' : 'episode', + sourceId: this.content.sourceId, +diff --git a/public/login.html b/public/login.html +index 454acfe..34bcb46 100644 +--- a/public/login.html ++++ b/public/login.html +@@ -5,7 +5,7 @@ + + + Login - NodeCast TV +- ++ + + + + + + + + + + + + diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..2834e18 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,276 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const passport = require('passport'); +const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { Strategy: LocalStrategy } = require('passport-local'); + +/** + * Authentication and Authorization Module + * Handles user authentication, session management, and role-based access control + * Using Passport.js with JWT tokens + */ + +// JWT Secret - In production, use environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production'; +const JWT_EXPIRY = '24h'; + +/** + * Hash password using bcrypt + */ +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +/** + * Verify password against hash + */ +async function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); +} + +/** + * Generate JWT token + */ +function generateToken(user) { + return jwt.sign( + { + id: user.id, + username: user.username, + role: user.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRY } + ); +} + +/** + * Verify JWT token + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (err) { + return null; + } +} + +/** + * Configure Passport Local Strategy for username/password authentication + */ +function configureLocalStrategy(getUserByUsername, verifyUserPassword) { + passport.use(new LocalStrategy( + async (username, password, done) => { + try { + const user = await getUserByUsername(username); + + if (!user) { + return done(null, false, { message: 'Invalid credentials' }); + } + + const isValid = await verifyUserPassword(password, user.passwordHash); + + if (!isValid) { + return done(null, false, { message: 'Invalid credentials' }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + } + )); +} + +/** + * Configure Passport JWT Strategy for token-based authentication + */ +function configureJwtStrategy(getUserById) { + const options = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: JWT_SECRET + }; + + passport.use(new JwtStrategy(options, async (payload, done) => { + try { + const user = await getUserById(payload.id); + + if (!user) { + return done(null, false); + } + + return done(null, { + id: Number(user.id), + username: user.username, + role: user.role + }); + } catch (err) { + return done(err, false); + } + })); +} + +/** + * Configure Passport session serialization + * Required for OIDC flow which uses sessions + */ +function configureSessionSerialization(getUserById) { + passport.serializeUser((user, done) => { + done(null, user.id); + }); + + passport.deserializeUser(async (id, done) => { + try { + const user = await getUserById(id); + done(null, user); + } catch (err) { + done(err, null); + } + }); +} + +/** + * Configure Passport OpenID Connect Strategy + */ +function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) { + if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) { + console.warn('OIDC configuration missing - SSO disabled'); + return; + } + + const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect'); + + passport.use(new OpenIDConnectStrategy({ + issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock + authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`, + tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`, + userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`, + clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id', + clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret', + callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback', + scope: ['openid', 'profile', 'email'] + }, + async (...args) => { + // The done callback is always the last argument + const done = args[args.length - 1]; + + // Map known arguments + // Standard: issuer, sub, profile, accessToken, refreshToken, done + // Some versions: issuer, sub, profile, accessToken, refreshToken, params, done + + let issuer, sub, profile; + + if (args.length === 3) { + // Scenario: (issuer, profile, done) + const arg0 = args[0]; + const arg1 = args[1]; + + if (typeof arg1 === 'object' && arg1.id) { + issuer = arg0; + profile = arg1; + sub = profile.id; + } else if (typeof arg0 === 'string' && typeof arg1 === 'string') { + issuer = arg0; + sub = arg1; + profile = { id: sub, displayName: 'Unknown' }; + } + } else if (args.length >= 4) { + // Assume standard: iss, sub, profile... + issuer = args[0]; + sub = args[1]; + profile = args[2]; + } + + if (!sub && profile && profile.id) sub = profile.id; + + if (!sub) { + return done(new Error('Could not identify OIDC Subject (sub) from arguments')); + } + + try { + // 1. Try to find by OIDC ID (sub) + let user = await findUserByOidcId(sub); + + // 2. If not found, try to match by email + // Extract email - handle both profile.emails[] (Google) and profile.email (others) + const email = profile.emails?.[0]?.value || profile.email || profile._json?.email; + + if (!user && email) { + user = await findUserByEmail(email); + + // If found by email but no OIDC ID, link them + if (user && !user.oidcId) { + // We don't have a direct update method for specific fields without full user object in this context + // Ideally we'd update the user here. For now, we'll just log in. + // Future: Update user with oidcId + } + } + + // 3. If still not found, create new user (JIT Provisioning) + if (!user) { + const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`); + + user = await createUser({ + username: username, + role: 'viewer', // Default role for SSO users + oidcId: sub, + email: email || null + }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + })); +} + +/** + * Middleware: Require authentication using Passport JWT + */ +const requireAuth = passport.authenticate('jwt', { session: false }); + +/** + * True if user has admin role (case-insensitive, tolerates stray whitespace). + */ +function isAdminRole(user) { + return !!(user && String(user.role || '').trim().toLowerCase() === 'admin'); +} + +/** + * Middleware: Require admin role + */ +function requireAdmin(req, res, next) { + if (!isAdminRole(req.user)) { + return res.status(403).json({ error: 'Forbidden - Admin access required' }); + } + next(); +} + +/** + * Middleware: Check for specific role + */ +function requireRole(role) { + return (req, res, next) => { + if (!req.user || req.user.role !== role) { + return res.status(403).json({ error: `Forbidden - ${role} access required` }); + } + next(); + }; +} + +module.exports = { + passport, + hashPassword, + verifyPassword, + generateToken, + verifyToken, + configureLocalStrategy, + configureJwtStrategy, + configureSessionSerialization, + configureOidcStrategy, + requireAuth, + requireAdmin, + requireRole, + isAdminRole +}; diff --git a/server/auth.js.bak.202605021955 b/server/auth.js.bak.202605021955 new file mode 100644 index 0000000..ac76206 --- /dev/null +++ b/server/auth.js.bak.202605021955 @@ -0,0 +1,268 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const passport = require('passport'); +const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { Strategy: LocalStrategy } = require('passport-local'); + +/** + * Authentication and Authorization Module + * Handles user authentication, session management, and role-based access control + * Using Passport.js with JWT tokens + */ + +// JWT Secret - In production, use environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production'; +const JWT_EXPIRY = '24h'; + +/** + * Hash password using bcrypt + */ +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +/** + * Verify password against hash + */ +async function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); +} + +/** + * Generate JWT token + */ +function generateToken(user) { + return jwt.sign( + { + id: user.id, + username: user.username, + role: user.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRY } + ); +} + +/** + * Verify JWT token + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (err) { + return null; + } +} + +/** + * Configure Passport Local Strategy for username/password authentication + */ +function configureLocalStrategy(getUserByUsername, verifyUserPassword) { + passport.use(new LocalStrategy( + async (username, password, done) => { + try { + const user = await getUserByUsername(username); + + if (!user) { + return done(null, false, { message: 'Invalid credentials' }); + } + + const isValid = await verifyUserPassword(password, user.passwordHash); + + if (!isValid) { + return done(null, false, { message: 'Invalid credentials' }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + } + )); +} + +/** + * Configure Passport JWT Strategy for token-based authentication + */ +function configureJwtStrategy(getUserById) { + const options = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: JWT_SECRET + }; + + passport.use(new JwtStrategy(options, async (payload, done) => { + try { + const user = await getUserById(payload.id); + + if (!user) { + return done(null, false); + } + + return done(null, { + id: user.id, + username: user.username, + role: user.role + }); + } catch (err) { + return done(err, false); + } + })); +} + +/** + * Configure Passport session serialization + * Required for OIDC flow which uses sessions + */ +function configureSessionSerialization(getUserById) { + passport.serializeUser((user, done) => { + done(null, user.id); + }); + + passport.deserializeUser(async (id, done) => { + try { + const user = await getUserById(id); + done(null, user); + } catch (err) { + done(err, null); + } + }); +} + +/** + * Configure Passport OpenID Connect Strategy + */ +function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) { + if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) { + console.warn('OIDC configuration missing - SSO disabled'); + return; + } + + const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect'); + + passport.use(new OpenIDConnectStrategy({ + issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock + authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`, + tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`, + userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`, + clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id', + clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret', + callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback', + scope: ['openid', 'profile', 'email'] + }, + async (...args) => { + // The done callback is always the last argument + const done = args[args.length - 1]; + + // Map known arguments + // Standard: issuer, sub, profile, accessToken, refreshToken, done + // Some versions: issuer, sub, profile, accessToken, refreshToken, params, done + + let issuer, sub, profile; + + if (args.length === 3) { + // Scenario: (issuer, profile, done) + const arg0 = args[0]; + const arg1 = args[1]; + + if (typeof arg1 === 'object' && arg1.id) { + issuer = arg0; + profile = arg1; + sub = profile.id; + } else if (typeof arg0 === 'string' && typeof arg1 === 'string') { + issuer = arg0; + sub = arg1; + profile = { id: sub, displayName: 'Unknown' }; + } + } else if (args.length >= 4) { + // Assume standard: iss, sub, profile... + issuer = args[0]; + sub = args[1]; + profile = args[2]; + } + + if (!sub && profile && profile.id) sub = profile.id; + + if (!sub) { + return done(new Error('Could not identify OIDC Subject (sub) from arguments')); + } + + try { + // 1. Try to find by OIDC ID (sub) + let user = await findUserByOidcId(sub); + + // 2. If not found, try to match by email + // Extract email - handle both profile.emails[] (Google) and profile.email (others) + const email = profile.emails?.[0]?.value || profile.email || profile._json?.email; + + if (!user && email) { + user = await findUserByEmail(email); + + // If found by email but no OIDC ID, link them + if (user && !user.oidcId) { + // We don't have a direct update method for specific fields without full user object in this context + // Ideally we'd update the user here. For now, we'll just log in. + // Future: Update user with oidcId + } + } + + // 3. If still not found, create new user (JIT Provisioning) + if (!user) { + const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`); + + user = await createUser({ + username: username, + role: 'viewer', // Default role for SSO users + oidcId: sub, + email: email || null + }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + })); +} + +/** + * Middleware: Require authentication using Passport JWT + */ +const requireAuth = passport.authenticate('jwt', { session: false }); + +/** + * Middleware: Require admin role + */ +function requireAdmin(req, res, next) { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden - Admin access required' }); + } + next(); +} + +/** + * Middleware: Check for specific role + */ +function requireRole(role) { + return (req, res, next) => { + if (!req.user || req.user.role !== role) { + return res.status(403).json({ error: `Forbidden - ${role} access required` }); + } + next(); + }; +} + +module.exports = { + passport, + hashPassword, + verifyPassword, + generateToken, + verifyToken, + configureLocalStrategy, + configureJwtStrategy, + configureSessionSerialization, + configureOidcStrategy, + requireAuth, + requireAdmin, + requireRole +}; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..2a20e27 --- /dev/null +++ b/server/db.js @@ -0,0 +1,469 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { existsSync, mkdirSync } = require('fs'); + +// Ensure data directory exists (sync is fine for startup) +const dataDir = path.join(__dirname, '..', 'data'); +if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); +} + +const dbPath = path.join(dataDir, 'db.json'); + +// Initialize database structure +async function loadDb() { + try { + // Check if file exists (using fs.access is better for async, but we can catch ENOENT) + try { + const fileContent = await fs.readFile(dbPath, 'utf-8'); + const data = JSON.parse(fileContent); + return { + sources: data.sources || [], + hiddenItems: data.hiddenItems || [], + favorites: data.favorites || [], + settings: data.settings || getDefaultSettings(), + users: data.users || [], + nextId: data.nextId || 1 + }; + } catch (error) { + if (error.code === 'ENOENT') { + // File doesn't exist, return default + return { + sources: [], + hiddenItems: [], + favorites: [], + settings: getDefaultSettings(), + users: [], + nextId: 1 + }; + } + throw error; + } + } catch (err) { + console.error('Error loading database:', err); + // Return safe default on error to prevent crashing, but log it + return { + sources: [], + hiddenItems: [], + favorites: [], + settings: getDefaultSettings(), + users: [], + nextId: 1 + }; + } +} + +// Default settings +function getDefaultSettings() { + return { + arrowKeysChangeChannel: true, + overlayDuration: 5, + defaultVolume: 80, + rememberVolume: true, + lastVolume: 80, + autoPlayNextEpisode: false, + forceProxy: false, + forceTranscode: false, // Force Audio Transcode + forceVideoTranscode: false, // Force Video Transcode + forceRemux: false, + autoTranscode: true, + // Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensiΓ³n sea .m3u8; Hls.js falla. Usar ts. + streamFormat: 'ts', + epgRefreshInterval: '24', + // User-Agent settings + userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom + userAgentCustom: '', // Custom UA string when preset is 'custom' + // Transcoding settings + hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software + maxResolution: '1080p', // 4k | 1080p | 720p | 480p + quality: 'medium', // high | medium | low + audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough + // Probe cache settings + probeCacheTTL: 300, // 5 minutes for URL probe cache + seriesProbeCacheDays: 7, // 7 days for series episode probe cache + // Upscaling settings + upscaleEnabled: false, + upscaleMethod: 'hardware', // hardware | software + upscaleTarget: '1080p' // 1080p | 4k | 720p + }; +} + +// User-Agent presets +const USER_AGENT_PRESETS = { + chrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + vlc: 'VLC/3.0.20 LibVLC/3.0.20', + tivimate: 'TiviMate/4.7.0', +}; + +function getUserAgent(settings) { + if (settings.userAgentPreset === 'custom' && settings.userAgentCustom) { + return settings.userAgentCustom; + } + return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome; +} + +// Write lock to prevent concurrent writes from corrupting db.json +let writeQueue = Promise.resolve(); +const tmpPath = dbPath + '.tmp'; + +async function saveDb(data) { + // Queue this write operation - each write waits for the previous one + writeQueue = writeQueue.then(async () => { + try { + const jsonString = JSON.stringify(data, null, 2); + // Atomic write: write to temp file, then rename + // Rename is atomic on most filesystems, preventing corruption on crash + await fs.writeFile(tmpPath, jsonString); + await fs.rename(tmpPath, dbPath); + } catch (err) { + console.error('Error writing database:', err); + // Clean up temp file if it exists + try { await fs.unlink(tmpPath); } catch { /* ignore */ } + throw err; + } + }).catch(err => { + console.error('Database write failed:', err); + }); + + return writeQueue; +} + +// Source CRUD operations +const sources = { + async getAll() { + const db = await loadDb(); + return db.sources; + }, + + async getById(id) { + const db = await loadDb(); + return db.sources.find(s => s.id === parseInt(id)); + }, + + async getByType(type) { + const db = await loadDb(); + return db.sources.filter(s => s.type === type && s.enabled); + }, + + async create(source) { + const db = await loadDb(); + const newSource = { + id: db.nextId++, + ...source, + enabled: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + db.sources.push(newSource); + await saveDb(db); + return newSource; + }, + + async update(id, updates) { + const db = await loadDb(); + const index = db.sources.findIndex(s => s.id === parseInt(id)); + if (index === -1) return null; + + db.sources[index] = { + ...db.sources[index], + ...updates, + updated_at: new Date().toISOString() + }; + await saveDb(db); + return db.sources[index]; + }, + + async delete(id) { + const db = await loadDb(); + db.sources = db.sources.filter(s => s.id !== parseInt(id)); + // Also delete related hidden items and favorites + db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); + db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id)); + await saveDb(db); + }, + + async toggleEnabled(id) { + const db = await loadDb(); + const source = db.sources.find(s => s.id === parseInt(id)); + if (source) { + source.enabled = !source.enabled; + source.updated_at = new Date().toISOString(); + await saveDb(db); + } + return source; + } +}; + +// Hidden items operations +const hiddenItems = { + async getAll(sourceId = null) { + const db = await loadDb(); + if (sourceId) { + return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId)); + } + return db.hiddenItems; + }, + + async hide(sourceId, itemType, itemId) { + const db = await loadDb(); + // Check if already hidden + const exists = db.hiddenItems.find( + h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId + ); + if (!exists) { + db.hiddenItems.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_type: itemType, + item_id: itemId + }); + await saveDb(db); + } + }, + + async show(sourceId, itemType, itemId) { + const db = await loadDb(); + db.hiddenItems = db.hiddenItems.filter( + h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId) + ); + await saveDb(db); + }, + + async isHidden(sourceId, itemType, itemId) { + const db = await loadDb(); + return db.hiddenItems.some( + h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId + ); + }, + + async bulkHide(items) { + const db = await loadDb(); + let modified = false; + + items.forEach(item => { + const { sourceId, itemType, itemId } = item; + const exists = db.hiddenItems.find( + h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId + ); + + if (!exists) { + db.hiddenItems.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_type: itemType, + item_id: itemId + }); + modified = true; + } + }); + + if (modified) { + await saveDb(db); + } + return true; + }, + + async bulkShow(items) { + const db = await loadDb(); + const initialLength = db.hiddenItems.length; + + // Create a set of "signatures" for O(1) lookup of items to remove + const toRemove = new Set(items.map(i => `${i.sourceId}:${i.itemType}:${i.itemId}`)); + + db.hiddenItems = db.hiddenItems.filter(h => + !toRemove.has(`${h.source_id}:${h.item_type}:${h.item_id}`) + ); + + if (db.hiddenItems.length !== initialLength) { + await saveDb(db); + } + return true; + } +}; + +// Favorites operations +const favorites = { + async getAll(sourceId = null, itemType = null) { + const db = await loadDb(); + let results = db.favorites; + if (sourceId) { + results = results.filter(f => f.source_id === parseInt(sourceId)); + } + if (itemType) { + results = results.filter(f => f.item_type === itemType); + } + return results; + }, + + async add(sourceId, itemId, itemType = 'channel') { + const db = await loadDb(); + // Check if already favorited + const exists = db.favorites.find( + f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType + ); + if (!exists) { + db.favorites.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_id: String(itemId), + item_type: itemType, // 'channel', 'movie', 'series' + created_at: new Date().toISOString() + }); + await saveDb(db); + } + return true; + }, + + async remove(sourceId, itemId, itemType = 'channel') { + const db = await loadDb(); + db.favorites = db.favorites.filter( + f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType) + ); + await saveDb(db); + return true; + }, + + async isFavorite(sourceId, itemId, itemType = 'channel') { + const db = await loadDb(); + return db.favorites.some( + f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType + ); + } +}; + +// Settings operations +const settings = { + async get() { + const db = await loadDb(); + return { ...getDefaultSettings(), ...db.settings }; + }, + + async update(newSettings) { + const db = await loadDb(); + db.settings = { ...db.settings, ...newSettings }; + await saveDb(db); + return db.settings; + }, + + async reset() { + const db = await loadDb(); + db.settings = getDefaultSettings(); + await saveDb(db); + return db.settings; + } +}; + +// User operations +const users = { + async getAll() { + const db = await loadDb(); + return db.users || []; + }, + + async getById(id) { + const db = await loadDb(); + return db.users?.find(u => u.id === parseInt(id)); + }, + + async getByUsername(username) { + const db = await loadDb(); + return db.users?.find(u => u.username === username); + }, + + async getByOidcId(oidcId) { + const db = await loadDb(); + return db.users?.find(u => u.oidcId === oidcId); + }, + + async getByEmail(email) { + const db = await loadDb(); + return db.users?.find(u => u.email === email); + }, + + async create(userData) { + const db = await loadDb(); + if (!db.users) { + db.users = []; + } + + // Check if username already exists + if (db.users.some(u => u.username === userData.username)) { + throw new Error('Username already exists'); + } + + const newUser = { + id: db.nextId++, + username: userData.username, + // For OIDC users, passwordHash is optional + passwordHash: userData.passwordHash || null, + role: userData.role || 'viewer', + oidcId: userData.oidcId || null, + email: userData.email || null, + createdAt: new Date().toISOString() + }; + + db.users.push(newUser); + await saveDb(db); + + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = newUser; + return userWithoutPassword; + }, + + async update(id, updates) { + const db = await loadDb(); + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); + } + + // Check if username is being changed and if it already exists + if (updates.username && updates.username !== db.users[userIndex].username) { + if (db.users.some(u => u.username === updates.username)) { + throw new Error('Username already exists'); + } + } + + db.users[userIndex] = { + ...db.users[userIndex], + ...updates, + updatedAt: new Date().toISOString() + }; + + await saveDb(db); + + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = db.users[userIndex]; + return userWithoutPassword; + }, + + async delete(id) { + const db = await loadDb(); + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); + } + + // Prevent deleting the last admin + const user = db.users[userIndex]; + if (user.role === 'admin') { + const adminCount = db.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + throw new Error('Cannot delete the last admin user'); + } + } + + db.users.splice(userIndex, 1); + await saveDb(db); + return true; + }, + + async count() { + const db = await loadDb(); + return db.users?.length || 0; + } +}; + +module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS }; diff --git a/server/db/sqlite.js b/server/db/sqlite.js new file mode 100644 index 0000000..c3a76e4 --- /dev/null +++ b/server/db/sqlite.js @@ -0,0 +1,216 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const dataDir = path.join(__dirname, '..', '..', 'data'); +const dbPath = path.join(dataDir, 'content.db'); + +// Ensure data directory exists +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +let db; + +function getDb() { + if (!db) { + console.log('[SQLite] Opening database at', dbPath); + db = new Database(dbPath); + // Optimize performance + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + initSchema(); + } + return db; +} + +function initSchema() { + if (!db) throw new Error('Database not initialized'); + + // Categories (Groups) + db.exec(` + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, -- Composite key: sourceId:categoryId + source_id INTEGER NOT NULL, + category_id TEXT NOT NULL, + type TEXT NOT NULL, -- 'live', 'movie', 'series' + name TEXT NOT NULL, + parent_id TEXT, -- For nested categories + is_hidden INTEGER DEFAULT 0, + data JSON -- Extra provider data + ); + CREATE INDEX IF NOT EXISTS idx_categories_source_type ON categories(source_id, type); + `); + + // Playlist Items (Channels, Movies, Series, Episodes) + db.exec(` + CREATE TABLE IF NOT EXISTS playlist_items ( + id TEXT PRIMARY KEY, -- Composite key: sourceId:itemId + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, -- Original ID from provider + type TEXT NOT NULL, -- 'live', 'movie', 'series', 'episode' + name TEXT NOT NULL, + category_id TEXT, -- maps to categories.category_id (not our composite id) + parent_id TEXT, -- For episodes -> series_id + + -- Common Media Fields + stream_icon TEXT, + stream_url TEXT, -- Direct link if available + container_extension TEXT, + + -- VOD/Series Specific + rating REAL, + year TEXT, + added_at TEXT, + + -- App State + is_hidden INTEGER DEFAULT 0, + is_favorite INTEGER DEFAULT 0, + + data JSON -- Full original JSON object + ); + CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type); + CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); + `); + + // EPG Programs + // Optimized for range queries + db.exec(` + CREATE TABLE IF NOT EXISTS epg_programs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, -- matches playlist_items.id if possible, or mapping key + source_id INTEGER NOT NULL, + start_time INTEGER NOT NULL, -- Unix timestamp (ms) + end_time INTEGER NOT NULL, -- Unix timestamp (ms) + title TEXT, + description TEXT, + data JSON + ); + CREATE INDEX IF NOT EXISTS idx_epg_channel_time ON epg_programs(channel_id, start_time, end_time); + CREATE INDEX IF NOT EXISTS idx_epg_cleanup ON epg_programs(end_time); -- For deleting old programs + `); + + // Sync Status + db.exec(` + CREATE TABLE IF NOT EXISTS sync_status ( + source_id INTEGER NOT NULL, + type TEXT NOT NULL, -- 'live', 'vod', 'series', 'epg' + last_sync INTEGER NOT NULL, + status TEXT, -- 'success', 'error', 'syncing' + error TEXT, + PRIMARY KEY (source_id, type) + ); + `); + + // User Favorites (per-user) + db.exec(` + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, + item_type TEXT NOT NULL, -- 'channel', 'movie', 'series' + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_id, item_id, item_type) + ); + CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id); + CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type); + `); + + // Watch History (per-user) + db.exec(` + CREATE TABLE IF NOT EXISTS watch_history ( + id TEXT PRIMARY KEY, -- Composite key: user_id:item_id + user_id INTEGER NOT NULL, + source_id INTEGER, -- Source ID for Xtream/M3U + item_type TEXT NOT NULL, -- 'movie', 'episode' + item_id TEXT NOT NULL, -- The original item ID (stream_id or composite) + parent_id TEXT, -- For episodes (series ID) + progress INTEGER DEFAULT 0, -- Current position in seconds + duration INTEGER DEFAULT 0, -- Total duration in seconds + updated_at INTEGER NOT NULL, -- Timestamp + data JSON -- Snapshot of item data (title, poster, etc) + ); + CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id); + `); + + // Migration: Add source_id column if missing (for existing databases) + try { + db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`); + console.log('[SQLite] Added source_id column to watch_history'); + } catch (e) { + // Column already exists, ignore + } + + console.log('[SQLite] Schema initialized'); +} + +// ============================================================ +// Favorites CRUD Operations +// ============================================================ +const favorites = { + getAll(userId, sourceId = null, itemType = null) { + const db = getDb(); + let sql = 'SELECT * FROM favorites WHERE user_id = ?'; + const params = [userId]; + + if (sourceId) { + sql += ' AND source_id = ?'; + params.push(sourceId); + } + if (itemType) { + sql += ' AND item_type = ?'; + params.push(itemType); + } + + sql += ' ORDER BY created_at DESC'; + return db.prepare(sql).all(...params); + }, + + add(userId, sourceId, itemId, itemType = 'channel') { + const db = getDb(); + const stmt = db.prepare(` + INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type) + VALUES (?, ?, ?, ?) + `); + const result = stmt.run(userId, sourceId, itemId, itemType); + return result.changes > 0; + }, + + remove(userId, sourceId, itemId, itemType = 'channel') { + const db = getDb(); + const stmt = db.prepare(` + DELETE FROM favorites + WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ? + `); + const result = stmt.run(userId, sourceId, itemId, itemType); + return result.changes > 0; + }, + + isFavorite(userId, sourceId, itemId, itemType = 'channel') { + const db = getDb(); + const row = db.prepare(` + SELECT 1 FROM favorites + WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ? + `).get(userId, sourceId, itemId, itemType); + return !!row; + }, + + // Get all favorites for a user, grouped by type (for bulk checks) + getAllAsSet(userId) { + const db = getDb(); + const rows = db.prepare('SELECT source_id, item_id, item_type FROM favorites WHERE user_id = ?').all(userId); + const set = new Set(); + for (const row of rows) { + set.add(`${row.source_id}:${row.item_id}:${row.item_type}`); + } + return set; + } +}; + +module.exports = { + getDb, + initSchema, + favorites +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..097c781 --- /dev/null +++ b/server/index.js @@ -0,0 +1,230 @@ +const express = require('express'); +require('dotenv').config(); +const path = require('path'); +const passport = require('passport'); +const syncService = require('./services/syncService'); + +// Initialize database +require('./db'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.) +// Required for correct protocol detection behind reverse proxies (nginx, Caddy, etc.) +app.set('trust proxy', true); + +// Middleware +app.use(express.json({ limit: '50mb' })); + +// Initialize Passport +const session = require('express-session'); +app.use(session({ + secret: process.env.JWT_SECRET || 'keyboard cat', + resave: false, + saveUninitialized: true +})); +app.use(passport.initialize()); +app.use(passport.session()); + +// Serve static files with no-cache for JS/CSS so browser always loads the latest version +app.use(express.static(path.join(__dirname, '..', 'public'), { + setHeaders(res, filePath) { + if (filePath.endsWith('.js') || filePath.endsWith('.css')) { + res.setHeader('Cache-Control', 'no-cache, must-revalidate'); + } + } +})); + +// FFMPEG Configuration (optional - for transcoding support) +// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package +const { execSync } = require('child_process'); + +function findFFmpeg() { + // Try system FFmpeg first (better Docker compatibility) + try { + execSync('ffmpeg -version', { stdio: 'ignore' }); + console.log('FFmpeg binary configured at: ffmpeg (system)'); + return 'ffmpeg'; + } catch (e) { + // System FFmpeg not found, try ffmpeg-static + } + + // Try ffmpeg-static npm package + try { + let ffmpegPath = require('ffmpeg-static'); + // In packaged Electron apps, ffmpeg-static returns path inside .asar archive + // but the binary is actually unpacked to app.asar.unpacked + if (ffmpegPath && ffmpegPath.includes('app.asar')) { + ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked'); + } + console.log('FFmpeg binary configured at:', ffmpegPath); + return ffmpegPath; + } catch (err) { + console.warn('FFmpeg not available - transcoding/remuxing will be disabled.'); + console.warn('Install FFmpeg via your package manager or npm install ffmpeg-static'); + return null; + } +} + +function findFFprobe() { + // Try system ffprobe first + try { + execSync('ffprobe -version', { stdio: 'ignore' }); + console.log('FFprobe binary configured at: ffprobe (system)'); + return 'ffprobe'; + } catch (e) { + // Not found in system + } + + // Try @ffprobe-installer/ffprobe package + try { + const ffprobePath = require('@ffprobe-installer/ffprobe').path; + if (ffprobePath) { + console.log('FFprobe binary configured at:', ffprobePath); + return ffprobePath; + } + } catch (err) { + // Package not available + } + + console.warn('FFprobe not available - auto transcode will fallback to always transcode'); + return null; +} + +app.locals.ffmpegPath = findFFmpeg(); +app.locals.ffprobePath = findFFprobe(); + +// Dynamic services loader - collects exports from files in ./services +const fs = require('fs'); +const services = {}; +try { + const servicesDir = path.join(__dirname, 'services'); + const serviceFiles = fs.readdirSync(servicesDir).filter(f => f.endsWith('.js')); + for (const file of serviceFiles) { + const name = file.replace(/\.js$/, ''); + try { + services[name] = require(path.join(servicesDir, file)); + } catch (e) { + console.warn(`Failed to load service ${file}:`, e.message); + } + } +} catch (e) { + console.warn('No services directory found or failed to read services:', e.message); +} + +// Freeze services object to prevent plugins from mutating shared state +Object.freeze(services); + +// Plugin loader: loads any .js file inside server/plugins and calls the +// exported function with (app, services). +// Supports both function exports and object exports with lifecycle hooks. +const loadedPlugins = []; + +async function loadPlugins() { + try { + const pluginsDir = path.join(__dirname, 'plugins'); + if (fs.existsSync(pluginsDir)) { + // Sort plugin files alphabetically for deterministic load order + const pluginFiles = fs.readdirSync(pluginsDir) + .filter(f => f.endsWith('.js')) + .sort(); + + for (const file of pluginFiles) { + const pluginPath = path.join(pluginsDir, file); + try { + const plugin = require(pluginPath); + + // Support both function exports and object exports with lifecycle hooks + if (typeof plugin === 'function') { + // Direct function export (sync or async) + await plugin(app, services); + loadedPlugins.push({ name: file, plugin: null }); + console.log(`βœ“ Loaded plugin: ${file}`); + } else if (plugin && typeof plugin.init === 'function') { + // Object export with init/shutdown lifecycle + await plugin.init(app, services); + loadedPlugins.push({ name: file, plugin }); + console.log(`βœ“ Loaded plugin: ${file} (with lifecycle hooks)`); + } else { + console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`); + } + } catch (err) { + console.error(`βœ— Failed to load plugin ${file}:`, err); + } + } + } + } catch (err) { + console.warn('Plugin loader failed:', err.message); + } +} + +// Graceful shutdown handler for plugins with shutdown hooks +process.on('SIGTERM', async () => { + console.log('SIGTERM received, shutting down plugins...'); + for (const { name, plugin } of loadedPlugins) { + if (plugin && typeof plugin.shutdown === 'function') { + try { + await plugin.shutdown(); + console.log(`βœ“ Shutdown plugin: ${name}`); + } catch (err) { + console.error(`βœ— Error shutting down plugin ${name}:`, err); + } + } + } + process.exit(0); +}); + +// API Routes +app.use('/api/auth', require('./routes/auth')); +app.use('/api/sources', require('./routes/sources')); +app.use('/api/proxy', require('./routes/proxy')); +app.use('/api/channels', require('./routes/channels')); +app.use('/api/favorites', require('./routes/favorites')); +app.use('/api/transcode', require('./routes/transcode')); +app.use('/api/remux', require('./routes/remux')); +app.use('/api/probe', require('./routes/probe')); +app.use('/api/subtitle', require('./routes/subtitle')); +app.use('/api/settings', require('./routes/settings')); +app.use('/api/history', require('./routes/history')); + +// Version endpoint +app.get('/api/version', (req, res) => { + const pkg = require('../package.json'); + res.json({ version: pkg.version }); +}); + +// SPA fallback - serve index.html for all non-API routes +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); +}); + +// Error handling +app.use((err, req, res, next) => { + console.error('Server error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, async () => { + console.log(`KiraTV server running on http://localhost:${PORT}`); + + // Load plugins + await loadPlugins().catch(err => { + console.error('Plugin initialization failed:', err); + }); + + // Trigger background sync with delay to allow server to settle + setTimeout(async () => { + await syncService.syncAll().catch(console.error); + // Start the server-side sync timer after initial sync + await syncService.startSyncTimer().catch(console.error); + + // Detect hardware acceleration capabilities + try { + const hwDetect = require('./services/hwDetect'); + await hwDetect.detect(); + } catch (err) { + console.warn('Hardware detection failed:', err.message); + } + }, 5000); +}); diff --git a/server/plugins/PLUGINS.md b/server/plugins/PLUGINS.md new file mode 100644 index 0000000..6136762 --- /dev/null +++ b/server/plugins/PLUGINS.md @@ -0,0 +1,163 @@ +# nodecast-tv Plugin System + +This directory allows you to extend the functionality of **nodecast-tv** without modifying the core source code. The server automatically detects and loads any `.js` file placed in this folder at startup. + +--- + +## How It Works + +The plugin loader in `server/index.js` scans this directory at startup and loads plugins in **alphabetical order** (sorted by filename). Each plugin file should export either: +- A function (sync or async) that will be called during initialization +- An object with `init()` and optionally `shutdown()` methods for lifecycle management + +When the server starts, it calls your plugin's initialization code and passes: +- `app` - The Express application instance +- `services` - A **frozen** (read-only) object containing all internal services + +--- + +## Plugin Patterns + +### Pattern 1: Simple Function Export + +The simplest plugin pattern - just export a function: + +```javascript +/** + * @param {Object} app - The Express application instance + * @param {Object} services - Frozen object containing all internal services + */ +module.exports = function(app, services) { + // Register routes, middleware, etc. + app.get('/api/my-route', (req, res) => { + res.json({ message: 'Hello from plugin!' }); + }); +}; +``` + +### Pattern 2: Async Function Export + +For plugins that need to perform async initialization (database connections, API calls, etc.): + +```javascript +module.exports = async function(app, services) { + // Async initialization + await someAsyncSetup(); + + // Access services + if (services.syncService) { + console.log('Sync service available'); + } + + // Register routes + app.get('/api/my-route', (req, res) => { + res.json({ status: 'ready' }); + }); +}; +``` + +### Pattern 3: Lifecycle Hooks (Advanced) + +For plugins that need cleanup on shutdown: + +```javascript +module.exports = { + /** + * Called during server startup + */ + init: async (app, services) => { + // Setup code + this.interval = setInterval(() => { + console.log('Background task running...'); + }, 60000); + + app.get('/api/status', (req, res) => { + res.json({ uptime: process.uptime() }); + }); + }, + + /** + * Called on SIGTERM (graceful shutdown) + */ + shutdown: async () => { + // Cleanup code + if (this.interval) { + clearInterval(this.interval); + } + console.log('Plugin cleaned up'); + } +}; +``` + +--- + +## Security & Permissions + +> [!WARNING] +> Plugins run with **full Node.js permissions** and have access to the Express app and all services. Only install plugins from trusted sources. + +**Important notes:** +- The `services` object is **frozen** - you cannot modify or delete services +- Plugins can still mutate properties of service objects (e.g., `services.cache.set()`) +- Plugins can register routes, middleware, and access the file system +- This is appropriate for self-hosted applications where you control what plugins are installed + +--- + +## Available Services + +The `services` object contains all modules from `server/services/`: + +| Service | Description | +|---------|-------------| +| `cache` | Caching utilities | +| `epgParser` | EPG/XMLTV parsing | +| `hwDetect` | Hardware acceleration detection | +| `m3uParser` | M3U playlist parsing | +| `m3uXtreamAdapter` | Xtream API adapter | +| `syncService` | Channel/EPG synchronization | +| `transcodeSession` | Transcoding session management | +| `xtreamApi` | Xtream API client | + +**Example:** +```javascript +module.exports = async function(app, services) { + // Use the EPG parser service + const epgData = await services.epgParser.fetchAndParse('http://example.com/epg.xml'); + console.log(`Loaded ${epgData.programmes.length} programmes`); +}; +``` + +--- + +## Load Order + +Plugins are loaded in **alphabetical order** by filename. If you need specific ordering: + +``` +plugins/ + 01-database.js # Loads first + 02-api-client.js # Loads second + 99-cleanup.js # Loads last +``` + +--- + +## Testing Your Plugin + +1. Place your `.js` file in `server/plugins/` +2. Restart the NodeCast TV server +3. Check the console for plugin loading messages: + - `βœ“ Loaded plugin: your-plugin.js` - Success + - `⚠ Plugin your-plugin.js does not export...` - Wrong export format + - `βœ— Failed to load plugin your-plugin.js` - Error during initialization + +--- + +## Example Use Cases + +- **Custom scrapers** - Add support for new streaming sources +- **Notification systems** - Send alerts when new content is available +- **Analytics** - Track viewing patterns +- **Custom APIs** - Expose additional endpoints for integrations +- **Middleware** - Add authentication, logging, or rate limiting \ No newline at end of file diff --git a/server/plugins/hello.js b/server/plugins/hello.js new file mode 100644 index 0000000..c2ec54e --- /dev/null +++ b/server/plugins/hello.js @@ -0,0 +1,25 @@ +/** + * Example plugin demonstrating async initialization and service access + * This plugin registers a test route at /api/hello + */ +module.exports = async function (app, services) { + console.log("Plugin 'Hello' activated!"); + + // Example: Access loaded services + if (services.syncService) { + console.log(" - syncService is available"); + } + + // Simulate async initialization (e.g., database connection, API setup) + await new Promise(resolve => setTimeout(resolve, 100)); + + // Register a test route accessible at http://localhost:3000/api/hello + app.get('/api/hello', (req, res) => { + res.json({ + message: "The plugin system is working!", + availableServices: Object.keys(services) + }); + }); + + console.log(" - Registered route: GET /api/hello"); +}; \ No newline at end of file diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..73ce607 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,294 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const auth = require('../auth'); + +// Configure Passport strategies +auth.configureLocalStrategy( + async (username) => await db.users.getByUsername(username), + async (password, hash) => await auth.verifyPassword(password, hash) +); + +auth.configureJwtStrategy( + async (id) => await db.users.getById(id) +); + +// Configure Passport session serialization (required for OIDC) +auth.configureSessionSerialization( + async (id) => await db.users.getById(id) +); + +// Configure OIDC Strategy +auth.configureOidcStrategy( + async (oidcId) => await db.users.getByOidcId(oidcId), + async (email) => await db.users.getByEmail(email), + async (userData) => await db.users.create(userData) +); + +/** + * Start OIDC Login + * GET /api/auth/oidc/login + */ +router.get('/oidc/login', auth.passport.authenticate('openidconnect')); + +/** + * OIDC Callback + * GET /api/auth/oidc/callback + */ +router.get('/oidc/callback', + auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }), + (req, res) => { + // Successful authentication + const token = auth.generateToken(req.user); + + // Redirect to hompage with token + res.redirect(`/?token=${token}`); + } +); + +/** + * Check if initial setup is required + * GET /api/auth/setup-required + */ +router.get('/setup-required', async (req, res) => { + try { + const userCount = await db.users.count(); + res.json({ setupRequired: userCount === 0 }); + } catch (err) { + console.error('Error in /setup-required:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Initial setup - Create admin user + * POST /api/auth/setup + */ +router.post('/setup', async (req, res) => { + try { + const userCount = await db.users.count(); + + // Check if setup already done + if (userCount > 0) { + return res.status(400).json({ error: 'Setup already completed' }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + // Create admin user + const passwordHash = await auth.hashPassword(password); + const adminUser = await db.users.create({ + username, + passwordHash, + role: 'admin' + }); + + // Generate token for immediate login + const token = auth.generateToken(adminUser); + + res.status(201).json({ + message: 'Admin user created successfully', + token, + user: adminUser + }); + } catch (err) { + console.error('Error in /setup:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Login with Passport Local Strategy + * POST /api/auth/login + */ +router.post('/login', (req, res, next) => { + auth.passport.authenticate('local', { session: false }, (err, user, info) => { + if (err) { + console.error('Login error:', err); + return res.status(500).json({ error: 'Server error' }); + } + + if (!user) { + return res.status(401).json({ error: info?.message || 'Invalid credentials' }); + } + + // Generate JWT token + const token = auth.generateToken(user); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + role: user.role + } + }); + })(req, res, next); +}); + +/** + * Logout (client-side handles token removal) + * POST /api/auth/logout + */ +router.post('/logout', (req, res) => { + // With JWT, logout is handled client-side by removing the token + // This endpoint exists for consistency and future server-side token blacklisting + res.json({ success: true, message: 'Logged out successfully' }); +}); + +/** + * Get current user + * GET /api/auth/me + */ +router.get('/me', auth.requireAuth, async (req, res) => { + try { + const user = await db.users.getById(req.user.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ + id: user.id, + username: user.username, + role: user.role + }); + } catch (err) { + console.error('Error in /me:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Get all users (admin only) + * GET /api/auth/users + */ +router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const allUsers = await db.users.getAll(); + + // Remove password hashes + const users = allUsers.map(u => { + const { passwordHash, ...userWithoutPassword } = u; + return userWithoutPassword; + }); + + res.json(users); + } catch (err) { + console.error('Error fetching users:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Create a new user (admin only) + * POST /api/auth/users + */ +router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { username, password, role } = req.body; + + if (!username || !password || !role) { + return res.status(400).json({ error: 'Username, password, and role are required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' }); + } + + const passwordHash = await auth.hashPassword(password); + const newUser = await db.users.create({ + username, + passwordHash, + role + }); + + res.status(201).json(newUser); + } catch (err) { + console.error('Error creating user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Update a user (admin only) + * PUT /api/auth/users/:id + */ +router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const { username, password, role } = req.body; + + const updates = {}; + + if (username) { + updates.username = username; + } + + if (password) { + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + updates.passwordHash = await auth.hashPassword(password); + } + + if (role) { + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' }); + } + + // Prevent removing admin role from the last admin + const user = await db.users.getById(id); + if (user && user.role === 'admin' && role !== 'admin') { + const allUsers = await db.users.getAll(); + const adminCount = allUsers.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' }); + } + } + + updates.role = role; + } + + const updatedUser = await db.users.update(id, updates); + res.json(updatedUser); + } catch (err) { + console.error('Error updating user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Delete a user (admin only) + * DELETE /api/auth/users/:id + */ +router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { id } = req.params; + + // Prevent deleting yourself + if (parseInt(id) === req.user.id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + await db.users.delete(id); + res.json({ success: true, message: 'User deleted successfully' }); + } catch (err) { + console.error('Error deleting user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +module.exports = router; diff --git a/server/routes/channels.js b/server/routes/channels.js new file mode 100644 index 0000000..35d6fcb --- /dev/null +++ b/server/routes/channels.js @@ -0,0 +1,371 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db/sqlite'); +const { sources } = require('../db'); +const { requireAuth } = require('../auth'); +const { + getAccessibleSourceIds, + assertCanUseSourceById +} = require('../sourceAccess'); + +router.use(requireAuth); + +// Helper to map API item types to DB types and tables +function mapItemType(apiType) { + switch (apiType) { + case 'channel': return { table: 'playlist_items', type: 'live' }; + case 'group': return { table: 'categories', type: 'live' }; + case 'vod_category': return { table: 'categories', type: 'movie' }; + case 'series_category': return { table: 'categories', type: 'series' }; + case 'movie': return { table: 'playlist_items', type: 'movie' }; + case 'series': return { table: 'playlist_items', type: 'series' }; + default: return null; + } +} + +// Get all hidden items (formatted like db.json for frontend compatibility) +router.get('/hidden', async (req, res) => { + try { + const { sourceId } = req.query; + const db = getDb(); + + let allowedIds = null; + if (req.user.role !== 'admin') { + allowedIds = await getAccessibleSourceIds(req, sources); + if (allowedIds.length === 0) { + return res.json([]); + } + } + + const resultFormat = (row, itemType) => ({ + source_id: row.source_id, + item_type: itemType, + item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id + }); + + let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`; + let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`; + let catParams = []; + let itemParams = []; + + if (sourceId) { + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + const sid = parseInt(sourceId, 10); + catQuery += ` AND source_id = ?`; + itemQuery += ` AND source_id = ?`; + catParams.push(sid); + itemParams.push(sid); + } else if (allowedIds) { + const ph = allowedIds.map(() => '?').join(','); + catQuery += ` AND source_id IN (${ph})`; + itemQuery += ` AND source_id IN (${ph})`; + catParams = [...allowedIds]; + itemParams = [...allowedIds]; + } + + const hiddenCats = db.prepare(catQuery).all(...catParams); + const hiddenItems = db.prepare(itemQuery).all(...itemParams); + + const hidden = []; + + hiddenCats.forEach(row => { + let apiType; + if (row.type === 'live') apiType = 'group'; + else if (row.type === 'movie') apiType = 'vod_category'; + else if (row.type === 'series') apiType = 'series_category'; + + if (apiType) hidden.push(resultFormat(row, apiType)); + }); + + hiddenItems.forEach(row => { + let apiType; + if (row.type === 'live') apiType = 'channel'; + else if (row.type === 'movie') apiType = 'movie'; + else if (row.type === 'series') apiType = 'series'; + + if (apiType) hidden.push(resultFormat(row, apiType)); + }); + + res.json(hidden); + } catch (err) { + console.error('Error getting hidden items:', err); + res.status(500).json({ error: 'Failed to get hidden items' }); + } +}); + +// Hide item +router.post('/hide', async (req, res) => { + try { + const { sourceId, itemType, itemId } = req.body; + const mapping = mapItemType(itemType); + + if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + + const stmt = db.prepare(` + UPDATE ${mapping.table} + SET is_hidden = 1 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `); + + stmt.run(sourceId, mapping.type, itemId); + + res.json({ success: true }); + } catch (err) { + console.error('Error hiding item:', err); + res.status(500).json({ error: 'Failed to hide item' }); + } +}); + +// Show item +router.post('/show', async (req, res) => { + try { + const { sourceId, itemType, itemId } = req.body; + const mapping = mapItemType(itemType); + + if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + + const stmt = db.prepare(` + UPDATE ${mapping.table} + SET is_hidden = 0 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `); + + stmt.run(sourceId, mapping.type, itemId); + + res.json({ success: true }); + } catch (err) { + console.error('Error showing item:', err); + res.status(500).json({ error: 'Failed to show item' }); + } +}); + +// Check hidden status +router.get('/hidden/check', async (req, res) => { + try { + const { sourceId, itemType, itemId } = req.query; + const mapping = mapItemType(itemType); + if (!mapping) return res.json({ hidden: false }); + + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + + const row = db.prepare(` + SELECT is_hidden FROM ${mapping.table} + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `).get(sourceId, mapping.type, itemId); + + res.json({ hidden: !!(row && row.is_hidden) }); + } catch (err) { + console.error('Error checking hidden:', err); + res.status(500).json({ error: 'Failed to check status' }); + } +}); + +// Bulk Hide +router.post('/hide/bulk', async (req, res) => { + try { + const { items } = req.body; + if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); + + const uniqueIds = [...new Set(items.map(i => i.sourceId))]; + for (const sid of uniqueIds) { + if (!(await assertCanUseSourceById(req, res, sources, sid))) return; + } + + const db = getDb(); + + const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?'); + const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?'); + + const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?'); + + const runBulk = db.transaction((list) => { + for (const item of list) { + const mapping = mapItemType(item.itemType); + if (mapping) { + if (mapping.table === 'categories') { + hideCat.run(item.sourceId, mapping.type, item.itemId); + hideCatChildren.run(item.sourceId, mapping.type, item.itemId); + } else { + hideItem.run(item.sourceId, mapping.type, item.itemId); + } + } + } + }); + + runBulk(items); + res.json({ success: true, count: items.length }); + } catch (err) { + if (err.code === 'SQLITE_BUSY') { + return res.status(503).json({ error: 'Database is busy, please try again' }); + } + console.error('Error bulk hide:', err); + res.status(500).json({ error: 'Failed' }); + } +}); + +// Bulk Show +router.post('/show/bulk', async (req, res) => { + try { + const { items } = req.body; + if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); + + const uniqueIds = [...new Set(items.map(i => i.sourceId))]; + for (const sid of uniqueIds) { + if (!(await assertCanUseSourceById(req, res, sources, sid))) return; + } + + const db = getDb(); + + const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?'); + const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?'); + + const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?'); + + const runBulk = db.transaction((list) => { + for (const item of list) { + const mapping = mapItemType(item.itemType); + if (mapping) { + if (mapping.table === 'categories') { + showCat.run(item.sourceId, mapping.type, item.itemId); + showCatChildren.run(item.sourceId, mapping.type, item.itemId); + } else { + showItem.run(item.sourceId, mapping.type, item.itemId); + } + } + } + }); + + runBulk(items); + res.json({ success: true, count: items.length }); + } catch (err) { + if (err.code === 'SQLITE_BUSY') { + return res.status(503).json({ error: 'Database is busy, please try again' }); + } + console.error('Error bulk show:', err); + res.status(500).json({ error: 'Failed' }); + } +}); + +// Show ALL items for a source (single SQL statement - much faster than bulk) +router.post('/show/all', async (req, res) => { + try { + const { sourceId, contentType } = req.body; + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + + const db = getDb(); + let catCount = 0; + let itemCount = 0; + + const types = contentType === 'movies' ? ['movie'] + : contentType === 'series' ? ['series'] + : ['live']; + + for (const type of types) { + const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); + const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); + catCount += catResult.changes; + itemCount += itemResult.changes; + } + + console.log(`[Channels] Show all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`); + res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount }); + } catch (err) { + console.error('Error show all:', err); + res.status(500).json({ error: 'Failed to show all' }); + } +}); + +// Hide ALL items for a source (single SQL statement - much faster than bulk) +router.post('/hide/all', async (req, res) => { + try { + const { sourceId, contentType } = req.body; + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + + if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return; + + const db = getDb(); + let catCount = 0; + let itemCount = 0; + + const types = contentType === 'movies' ? ['movie'] + : contentType === 'series' ? ['series'] + : ['live']; + + for (const type of types) { + const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); + const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); + catCount += catResult.changes; + itemCount += itemResult.changes; + } + + console.log(`[Channels] Hide all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`); + res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount }); + } catch (err) { + console.error('Error hide all:', err); + res.status(500).json({ error: 'Failed to hide all' }); + } +}); + +// Get recent movies or series +router.get('/recent', async (req, res) => { + try { + const { type, limit = 12 } = req.query; + if (!type || (type !== 'movie' && type !== 'series')) { + return res.status(400).json({ error: 'Valid type (movie or series) is required' }); + } + + let accessibleFilter = ''; + const params = [type]; + if (req.user.role !== 'admin') { + const ids = await getAccessibleSourceIds(req, sources); + if (ids.length === 0) { + return res.json([]); + } + accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`; + params.push(...ids); + } + params.push(parseInt(limit, 10)); + + const db = getDb(); + const recentItems = db.prepare(` + SELECT * FROM playlist_items p + WHERE p.type = ? + AND p.is_hidden = 0 + AND NOT EXISTS ( + SELECT 1 FROM categories c + WHERE c.source_id = p.source_id + AND c.category_id = p.category_id + AND c.type = p.type + AND c.is_hidden = 1 + ) + ${accessibleFilter} + ORDER BY p.added_at DESC + LIMIT ? + `).all(...params); + + const formatted = recentItems.map(item => ({ + ...item, + data: JSON.parse(item.data) + })); + + res.json(formatted); + } catch (err) { + console.error(`Error getting recent ${req.query.type}:`, err); + res.status(500).json({ error: 'Failed to get recent items' }); + } +}); + +module.exports = router; diff --git a/server/routes/favorites.js b/server/routes/favorites.js new file mode 100644 index 0000000..980e1bb --- /dev/null +++ b/server/routes/favorites.js @@ -0,0 +1,66 @@ +const express = require('express'); +const router = express.Router(); +const { favorites } = require('../db/sqlite'); +const { requireAuth } = require('../auth'); + +// All favorites routes require authentication +router.use(requireAuth); + +// Get all favorites for current user +router.get('/', async (req, res) => { + try { + const { sourceId, itemType } = req.query; + const items = favorites.getAll(req.user.id, sourceId || null, itemType || null); + res.json(items); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Add favorite for current user +router.post('/', async (req, res) => { + try { + const { sourceId, itemId, itemType = 'channel' } = req.body; + if (!sourceId || !itemId) { + return res.status(400).json({ error: 'Source ID and Item ID are required' }); + } + + favorites.add(req.user.id, sourceId, itemId, itemType); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Remove favorite for current user +router.delete('/', async (req, res) => { + try { + const { sourceId, itemId, itemType = 'channel' } = req.body; + if (!sourceId || !itemId) { + return res.status(400).json({ error: 'Source ID and Item ID are required' }); + } + + favorites.remove(req.user.id, sourceId, itemId, itemType); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Check if item is favorited by current user +router.get('/check', async (req, res) => { + try { + const { sourceId, itemId, itemType = 'channel' } = req.query; + if (!sourceId || !itemId) { + return res.status(400).json({ error: 'Source ID and Item ID are required' }); + } + + const isFav = favorites.isFavorite(req.user.id, sourceId, itemId, itemType); + res.json({ isFavorite: isFav }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; + diff --git a/server/routes/history.js b/server/routes/history.js new file mode 100644 index 0000000..1ff91d8 --- /dev/null +++ b/server/routes/history.js @@ -0,0 +1,112 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../db/sqlite'); +const { requireAuth } = require('../auth'); + +// Middleware to ensure authentication +router.use(requireAuth); + +/** + * GET /api/history + * Returns the watch history for the authenticated user + */ +router.get('/', (req, res) => { + try { + const db = getDb(); + const userId = req.user.id; + const limit = parseInt(req.query.limit) || 20; + + const rows = db.prepare(` + SELECT * FROM watch_history + WHERE user_id = ? + ORDER BY updated_at DESC + LIMIT ? + `).all(userId, limit); + + const history = rows.map(row => ({ + ...row, + data: JSON.parse(row.data || '{}') + })); + + res.json(history); + } catch (err) { + console.error('[History] Error fetching history:', err); + res.status(500).json({ error: 'Failed to fetch history' }); + } +}); + +/** + * POST /api/history + * Saves/updates watch progress for an item + */ +router.post('/', (req, res) => { + try { + const db = getDb(); + const userId = req.user.id; + const { id, type, parentId, progress, duration, data, sourceId } = req.body; + + if (!id || !type) { + return res.status(400).json({ error: 'Missing required fields (id, type)' }); + } + + const compositeId = `${userId}:${id}`; + const timestamp = Date.now(); + + const stmt = db.prepare(` + INSERT INTO watch_history (id, user_id, source_id, item_type, item_id, parent_id, progress, duration, updated_at, data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + source_id = excluded.source_id, + progress = excluded.progress, + duration = excluded.duration, + updated_at = excluded.updated_at, + data = excluded.data + `); + + stmt.run( + compositeId, + userId, + sourceId || null, + type, + id.toString(), + parentId ? parentId.toString() : null, + progress || 0, + duration || 0, + timestamp, + JSON.stringify(data || {}) + ); + + res.json({ success: true, timestamp }); + } catch (err) { + console.error('[History] Error saving progress:', err); + res.status(500).json({ error: 'Failed to save progress' }); + } +}); + +/** + * DELETE /api/history/:itemId + * Removes an item from the user's watch history + */ +router.delete('/:itemId', (req, res) => { + try { + const db = getDb(); + const userId = req.user.id; + const itemId = req.params.itemId; + + const compositeId = `${userId}:${itemId}`; + + const stmt = db.prepare('DELETE FROM watch_history WHERE id = ? AND user_id = ?'); + const result = stmt.run(compositeId, userId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Item not found in history' }); + } + + res.json({ success: true }); + } catch (err) { + console.error('[History] Error deleting history item:', err); + res.status(500).json({ error: 'Failed to delete history item' }); + } +}); + +module.exports = router; diff --git a/server/routes/probe.js b/server/routes/probe.js new file mode 100644 index 0000000..bb1f8bd --- /dev/null +++ b/server/routes/probe.js @@ -0,0 +1,224 @@ +const express = require('express'); +const router = express.Router(); +const { spawn } = require('child_process'); + +/** + * Probe endpoint - detects stream codecs and container + * GET /api/probe?url=... + * + * Returns: + * { + * video: "h264", + * audio: "aac", + * container: "mpegts", + * compatible: true, + * needsRemux: false, + * needsTranscode: false + * } + */ + +// Probe cache (URL β†’ result) +const probeCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// Browser-compatible codecs +const BROWSER_VIDEO_CODECS = ['h264', 'avc', 'avc1']; +const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis']; + +/** + * Probe stream with ffprobe + */ +function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) { + return new Promise((resolve, reject) => { + const args = [ + '-v', 'error', + '-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + '-print_format', 'json', + '-show_streams', + '-show_format', + '-probesize', '5000000', + '-analyzeduration', '5000000', + url + ]; + + const proc = spawn(ffprobePath, args); + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error('Probe timeout')); + }, timeout); + + proc.stdout.on('data', (data) => { stdout += data; }); + proc.stderr.on('data', (data) => { stderr += data; }); + + proc.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`ffprobe exited with code ${code}: ${stderr}`)); + return; + } + try { + const result = JSON.parse(stdout); + resolve(result); + } catch (e) { + reject(new Error('Failed to parse ffprobe output')); + } + }); + + proc.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** + * Analyze probe result and determine compatibility + */ +function analyzeProbeResult(probeResult, url) { + const streams = probeResult.streams || []; + const format = probeResult.format || {}; + + const videoStream = streams.find(s => s.codec_type === 'video'); + const audioStream = streams.find(s => s.codec_type === 'audio'); + + const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; + const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; + const container = format.format_name?.toLowerCase() || 'unknown'; + + // Check codec compatibility + const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); + const audioOk = BROWSER_AUDIO_CODECS.some(c => audioCodec.includes(c)); + + // Browser-safe containers + // Note: We exclude 'webm' because ffprobe reports MKV as "matroska,webm", + // and H.264/AAC in MKV/WebM is not universally supported. Best to remux to MP4. + const BROWSER_CONTAINERS = ['hls', 'mp4', 'mov']; + const containerOk = BROWSER_CONTAINERS.some(c => container.includes(c)); + + // Check if it's a raw TS stream (not HLS) + const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8'); + + // Extract subtitle tracks + const subtitles = streams + .filter(s => s.codec_type === 'subtitle' && s.codec_name !== 'timed_id3' && s.codec_name !== 'bin_data') + .map(s => ({ + index: s.index, + language: s.tags?.language || 'und', + title: s.tags?.title || s.tags?.language || `Track ${s.index}`, + codec: s.codec_name + })); + + // Determine what processing is needed + // 4. MKV files often cause OOM/decoding issues in browser fMP4 remux, + // so we force them to "needsTranscode" which uses HLS (more robust). + // The frontend will still use "copy" mode if codecs are compatible. + const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv'); + + // 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy) + const needsTranscode = !audioOk || !videoOk || isMkv; + + // 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe) + const needsRemux = !needsTranscode && (!containerOk || isRawTs); + + const compatible = !needsTranscode && !needsRemux; + + return { + video: videoCodec, + audio: audioCodec, + width: videoStream?.width || 0, + height: videoStream?.height || 0, + audioChannels: audioStream?.channels || 0, // For Smart Audio Copy + container: container, + compatible: compatible, + needsRemux: needsRemux, + needsTranscode: needsTranscode, + subtitles: subtitles + }; +} + +router.get('/', async (req, res) => { + const { url, ua } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL parameter is required' }); + } + + const ffprobePath = req.app.locals.ffprobePath; + const cacheKey = `${url}${ua ? `|${ua}` : ''}`; + + if (!ffprobePath) { + // No ffprobe available - assume needs transcoding to be safe + console.log('[Probe] FFprobe not available, assuming transcode needed'); + return res.json({ + video: 'unknown', + audio: 'unknown', + container: 'unknown', + compatible: false, + needsRemux: false, + needsTranscode: true + }); + } + + // Check cache + const cached = probeCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { + console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`); + return res.json(cached.result); + } + + console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`); + + // Retry probe up to 3 times on 5XX errors (provider slot not released yet) + const MAX_PROBE_RETRIES = 3; + const PROBE_RETRY_DELAY_MS = 2500; + let lastErr = null; + + for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) { + try { + const probeResult = await probeStream(url, ffprobePath, ua); + const analysis = analyzeProbeResult(probeResult, url); + + // Cache successful result + probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() }); + + console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` + + `container=${analysis.container}, compatible=${analysis.compatible}, ` + + `needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`); + + return res.json(analysis); + } catch (err) { + lastErr = err; + const is5xx = /5[0-9]{2}|Server Error/i.test(err.message); + if (is5xx && attempt < MAX_PROBE_RETRIES) { + console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`); + await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS)); + continue; + } + break; + } + } + + console.error('[Probe] Failed after retries:', lastErr.message); + + // If there's a stale cached result, use it rather than assuming needsTranscode + const stale = probeCache.get(cacheKey); + if (stale) { + console.warn('[Probe] Using stale cached result as fallback'); + return res.json({ ...stale.result, stale: true }); + } + + // Final fallback: assume transcode needed + res.json({ + video: 'unknown', + audio: 'unknown', + container: 'unknown', + compatible: false, + needsRemux: false, + needsTranscode: true, + error: lastErr.message + }); +}); + +module.exports = router; diff --git a/server/routes/proxy.js b/server/routes/proxy.js new file mode 100644 index 0000000..54e217f --- /dev/null +++ b/server/routes/proxy.js @@ -0,0 +1,852 @@ +const express = require('express'); +const router = express.Router(); +const { sources } = require('../db'); +const { getDb } = require('../db/sqlite'); // Import SQLite +const xtreamApi = require('../services/xtreamApi'); +const epgParser = require('../services/epgParser'); +const cache = require('../services/cache'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const { spawn } = require('child_process'); +const ffmpegPath = require('ffmpeg-static'); +const { Readable } = require('stream'); +const auth = require('../auth'); +const { canUserAccessSource } = require('../sourceAccess'); + +// Las rutas de proxy (categorΓ­as, streams, EPG, imΓ‘genes, HLS, etc.) son accedidas +// directamente por el reproductor y los navegadores sin header Authorization. +// El control de acceso a fuentes se hace en /api/sources (CRUD). +// Por eso NO aplicamos requireAuth globalmente aquΓ­. + +router.param('sourceId', async (req, res, next, id) => { + try { + const sid = parseInt(id, 10); + if (Number.isNaN(sid)) { + return res.status(400).json({ error: 'Invalid source id' }); + } + const source = await sources.getById(sid); + if (!source) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, source)) { + // Solo bloqueamos si hay usuario autenticado y no tiene acceso + // (sin token, req.user es undefined β†’ dejamos pasar, el reproductor no lleva Bearer) + if (req.user) { + return res.status(403).json({ error: 'No access to this source' }); + } + } + req.grantedSource = source; + next(); + } catch (err) { + next(err); + } +}); + +// Default cache max age in hours +const DEFAULT_MAX_AGE_HOURS = 24; + +// Helper to get formatted category list from DB +function getCategoriesFromDb(sourceId, type, includeHidden = false) { + const db = getDb(); + let query = ` + SELECT category_id, name as category_name, parent_id + FROM categories + WHERE source_id = ? AND type = ? + `; + if (!includeHidden) { + query += ` AND is_hidden = 0`; + } + query += ` ORDER BY name ASC`; + const cats = db.prepare(query).all(sourceId, type); + return cats; +} + +// Helper to get formatted streams from DB +function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = false) { + const db = getDb(); + let query = ` + SELECT item_id, name, stream_icon, added_at, rating, container_extension, year, category_id, data + FROM playlist_items + WHERE source_id = ? AND type = ? + `; + if (!includeHidden) { + query += ` AND is_hidden = 0`; + } + const params = [sourceId, type]; + + if (categoryId) { + query += ` AND category_id = ?`; + params.push(categoryId); + } + + // Default sorting + // query += ` ORDER BY name ASC`; // Sorting usually handled by client + + const items = db.prepare(query).all(...params); + + // Map to Xtream format + return items.map(item => { + const data = JSON.parse(item.data || '{}'); + // Override with our local fields if needed, or just return the mixed object + // We should ensure critical fields are present + return { + ...data, + stream_id: item.item_id, // ensure ID matches what client expects + series_id: type === 'series' ? item.item_id : undefined, + name: item.name, + stream_icon: item.stream_icon, + cover: item.stream_icon, // series/vod often use cover + added: item.added_at, + rating: item.rating, + container_extension: item.container_extension, + category_id: item.category_id, + // Normalize EPG channel ID: Xtream uses epg_channel_id, M3U uses tvgId + epg_channel_id: data.epg_channel_id || data.tvgId || null + }; + }); +} + + +// --- Xtream Codes Proxy API --- // + +// Login / Authenticate +router.get('/xtream/:sourceId', async (req, res) => { + try { + const source = req.grantedSource; + if (source.type !== 'xtream') return res.status(404).send('Source not found'); + + // Proxy auth check to upstream to ensure credentials are still valid + + const cached = cache.get('xtream', source.id, 'auth', 300000); + if (cached) return res.json(cached); + + const api = xtreamApi.createFromSource(source); + const data = await api.authenticate(); + cache.set('xtream', source.id, 'auth', data); + res.json(data); + } catch (err) { + res.status(502).json({ error: 'Upstream error', details: err.message }); + } +}); + +// Live Categories +router.get('/xtream/:sourceId/live_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'live', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Live Streams +router.get('/xtream/:sourceId/live_streams', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'live', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// VOD Categories +router.get('/xtream/:sourceId/vod_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'movie', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// VOD Streams +router.get('/xtream/:sourceId/vod_streams', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'movie', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series Categories +router.get('/xtream/:sourceId/series_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'series', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series +router.get('/xtream/:sourceId/series', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'series', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series Info (Episodes) +// Proxy series info request +router.get('/xtream/:sourceId/series_info', async (req, res) => { + try { + const source = req.grantedSource; + + const seriesId = req.query.series_id; + if (!seriesId) return res.status(400).send('series_id required'); + + const cacheKey = `series_info_${seriesId}`; + const cached = cache.get('xtream', source.id, cacheKey, 3600000); + if (cached) return res.json(cached); + + const api = xtreamApi.createFromSource(source); + const data = await api.getSeriesInfo(seriesId); + cache.set('xtream', source.id, cacheKey, data); + res.json(data); + } catch (err) { + res.status(502).json({ error: 'Upstream error', details: err.message }); + } +}); + +// VOD Info +router.get('/xtream/:sourceId/vod_info', async (req, res) => { + try { + const source = req.grantedSource; + + const vodId = req.query.vod_id; + if (!vodId) return res.status(400).send('vod_id required'); + + const cacheKey = `vod_info_${vodId}`; + const cached = cache.get('xtream', source.id, cacheKey, 3600000); + if (cached) return res.json(cached); + + const api = xtreamApi.createFromSource(source); + const data = await api.getVodInfo(vodId); + cache.set('xtream', source.id, cacheKey, data); + res.json(data); + } catch (err) { + res.status(502).json({ error: 'Upstream error', details: err.message }); + } +}); + +// Get Stream URL for playback +// Returns the direct stream URL for a given stream ID +router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => { + try { + const source = req.grantedSource; + if (source.type !== 'xtream') { + return res.status(404).json({ error: 'Xtream source not found' }); + } + + const streamId = req.params.streamId; + const type = req.params.type || 'live'; + const container = req.query.container || 'ts'; + + // Construct the Xtream stream URL + // Format: http://server:port/live/username/password/streamId.container (for live) + // Format: http://server:port/movie/username/password/streamId.container (for movie) + // Format: http://server:port/series/username/password/streamId.container (for series) + + let streamUrl; + const baseUrl = source.url.replace(/\/$/, ''); // Remove trailing slash + + if (type === 'live') { + streamUrl = `${baseUrl}/live/${source.username}/${source.password}/${streamId}.${container}`; + } else if (type === 'movie') { + streamUrl = `${baseUrl}/movie/${source.username}/${source.password}/${streamId}.${container}`; + } else if (type === 'series') { + streamUrl = `${baseUrl}/series/${source.username}/${source.password}/${streamId}.${container}`; + } else { + return res.status(400).json({ error: 'Invalid stream type' }); + } + + res.json({ url: streamUrl }); + } catch (err) { + console.error('Error getting stream URL:', err); + res.status(500).json({ error: 'Failed to get stream URL' }); + } +}); + + +// --- Other Proxy Routes --- // + +// M3U Playlist +// (For M3U sources, we now have data in DB. We can reconstruct M3U or return JSON) +// Frontend ChannelList.js for M3U sources calls `API.proxy.m3u.get(sourceId)` +// which points here. It expects { channels, groups }. +router.get('/m3u/:sourceId', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + + // Fetch from DB + const channels = getStreamsFromDb(sourceId, 'live', null, includeHidden); + const groups = getCategoriesFromDb(sourceId, 'live', includeHidden); + + // Format for frontend helper + // ChannelList expects: + // { + // channels: [ { id, name, groupTitle, url, tvgLogo, ... } ], + // groups: [ { id, name, channelCount } ] + // } + // Note: DB `live` items from M3U sync have `category_id` as their group name usually. + + const reformattedChannels = channels.map(c => ({ + ...c, + id: c.stream_id, + groupTitle: c.category_id || 'Uncategorized', + url: c.stream_url || c.url, + tvgLogo: c.stream_icon + })); + + const reformattedGroups = groups.map(g => ({ + id: g.category_id, + name: g.category_name, + channelCount: 0 // Frontend calculates this or we can + })); + + // Add implicit groups check? + // The frontend M3U parser generates groups from the channels if explicit groups missing. + // Our SyncService `saveCategories` handles explicit groups. + + res.json({ channels: reformattedChannels, groups: reformattedGroups }); + + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// EPG +router.get('/epg/:sourceId', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const db = getDb(); + + // Time window: 24 hours ago to 24 hours from now + // This prevents returning millions of rows and crashing the server/browser + const windowStart = Date.now() - (24 * 60 * 60 * 1000); // -24 hours + const windowEnd = Date.now() + (24 * 60 * 60 * 1000); // +24 hours + + // Fetch programs within the time window + let programsQuery = ` + SELECT channel_id as channelId, start_time, end_time, title, description, data + FROM epg_programs + WHERE source_id = ? AND end_time > ? AND start_time < ? + `; + const params = [sourceId, windowStart, windowEnd]; + + const programs = db.prepare(programsQuery).all(...params); + + const formattedPrograms = programs.map(p => ({ + channelId: p.channelId, + start: new Date(p.start_time).toISOString(), // EpgGuide parse this back + stop: new Date(p.end_time).toISOString(), + title: p.title, + description: p.description + })); + + // Fetch EPG channels from playlist_items (type='epg_channel') + + + let epgChannels = []; + + // Try getting stored channels first + const storedChannels = db.prepare(` + SELECT item_id as id, name, stream_icon as icon, data + FROM playlist_items + WHERE source_id = ? AND type = 'epg_channel' + `).all(sourceId); + + if (storedChannels.length > 0) { + epgChannels = storedChannels; + } else { + // Fallback: Build from unique channelIds in programmes (Legacy behavior) + const uniqueChannelIds = [...new Set(programs.map(p => p.channelId))]; + epgChannels = uniqueChannelIds.map(id => ({ + id: id, + name: id // Use channelId as name (fallback) + })); + } + + res.json({ + channels: epgChannels, + programmes: formattedPrograms + }); + + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Clear cache (kept for compatibility) +router.delete('/cache/:sourceId', (req, res) => { + const sourceId = req.params.sourceId; + cache.clearSource(sourceId); + res.json({ success: true }); +}); + + + +/** + * Proxy Xtream API calls + * GET /api/proxy/xtream/:sourceId/:action + */ +router.get('/xtream/:sourceId/:action', async (req, res) => { + try { + const sourceId = req.params.sourceId; + const source = req.grantedSource; + if (source.type !== 'xtream') { + return res.status(404).json({ error: 'Xtream source not found' }); + } + + const { action } = req.params; + const { category_id, stream_id, vod_id, series_id, limit, refresh, maxAge } = req.query; + const forceRefresh = refresh === '1'; + const maxAgeHours = parseInt(maxAge) || DEFAULT_MAX_AGE_HOURS; + const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + + // Actions that should be cached + const cacheableActions = [ + 'live_categories', 'live_streams', + 'vod_categories', 'vod_streams', + 'series_categories', 'series' + ]; + + // Build cache key (include category_id if present) + const cacheKey = category_id ? `${action}_${category_id}` : action; + + // Check cache for cacheable actions + if (!forceRefresh && cacheableActions.includes(action)) { + const cached = cache.get('xtream', sourceId, cacheKey, maxAgeMs); + if (cached) { + return res.json(cached); + } + } + + // Fetch fresh data + const api = xtreamApi.createFromSource(source); + let data; + switch (action) { + case 'auth': + data = await api.authenticate(); + break; + case 'live_categories': + data = await api.getLiveCategories(); + break; + case 'live_streams': + data = await api.getLiveStreams(category_id); + break; + case 'vod_categories': + data = await api.getVodCategories(); + break; + case 'vod_streams': + data = await api.getVodStreams(category_id); + break; + case 'vod_info': + data = await api.getVodInfo(vod_id); + break; + case 'series_categories': + data = await api.getSeriesCategories(); + break; + case 'series': + data = await api.getSeries(category_id); + break; + case 'series_info': + data = await api.getSeriesInfo(series_id); + break; + case 'short_epg': + data = await api.getShortEpg(stream_id, limit); + break; + default: + return res.status(400).json({ error: 'Unknown action' }); + } + + // Cache the result for cacheable actions + if (cacheableActions.includes(action)) { + cache.set('xtream', sourceId, cacheKey, data); + } + + res.json(data); + } catch (err) { + console.error('Xtream proxy error:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Get Xtream stream URL + * GET /api/proxy/xtream/:sourceId/stream/:streamId + */ +router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => { + try { + const source = req.grantedSource; + if (source.type !== 'xtream') { + return res.status(404).json({ error: 'Xtream source not found' }); + } + + const api = xtreamApi.createFromSource(source); + const { streamId, type = 'live' } = req.params; + const { container = 'ts' } = req.query; + + const url = api.buildStreamUrl(streamId, type, container); + res.json({ url }); + } catch (err) { + console.error('Stream URL error:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Fetch and parse EPG (with file-based caching) + * GET /api/proxy/epg/:sourceId + * Query params: + * - refresh=1 Force refresh, bypass cache + * - maxAge=N Max cache age in hours (default 24) + */ +router.get('/epg/:sourceId', async (req, res) => { + try { + const sourceId = req.params.sourceId; + const source = req.grantedSource; + if (source.type !== 'epg' && source.type !== 'xtream') { + return res.status(404).json({ error: 'Valid EPG source not found' }); + } + + const forceRefresh = req.query.refresh === '1'; + const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS; + const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + + // Check file cache (unless force refresh) + if (!forceRefresh) { + const cached = cache.get('epg', sourceId, 'data', maxAgeMs); + if (cached) { + return res.json(cached); + } + } + + // Fetch fresh data + let url = source.url; + if (source.type === 'xtream') { + const api = xtreamApi.createFromSource(source); + url = api.getXmltvUrl(); + } + + const data = await epgParser.fetchAndParse(url); + + // Store in file cache + cache.set('epg', sourceId, 'data', data); + + res.json(data); + } catch (err) { + console.error('EPG proxy error:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Clear cache for a source + * DELETE /api/proxy/cache/:sourceId + */ +router.delete('/cache/:sourceId', (req, res) => { + const sourceId = req.params.sourceId; + cache.clearSource(sourceId); + res.json({ success: true }); +}); + +/** + * Clear EPG cache for a source (legacy endpoint, calls clearSource) + * DELETE /api/proxy/epg/:sourceId/cache + */ +router.delete('/epg/:sourceId/cache', (req, res) => { + const sourceId = req.params.sourceId; + cache.clear('epg', sourceId, 'data'); + res.json({ success: true }); +}); + +/** + * Get EPG for specific channels + * POST /api/proxy/epg/:sourceId/channels + */ +router.post('/epg/:sourceId/channels', async (req, res) => { + try { + const source = req.grantedSource; + if (source.type !== 'epg') { + return res.status(404).json({ error: 'EPG source not found' }); + } + + const { channelIds } = req.body; + if (!channelIds || !Array.isArray(channelIds)) { + return res.status(400).json({ error: 'channelIds array required' }); + } + + const data = await epgParser.fetchAndParse(source.url); + + // Filter programmes for requested channels + const result = {}; + for (const channelId of channelIds) { + result[channelId] = epgParser.getCurrentAndUpcoming(data.programmes, channelId); + } + + res.json(result); + } catch (err) { + console.error('EPG channels error:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Proxy stream for playback + * This handles CORS for streams that don't allow cross-origin + * Supports HTTP Range requests for video seeking + */ +router.get('/stream', async (req, res) => { + const maxRetries = 2; + let lastError = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + let { url } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL required' }); + } + + // Forward some headers to be more "transparent" back to the origin + // Pluto TV uses multiple domains for content delivery + const plutoDomains = ['pluto.tv', 'pluto.io', 'plutotv.net', 'siloh.pluto.tv', 'service-stitcher']; + const isPluto = plutoDomains.some(domain => url.includes(domain)); + + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + // Using https and matching the origin of the request + 'Origin': isPluto ? 'https://pluto.tv' : new URL(url).origin, + 'Referer': isPluto ? 'https://pluto.tv/' : new URL(url).origin + '/' + }; + + // Forward Range header for video seeking support + const rangeHeader = req.get('range'); + if (rangeHeader) { + headers['Range'] = rangeHeader; + } + + const response = await fetch(url, { headers }); + + // Retry on 5xx errors (transient upstream issues) + if (response.status >= 500 && attempt < maxRetries) { + console.log(`[Proxy] Upstream 5xx error (attempt ${attempt}/${maxRetries}), retrying in 500ms...`); + await new Promise(r => setTimeout(r, 500)); + continue; + } + + if (!response.ok) { + console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`); + if (response.status === 403) { + const errorBody = await response.text().catch(() => 'N/A'); + console.error(`403 Response body: ${errorBody.substring(0, 200)}`); + } + return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`); + } + + const contentType = response.headers.get('content-type') || ''; + res.set('Access-Control-Allow-Origin', '*'); + + // Forward range-related headers for video seeking support + const contentLength = response.headers.get('content-length'); + const contentRange = response.headers.get('content-range'); + const acceptRanges = response.headers.get('accept-ranges'); + + if (contentLength) { + res.set('Content-Length', contentLength); + } + if (contentRange) { + res.set('Content-Range', contentRange); + } + if (acceptRanges) { + res.set('Accept-Ranges', acceptRanges); + } else if (contentLength && !contentRange) { + // If server supports content-length but didn't explicitly state accept-ranges, + // we can safely assume it supports byte ranges + res.set('Accept-Ranges', 'bytes'); + } + + // Set status code (206 for partial content when range request was made) + res.status(response.status); + + // Create an async iterator for the response body + const iterator = response.body[Symbol.asyncIterator](); + const first = await iterator.next(); + + if (first.done) { + res.set('Content-Type', contentType || 'application/octet-stream'); + return res.end(); + } + + const firstChunk = Buffer.from(first.value); + + // Peek at first bytes to check for HLS manifest ({ #EXTM3U }) + const textPrefix = firstChunk.subarray(0, 7).toString('utf8'); + const contentLooksLikeHls = textPrefix === '#EXTM3U'; + + if (contentLooksLikeHls) { + // HLS Manifest: We must read the WHOLE manifest to rewrite it + const chunks = [firstChunk]; + + // Consume the rest of the stream + let result = await iterator.next(); + while (!result.done) { + chunks.push(Buffer.from(result.value)); + result = await iterator.next(); + } + + const buffer = Buffer.concat(chunks); + const finalUrl = response.url || url; + console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`); + res.set('Content-Type', 'application/vnd.apple.mpegurl'); + + let manifest = buffer.toString('utf-8'); + + const finalUrlObj = new URL(finalUrl); + const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1); + + manifest = manifest.split('\n').map(line => { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + // Handle both URI="..." and URI='...' formats + if (trimmed.includes('URI=')) { + // Replace both double and single quoted URIs + return line.replace(/URI=["']([^"']+)["']/g, (match, p1) => { + try { + const absoluteUrl = new URL(p1, baseUrl).href; + return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`; + } catch (e) { + return match; + } + }); + } + return line; + } + + // Stream URL handling + try { + let absoluteUrl; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + absoluteUrl = trimmed; + } else { + absoluteUrl = new URL(trimmed, baseUrl).href; + } + return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`; + } catch (e) { return line; } + }).join('\n'); + + return res.send(manifest); + } + + // Binary content (Video Segment or Key): Collect and send + console.log(`[Proxy] Serving binary content (${contentType})`); + res.set('Content-Type', contentType || 'application/octet-stream'); + + // For small files (like encryption keys), collect all data and send at once + // This ensures proper Content-Length and response completion + const chunks = [firstChunk]; + let result = await iterator.next(); + while (!result.done) { + chunks.push(Buffer.from(result.value)); + result = await iterator.next(); + } + const fullContent = Buffer.concat(chunks); + + // Set Content-Length for proper client handling + res.set('Content-Length', fullContent.length); + res.send(fullContent); + return; // Success - exit the retry loop + + } catch (err) { + lastError = err; + console.error(`Stream proxy error (attempt ${attempt}/${maxRetries}):`, err.message); + if (attempt < maxRetries) { + console.log('[Proxy] Retrying after error...'); + await new Promise(r => setTimeout(r, 500)); + continue; + } + } + } + + // All retries failed + if (!res.headersSent) { + res.status(500).json({ error: lastError?.message || 'Stream proxy failed after retries' }); + } +}); + +/** + * Proxy images (channel logos, posters) + * Fixes mixed content errors when loading HTTP images on HTTPS pages + * GET /api/proxy/image?url=... + */ +router.get('/image', async (req, res) => { + try { + const { url } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL required' }); + } + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/*,*/*;q=0.8' + } + }); + + if (!response.ok) { + return res.status(response.status).send('Failed to fetch image'); + } + + const contentType = response.headers.get('content-type') || 'image/png'; + res.set('Content-Type', contentType); + res.set('Access-Control-Allow-Origin', '*'); + res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours + + // Efficiently pipe the response body + if (response.body) { + // response.body is an AsyncIterable in standard fetch/undici + // Readable.from converts it to a Node.js Readable stream + const stream = Readable.from(response.body); + stream.pipe(res); + } else { + res.end(); + } + + } catch (err) { + console.error('Image proxy error:', err.message); + res.status(500).send('Image proxy error'); + } +}); + +module.exports = router; diff --git a/server/routes/remux.js b/server/routes/remux.js new file mode 100644 index 0000000..a30a61a --- /dev/null +++ b/server/routes/remux.js @@ -0,0 +1,124 @@ +const express = require('express'); +const router = express.Router(); +const { spawn } = require('child_process'); +const db = require('../db'); + +/** + * Remux stream (container conversion only) + * GET /api/remux?url=... + * + * Remuxes MPEG-TS to fragmented MP4 for browser playback. + * This is a lightweight operation - no video/audio re-encoding. + * Use this for raw .ts streams that browsers can't play directly. + * + * Note: This does NOT fix Dolby/AC3 audio issues - use /api/transcode for that. + */ +router.get('/', async (req, res) => { + const { url } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL parameter is required' }); + } + + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; + + // Get User-Agent from settings + const settings = await db.settings.get(); + const userAgent = db.getUserAgent(settings); + + console.log(`[Remux] Starting remux for: ${url}`); + console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`); + + // FFmpeg arguments for pure remux (no encoding) + // Very lightweight - just changes container from TS to fragmented MP4 + const args = [ + '-hide_banner', + '-loglevel', 'warning', + '-user_agent', userAgent, + '-user_agent', userAgent, + // Standard probe size to handle complex containers (MKV) correctly + '-probesize', '5000000', + '-analyzeduration', '5000000', + // Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering + '-fflags', '+genpts+discardcorrupt+igndts+nobuffer', + // Ignore errors in stream and continue + '-err_detect', 'ignore_err', + // Limit max demux delay to prevent buffering issues with bad timestamps + '-max_delay', '5000000', + // Reconnect settings for network drops + '-reconnect', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '5', + // Prevent Range/HEAD requests that some providers reject with 405 + '-seekable', '0', + '-i', url, + // STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments + // This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4) + '-map', '0:v', + '-map', '0:a', + // Drop subtitles (-sn) and data (-dn) explicitly + '-sn', '-dn', + // Copy streams without re-encoding + '-c', 'copy', + // Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox) + '-bsf:v', 'dump_extra', + // NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here + // That filter only works for AAC audio and breaks AC3/EAC3/MP3. + // If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead. + // Handle timestamp discontinuities at output + '-fps_mode', 'passthrough', + '-max_muxing_queue_size', '1024', + // Fragmented MP4 for streaming (browser-compatible) + '-f', 'mp4', + '-movflags', 'frag_keyframe+empty_moov+default_base_moof', + '-' // Output to stdout + ]; + + console.log(`[Remux] Full command: ${ffmpegPath} ${args.join(' ')}`); + + let ffmpeg; + try { + ffmpeg = spawn(ffmpegPath, args); + } catch (spawnErr) { + console.error('[Remux] Failed to spawn FFmpeg:', spawnErr); + return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message }); + } + + // Set headers for fragmented MP4 + res.setHeader('Content-Type', 'video/mp4'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + // Pipe stdout to response + ffmpeg.stdout.pipe(res); + + // Log stderr (useful for debugging) + ffmpeg.stderr.on('data', (data) => { + const msg = data.toString(); + // Only log warnings/errors, not progress + if (msg.includes('Warning') || msg.includes('Error') || msg.includes('error')) { + console.log(`[Remux FFmpeg] ${msg}`); + } + }); + + // Cleanup on client disconnect + req.on('close', () => { + console.log('[Remux] Client disconnected, killing FFmpeg process'); + ffmpeg.kill('SIGKILL'); + }); + + // Handle process exit + ffmpeg.on('exit', (code) => { + if (code !== null && code !== 0 && code !== 255) { + console.error(`[Remux] FFmpeg exited with code ${code}`); + } + }); + + // Handle spawn errors + ffmpeg.on('error', (err) => { + console.error('[Remux] Failed to spawn FFmpeg:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Remux failed to start' }); + } + }); +}); + +module.exports = router; diff --git a/server/routes/settings.js b/server/routes/settings.js new file mode 100644 index 0000000..c9b7cf2 --- /dev/null +++ b/server/routes/settings.js @@ -0,0 +1,111 @@ +const express = require('express'); +const router = express.Router(); +const { settings, getDefaultSettings } = require('../db'); +const syncService = require('../services/syncService'); + +/** + * Get all settings + * GET /api/settings + */ +router.get('/', async (req, res) => { + try { + const currentSettings = await settings.get(); + res.json(currentSettings); + } catch (err) { + console.error('Error getting settings:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Update settings (partial update) + * PUT /api/settings + */ +router.put('/', async (req, res) => { + try { + const updates = req.body; + const updatedSettings = await settings.update(updates); + + // If sync interval changed, restart the server-side sync timer + if (updates.epgRefreshInterval !== undefined) { + syncService.restartSyncTimer().catch(console.error); + } + + res.json(updatedSettings); + } catch (err) { + console.error('Error updating settings:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Reset settings to defaults + * DELETE /api/settings + */ +router.delete('/', async (req, res) => { + try { + const defaultSettings = await settings.reset(); + res.json(defaultSettings); + } catch (err) { + console.error('Error resetting settings:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Get default settings (for reference) + * GET /api/settings/defaults + */ +router.get('/defaults', (req, res) => { + res.json(getDefaultSettings()); +}); + +/** + * Get sync status (last sync time) + * GET /api/settings/sync-status + */ +router.get('/sync-status', (req, res) => { + const lastSyncTime = syncService.getLastSyncTime(); + res.json({ + lastSyncTime: lastSyncTime ? lastSyncTime.toISOString() : null + }); +}); + +/** + * Get hardware capabilities (GPU acceleration support) + * GET /api/settings/hw-info + */ +router.get('/hw-info', async (req, res) => { + try { + const hwDetect = require('../services/hwDetect'); + let capabilities = hwDetect.getCapabilities(); + + // If not yet detected, run detection now + if (!capabilities) { + capabilities = await hwDetect.detect(); + } + + res.json(capabilities); + } catch (err) { + console.error('Error getting hardware info:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * Refresh hardware detection (re-probe GPUs) + * POST /api/settings/hw-info/refresh + */ +router.post('/hw-info/refresh', async (req, res) => { + try { + const hwDetect = require('../services/hwDetect'); + const capabilities = await hwDetect.refresh(); + res.json(capabilities); + } catch (err) { + console.error('Error refreshing hardware info:', err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; + diff --git a/server/routes/sources.js b/server/routes/sources.js new file mode 100644 index 0000000..37dae77 --- /dev/null +++ b/server/routes/sources.js @@ -0,0 +1,323 @@ +const express = require('express'); +const router = express.Router(); +const { sources } = require('../db'); +const { getDb } = require('../db/sqlite'); +const xtreamApi = require('../services/xtreamApi'); +const syncService = require('../services/syncService'); +const m3uParser = require('../services/m3uParser'); +const { requireAuth, requireAdmin, isAdminRole } = require('../auth'); +const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess'); + +router.use(requireAuth); + +function sanitizeSourceList(list) { + return list.map((s) => ({ + ...s, + password: s.password ? 'β€’β€’β€’β€’β€’β€’β€’β€’' : null + })); +} + +function parseOwnerId(body) { + const raw = body.ownerId; + if (raw === undefined || raw === null || raw === '') return null; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? null : n; +} + +// Estimate by URL (must stay before /:id routes) +const M3U_LARGE_THRESHOLD = 50000; + +router.post('/estimate', async (req, res) => { + try { + const { url, type } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + if (type !== 'm3u') { + return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD }); + } + + const count = await m3uParser.countEntries(url); + + res.json({ + count, + needsWarning: count > M3U_LARGE_THRESHOLD, + threshold: M3U_LARGE_THRESHOLD + }); + } catch (err) { + console.error('Error estimating M3U size:', err); + res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message }); + } +}); + +// Global sync β€” admin only +router.post('/sync-all', requireAdmin, async (req, res) => { + try { + syncService.syncAll().catch(console.error); + res.json({ success: true, message: 'Global sync started' }); + } catch (err) { + console.error('Error starting global sync:', err); + res.status(500).json({ error: 'Failed to start global sync' }); + } +}); + +// Get all sources (filtered by access) +router.get('/', async (req, res) => { + try { + const allSources = await sources.getAll(); + const filtered = allSources.filter((s) => canUserAccessSource(req.user, s)); + res.json(sanitizeSourceList(filtered)); + } catch (err) { + console.error('Error getting sources:', err); + res.status(500).json({ error: 'Failed to get sources' }); + } +}); + +// Get sync status (filtered for non-admins) +router.get('/status', async (req, res) => { + try { + const db = getDb(); + let statuses = db.prepare('SELECT * FROM sync_status').all(); + if (req.user.role !== 'admin') { + const allowed = new Set(await getAccessibleSourceIds(req, sources)); + statuses = statuses.filter((row) => allowed.has(row.source_id)); + } + res.json(statuses); + } catch (err) { + console.error('Error getting sync status:', err); + res.status(500).json({ error: 'Failed to get sync status' }); + } +}); + +// Get sources by type +router.get('/type/:type', async (req, res) => { + try { + const typeSources = await sources.getByType(req.params.type); + const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s)); + res.json(sanitizeSourceList(filtered)); + } catch (err) { + console.error('Error getting sources by type:', err); + res.status(500).json({ error: 'Failed to get sources' }); + } +}); + +// Estimate by source ID (before bare GET /:id β€” same base path length) +router.get('/:id/estimate', async (req, res) => { + try { + const source = await sources.getById(req.params.id); + if (!source) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, source)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + if (source.type !== 'm3u') { + return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD }); + } + + const count = await m3uParser.countEntries(source.url); + + res.json({ + count, + needsWarning: count > M3U_LARGE_THRESHOLD, + threshold: M3U_LARGE_THRESHOLD + }); + } catch (err) { + console.error('Error estimating M3U size:', err); + res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message }); + } +}); + +// Get single source +router.get('/:id', async (req, res) => { + try { + const source = await sources.getById(req.params.id); + if (!source) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, source)) { + return res.status(403).json({ error: 'No access to this source' }); + } + res.json(source); + } catch (err) { + console.error('Error getting source:', err); + res.status(500).json({ error: 'Failed to get source' }); + } +}); + +// Create source +router.post('/', async (req, res) => { + try { + const { type, name, url, username, password } = req.body; + + if (!type || !name || !url) { + return res.status(400).json({ error: 'Type, name, and URL are required' }); + } + + if (!['xtream', 'm3u', 'epg'].includes(type)) { + return res.status(400).json({ error: 'Invalid source type' }); + } + + let ownerId = null; + if (isAdminRole(req.user)) { + ownerId = parseOwnerId(req.body); + } else { + const uid = req.user.id != null ? Number(req.user.id) : NaN; + ownerId = Number.isNaN(uid) ? null : uid; + } + + const source = await sources.create({ type, name, url, username, password, ownerId }); + syncService.syncSource(source.id).catch(console.error); + res.status(201).json(source); + } catch (err) { + console.error('Error creating source:', err); + res.status(500).json({ error: 'Failed to create source' }); + } +}); + +// Update source +router.put('/:id', async (req, res) => { + try { + const existing = await sources.getById(req.params.id); + if (!existing) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, existing)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + const { name, url, username, password } = req.body; + const updates = { + name: name || existing.name, + url: url || existing.url, + username: username !== undefined ? username : existing.username, + password: password !== undefined ? password : existing.password + }; + if (isAdminRole(req.user) && req.body.ownerId !== undefined) { + updates.ownerId = parseOwnerId(req.body); + } + + const updated = await sources.update(req.params.id, updates); + syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error); + res.json(updated); + } catch (err) { + console.error('Error updating source:', err); + res.status(500).json({ error: 'Failed to update source' }); + } +}); + +// Delete source +router.delete('/:id', async (req, res) => { + try { + const sourceId = parseInt(req.params.id, 10); + const existing = await sources.getById(sourceId); + if (!existing) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, existing)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + const db = getDb(); + const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?'); + const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?'); + const deleteEpg = db.prepare('DELETE FROM epg_programs WHERE source_id = ?'); + const deleteSyncStatus = db.prepare('DELETE FROM sync_status WHERE source_id = ?'); + + const catResult = deleteCategories.run(sourceId); + const itemResult = deleteItems.run(sourceId); + const epgResult = deleteEpg.run(sourceId); + deleteSyncStatus.run(sourceId); + + console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`); + + await sources.delete(sourceId); + + res.json({ success: true }); + } catch (err) { + console.error('Error deleting source:', err); + res.status(500).json({ error: 'Failed to delete source' }); + } +}); + +// Toggle source enabled/disabled +router.post('/:id/toggle', async (req, res) => { + try { + const existing = await sources.getById(req.params.id); + if (!existing) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, existing)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + const updated = await sources.toggleEnabled(req.params.id); + if (!updated) { + return res.status(404).json({ error: 'Source not found' }); + } + + if (updated.enabled) { + syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error); + } + + res.json(updated); + } catch (err) { + console.error('Error toggling source:', err); + res.status(500).json({ error: 'Failed to toggle source' }); + } +}); + +// Manual Sync +router.post('/:id/sync', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const source = await sources.getById(id); + if (!source) return res.status(404).json({ error: 'Source not found' }); + if (!canUserAccessSource(req.user, source)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + syncService.syncSource(id).catch(console.error); + + res.json({ success: true, message: 'Sync started' }); + } catch (err) { + console.error('Error starting sync:', err); + res.status(500).json({ error: 'Failed to start sync' }); + } +}); + +// Test source connection +router.post('/:id/test', async (req, res) => { + try { + const source = await sources.getById(req.params.id); + if (!source) { + return res.status(404).json({ error: 'Source not found' }); + } + if (!canUserAccessSource(req.user, source)) { + return res.status(403).json({ error: 'No access to this source' }); + } + + if (source.type === 'xtream') { + const result = await xtreamApi.authenticate(source.url, source.username, source.password); + res.json({ success: true, data: result }); + } else if (source.type === 'm3u') { + const response = await fetch(source.url); + const text = await response.text(); + const isValid = text.includes('#EXTM3U'); + res.json({ success: isValid, message: isValid ? 'Valid M3U playlist' : 'Invalid M3U format' }); + } else if (source.type === 'epg') { + const response = await fetch(source.url); + const text = await response.text(); + const isValid = text.includes(' { + const { url, index } = req.query; + + if (!url || index === undefined) { + return res.status(400).json({ error: 'URL and index parameters are required' }); + } + + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; + // console.log(`[Subtitle] Extracting track ${index} from: ${url}`); + + const args = [ + '-hide_banner', + '-loglevel', 'warning', + '-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + '-probesize', '5000000', + '-analyzeduration', '5000000', + '-i', url, + '-map', `0:${index}`, + '-c:s', 'webvtt', + '-f', 'webvtt', + '-' + ]; + + const ffmpeg = spawn(ffmpegPath, args); + + res.setHeader('Content-Type', 'text/vtt'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + // Pipe stdout to response + ffmpeg.stdout.pipe(res); + + ffmpeg.stderr.on('data', (data) => { + // console.error(`[Subtitle FFmpeg] ${data}`); + }); + + req.on('close', () => { + ffmpeg.kill('SIGKILL'); + }); + + ffmpeg.on('error', (err) => { + console.error('[Subtitle] Failed to spawn FFmpeg:', err); + if (!res.headersSent) { + res.status(500).send('Subtitle extraction failed'); + } + }); +}); + +module.exports = router; diff --git a/server/routes/transcode.js b/server/routes/transcode.js new file mode 100644 index 0000000..76f0be5 --- /dev/null +++ b/server/routes/transcode.js @@ -0,0 +1,331 @@ +const express = require('express'); +const router = express.Router(); +const { spawn, execFile } = require('child_process'); +const path = require('path'); +const fs = require('fs').promises; +const db = require('../db'); +const transcodeSession = require('../services/transcodeSession'); + +/** + * Probe a stream URL with FFprobe to get its total duration in seconds. + * Times out quickly (8 s) so it doesn't block session creation for long. + */ +function probeDuration(url, ffprobePath, userAgent) { + return new Promise((resolve) => { + const args = [ + '-v', 'quiet', + '-print_format', 'json', + '-show_entries', 'format=duration', + '-user_agent', userAgent || 'Mozilla/5.0', + '-probesize', '3000000', + '-analyzeduration', '2000000', + url + ]; + const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (err, stdout) => { + if (err) { resolve(0); return; } + try { + const data = JSON.parse(stdout); + const dur = parseFloat(data?.format?.duration); + resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0); + } catch { resolve(0); } + }); + // Ensure the process is killed if execFile timeout fires + proc.on('error', () => resolve(0)); + }); +} + +/** + * Transcode Routes + * + * Direct streaming (backward compatible): + * GET /api/transcode?url=... + * + * HLS session-based (new, supports seeking): + * POST /api/transcode/session - Create new session + * GET /api/transcode/:id/stream.m3u8 - Get HLS playlist + * GET /api/transcode/:id/:segment.ts - Get segment file + * DELETE /api/transcode/:id - Stop and cleanup session + * GET /api/transcode/sessions - List all sessions (debug) + */ + +// Start session cleanup interval +transcodeSession.startCleanupInterval(); + +/** + * Create a new transcode session + * POST /api/transcode/session + * Body: { url: string, seekOffset?: number } + */ +router.post('/session', async (req, res) => { + const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; + const settings = await db.settings.get(); + const userAgent = db.getUserAgent(settings); + + try { + const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe'); + + // Probe duration and start session in parallel (don't block on duration) + const [session, durationSec] = await Promise.all([ + transcodeSession.createSession(url, { + ffmpegPath, + userAgent, + seekOffset: seekOffset || 0, + hwEncoder: settings.hwEncoder || 'software', + maxResolution: settings.maxResolution || '1080p', + quality: settings.quality || 'medium', + audioMixPreset: settings.audioMixPreset || 'auto', + upscaleEnabled: settings.upscaleEnabled || false, + upscaleMethod: settings.upscaleMethod || 'hardware', + upscaleTarget: settings.upscaleTarget || '1080p', + videoMode: videoMode, + videoCodec: videoCodec, + audioCodec: audioCodec, + audioChannels: audioChannels + }).then(async s => { await s.start(); return s; }), + probeDuration(url, ffprobePath, userAgent) + ]); + + // Wait for playlist to be ready (first segments generated) + const ready = await session.waitForPlaylist(15000); + + if (!ready) { + await transcodeSession.removeSession(session.id); + return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' }); + } + + console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`); + + res.json({ + sessionId: session.id, + playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, + status: session.status, + durationSec: durationSec || 0 + }); + + } catch (err) { + console.error('[Transcode] Session creation failed:', err); + res.status(500).json({ error: 'Failed to create session', details: err.message }); + } +}); + +/** + * Get HLS playlist for a session + * GET /api/transcode/:sessionId/stream.m3u8 + */ +router.get('/:sessionId/stream.m3u8', async (req, res) => { + const { sessionId } = req.params; + const session = transcodeSession.getSession(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const playlist = await session.getPlaylist(); + if (!playlist) { + return res.status(404).json({ error: 'Playlist not ready' }); + } + + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(playlist); +}); + +/** + * Get a segment file for a session + * GET /api/transcode/:sessionId/:segment.ts + */ +router.get('/:sessionId/:segment', async (req, res) => { + const { sessionId, segment } = req.params; + + // Only handle .ts files + if (!segment.endsWith('.ts')) { + return res.status(404).json({ error: 'Invalid segment' }); + } + + const session = transcodeSession.getSession(sessionId); + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + const segmentPath = await session.getSegment(segment); + if (!segmentPath) { + return res.status(404).json({ error: 'Segment not found' }); + } + + res.setHeader('Content-Type', 'video/MP2T'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache forever (immutable) + res.sendFile(segmentPath); +}); + +/** + * Stop and cleanup a session + * DELETE /api/transcode/:sessionId + */ +router.delete('/:sessionId', async (req, res) => { + const { sessionId } = req.params; + + try { + await transcodeSession.removeSession(sessionId); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to remove session', details: err.message }); + } +}); + +/** + * Heartbeat β€” keeps a session alive while the player is active. + * POST /api/transcode/:sessionId/heartbeat + */ +router.post('/:sessionId/heartbeat', (req, res) => { + const session = transcodeSession.getSession(req.params.sessionId); + if (!session) return res.status(404).json({ error: 'Session not found' }); + session.lastAccess = Date.now(); + res.json({ ok: true }); +}); + +/** + * Stop session via POST (for sendBeacon on page close) + * POST /api/transcode/:sessionId/stop + */ +router.post('/:sessionId/stop', async (req, res) => { + try { + await transcodeSession.removeSession(req.params.sessionId); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to remove session', details: err.message }); + } +}); + +/** + * List all active sessions (for debugging) + * GET /api/transcode/sessions + */ +router.get('/sessions', (req, res) => { + res.json(transcodeSession.getAllSessions()); +}); + +/** + * Direct transcode stream (backward compatible, no seeking) + * GET /api/transcode?url=... + * + * Transcodes audio to AAC for browser compatibility while passing video through. + * This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode. + */ +router.get('/', async (req, res) => { + const { url } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL parameter is required' }); + } + + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; + + // Get User-Agent from settings + const settings = await db.settings.get(); + const userAgent = db.getUserAgent(settings); + + console.log(`[Transcode] Starting transcoding for: ${url}`); + console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`); + console.log(`[Transcode] Using binary: ${ffmpegPath}`); + + // FFmpeg arguments for transcoding + // Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3) + // Also works for live streams with ad stitching (Pluto TV, etc.) + const args = [ + '-hide_banner', + '-loglevel', 'warning', + '-user_agent', userAgent, + // Faster startup - reduced probe/analyze for quicker first bytes + '-probesize', '2000000', // 2MB (reduced from 5MB) + '-analyzeduration', '3000000', // 3 seconds (reduced from 10s) + // Error resilience: generate timestamps, discard corrupt packets + '-fflags', '+genpts+discardcorrupt+nobuffer', + // Ignore errors in stream and continue + '-err_detect', 'ignore_err', + // Limit max demux delay to prevent buffering issues + '-max_delay', '2000000', + // Reconnect settings for network drops (useful for live streams) + '-reconnect', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '3', + // Prevent Range/HEAD requests that some providers reject with 405 + '-seekable', '0', + '-i', url, + // Map only first video and audio stream (avoid subtitle streams causing issues) + '-map', '0:v:0', + '-map', '0:a:0?', // ? makes audio optional if not present + // Video: passthrough (no re-encoding = fast!) + '-c:v', 'copy', + // Audio: Transcode to browser-compatible AAC + '-c:a', 'aac', + '-ar', '48000', + '-b:a', '192k', + // Handle async audio/video using async filter + '-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0', + // Timestamp handling + '-fps_mode', 'passthrough', + '-async', '1', + '-max_muxing_queue_size', '2048', + // Fragmented MP4 for streaming (browser-compatible) + '-f', 'mp4', + '-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart', + '-flush_packets', '1', // Send data immediately + '-' // Output to stdout + ]; + + console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`); + + let ffmpeg; + try { + ffmpeg = spawn(ffmpegPath, args); + } catch (spawnErr) { + console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr); + return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message }); + } + + // Collect stderr for error reporting + let stderrBuffer = ''; + + // Set headers for fragmented MP4 + res.setHeader('Content-Type', 'video/mp4'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + // Pipe stdout to response + ffmpeg.stdout.pipe(res); + + // Log stderr (useful for debugging transcoding failures) + ffmpeg.stderr.on('data', (data) => { + const msg = data.toString(); + stderrBuffer += msg; + console.log(`[FFmpeg] ${msg}`); + }); + + // Cleanup on client disconnect + req.on('close', () => { + console.log('[Transcode] Client disconnected, killing FFmpeg process'); + ffmpeg.kill('SIGKILL'); + }); + + // Handle process exit + ffmpeg.on('exit', (code) => { + if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill + console.error(`[Transcode] FFmpeg exited with code ${code}`); + } + }); + + // Handle spawn errors + ffmpeg.on('error', (err) => { + console.error('[Transcode] Failed to spawn FFmpeg:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Transcoding failed to start' }); + } + }); +}); + +module.exports = router; + diff --git a/server/routes/users.js b/server/routes/users.js new file mode 100644 index 0000000..3393afb --- /dev/null +++ b/server/routes/users.js @@ -0,0 +1,186 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const auth = require('../auth'); + +/** + * Get all users (admin only) + * GET /api/users + */ +router.get('/', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const data = await db.loadDb(); + const users = (data.users || []).map(u => ({ + id: u.id, + username: u.username, + role: u.role, + createdAt: u.createdAt + })); + res.json(users); + } catch (err) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Create user (admin only) + * POST /api/users + */ +router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { username, password, role } = req.body; + + if (!username || !password || !role) { + return res.status(400).json({ error: 'Username, password, and role required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be admin or viewer' }); + } + + const data = await db.loadDb(); + + // Check if username exists + if (data.users?.some(u => u.username === username)) { + return res.status(400).json({ error: 'Username already exists' }); + } + + // Create user + const passwordHash = await auth.hashPassword(password); + const newUser = { + id: data.nextUserId || (data.users?.length || 0) + 1, + username, + passwordHash, + role, + createdAt: new Date().toISOString() + }; + + data.users = data.users || []; + data.users.push(newUser); + data.nextUserId = newUser.id + 1; + + await db.saveDb(data); + + res.json({ + id: newUser.id, + username: newUser.username, + role: newUser.role, + createdAt: newUser.createdAt + }); + } catch (err) { + console.error('Create user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Update user (admin only) + * PUT /api/users/:id + */ +router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const userId = parseInt(req.params.id); + const { username, password, role } = req.body; + + const data = await db.loadDb(); + const userIndex = data.users?.findIndex(u => u.id === userId); + + if (userIndex === -1 || userIndex === undefined) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = data.users[userIndex]; + + // Update username if provided + if (username && username !== user.username) { + // Check if new username exists + if (data.users.some(u => u.username === username && u.id !== userId)) { + return res.status(400).json({ error: 'Username already exists' }); + } + user.username = username; + } + + // Update password if provided + if (password) { + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + user.passwordHash = await auth.hashPassword(password); + } + + // Update role if provided + if (role) { + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be admin or viewer' }); + } + + // Prevent removing last admin + if (user.role === 'admin' && role !== 'admin') { + const adminCount = data.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot remove last admin user' }); + } + } + + user.role = role; + } + + await db.saveDb(data); + + res.json({ + id: user.id, + username: user.username, + role: user.role, + createdAt: user.createdAt + }); + } catch (err) { + console.error('Update user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Delete user (admin only) + * DELETE /api/users/:id + */ +router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const userId = parseInt(req.params.id); + + const data = await db.loadDb(); + const userIndex = data.users?.findIndex(u => u.id === userId); + + if (userIndex === -1 || userIndex === undefined) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = data.users[userIndex]; + + // Prevent deleting yourself + if (user.id === req.session.userId) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + // Prevent deleting last admin + if (user.role === 'admin') { + const adminCount = data.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot delete last admin user' }); + } + } + + data.users.splice(userIndex, 1); + await db.saveDb(data); + + res.json({ success: true }); + } catch (err) { + console.error('Delete user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +module.exports = router; diff --git a/server/services/cache.js b/server/services/cache.js new file mode 100644 index 0000000..4b09a76 --- /dev/null +++ b/server/services/cache.js @@ -0,0 +1,151 @@ +/** + * File-based Cache Service + * Stores cached data as JSON files in data/cache/ + */ + +const fs = require('fs'); +const path = require('path'); + +// Cache directory +const cacheDir = path.join(__dirname, '..', '..', 'data', 'cache'); + +// Ensure cache directories exist +function ensureCacheDir(type, sourceId) { + const dir = path.join(cacheDir, type, String(sourceId)); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +// Get cache file path +function getCachePath(type, sourceId, key) { + const dir = ensureCacheDir(type, sourceId); + // Sanitize key for filename + const safeKey = String(key || 'default').replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(dir, `${safeKey}.json`); +} + +/** + * Get cached data if not expired + * @param {string} type - Cache type (epg, m3u, xtream) + * @param {number|string} sourceId - Source ID + * @param {string} key - Cache key (e.g., action name) + * @param {number} maxAgeMs - Maximum age in milliseconds + * @returns {any|null} - Cached data or null if expired/missing + */ +function get(type, sourceId, key, maxAgeMs) { + try { + const cachePath = getCachePath(type, sourceId, key); + + if (!fs.existsSync(cachePath)) { + return null; + } + + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + const age = Date.now() - cached.timestamp; + + if (age > maxAgeMs) { + return null; // Expired + } + + return cached.data; + } catch (err) { + console.warn(`Cache read error for ${type}/${sourceId}/${key}:`, err.message); + return null; + } +} + +/** + * Store data in cache + * @param {string} type - Cache type + * @param {number|string} sourceId - Source ID + * @param {string} key - Cache key + * @param {any} data - Data to cache + */ +function set(type, sourceId, key, data) { + try { + const cachePath = getCachePath(type, sourceId, key); + const cached = { + timestamp: Date.now(), + data: data + }; + fs.writeFileSync(cachePath, JSON.stringify(cached)); + } catch (err) { + console.error(`Cache write error for ${type}/${sourceId}/${key}:`, err.message); + } +} + +/** + * Clear specific cache entry + */ +function clear(type, sourceId, key) { + try { + const cachePath = getCachePath(type, sourceId, key); + if (fs.existsSync(cachePath)) { + fs.unlinkSync(cachePath); + } + } catch (err) { + console.warn(`Cache clear error:`, err.message); + } +} + +/** + * Clear all cache for a source + */ +function clearSource(sourceId) { + try { + const types = ['epg', 'm3u', 'xtream']; + for (const type of types) { + const dir = path.join(cacheDir, type, String(sourceId)); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true }); + } + } + } catch (err) { + console.warn(`Cache clear source error:`, err.message); + } +} + +/** + * Clear all cache + */ +function clearAll() { + try { + if (fs.existsSync(cacheDir)) { + fs.rmSync(cacheDir, { recursive: true }); + } + } catch (err) { + console.warn(`Cache clear all error:`, err.message); + } +} + +/** + * Get cache info for debugging + */ +function getInfo(type, sourceId, key) { + try { + const cachePath = getCachePath(type, sourceId, key); + if (!fs.existsSync(cachePath)) { + return null; + } + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + const stats = fs.statSync(cachePath); + return { + timestamp: cached.timestamp, + age: Date.now() - cached.timestamp, + size: stats.size + }; + } catch (err) { + return null; + } +} + +module.exports = { + get, + set, + clear, + clearSource, + clearAll, + getInfo +}; diff --git a/server/services/epgParser.js b/server/services/epgParser.js new file mode 100644 index 0000000..9b75fff --- /dev/null +++ b/server/services/epgParser.js @@ -0,0 +1,448 @@ +/** + * EPG (XMLTV) Parser (Streaming) + * Parses XMLTV format EPG data and extracts channel/programme information using streaming XML parser + */ + +const sax = require('sax'); +const zlib = require('zlib'); +const { Readable } = require('stream'); + +/** + * Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ) + * @param {string} dateStr - XMLTV format date string + * @returns {Date} + */ +function parseXmltvDate(dateStr) { + if (!dateStr) return null; + + // Format: 20231225120000 +0000 + const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/); + if (!match) { + // Try ISO format fallback + return new Date(dateStr); + } + + const [, year, month, day, hour, minute, second, tz] = match; + let isoStr = `${year}-${month}-${day}T${hour}:${minute}:${second}`; + + if (tz) { + const tzHours = tz.substring(0, 3); + const tzMins = tz.substring(3); + isoStr += `${tzHours}:${tzMins}`; + } else { + isoStr += 'Z'; + } + + return new Date(isoStr); +} + +/** + * Parse XMLTV content (Stream or String) + * @param {Readable|string} input - XMLTV content as Stream or String + * @returns {Promise<{ channels: Array, programmes: Array }>} + */ +function parse(input) { + return new Promise((resolve, reject) => { + const channels = []; + const programmes = []; + + const saxStream = sax.createStream(true, { trim: true, normalize: true }); // strict mode + + let currentTag = null; + let currentObject = null; + let textBuffer = ''; + + saxStream.on('error', function (e) { + // clear the error + this._parser.error = null; + this._parser.resume(); + console.warn('XML Parse Warning:', e.message); + }); + + saxStream.on('opentag', function (node) { + currentTag = node.name; + const attr = node.attributes; + + if (currentTag === 'channel') { + currentObject = { + id: attr.id, + name: null, // Will be populated by display-name tag + icon: null, + url: null + }; + } else if (currentTag === 'programme') { + currentObject = { + channelId: attr.channel, + start: parseXmltvDate(attr.start), + stop: parseXmltvDate(attr.stop), + title: null, + subtitle: null, + description: null, + category: [], + icon: null, + date: null, + episodeNum: null + }; + } else if (currentTag === 'icon') { + if (currentObject) { + currentObject.icon = attr.src; + } + } + textBuffer = ''; + }); + + saxStream.on('text', function (text) { + textBuffer += text; + }); + + saxStream.on('cdata', function (text) { + textBuffer += text; + }); + + saxStream.on('closetag', function (tagName) { + if (tagName === 'channel') { + if (currentObject) channels.push(currentObject); + currentObject = null; + } else if (tagName === 'programme') { + if (currentObject) programmes.push(currentObject); + currentObject = null; + } else if (currentObject) { + // Handle properties within objects + switch (tagName) { + case 'display-name': // channel name + if (!currentObject.name) currentObject.name = textBuffer; + break; + case 'url': // channel url + currentObject.url = textBuffer; + break; + case 'title': + currentObject.title = textBuffer; + break; + case 'sub-title': + currentObject.subtitle = textBuffer; + break; + case 'desc': + currentObject.description = textBuffer; + break; + case 'category': + if (textBuffer && currentObject.category) currentObject.category.push(textBuffer); + break; + case 'date': + currentObject.date = textBuffer; + break; + case 'episode-num': + // Prefer system "xmltv_ns" or just take text + // Complex episode parsing logic can go here if needed + currentObject.episodeNum = textBuffer; + break; + } + } + }); + + saxStream.on('end', function () { + resolve({ channels, programmes }); + }); + + // Handle input type + if (typeof input === 'string') { + const inputStream = Readable.from([input]); + inputStream.pipe(saxStream); + } else { + input.pipe(saxStream); + } + }); +} + +/** + * Get programmes for a specific channel + */ +function getProgrammesForChannel(programmes, channelId) { + return programmes.filter(p => p.channelId === channelId); +} + +/** + * Get current and upcoming programmes for a channel + */ +function getCurrentAndUpcoming(programmes, channelId, count = 5) { + const now = new Date(); + const channelProgrammes = getProgrammesForChannel(programmes, channelId); + + // Sort by start time + channelProgrammes.sort((a, b) => a.start - b.start); + + // Find current and upcoming + const current = channelProgrammes.find(p => p.start <= now && p.stop > now); + const upcoming = channelProgrammes + .filter(p => p.start > now) + .slice(0, count); + + return { current, upcoming }; +} + +/** + * Fetch and parse XMLTV from URL + */ +async function fetchAndParse(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`); + } + + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + stream = Readable.fromWeb(response.body); + } else { + stream = Readable.from([]); + } + + // Check for GZIP + // Note: We can't easily check for magic bytes on a stream without buffering. + // We'll rely on response headers or file extension mostly, or try to peek. + // For now, let's assume if content-encoding is gzip OR url ends in .gz + + // However, undici/fetch usually handles 'Content-Encoding: gzip' automatically transparently. + // We only need to manually gunzip if the server serves it as application/octet-stream but it's actually gzipped, + // or if it's a .gz file download. + + // A robust way for streams is checking magic bytes, but that requires peeking. + // Simplified approach: try to pipe through gunzip if the URL indicates it. + + const isGzipped = url.endsWith('.gz') || (response.headers.get('content-type') || '').includes('gzip'); + + if (isGzipped) { + const gunzip = zlib.createGunzip(); + stream.pipe(gunzip); + return parse(gunzip); + } + + // In the previous version we read magic bytes. + // To support that with streams we'd need a peek stream. + // For now let's trust the transparent decompression of fetch or the URL. + + return parse(stream); +} + +/** + * Streaming EPG parser that yields batches of programmes (memory-efficient) + * Channels are collected and returned with the first batch, then programmes are yielded in batches. + * + * @param {string} url - XMLTV URL + * @param {number} batchSize - Number of programmes per batch (default: 1000) + * @yields {{ channels: Array|null, programmes: Array, isLast: boolean }} + */ +async function* fetchAndParseStreaming(url, batchSize = 1000) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`); + } + + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + stream = Readable.fromWeb(response.body); + } else { + stream = Readable.from([]); + } + + const isGzipped = url.endsWith('.gz') || (response.headers.get('content-type') || '').includes('gzip'); + + if (isGzipped) { + const gunzip = zlib.createGunzip(); + stream.pipe(gunzip); + stream = gunzip; + } + + // Use async iterator pattern with SAX + yield* parseStreaming(stream, batchSize); +} + +/** + * Parse XMLTV as streaming async generator + * @param {Readable} input - XMLTV stream + * @param {number} batchSize - Number of programmes per batch + * @yields {{ channels: Array|null, programmes: Array, isLast: boolean }} + */ +async function* parseStreaming(input, batchSize = 1000) { + const channels = []; + let programmeBatch = []; + let channelsYielded = false; + + // We need to convert SAX events to an async iterator + // This requires collecting events and yielding when batch is full + + const saxStream = sax.createStream(true, { trim: true, normalize: true }); + + let currentTag = null; + let currentObject = null; + let textBuffer = ''; + let resolveNext = null; + let pendingBatch = null; + let ended = false; + let error = null; + + saxStream.on('error', function (e) { + this._parser.error = null; + this._parser.resume(); + console.warn('XML Parse Warning:', e.message); + }); + + saxStream.on('opentag', function (node) { + currentTag = node.name; + const attr = node.attributes; + + if (currentTag === 'channel') { + currentObject = { + id: attr.id, + name: null, + icon: null, + url: null + }; + } else if (currentTag === 'programme') { + currentObject = { + channelId: attr.channel, + start: parseXmltvDate(attr.start), + stop: parseXmltvDate(attr.stop), + title: null, + subtitle: null, + description: null, + category: [], + icon: null, + date: null, + episodeNum: null + }; + } else if (currentTag === 'icon') { + if (currentObject) { + currentObject.icon = attr.src; + } + } + textBuffer = ''; + }); + + saxStream.on('text', function (text) { + textBuffer += text; + }); + + saxStream.on('cdata', function (text) { + textBuffer += text; + }); + + saxStream.on('closetag', function (tagName) { + if (tagName === 'channel') { + if (currentObject) channels.push(currentObject); + currentObject = null; + } else if (tagName === 'programme') { + if (currentObject) { + programmeBatch.push(currentObject); + + // Check if we should yield a batch + if (programmeBatch.length >= batchSize) { + const batch = { + channels: !channelsYielded ? channels : null, + programmes: programmeBatch, + isLast: false + }; + channelsYielded = true; + programmeBatch = []; + + if (resolveNext) { + resolveNext(batch); + resolveNext = null; + } else { + pendingBatch = batch; + } + } + } + currentObject = null; + } else if (currentObject) { + switch (tagName) { + case 'display-name': + if (!currentObject.name) currentObject.name = textBuffer; + break; + case 'url': + currentObject.url = textBuffer; + break; + case 'title': + currentObject.title = textBuffer; + break; + case 'sub-title': + currentObject.subtitle = textBuffer; + break; + case 'desc': + currentObject.description = textBuffer; + break; + case 'category': + if (textBuffer && currentObject.category) currentObject.category.push(textBuffer); + break; + case 'date': + currentObject.date = textBuffer; + break; + case 'episode-num': + currentObject.episodeNum = textBuffer; + break; + } + } + }); + + saxStream.on('end', function () { + ended = true; + // Yield final batch + const batch = { + channels: !channelsYielded ? channels : null, + programmes: programmeBatch, + isLast: true + }; + if (resolveNext) { + resolveNext(batch); + resolveNext = null; + } else { + pendingBatch = batch; + } + }); + + saxStream.on('error', function (e) { + error = e; + if (resolveNext) { + resolveNext(null); + } + }); + + // Start piping + input.pipe(saxStream); + + // Yield batches as they become available + while (!ended || pendingBatch) { + if (pendingBatch) { + const batch = pendingBatch; + pendingBatch = null; + yield batch; + if (batch.isLast) break; + } else if (!ended) { + // Wait for next batch + const batch = await new Promise(resolve => { + resolveNext = resolve; + }); + if (batch) { + yield batch; + if (batch.isLast) break; + } + } + } + + if (error) { + throw error; + } +} + +module.exports = { + parse, + parseXmltvDate, + fetchAndParse, + fetchAndParseStreaming, + parseStreaming, + getProgrammesForChannel, + getCurrentAndUpcoming +}; + diff --git a/server/services/hwDetect.js b/server/services/hwDetect.js new file mode 100644 index 0000000..adcee43 --- /dev/null +++ b/server/services/hwDetect.js @@ -0,0 +1,283 @@ +/** + * Hardware Detection Service + * + * Detects available hardware acceleration capabilities: + * - NVIDIA GPU (NVENC/NVDEC via nvidia-smi) + * - VAAPI (Linux integrated GPU acceleration) + * - QuickSync (Intel GPU acceleration) + * + * Results are cached at startup and exposed via API. + */ + +const { execSync, exec } = require('child_process'); +const os = require('os'); + +// Cache detection results +let hwCapabilities = null; + +/** + * NVIDIA GPU compute capability requirements for codec support + * Source: https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new + */ +const NVDEC_MIN_COMPUTE = { + h264: 3.0, // Kepler+ + hevc: 5.0, // Maxwell GM206+ + av1: 8.0, // Ada Lovelace+ +}; + +/** + * Detect NVIDIA GPU and its capabilities + */ +async function detectNvidia() { + try { + // Query GPU info via nvidia-smi + const result = execSync( + 'nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader', + { timeout: 5000, encoding: 'utf-8', windowsHide: true } + ); + + const lines = result.trim().split('\n'); + if (lines.length === 0 || !lines[0]) { + return { available: false }; + } + + // Parse "NVIDIA GeForce RTX 3080, 8.6" + const parts = lines[0].split(','); + if (parts.length < 2) { + return { available: false }; + } + + const gpuName = parts[0].trim(); + const computeCap = parseFloat(parts[1].trim()); + + // Determine supported codecs based on compute capability + const supportedCodecs = []; + for (const [codec, minCap] of Object.entries(NVDEC_MIN_COMPUTE)) { + if (computeCap >= minCap) { + supportedCodecs.push(codec); + } + } + + console.log(`[HwDetect] NVIDIA GPU detected: ${gpuName} (compute ${computeCap})`); + console.log(`[HwDetect] NVDEC supported codecs: ${supportedCodecs.join(', ')}`); + + return { + available: true, + name: gpuName, + computeCap, + supportedCodecs, + encoder: 'h264_nvenc', + decoder: 'h264_cuvid' + }; + } catch (err) { + // nvidia-smi not found or no GPU + console.log('[HwDetect] No NVIDIA GPU detected'); + return { available: false }; + } +} + +/** + * Detect VAAPI support (Linux only) + * Checks for /dev/dri/renderD* devices + */ +async function detectVAAPI() { + if (os.platform() !== 'linux') { + return { available: false, reason: 'VAAPI is Linux-only' }; + } + + try { + // Check for render nodes + const result = execSync('ls /dev/dri/renderD* 2>/dev/null', { + timeout: 2000, + encoding: 'utf-8', + shell: true + }); + + const devices = result.trim().split('\n').filter(d => d); + if (devices.length === 0) { + return { available: false, reason: 'No render devices found' }; + } + + // Use first available device + const device = devices[0]; + console.log(`[HwDetect] VAAPI device found: ${device}`); + + return { + available: true, + device, + encoder: 'h264_vaapi', + decoder: 'vaapi' + }; + } catch (err) { + console.log('[HwDetect] VAAPI not available'); + return { available: false }; + } +} + +/** + * Detect Intel QuickSync support + * Checks if FFmpeg can use QSV + */ +async function detectQuickSync() { + try { + // Check if Intel GPU exists + let hasIntelGpu = false; + + if (os.platform() === 'win32') { + // Windows: Check via WMIC + const result = execSync( + 'wmic path win32_VideoController get name', + { timeout: 5000, encoding: 'utf-8', windowsHide: true } + ); + hasIntelGpu = result.toLowerCase().includes('intel'); + } else if (os.platform() === 'linux') { + // Linux: Check lspci + try { + const result = execSync('lspci | grep -i "vga\\|display" | grep -i intel', { + timeout: 5000, + encoding: 'utf-8', + shell: true + }); + hasIntelGpu = result.trim().length > 0; + } catch { + hasIntelGpu = false; + } + } + + if (!hasIntelGpu) { + return { available: false, reason: 'No Intel GPU found' }; + } + + console.log('[HwDetect] Intel GPU detected, QSV may be available'); + + return { + available: true, + encoder: 'h264_qsv', + decoder: 'h264_qsv' + }; + } catch (err) { + console.log('[HwDetect] QuickSync not available'); + return { available: false }; + } +} + +/** + * Detect AMD AMF support (Windows only) + * Linux AMD uses VAAPI (detected separately) + */ +async function detectAMF() { + if (os.platform() !== 'win32') { + // On Linux, AMD GPUs use VAAPI which is detected separately + return { available: false, reason: 'AMF is Windows-only (Linux uses VAAPI)' }; + } + + try { + // Windows: Check via WMIC for AMD/Radeon + const result = execSync( + 'wmic path win32_VideoController get name', + { timeout: 5000, encoding: 'utf-8', windowsHide: true } + ); + + const lowerResult = result.toLowerCase(); + const hasAmdGpu = lowerResult.includes('amd') || lowerResult.includes('radeon'); + + if (!hasAmdGpu) { + return { available: false, reason: 'No AMD GPU found' }; + } + + // Extract GPU name for display + const lines = result.trim().split('\n').filter(l => l.trim()); + let gpuName = 'AMD GPU'; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.toLowerCase().includes('amd') || trimmed.toLowerCase().includes('radeon')) { + gpuName = trimmed; + break; + } + } + + console.log(`[HwDetect] AMD GPU detected: ${gpuName}`); + + return { + available: true, + name: gpuName, + encoder: 'h264_amf', + decoder: 'h264' // AMF has limited decode support, often uses software + }; + } catch (err) { + console.log('[HwDetect] AMF not available'); + return { available: false }; + } +} + +/** + * Detect all hardware capabilities + * Results are cached for performance + */ +async function detect() { + if (hwCapabilities !== null) { + return hwCapabilities; + } + + console.log('[HwDetect] Probing hardware acceleration capabilities...'); + + const [nvidia, vaapi, qsv, amf] = await Promise.all([ + detectNvidia(), + detectVAAPI(), + detectQuickSync(), + detectAMF() + ]); + + // Determine recommended encoder + // On Linux: prefer VAAPI over QSV β€” VAAPI is more reliable in LXC containers + // (QSV may fail to initialize the MFX device even when /dev/dri is passed through) + let recommended = 'software'; + if (nvidia.available) { + recommended = 'nvenc'; + } else if (amf.available) { + recommended = 'amf'; + } else if (vaapi.available) { + recommended = 'vaapi'; + } else if (qsv.available) { + recommended = 'qsv'; + } + + hwCapabilities = { + nvidia, + amf, + vaapi, + qsv, + recommended, + platform: os.platform(), + detectedAt: new Date().toISOString() + }; + + console.log(`[HwDetect] Recommended encoder: ${recommended}`); + + return hwCapabilities; +} + +/** + * Get cached capabilities (or detect if not cached) + */ +function getCapabilities() { + return hwCapabilities; +} + +/** + * Force re-detection (clears cache) + */ +async function refresh() { + hwCapabilities = null; + return detect(); +} + +module.exports = { + detect, + getCapabilities, + refresh, + detectNvidia, + detectAMF, + detectVAAPI, + detectQuickSync +}; diff --git a/server/services/m3uParser.js b/server/services/m3uParser.js new file mode 100644 index 0000000..fa59425 --- /dev/null +++ b/server/services/m3uParser.js @@ -0,0 +1,316 @@ +/** + * M3U Playlist Parser (Streaming) + * Parses EXTM3U format playlists and extracts channel information line-by-line + */ + +const readline = require('readline'); +const { Readable } = require('stream'); + +/** + * Generate a simple stable ID from name and group + * @param {string} name - Channel name + * @param {string} group - Group title + * @returns {string} Stable ID + */ +function generateStableId(name, group) { + const str = `${name || 'unknown'}:${group || 'unknown'}`; + // Simple hash function + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return `m3u_${Math.abs(hash).toString(36)}`; +} + +/** + * Parse EXTINF line and extract attributes + * @param {string} line - EXTINF line + * @returns {Object} Parsed channel info + */ +function parseExtinf(line) { + const info = { + duration: -1, + tvgId: null, + tvgName: null, + tvgLogo: null, + groupTitle: null, + name: null + }; + + // Extract duration and rest + const match = line.match(/#EXTINF:(-?\d+\.?\d*)\s*(.*)/); + if (!match) return info; + + info.duration = parseFloat(match[1]); + const rest = match[2]; + + // Extract attributes using regex + const attrPatterns = { + tvgId: /tvg-id="([^"]*)"/i, + tvgName: /tvg-name="([^"]*)"/i, + tvgLogo: /tvg-logo="([^"]*)"/i, + groupTitle: /group-title="([^"]*)"/i + }; + + for (const [key, pattern] of Object.entries(attrPatterns)) { + const attrMatch = rest.match(pattern); + if (attrMatch) { + info[key] = attrMatch[1]; + } + } + + // Extract channel name (after the comma) + const commaIndex = rest.lastIndexOf(','); + if (commaIndex !== -1) { + info.name = rest.substring(commaIndex + 1).trim(); + } else { + // Fallback: use tvg-name or the whole rest + info.name = info.tvgName || rest.trim(); + } + + // Generate ID if not present + if (!info.tvgId) { + info.tvgId = info.name ? info.name.toLowerCase().replace(/\s+/g, '_') : `channel_${Date.now()}`; + } + + return info; +} + +/** + * Parse M3U content (Stream or String) + * @param {Readable|string} input - M3U content as Stream or String + * @returns {Promise<{ channels: Array, groups: Array }>} + */ +async function parse(input) { + const channels = []; + const groupsSet = new Set(); + let currentInfo = null; + let currentGroup = null; + + let lines; + + if (typeof input === 'string') { + // Handle string input directly + lines = input.split(/\r?\n/); + } else { + // Handle stream input + const rl = readline.createInterface({ + input: input, + crlfDelay: Infinity + }); + lines = rl; + } + + for await (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('#EXTINF:')) { + // Parse EXTINF line + currentInfo = parseExtinf(trimmed); + if (currentInfo.groupTitle) { + groupsSet.add(currentInfo.groupTitle); + currentGroup = currentInfo.groupTitle; + } + } else if (trimmed.startsWith('#EXTGRP:')) { + // Parse EXTGRP line (alternative group specification) + currentGroup = trimmed.substring(8).trim(); + groupsSet.add(currentGroup); + if (currentInfo) { + currentInfo.groupTitle = currentGroup; + } + } else if (!trimmed.startsWith('#')) { + // This is a stream URL + if (currentInfo) { + const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized'; + // Generate a stable ID: use tvgId if present, otherwise hash name+group + const stableId = currentInfo.tvgId || generateStableId(currentInfo.name, groupTitle); + + channels.push({ + ...currentInfo, + id: stableId, + url: trimmed, + groupTitle: groupTitle + }); + currentInfo = null; + } + } + } + + // Convert groups to array of objects + const groups = Array.from(groupsSet).map((name, index) => ({ + id: `group_${index}`, + name, + channelCount: channels.filter(c => c.groupTitle === name).length + })); + + return { channels, groups }; +} + +/** + * Fetch and parse M3U from URL + * @param {string} url - M3U playlist URL + * @returns {Promise<{ channels: Array, groups: Array }>} + */ +async function fetchAndParse(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`); + } + + // Check if body is a Node.js stream (undici/node-fetch) or web stream + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + // Convert Web Stream to Node Readable for readline + stream = Readable.fromWeb(response.body); + } else { + // Fallback for empty body + stream = Readable.from([]); + } + + return parse(stream); +} + +/** + * Parse M3U content as a streaming async generator (memory-efficient) + * Yields batches of channels to avoid loading entire playlist into memory. + * + * @param {Readable|string} input - M3U content as Stream or String + * @param {number} batchSize - Number of channels per batch (default: 500) + * @yields {{ channels: Array, groups: Set, isLast: boolean }} + */ +async function* parseStreaming(input, batchSize = 500) { + const groupsSet = new Set(); + let currentInfo = null; + let currentGroup = null; + let batch = []; + + let lines; + if (typeof input === 'string') { + lines = input.split(/\r?\n/); + } else { + const rl = readline.createInterface({ + input: input, + crlfDelay: Infinity + }); + lines = rl; + } + + for await (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('#EXTINF:')) { + currentInfo = parseExtinf(trimmed); + if (currentInfo.groupTitle) { + groupsSet.add(currentInfo.groupTitle); + currentGroup = currentInfo.groupTitle; + } + } else if (trimmed.startsWith('#EXTGRP:')) { + currentGroup = trimmed.substring(8).trim(); + groupsSet.add(currentGroup); + if (currentInfo) { + currentInfo.groupTitle = currentGroup; + } + } else if (!trimmed.startsWith('#')) { + if (currentInfo) { + const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized'; + const stableId = currentInfo.tvgId || generateStableId(currentInfo.name, groupTitle); + + batch.push({ + ...currentInfo, + id: stableId, + url: trimmed, + groupTitle: groupTitle + }); + currentInfo = null; + + // Yield batch when full + if (batch.length >= batchSize) { + yield { channels: batch, groups: groupsSet, isLast: false }; + batch = []; + } + } + } + } + + // Yield remaining channels + if (batch.length > 0) { + yield { channels: batch, groups: groupsSet, isLast: true }; + } else { + // Yield empty final batch with isLast=true so caller knows we're done + yield { channels: [], groups: groupsSet, isLast: true }; + } +} + +/** + * Fetch and parse M3U from URL as streaming async generator (memory-efficient) + * @param {string} url - M3U playlist URL + * @param {number} batchSize - Number of channels per batch + * @yields {{ channels: Array, groups: Set, isLast: boolean }} + */ +async function* fetchAndParseStreaming(url, batchSize = 500) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`); + } + + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + stream = Readable.fromWeb(response.body); + } else { + stream = Readable.from([]); + } + + yield* parseStreaming(stream, batchSize); +} + +/** + * Fast count of entries in an M3U playlist (for size estimation) + * Streams the file and counts #EXTINF lines without full parsing + * @param {string} url - URL of the M3U playlist + * @returns {Promise} Number of entries + */ +async function countEntries(url) { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch playlist: ${response.status}`); + } + + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + stream = Readable.fromWeb(response.body); + } else { + return 0; + } + + return new Promise((resolve, reject) => { + let count = 0; + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + rl.on('line', (line) => { + if (line.startsWith('#EXTINF:')) { + count++; + } + }); + + rl.on('close', () => resolve(count)); + rl.on('error', reject); + }); +} + +module.exports = { parse, parseExtinf, fetchAndParse, parseStreaming, fetchAndParseStreaming, countEntries }; + diff --git a/server/services/m3uXtreamAdapter.js b/server/services/m3uXtreamAdapter.js new file mode 100644 index 0000000..adb7b9b --- /dev/null +++ b/server/services/m3uXtreamAdapter.js @@ -0,0 +1,133 @@ +/** + * M3U Xtream Adapter + * + * Makes M3U sources respond to Xtream-style API methods. + * Queries data from SQLite (already synced during source refresh). + */ + +const { getDb } = require('../db/sqlite'); + +class M3uXtreamAdapter { + constructor(sourceId) { + this.sourceId = sourceId; + } + + /** + * Get live categories (groups) for this M3U source. + * Returns Xtream-compatible format: [{ category_id, category_name, parent_id }] + */ + getLiveCategories(includeHidden = false) { + const db = getDb(); + + // M3U stores group name in category_id field of playlist_items + // We need to aggregate unique groups with counts + let query = ` + SELECT + category_id, + category_id as category_name, + NULL as parent_id, + COUNT(*) as channel_count + FROM playlist_items + WHERE source_id = ? AND type = 'live' + ${!includeHidden ? 'AND is_hidden = 0' : ''} + GROUP BY category_id + ORDER BY category_id ASC + `; + + const rows = db.prepare(query).all(this.sourceId); + + return rows.map(row => ({ + category_id: row.category_id || 'Uncategorized', + category_name: row.category_id || 'Uncategorized', + parent_id: null, + // Bonus: include count for lazy-loading UI + channel_count: row.channel_count + })); + } + + /** + * Get live streams (channels), optionally filtered by category. + * Returns Xtream-compatible format: [{ stream_id, name, stream_icon, category_id, ... }] + */ + getLiveStreams(categoryId = null, includeHidden = false) { + const db = getDb(); + + let query = ` + SELECT + item_id as stream_id, + name, + stream_icon, + stream_url, + category_id, + added_at, + data + FROM playlist_items + WHERE source_id = ? AND type = 'live' + ${!includeHidden ? 'AND is_hidden = 0' : ''} + `; + + const params = [this.sourceId]; + + if (categoryId) { + query += ` AND category_id = ?`; + params.push(categoryId); + } + + query += ` ORDER BY name ASC`; + + const rows = db.prepare(query).all(...params); + + return rows.map(row => { + // Parse any extra data stored as JSON + let extra = {}; + if (row.data) { + try { extra = JSON.parse(row.data); } catch (e) { } + } + + return { + stream_id: row.stream_id, + name: row.name, + stream_icon: row.stream_icon, + category_id: row.category_id, + added: row.added_at, + // M3U-specific: direct stream URL (Xtream builds URLs from credentials) + stream_url: row.stream_url, + // Include extra fields from parser (tvgId, etc.) + epg_channel_id: extra.tvgId || null, + ...extra + }; + }); + } + + /** + * Build stream URL for playback. + * For M3U, we return the stored URL directly (no credential building). + */ + buildStreamUrl(streamId, type = 'live', container = 'ts') { + const db = getDb(); + + const row = db.prepare(` + SELECT stream_url FROM playlist_items + WHERE source_id = ? AND item_id = ? + `).get(this.sourceId, streamId); + + return row?.stream_url || null; + } + + /** + * Get XMLTV EPG URL - M3U sources don't have built-in EPG URLs. + * Returns null; EPG must be configured as a separate source. + */ + getXmltvUrl() { + return null; + } +} + +/** + * Factory function to create adapter from source ID + */ +function createFromSourceId(sourceId) { + return new M3uXtreamAdapter(sourceId); +} + +module.exports = { M3uXtreamAdapter, createFromSourceId }; diff --git a/server/services/syncService.js b/server/services/syncService.js new file mode 100644 index 0000000..8ac35f5 --- /dev/null +++ b/server/services/syncService.js @@ -0,0 +1,574 @@ +const { getDb } = require('../db/sqlite'); +const { sources, settings } = require('../db'); // For source config and settings +const xtreamApi = require('./xtreamApi'); +const m3uParser = require('./m3uParser'); +const epgParser = require('./epgParser'); + +// Sync tracking +const activeSyncs = new Set(); // sourceId + +class SyncService { + constructor() { + this.lastSyncTime = null; // Track when global sync last completed + this._syncTimer = null; // Server-side sync timer + this._currentInterval = null; + } + + /** + * Get when the last global sync completed + */ + getLastSyncTime() { + return this.lastSyncTime; + } + + /** + * Start the server-side sync timer based on settings + * Should be called once on server startup after initial sync + */ + async startSyncTimer() { + // Get interval from settings + const currentSettings = await settings.get(); + const intervalHours = parseInt(currentSettings.epgRefreshInterval) || 24; + + // If interval is 0, don't start timer (manual only mode) + if (intervalHours <= 0) { + console.log('[Sync] Auto-sync disabled (manual only mode)'); + this.stopSyncTimer(); + this._currentInterval = 0; + return; + } + + const intervalMs = intervalHours * 60 * 60 * 1000; + + // Don't restart if interval hasn't changed and timer exists + if (this._currentInterval === intervalHours && this._syncTimer) { + console.log(`[Sync] Timer already running for ${intervalHours} hours, not restarting`); + return; + } + + // Clear existing timer + this.stopSyncTimer(); + + const nextSyncTime = new Date(Date.now() + intervalMs); + console.log(`[Sync] Starting server-side sync timer: every ${intervalHours} hours`); + console.log(`[Sync] Next scheduled sync at: ${nextSyncTime.toLocaleString()}`); + + this._syncTimer = setInterval(async () => { + console.log('[Sync] Scheduled sync triggered'); + await this.syncAll(); + // Log next sync time + const next = new Date(Date.now() + intervalMs); + console.log(`[Sync] Next scheduled sync at: ${next.toLocaleString()}`); + }, intervalMs); + + this._currentInterval = intervalHours; + } + + /** + * Stop the server-side sync timer + */ + stopSyncTimer() { + if (this._syncTimer) { + clearInterval(this._syncTimer); + this._syncTimer = null; + } + } + + /** + * Restart the sync timer with updated settings + * Called when sync interval setting changes + */ + async restartSyncTimer() { + await this.startSyncTimer(); + } + + /** + * Sync all enabled sources + */ + async syncAll() { + console.log('[Sync] Starting global sync...'); + try { + const allSources = await sources.getAll(); + for (const source of allSources) { + if (source.enabled) { + // Run sequentially to not overload + await this.syncSource(source.id); + } + } + this.lastSyncTime = new Date(); + console.log('[Sync] Global sync completed at', this.lastSyncTime.toISOString()); + } catch (err) { + console.error('[Sync] Global sync failed:', err); + } + } + + /** + * Start sync for a source + */ + async syncSource(sourceId) { + if (activeSyncs.has(sourceId)) { + console.log(`[Sync] Source ${sourceId} is already syncing`); + return; + } + + activeSyncs.add(sourceId); + + try { + const db = getDb(); + const source = await sources.getById(sourceId); + + if (!source) { + throw new Error(`Source ${sourceId} not found`); + } + + console.log(`[Sync] Starting sync for source ${source.name} (ID: ${sourceId})`); + + if (!source.enabled) { + console.log(`[Sync] Skipping disabled source ${source.name}`); + activeSyncs.delete(sourceId); + return; + } + + // Update status + this.updateSyncStatus(sourceId, 'all', 'syncing'); + + if (source.type === 'xtream') { + await this.syncXtream(source); + } else if (source.type === 'm3u') { + await this.syncM3u(source); + } else if (source.type === 'epg') { + await this.syncEpg(source); + } + + this.updateSyncStatus(sourceId, 'all', 'success'); + console.log(`[Sync] Completed sync for source ${source.name}`); + + } catch (err) { + console.error(`[Sync] Failed sync for source ${sourceId}:`, err); + this.updateSyncStatus(sourceId, 'all', 'error', err.message); + } finally { + activeSyncs.delete(sourceId); + } + } + + /** + * Update sync status in DB + */ + updateSyncStatus(sourceId, type, status, error = null) { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO sync_status (source_id, type, last_sync, status, error) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, type) DO UPDATE SET + last_sync = excluded.last_sync, + status = excluded.status, + error = excluded.error + `); + stmt.run(sourceId, type, Date.now(), status, error); + } + + /** + * Xtream Sync Logic + */ + async syncXtream(source) { + const api = xtreamApi.createFromSource(source); + const db = getDb(); + + // 1. Live Categories + console.log(`[Sync] Fetching Live Categories for ${source.name}`); + const liveCats = await api.getLiveCategories(); + await this.saveCategories(source.id, 'live', liveCats); + + // 2. Live Streams + console.log(`[Sync] Fetching Live Streams for ${source.name}`); + const liveStreams = await api.getLiveStreams(); + await this.saveStreams(source.id, 'live', liveStreams); + + // 3. VOD Categories + console.log(`[Sync] Fetching VOD Categories for ${source.name}`); + const vodCats = await api.getVodCategories(); + await this.saveCategories(source.id, 'movie', vodCats); + + // 4. VOD Streams + console.log(`[Sync] Fetching VOD Streams for ${source.name}`); + const vodStreams = await api.getVodStreams(); + await this.saveStreams(source.id, 'movie', vodStreams); + + // 5. Series Categories + console.log(`[Sync] Fetching Series Categories for ${source.name}`); + const seriesCats = await api.getSeriesCategories(); + await this.saveCategories(source.id, 'series', seriesCats); + + // 6. Series + console.log(`[Sync] Fetching Series for ${source.name}`); + const series = await api.getSeries(); + await this.saveStreams(source.id, 'series', series); + + // 7. EPG (Xmltv) + // Try to fetch XMLTV if available + console.log(`[Sync] Fetching EPG for ${source.name}`); + try { + const xmltvUrl = api.getXmltvUrl(); + await this.syncEpgFromUrl(source.id, xmltvUrl); + } catch (e) { + console.warn('[Sync] XMLTV fetch failed, skipping EPG sync for now:', e.message); + } + } + + /** + * Batch save categories + */ + async saveCategories(sourceId, type, categories) { + if (!categories || categories.length === 0) return; + console.log(`[Sync] Saving ${categories.length} ${type} categories for source ${sourceId}...`); + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO categories (id, source_id, category_id, type, name, parent_id, data) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + data = excluded.data + `); + + const insertBatch = db.transaction((batch) => { + for (const cat of batch) { + const catId = cat.category_id; // standard xtream field + const name = cat.category_name; + const id = `${sourceId}:${catId}`; + stmt.run(id, sourceId, String(catId), type, name, cat.parent_id || null, JSON.stringify(cat)); + } + }); + + // Reduced batch size for better event loop interleaving + const BATCH_SIZE = 100; + for (let i = 0; i < categories.length; i += BATCH_SIZE) { + insertBatch(categories.slice(i, i + BATCH_SIZE)); + // Yield to event loop between batches to allow other requests + await new Promise(resolve => setImmediate(resolve)); + } + + console.log(`[Sync] Saved ${categories.length} ${type} categories`); + } + + /** + * Batch save streams (channels, vod, series) + * Also purges stale entries that no longer exist in the source (unless skipPurge is true) + * @param {number} sourceId - Source ID + * @param {string} type - Type of items (live, movie, series) + * @param {Array} items - Items to save + * @param {Object} options - Options { skipPurge: boolean } + * @returns {Set} Set of synced IDs (for external purge if skipPurge was true) + */ + async saveStreams(sourceId, type, items, options = {}) { + if (!items || items.length === 0) return new Set(); + const db = getDb(); + const { skipPurge = false } = options; + + // Collect all IDs we're syncing + const syncedIds = new Set(); + + const stmt = db.prepare(` + INSERT INTO playlist_items ( + id, source_id, item_id, type, name, category_id, + stream_icon, stream_url, container_extension, + rating, year, added_at, data + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + category_id = excluded.category_id, + stream_icon = excluded.stream_icon, + container_extension = excluded.container_extension, + data = excluded.data + `); + + const insertBatch = db.transaction((batch) => { + for (const item of batch) { + // Map fields based on type + let itemId, name, catId, icon, container; + let rating = null, year = null, added = null; + + if (type === 'live') { + itemId = item.stream_id; + name = item.name || `Channel ${item.stream_id}`; + catId = item.category_id; + icon = item.stream_icon; + added = item.added; + } else if (type === 'movie') { + itemId = item.stream_id; + name = item.name || `Movie ${item.stream_id}`; + catId = item.category_id; + icon = item.stream_icon; // or cover + container = item.container_extension; + rating = item.rating; + added = item.added; + } else if (type === 'series') { + itemId = item.series_id; + name = item.name || `Series ${item.series_id}`; + catId = item.category_id; + icon = item.cover; + rating = item.rating; + year = item.releaseDate; + added = item.last_modified; + } + + const id = `${sourceId}:${itemId}`; + syncedIds.add(id); + + stmt.run( + id, + sourceId, + String(itemId), + type, + name, + String(catId), + icon, + null, // Direct URL not stored for Xtream usually, built on fly + container, + rating, + year, + added, + JSON.stringify(item) + ); + } + }); + + // Reduced batch size for better event loop interleaving + const BATCH_SIZE = 100; + for (let i = 0; i < items.length; i += BATCH_SIZE) { + insertBatch(items.slice(i, i + BATCH_SIZE)); + // Yield to event loop between batches to allow other requests + await new Promise(resolve => setImmediate(resolve)); + } + + // Purge stale entries (skip if doing batch sync like M3U) + if (!skipPurge && syncedIds.size > 0) { + await this.purgeStaleItems(sourceId, type, syncedIds); + } + + console.log(`[Sync] Saved ${items.length} ${type} items`); + return syncedIds; + } + + /** + * Purge stale items that are no longer in the source + * @param {number} sourceId - Source ID + * @param {string} type - Type of items (live, movie, series) + * @param {Set} syncedIds - Set of IDs that should be kept + */ + async purgeStaleItems(sourceId, type, syncedIds) { + if (!syncedIds || syncedIds.size === 0) return; + + const db = getDb(); + db.exec('CREATE TEMP TABLE IF NOT EXISTS synced_ids (id TEXT PRIMARY KEY)'); + db.exec('DELETE FROM synced_ids'); + + const insertTemp = db.prepare('INSERT OR IGNORE INTO synced_ids (id) VALUES (?)'); + const insertTempBatch = db.transaction((ids) => { + for (const id of ids) { + insertTemp.run(id); + } + }); + insertTempBatch([...syncedIds]); + + const deleteStmt = db.prepare(` + DELETE FROM playlist_items + WHERE source_id = ? AND type = ? + AND id NOT IN (SELECT id FROM synced_ids) + `); + const deleted = deleteStmt.run(sourceId, type); + + if (deleted.changes > 0) { + console.log(`[Sync] Purged ${deleted.changes} stale ${type} items`); + } + } + + + /** + * Sync EPG from URL (Streaming - Memory Efficient) + * Processes EPG files in batches to avoid OOM on large EPG data + */ + async syncEpgFromUrl(sourceId, url) { + console.log(`[Sync] Fetching EPG from: ${url.substring(0, 60)}...`); + + // Temporary memory logging for verification + const logMemory = () => { + const used = process.memoryUsage(); + console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`); + }; + + logMemory(); + + const db = getDb(); + let allChannels = []; + let totalProgrammes = 0; + let batchCount = 0; + + // Clear old programmes first + db.prepare('DELETE FROM epg_programs WHERE source_id = ?').run(sourceId); + + const programmeStmt = db.prepare(` + INSERT INTO epg_programs (channel_id, source_id, start_time, end_time, title, description, data) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertProgrammes = db.transaction((progs) => { + for (const p of progs) { + programmeStmt.run( + p.channelId, + sourceId, + p.start ? p.start.getTime() : 0, + p.stop ? p.stop.getTime() : 0, + p.title, + p.description || p.desc, + JSON.stringify(p) + ); + } + }); + + // Stream and process in batches (default 1000 programmes per batch) + for await (const batch of epgParser.fetchAndParseStreaming(url)) { + batchCount++; + + // Collect channels from first batch + if (batch.channels) { + allChannels = batch.channels; + } + + // Save this batch of programmes immediately + if (batch.programmes.length > 0) { + insertProgrammes(batch.programmes); + totalProgrammes += batch.programmes.length; + } + + // Log progress every 10 batches + if (batchCount % 10 === 0) { + console.log(`[Sync] Processed ${totalProgrammes} programmes so far...`); + logMemory(); + } + + // Yield to event loop + await new Promise(resolve => setImmediate(resolve)); + } + + console.log(`[Sync] EPG Parsed: ${allChannels.length} channels, ${totalProgrammes} programmes`); + logMemory(); + + // Save EPG Channels + if (allChannels.length > 0) { + const channelStmt = db.prepare(` + INSERT INTO playlist_items ( + id, source_id, item_id, type, name, stream_icon, + stream_url, category_id, data + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + stream_icon = excluded.stream_icon, + data = excluded.data + `); + + const insertChannels = db.transaction((chanList) => { + for (const ch of chanList) { + const id = `${sourceId}:${ch.id}`; + channelStmt.run( + id, + sourceId, + ch.id, + 'epg_channel', + ch.name, + ch.icon || null, + null, + null, + JSON.stringify(ch) + ); + } + }); + + insertChannels(allChannels); + console.log(`[Sync] Saved ${allChannels.length} EPG channels`); + } + + console.log(`[Sync] Saved ${totalProgrammes} programmes`); + } + + /** + * M3U Sync Logic (Streaming - Memory Efficient) + * Processes M3U files in batches to avoid OOM on large playlists + */ + async syncM3u(source) { + console.log(`[Sync] Fetching M3U playlist for ${source.name}`); + + // Temporary memory logging for verification + const logMemory = () => { + const used = process.memoryUsage(); + console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`); + }; + + logMemory(); + + const allGroups = new Set(); + const allSyncedIds = new Set(); // Collect IDs across all batches + let totalChannels = 0; + let batchCount = 0; + + // Stream and process in batches (default 500 channels per batch) + for await (const batch of m3uParser.fetchAndParseStreaming(source.url)) { + batchCount++; + + // Map M3U channel format to our schema + const playlistItems = batch.channels.map(ch => ({ + stream_id: ch.id, + name: ch.name, + category_id: ch.groupTitle || 'Uncategorized', + stream_icon: ch.tvgLogo, + stream_url: ch.url, + tvgId: ch.tvgId || null, + })); + + // Save this batch immediately (skip purge - we'll do it at the end) + if (playlistItems.length > 0) { + const batchIds = await this.saveStreams(source.id, 'live', playlistItems, { skipPurge: true }); + batchIds.forEach(id => allSyncedIds.add(id)); + totalChannels += playlistItems.length; + } + + // Collect groups for category creation at the end + batch.groups.forEach(g => allGroups.add(g)); + + // Log progress every 10 batches + if (batchCount % 10 === 0) { + console.log(`[Sync] Processed ${totalChannels} channels so far...`); + logMemory(); + } + } + + console.log(`[Sync] M3U Parsed: ${totalChannels} channels, ${allGroups.size} groups`); + logMemory(); + + // Purge stale items after all batches are complete + if (allSyncedIds.size > 0) { + await this.purgeStaleItems(source.id, 'live', allSyncedIds); + } + + // Save Categories (Groups) at the end + const categories = Array.from(allGroups).map(name => ({ + category_id: name, + category_name: name, + parent_id: null + })); + + await this.saveCategories(source.id, 'live', categories); + console.log(`[Sync] M3U sync complete for ${source.name}`); + } + + /** + * EPG Source Sync Logic + */ + async syncEpg(source) { + console.log(`[Sync] Fetching standalone EPG for ${source.name}`); + await this.syncEpgFromUrl(source.id, source.url); + } +} + +module.exports = new SyncService(); diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js new file mode 100644 index 0000000..483e8e2 --- /dev/null +++ b/server/services/transcodeSession.js @@ -0,0 +1,781 @@ +/** + * Transcode Session Service + * + * Manages HLS transcoding sessions with segment caching for VOD seeking. + * Each session transcodes a source URL to HLS segments on disk. + * + * Key features: + * - Session-based transcoding with unique IDs + * - HLS segment output for seeking support + * - Segment caching for fast access + * - Session persistence for recovery after restart + * - Automatic cleanup of stale sessions + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs').promises; +const crypto = require('crypto'); +const EventEmitter = require('events'); +const hwDetect = require('./hwDetect'); + +// Session storage +const sessions = new Map(); + +// Cache directory for transcoded segments +const CACHE_DIR = path.join(process.cwd(), 'transcode-cache'); + +// Session settings +const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly) +const SEGMENT_DURATION = 4; // seconds per HLS segment +const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds + +/** + * Generate a unique session ID + */ +function generateSessionId() { + return crypto.randomBytes(8).toString('hex'); +} + +/** + * Ensure cache directory exists + */ +async function ensureCacheDir() { + try { + await fs.mkdir(CACHE_DIR, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +} + +/** + * TranscodeSession class + * Manages a single transcoding session from source URL to HLS segments + */ +class TranscodeSession extends EventEmitter { + constructor(url, options = {}) { + super(); + this.id = generateSessionId(); + this.url = url; + this.dir = path.join(CACHE_DIR, this.id); + this.playlistPath = path.join(this.dir, 'stream.m3u8'); + this.process = null; + this.segments = new Map(); // segment index -> { ready: boolean, path: string } + this.status = 'pending'; // pending | starting | running | stopped | error + this.error = null; + this.startTime = Date.now(); + this.lastAccess = Date.now(); + this.options = { + ffmpegPath: options.ffmpegPath || 'ffmpeg', + userAgent: options.userAgent || 'Mozilla/5.0', + seekOffset: options.seekOffset || 0, + hwEncoder: options.hwEncoder || 'software', + maxResolution: options.maxResolution || '1080p', + quality: options.quality || 'medium', + // Upscaling options + upscaleEnabled: options.upscaleEnabled || false, + upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' + upscaleTarget: options.upscaleTarget || '1080p', + ...options + }; + } + + /** + * Start the transcoding process + */ + async start() { + if (this.status === 'running') { + return; + } + + this.status = 'starting'; + console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`); + + // Create session directory + try { + await fs.mkdir(this.dir, { recursive: true }); + } catch (err) { + this.status = 'error'; + this.error = err.message; + throw err; + } + + // Build FFmpeg arguments for HLS output + const args = this.buildFFmpegArgs(); + + console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`); + + try { + this.process = spawn(this.options.ffmpegPath, args, { + cwd: this.dir, + windowsHide: true + }); + + this.status = 'running'; + + // Handle stdout (should be empty for file output) + this.process.stdout.on('data', (data) => { + console.log(`[TranscodeSession ${this.id}] stdout: ${data}`); + }); + + // Handle stderr (FFmpeg progress/errors) + let stderrBuffer = ''; + this.process.stderr.on('data', (data) => { + stderrBuffer += data.toString(); + // Log periodically to avoid spam + const lines = stderrBuffer.split('\n'); + if (lines.length > 1) { + lines.slice(0, -1).forEach(line => { + if (line.trim()) { + console.log(`[FFmpeg ${this.id}] ${line}`); + } + }); + stderrBuffer = lines[lines.length - 1]; + } + }); + + // Handle process exit + this.process.on('exit', (code) => { + if (code === 0 || code === null) { + console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`); + this.status = 'stopped'; + } else if (code !== 255) { // 255 is often from SIGKILL + console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`); + this.status = 'error'; + this.error = `FFmpeg exited with code ${code}`; + } + this.process = null; + this.emit('exit', code); + }); + + // Handle spawn errors + this.process.on('error', (err) => { + console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err); + this.status = 'error'; + this.error = err.message; + this.emit('error', err); + }); + + // Save session metadata + await this.persist(); + + } catch (err) { + this.status = 'error'; + this.error = err.message; + throw err; + } + } + + /** + * Build FFmpeg arguments for HLS output with optional GPU encoding + */ + buildFFmpegArgs() { + const segmentPattern = path.join(this.dir, 'seg%04d.m4s'); + const videoMode = this.options.videoMode || 'encode'; + + // Resolve 'auto' encoder to detected hardware, fallback to software + let encoder = this.options.hwEncoder || 'software'; + if (encoder === 'auto') { + const hwCaps = hwDetect.getCapabilities(); + encoder = hwCaps?.recommended || 'software'; + console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); + } + + const args = [ + '-hide_banner', + '-loglevel', 'warning', + '-user_agent', this.options.userAgent, + ]; + + // Add hardware acceleration input options based on encoder (only if encoding) + if (videoMode === 'encode') { + this.addHwAccelInputArgs(args, encoder); + } + + // Input options (common) + args.push( + '-probesize', '5000000', + '-analyzeduration', '5000000', + '-fflags', '+genpts+discardcorrupt', + '-err_detect', 'ignore_err', + '-reconnect', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '3' + ); + + args.push('-i', this.url); + + // Add seek offset if specified (as output option to avoid Range requests) + if (this.options.seekOffset > 0) { + args.push('-ss', String(this.options.seekOffset)); + } + + // Map streams + args.push('-map', '0:v:0'); + args.push('-map', '0:a:0?'); + + // Add video encoder and filters based on selected encoder OR copy + if (videoMode === 'copy') { + args.push('-c:v', 'copy'); + + // Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B + if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') { + args.push('-bsf:v', 'hevc_mp4toannexb'); + } else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') { + // Keep Annex-B conversion and force parameter sets to be repeated. + // This helps players recover on streams where SPS/PPS appear sporadically, + // which often manifests as "audio OK, black video". + args.push('-bsf:v', 'h264_mp4toannexb,dump_extra'); + } else { + // Fallback (e.g. unknown codec), try strict extraction + args.push('-bsf:v', 'dump_extra'); + } + } else { + this.addVideoEncoderArgs(args, encoder); + } + + // Audio: Apply mix preset + const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown'; + const audioChannels = this.options.audioChannels || 0; + const audioMixPreset = this.options.audioMixPreset || 'auto'; + const isStereoAac = audioCodec.includes('aac') && audioChannels === 2; + + // Define pan filter presets for 5.1 -> Stereo downmix + const AUDIO_MIX_FILTERS = { + // ITU-R BS.775 Standard: Mathematically balanced, transparent + itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE', + // Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing + night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE', + // Cinematic: Wide soundstage, immersive (original "dialogue boost" mix) + cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE' + }; + + if (audioMixPreset === 'passthrough') { + // Passthrough: Always copy audio, no processing + console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`); + args.push('-c:a', 'copy'); + } else if (audioMixPreset === 'auto' && isStereoAac) { + // Auto + Stereo AAC source: Smart copy + console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`); + args.push('-c:a', 'copy'); + } else { + // Transcode to AAC with selected mix preset (default to ITU for 'auto') + const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset; + const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu; + + console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`); + args.push( + '-c:a', 'aac', + '-ar', '48000', + '-b:a', '192k', + '-af', `${panFilter},aresample=async=1` + ); + } + + // HLS output options + args.push( + '-f', 'hls', + '-hls_time', String(SEGMENT_DURATION), + '-hls_list_size', '0', // Keep all segments in playlist + '-hls_flags', 'independent_segments+append_list', + '-hls_segment_type', 'mpegts', + '-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'), + this.playlistPath + ); + + return args; + } + + /** + * Add hardware acceleration input arguments + */ + addHwAccelInputArgs(args, encoder) { + switch (encoder) { + case 'nvenc': + // NVIDIA CUDA/NVDEC hardware decoding + args.push( + '-hwaccel', 'cuda', + '-hwaccel_output_format', 'cuda' + ); + break; + case 'vaapi': + // VAAPI hardware decoding (Linux) + args.push( + '-hwaccel', 'vaapi', + '-hwaccel_device', '/dev/dri/renderD128', + '-hwaccel_output_format', 'vaapi' + ); + break; + case 'qsv': + // Intel QuickSync hardware decoding + args.push( + '-hwaccel', 'qsv', + '-hwaccel_output_format', 'qsv' + ); + break; + case 'amf': + // AMD AMF (no hwaccel input, AMF is encode-only) + // Decode on CPU, encode on GPU + break; + case 'software': + case 'auto': + default: + // No hardware acceleration for input + break; + } + } + + /** + * Add video encoder arguments based on selected encoder + */ + addVideoEncoderArgs(args, encoder) { + const resolution = this.getTargetHeight(); + const quality = this.options.quality || 'medium'; + + // Quality presets mapping + const qualityPresets = { + 'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 }, + 'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 }, + 'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 } + }; + const qp = qualityPresets[quality] || qualityPresets.medium; + + switch (encoder) { + case 'nvenc': + this.addNvencEncoderArgs(args, resolution, qp.nvenc); + break; + case 'amf': + this.addAmfEncoderArgs(args, resolution, qp.amf); + break; + case 'vaapi': + this.addVaapiEncoderArgs(args, resolution, qp.vaapi); + break; + case 'qsv': + this.addQsvEncoderArgs(args, resolution, qp.qsv); + break; + case 'software': + case 'auto': + default: + this.addSoftwareEncoderArgs(args, resolution, qp.software); + break; + } + } + + /** + * Get target height based on maxResolution or upscaleTarget setting + * When upscaling is enabled, uses the upscaleTarget resolution. + * Otherwise, uses maxResolution to cap the output. + */ + getTargetHeight() { + const resolutionMap = { + '4k': 2160, + '1080p': 1080, + '720p': 720, + '480p': 480 + }; + + // When upscaling is enabled, use the upscale target resolution + if (this.options.upscaleEnabled) { + const target = resolutionMap[this.options.upscaleTarget] || 1080; + console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`); + return target; + } + + // Otherwise, use max resolution as the cap + return resolutionMap[this.options.maxResolution] || 1080; + } + + /** + * Build scale filter string based on encoder and upscaling settings + * @param {string} encoder - The encoder being used + * @param {number} height - Target height + */ + buildScaleFilter(encoder, height) { + const useUpscale = this.options.upscaleEnabled; + const upscaleMethod = this.options.upscaleMethod || 'hardware'; + + // Log upscaling status + if (useUpscale) { + console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`); + } + + // Hardware scaling filters (for both upscale and downscale) + if (upscaleMethod === 'hardware' || !useUpscale) { + switch (encoder) { + case 'nvenc': + // NVIDIA CUDA scaling with Lanczos + // Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported") + return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`; + case 'vaapi': + return `scale_vaapi=w=-2:h=${height}:format=nv12`; + case 'qsv': + return `scale_qsv=w=-2:h=${height}:format=nv12`; + case 'amf': + // AMF uses CPU decode, so use software scale + return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`; + case 'software': + default: + return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`; + } + } + + // Software Lanczos scaling (high quality, slower) + return `scale=-2:${height}:flags=lanczos`; + } + + /** + * NVIDIA NVENC encoder arguments + */ + addNvencEncoderArgs(args, height, qp) { + // Video filter for scaling on GPU + args.push('-vf', this.buildScaleFilter('nvenc', height)); + + // NVENC encoder with quality settings + // Using portable options that work across FFmpeg builds + args.push( + '-c:v', 'h264_nvenc', + '-preset', 'p4', // Balanced preset (p1=fastest, p7=best) + '-rc', 'constqp', // Constant QP mode + '-qp', String(qp), + '-bf', '3' // B-frames for better compression + ); + } + + /** + * AMD AMF encoder arguments + */ + addAmfEncoderArgs(args, height, qp) { + // CPU decoding + software scale + AMF encode + args.push('-vf', this.buildScaleFilter('amf', height)); + + args.push( + '-c:v', 'h264_amf', + '-quality', 'quality', // Quality preset + '-rc', 'cqp', // Constant QP + '-qp_i', String(qp), + '-qp_p', String(qp + 2), + '-qp_b', String(qp + 4), + '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility + ); + } + + /** + * VAAPI encoder arguments (Linux) + */ + addVaapiEncoderArgs(args, height, qp) { + // VAAPI filter chain: + // 1. scale_vaapi to resize on GPU + // 2. Ensure output format is nv12 for maximum encoder compatibility + // The format is handled automatically when using -hwaccel_output_format vaapi + args.push('-vf', this.buildScaleFilter('vaapi', height)); + + // VAAPI encoder with quality setting + // Note: -global_quality is the portable way to set quality for VAAPI + args.push( + '-c:v', 'h264_vaapi', + '-profile:v', 'main', // Use main profile for compatibility + '-global_quality', String(qp), + '-bf', '3', + '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility + ); + } + + /** + * Intel QuickSync encoder arguments + */ + addQsvEncoderArgs(args, height, qp) { + // Scale on QSV + args.push('-vf', this.buildScaleFilter('qsv', height)); + + args.push( + '-c:v', 'h264_qsv', + '-preset', 'medium', + '-global_quality', String(qp), + '-look_ahead', '1', + '-look_ahead_depth', '40', + '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility + ); + } + + /** + * Software encoder arguments (fallback) + */ + addSoftwareEncoderArgs(args, height, crf) { + // Software scaling (use Lanczos for upscaling if enabled) + args.push('-vf', this.buildScaleFilter('software', height)); + + args.push( + '-c:v', 'libx264', + '-preset', 'veryfast', // Fast for real-time + '-crf', String(crf), + '-profile:v', 'high', + '-level', '4.1', + '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors) + ); + } + + /** + * Stop the transcoding process + */ + stop() { + if (this.process) { + console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`); + this.process.kill('SIGTERM'); + // Force kill after 2 seconds if still running + setTimeout(() => { + if (this.process) { + this.process.kill('SIGKILL'); + } + }, 2000); + } + this.status = 'stopped'; + } + + /** + * Update last access time (prevents cleanup) + */ + touch() { + this.lastAccess = Date.now(); + } + + /** + * Check if playlist exists and is ready + */ + async isPlaylistReady() { + try { + await fs.access(this.playlistPath); + const content = await fs.readFile(this.playlistPath, 'utf8'); + // Check if playlist has at least one segment + return content.includes('.ts'); + } catch { + return false; + } + } + + /** + * Wait for playlist to be ready (with timeout) + * Fast-fails if FFmpeg exited with an error before the timeout. + */ + async waitForPlaylist(timeoutMs = 10000) { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (await this.isPlaylistReady()) { + return true; + } + // Fast-fail: FFmpeg already exited with an error β€” no point waiting + if (this.status === 'error' && !this.process) { + console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`); + return false; + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + return false; + } + + /** + * Get the HLS playlist content + */ + async getPlaylist() { + this.touch(); + try { + return await fs.readFile(this.playlistPath, 'utf8'); + } catch (err) { + return null; + } + } + + /** + * Get a specific segment + */ + async getSegment(segmentName) { + this.touch(); + const segmentPath = path.join(this.dir, segmentName); + try { + await fs.access(segmentPath); + return segmentPath; + } catch { + return null; + } + } + + /** + * Save session metadata to disk for recovery + */ + async persist() { + const metadata = { + id: this.id, + url: this.url, + status: this.status, + startTime: this.startTime, + lastAccess: this.lastAccess, + options: this.options, + seekOffset: this.options.seekOffset + }; + const metaPath = path.join(this.dir, 'session.json'); + await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2)); + } + + /** + * Restore a session from disk metadata + */ + static async restore(sessionDir) { + const metaPath = path.join(sessionDir, 'session.json'); + try { + const data = await fs.readFile(metaPath, 'utf8'); + const metadata = JSON.parse(data); + const session = new TranscodeSession(metadata.url, metadata.options); + session.id = metadata.id; + session.dir = sessionDir; + session.playlistPath = path.join(sessionDir, 'stream.m3u8'); + session.startTime = metadata.startTime; + session.lastAccess = metadata.lastAccess; + session.status = 'stopped'; // Not running after restart + return session; + } catch (err) { + console.error(`Failed to restore session from ${sessionDir}:`, err.message); + return null; + } + } + + /** + * Delete session directory and all segments + */ + async cleanup() { + this.stop(); + try { + await fs.rm(this.dir, { recursive: true, force: true }); + console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`); + } catch (err) { + console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message); + } + } +} + +/** + * Session Manager + */ + +/** + * Create a new transcode session + */ +async function createSession(url, options = {}) { + await ensureCacheDir(); + const session = new TranscodeSession(url, options); + sessions.set(session.id, session); + return session; +} + +/** + * Get an existing session by ID + */ +function getSession(sessionId) { + const session = sessions.get(sessionId); + if (session) { + session.touch(); + } + return session; +} + +/** + * Get or create a session for a URL (reuses existing if still valid) + */ +async function getOrCreateSession(url, options = {}) { + // Check for existing session with same URL + for (const session of sessions.values()) { + if (session.url === url && session.status === 'running') { + session.touch(); + return session; + } + } + // Create new session + return createSession(url, options); +} + +/** + * Stop and remove a session + */ +async function removeSession(sessionId) { + const session = sessions.get(sessionId); + if (session) { + await session.cleanup(); + sessions.delete(sessionId); + } +} + +/** + * Cleanup stale sessions (idle for too long) + */ +async function cleanupStaleSessions() { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastAccess > SESSION_TIMEOUT_MS) { + console.log(`[TranscodeSession] Cleaning up stale session ${id}`); + await removeSession(id); + } + } +} + +/** + * Recover sessions from disk after server restart + */ +async function recoverSessions() { + try { + await fs.access(CACHE_DIR); + const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true }); + + for (const dirent of dirs) { + if (dirent.isDirectory()) { + const sessionDir = path.join(CACHE_DIR, dirent.name); + const session = await TranscodeSession.restore(sessionDir); + if (session) { + sessions.set(session.id, session); + console.log(`[TranscodeSession] Recovered session ${session.id}`); + } + } + } + } catch (err) { + // Cache dir doesn't exist yet, that's fine + if (err.code !== 'ENOENT') { + console.error('[TranscodeSession] Error recovering sessions:', err.message); + } + } +} + +/** + * Start cleanup interval + */ +let cleanupInterval = null; +function startCleanupInterval() { + if (!cleanupInterval) { + cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS); + cleanupInterval.unref(); // Don't prevent process exit + } +} + +/** + * Get all active sessions (for debugging/monitoring) + */ +function getAllSessions() { + return Array.from(sessions.values()).map(s => ({ + id: s.id, + url: s.url, + status: s.status, + startTime: s.startTime, + lastAccess: s.lastAccess, + idleMs: Date.now() - s.lastAccess + })); +} + +module.exports = { + TranscodeSession, + createSession, + getSession, + getOrCreateSession, + removeSession, + cleanupStaleSessions, + recoverSessions, + startCleanupInterval, + getAllSessions, + CACHE_DIR, + SEGMENT_DURATION +}; diff --git a/server/services/xtreamApi.js b/server/services/xtreamApi.js new file mode 100644 index 0000000..067ca31 --- /dev/null +++ b/server/services/xtreamApi.js @@ -0,0 +1,161 @@ +/** + * Xtream Codes API v2 Client + * Handles authentication and API calls to Xtream servers + */ + +class XtreamApi { + constructor(baseUrl, username, password) { + // Clean up base URL + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.username = username; + this.password = password; + } + + /** + * Build API URL with authentication + */ + buildApiUrl(action, params = {}) { + const url = new URL(`${this.baseUrl}/player_api.php`); + url.searchParams.set('username', this.username); + url.searchParams.set('password', this.password); + if (action) { + url.searchParams.set('action', action); + } + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, value); + } + } + return url.toString(); + } + + /** + * Make API request + */ + async request(action, params = {}) { + const url = this.buildApiUrl(action, params); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Xtream API error: ${response.status} ${response.statusText}`); + } + return response.json(); + } + + /** + * Authenticate and get server/user info + */ + async authenticate() { + const data = await this.request(null); + if (!data.user_info) { + throw new Error('Invalid credentials or server response'); + } + return data; + } + + /** + * Get live channel categories + */ + async getLiveCategories() { + return this.request('get_live_categories'); + } + + /** + * Get live streams, optionally filtered by category + */ + async getLiveStreams(categoryId = null) { + return this.request('get_live_streams', { category_id: categoryId }); + } + + /** + * Get VOD categories + */ + async getVodCategories() { + return this.request('get_vod_categories'); + } + + /** + * Get VOD streams, optionally filtered by category + */ + async getVodStreams(categoryId = null) { + return this.request('get_vod_streams', { category_id: categoryId }); + } + + /** + * Get VOD info + */ + async getVodInfo(vodId) { + return this.request('get_vod_info', { vod_id: vodId }); + } + + /** + * Get series categories + */ + async getSeriesCategories() { + return this.request('get_series_categories'); + } + + /** + * Get series, optionally filtered by category + */ + async getSeries(categoryId = null) { + return this.request('get_series', { category_id: categoryId }); + } + + /** + * Get series info + */ + async getSeriesInfo(seriesId) { + return this.request('get_series_info', { series_id: seriesId }); + } + + /** + * Get short EPG for a stream + */ + async getShortEpg(streamId, limit = 10) { + return this.request('get_short_epg', { stream_id: streamId, limit }); + } + + /** + * Get full EPG for a stream + */ + async getSimpleDateTable(streamId) { + return this.request('get_simple_data_table', { stream_id: streamId }); + } + + /** + * Build stream URL for playback + */ + buildStreamUrl(streamId, type = 'live', container = 'ts') { + const typeMap = { + live: 'live', + vod: 'movie', + series: 'series' + }; + const streamType = typeMap[type] || 'live'; + return `${this.baseUrl}/${streamType}/${this.username}/${this.password}/${streamId}.${container}`; + } + + /** + * Get XMLTV EPG URL + */ + getXmltvUrl() { + return `${this.baseUrl}/xmltv.php?username=${this.username}&password=${this.password}`; + } +} + +/** + * Factory function to create API instance from source + */ +function createFromSource(source) { + return new XtreamApi(source.url, source.username, source.password); +} + +/** + * Static authenticate for testing + */ +async function authenticate(url, username, password) { + const api = new XtreamApi(url, username, password); + return api.authenticate(); +} + +module.exports = { XtreamApi, createFromSource, authenticate }; diff --git a/server/sourceAccess.js b/server/sourceAccess.js new file mode 100644 index 0000000..1ea255c --- /dev/null +++ b/server/sourceAccess.js @@ -0,0 +1,46 @@ +const { isAdminRole } = require('./auth'); + +/** + * Who may use a source for playback and API access. + * ownerId null/undefined: legacy shared β€” any authenticated user may use it. + * ownerId N: only that user id (and admins) may use it. + */ +function canUserAccessSource(user, source) { + if (!user || !source) return false; + if (isAdminRole(user)) return true; + const oid = source.ownerId; + if (oid == null || oid === undefined) return true; + const uid = user.id != null ? Number(user.id) : NaN; + const oidN = Number(oid); + return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid; +} + +async function getAccessibleSourceIds(req, sourcesApi) { + const all = await sourcesApi.getAll(); + return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id); +} + +/** Returns false after sending res if missing, forbidden, or invalid. */ +async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) { + const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10); + if (Number.isNaN(sid)) { + res.status(400).json({ error: 'Invalid source id' }); + return false; + } + const source = await sourcesApi.getById(sid); + if (!source) { + res.status(404).json({ error: 'Source not found' }); + return false; + } + if (!canUserAccessSource(req.user, source)) { + res.status(403).json({ error: 'No access to this source' }); + return false; + } + return true; +} + +module.exports = { + canUserAccessSource, + getAccessibleSourceIds, + assertCanUseSourceById +};